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 { hlsURL, stopHLSJob, streamURL } from '../api/client' import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } 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 } from '../types' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' 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, type PlayerMode } from './playerPageModel' import { classifyDirectPlayError } from './directPlayError' import { apiErrorMessage } from './StrmManagePage' import { PlayerTopBar } from './PlayerTopBar' import { PlayerVideoStage } from './PlayerVideoStage' import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel' import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel' import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher' import { mediaVersionsOf } from '../utils/mediaVersion' // 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 elements. export function PlayerPage() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() const navigate = useNavigate() const location = useLocation() const ref = useRef(null) const hlsRef = useRef(null) const lastSentRef = useRef(0) const directRetryRef = useRef(false) const retryingDirectRef = useRef(false) const fallbackTimerRef = useRef | null>(null) const [media, setMedia] = useState(null) const [mode, setMode] = useState('direct') const [subs, setSubs] = useState([]) const [subtitleIndex, setSubtitleIndex] = useState(0) const authUser = useAuthStore((state) => state.user) const setAuthUser = useAuthStore((state) => state.setUser) const [subtitleChineseMode, setSubtitleChineseMode] = useState(() => normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode), ) const [subtitlePosition, setSubtitlePosition] = useState(loadSubtitlePosition) const [subtitleStyle, setSubtitleStyle] = useState(loadSubtitleStyle) const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode) const subtitlePreferenceTouchedRef = useRef(false) const subtitlePreferenceSaveQueueRef = useRef>(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) // 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。 const [danmakuOpen, setDanmakuOpen] = useState(false) const [danmakuEnabled, setDanmakuEnabled] = useState(true) const [danmakuSearch, setDanmakuSearch] = useState(null) const [danmakuSearchTrigger, setDanmakuSearchTrigger] = useState(0) const [danmakuSearching, setDanmakuSearching] = useState(false) // 用户从候选列表选定的弹幕库;null = 自动匹配。 const [danmakuEpisodeId, setDanmakuEpisodeId] = useState(null) // 自动匹配歧义(多番剧命中)时的候选列表。 const [danmakuCandidates, setDanmakuCandidates] = useState([]) // 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。 const [danmakuInfo, setDanmakuInfo] = useState(null) // 用户当前选定的弹幕来源描述(面板中展示)。 const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('') const [danmakuOpacity, setDanmakuOpacity] = useState(1) const [danmakuFontSize, setDanmakuFontSize] = useState(24) const [danmakuArea, setDanmakuArea] = useState(1) // 选集 / 播放列表状态 const [playlistEpisodes, setPlaylistEpisodes] = useState([]) const [playlistOpen, setPlaylistOpen] = useState(false) 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]) // 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。 useEffect(() => { danmakuAPI .config() .then((cfg) => { setDanmakuEnabled(cfg.enabled) setDanmakuOpacity(Number(cfg.opacity) || 1) setDanmakuFontSize(Number(cfg.font_size) || 24) setDanmakuArea(Number(cfg.area) || 1) }) .catch(() => undefined) }, []) // 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。 // loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。 const searchDanmaku = useCallback((kw: string) => { setDanmakuSearching(true) setDanmakuCandidates([]) setDanmakuEpisodeId(null) setDanmakuInfo(null) setDanmakuSearch(kw || null) setDanmakuSearchTrigger((prev) => prev + 1) }, []) const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => { setDanmakuSearching(false) setDanmakuInfo(info) }, []) // 多番剧命中(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([]) setDanmakuSearching(true) // 展示当前所选来源(面板标题处可见)。 setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle) setDanmakuSearchTrigger((prev) => prev + 1) }, []) // 回到自动匹配(清除用户手动选择)。 const danmakuResetAuto = useCallback(() => { setDanmakuEpisodeId(null) setDanmakuCandidates([]) setDanmakuSearching(true) setDanmakuSearch(null) setDanmakuSelectedSource('') setDanmakuInfo(null) setDanmakuSearchTrigger((prev) => prev + 1) }, []) // 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕 useEffect(() => { setMedia(null) setLoadError('') setHlsStartSec(0) setDanmakuEpisodeId(null) setDanmakuCandidates([]) setDanmakuSearch(null) setDanmakuSelectedSource('') setDanmakuInfo(null) setDanmakuSearching(true) directRetryRef.current = false retryingDirectRef.current = false if (fallbackTimerRef.current) { clearTimeout(fallbackTimerRef.current) fallbackTimerRef.current = null } return () => { if (fallbackTimerRef.current) { clearTimeout(fallbackTimerRef.current) fallbackTimerRef.current = null } } }, [id]) // 依赖收敛为 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 } }, []) // Load metadata and pick a default mode. useEffect(() => { if (!id) return let cancelled = false mediaAPI .get(id) .then((m) => { if (cancelled) return setMedia(m) const isDirect = isDirectStreamMedia(m) const auto = pickPlayerMode(m) // 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。 setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto)) setPlayerError('') setLoadError('') }) .catch((err: unknown) => { if (cancelled) return // 404 / 无权限等:给出可见错误提示,避免永久「加载中」 setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`) }) return () => { cancelled = true } }, [id, modeParam, directOnly]) // Wire up the actual