Files
MeBox/web/src/pages/PlayerPage.tsx
T
truewhile 8017e994f4 优化
2026-09-17 22:57:41 +08:00

1834 lines
68 KiB
TypeScript

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 <track> 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<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)
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),
)
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
normalizePlaybackRate(authUser?.player_playback_rate),
)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
const playerVolumeTouchedRef = useRef(false)
const playerPlaybackRateTouchedRef = useRef(false)
const playerPlaybackRateSaveSeqRef = useRef(0)
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)
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 <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
// 加载统一播放能力: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 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 onSeeked: (() => void) | undefined
const applyResume = () => {
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
onSeeked = () => {
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 })
}
video.addEventListener('seeked', onSeeked, { once: true })
video.currentTime = resumePosition
return
}
setInitialSeekDone(true)
}
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
applyResume()
} else {
video.addEventListener('canplay', applyResume, { once: true })
}
return () => {
video.removeEventListener('canplay', applyResume)
if (onSeeked) video.removeEventListener('seeked', onSeeked)
}
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
// 使用 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])
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
})
}, [])
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 烧录任务;文本字幕只在网页层切换。
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('')
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,
})
if (action === 'ignore') return
if (action === 'retry' && video && mediaId) {
directRetryRef.current = true
retryingDirectRef.current = true
try {
video.load()
void video.play().catch(() => undefined)
} finally {
retryingDirectRef.current = false
}
return
}
const fallbackDirectPlay = () => {
if (modeRef.current !== 'direct') 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()
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
}, [
clearFallbackTimer,
directOnly,
hlsUnavailable,
mediaId,
mode,
playbackInfo,
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() ||
''
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
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}
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
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={togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : 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}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
danmakuPanel={
<PlayerDanmakuPanel
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}
/>
}
/>
</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()
}