mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd441c288 | |||
| c514fb360e | |||
| c0c1412f4a | |||
| 360f9c173b | |||
| 60b11bc2bc | |||
| ac546f1197 | |||
| 93c7487c96 | |||
| 2e8b1cb7a1 | |||
| cdb1564826 | |||
| 90c10137e9 |
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,10 @@ type OpenClient struct {
|
||||
AccessToken string
|
||||
RefreshTokenStr string
|
||||
executor *QueueExecutor
|
||||
|
||||
// tokenMu 保护令牌刷新:业务请求中途 access_token 失效时自动刷新重试,
|
||||
// 多 goroutine(同步列表 + 下载队列)并发下只允许一次刷新进行。
|
||||
tokenMu sync.Mutex
|
||||
}
|
||||
|
||||
// default115HTTPClient 创建带有防 405 重定向保护的 http.Client。
|
||||
@@ -175,9 +180,18 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
return &base, lastErr
|
||||
}
|
||||
|
||||
// Token 失效不重试
|
||||
// Token 失效(access_token 过期,如长时间同步中途过期):自动用
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
return &base, nil
|
||||
if access && c.tryRefreshTokenLocked() {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
// 刷新失败(或已刷新仍失败)时返回明确错误
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115: access_token 校验失败且刷新未成功")
|
||||
}
|
||||
return &base, lastErr
|
||||
}
|
||||
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
|
||||
@@ -243,6 +257,22 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
||||
return c.doJSON(ctx, method, rawURL, form, true, retries, ua)
|
||||
}
|
||||
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
|
||||
func (c *OpenClient) tryRefreshTokenLocked() bool {
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err != nil {
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
}
|
||||
return false
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
func IsThrottleCode(code int) bool {
|
||||
return code == RequestMaxLimitCode || code == RequestRateLimitCode
|
||||
|
||||
@@ -86,6 +86,21 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
return STRMPlaybackEnabled(ctx, repo)
|
||||
}
|
||||
|
||||
// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
func isStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
|
||||
}
|
||||
|
||||
func isHTTPPlaybackTarget(raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil || !u.IsAbs() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -229,7 +230,14 @@ func (st *strmSyncState) run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkRemote 广度优先遍历网盘目录树。
|
||||
// strmScanWorkers 远端目录树并发遍历的 worker 数。115 开放平台有全局
|
||||
// 令牌桶限流(QPS/QPM/QPH),并发请求自动排队,不会触发风控;并发让
|
||||
// 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。
|
||||
const strmScanWorkers = 8
|
||||
|
||||
// walkRemote 并发广度优先遍历网盘目录树。
|
||||
// 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态
|
||||
// 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。
|
||||
func (st *strmSyncState) walkRemote() error {
|
||||
root := strings.TrimSpace(st.p.RemotePath)
|
||||
if root == "" {
|
||||
@@ -239,33 +247,78 @@ func (st *strmSyncState) walkRemote() error {
|
||||
id string
|
||||
rel string
|
||||
}
|
||||
queue := []dirTask{{id: root, rel: ""}}
|
||||
for len(queue) > 0 {
|
||||
select {
|
||||
case <-st.ctx.Done():
|
||||
return st.ctx.Err()
|
||||
default:
|
||||
}
|
||||
task := queue[0]
|
||||
queue = queue[1:]
|
||||
entries, err := st.provider.List(st.ctx, task.id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
|
||||
ctx, cancel := context.WithCancel(st.ctx)
|
||||
defer cancel()
|
||||
|
||||
queue := make(chan dirTask, 512)
|
||||
var pending atomic.Int64
|
||||
|
||||
// 根目录入队
|
||||
pending.Add(1)
|
||||
queue <- dirTask{id: root, rel: ""}
|
||||
|
||||
// 当队列中所有目录都被消费(pending 归零)或出错时关闭 channel,
|
||||
// 让 worker 全部退出。
|
||||
go func() {
|
||||
for {
|
||||
if ctx.Err() != nil || pending.Load() == 0 {
|
||||
close(queue)
|
||||
return
|
||||
}
|
||||
if entry.IsDir {
|
||||
queue = append(queue, dirTask{id: entry.ID, rel: rel})
|
||||
continue
|
||||
}
|
||||
st.processRemoteFile(entry, rel)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
errMu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
for i := 0; i < strmScanWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for task := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
entries, err := st.provider.List(ctx, task.id)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
pending.Add(1)
|
||||
select {
|
||||
case queue <- dirTask{id: entry.ID, rel: rel}:
|
||||
case <-ctx.Done():
|
||||
pending.Add(-1)
|
||||
}
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
}
|
||||
}
|
||||
pending.Add(-1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
|
||||
@@ -340,7 +393,8 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
}
|
||||
content, err := st.strmContent(entry, rel, ext)
|
||||
if err != nil {
|
||||
st.rec.Message = err.Error()
|
||||
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
|
||||
// 最终同步结果的 message 由 finishSync 统一填充。
|
||||
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service/cloud"
|
||||
)
|
||||
|
||||
// testStrmService 构建带内存库的 StrmService。
|
||||
@@ -340,3 +341,108 @@ func TestScanLocalMetaForUpload(t *testing.T) {
|
||||
t.Fatalf("expected still 1 upload task after dedup, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
||||
type fakeRemoteProvider struct {
|
||||
listed map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeRemoteProvider) Type() string { return "fake" }
|
||||
func (f *fakeRemoteProvider) Ping(context.Context) error { return nil }
|
||||
func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink, error) {
|
||||
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
||||
if f.listed == nil {
|
||||
f.listed = map[string]int{}
|
||||
}
|
||||
f.listed[dirID]++
|
||||
switch dirID {
|
||||
case "root":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "a", Name: "动漫", IsDir: true},
|
||||
{ID: "b", Name: "电影", IsDir: true},
|
||||
{ID: "f1", Name: "孤儿视频.mkv", Size: 100},
|
||||
}, nil
|
||||
case "a":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "a1", Name: "番剧", IsDir: true},
|
||||
{ID: "fa1", Name: "第01集.mkv", Size: 200},
|
||||
}, nil
|
||||
case "a1":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "fa11", Name: "第01集.mkv", Size: 300},
|
||||
{ID: "fa12", Name: "第02集.mkv", Size: 300},
|
||||
}, nil
|
||||
case "b":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "fb1", Name: "电影A.mkv", Size: 400},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalkRemoteConcurrent 验证并发目录遍历:所有目录均被列出、所有文件
|
||||
// 均被处理(strm 生成 / 元数据入队),且不重复。
|
||||
func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
|
||||
acct := &model.StrmAccount{
|
||||
Name: "fake",
|
||||
Provider: "cloud115",
|
||||
Config: "{}",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &model.StrmSyncPath{
|
||||
Base: model.Base{ID: "walk-path"},
|
||||
AccountID: acct.ID,
|
||||
Provider: model.StrmProvider115,
|
||||
RemotePath: "root",
|
||||
LocalPath: localDir,
|
||||
}
|
||||
|
||||
provider := &fakeRemoteProvider{listed: map[string]int{}}
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
provider: provider,
|
||||
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1, DownloadMeta: false},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
seenVideo: map[string]bool{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
}
|
||||
if err := st.walkRemote(); err != nil {
|
||||
t.Fatalf("walkRemote failed: %v", err)
|
||||
}
|
||||
|
||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||
if provider.listed[dir] != 1 {
|
||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
|
||||
}
|
||||
}
|
||||
|
||||
// 5 个视频文件应生成 5 个 .strm:孤儿视频.mkv / a目录第01集 /
|
||||
// 番剧第01集+第02集 / 电影A(递归统计,含子目录)
|
||||
strmCount := 0
|
||||
walkErr := filepath.WalkDir(localDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() && strings.HasSuffix(d.Name(), ".strm") {
|
||||
strmCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
t.Fatal(walkErr)
|
||||
}
|
||||
if strmCount != 5 {
|
||||
t.Errorf("生成的 .strm 数量 = %d,期望 5", strmCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,12 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if m == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
|
||||
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
|
||||
// 转码,迫使播放器走 /api/stream 302 直连播放。
|
||||
if isStrmMediaRow(m) {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
@@ -17,8 +20,7 @@ import {
|
||||
//
|
||||
// Native keyboard shortcuts (space / arrows) still work because they are
|
||||
// element-level defaults on <video>. Subtitles from <track> elements keep
|
||||
// rendering (the default track shows as before); only the track picker UI is
|
||||
// not re-implemented here.
|
||||
// rendering; the CC button opens a track picker (关闭 / 各轨道).
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) s = 0
|
||||
@@ -29,6 +31,10 @@ function formatTime(s: number): string {
|
||||
|
||||
type PlayerControlsProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
@@ -36,6 +42,9 @@ type PlayerControlsProps = {
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
@@ -51,8 +60,22 @@ export function PlayerControls({
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [pip, setPip] = useState(false)
|
||||
const [uiVisible, setUiVisible] = useState(true)
|
||||
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
|
||||
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||
setSubtitleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
}, [subtitleMenuOpen])
|
||||
|
||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
|
||||
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
|
||||
useEffect(() => {
|
||||
@@ -217,6 +240,55 @@ export function PlayerControls({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{subs.length > 0 && (
|
||||
<div className="relative" ref={subtitleMenuRef}>
|
||||
<button
|
||||
onClick={() => setSubtitleMenuOpen((v) => !v)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="字幕"
|
||||
>
|
||||
{subtitleIndex >= 0 ? (
|
||||
<Captions size={18} className="text-rose-400" />
|
||||
) : (
|
||||
<CaptionsOff size={18} className="text-white/70" />
|
||||
)}
|
||||
</button>
|
||||
{subtitleMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSubtitle(-1)
|
||||
setSubtitleMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||
subtitleIndex < 0 ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
>
|
||||
关闭字幕
|
||||
</button>
|
||||
{subs.map((track, index) => (
|
||||
<button
|
||||
key={track.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSubtitle(index)
|
||||
setSubtitleMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 truncate rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||
subtitleIndex === index ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
title={track.label || track.lang}
|
||||
>
|
||||
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
|
||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
|
||||
@@ -59,8 +59,26 @@ export function HomePage() {
|
||||
})
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
|
||||
const results = await Promise.allSettled(
|
||||
libs.map(async (lib) => {
|
||||
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
|
||||
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
|
||||
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
|
||||
// 改用 /series 分页拉取全部聚合后的剧集卡片。
|
||||
if (isSeriesType(lib.type)) {
|
||||
const cards: SeriesCard[] = []
|
||||
let total = 0
|
||||
const pageSize = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
|
||||
const pageItems = asArray<SeriesCard>(data?.items)
|
||||
cards.push(...pageItems)
|
||||
total = data?.total ?? cards.length
|
||||
if (cards.length >= total || pageItems.length < pageSize) break
|
||||
}
|
||||
return { id: lib.id, cards, items: [], total }
|
||||
}
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 30)
|
||||
const items = asArray<Media>(page?.items)
|
||||
const cards = groupSeries(items)
|
||||
|
||||
@@ -27,6 +27,23 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
//
|
||||
// External subtitles next to the source file are auto-discovered and
|
||||
// attached as <track> elements.
|
||||
const SUBTITLE_STORAGE_KEY = 'mmtl.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()
|
||||
@@ -40,6 +57,7 @@ export function PlayerPage() {
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
@@ -156,7 +174,13 @@ export function PlayerPage() {
|
||||
})
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.then((tracks) => setSubs(tracks ?? []))
|
||||
.then((tracks) => {
|
||||
const list = tracks ?? []
|
||||
setSubs(list)
|
||||
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
|
||||
// 越界时回退到第一条;无字幕则关闭。
|
||||
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
|
||||
})
|
||||
.catch(() => setSubs([]))
|
||||
}, [id, params, directOnly])
|
||||
|
||||
@@ -246,6 +270,16 @@ export function PlayerPage() {
|
||||
setParams(params, { replace: true })
|
||||
}, [mode, params, setParams])
|
||||
|
||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
setSubtitleIndex(index)
|
||||
try {
|
||||
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
@@ -281,6 +315,8 @@ export function PlayerPage() {
|
||||
media={media}
|
||||
playerError={playerError}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={selectSubtitle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
@@ -10,6 +11,9 @@ type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
playerError: string
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
@@ -30,6 +34,8 @@ export function PlayerVideoStage({
|
||||
media,
|
||||
playerError,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
@@ -58,6 +64,23 @@ export function PlayerVideoStage({
|
||||
else void stage.requestFullscreen?.()
|
||||
}
|
||||
|
||||
// 把用户选择的字幕轨道应用到 <video> 的 textTracks(跨浏览器显式设置
|
||||
// mode;<track default> 只影响初始值,部分浏览器不会自动显示)。
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || subs.length === 0) return
|
||||
const apply = () => {
|
||||
const tracks = video.textTracks
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
tracks[i].mode = i === subtitleIndex ? 'showing' : 'disabled'
|
||||
}
|
||||
}
|
||||
apply()
|
||||
// 轨道元数据就绪后再应用一次,确保字幕真正可见
|
||||
video.addEventListener('loadedmetadata', apply)
|
||||
return () => video.removeEventListener('loadedmetadata', apply)
|
||||
}, [subtitleIndex, subs, videoRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
@@ -73,14 +96,13 @@ export function PlayerVideoStage({
|
||||
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track, index) => (
|
||||
{subs.map((track) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
@@ -98,6 +120,9 @@ export function PlayerVideoStage({
|
||||
/>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
|
||||
@@ -12,6 +12,10 @@ export function pickPlayerMode(media: Media): PlayerMode {
|
||||
|
||||
export function needsTranscodeForBrowser(media: Media): boolean {
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
// .strm 媒体内容是远程直链(服务端 302 到播放 CDN 或反向代理),
|
||||
// 浏览器直接播放该远程流即可,转码无意义且必然失败(ffmpeg 无法读取
|
||||
// 文本 strm),恒走 direct play。
|
||||
if (container.includes('strm') || String(media.strm_url ?? '').trim() !== '') return false
|
||||
const videoCodec = (media.video_codec ?? '').toLowerCase()
|
||||
const audioCodec = (media.audio_codec ?? '').toLowerCase()
|
||||
const containerOK = directContainers.some((item) => container.includes(item))
|
||||
|
||||
Reference in New Issue
Block a user