mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 13:56:37 +08:00
优化播放
This commit is contained in:
@@ -124,6 +124,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||||
|
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||||
if b.c.EmbyRemote != nil {
|
if b.c.EmbyRemote != nil {
|
||||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
|||||||
}
|
}
|
||||||
startSec := parseHLSStartSec(r)
|
startSec := parseHLSStartSec(r)
|
||||||
seekGen := parseHLSSeekGen(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
|
return err
|
||||||
}
|
}
|
||||||
s.transcoder.TouchJob(mediaID)
|
s.transcoder.TouchJob(mediaID)
|
||||||
@@ -60,6 +61,21 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
|||||||
return nil
|
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 {
|
func parseHLSStartSec(r *http.Request) float64 {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+197
-13
@@ -16,12 +16,15 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,14 +32,16 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SubtitleService is the discovery + conversion entry point.
|
// SubtitleService is the discovery + conversion entry point.
|
||||||
type SubtitleService struct {
|
type SubtitleService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||||
|
|
||||||
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
|
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
|
||||||
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
|
// 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.
|
// SubtitleTrack describes one external subtitle file.
|
||||||
type SubtitleTrack struct {
|
type SubtitleTrack struct {
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Codec string `json:"codec"`
|
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.
|
// 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 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
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) {
|
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)
|
lang := detectLang(fullName, matchedBase)
|
||||||
tracks = append(tracks, SubtitleTrack{
|
tracks = append(tracks, SubtitleTrack{
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
Label: lang,
|
Label: lang,
|
||||||
Path: filepath.Join(c, e.Name()),
|
Path: filepath.Join(c, e.Name()),
|
||||||
Codec: codec,
|
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
|
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.
|
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
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 {
|
if err != nil || m == nil {
|
||||||
return errors.New("media not found")
|
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)
|
abs, err := filepath.Abs(sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -252,6 +414,28 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
|||||||
return err
|
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
|
// ServeRaw writes the subtitle file in its original format without any
|
||||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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) {
|
func TestNormaliseTimecode(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"0:00:01": "00:00:01",
|
"0:00:01": "00:00:01",
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ type hlsJob struct {
|
|||||||
// seekGen is the client `_seek` token for this job. Newer gens win;
|
// seekGen is the client `_seek` token for this job. Newer gens win;
|
||||||
// older/missing gens must not cancel a mid-file restart.
|
// older/missing gens must not cancel a mid-file restart.
|
||||||
seekGen int64
|
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 is closed when the ffmpeg goroutine fully exits (after process death).
|
||||||
done chan struct{}
|
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
|
// 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.
|
// 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) {
|
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 {
|
if !t.cfg.Transcoder.Enabled {
|
||||||
return "", ErrTranscodeDisabled
|
return "", ErrTranscodeDisabled
|
||||||
}
|
}
|
||||||
@@ -141,12 +150,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
|||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if existing, ok := t.jobs[mediaID]; ok {
|
if existing, ok := t.jobs[mediaID]; ok {
|
||||||
if sameHLSStart(existing.startSec, startSec) {
|
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||||
t.touchJobLocked(mediaID)
|
t.touchJobLocked(mediaID)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return t.PlaylistPath(mediaID), nil
|
return t.PlaylistPath(mediaID), nil
|
||||||
}
|
}
|
||||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||||
t.touchJobLocked(mediaID)
|
t.touchJobLocked(mediaID)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return t.PlaylistPath(mediaID), nil
|
return t.PlaylistPath(mediaID), nil
|
||||||
@@ -163,6 +172,9 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
input.StartSec = startSec
|
input.StartSec = startSec
|
||||||
|
if subtitleStream >= 0 {
|
||||||
|
input.SubtitleStream = &subtitleStream
|
||||||
|
}
|
||||||
t.maybeFillDuration(ctx, m, input)
|
t.maybeFillDuration(ctx, m, input)
|
||||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -177,12 +189,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
|||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if existing, ok := t.jobs[mediaID]; ok {
|
if existing, ok := t.jobs[mediaID]; ok {
|
||||||
if sameHLSStart(existing.startSec, startSec) {
|
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||||
t.touchJobLocked(mediaID)
|
t.touchJobLocked(mediaID)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return t.PlaylistPath(mediaID), nil
|
return t.PlaylistPath(mediaID), nil
|
||||||
}
|
}
|
||||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||||
t.touchJobLocked(mediaID)
|
t.touchJobLocked(mediaID)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return t.PlaylistPath(mediaID), nil
|
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())
|
jobCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
jobEncoder := t.effectiveEncoder()
|
||||||
|
if subtitleStream >= 0 {
|
||||||
|
jobEncoder = "software (subtitle burn-in)"
|
||||||
|
}
|
||||||
job := &hlsJob{
|
job := &hlsJob{
|
||||||
mediaID: mediaID,
|
mediaID: mediaID,
|
||||||
outputDir: outDir,
|
outputDir: outDir,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
lastAccess: time.Now(),
|
lastAccess: time.Now(),
|
||||||
encoder: t.effectiveEncoder(),
|
encoder: jobEncoder,
|
||||||
startSec: startSec,
|
startSec: startSec,
|
||||||
seekGen: seekGen,
|
seekGen: seekGen,
|
||||||
done: make(chan struct{}),
|
subtitleStream: subtitleStream,
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
t.jobs[mediaID] = job
|
t.jobs[mediaID] = job
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -229,6 +246,20 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
|||||||
if sameHLSStart(existing.startSec, startSec) {
|
if sameHLSStart(existing.startSec, startSec) {
|
||||||
return false
|
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 {
|
if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -239,6 +270,12 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
|||||||
return true
|
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 {
|
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
|
||||||
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
|
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
|
||||||
return v.(*sync.Mutex)
|
return v.(*sync.Mutex)
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type transcodeInput struct {
|
type transcodeInput struct {
|
||||||
Source string
|
Source string
|
||||||
Headers map[string]string
|
Headers map[string]string
|
||||||
StartSec float64
|
StartSec float64
|
||||||
|
SubtitleStream *int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ffmpegArgSettings struct {
|
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 {
|
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
|
||||||
settings := ffmpegArgSettingsFromConfig(cfg)
|
settings := ffmpegArgSettingsFromConfig(cfg)
|
||||||
video := ffmpegVideoPlanForSettings(settings)
|
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
|
// 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.
|
// 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, "-ss", ss)
|
||||||
}
|
}
|
||||||
args = append(args, "-i", input.Source)
|
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" {
|
if settings.threads > 0 && video.codec == "libx264" {
|
||||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestSameHLSStart(t *testing.T) {
|
||||||
if !sameHLSStart(10, 10.2) {
|
if !sameHLSStart(10, 10.2) {
|
||||||
t.Fatal("expected close starts to match")
|
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) {
|
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
|
||||||
got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1")
|
got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1")
|
||||||
if strings.Contains(got, "start=") {
|
if strings.Contains(got, "start=") {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string {
|
|||||||
|
|
||||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
|
// 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)
|
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
|
||||||
// Always send start= (including 0) so the server can tell an intentional
|
// Always send start= (including 0) so the server can tell an intentional
|
||||||
// restart-from-head apart from a missing query on a stale refresh.
|
// 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
|
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
|
||||||
// requests must not cancel the active ffmpeg job back to t=0.
|
// requests must not cancel the active ffmpeg job back to t=0.
|
||||||
const bust = `&_seek=${Date.now()}`
|
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
|
// Stop an on-demand HLS job. keepalive makes the request survive page
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface SubtitleTrack {
|
|||||||
path: string
|
path: string
|
||||||
url: string
|
url: string
|
||||||
codec: string
|
codec: string
|
||||||
|
source: 'external' | 'embedded'
|
||||||
|
delivery: 'webvtt' | 'burn'
|
||||||
|
stream_index?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const subtitlesAPI = {
|
export const subtitlesAPI = {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ function formatTime(s: number): string {
|
|||||||
|
|
||||||
type PlayerControlsProps = {
|
type PlayerControlsProps = {
|
||||||
videoRef: React.RefObject<HTMLVideoElement>
|
videoRef: React.RefObject<HTMLVideoElement>
|
||||||
|
uiVisible: boolean
|
||||||
|
onUiVisibleChange: (visible: boolean) => void
|
||||||
subs: SubtitleTrack[]
|
subs: SubtitleTrack[]
|
||||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
subtitleIndex: number
|
subtitleIndex: number
|
||||||
@@ -56,6 +58,8 @@ type PlayerControlsProps = {
|
|||||||
|
|
||||||
export function PlayerControls({
|
export function PlayerControls({
|
||||||
videoRef,
|
videoRef,
|
||||||
|
uiVisible,
|
||||||
|
onUiVisibleChange,
|
||||||
subs,
|
subs,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
onSelectSubtitle,
|
onSelectSubtitle,
|
||||||
@@ -88,7 +92,6 @@ export function PlayerControls({
|
|||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
const [fullscreen, setFullscreen] = useState(false)
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
const [pip, setPip] = useState(false)
|
const [pip, setPip] = useState(false)
|
||||||
const [uiVisible, setUiVisible] = useState(true)
|
|
||||||
const [controlsHovered, setControlsHovered] = useState(false)
|
const [controlsHovered, setControlsHovered] = useState(false)
|
||||||
const [isScrubbing, setIsScrubbing] = useState(false)
|
const [isScrubbing, setIsScrubbing] = useState(false)
|
||||||
const [scrubValue, setScrubValue] = useState<number | null>(null)
|
const [scrubValue, setScrubValue] = useState<number | null>(null)
|
||||||
@@ -159,14 +162,14 @@ export function PlayerControls({
|
|||||||
!danmakuOpenRef.current &&
|
!danmakuOpenRef.current &&
|
||||||
!playlistOpenRef.current
|
!playlistOpenRef.current
|
||||||
) {
|
) {
|
||||||
setUiVisible(false)
|
onUiVisibleChange(false)
|
||||||
}
|
}
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMove = () => {
|
const onMove = () => {
|
||||||
setUiVisible(true)
|
onUiVisibleChange(true)
|
||||||
resetTimer()
|
resetTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ export function PlayerControls({
|
|||||||
}
|
}
|
||||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
setUiVisible(false)
|
onUiVisibleChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncPlay = () => {
|
const syncPlay = () => {
|
||||||
@@ -248,7 +251,7 @@ export function PlayerControls({
|
|||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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.
|
// Keep the scrubber max in sync when metadata duration arrives after mount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -260,16 +263,16 @@ export function PlayerControls({
|
|||||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
||||||
setUiVisible(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
} else {
|
} else {
|
||||||
const el = video()
|
const el = video()
|
||||||
if (el && !el.paused) {
|
if (el && !el.paused) {
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
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 togglePlay = () => {
|
||||||
const el = video()
|
const el = video()
|
||||||
@@ -301,7 +304,7 @@ export function PlayerControls({
|
|||||||
|
|
||||||
const handleSeekStart = () => {
|
const handleSeekStart = () => {
|
||||||
setIsScrubbing(true)
|
setIsScrubbing(true)
|
||||||
setUiVisible(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +364,7 @@ export function PlayerControls({
|
|||||||
onMouseEnter={() => setControlsHovered(true)}
|
onMouseEnter={() => setControlsHovered(true)}
|
||||||
onMouseLeave={() => setControlsHovered(false)}
|
onMouseLeave={() => setControlsHovered(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
|
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
|
||||||
{/* 上一集 */}
|
{/* 上一集 */}
|
||||||
|
|||||||
@@ -33,23 +33,6 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
|
|||||||
//
|
//
|
||||||
// External subtitles next to the source file are auto-discovered and
|
// External subtitles next to the source file are auto-discovered and
|
||||||
// attached as <track> elements.
|
// attached as <track> 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() {
|
export function PlayerPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
@@ -63,13 +46,14 @@ export function PlayerPage() {
|
|||||||
const [media, setMedia] = useState<Media | null>(null)
|
const [media, setMedia] = useState<Media | null>(null)
|
||||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
|
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||||
const [playerError, setPlayerError] = useState('')
|
const [playerError, setPlayerError] = useState('')
|
||||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||||
const [loadError, setLoadError] = useState('')
|
const [loadError, setLoadError] = useState('')
|
||||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||||
const [directOnly, setDirectOnly] = useState(false)
|
const [directOnly, setDirectOnly] = useState(false)
|
||||||
|
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
|
||||||
const [resumePosition, setResumePosition] = useState(0)
|
const [resumePosition, setResumePosition] = useState(0)
|
||||||
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
||||||
// HLS session source offset: playlist t=0 maps to this absolute second.
|
// HLS session source offset: playlist t=0 maps to this absolute second.
|
||||||
@@ -121,6 +105,7 @@ export function PlayerPage() {
|
|||||||
.info()
|
.info()
|
||||||
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
|
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
|
||||||
.catch(() => setDirectOnly(false))
|
.catch(() => setDirectOnly(false))
|
||||||
|
.finally(() => setDirectOnlyKnown(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||||
@@ -225,9 +210,8 @@ export function PlayerPage() {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const list = tracks ?? []
|
const list = tracks ?? []
|
||||||
setSubs(list)
|
setSubs(list)
|
||||||
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
|
// 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。
|
||||||
// 越界时回退到第一条;无字幕则关闭。
|
setSubtitleIndex(list.length > 0 ? 0 : -1)
|
||||||
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -242,6 +226,9 @@ export function PlayerPage() {
|
|||||||
// Depend on media.id (not the media object): refreshing duration after
|
// Depend on media.id (not the media object): refreshing duration after
|
||||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||||
const mediaId = media?.id
|
const mediaId = media?.id
|
||||||
|
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
|
const burnedSubtitleStream =
|
||||||
|
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||||
const mediaRef = useRef(media)
|
const mediaRef = useRef(media)
|
||||||
mediaRef.current = media
|
mediaRef.current = media
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,7 +241,7 @@ export function PlayerPage() {
|
|||||||
const video = ref.current
|
const video = ref.current
|
||||||
const durationSec = currentMedia.duration_sec || 0
|
const durationSec = currentMedia.duration_sec || 0
|
||||||
if (mode === 'hls') {
|
if (mode === 'hls') {
|
||||||
const url = hlsURL(mediaId, hlsStartSec)
|
const url = hlsURL(mediaId, hlsStartSec, burnedSubtitleStream)
|
||||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||||
if (cancelled || !ref.current) return
|
if (cancelled || !ref.current) return
|
||||||
if (HlsCtor.isSupported()) {
|
if (HlsCtor.isSupported()) {
|
||||||
@@ -323,7 +310,7 @@ export function PlayerPage() {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
teardownHls()
|
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
|
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||||
// survives route navigation while the component is being torn down.
|
// survives route navigation while the component is being torn down.
|
||||||
@@ -600,6 +587,31 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
const isDirectStream = isDirectStreamMedia(media)
|
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(() => {
|
const toggleMode = useCallback(() => {
|
||||||
if (isDirectStream) {
|
if (isDirectStream) {
|
||||||
toast('该媒体为直连播放,无需且不支持转码')
|
toast('该媒体为直连播放,无需且不支持转码')
|
||||||
@@ -646,15 +658,27 @@ export function PlayerPage() {
|
|||||||
[hlsStartSec, mode],
|
[hlsStartSec, mode],
|
||||||
)
|
)
|
||||||
|
|
||||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
|
||||||
const selectSubtitle = useCallback((index: number) => {
|
const selectSubtitle = useCallback((index: number) => {
|
||||||
setSubtitleIndex(index)
|
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
try {
|
const nextTrack = index >= 0 ? subs[index] : undefined
|
||||||
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
|
if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) {
|
||||||
} catch {
|
toast.error('图片字幕需要开启 HLS 转码后才能显示')
|
||||||
// ignore
|
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(() => {
|
const handleVideoError = useCallback(() => {
|
||||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode, RefObject } from 'react'
|
import type { PointerEvent, ReactNode, RefObject } from 'react'
|
||||||
|
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
@@ -85,6 +85,8 @@ export function PlayerVideoStage({
|
|||||||
const stageRef = useRef<HTMLDivElement>(null)
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
|
const revealControlsOnlyRef = useRef(false)
|
||||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||||
|
|
||||||
@@ -123,13 +125,26 @@ export function PlayerVideoStage({
|
|||||||
}
|
}
|
||||||
}, [videoRef, media])
|
}, [videoRef, media])
|
||||||
|
|
||||||
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
|
// 桌面端点击直接切换播放;移动端控制栏隐藏时首次轻触只唤出控制栏,
|
||||||
|
// 控制栏已显示时再次轻触才切换播放/暂停。
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
if (video.paused) void video.play()?.catch(() => undefined)
|
if (video.paused) void video.play()?.catch(() => undefined)
|
||||||
else video.pause()
|
else video.pause()
|
||||||
}
|
}
|
||||||
|
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
||||||
|
}
|
||||||
|
const handleStageClick = () => {
|
||||||
|
if (revealControlsOnlyRef.current) {
|
||||||
|
revealControlsOnlyRef.current = false
|
||||||
|
setControlsVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealControlsOnlyRef.current = false
|
||||||
|
togglePlay()
|
||||||
|
}
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
const stage = stageRef.current
|
const stage = stageRef.current
|
||||||
if (!stage) return
|
if (!stage) return
|
||||||
@@ -142,15 +157,22 @@ export function PlayerVideoStage({
|
|||||||
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
|
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
|
const selectedTrack = subs[subtitleIndex]
|
||||||
|
if (
|
||||||
|
!video ||
|
||||||
|
subs.length === 0 ||
|
||||||
|
subtitleIndex < 0 ||
|
||||||
|
!selectedTrack ||
|
||||||
|
selectedTrack.delivery === 'burn'
|
||||||
|
) {
|
||||||
setActiveCueText('')
|
setActiveCueText('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const trackIdx = subtitleIndex
|
|
||||||
|
|
||||||
const updateCue = () => {
|
const updateCue = () => {
|
||||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
const selectedEl = video.querySelector<HTMLTrackElement>(
|
||||||
const selectedEl = trackEls[trackIdx]
|
`track[data-subtitle-index="${subtitleIndex}"]`,
|
||||||
|
)
|
||||||
const tt = selectedEl?.track
|
const tt = selectedEl?.track
|
||||||
if (!tt) {
|
if (!tt) {
|
||||||
setActiveCueText('')
|
setActiveCueText('')
|
||||||
@@ -160,13 +182,13 @@ export function PlayerVideoStage({
|
|||||||
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
|
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
|
||||||
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
|
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
|
||||||
const texts: string[] = []
|
const texts: string[] = []
|
||||||
if (tt.activeCues && tt.activeCues.length > 0) {
|
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
|
||||||
for (let i = 0; i < tt.activeCues.length; i++) {
|
for (let i = 0; i < tt.activeCues.length; i++) {
|
||||||
const cue = tt.activeCues[i] as VTTCue
|
const cue = tt.activeCues[i] as VTTCue
|
||||||
if (cue && cue.text) texts.push(cue.text)
|
if (cue && cue.text) texts.push(cue.text)
|
||||||
}
|
}
|
||||||
} else if (tt.cues && tt.cues.length > 0) {
|
} else if (tt.cues && tt.cues.length > 0) {
|
||||||
const cur = video.currentTime
|
const cur = video.currentTime + (streamOffset ?? 0)
|
||||||
for (let i = 0; i < tt.cues.length; i++) {
|
for (let i = 0; i < tt.cues.length; i++) {
|
||||||
const cue = tt.cues[i] as VTTCue
|
const cue = tt.cues[i] as VTTCue
|
||||||
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
|
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
|
||||||
@@ -180,15 +202,18 @@ export function PlayerVideoStage({
|
|||||||
const apply = () => {
|
const apply = () => {
|
||||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||||
if (trackEls.length === 0) return
|
if (trackEls.length === 0) return
|
||||||
trackEls.forEach((el, i) => {
|
trackEls.forEach((el) => {
|
||||||
const tt = el.track
|
const tt = el.track
|
||||||
if (tt) {
|
if (tt) {
|
||||||
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||||
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
|
tt.mode =
|
||||||
|
el.dataset.subtitleIndex === String(subtitleIndex) ? 'hidden' : 'disabled'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = trackEls[trackIdx]
|
const selected = video.querySelector<HTMLTrackElement>(
|
||||||
|
`track[data-subtitle-index="${subtitleIndex}"]`,
|
||||||
|
)
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
|
|
||||||
const tt = selected.track
|
const tt = selected.track
|
||||||
@@ -216,8 +241,9 @@ export function PlayerVideoStage({
|
|||||||
video.removeEventListener('seeking', updateCue)
|
video.removeEventListener('seeking', updateCue)
|
||||||
video.removeEventListener('seeked', updateCue)
|
video.removeEventListener('seeked', updateCue)
|
||||||
video.removeEventListener('playing', updateCue)
|
video.removeEventListener('playing', updateCue)
|
||||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
const selected = video.querySelector<HTMLTrackElement>(
|
||||||
const selected = trackEls[trackIdx]
|
`track[data-subtitle-index="${subtitleIndex}"]`,
|
||||||
|
)
|
||||||
if (selected) {
|
if (selected) {
|
||||||
selected.removeEventListener('load', updateCue)
|
selected.removeEventListener('load', updateCue)
|
||||||
if (selected.track) {
|
if (selected.track) {
|
||||||
@@ -225,7 +251,7 @@ export function PlayerVideoStage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [subtitleIndex, subs, videoRef, media])
|
}, [subtitleIndex, subs, videoRef, media, streamOffset])
|
||||||
|
|
||||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||||
const isWiderThanStage =
|
const isWiderThanStage =
|
||||||
@@ -251,7 +277,8 @@ export function PlayerVideoStage({
|
|||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
data-player-stage
|
data-player-stage
|
||||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||||
onClick={togglePlay}
|
onPointerDown={handleStagePointerDown}
|
||||||
|
onClick={handleStageClick}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
>
|
>
|
||||||
{media ? (
|
{media ? (
|
||||||
@@ -267,16 +294,19 @@ export function PlayerVideoStage({
|
|||||||
className="h-full w-full object-contain bg-black"
|
className="h-full w-full object-contain bg-black"
|
||||||
onError={onVideoError}
|
onError={onVideoError}
|
||||||
>
|
>
|
||||||
{subs.map((track, index) => (
|
{subs.map((track, index) =>
|
||||||
|
track.delivery === 'burn' ? null : (
|
||||||
<track
|
<track
|
||||||
key={track.path}
|
key={track.path}
|
||||||
|
data-subtitle-index={index}
|
||||||
kind="subtitles"
|
kind="subtitles"
|
||||||
src={subtitlesAPI.url(media.id, track.path)}
|
src={subtitlesAPI.url(media.id, track.path)}
|
||||||
srcLang={track.lang}
|
srcLang={track.lang}
|
||||||
label={track.label || track.lang}
|
label={track.label || track.lang}
|
||||||
default={subtitleIndex === index}
|
default={subtitleIndex === index}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</video>
|
</video>
|
||||||
<DanmakuStage
|
<DanmakuStage
|
||||||
key={media.id}
|
key={media.id}
|
||||||
@@ -310,6 +340,8 @@ export function PlayerVideoStage({
|
|||||||
</div>
|
</div>
|
||||||
<PlayerControls
|
<PlayerControls
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
|
uiVisible={controlsVisible}
|
||||||
|
onUiVisibleChange={setControlsVisible}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={onSelectSubtitle}
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
|
|||||||
Reference in New Issue
Block a user