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 } from '../types' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion' import { normalizePlaybackRate } from '../utils/playbackRate' 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 } 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 { 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 elements. type PlaybackProgressSession = { mediaId: string id: string startedAtMs: number sequence: number } 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(null) const hlsRef = useRef(null) const lastSentRef = useRef(0) const progressSessionRef = useRef(null) const directRetryRef = useRef(false) const retryingDirectRef = useRef(false) const fallbackTimerRef = useRef | null>(null) const [media, setMedia] = useState(null) // VR 全景播放:null = 普通播放。识别结果与用户开关只存本机, // 不改动数据库,换设备/换浏览器不会互相影响。 const [vr360, setVr360] = useState(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('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 [playerVolume, setPlayerVolume] = useState(() => normalizePlayerVolume(authUser?.player_volume), ) const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => normalizePlaybackRate(authUser?.player_playback_rate), ) const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode) // VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来, // 这时不弹说明,避免给老用户闪一下。 const [vr360GuideSeen, setVr360GuideSeen] = useState(null) const playerVolumeTouchedRef = useRef(false) const playerPlaybackRateTouchedRef = useRef(false) const playerPlaybackRateSaveSeqRef = useRef(0) 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) // 统一播放能力:115 提供 direct → cloud_hls → local_hls,其它源 direct → local_hls。 const [playbackInfo, setPlaybackInfo] = useState(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 | null>(null) const cloudRetryRef = useRef(5) const pendingSeekRef = useRef(null) // 弹幕控制:状态来自 /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 [danmakuAlternatives, setDanmakuAlternatives] = useState([]) // 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。 const [danmakuInfo, setDanmakuInfo] = useState(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([]) 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]) 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) } const playbackRate = normalizePlaybackRate(cfg.playback_rate) if (!playerPlaybackRateTouchedRef.current) { setPlayerPlaybackRate(playbackRate) } 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 }) } if (!playerPlaybackRateTouchedRef.current) { const current = useAuthStore.getState().user if (current) setAuthUser({ ...current, player_playback_rate: playbackRate }) } }) .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) => { playerPlaybackRateTouchedRef.current = true setPlayerPlaybackRate(normalizePlaybackRate(next)) }, []) const commitPlayerPlaybackRate = useCallback( (next: number) => { playerPlaybackRateTouchedRef.current = true const playbackRate = normalizePlaybackRate(next) setPlayerPlaybackRate(playbackRate) const saveSeq = ++playerPlaybackRateSaveSeqRef.current void danmakuAPI .updateSettings({ playback_rate: playbackRate }) .then((cfg) => { if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return const saved = normalizePlaybackRate(cfg.playback_rate) setPlayerPlaybackRate(saved) const current = useAuthStore.getState().user if (current) setAuthUser({ ...current, player_playback_rate: saved }) }) .catch(() => { if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return toast.error('倍速保存失败,请重试') }) }, [setAuthUser], ) 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 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]) // 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