From c4b8114115bd9bf6fbbcd69892ee6ddf558e1ebb Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:10:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BD=AC=E7=A0=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/service_builder.go | 1 + internal/service/stream_hls.go | 50 +++++++++- internal/service/transcoder.go | 104 ++++++++++++++++---- internal/service/transcoder_ffmpeg_args.go | 10 +- internal/service/transcoder_ffmpeg_probe.go | 1 + internal/service/transcoder_test.go | 44 +++++++++ web/src/api/client.ts | 6 +- web/src/components/PlayerControls.tsx | 48 ++++++--- web/src/pages/PlayerPage.tsx | 86 ++++++++++++++-- web/src/pages/PlayerVideoStage.tsx | 9 ++ 10 files changed, 314 insertions(+), 45 deletions(-) diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 9e0fae9..0a52489 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -123,6 +123,7 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay) // STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。 b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget) + b.c.Transcoder.SetProbe(b.c.FFprobe) // 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。 if b.c.EmbyRemote != nil { b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) { diff --git a/internal/service/stream_hls.go b/internal/service/stream_hls.go index cadd176..3347481 100644 --- a/internal/service/stream_hls.go +++ b/internal/service/stream_hls.go @@ -6,19 +6,25 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" ) // ServeHLSPlaylist makes sure a transcode is running and writes the m3u8. // We block (with a 30s timeout) until the playlist file shows up. +// +// Optional query `start` (seconds) restarts ffmpeg from that source offset so +// the web player can scrub the full timeline without waiting for a full +// head-to-tail transcode. func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error { // 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝, // 迫使播放器走 direct play 本地解码。 if s.directPlayOnly(r.Context()) { return ErrTranscodeDisabled } - if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil { + startSec := parseHLSStartSec(r) + if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec); err != nil { return err } s.transcoder.TouchJob(mediaID) @@ -48,10 +54,31 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, return nil } +func parseHLSStartSec(r *http.Request) float64 { + if r == nil { + return 0 + } + raw := strings.TrimSpace(r.URL.Query().Get("start")) + if raw == "" { + return 0 + } + v, err := strconv.ParseFloat(raw, 64) + if err != nil || v < 0 { + return 0 + } + return v +} + func appendQueryToHLSSegments(playlist, rawQuery string) string { if strings.TrimSpace(rawQuery) == "" { return playlist } + // Segment fetches only need auth/profile tokens; drop start= so a seek + // restart does not keep forcing EnsureJobFrom on every .ts hit. + q := filterHLSSegmentQuery(rawQuery) + if q == "" { + return playlist + } lines := strings.SplitAfter(playlist, "\n") for i, line := range lines { trimmed := strings.TrimSpace(line) @@ -65,12 +92,31 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string { } else if strings.HasSuffix(line, "\n") { lineEnding = "\n" } - lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding + lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding } } return strings.Join(lines, "") } +func filterHLSSegmentQuery(rawQuery string) string { + parts := strings.Split(rawQuery, "&") + kept := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + key := part + if i := strings.IndexByte(part, '='); i >= 0 { + key = part[:i] + } + if strings.EqualFold(key, "start") { + continue + } + kept = append(kept, part) + } + return strings.Join(kept, "&") +} + // ServeHLSSegment writes a single .ts segment from the on-disk cache. func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error { s.transcoder.TouchJob(mediaID) diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 33d604a..402263e 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -50,6 +50,7 @@ type TranscoderService struct { mu sync.Mutex jobs map[string]*hlsJob strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error) + probe *FFprobeService } // hlsJob holds the live state of one ffmpeg run. @@ -61,6 +62,9 @@ type hlsJob struct { lastAccess time.Time playlistOK bool encoder string + // startSec is the source seek offset fed to ffmpeg (-ss). The HLS + // playlist itself always starts at t=0 for that session. + startSec float64 } var ( @@ -92,13 +96,24 @@ func (t *TranscoderService) PlaylistPath(mediaID string) string { return filepath.Join(t.HLSDir(mediaID), "index.m3u8") } -// EnsureJob makes sure a transcode is running for mediaID. The function is -// non-blocking: it returns the playlist path immediately. The caller is -// expected to poll until WaitReady reports true. +// EnsureJob makes sure a transcode is running for mediaID from the start of +// the source. Prefer EnsureJobFrom when the player seeks into the middle. func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) { + return t.EnsureJobFrom(ctx, mediaID, 0) +} + +// EnsureJobFrom starts (or reuses) an HLS job that seeks the source to +// startSec before encoding. Reusing only happens when an active job already +// matches that offset; otherwise the previous job is cancelled and the HLS +// cache dir is wiped so the player can jump without waiting for a full +// head-to-tail transcode. +func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64) (string, error) { if !t.cfg.Transcoder.Enabled { return "", ErrTranscodeDisabled } + if startSec < 0 { + startSec = 0 + } m, err := t.repo.Media.FindByID(ctx, mediaID) if err != nil { return "", err @@ -106,11 +121,16 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri if m == nil { return "", ErrMediaNotFound } + t.mu.Lock() - if _, ok := t.jobs[mediaID]; ok { - t.touchJobLocked(mediaID) - t.mu.Unlock() - return t.PlaylistPath(mediaID), nil + if existing, ok := t.jobs[mediaID]; ok { + if sameHLSStart(existing.startSec, startSec) { + t.touchJobLocked(mediaID) + t.mu.Unlock() + return t.PlaylistPath(mediaID), nil + } + existing.cancel() + delete(t.jobs, mediaID) } t.mu.Unlock() @@ -118,27 +138,34 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri if err != nil { return "", err } + input.StartSec = startSec + t.maybeFillDuration(ctx, m, input) if _, err := t.resolveFFmpegPath(); err != nil { return "", err } + outDir := t.HLSDir(mediaID) + // Wipe prior segments so a mid-file restart cannot serve stale early chunks. + _ = os.RemoveAll(outDir) + if err := os.MkdirAll(outDir, 0o750); err != nil { + return "", err + } + t.mu.Lock() - if _, ok := t.jobs[mediaID]; ok { - t.touchJobLocked(mediaID) - t.mu.Unlock() - return t.PlaylistPath(mediaID), nil + if existing, ok := t.jobs[mediaID]; ok { + if sameHLSStart(existing.startSec, startSec) { + t.touchJobLocked(mediaID) + t.mu.Unlock() + return t.PlaylistPath(mediaID), nil + } + existing.cancel() + delete(t.jobs, mediaID) } if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max { t.mu.Unlock() return "", ErrTranscodeBusy } - outDir := t.HLSDir(mediaID) - if err := os.MkdirAll(outDir, 0o750); err != nil { - t.mu.Unlock() - return "", err - } - jobCtx, cancel := context.WithCancel(context.Background()) job := &hlsJob{ mediaID: mediaID, @@ -147,6 +174,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri startedAt: time.Now(), lastAccess: time.Now(), encoder: t.effectiveEncoder(), + startSec: startSec, } t.jobs[mediaID] = job t.mu.Unlock() @@ -156,6 +184,14 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri return t.PlaylistPath(mediaID), nil } +func sameHLSStart(a, b float64) bool { + const tol = 0.75 + if a < b { + return b-a < tol + } + return a-b < tol +} + // SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode // remote .strm media (HTTP 直链 or local source path) after direct play fails. func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) { @@ -165,6 +201,40 @@ func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.C t.strmResolve = resolve } +// SetProbe wires ffprobe so STRM/HLS jobs can persist source duration when the +// media row still has duration_sec=0 (common for .strm that was never probed). +func (t *TranscoderService) SetProbe(probe *FFprobeService) { + if t == nil { + return + } + t.probe = probe +} + +func (t *TranscoderService) maybeFillDuration(ctx context.Context, m *model.Media, input transcodeInput) { + if t == nil || t.probe == nil || m == nil || m.DurationSec > 0 || strings.TrimSpace(input.Source) == "" { + return + } + var ( + res *ProbeResult + err error + ) + if isHTTPSource(input.Source) { + res, err = t.probe.ProbeHTTP(ctx, input.Source, input.Headers) + } else { + res, err = t.probe.Probe(ctx, input.Source) + } + if err != nil || res == nil || res.DurationSec <= 0 { + return + } + m.DurationSec = res.DurationSec + if t.repo == nil || t.repo.DB == nil { + return + } + if err := t.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).Update("duration_sec", res.DurationSec).Error; err != nil && t.log != nil { + t.log.Debug("persist probed duration failed", zap.String("media_id", m.ID), zap.Error(err)) + } +} + func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) { if m == nil { return transcodeInput{}, ErrMediaNotFound diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index ad45066..c87074d 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -11,8 +11,9 @@ import ( ) type transcodeInput struct { - Source string - Headers map[string]string + Source string + Headers map[string]string + StartSec float64 } type ffmpegArgSettings struct { @@ -125,6 +126,11 @@ func baseFFmpegArgs(preInput string, realtime bool) []string { func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string { args = append(args, ffmpegHTTPInputArgs(input)...) + // Input seek (-ss before -i) lets mid-file HLS restarts jump without + // decoding everything before the click position. + if input.StartSec > 0.05 { + args = append(args, "-ss", strconv.FormatFloat(input.StartSec, 'f', 3, 64)) + } args = append(args, "-i", input.Source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec) if settings.threads > 0 && video.codec == "libx264" { args = append(args, "-threads", strconv.Itoa(settings.threads)) diff --git a/internal/service/transcoder_ffmpeg_probe.go b/internal/service/transcoder_ffmpeg_probe.go index e165d7c..57c592a 100644 --- a/internal/service/transcoder_ffmpeg_probe.go +++ b/internal/service/transcoder_ffmpeg_probe.go @@ -40,6 +40,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr zap.String("media_id", job.mediaID), zap.String("encoder", job.encoder), zap.String("source", input.Source), + zap.Float64("start_sec", input.StartSec), ) t.hub.Publish("transcode", map[string]any{ "media_id": job.mediaID, diff --git a/internal/service/transcoder_test.go b/internal/service/transcoder_test.go index 501d42f..7d6885f 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -193,3 +193,47 @@ func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) { t.Fatalf("http flags must come before -i, args=%v", args) } } + +func TestBuildFFmpegArgsInputSeekBeforeDashI(t *testing.T) { + cfg := &config.Config{} + cfg.Transcoder.MaxHeight = 720 + cfg.Transcoder.SegmentSeconds = 4 + args := buildFFmpegArgsForInput(cfg, transcodeInput{ + Source: "/x.mkv", + StartSec: 125.5, + }, "/o/x.m3u8", "/o/seg_%05d.ts") + idxSS, idxI := -1, -1 + for i, arg := range args { + if arg == "-ss" { + idxSS = i + } + if arg == "-i" && idxI < 0 { + idxI = i + } + } + if idxSS < 0 || idxI < 0 || idxSS > idxI { + t.Fatalf("expected -ss before -i, args=%v", args) + } + if args[idxSS+1] != "125.500" { + t.Fatalf("start = %q", args[idxSS+1]) + } +} + +func TestSameHLSStart(t *testing.T) { + if !sameHLSStart(10, 10.2) { + t.Fatal("expected close starts to match") + } + if sameHLSStart(10, 12) { + t.Fatal("expected distant starts to differ") + } +} + +func TestFilterHLSSegmentQueryDropsStart(t *testing.T) { + got := filterHLSSegmentQuery("token=abc&start=120.5&profile_id=1") + if strings.Contains(got, "start=") { + t.Fatalf("start should be stripped, got %q", got) + } + if !strings.Contains(got, "token=abc") || !strings.Contains(got, "profile_id=1") { + t.Fatalf("auth/profile query should remain, got %q", got) + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d5f0e6f..66086db 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -146,8 +146,10 @@ export function streamURL(mediaId: string): string { } // hlsURL returns the m3u8 playlist URL fed into hls.js. -export function hlsURL(mediaId: string): string { - return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}` +// startSec > 0 asks the server to (re)start ffmpeg from that source offset. +export function hlsURL(mediaId: string, startSec = 0): string { + const start = startSec > 0.05 ? `&start=${encodeURIComponent(String(Math.floor(startSec * 1000) / 1000))}` : '' + return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}` } // imageURL converts a remote poster URL into a same-origin proxy URL so it diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index 7bf8ae2..cc93120 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -46,6 +46,12 @@ type PlayerControlsProps = { playlistOpen?: boolean hasPlaylist?: boolean onTogglePlaylist?: () => void + /** Media metadata duration (seconds). Used when HLS only knows transcoded length. */ + knownDuration?: number + /** Absolute source offset of the current HLS session (seconds). */ + streamOffset?: number + /** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */ + onSeekAbsolute?: (seconds: number) => boolean } export function PlayerControls({ @@ -65,6 +71,9 @@ export function PlayerControls({ playlistOpen = false, hasPlaylist = false, onTogglePlaylist, + knownDuration = 0, + streamOffset = 0, + onSeekAbsolute, }: PlayerControlsProps) { const video = () => videoRef.current const container = () => @@ -176,13 +185,14 @@ export function PlayerControls({ } const syncTime = () => { if (!isScrubbingRef.current) { - setCurrentTime(el.currentTime) + setCurrentTime(streamOffset + el.currentTime) } } const syncMeta = () => { - setDuration(el.duration || 0) + const streamDur = Number.isFinite(el.duration) ? el.duration : 0 + setDuration(Math.max(knownDuration || 0, streamOffset + streamDur)) if (!isScrubbingRef.current) { - setCurrentTime(el.currentTime) + setCurrentTime(streamOffset + el.currentTime) } } const syncVolume = () => { @@ -225,7 +235,14 @@ export function PlayerControls({ if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [videoRef]) + }, [videoRef, knownDuration, streamOffset]) + + // Keep the scrubber max in sync when metadata duration arrives after mount. + useEffect(() => { + const el = video() + const streamDur = el && Number.isFinite(el.duration) ? el.duration : 0 + setDuration(Math.max(knownDuration || 0, streamOffset + streamDur)) + }, [knownDuration, streamOffset]) // 当悬停或菜单状态改变时,更新控制栏计时器 useEffect(() => { @@ -248,12 +265,23 @@ export function PlayerControls({ else el.pause() } + const applyAbsoluteSeek = (absolute: number) => { + const el = video() + if (!el) return + if (onSeekAbsolute?.(absolute)) { + setCurrentTime(absolute) + return + } + const local = Math.max(0, absolute - streamOffset) + el.currentTime = local + setCurrentTime(streamOffset + local) + } + const handleSeekChange = (v: number) => { setScrubValue(v) setCurrentTime(v) - const el = video() - if (el && !isScrubbing) { - el.currentTime = v + if (!isScrubbing) { + applyAbsoluteSeek(v) } } @@ -264,11 +292,7 @@ export function PlayerControls({ } const handleSeekEnd = (v: number) => { - const el = video() - if (el) { - el.currentTime = v - setCurrentTime(v) - } + applyAbsoluteSeek(v) setIsScrubbing(false) setScrubValue(null) } diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index c8bf13e..285a0b4 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -72,6 +72,8 @@ export function PlayerPage() { const [directOnly, setDirectOnly] = useState(false) const [resumePosition, setResumePosition] = useState(0) const [initialSeekDone, setInitialSeekDone] = useState(false) + // HLS session source offset: playlist t=0 maps to this absolute second. + const [hlsStartSec, setHlsStartSec] = useState(0) // 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。 const [danmakuOpen, setDanmakuOpen] = useState(false) @@ -187,6 +189,7 @@ export function PlayerPage() { useEffect(() => { setMedia(null) setLoadError('') + setHlsStartSec(0) setDanmakuEpisodeId(null) setDanmakuCandidates([]) setDanmakuSearch(null) @@ -245,12 +248,22 @@ export function PlayerPage() { const video = ref.current if (mode === 'hls') { - const url = hlsURL(media.id) + const url = hlsURL(media.id, hlsStartSec) void import('hls.js').then(({ default: HlsCtor }) => { if (HlsCtor.isSupported()) { const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false }) hls.loadSource(url) hls.attachMedia(video) + hls.on(HlsCtor.Events.MANIFEST_PARSED, () => { + // .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。 + if ((media.duration_sec || 0) > 0) return + mediaAPI + .get(media.id) + .then((fresh) => { + if ((fresh.duration_sec || 0) > 0) setMedia(fresh) + }) + .catch(() => undefined) + }) hls.on(HlsCtor.Events.ERROR, (_, data) => { if (data.fatal) { setHlsUnavailable(true) @@ -283,8 +296,17 @@ export function PlayerPage() { } void video.play().catch(() => undefined) } - return () => teardownHls(media.id, mode === 'hls') - }, [hlsUnavailable, media, mode, params, setParams, teardownHls]) + return () => teardownHls() + }, [hlsUnavailable, hlsStartSec, media, mode, params, setParams, teardownHls]) + + // Stop the host ffmpeg job only when leaving HLS for this media (not on mid-file seek restarts). + useEffect(() => { + if (!media || mode !== 'hls') return + const mediaId = media.id + return () => { + api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined) + } + }, [media, mode]) // 自动拉取已有的播放进度并恢复播放位置 useEffect(() => { @@ -302,8 +324,21 @@ export function PlayerPage() { }, [id]) useEffect(() => { + if (!resumePosition || initialSeekDone) return + if (mode === 'hls') { + // Restart transcode near the resume point instead of seeking a short partial playlist. + if (Math.abs(hlsStartSec - resumePosition) > 2) { + setHlsStartSec(resumePosition) + } + setInitialSeekDone(true) + const m = Math.floor(resumePosition / 60) + const s = Math.floor(resumePosition % 60) + const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 }) + return + } const video = ref.current - if (!video || !resumePosition || initialSeekDone) return + if (!video) return const applyResume = () => { if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) { video.currentTime = resumePosition @@ -320,18 +355,22 @@ export function PlayerPage() { video.addEventListener('loadedmetadata', applyResume, { once: true }) return () => video.removeEventListener('loadedmetadata', applyResume) } - }, [resumePosition, initialSeekDone]) + }, [resumePosition, initialSeekDone, mode, hlsStartSec]) // Persist resume position every 10 seconds while playing, and immediately upon pause/unmount. useEffect(() => { if (!media || !ref.current) return const video = ref.current + const absolutePositionMs = () => + Math.floor(((mode === 'hls' ? hlsStartSec : 0) + video.currentTime) * 1000) + const absoluteDurationMs = () => + Math.floor(Math.max(media.duration_sec || 0, (mode === 'hls' ? hlsStartSec : 0) + (video.duration || 0)) * 1000) const handler = () => { const now = Date.now() if (now - lastSentRef.current < 10_000) return lastSentRef.current = now - const positionMs = Math.floor(video.currentTime * 1000) - const durationMs = Math.floor((video.duration || 0) * 1000) + const positionMs = absolutePositionMs() + const durationMs = absoluteDurationMs() if (positionMs > 0) { playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined) } @@ -341,13 +380,13 @@ export function PlayerPage() { return () => { video.removeEventListener('timeupdate', handler) video.removeEventListener('pause', handler) - const positionMs = Math.floor(video.currentTime * 1000) - const durationMs = Math.floor((video.duration || 0) * 1000) + const positionMs = absolutePositionMs() + const durationMs = absoluteDurationMs() if (positionMs > 0 && media) { playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined) } } - }, [media]) + }, [media, mode, hlsStartSec]) // 加载剧集/播放列表 useEffect(() => { @@ -521,11 +560,35 @@ export function PlayerPage() { return } const next = mode === 'hls' ? 'direct' : 'hls' + if (next === 'hls') { + setHlsStartSec(0) + } setMode(next) params.set('mode', next) setParams(params, { replace: true }) }, [isDirectStream, mode, params, setParams]) + const handleSeekAbsolute = useCallback( + (absoluteSec: number) => { + if (mode !== 'hls') return false + const video = ref.current + if (!video) return false + const target = Math.max(0, absoluteSec) + const local = target - hlsStartSec + const available = Number.isFinite(video.duration) ? video.duration : 0 + // Stay inside the already-transcoded window of this HLS session. + if (local >= 0 && local <= Math.max(0, available - 0.5)) { + video.currentTime = local + return true + } + setPlayerError('') + toast('正在从该位置重新转码…', { duration: 2000 }) + setHlsStartSec(target) + return true + }, + [hlsStartSec, mode], + ) + // 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。 const selectSubtitle = useCallback((index: number) => { setSubtitleIndex(index) @@ -614,6 +677,9 @@ export function PlayerPage() { playlistOpen={playlistOpen} hasPlaylist={playlistEpisodes.length > 0} onTogglePlaylist={togglePlaylistOpen} + knownDuration={media?.duration_sec || 0} + streamOffset={mode === 'hls' ? hlsStartSec : 0} + onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined} playlistPanel={ void + knownDuration?: number + streamOffset?: number + onSeekAbsolute?: (seconds: number) => boolean } export function PlayerVideoStage({ @@ -75,6 +78,9 @@ export function PlayerVideoStage({ playlistOpen, hasPlaylist, onTogglePlaylist, + knownDuration, + streamOffset, + onSeekAbsolute, }: PlayerVideoStageProps) { const stageRef = useRef(null) const [videoRatio, setVideoRatio] = useState(null) @@ -319,6 +325,9 @@ export function PlayerVideoStage({ playlistOpen={playlistOpen} hasPlaylist={hasPlaylist} onTogglePlaylist={onTogglePlaylist} + knownDuration={knownDuration} + streamOffset={streamOffset} + onSeekAbsolute={onSeekAbsolute} /> {danmakuPanel} {playlistPanel}