From 4d698d2757ddf2103236bbd1e4273e116425a6bb Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Tue, 8 Sep 2026 09:24:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=92=AD=E6=94=BE?= 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 | 18 +- internal/service/subtitle.go | 210 +++++++++++++++++++-- internal/service/subtitle_test.go | 22 +++ internal/service/transcoder.go | 63 +++++-- internal/service/transcoder_ffmpeg_args.go | 25 ++- internal/service/transcoder_test.go | 36 ++++ web/src/api/client.ts | 8 +- web/src/api/subtitles.ts | 3 + web/src/components/PlayerControls.tsx | 22 ++- web/src/pages/PlayerPage.tsx | 84 ++++++--- web/src/pages/PlayerVideoStage.tsx | 66 +++++-- 12 files changed, 469 insertions(+), 89 deletions(-) diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 0a52489..e32e9bd 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -124,6 +124,7 @@ func (b *serviceContainerBuilder) initContentServices() { // STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。 b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget) b.c.Transcoder.SetProbe(b.c.FFprobe) + b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget) // 弹幕识别需要把远程 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 ebf4b76..8a5d9e1 100644 --- a/internal/service/stream_hls.go +++ b/internal/service/stream_hls.go @@ -25,7 +25,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, } startSec := parseHLSStartSec(r) seekGen := parseHLSSeekGen(r) - if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec, seekGen); err != nil { + subtitleStream := parseHLSSubtitleStream(r) + if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil { return err } s.transcoder.TouchJob(mediaID) @@ -60,6 +61,21 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, return nil } +func parseHLSSubtitleStream(r *http.Request) int { + if r == nil { + return -1 + } + raw := strings.TrimSpace(r.URL.Query().Get("subtitle")) + if raw == "" { + return -1 + } + v, err := strconv.Atoi(raw) + if err != nil || v < 0 { + return -1 + } + return v +} + func parseHLSStartSec(r *http.Request) float64 { if r == nil { return 0 diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index ce9eebf..42763f0 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -16,12 +16,15 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -29,14 +32,16 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) // SubtitleService is the discovery + conversion entry point. type SubtitleService struct { - log *zap.Logger - repo *repository.Container - cfg *config.Config + log *zap.Logger + repo *repository.Container + cfg *config.Config + strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error) // 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次 // os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。 @@ -61,11 +66,14 @@ func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Co // SubtitleTrack describes one external subtitle file. type SubtitleTrack struct { - Lang string `json:"lang"` - Label string `json:"label"` - Path string `json:"path"` - URL string `json:"url"` - Codec string `json:"codec"` + Lang string `json:"lang"` + Label string `json:"label"` + Path string `json:"path"` + URL string `json:"url"` + Codec string `json:"codec"` + Source string `json:"source"` + Delivery string `json:"delivery"` + StreamIndex int `json:"stream_index,omitempty"` } // extToCodec maps the file extension to the inner codec name. @@ -86,7 +94,17 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti // DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。 // Emby 字幕接口(/Videos/:id/Subtitles/...)用。 func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { - return s.discover(ctx, mediaID) + tracks, err := s.discover(ctx, mediaID) + if err != nil { + return nil, err + } + out := make([]SubtitleTrack, 0, len(tracks)) + for _, track := range tracks { + if track.Source != "embedded" { + out = append(out, track) + } + } + return out, nil } func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { @@ -187,16 +205,153 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) } lang := detectLang(fullName, matchedBase) tracks = append(tracks, SubtitleTrack{ - Lang: lang, - Label: lang, - Path: filepath.Join(c, e.Name()), - Codec: codec, + Lang: lang, + Label: lang, + Path: filepath.Join(c, e.Name()), + Codec: codec, + Source: "external", + Delivery: "webvtt", }) } } + embedded, err := s.discoverEmbedded(ctx, m) + if err != nil { + if s.log != nil { + s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err)) + } + } else { + tracks = append(tracks, embedded...) + } return tracks, nil } +type embeddedSubtitleProbe struct { + Streams []struct { + Index int `json:"index"` + CodecName string `json:"codec_name"` + Tags struct { + Language string `json:"language"` + Title string `json:"title"` + } `json:"tags"` + Disposition struct { + Default int `json:"default"` + Forced int `json:"forced"` + } `json:"disposition"` + } `json:"streams"` +} + +var imageSubtitleCodecs = map[string]bool{ + "hdmv_pgs_subtitle": true, + "dvd_subtitle": true, + "dvb_subtitle": true, + "xsub": true, +} + +func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Media) ([]SubtitleTrack, error) { + if s == nil || s.cfg == nil { + return nil, errors.New("subtitle probe unavailable") + } + input, err := s.resolveInput(ctx, media) + if err != nil { + return nil, err + } + bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe") + if err != nil { + return nil, err + } + probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + args := []string{"-v", "error"} + if headers := ffmpegHeaderText(input.Headers); headers != "" { + args = append(args, "-headers", headers) + } + args = append(args, + "-select_streams", "s", + "-show_entries", "stream=index,codec_name:stream_tags=language,title:stream_disposition=default,forced", + "-of", "json", input.Source, + ) + out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- executable is resolved locally and arguments do not use a shell. + if err != nil { + return nil, err + } + var probe embeddedSubtitleProbe + if err := json.Unmarshal(out, &probe); err != nil { + return nil, err + } + return subtitleTracksFromProbe(probe), nil +} + +func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack { + tracks := make([]SubtitleTrack, 0, len(probe.Streams)) + for _, stream := range probe.Streams { + codec := strings.ToLower(strings.TrimSpace(stream.CodecName)) + lang := strings.ToLower(strings.TrimSpace(stream.Tags.Language)) + if lang == "" { + lang = "und" + } + label := strings.TrimSpace(stream.Tags.Title) + if label == "" { + label = lang + } + if stream.Disposition.Forced != 0 { + label += "(强制)" + } else if stream.Disposition.Default != 0 { + label += "(默认)" + } + delivery := "webvtt" + if imageSubtitleCodecs[codec] { + delivery = "burn" + } + sourceLabel := "(内嵌)" + if delivery == "burn" { + sourceLabel = "(内嵌·图片)" + } + tracks = append(tracks, SubtitleTrack{ + Lang: lang, + Label: label + sourceLabel, + Path: "embedded:" + strconv.Itoa(stream.Index), + Codec: codec, + Source: "embedded", + Delivery: delivery, + StreamIndex: stream.Index, + }) + } + return tracks +} + +func (s *SubtitleService) SetStrmPlayTargetResolver(resolve func(context.Context, string) (*StrmPlayResult, error)) { + if s != nil { + s.strmResolve = resolve + } +} + +func (s *SubtitleService) resolveInput(ctx context.Context, media *model.Media) (transcodeInput, error) { + if media == nil { + return transcodeInput{}, ErrMediaNotFound + } + if !isStrmMediaRow(media) { + if _, err := os.Stat(media.Path); err != nil { + return transcodeInput{}, ErrMediaNotFound + } + return transcodeInput{Source: media.Path}, nil + } + raw := strings.TrimSpace(media.STRMURL) + if raw == "" && strings.HasSuffix(strings.ToLower(media.Path), ".strm") { + raw, _ = readLocalSTRMTarget(media.Path) + } + if s.strmResolve != nil { + resolved, err := s.strmResolve(ctx, raw) + if err != nil { + return transcodeInput{}, err + } + return transcodeInputFromPlayResult(resolved) + } + if isHTTPPlaybackTarget(raw) { + return transcodeInput{Source: raw}, nil + } + return transcodeInput{}, errors.New("subtitle source unavailable") +} + // langTag matches the .zh / .zh-cn / .chs language sub-extensions. var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`) @@ -220,6 +375,13 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W if err != nil || m == nil { return errors.New("media not found") } + if strings.HasPrefix(sub, "embedded:") { + index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:")) + if err != nil || index < 0 { + return errors.New("invalid embedded subtitle") + } + return s.serveEmbedded(ctx, m, index, w) + } abs, err := filepath.Abs(sub) if err != nil { return err @@ -252,6 +414,28 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W return err } +func (s *SubtitleService) serveEmbedded(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error { + input, err := s.resolveInput(ctx, media) + if err != nil { + return err + } + bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg") + if err != nil { + return err + } + args := []string{"-hide_banner", "-loglevel", "error"} + args = append(args, ffmpegHTTPInputArgs(input)...) + args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-f", "webvtt", "-") + cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell. + cmd.Stdout = w + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("extract embedded subtitle: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return nil +} + // ServeRaw writes the subtitle file in its original format without any // WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS, // subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index bfb5236..ef663de 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "encoding/json" "os" "path/filepath" "testing" @@ -46,6 +47,27 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) { } } +func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) { + var probe embeddedSubtitleProbe + raw := []byte(`{"streams":[ + {"index":2,"codec_name":"ass","tags":{"language":"chi","title":"中文"},"disposition":{"default":1}}, + {"index":4,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"eng"},"disposition":{"forced":1}} + ]}`) + if err := json.Unmarshal(raw, &probe); err != nil { + t.Fatal(err) + } + tracks := subtitleTracksFromProbe(probe) + if len(tracks) != 2 { + t.Fatalf("len(tracks) = %d, want 2", len(tracks)) + } + if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" { + t.Fatalf("text track = %#v", tracks[0]) + } + if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 { + t.Fatalf("bitmap track = %#v", tracks[1]) + } +} + func TestNormaliseTimecode(t *testing.T) { cases := map[string]string{ "0:00:01": "00:00:01", diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 3092b43..1f3522c 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -71,6 +71,9 @@ type hlsJob struct { // seekGen is the client `_seek` token for this job. Newer gens win; // older/missing gens must not cancel a mid-file restart. seekGen int64 + // subtitleStream is an absolute ffmpeg stream index to burn into the + // picture, or -1 when no bitmap subtitle is selected. + subtitleStream int // done is closed when the ffmpeg goroutine fully exits (after process death). done chan struct{} } @@ -121,6 +124,12 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri // in-flight playlist refreshes from a destroyed player commonly arrive as // start=0 right after a scrub and would otherwise reset playback to the head. func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64, seekGen int64) (string, error) { + return t.EnsureJobFromSubtitle(ctx, mediaID, startSec, seekGen, -1) +} + +// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle +// stream. Selecting or closing a burned subtitle creates a new HLS generation. +func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) { if !t.cfg.Transcoder.Enabled { return "", ErrTranscodeDisabled } @@ -141,12 +150,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s t.mu.Lock() if existing, ok := t.jobs[mediaID]; ok { - if sameHLSStart(existing.startSec, startSec) { + if sameHLSConfiguration(existing, startSec, subtitleStream) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil } - if !shouldReplaceHLSJob(existing, startSec, seekGen) { + if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil @@ -163,6 +172,9 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s return "", err } input.StartSec = startSec + if subtitleStream >= 0 { + input.SubtitleStream = &subtitleStream + } t.maybeFillDuration(ctx, m, input) if _, err := t.resolveFFmpegPath(); err != nil { return "", err @@ -177,12 +189,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s t.mu.Lock() if existing, ok := t.jobs[mediaID]; ok { - if sameHLSStart(existing.startSec, startSec) { + if sameHLSConfiguration(existing, startSec, subtitleStream) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil } - if !shouldReplaceHLSJob(existing, startSec, seekGen) { + if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) { t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil @@ -198,16 +210,21 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s } jobCtx, cancel := context.WithCancel(context.Background()) + jobEncoder := t.effectiveEncoder() + if subtitleStream >= 0 { + jobEncoder = "software (subtitle burn-in)" + } job := &hlsJob{ - mediaID: mediaID, - outputDir: outDir, - cancel: cancel, - startedAt: time.Now(), - lastAccess: time.Now(), - encoder: t.effectiveEncoder(), - startSec: startSec, - seekGen: seekGen, - done: make(chan struct{}), + mediaID: mediaID, + outputDir: outDir, + cancel: cancel, + startedAt: time.Now(), + lastAccess: time.Now(), + encoder: jobEncoder, + startSec: startSec, + seekGen: seekGen, + subtitleStream: subtitleStream, + done: make(chan struct{}), } t.jobs[mediaID] = job t.mu.Unlock() @@ -229,6 +246,20 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool if sameHLSStart(existing.startSec, startSec) { return false } + return newerHLSGenerationMayReplace(existing, seekGen) +} + +func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool { + if existing == nil { + return true + } + if sameHLSConfiguration(existing, startSec, subtitleStream) { + return false + } + return newerHLSGenerationMayReplace(existing, seekGen) +} + +func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool { if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen { return false } @@ -239,6 +270,12 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool return true } +func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool { + return existing != nil && + existing.subtitleStream == subtitleStream && + sameHLSStart(existing.startSec, startSec) +} + func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex { v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{}) return v.(*sync.Mutex) diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index 50b1ef4..6f6eedb 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -11,9 +11,10 @@ import ( ) type transcodeInput struct { - Source string - Headers map[string]string - StartSec float64 + Source string + Headers map[string]string + StartSec float64 + SubtitleStream *int } type ffmpegArgSettings struct { @@ -46,6 +47,13 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string { settings := ffmpegArgSettingsFromConfig(cfg) video := ffmpegVideoPlanForSettings(settings) + if input.SubtitleStream != nil { + // Bitmap subtitles must be composited in software. Keeping CUDA/QSV/ + // VAAPI frames here would require a download/upload filter chain that + // differs by driver and is considerably less portable. + settings.encoder = "" + video = ffmpegVideoPlanForSettings(settings) + } // Mid-file restarts must not use -re: output -ss would otherwise crawl to the // seek point at 1x wall-clock before emitting the first HLS segment. @@ -140,7 +148,16 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe args = append(args, "-ss", ss) } args = append(args, "-i", input.Source) - args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec) + if input.SubtitleStream != nil { + filter := fmt.Sprintf( + "[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]", + *input.SubtitleStream, + settings.height, + ) + args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec) + } else { + args = append(args, "-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_test.go b/internal/service/transcoder_test.go index 381bbec..56102ea 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -255,6 +255,29 @@ func TestBuildFFmpegArgsHTTPSeekAfterDashI(t *testing.T) { } } +func TestBuildFFmpegArgsBurnsBitmapSubtitleInSoftware(t *testing.T) { + cfg := &config.Config{} + cfg.Transcoder.HardwareAccel = true + cfg.Transcoder.Encoder = "nvenc" + cfg.Transcoder.MaxHeight = 720 + cfg.Transcoder.SegmentSeconds = 4 + stream := 3 + args := buildFFmpegArgsForInput(cfg, transcodeInput{ + Source: "/x.mkv", + SubtitleStream: &stream, + }, "/o/x.m3u8", "/o/seg_%05d.ts") + joined := strings.Join(args, " ") + if !strings.Contains(joined, "[0:v:0][0:3]overlay=0:0:eof_action=pass") { + t.Fatalf("bitmap subtitle overlay missing: %s", joined) + } + if !strings.Contains(joined, "-map [v]") || !strings.Contains(joined, "-c:v libx264") { + t.Fatalf("burn-in should use the filtered software video stream: %s", joined) + } + if strings.Contains(joined, "cuda") || strings.Contains(joined, "h264_nvenc") { + t.Fatalf("burn-in must not retain hardware-only frames: %s", joined) + } +} + func TestSameHLSStart(t *testing.T) { if !sameHLSStart(10, 10.2) { t.Fatal("expected close starts to match") @@ -283,6 +306,19 @@ func TestShouldReplaceHLSJob(t *testing.T) { } } +func TestBitmapSubtitleChangeReplacesHLSJob(t *testing.T) { + existing := &hlsJob{startSec: 120, seekGen: 1000, subtitleStream: 2} + if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, 3) { + t.Fatal("changing bitmap subtitle must replace the HLS generation") + } + if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, -1) { + t.Fatal("closing bitmap subtitle must replace the HLS generation") + } + if shouldReplaceHLSJobConfiguration(existing, 120.2, 1001, 2) { + t.Fatal("same subtitle and nearby start should reuse the HLS generation") + } +} + func TestFilterHLSSegmentQueryDropsStart(t *testing.T) { got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1") if strings.Contains(got, "start=") { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index dbdf7ed..0ac2e04 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string { // hlsURL returns the m3u8 playlist URL fed into hls.js. // startSec > 0 asks the server to (re)start ffmpeg from that source offset. -export function hlsURL(mediaId: string, startSec = 0): string { +export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string { const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000) // Always send start= (including 0) so the server can tell an intentional // restart-from-head apart from a missing query on a stale refresh. @@ -155,7 +155,11 @@ export function hlsURL(mediaId: string, startSec = 0): string { // Monotonic-ish client generation: newer seeks win; older in-flight playlist // requests must not cancel the active ffmpeg job back to t=0. const bust = `&_seek=${Date.now()}` - return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${bust}` + const subtitle = + subtitleStream !== undefined && subtitleStream >= 0 + ? `&subtitle=${encodeURIComponent(String(subtitleStream))}` + : '' + return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}` } // Stop an on-demand HLS job. keepalive makes the request survive page diff --git a/web/src/api/subtitles.ts b/web/src/api/subtitles.ts index e9d668c..57c1a76 100644 --- a/web/src/api/subtitles.ts +++ b/web/src/api/subtitles.ts @@ -7,6 +7,9 @@ export interface SubtitleTrack { path: string url: string codec: string + source: 'external' | 'embedded' + delivery: 'webvtt' | 'burn' + stream_index?: number } export const subtitlesAPI = { diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index 166b9e5..9521718 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -30,6 +30,8 @@ function formatTime(s: number): string { type PlayerControlsProps = { videoRef: React.RefObject + uiVisible: boolean + onUiVisibleChange: (visible: boolean) => void subs: SubtitleTrack[] /** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */ subtitleIndex: number @@ -56,6 +58,8 @@ type PlayerControlsProps = { export function PlayerControls({ videoRef, + uiVisible, + onUiVisibleChange, subs, subtitleIndex, onSelectSubtitle, @@ -88,7 +92,6 @@ export function PlayerControls({ const [muted, setMuted] = useState(false) const [fullscreen, setFullscreen] = useState(false) const [pip, setPip] = useState(false) - const [uiVisible, setUiVisible] = useState(true) const [controlsHovered, setControlsHovered] = useState(false) const [isScrubbing, setIsScrubbing] = useState(false) const [scrubValue, setScrubValue] = useState(null) @@ -159,14 +162,14 @@ export function PlayerControls({ !danmakuOpenRef.current && !playlistOpenRef.current ) { - setUiVisible(false) + onUiVisibleChange(false) } }, 3000) } } const onMove = () => { - setUiVisible(true) + onUiVisibleChange(true) resetTimer() } @@ -177,7 +180,7 @@ export function PlayerControls({ } if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return if (hideTimerRef.current) clearTimeout(hideTimerRef.current) - setUiVisible(false) + onUiVisibleChange(false) } const syncPlay = () => { @@ -248,7 +251,7 @@ export function PlayerControls({ if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [videoRef, knownDuration, streamOffset]) + }, [videoRef, knownDuration, streamOffset, onUiVisibleChange]) // Keep the scrubber max in sync when metadata duration arrives after mount. useEffect(() => { @@ -260,16 +263,16 @@ export function PlayerControls({ // 当悬停或菜单状态改变时,更新控制栏计时器 useEffect(() => { if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) { - setUiVisible(true) + onUiVisibleChange(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } else { const el = video() if (el && !el.paused) { if (hideTimerRef.current) clearTimeout(hideTimerRef.current) - hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000) + hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000) } } - }, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen]) + }, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange]) const togglePlay = () => { const el = video() @@ -301,7 +304,7 @@ export function PlayerControls({ const handleSeekStart = () => { setIsScrubbing(true) - setUiVisible(true) + onUiVisibleChange(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } @@ -361,6 +364,7 @@ export function PlayerControls({ onMouseEnter={() => setControlsHovered(true)} onMouseLeave={() => setControlsHovered(false)} onClick={(e) => e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} >
{/* 上一集 */} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 0c7ba08..755e629 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -33,23 +33,6 @@ import { mediaVersionsOf } from '../utils/mediaVersion' // // External subtitles next to the source file are auto-discovered and // attached as elements. -const SUBTITLE_STORAGE_KEY = 'mebox.subtitle' - -// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时 -// 默认 0(自动加载第一条字幕)。 -function initialSubtitleIndex(): number { - try { - const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY) - if (saved !== null && saved !== '') { - const n = parseInt(saved, 10) - if (Number.isFinite(n)) return n - } - } catch { - // ignore - } - return 0 -} - export function PlayerPage() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() @@ -63,13 +46,14 @@ export function PlayerPage() { const [media, setMedia] = useState(null) const [mode, setMode] = useState('direct') const [subs, setSubs] = useState([]) - const [subtitleIndex, setSubtitleIndex] = useState(initialSubtitleIndex) + const [subtitleIndex, setSubtitleIndex] = useState(0) const [hlsUnavailable, setHlsUnavailable] = useState(false) const [playerError, setPlayerError] = useState('') // 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」 const [loadError, setLoadError] = useState('') // 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。 const [directOnly, setDirectOnly] = useState(false) + const [directOnlyKnown, setDirectOnlyKnown] = useState(false) const [resumePosition, setResumePosition] = useState(0) const [initialSeekDone, setInitialSeekDone] = useState(false) // HLS session source offset: playlist t=0 maps to this absolute second. @@ -121,6 +105,7 @@ export function PlayerPage() { .info() .then((info) => setDirectOnly(Boolean(info.direct_play_only))) .catch(() => setDirectOnly(false)) + .finally(() => setDirectOnlyKnown(true)) }, []) // 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。 @@ -225,9 +210,8 @@ export function PlayerPage() { if (cancelled) return const list = tracks ?? [] setSubs(list) - // 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同), - // 越界时回退到第一条;无字幕则关闭。 - setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur)) + // 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。 + setSubtitleIndex(list.length > 0 ? 0 : -1) }) .catch(() => { if (cancelled) return @@ -242,6 +226,9 @@ export function PlayerPage() { // Depend on media.id (not the media object): refreshing duration after // MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE. const mediaId = media?.id + const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined + const burnedSubtitleStream = + selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined const mediaRef = useRef(media) mediaRef.current = media useEffect(() => { @@ -254,7 +241,7 @@ export function PlayerPage() { const video = ref.current const durationSec = currentMedia.duration_sec || 0 if (mode === 'hls') { - const url = hlsURL(mediaId, hlsStartSec) + const url = hlsURL(mediaId, hlsStartSec, burnedSubtitleStream) void import('hls.js').then(({ default: HlsCtor }) => { if (cancelled || !ref.current) return if (HlsCtor.isSupported()) { @@ -323,7 +310,7 @@ export function PlayerPage() { cancelled = true teardownHls() } - }, [hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, teardownHls]) + }, [burnedSubtitleStream, hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, teardownHls]) // Stop host ffmpeg when leaving this HLS player. The keepalive request also // survives route navigation while the component is being torn down. @@ -600,6 +587,31 @@ export function PlayerPage() { const isDirectStream = isDirectStreamMedia(media) + // 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。 + useEffect(() => { + if ( + !directOnlyKnown || + directOnly || + isDirectStream || + selectedSubtitle?.delivery !== 'burn' || + mode === 'hls' + ) { + return + } + setHlsStartSec(ref.current?.currentTime || 0) + setMode('hls') + params.set('mode', 'hls') + setParams(params, { replace: true }) + }, [ + directOnly, + directOnlyKnown, + isDirectStream, + mode, + params, + selectedSubtitle?.delivery, + setParams, + ]) + const toggleMode = useCallback(() => { if (isDirectStream) { toast('该媒体为直连播放,无需且不支持转码') @@ -646,15 +658,27 @@ export function PlayerPage() { [hlsStartSec, mode], ) - // 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。 + // 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。 const selectSubtitle = useCallback((index: number) => { - setSubtitleIndex(index) - try { - localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index)) - } catch { - // ignore + const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined + const nextTrack = index >= 0 ? subs[index] : undefined + if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) { + toast.error('图片字幕需要开启 HLS 转码后才能显示') + return } - }, []) + const burnChanged = + oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn' + if (burnChanged && mode === 'hls' && ref.current) { + setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0)) + } + setSubtitleIndex(index) + if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) { + setHlsStartSec(ref.current?.currentTime || 0) + setMode('hls') + params.set('mode', 'hls') + setParams(params, { replace: true }) + } + }, [directOnly, hlsStartSec, isDirectStream, mode, params, setParams, subs, subtitleIndex]) const handleVideoError = useCallback(() => { // 浏览器对