Files
MeBox/web/src/pages/PlayerPage.tsx
T
2026-09-25 13:24:10 +08:00

2327 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import {
danmakuAPI,
type DanmakuAnime,
type DanmakuConfig,
type DanmakuLoadedInfo,
type DanmakuSettingsPatch,
} from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import { profileAPI } from '../api/profile'
import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import {
normalizeSubtitleChineseMode,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import {
loadSubtitlePosition,
loadSubtitleStyle,
saveSubtitlePosition,
saveSubtitleStyle,
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerMobileTheaterInfo } from '../components/PlayerMobileTheaterInfo'
import { useIsMobileTheater } from '../hooks/useIsMobileTheater'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import {
detectVr360Profile,
loadVr360Preference,
sameVr360Profile,
saveVr360Preference,
type Vr360Detection,
type Vr360Profile,
} from '../utils/vr360'
// Fullscreen, dark-themed video page.
//
// ?mode=hls force HLS even when direct play would work
// ?mode=direct force direct play (default for browser-friendly codecs)
//
// We pick a sensible default based on the source codec: H.264 + AAC in
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
// 浏览器播不了时再切 HLS。远程 Emby 挂载只能直连。
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
type PlaybackProgressSession = {
mediaId: string
id: string
startedAtMs: number
sequence: number
}
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
function normalizePlayerVolume(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
return Math.min(1, Math.max(0, parsed))
}
function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
const randomID =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
mediaId,
id: `${mediaId}:${randomID}`,
startedAtMs: Date.now(),
sequence: 0,
}
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const navigate = useNavigate()
const location = useLocation()
const ref = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const lastSentRef = useRef(0)
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
// 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。
const directSeekGraceUntilRef = useRef(0)
// STRM 直连跳转目标;seekable 未覆盖时会等到可跳再写 currentTime。
const pendingDirectSeekRef = useRef<number | null>(null)
const directSeekWaitCleanupRef = useRef<(() => void) | null>(null)
// 切集时清掉旧 ?mode= 的同一轮渲染里,先挡住 modeParam effect 把 hls 写回来。
const ignoreModeParamRef = useRef(false)
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [media, setMedia] = useState<Media | null>(null)
// VR 全景播放:null = 普通播放。识别结果与用户开关只存本机,
// 不改动数据库,换设备/换浏览器不会互相影响。
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
const [vr360Detected, setVr360Detected] = useState(false)
const vr360TouchedRef = useRef(false)
// VR 取帧失败后的自愈:只尝试一次「切到 HLS」,并在切换窗口内忽略重复报错。
const vr360TextureRetryUsedRef = useRef(false)
const vr360TextureRetryUntilRef = useRef(0)
// VR 全景下 STRM/网盘直连必须由服务端同源转发,浏览器才允许 WebGL 读取画面;
// 普通播放仍然 302 直连,避免把网盘流量压到服务器。放在 ref 里是为了让
// handleVideoError 里的 src 比对始终拿到最新值。
const vr360DirectProxy = Boolean(vr360) && isStrmMedia(media)
const vr360DirectProxyRef = useRef(vr360DirectProxy)
useEffect(() => {
vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy])
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const authUser = useAuthStore((state) => state.user)
const setAuthUser = useAuthStore((state) => state.setUser)
const [subtitleChineseMode, setSubtitleChineseMode] =
useState<SubtitleChineseMode>(() =>
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
)
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
const [playerVolume, setPlayerVolume] = useState(() =>
normalizePlayerVolume(authUser?.player_volume),
)
// 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
const playerVolumeTouchedRef = useRef(false)
const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
const [loadError, setLoadError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
const [resumePosition, setResumePosition] = useState(0)
const [initialSeekDone, setInitialSeekDone] = useState(false)
// HLS session source offset: playlist t=0 maps to this absolute second.
const [hlsStartSec, setHlsStartSec] = useState(0)
// 统一播放能力:115 提供 direct → cloud_hls → local_hls,其它源 direct → local_hls。
const [playbackInfo, setPlaybackInfo] = useState<PlaybackInfo | null>(null)
const [hlsSource, setHlsSource] = useState<'cloud' | 'local'>('local')
const [selectedQuality, setSelectedQuality] = useState('')
const [cloudWaiting, setCloudWaiting] = useState(false)
const [cloudWaitMessage, setCloudWaitMessage] = useState('')
const [cloudWaitStartedAt, setCloudWaitStartedAt] = useState(0)
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cloudRetryRef = useRef(5)
const pendingSeekRef = useRef<number | null>(null)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
const [danmakuEnabled, setDanmakuEnabled] = useState(true)
const [danmakuSearch, setDanmakuSearch] = useState<string | null>(null)
const [danmakuSearchTrigger, setDanmakuSearchTrigger] = useState(0)
const [danmakuSearching, setDanmakuSearching] = useState(false)
// 用户从候选列表选定的弹幕库;null = 自动匹配。
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
// 自动匹配歧义(多番剧命中)时的候选列表。
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
// 同一集的其它可选来源(弹幕已自动加载,用户可随时切换,无需重新搜索)。
const [danmakuAlternatives, setDanmakuAlternatives] = useState<DanmakuAnime[]>([])
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
const [danmakuInfo, setDanmakuInfo] = useState<DanmakuLoadedInfo | null>(null)
// 用户当前选定的弹幕来源描述(面板中展示)。
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
// 弹幕合并偏好:从 /danmaku/config 读取(按用户落库),切换后写回并重新抓取。
const [danmakuMergeSources, setDanmakuMergeSources] = useState(false)
const [danmakuMergeSaving, setDanmakuMergeSaving] = useState(false)
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
const [danmakuArea, setDanmakuArea] = useState(1)
const [danmakuSource, setDanmakuSource] = useState('')
const [danmakuAppID, setDanmakuAppID] = useState('')
const [danmakuAppKeyConfigured, setDanmakuAppKeyConfigured] = useState(false)
const [playerSettingsSaving, setPlayerSettingsSaving] = useState(false)
// 选集 / 播放列表状态
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
const [playlistOpen, setPlaylistOpen] = useState(false)
// 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。
const [episodeRevealToken, setEpisodeRevealToken] = useState(0)
const teardownHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
}, [])
const backTarget = useCallback(() => {
const state = location.state as { from?: string } | null
if (state?.from) return state.from
const target = media?.id || id
return target ? `/media/${target}` : '/'
}, [id, location.state, media])
const goBack = useCallback(() => {
navigate(backTarget(), { replace: true })
}, [backTarget, navigate])
// 读取宿主机的「直连解码」开关。开启时全程 direct play,不走 HLS。
useEffect(() => {
systemAPI
.info()
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
.catch(() => setDirectOnly(false))
.finally(() => setDirectOnlyKnown(true))
}, [])
// 每次进入播放器都从数据库刷新用户的字幕转换偏好;本地登录缓存只用作首屏初值。
useEffect(() => {
let cancelled = false
profileAPI
.get()
.then((user) => {
if (cancelled || subtitlePreferenceTouchedRef.current) return
const savedMode = normalizeSubtitleChineseMode(user.subtitle_chinese_mode)
persistedSubtitleChineseModeRef.current = savedMode
setSubtitleChineseMode(savedMode)
setAuthUser(user)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [setAuthUser])
const applyDanmakuConnectionConfig = useCallback((cfg: DanmakuConfig) => {
setDanmakuSource(cfg.source ?? '')
setDanmakuAppID(cfg.app_id ?? '')
setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured))
}, [])
const saveDanmakuSettings = useCallback(
async (patch: DanmakuSettingsPatch, failureMessage = '播放器设置保存失败,请重试') => {
setPlayerSettingsSaving(true)
try {
const cfg = await danmakuAPI.updateSettings(patch)
applyDanmakuConnectionConfig(cfg)
return cfg
} catch (error) {
toast.error(failureMessage)
throw error
} finally {
setPlayerSettingsSaving(false)
}
},
[applyDanmakuConnectionConfig],
)
// 一次读取当前用户的音量、弹幕渲染参数、服务地址和凭据配置状态。
useEffect(() => {
let cancelled = false
danmakuAPI
.config()
.then((cfg) => {
if (cancelled) return
const volume = normalizePlayerVolume(cfg.volume)
if (!playerVolumeTouchedRef.current) {
setPlayerVolume(volume)
}
setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24)
setDanmakuArea(Number(cfg.area) || 1)
setDanmakuMergeSources(Boolean(cfg.merge_sources))
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
applyDanmakuConnectionConfig(cfg)
if (!playerVolumeTouchedRef.current) {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: volume })
}
})
.catch(() => {
// 配置读取失败时保持本地默认值,不影响播放。
})
return () => {
cancelled = true
}
}, [applyDanmakuConnectionConfig, setAuthUser, authUser?.id])
const changePlayerVolume = useCallback((next: number) => {
playerVolumeTouchedRef.current = true
setPlayerVolume(normalizePlayerVolume(next))
}, [])
const commitPlayerVolume = useCallback(
(next: number) => {
playerVolumeTouchedRef.current = true
const volume = normalizePlayerVolume(next)
setPlayerVolume(volume)
void danmakuAPI
.updateSettings({ volume })
.then((cfg) => {
const saved = normalizePlayerVolume(cfg.volume)
setPlayerVolume(saved)
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: saved })
})
.catch(() => {
toast.error('音量保存失败,请重试')
})
},
[setAuthUser],
)
// 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
const changePlayerPlaybackRate = useCallback((next: number) => {
setPlayerPlaybackRate(normalizePlaybackRate(next))
}, [])
const saveDanmakuAdvanced = useCallback(
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
const patch: DanmakuSettingsPatch = {
source: values.source.trim(),
app_id: values.appId.trim(),
}
if (values.clearAppKey) {
patch.clear_app_key = true
} else if (values.appKey.trim()) {
patch.app_key = values.appKey.trim()
}
const cfg = await saveDanmakuSettings(patch, '弹幕服务设置保存失败,请重试')
setDanmakuSource(cfg.source ?? '')
setDanmakuAppID(cfg.app_id ?? '')
setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured))
// 地址或凭据变化后立即按新配置重新抓取当前对象的弹幕。
setDanmakuSearching(true)
setDanmakuSearchTrigger((prev) => prev + 1)
toast.success('弹幕服务设置已保存')
},
[saveDanmakuSettings],
)
// 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
const danmakuChangeMergeSources = useCallback(
(next: boolean) => {
setDanmakuMergeSaving(true)
saveDanmakuSettings({ merge_sources: next })
.then(() => {
setDanmakuMergeSources(next)
setDanmakuSearching(true)
setDanmakuSearchTrigger((prev) => prev + 1)
})
.catch(() => undefined)
.finally(() => setDanmakuMergeSaving(false))
},
[saveDanmakuSettings],
)
const danmakuChangeEnabled = useCallback(
(next: boolean) => {
setDanmakuEnabled(next)
void saveDanmakuSettings({ enabled: next }).catch(() => undefined)
},
[saveDanmakuSettings],
)
const commitDanmakuArea = useCallback(
(next: number) => {
void saveDanmakuSettings({ area: next }).catch(() => undefined)
},
[saveDanmakuSettings],
)
const commitDanmakuOpacity = useCallback(
(next: number) => {
void saveDanmakuSettings({ opacity: next }).catch(() => undefined)
},
[saveDanmakuSettings],
)
const commitDanmakuFontSize = useCallback(
(next: number) => {
void saveDanmakuSettings({ font_size: next }).catch(() => undefined)
},
[saveDanmakuSettings],
)
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
const searchDanmaku = useCallback((kw: string) => {
setDanmakuSearching(true)
setDanmakuCandidates([])
setDanmakuAlternatives([])
setDanmakuEpisodeId(null)
setDanmakuInfo(null)
setDanmakuSearch(kw || null)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => {
setDanmakuSearching(false)
setDanmakuInfo(info)
}, [])
// 同一集的其它来源:弹幕已自动加载好,这里只更新可切换列表。
const danmakuGotAlternatives = useCallback((alternatives: DanmakuAnime[]) => {
setDanmakuAlternatives(alternatives)
}, [])
// 多番剧命中(disambiguation):展示候选让用户选择。
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
setDanmakuCandidates(candidates)
setDanmakuSearching(false)
setDanmakuInfo(null)
// 候选是静默返回的(此时没有任何弹幕);自动打开面板提示用户选择来源。
if (candidates.length > 0) setDanmakuOpen(true)
}, [])
// 用户选定某个弹幕库:显式 episodeId 重新拉取。
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
setDanmakuEpisodeId(episodeId)
setDanmakuCandidates([])
// 刻意不清空 danmakuAlternatives:切到别的来源后仍要能继续切换回去,
// 否则用户每次都得重新搜一遍。
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
// 回到自动匹配(清除用户手动选择)。
const danmakuResetAuto = useCallback(() => {
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuAlternatives([])
setDanmakuSearching(true)
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
useEffect(() => {
setMedia(null)
setLoadError('')
vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false
vr360TextureRetryUntilRef.current = 0
setVr360(null)
setVr360Detected(false)
setHlsStartSec(0)
setPlaybackInfo(null)
setHlsSource('local')
setSelectedQuality('')
setCloudWaiting(false)
setCloudWaitMessage('')
setCloudWaitStartedAt(0)
pendingSeekRef.current = null
if (cloudPollTimerRef.current) {
clearTimeout(cloudPollTimerRef.current)
cloudPollTimerRef.current = null
}
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuAlternatives([])
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearching(true)
directRetryRef.current = false
retryingDirectRef.current = false
directSeekGraceUntilRef.current = 0
pendingDirectSeekRef.current = null
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
// 新片子清掉上一部失败回退留下的 ?mode=hls,避免「一直 HLS」。
ignoreModeParamRef.current = true
setMode('direct')
const nextParams = new URLSearchParams(window.location.search)
if (nextParams.has('mode')) {
nextParams.delete('mode')
setParams(nextParams, { replace: true })
}
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
return () => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
}
}, [id, setParams])
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
const modeParam = params.get('mode') as PlayerMode | null
const setPlaybackMode = useCallback(
(next: PlayerMode) => {
setMode(next)
const nextParams = new URLSearchParams(window.location.search)
nextParams.set('mode', next)
setParams(nextParams, { replace: true })
},
[setParams],
)
const clearFallbackTimer = useCallback(() => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
}, [])
const clearDirectSeekWait = useCallback(() => {
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
}, [])
/**
* STRM/115 直连跳转:等 seekable 覆盖目标再写 currentTime,避免被钳回 0;
* 同时打开误报宽限期,防止 seek 触发 video.load() 从头播。
*/
const seekStrmDirectTo = useCallback(
(absoluteSec: number, options?: { onSettled?: () => void }) => {
const video = ref.current
if (!video) return false
const target = Math.max(0, absoluteSec)
pendingDirectSeekRef.current = target
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
directRetryRef.current = false
clearFallbackTimer()
clearDirectSeekWait()
let pollTimer: ReturnType<typeof setInterval> | null = null
let finishTimer: ReturnType<typeof setTimeout> | null = null
let forced = false
let forceAttempts = 0
let applied = false
let toastShown = false
const startedAt = Date.now()
const cleanup = () => {
video.removeEventListener('progress', tick)
video.removeEventListener('durationchange', tick)
video.removeEventListener('loadedmetadata', tick)
video.removeEventListener('seeked', onSeeked)
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
if (finishTimer) {
clearTimeout(finishTimer)
finishTimer = null
}
if (directSeekWaitCleanupRef.current === cleanup) {
directSeekWaitCleanupRef.current = null
}
}
const settleIfMatched = () => {
const el = ref.current
if (!el || pendingDirectSeekRef.current === null) return false
const want = pendingDirectSeekRef.current
if (Math.abs(el.currentTime - want) <= 2.5) {
pendingDirectSeekRef.current = null
cleanup()
options?.onSettled?.()
return true
}
return false
}
const writeCurrentTime = (want: number) => {
const el = ref.current
if (!el) return false
selfSkipSeekRef.current = true
try {
el.currentTime = want
} catch {
return false
}
applied = true
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
return true
}
const onSeeked = () => {
if (settleIfMatched()) return
const el = ref.current
if (!el || pendingDirectSeekRef.current === null) return
const want = pendingDirectSeekRef.current
if (el.currentTime < 1.5 && want > 3) {
// 被钳回 0:允许在 seekable 就绪后再写一次。
applied = false
forced = false
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
if (canSeekDirectNow(el.seekable, want)) {
writeCurrentTime(want)
}
}
}
const tick = () => {
const el = ref.current
if (!el || pendingDirectSeekRef.current === null) {
cleanup()
return
}
const want = pendingDirectSeekRef.current
if (!applied && canSeekDirectNow(el.seekable, want)) {
writeCurrentTime(want)
return
}
if (!toastShown && Date.now() - startedAt > 600) {
toastShown = true
toast('正在定位播放位置…', { duration: 2200 })
}
if (!applied && !forced && Date.now() - startedAt >= DIRECT_SEEK_WAIT_MS) {
if (forceAttempts >= 2) {
pendingDirectSeekRef.current = null
cleanup()
return
}
forced = true
forceAttempts += 1
writeCurrentTime(want)
finishTimer = setTimeout(() => {
if (!settleIfMatched() && forceAttempts >= 2) {
pendingDirectSeekRef.current = null
cleanup()
} else if (pendingDirectSeekRef.current !== null) {
forced = false
applied = false
}
}, 2500)
}
}
video.addEventListener('progress', tick)
video.addEventListener('durationchange', tick)
video.addEventListener('loadedmetadata', tick)
video.addEventListener('seeked', onSeeked)
pollTimer = setInterval(tick, 350)
directSeekWaitCleanupRef.current = cleanup
tick()
return true
},
[clearDirectSeekWait, clearFallbackTimer],
)
// Load metadata and pick a default mode.
useEffect(() => {
if (!id) return
let cancelled = false
mediaAPI
.get(id)
.then((m) => {
if (cancelled) return
setMedia(m)
setPlayerError('')
setLoadError('')
const isDirect = isDirectStreamMedia(m)
const auto = pickPlayerMode(m)
// 直连解码 / 远程 Emby:强制直连。
// 不把 modeParam 放进依赖:失败回退写 ?mode=hls 时不应重拉 metadata,
// 也不要用滞后的 get 回调把 hls 盖回 direct。
if (directOnly || isDirect) {
setMode('direct')
return
}
setMode((prev) => (prev === 'hls' ? prev : auto))
})
.catch((err: unknown) => {
if (cancelled) return
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
})
return () => {
cancelled = true
}
}, [id, directOnly])
// 仅在用户手动切换 / 回退写入 URL 后,把 ?mode= 同步回 state(切集时 id effect 已清掉旧 mode)。
useEffect(() => {
if (ignoreModeParamRef.current) {
ignoreModeParamRef.current = false
return
}
if (directOnly) {
setMode('direct')
return
}
if (modeParam !== 'direct' && modeParam !== 'hls') return
setMode(modeParam)
}, [modeParam, directOnly, id])
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
useEffect(() => {
if (!media || media.id !== id) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: media.width,
height: media.height,
})
setVr360Detected(detection.confident)
if (vr360TouchedRef.current) return
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) {
setVr360(null)
return
}
if (detection.profile.projection !== preference.profile.projection) {
// 投影方式由文件名决定(360 / 180 / 鱼眼),记住它作为下次的默认值。
saveVr360Preference({ ...preference, profile: detection.profile })
}
setVr360(detection.profile)
}, [id, media])
// 远端 Emby 挂载的媒体常常没有宽高元数据(width/height 为 0),等播放器拿到
// 真实画面尺寸后再补一次识别。HLS 转码是等比缩放,所以比例依然可信。
useEffect(() => {
if (!media || media.id !== id) return
if (media.width > 0 && media.height > 0) return
const video = ref.current
if (!video) return
const refine = () => {
if (vr360TouchedRef.current || video.videoWidth <= 0 || video.videoHeight <= 0) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: video.videoWidth,
height: video.videoHeight,
})
setVr360Detected(detection.confident)
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) return
setVr360((current) =>
current && sameVr360Profile(current, detection.profile) ? current : detection.profile,
)
}
video.addEventListener('loadedmetadata', refine)
refine()
return () => video.removeEventListener('loadedmetadata', refine)
}, [id, media])
const toggleVr360 = useCallback(() => {
vr360TouchedRef.current = true
if (vr360) {
setVr360(null)
return
}
const current = mediaRef.current
const detection: Vr360Detection | null = current
? detectVr360Profile({
title: current.title,
originalName: current.original_name,
path: current.path,
relativePath: current.relative_path,
width: current.width,
height: current.height,
})
: null
const preference = loadVr360Preference()
const profile = detection?.profile ?? preference.profile
saveVr360Preference({ ...preference, profile })
setVr360(profile)
}, [vr360])
const changeVr360Profile = useCallback((profile: Vr360Profile) => {
vr360TouchedRef.current = true
setVr360(profile)
saveVr360Preference({ ...loadVr360Preference(), profile })
}, [])
// VR 操作说明是「按用户只弹一次」:用户关掉说明时就落库,之后进入 VR 不再显示。
// 保存失败只影响下次是否再弹一次,不打断播放,所以本地状态不回滚。
const dismissVr360Guide = useCallback(() => {
setVr360GuideSeen(true)
void danmakuAPI.updateSettings({ vr360_guide_seen: true }).catch(() => undefined)
}, [])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const playbackProvider = playbackInfo?.provider
// 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
// 倍速被顺手带到下一集。
useEffect(() => {
setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
}, [mediaId])
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => {
if (!mediaId) return
let cancelled = false
mediaAPI
.playbackInfo(mediaId)
.then((info) => {
if (cancelled) return
setPlaybackInfo(info)
cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5)
setSelectedQuality((prev) => {
if (prev && findPlaybackQualityById(info, prev)) return prev
return info.default_quality || info.local_qualities?.[0]?.id || ''
})
})
.catch(() => {
if (!cancelled) setPlaybackInfo(null)
})
return () => {
cancelled = true
}
}, [mediaId])
useEffect(() => {
cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5)
}, [playbackInfo])
// ── 片头/片尾跳过 ──────────────────────────────────────────────────────────
// 原始片段(服务端单位)与当前生效档案的「自动跳过片头」开关。
const [rawSkipSegments, setRawSkipSegments] = useState<PlaybackSegment[]>([])
const [autoSkipIntro, setAutoSkipIntro] = useState(false)
// 当前落进的跳过提示;不在任何区间时为 null。
const [activeSkip, setActiveSkip] = useState<SkipPrompt | null>(null)
// 自动跳过后的撤销提示。
const [skipNotice, setSkipNotice] = useState<{
text: string
startSec: number
kind: PlaybackSegmentKind
} | null>(null)
// 用户已经处理过的区间类型:本次播放内不再重复提示。
const [dismissedSkipKinds, setDismissedSkipKinds] = useState<PlaybackSegmentKind[]>([])
// 用户主动跳进过片头/片尾区间(多半是想重看):本次播放不再自动跳过该类型,
// 但按钮保留,想跳随时可以点。
const [autoSuppressedKinds, setAutoSuppressedKinds] = useState<PlaybackSegmentKind[]>([])
const skipNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 标记「这次 seeked 是我们自己跳的」,避免把自己的跳转当成用户手动跳转。
const selfSkipSeekRef = useRef(false)
// end_ms 为 0 的区间要用媒体总时长补齐,而时长可能晚于片段到达(STRM/HLS 起播
// 后才回填 duration_sec),所以派生放在这里,时长更新后区间会自动重算。
const skipSegments = useMemo(
() => toSkipSegments(rawSkipSegments, media?.duration_sec || 0),
[rawSkipSegments, media?.duration_sec],
)
useEffect(() => {
if (!mediaId) return
let cancelled = false
setRawSkipSegments([])
setAutoSkipIntro(false)
setActiveSkip(null)
setSkipNotice(null)
setDismissedSkipKinds([])
setAutoSuppressedKinds([])
// 片段数据与播放来源无关,播放开始后异步补抓即可,绝不挡在起播路径上。
playbackAPI
.segments(mediaId)
.then((res) => {
if (cancelled) return
setAutoSkipIntro(Boolean(res.auto_skip))
setRawSkipSegments(res.segments ?? [])
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [mediaId])
useEffect(() => {
return () => {
if (skipNoticeTimerRef.current) clearTimeout(skipNoticeTimerRef.current)
}
}, [])
const switchToLocalHLS = useCallback(
(position = 0) => {
setHlsSource('local')
setCloudWaiting(false)
setSelectedQuality((prev) => {
if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev
return defaultLocalQualityId(playbackInfo)
})
if (position > 2) setHlsStartSec(position)
setPlaybackMode('hls')
},
[playbackInfo, setPlaybackMode],
)
const startCloudTranscode = useCallback(
async (definition: number) => {
if (!mediaId) return
try {
const info = await mediaAPI.startCloudTranscode(mediaId, definition)
setPlaybackInfo(info)
if (info.transcode.state === 'ready') {
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsSource('cloud')
setPlaybackMode('hls')
return
}
setCloudWaiting(true)
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
setCloudWaitStartedAt(Date.now())
} catch {
switchToLocalHLS()
toast.error('115 云端转码触发失败,已切换本地转码')
}
},
[mediaId, setPlaybackMode, switchToLocalHLS],
)
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
const enterHlsPlayback = useCallback(
(startSec: number) => {
setHlsStartSec(startSec)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS(startSec)
}
setPlaybackMode('hls')
},
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
)
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
useEffect(() => {
if (!cloudWaiting || !mediaId) return
let cancelled = false
const poll = async () => {
if (cancelled) return
if (cloudWaitStartedAt > 0 && Date.now() - cloudWaitStartedAt > 10 * 60 * 1000) {
switchToLocalHLS()
toast.error('115 云端转码等待超时,已切换本地转码')
return
}
try {
const definition = Number(selectedQuality) || undefined
const info = await mediaAPI.playbackInfo(mediaId, definition)
if (cancelled) return
setPlaybackInfo(info)
cloudRetryRef.current = Math.max(3, info.transcode.retry_after_sec || 5)
if (info.transcode.state === 'ready') {
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsSource('cloud')
setPlaybackMode('hls')
return
}
if (info.transcode.state === 'unavailable') {
switchToLocalHLS()
toast.error(info.transcode.message || '115 云端转码不可用,已切换本地转码')
return
}
setCloudWaitMessage(info.transcode.message || '115 正在转码…')
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
} catch {
cloudPollTimerRef.current = setTimeout(poll, 5000)
}
}
cloudPollTimerRef.current = setTimeout(poll, cloudRetryRef.current * 1000)
return () => {
cancelled = true
if (cloudPollTimerRef.current) {
clearTimeout(cloudPollTimerRef.current)
cloudPollTimerRef.current = null
}
}
}, [cloudWaiting, cloudWaitStartedAt, mediaId, selectedQuality, setPlaybackMode, switchToLocalHLS])
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
useEffect(() => {
if (!mediaId) return
let cancelled = false
subtitlesAPI
.list(mediaId, mode === 'hls')
.then((tracks) => {
if (cancelled) return
const list = tracks ?? []
setSubs(list)
// 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。
setSubtitleIndex(list.length > 0 ? 0 : -1)
})
.catch(() => {
if (cancelled) return
setSubs([])
setSubtitleIndex(-1)
})
return () => {
cancelled = true
}
}, [mediaId, mode])
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const burnedSubtitleStream =
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
// 直连不使用烧录字幕参数。字幕列表通常比媒体信息晚返回,若把该参数直接
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
const mediaRef = useRef(media)
useEffect(() => {
mediaRef.current = media
}, [media])
useEffect(() => {
if (!mediaId || !ref.current) return
const currentMedia = mediaRef.current
if (!currentMedia) return
let cancelled = false
teardownHls()
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
if (hlsSource === 'cloud' && cloudWaiting) {
teardownHls()
return
}
const url =
hlsSource === 'cloud'
? cloudHlsURL(mediaId, selectedQuality)
: hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream, selectedQuality)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
const hls = new HlsCtor({
enableWorker: true,
lowLatencyMode: false,
// The server waits up to 45s for the first segment. Slow two-core
// 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,
})
try {
video.currentTime = 0
} catch {
// ignore
}
hls.loadSource(url)
hls.attachMedia(video)
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
if (pendingSeekRef.current !== null) {
const target = pendingSeekRef.current
pendingSeekRef.current = null
try {
video.currentTime = target
} catch {
// ignore
}
}
void video.play().catch(() => undefined)
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
if (durationSec > 0) return
mediaAPI
.get(mediaId)
.then((fresh) => {
if (cancelled || fresh.id !== mediaId) return
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
})
.catch(() => undefined)
})
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
if (hlsSource === 'cloud') {
const position = ref.current?.currentTime || 0
setHlsUnavailable(false)
switchToLocalHLS(position)
toast.error('115 云端播放失败,切换本地转码')
return
}
setHlsUnavailable(true)
if (playbackProvider === 'cloud115') {
setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
toast.error('115 云端和本地转码均不可用')
return
}
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setPlaybackMode('direct')
}
})
if (cancelled) {
hls.destroy()
return
}
hlsRef.current = hls
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
try {
video.currentTime = 0
} catch {
// ignore
}
video.src = url
void video.play().catch(() => undefined)
} else {
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setPlaybackMode('direct')
}
}).catch(() => {
if (cancelled) return
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
setPlaybackMode('direct')
})
} else {
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
try {
video.currentTime = resumeAt
} catch {
// ignore
}
},
{ once: true },
)
}
video.src = url
void video.play().catch(() => undefined)
}
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
}
const onPlaying = () => clearFallbackTimer()
video.addEventListener('playing', onPlaying)
return () => {
cancelled = true
video.removeEventListener('playing', onPlaying)
teardownHls()
}
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
cloudWaiting,
hlsSource,
hlsUnavailable,
hlsStartSec,
mediaId,
mode,
playbackProvider,
selectedQuality,
setPlaybackMode,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
useEffect(() => {
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
return () => {
stopHLSJob(mediaId)
}
}, [hlsSource, mediaId, mode])
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
// while the document can still dispatch a keepalive request.
useEffect(() => {
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [hlsSource, mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
if (!id) return
setResumePosition(0)
setInitialSeekDone(false)
playbackAPI
.getResume(id)
.then((progress) => {
if (progress.position_ms > 2000 && !progress.completed) {
setResumePosition(progress.position_ms / 1000)
}
})
.catch(() => undefined)
}, [id])
useEffect(() => {
if (!resumePosition || initialSeekDone) return
if (mode === 'hls') {
// Restart transcode near the resume point instead of seeking a short partial playlist.
if (Math.abs(hlsStartSec - resumePosition) > 2) {
setHlsStartSec(resumePosition)
}
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
return
}
const video = ref.current
if (!video) return
let cancelled = false
let seekStarted = false
let onPlaying: (() => void) | undefined
let onCanPlay: (() => void) | undefined
const notifyResume = () => {
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
}
const performResumeSeek = () => {
if (cancelled || seekStarted) return
if (Math.abs(video.currentTime - resumePosition) <= 2) {
seekStarted = true
setInitialSeekDone(true)
return
}
seekStarted = true
if (isStrmMedia(mediaRef.current)) {
seekStrmDirectTo(resumePosition, {
onSettled: () => {
if (!cancelled) {
setInitialSeekDone(true)
notifyResume()
}
},
})
// 即使定位较慢,也不要卡住「未完成续播」状态太久。
window.setTimeout(() => {
if (!cancelled) setInitialSeekDone(true)
}, DIRECT_SEEK_WAIT_MS + 3000)
return
}
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
directRetryRef.current = false
clearFallbackTimer()
const onSeeked = () => {
setInitialSeekDone(true)
notifyResume()
}
video.addEventListener('seeked', onSeeked, { once: true })
try {
video.currentTime = resumePosition
} catch {
setInitialSeekDone(true)
}
}
// STRM/云盘:等真正起播后再续播跳转,避免 302 未完成就 seek。
const deferForStrm = isStrmMedia(mediaRef.current)
if (deferForStrm) {
onPlaying = () => {
performResumeSeek()
}
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
performResumeSeek()
} else {
video.addEventListener('playing', onPlaying, { once: true })
}
} else if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
performResumeSeek()
} else {
onCanPlay = () => {
performResumeSeek()
}
video.addEventListener('canplay', onCanPlay, { once: true })
}
return () => {
cancelled = true
if (onPlaying) video.removeEventListener('playing', onPlaying)
if (onCanPlay) video.removeEventListener('canplay', onCanPlay)
}
}, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
const hlsStartSecRef = useRef(hlsStartSec)
const modeRef = useRef(mode)
useEffect(() => {
hlsStartSecRef.current = hlsStartSec
modeRef.current = mode
}, [hlsStartSec, mode])
// Persist resume position every 10 seconds while playing, and immediately upon
// pause/page hide/unmount. Bind after mediaId is available because
// PlayerVideoStage does not render the <video> element until media is loaded.
useEffect(() => {
if (!id || !mediaId || !ref.current) return
const video = ref.current
if (progressSessionRef.current?.mediaId !== mediaId) {
progressSessionRef.current = newPlaybackProgressSession(mediaId)
lastSentRef.current = 0
}
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = currentStartSec + (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)
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
}
const send = (keepalive: boolean) => {
const currentMedia = mediaRef.current
if (!currentMedia || currentMedia.id !== mediaId) return
const now = Date.now()
if (!keepalive && now - lastSentRef.current < 10_000) return
lastSentRef.current = now
const positionMs = absolutePositionMs()
if (positionMs <= 0) return
const durationMs = absoluteDurationMs()
const session = progressSessionRef.current
if (!session || session.mediaId !== mediaId) return
session.sequence += 1
const payload = {
media_id: currentMedia.id,
position_ms: positionMs,
duration_ms: durationMs,
session_id: session.id,
session_started_at_ms: session.startedAtMs,
sequence: session.sequence,
}
if (keepalive) {
postPlaybackProgressKeepalive(payload)
return
}
playbackAPI.recordProgress(payload).catch(() => undefined)
}
const onTimeUpdate = () => send(false)
const onPause = () => send(true)
const onPageHide = () => send(true)
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') send(true)
}
video.addEventListener('timeupdate', onTimeUpdate)
video.addEventListener('pause', onPause)
window.addEventListener('pagehide', onPageHide)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
video.removeEventListener('timeupdate', onTimeUpdate)
video.removeEventListener('pause', onPause)
window.removeEventListener('pagehide', onPageHide)
document.removeEventListener('visibilitychange', onVisibilityChange)
send(true)
}
}, [id, mediaId])
// 加载剧集/播放列表
useEffect(() => {
if (!id) return
let canceled = false
mediaAPI
.getEpisodes(id)
.then((res) => {
if (canceled) return
setPlaylistEpisodes(res.items ?? [])
})
.catch(() => {
if (canceled) return
if (media && (media.display_library_id || media.library_id)) {
const libId = media.display_library_id || media.library_id
const seriesKey = getSeriesKey(media)
if (seriesKey) {
libraryAPI
.listSeriesEpisodes(libId, seriesKey)
.then((res) => {
if (!canceled) setPlaylistEpisodes(res.items ?? [])
})
.catch(() => {
if (!canceled) setPlaylistEpisodes([])
})
return
}
}
setPlaylistEpisodes([])
})
return () => {
canceled = true
}
}, [id, media])
// 选集列表按版本组折叠,行的 id 是组内最优版本;用户切到同一条目的其它
// 版本后 media.id 与行 id 不再相等,这里必须按版本组比对,否则上一集/下一集
// 与自动连播都会失效。
const currentEpisodeIndex = useMemo(() => {
if (!media || playlistEpisodes.length === 0) return -1
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
}, [media, playlistEpisodes])
const prevEpisode = useMemo(() => {
if (currentEpisodeIndex > 0) {
return playlistEpisodes[currentEpisodeIndex - 1]
}
return null
}, [currentEpisodeIndex, playlistEpisodes])
const nextEpisode = useMemo(() => {
if (currentEpisodeIndex >= 0 && currentEpisodeIndex < playlistEpisodes.length - 1) {
return playlistEpisodes[currentEpisodeIndex + 1]
}
return null
}, [currentEpisodeIndex, playlistEpisodes])
const prevEpisodeTitle = useMemo(() => {
return prevEpisode ? formatEpisodeDisplay(prevEpisode, playlistEpisodes) : ''
}, [prevEpisode, playlistEpisodes])
const nextEpisodeTitle = useMemo(() => {
return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : ''
}, [nextEpisode, playlistEpisodes])
// ── 片头/片尾跳过:位置判定 ────────────────────────────────────────────────
// 只记录「提示是否变化」,避免 timeupdate(约每秒 4 次)每次都写 state 重渲染。
const activeSkipKeyRef = useRef('')
// 监听播放位置,判断当前是否落在某个可跳过区间里。
useEffect(() => {
const video = ref.current
if (!video || skipSegments.length === 0) return
const sync = () => {
// HLS 转码时 <video>.currentTime 是相对转码起点的,必须把起点加回来。这里与
// 进度上报用的算法保持一致,否则转码场景下整段判断都会错位。
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = offset + (video.currentTime || 0)
const next = resolveActiveSkip(absolute, skipSegments, {
hasNextEpisode: Boolean(nextEpisode),
excludedKinds: dismissedSkipKinds,
})
const key = next ? `${next.kind}:${next.startSec}` : ''
if (key === activeSkipKeyRef.current) return
activeSkipKeyRef.current = key
setActiveSkip(next)
}
sync()
video.addEventListener('timeupdate', sync)
return () => video.removeEventListener('timeupdate', sync)
}, [skipSegments, nextEpisode, dismissedSkipKinds, mediaId])
// 用户主动跳进片头/回顾区间(多半是想重看):本次播放不再自动跳过该类型,
// 但按钮保留。只关心会被自动跳过的两种类型。
useEffect(() => {
const video = ref.current
if (!video || skipSegments.length === 0) return
const onSeeked = () => {
if (selfSkipSeekRef.current) {
selfSkipSeekRef.current = false
return
}
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = offset + (video.currentTime || 0)
const hit = skipSegments.find(
(segment) =>
(segment.kind === 'intro' || segment.kind === 'recap') &&
absolute >= segment.startSec &&
absolute < segment.endSec,
)
if (!hit) return
setAutoSuppressedKinds((prev) => (prev.includes(hit.kind) ? prev : [...prev, hit.kind]))
}
video.addEventListener('seeked', onSeeked)
return () => video.removeEventListener('seeked', onSeeked)
}, [skipSegments, mediaId])
const playEpisode = useCallback(
(target: Media) => {
navigate(
{
pathname: `/play/${target.id}`,
search: location.search,
},
{ state: location.state },
)
},
[navigate, location.search, location.state],
)
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
const currentVersions = useMemo(
() => (media?.id === id ? mediaVersionsOf(media) : []),
[id, media],
)
const switchVersion = useCallback(
(version: Media) => {
if (!version?.id || version.id === media?.id) return
playEpisode(version)
},
[media?.id, playEpisode],
)
const handlePrevEpisode = useCallback(() => {
if (prevEpisode) {
playEpisode(prevEpisode)
}
}, [prevEpisode, playEpisode])
const handleNextEpisode = useCallback(() => {
if (nextEpisode) {
playEpisode(nextEpisode)
}
}, [nextEpisode, playEpisode])
const togglePlaylistOpen = useCallback(() => {
setPlaylistOpen((prev) => {
const next = !prev
if (next) setDanmakuOpen(false)
return next
})
setEpisodeRevealToken((token) => token + 1)
}, [])
// 操作栏 / 设置面板里的「选集」入口。竖屏剧场模式下选集区就在视频下方,
// 这里只负责展开并滚过去,不把已经展开的列表收起来(收起由选集区自己的
// 「收起」按钮负责),这样播放画面永远不会被浮层挡住。
const openPlaylistEntry = useCallback(() => {
setPlaylistOpen(true)
setDanmakuOpen(false)
setEpisodeRevealToken((token) => token + 1)
}, [])
const toggleDanmakuOpen = useCallback(() => {
setDanmakuOpen((prev) => {
const next = !prev
if (next) setPlaylistOpen(false)
return next
})
}, [])
// 视频播放结束时自动播放下一集
useEffect(() => {
if (!ref.current || !nextEpisode) return
const video = ref.current
const onEnded = () => {
toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`)
playEpisode(nextEpisode)
}
video.addEventListener('ended', onEnded)
return () => {
video.removeEventListener('ended', onEnded)
}
}, [nextEpisode, nextEpisodeTitle, playEpisode])
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集。
// 左右方向键的回退/快进在播放器控制栏里处理。
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return
}
if (e.key === 'Escape') {
if (playlistOpen) {
setPlaylistOpen(false)
return
}
if (danmakuOpen) {
setDanmakuOpen(false)
return
}
goBack()
} else if (e.key === '[' || (e.shiftKey && e.key.toLowerCase() === 'p')) {
if (prevEpisode) {
e.preventDefault()
handlePrevEpisode()
}
} else if (e.key === ']' || (e.shiftKey && e.key.toLowerCase() === 'n')) {
if (nextEpisode) {
e.preventDefault()
handleNextEpisode()
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
const isDirectStream = isDirectStreamMedia(media)
const qualityOptions =
playbackInfo?.provider === 'cloud115'
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
: (playbackInfo?.local_qualities ?? [])
// 直连解码模式下宿主机不转码,档位选择没有意义(远程 Emby 挂载同理):直接隐藏。
const showQuality = !directOnly && qualityOptions.length > 0
// 清晰度按钮上显示的文字。直接播放时按「原画」呈现,和档位列表里的原画项一致,
// 避免出现「明明是原文件却显示 1080P」这种误导。
const originalQualityLabel =
qualityOptions.find((quality) => quality.source === 'original')?.label || '原画'
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
const qualityLabel =
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
const playbackModeLabel = isDirectStream
? isRemoteEmbyID(media?.id)
? 'Emby 直连播放'
: '直连播放'
: directOnly
? '客户端直连解码'
: mode === 'hls'
? 'HLS 转码'
: '直接播放'
const canTogglePlaybackMode = !isDirectStream && !directOnly
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
const topBarTitle = media?.title?.trim() || ''
const topBarSubtitle = useMemo(() => {
if (!media) return ''
const parts: string[] = []
if (prevEpisode || nextEpisode) {
const total = playlistEpisodes.length
const index = currentEpisodeIndex + 1
parts.push(total > 0 ? `第 ${index} / ${total} 集` : `第 ${index} 集`)
}
if (currentVersions.length > 1) parts.push(`${currentVersions.length} 个版本`)
return parts.join(' · ')
}, [
media,
currentEpisodeIndex,
currentVersions.length,
nextEpisode,
playlistEpisodes.length,
prevEpisode,
])
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
useEffect(() => {
if (
!directOnlyKnown ||
directOnly ||
isDirectStream ||
selectedSubtitle?.delivery !== 'burn' ||
mode === 'hls'
) {
return
}
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}, [
directOnly,
directOnlyKnown,
isDirectStream,
mode,
selectedSubtitle?.delivery,
setPlaybackMode,
])
const toggleMode = useCallback(() => {
if (isDirectStream) {
toast('该媒体为直连播放,无需且不支持转码')
return
}
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
enterHlsPlayback(0)
} else {
setCloudWaiting(false)
}
setPlaybackMode(next)
}, [
enterHlsPlayback,
isDirectStream,
mode,
setPlaybackMode,
])
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
const handleVr360Error = useCallback(
(message: string) => {
const now = Date.now()
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
if (now < vr360TextureRetryUntilRef.current) return
const canRetryWithHls =
!vr360TextureRetryUsedRef.current &&
modeRef.current === 'direct' &&
!directOnly &&
!isRemoteEmbyID(mediaRef.current?.id)
if (canRetryWithHls) {
vr360TextureRetryUsedRef.current = true
vr360TextureRetryUntilRef.current = now + 15_000
setPlayerError('')
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
enterHlsPlayback(ref.current?.currentTime || 0)
return
}
vr360TouchedRef.current = true
setVr360(null)
setPlayerError(
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
)
toast.error(message)
},
[directOnly, enterHlsPlayback],
)
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
const video = ref.current
if (!video) return false
const target = Math.max(0, absoluteSec)
const local = target - hlsStartSec
// Live/EVENT HLS during transcoding often reports duration=Infinity.
// Never treat that as "already buffered" or currentTime seeks reset to 0.
const finiteDuration = Number.isFinite(video.duration) ? video.duration : 0
let seekableEnd = 0
if (video.seekable && video.seekable.length > 0) {
try {
seekableEnd = video.seekable.end(video.seekable.length - 1)
} catch {
seekableEnd = 0
}
}
if (!Number.isFinite(seekableEnd)) seekableEnd = 0
const windowEnd = Math.max(finiteDuration, seekableEnd)
if (local >= 0 && windowEnd > 0.5 && local <= windowEnd - 0.5) {
video.currentTime = local
return true
}
setPlayerError('')
toast('正在从该位置重新转码…', { duration: 2000 })
setHlsStartSec(target)
return true
},
[hlsStartSec, mode],
)
// ── 片头/片尾跳过:执行 ────────────────────────────────────────────────────
// 跳到源时间轴上的绝对秒数。HLS 转码时不能直接写 currentTime:本地 HLS 交给
// handleSeekAbsolute(超出缓冲窗口时会从头起一段新转码),云端 HLS 按转码起点换算。
const seekPlaybackTo = useCallback(
(absoluteSec: number) => {
const video = ref.current
if (!video) return
const target = Math.max(0, absoluteSec)
if (mode === 'hls') {
if (hlsSource === 'local') {
handleSeekAbsolute(target)
return
}
selfSkipSeekRef.current = true
video.currentTime = Math.max(0, target - hlsStartSec)
return
}
if (isStrmMedia(mediaRef.current)) {
seekStrmDirectTo(target)
return
}
selfSkipSeekRef.current = true
video.currentTime = target
},
[handleSeekAbsolute, hlsSource, hlsStartSec, mode, seekStrmDirectTo],
)
const handlePlayerSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode === 'hls' && hlsSource === 'local') {
return handleSeekAbsolute(absoluteSec)
}
if (mode === 'direct' && isStrmMedia(mediaRef.current)) {
return seekStrmDirectTo(absoluteSec)
}
return false
},
[handleSeekAbsolute, hlsSource, mode, seekStrmDirectTo],
)
const showSkipNotice = useCallback(
(notice: { text: string; startSec: number; kind: PlaybackSegmentKind }) => {
setSkipNotice(notice)
if (skipNoticeTimerRef.current) clearTimeout(skipNoticeTimerRef.current)
skipNoticeTimerRef.current = setTimeout(() => setSkipNotice(null), SKIP_NOTICE_MS)
},
[],
)
// 执行一次跳过:片尾有下一集就直接进下一集,其余情况跳到区间终点。
const performSkip = useCallback(
(prompt: SkipPrompt) => {
setDismissedSkipKinds((prev) => (prev.includes(prompt.kind) ? prev : [...prev, prompt.kind]))
setActiveSkip(null)
activeSkipKeyRef.current = ''
const isOutro = prompt.kind === 'credits' || prompt.kind === 'preview'
if (isOutro && nextEpisode) {
toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`)
playEpisode(nextEpisode)
return
}
seekPlaybackTo(prompt.endSec)
},
[nextEpisode, nextEpisodeTitle, playEpisode, seekPlaybackTo],
)
const handleSkipClick = useCallback(() => {
if (activeSkip) performSkip(activeSkip)
}, [activeSkip, performSkip])
const handleUndoSkip = useCallback(() => {
const notice = skipNotice
setSkipNotice(null)
if (skipNoticeTimerRef.current) {
clearTimeout(skipNoticeTimerRef.current)
skipNoticeTimerRef.current = null
}
if (!notice) return
// 撤销后本次播放不再自动跳这一段,否则会被立刻再跳一次;同时恢复按钮,
// 用户改主意时还能手动跳。
setAutoSuppressedKinds((prev) => (prev.includes(notice.kind) ? prev : [...prev, notice.kind]))
setDismissedSkipKinds((prev) => prev.filter((kind) => kind !== notice.kind))
seekPlaybackTo(notice.startSec)
}, [seekPlaybackTo, skipNotice])
// 自动跳过只针对片头(含回顾):档案里的开关本身就叫「自动跳过片头」,而且自动
// 跳到结尾会让没开自动连播的用户莫名其妙。片尾始终只提供按钮。
useEffect(() => {
if (!autoSkipIntro || !activeSkip) return
if (activeSkip.kind !== 'intro' && activeSkip.kind !== 'recap') return
if (autoSuppressedKinds.includes(activeSkip.kind)) return
performSkip(activeSkip)
showSkipNotice({
text: skippedNoticeText(activeSkip.kind),
startSec: activeSkip.startSec,
kind: activeSkip.kind,
})
}, [activeSkip, autoSkipIntro, autoSuppressedKinds, performSkip, showSkipNotice])
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
const selectSubtitle = useCallback((index: number) => {
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const nextTrack = index >= 0 ? subs[index] : undefined
if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) {
toast.error('图片字幕需要开启 HLS 转码后才能显示')
return
}
if (nextTrack?.delivery === 'burn') {
// PGS 等图片字幕必须走本地 FFmpeg 烧录,不能交给云端 HLS。
switchToLocalHLS(ref.current?.currentTime || 0)
}
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)
setPlaybackMode('hls')
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex, switchToLocalHLS])
const selectPlaybackQuality = useCallback(
(quality: PlaybackQuality) => {
const video = ref.current
const position = video?.currentTime || 0
if (quality.source === 'original') {
setCloudWaiting(false)
setHlsSource('local')
setPlaybackMode('direct')
return
}
if (quality.source === 'cloud') {
setSelectedQuality(quality.id)
setHlsSource('cloud')
if (quality.available) {
setCloudWaiting(false)
setCloudWaitMessage('')
if (position > 2) pendingSeekRef.current = position
setPlaybackMode('hls')
} else {
setCloudWaiting(true)
setCloudWaitMessage(quality.note || `正在等待 115 转码 ${quality.label}…`)
void startCloudTranscode(Number(quality.id) || 4)
}
return
}
setSelectedQuality(quality.id)
setHlsSource('local')
setCloudWaiting(false)
setCloudWaitMessage('')
// 本地档位不可用(宿主机没有可用的 ffmpeg)时不要进入必然失败的 HLS:
// 保持直连播放并说明怎么修,避免播放器先打一个 500 再回退。
if (!quality.available) {
setPlaybackMode('direct')
toast.error(quality.note || '当前无法进行 HLS 转码,请在「系统设置 → 常规」安装 ffmpeg 后重试')
return
}
if (position > 2) setHlsStartSec(position)
setPlaybackMode('hls')
},
[setPlaybackMode, startCloudTranscode],
)
const changeSubtitleChineseMode = useCallback(
(nextMode: SubtitleChineseMode) => {
subtitlePreferenceTouchedRef.current = true
setSubtitleChineseMode(nextMode)
const save = subtitlePreferenceSaveQueueRef.current.then(async () => {
const user = await profileAPI.update({ subtitle_chinese_mode: nextMode })
persistedSubtitleChineseModeRef.current = nextMode
setAuthUser(user)
toast.success('字幕转换偏好已保存,后续播放将自动沿用')
})
subtitlePreferenceSaveQueueRef.current = save.catch(() => undefined)
void save.catch(() => {
setSubtitleChineseMode((currentMode) =>
currentMode === nextMode
? persistedSubtitleChineseModeRef.current
: currentMode,
)
toast.error('字幕转换偏好保存失败,已恢复上次设置')
})
},
[setAuthUser],
)
const handleVideoError = useCallback(() => {
const video = ref.current
if (mode !== 'direct') {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
return
}
if (retryingDirectRef.current) return
const expectedSrc = mediaId
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
: ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
readyState: video?.readyState ?? 0,
elementSrc: video?.src ?? '',
expectedSrc,
alreadyRetried: directRetryRef.current,
seekGraceActive: Date.now() < directSeekGraceUntilRef.current,
})
if (action === 'ignore') return
if (action === 'retry' && video && mediaId) {
directRetryRef.current = true
retryingDirectRef.current = true
try {
// STRM/115 直连:load() 会清空进度从头播。软恢复即可。
if (isStrmMedia(mediaRef.current) || pendingDirectSeekRef.current !== null) {
const pending = pendingDirectSeekRef.current
void video.play().catch(() => undefined)
if (pending !== null && pending > 1) {
seekStrmDirectTo(pending)
}
} else {
video.load()
void video.play().catch(() => undefined)
}
} finally {
retryingDirectRef.current = false
}
return
}
const fallbackDirectPlay = () => {
if (modeRef.current !== 'direct') return
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (playbackInfo?.provider === 'cloud115') {
const preferred =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
if (preferred?.source === 'cloud' && preferred.available) {
setCloudWaiting(false)
setHlsSource('cloud')
setPlaybackMode('hls')
} else if (preferred?.source === 'cloud') {
setHlsSource('cloud')
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
} else {
switchToLocalHLS(current?.currentTime || 0)
}
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
switchToLocalHLS(current?.currentTime || 0)
}
}
clearFallbackTimer()
// STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判定失败。
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
}, [
clearFallbackTimer,
directOnly,
hlsUnavailable,
mediaId,
mode,
playbackInfo,
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
saveSubtitlePosition(nextPosition)
}, [])
const changeSubtitleStyle = useCallback((nextStyle: SubtitleStylePreset) => {
setSubtitleStyle(nextStyle)
saveSubtitleStyle(nextStyle)
}, [])
const danmakuAutoTitle =
danmakuInfo?.animeTitle ||
media?.original_name?.trim() ||
media?.title?.trim() ||
''
// 竖屏手机走剧场布局:视频贴顶按 16:9 自适应,下方是标题/选集/简介。
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
const isMobileTheater = useIsMobileTheater()
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
title={topBarTitle}
subtitle={topBarSubtitle}
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
mode={mode}
onBack={goBack}
/>
<PlayerVideoStage
theater={isMobileTheater}
media={media}
loadError={loadError}
playerError={playerError}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={selectSubtitle}
subtitleChineseMode={subtitleChineseMode}
onSubtitleChineseModeChange={changeSubtitleChineseMode}
subtitlePosition={subtitlePosition}
onSubtitlePositionChange={changeSubtitlePosition}
subtitleStyle={subtitleStyle}
onSubtitleStyleChange={changeSubtitleStyle}
videoRef={ref}
onVideoError={handleVideoError}
playerVolume={playerVolume}
onPlayerVolumeChange={changePlayerVolume}
onPlayerVolumeCommit={commitPlayerVolume}
playerPlaybackRate={playerPlaybackRate}
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize}
danmakuArea={danmakuArea}
danmakuSearch={danmakuSearch}
danmakuEpisodeId={danmakuEpisodeId}
danmakuSearchTrigger={danmakuSearchTrigger}
danmakuOpen={danmakuOpen}
onOpenDanmaku={toggleDanmakuOpen}
onDanmakuLoaded={danmakuLoaded}
onDanmakuCandidates={danmakuGotCandidates}
onDanmakuAlternatives={danmakuGotAlternatives}
hasPrevEpisode={Boolean(prevEpisode)}
hasNextEpisode={Boolean(nextEpisode)}
onPrevEpisode={handlePrevEpisode}
onNextEpisode={handleNextEpisode}
prevEpisodeTitle={prevEpisodeTitle}
nextEpisodeTitle={nextEpisodeTitle}
playlistOpen={playlistOpen}
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
hasVersions={currentVersions.length > 1}
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={
(mode === 'hls' && hlsSource === 'local') || (mode === 'direct' && isStrmMedia(media))
? handlePlayerSeekAbsolute
: undefined
}
qualities={qualityOptions}
qualityLabel={qualityLabel}
selectedQuality={selectedQuality}
onSelectQuality={selectPlaybackQuality}
showQuality={showQuality}
playbackModeLabel={playbackModeLabel}
onTogglePlaybackMode={canTogglePlaybackMode ? toggleMode : undefined}
waiting={cloudWaiting}
waitingMessage={cloudWaitMessage}
vr360={vr360}
vr360Detected={vr360Detected}
onToggleVr360={toggleVr360}
onVr360ProfileChange={changeVr360Profile}
onVr360Error={handleVr360Error}
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
onDismissVr360Guide={dismissVr360Guide}
skipPrompt={activeSkip}
onSkipPrompt={handleSkipClick}
skipNotice={skipNotice ? { text: skipNotice.text } : null}
onUndoSkip={handleUndoSkip}
playlistPanel={
isMobileTheater
? undefined
: <PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
danmakuPanel={
<PlayerDanmakuPanel
theater={isMobileTheater}
open={danmakuOpen}
onClose={() => setDanmakuOpen(false)}
enabled={danmakuEnabled}
onToggleEnabled={danmakuChangeEnabled}
search={danmakuSearch ?? ''}
onSearch={searchDanmaku}
searching={danmakuSearching}
area={danmakuArea}
onAreaChange={setDanmakuArea}
onAreaCommit={commitDanmakuArea}
opacity={danmakuOpacity}
onOpacityChange={setDanmakuOpacity}
onOpacityCommit={commitDanmakuOpacity}
fontSize={danmakuFontSize}
onFontSizeChange={setDanmakuFontSize}
onFontSizeCommit={commitDanmakuFontSize}
source={danmakuSource}
appId={danmakuAppID}
appKeyConfigured={danmakuAppKeyConfigured}
settingsSaving={playerSettingsSaving}
onSaveAdvanced={saveDanmakuAdvanced}
candidates={danmakuCandidates}
alternatives={danmakuAlternatives}
mergeSources={danmakuMergeSources}
onMergeSourcesChange={danmakuChangeMergeSources}
mergeSaving={danmakuMergeSaving}
selectedSource={danmakuSelectedSource}
autoMatchTitle={danmakuAutoTitle}
danmakuInfo={danmakuInfo}
onSelectEpisode={danmakuSelectEpisode}
onResetAuto={danmakuResetAuto}
/>
}
/>
{isMobileTheater ? (
<PlayerMobileTheaterInfo
media={media?.id === id ? media : null}
title={topBarTitle}
subtitle={topBarSubtitle}
playbackModeLabel={playbackModeLabel}
qualityLabel={showQuality ? qualityLabel : undefined}
episodes={playlistEpisodes}
currentMediaId={media?.id ?? ''}
currentEpisodeIndex={currentEpisodeIndex}
currentVersions={currentVersions}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
playlistOpen={playlistOpen}
onTogglePlaylist={togglePlaylistOpen}
revealToken={episodeRevealToken}
playlistPanel={
<PlayerPlaylistPanel
inline
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
/>
) : null}
</div>
)
}
function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
const title = ep.episode_title?.trim()
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
return title
}
const mediaTitle = ep.title?.trim()
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
return mediaTitle
}
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
}
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
if (!info || !id) return undefined
return [...(info.cloud_qualities ?? []), ...(info.local_qualities ?? [])].find(
(quality) => quality.id === id,
)
}
function defaultLocalQualityId(info: PlaybackInfo | null): string {
const qualities = info?.local_qualities ?? []
for (const id of ['1080', '720', '480', 'source']) {
if (qualities.some((quality) => quality.id === id)) return id
}
return qualities[0]?.id ?? '720'
}
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
const normalized = normalizeEpisodeTitle(title)
if (!normalized) return true
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
const pathTitle = seriesTitleFromPath(ep.path)
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
const siblingTitles = new Set(
siblings
.map((item) => normalizeEpisodeTitle(item.title))
.filter(Boolean),
)
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
}
function normalizeEpisodeTitle(value?: string): string {
return (value ?? '')
.toLowerCase()
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
.replace(/[\s._-]+/g, ' ')
.trim()
}