From 5f4b613bdee44e93a62dc20aceb8aa2fd33e1cf1 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:50:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20115=20=E4=BA=91=E8=BD=AC?= =?UTF-8?q?=E7=A0=81=E6=92=AD=E6=94=BE=E4=B8=AD=E9=80=94=E6=96=AD=E6=B5=81?= =?UTF-8?q?=E5=90=8E=E7=9B=B4=E6=8E=A5=E6=8E=89=E5=88=B0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E8=BD=AC=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 115 云 HLS 会话只存在服务端内存里:服务重新部署、会话过期(30 分钟无活动)、 上游分片地址失效,都会让播放中的客户端在后续分片上拿到 404/410。原实现此时 直接退回本地转码,而低配宿主机本地转码追不上播放速度(实测 2 vCPU 只有 0.62x),用户看到的就是播放报废。 - 云 HLS 中途 fatal 时先重建会话续播(10 分钟内最多 3 次),仍失败才退回本地 - manifestLoadingMaxRetry 1 -> 4:master.m3u8 每次都要现调 115 接口,一次抖动 不该直接判死刑 - 播放列表重写改用上游地址哈希做 key:原来用自增序号,每次重写 entries 就膨胀 一轮、分片编号漂移(实测 762 段列表重写几次后 key 涨到 e2288..e3049) - 会话条目数加上限,异常播放列表不会无限占用内存 - 客户端断开(seek / 切清晰度取消在途请求)不再记成 502,改记 499 与 nginx 对齐;会话失效返回带 code 的 404/410,便于前端判断需要重建会话 - 修正进度/时长上报:云 HLS 时间轴起点恒为 0,不应再叠加 hlsStartSec(历史上 把 7624s 的片子记成 8346s 并标成「已看完」) - 切回直连 / 原画时保住播放位置:HLS 卸载会把 currentTime 归零,必须在切换前 先记下目标位置 --- internal/handler/cloud115_hls_session_test.go | 58 +++++++++ internal/handler/cloud115_playback.go | 15 ++- internal/service/cloud115_hls_proxy.go | 33 ++++- internal/service/cloud115_hls_proxy_test.go | 34 ++++- web/src/pages/PlayerPage.tsx | 117 ++++++++++++++++-- 5 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 internal/handler/cloud115_hls_session_test.go diff --git a/internal/handler/cloud115_hls_session_test.go b/internal/handler/cloud115_hls_session_test.go new file mode 100644 index 0000000..dfc8668 --- /dev/null +++ b/internal/handler/cloud115_hls_session_test.go @@ -0,0 +1,58 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" +) + +// 云 HLS 分片代理的错误映射: +// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播, +// 而不是当成播放失败直接退回本地转码; +// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499, +// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。 +func newCloud115HLSSessionTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + svc := &service.Container{ + Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil), + } + router := gin.New() + router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc)) + return router +} + +func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) { + router := newCloud115HLSSessionTestRouter() + req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "hls_session_not_found") { + t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String()) + } +} + +func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) { + router := newCloud115HLSSessionTestRouter() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil). + WithContext(ctx) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != 499 { + t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/handler/cloud115_playback.go b/internal/handler/cloud115_playback.go index 37bd010..2f61591 100644 --- a/internal/handler/cloud115_playback.go +++ b/internal/handler/cloud115_playback.go @@ -1,6 +1,7 @@ package handler import ( + "context" "errors" "net/http" "strconv" @@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc { if err == nil || c.Writer.Written() { return } + // 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让 + // 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前 + // 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + c.Request.Context().Err() != nil { + c.AbortWithStatus(499) + return + } switch { case errors.Is(err, service.ErrCloud115HLSSessionNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"}) + // code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」, + // 而不是直接放弃云端播放退回本地转码。 + c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"}) case errors.Is(err, service.ErrCloud115HLSUpstreamExpired): - c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"}) + c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"}) default: c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) } diff --git a/internal/service/cloud115_hls_proxy.go b/internal/service/cloud115_hls_proxy.go index bce5fba..02dab2a 100644 --- a/internal/service/cloud115_hls_proxy.go +++ b/internal/service/cloud115_hls_proxy.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -12,6 +14,8 @@ import ( "sync" "time" + "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/service/cloud115" ) @@ -24,6 +28,10 @@ const ( cloud115HLSSessionTTL = 30 * time.Minute cloud115HLSMaxSessions = 256 cloud115HLSMaxManifest = 8 << 20 + // cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址 + // 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长, + // 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。 + cloud115HLSMaxEntries = 20000 ) type cloud115HLSSession struct { @@ -35,7 +43,17 @@ type cloud115HLSSession struct { mu sync.Mutex entries map[string]string - next int +} + +// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。 +// +// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次 +// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复 +// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。 +// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。 +func cloud115HLSKeyForURL(upstream string) string { + sum := sha256.Sum256([]byte(upstream)) + return hex.EncodeToString(sum[:12]) } // Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。 @@ -302,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba } func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string { + key := cloud115HLSKeyForURL(upstream) session.mu.Lock() - session.next++ - key := "e" + strconv.Itoa(session.next) - session.entries[key] = upstream + _, known := session.entries[key] + if !known && len(session.entries) < cloud115HLSMaxEntries { + session.entries[key] = upstream + known = true + } mediaID := session.MediaID session.mu.Unlock() + if !known && p != nil && p.service != nil && p.service.log != nil { + p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve", + zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries)) + } query := childProxyQuery(rawQuery, mediaID) return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query diff --git a/internal/service/cloud115_hls_proxy_test.go b/internal/service/cloud115_hls_proxy_test.go index 1bfc3ba..48e068c 100644 --- a/internal/service/cloud115_hls_proxy_test.go +++ b/internal/service/cloud115_hls_proxy_test.go @@ -25,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) { if strings.Contains(rewritten, "cpats01.115.com") { t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten) } - if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") { + wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2") + if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") { t.Fatalf("proxy URL missing: %s", rewritten) } if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") { @@ -36,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) { } } +func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) { + proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}} + session := &cloud115HLSSession{ + ID: "sess", + MediaID: "media-1", + entries: map[string]string{}, + } + manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" + + "https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n" + + first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1") + entriesAfterFirst := len(session.entries) + second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1") + + // 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。 + if first != second { + t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second) + } + if len(session.entries) != entriesAfterFirst { + t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries)) + } + if entriesAfterFirst != 2 { + t.Fatalf("session entries = %d, want 2", entriesAfterFirst) + } +} + func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) { proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}} session := &cloud115HLSSession{ @@ -151,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) { MediaID: "media-1", ExpiresAt: time.Now().Add(time.Hour), entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"}, - next: 1, } proxy.sessions[session.ID] = session req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil) @@ -162,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } - if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") { + wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1") + if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") { t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "cpats01.115.com") { @@ -192,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) { MediaID: "media-1", ExpiresAt: time.Now().Add(time.Hour), entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"}, - next: 1, } proxy.sessions[session.ID] = session req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil) diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 5ac9020..4a718e6 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -81,6 +81,10 @@ type PlaybackProgressSession = { // 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。 const SKIP_NOTICE_MS = 6000 +// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。 +const CLOUD_HLS_RELOAD_MAX = 3 +const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000 + function normalizePlayerVolume(value: unknown): number { const parsed = Number(value) if (!Number.isFinite(parsed)) return 1 @@ -185,6 +189,17 @@ export function PlayerPage() { const cloudPollTimerRef = useRef | null>(null) const cloudRetryRef = useRef(5) const pendingSeekRef = useRef(null) + // 云 HLS 会话重建计数(时间戳)。115 云转码的会话只存在服务端内存里:服务 + // 重新部署、会话过期(30 分钟无活动)、上游地址失效都会让播放中途拿到 + // 404/410。这种情况下重新拉一次 master.m3u8 建个新会话即可续播,没必要直接 + // 掉到本地转码(这台机器本地转码只有 0.6x,等于播放报废)。窗口内限制次数, + // 避免上游真的坏了时无限重连。 + const cloudReloadsRef = useRef([]) + const [cloudReloadNonce, setCloudReloadNonce] = useState(0) + // 切回直连/原画时把当前位置带过去:HLS 卸载会把 video.currentTime 归零, + // 必须在切换前先记下来,等直连 loadedmetadata 后再跳。 + const pendingDirectStartRef = useRef(0) + const hlsSourceRef = useRef<'cloud' | 'local'>('local') // 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。 const [danmakuOpen, setDanmakuOpen] = useState(false) @@ -488,6 +503,8 @@ export function PlayerPage() { setCloudWaitMessage('') setCloudWaitStartedAt(0) pendingSeekRef.current = null + cloudReloadsRef.current = [] + pendingDirectStartRef.current = 0 if (cloudPollTimerRef.current) { clearTimeout(cloudPollTimerRef.current) cloudPollTimerRef.current = null @@ -953,6 +970,38 @@ export function PlayerPage() { [playbackInfo, setPlaybackMode], ) + /** + * 115 云 HLS 中途断流后重建会话。 + * + * 115 的云端转码会话只存在服务端内存里:服务重新部署、会话到期(30 分钟无活动)、 + * 上游分片地址失效,都会让正在播放的客户端在后续分片上拿到 404/410。重新请求一次 + * master.m3u8 就会建出新会话,从当前位置继续播即可——比直接退回本地转码好得多 + * (低配宿主机本地转码往往追不上播放速度,等于不能看)。 + * + * 窗口内超过次数上限返回 false,由调用方走本地转码兜底,避免上游真的坏了时无限重连。 + */ + const reloadCloudHls = useCallback( + (position: number): boolean => { + const now = Date.now() + const recent = cloudReloadsRef.current.filter((at) => now - at < CLOUD_HLS_RELOAD_WINDOW_MS) + if (recent.length >= CLOUD_HLS_RELOAD_MAX) { + cloudReloadsRef.current = recent + return false + } + recent.push(now) + cloudReloadsRef.current = recent + // 云 HLS 是整条时间轴,起播后按绝对位置跳一下,别让用户回到片头。 + pendingSeekRef.current = position > 2 ? position : null + setCloudWaiting(false) + setCloudWaitMessage('') + setHlsSource('cloud') + setPlaybackMode('hls') + setCloudReloadNonce((v) => v + 1) + return true + }, + [setPlaybackMode], + ) + const startCloudTranscode = useCallback( async (definition: number) => { if (!mediaId) return @@ -1126,7 +1175,10 @@ export function PlayerPage() { // hosts and remote STRM sources regularly need more than hls.js's // 10s default, which otherwise aborts a healthy transcode. manifestLoadingTimeOut: 60_000, - manifestLoadingMaxRetry: 1, + // 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s), + // 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑, + // 直接掉到本地转码。 + manifestLoadingMaxRetry: 4, }) try { video.currentTime = 0 @@ -1160,6 +1212,11 @@ export function PlayerPage() { if (data.fatal) { if (hlsSource === 'cloud') { const position = ref.current?.currentTime || 0 + // 先尝试重建云会话续播;只有连着失败到上限才退回本地转码。 + if (reloadCloudHls(position)) { + toast('115 云端播放中断,正在重新连接…', { duration: 2200 }) + return + } setHlsUnavailable(false) switchToLocalHLS(position) toast.error('115 云端播放失败,切换本地转码') @@ -1211,12 +1268,24 @@ export function PlayerPage() { if (video.src !== absoluteURL) { directRetryRef.current = false clearFallbackTimer() - // 进出 VR 会切换播放源地址,这里保住当前播放位置。 - const resumeAt = video.currentTime > 2 ? video.currentTime : 0 + // 保住当前位置。两个来源:显式切回直连/原画时提前记下的目标位置 + // (HLS 卸载会把 video.currentTime 归零,所以不能只读 video.currentTime), + // 以及 VR 进出时切换播放源地址的情形。 + const resumeAt = Math.max( + pendingDirectStartRef.current, + video.currentTime > 2 ? video.currentTime : 0, + ) + pendingDirectStartRef.current = 0 if (resumeAt > 0) { video.addEventListener( 'loadedmetadata', () => { + // STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被 + // 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。 + if (isStrmMedia(currentMedia)) { + seekStrmDirectTo(resumeAt) + return + } try { video.currentTime = resumeAt } catch { @@ -1243,6 +1312,7 @@ export function PlayerPage() { }, [ activeBurnedSubtitleStream, clearFallbackTimer, + cloudReloadNonce, cloudWaiting, hlsSource, hlsUnavailable, @@ -1250,6 +1320,8 @@ export function PlayerPage() { mediaId, mode, playbackProvider, + reloadCloudHls, + seekStrmDirectTo, selectedQuality, setPlaybackMode, switchToLocalHLS, @@ -1398,8 +1470,9 @@ export function PlayerPage() { const modeRef = useRef(mode) useEffect(() => { hlsStartSecRef.current = hlsStartSec + hlsSourceRef.current = hlsSource modeRef.current = mode - }, [hlsStartSec, mode]) + }, [hlsStartSec, hlsSource, mode]) // Persist resume position every 10 seconds while playing, and immediately upon // pause/page hide/unmount. Bind after mediaId is available because @@ -1412,16 +1485,21 @@ export function PlayerPage() { lastSentRef.current = 0 } + // 本地 HLS 的播放列表 t=0 对应 hlsStartSec,云 HLS 的播放列表本身就是整条 + // 时间轴(起点恒为 0)。以前两种模式都叠加 hlsStartSec:只要本地转码残留过 + // 一个非 0 起点再切到云端,上报的进度和总时长就会被整体抬高(实测把 7624s + // 的片子记成 8346s 并直接标成「已看完」)。 + const timelineOffsetSec = () => + modeRef.current === 'hls' && hlsSourceRef.current === 'local' ? hlsStartSecRef.current : 0 + const absolutePositionMs = () => { - const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0 - const absolute = currentStartSec + (video.currentTime || 0) + const absolute = timelineOffsetSec() + (video.currentTime || 0) return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0 } const absoluteDurationMs = () => { - const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0 const mediaDur = mediaRef.current?.duration_sec || 0 const streamDur = Number.isFinite(video.duration) ? video.duration : 0 - const totalSec = Math.max(mediaDur, currentStartSec + streamDur) + const totalSec = Math.max(mediaDur, timelineOffsetSec() + streamDur) return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0 } const send = (keepalive: boolean) => { @@ -1782,16 +1860,27 @@ export function PlayerPage() { toast('该媒体为直连播放,无需且不支持转码') return } + const video = ref.current + // HLS 卸载会把 video.currentTime 归零,所以先按当前模式换算成绝对秒数记下来。 + const position = video + ? mode === 'hls' && hlsSource === 'local' + ? hlsStartSec + video.currentTime + : video.currentTime + : 0 const next = mode === 'hls' ? 'direct' : 'hls' if (next === 'hls') { // 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。 - enterHlsPlayback(ref.current?.currentTime || 0) + enterHlsPlayback(position) } else { setCloudWaiting(false) + // 直连要等 loadedmetadata 再跳(见播放方式接线 effect),这里先把目标位置存下来。 + pendingDirectStartRef.current = position } setPlaybackMode(next) }, [ enterHlsPlayback, + hlsSource, + hlsStartSec, isDirectStream, mode, setPlaybackMode, @@ -1989,16 +2078,22 @@ export function PlayerPage() { const selectPlaybackQuality = useCallback( (quality: PlaybackQuality) => { const video = ref.current - const position = video?.currentTime || 0 + const localTime = video?.currentTime || 0 + // 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。 + const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime if (quality.source === 'original') { setCloudWaiting(false) setHlsSource('local') + // 切回原始文件直连:位置由直连分支的 loadedmetadata 跳转负责带过去。 + pendingDirectStartRef.current = position setPlaybackMode('direct') return } if (quality.source === 'cloud') { setSelectedQuality(quality.id) setHlsSource('cloud') + // 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。 + cloudReloadsRef.current = [] if (quality.available) { setCloudWaiting(false) setCloudWaitMessage('') @@ -2025,7 +2120,7 @@ export function PlayerPage() { if (position > 2) setHlsStartSec(position) setPlaybackMode('hls') }, - [setPlaybackMode, startCloudTranscode], + [hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode], ) const changeSubtitleChineseMode = useCallback(