添加115云端解码功能

This commit is contained in:
truewhile
2026-09-14 17:57:51 +08:00
parent 91c6d9dfd9
commit b28c0d9247
22 changed files with 2436 additions and 50 deletions
+12 -2
View File
@@ -147,7 +147,7 @@ export function streamURL(mediaId: string): string {
// hlsURL returns the m3u8 playlist URL fed into hls.js.
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
@@ -159,7 +159,17 @@ export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number):
subtitleStream !== undefined && subtitleStream >= 0
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
const qualityQuery = quality ? `&quality=${encodeURIComponent(quality)}` : ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}`
}
// cloudHlsURL returns the MeBox-proxied 115 cloud HLS master playlist URL.
// A browser cannot fetch the 115 CDN directly because its CORS policy only
// allows https://115.com; the backend rewrites all child URLs to this
// same-origin proxy.
export function cloudHlsURL(mediaId: string, definition: string): string {
const quality = definition ? `&definition=${encodeURIComponent(definition)}` : ''
return `/api/cloud115/media/${encodeURIComponent(mediaId)}/master.m3u8?${tokenQuery()}${profileQuery()}${quality}&media_id=${encodeURIComponent(mediaId)}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
+13 -1
View File
@@ -1,5 +1,5 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, LibraryRoot, Media, ScanResult } from '../types'
import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
export interface MediaPage {
@@ -233,6 +233,18 @@ export const mediaAPI = {
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
playbackInfo: (id: string, quality?: number) =>
api
.get<PlaybackInfo>(`/media/${id}/playback`, {
params: quality && quality > 0 ? { quality } : undefined,
})
.then((r) => r.data),
startCloudTranscode: (id: string, definition: number) =>
api
.post<PlaybackInfo>(`/media/${id}/transcode`, { definition })
.then((r) => r.data),
getEpisodes: (id: string) =>
api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data),
+104 -7
View File
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom'
import {
Captions,
CaptionsOff,
Check,
FastForward,
Gauge,
ListVideo,
Loader2,
Lock,
Maximize,
MessageSquareText,
Minimize,
@@ -18,6 +22,7 @@ import {
VolumeX,
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
import type { PlaybackQuality } from '../types'
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
import {
SUBTITLE_POSITION_OPTIONS,
@@ -86,6 +91,10 @@ type PlayerControlsProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
qualities?: PlaybackQuality[]
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
showQuality?: boolean
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
knownDuration?: number
/** Absolute source offset of the current HLS session (seconds). */
@@ -122,6 +131,10 @@ export function PlayerControls({
playlistOpen = false,
hasPlaylist = false,
onTogglePlaylist,
qualities = [],
selectedQuality = '',
onSelectQuality,
showQuality = false,
knownDuration = 0,
streamOffset = 0,
onSeekAbsolute,
@@ -144,10 +157,13 @@ export function PlayerControls({
const [scrubValue, setScrubValue] = useState<number | null>(null)
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const controlsHoveredRef = useRef(false)
const isScrubbingRef = useRef(false)
const subtitleMenuOpenRef = useRef(false)
const qualityMenuOpenRef = useRef(false)
const danmakuOpenRef = useRef(false)
const playlistOpenRef = useRef(false)
const pendingSeekRef = useRef<number | null>(null)
@@ -172,6 +188,10 @@ export function PlayerControls({
subtitleMenuOpenRef.current = subtitleMenuOpen
}, [subtitleMenuOpen])
useEffect(() => {
qualityMenuOpenRef.current = qualityMenuOpen
}, [qualityMenuOpen])
useEffect(() => {
danmakuOpenRef.current = danmakuOpen
}, [danmakuOpen])
@@ -199,17 +219,20 @@ export function PlayerControls({
setMuted(next === 0)
}, [video, volumeProp])
// 点击控制栏外部时关闭字幕菜单
// 点击控制栏外部时关闭字幕/画质菜单
useEffect(() => {
if (!subtitleMenuOpen) return
if (!subtitleMenuOpen && !qualityMenuOpen) return
const onDocClick = (e: MouseEvent) => {
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
setSubtitleMenuOpen(false)
}
if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) {
setQualityMenuOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [subtitleMenuOpen])
}, [subtitleMenuOpen, qualityMenuOpen])
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
@@ -225,6 +248,7 @@ export function PlayerControls({
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!qualityMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
@@ -233,6 +257,7 @@ export function PlayerControls({
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!qualityMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
@@ -335,7 +360,7 @@ export function PlayerControls({
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || danmakuOpen || playlistOpen) {
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
} else {
@@ -345,7 +370,7 @@ export function PlayerControls({
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
const togglePlay = () => {
const el = video()
@@ -409,6 +434,7 @@ export function PlayerControls({
controlsHoveredRef.current ||
isScrubbingRef.current ||
subtitleMenuOpenRef.current ||
qualityMenuOpenRef.current ||
danmakuOpenRef.current ||
playlistOpenRef.current
) {
@@ -419,6 +445,7 @@ export function PlayerControls({
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!qualityMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
@@ -549,6 +576,11 @@ export function PlayerControls({
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
const selectedQualityLabel = qualities.find((quality) => quality.id === selectedQuality)?.label ?? ''
const qualityGroups = [
{ key: 'cloud', label: '115 云端', items: qualities.filter((quality) => quality.source !== 'local') },
{ key: 'local', label: '本地 HLS', items: qualities.filter((quality) => quality.source === 'local') },
].filter((group) => group.items.length > 0)
const seekOverlay = seekHint && stageEl
? createPortal(
@@ -660,10 +692,75 @@ export function PlayerControls({
</button>
)}
{showQuality && qualities.length > 0 && onSelectQuality && (
<div className="relative" ref={qualityMenuRef}>
<button
onClick={() => {
setSubtitleMenuOpen(false)
setQualityMenuOpen((v) => !v)
}}
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
title="画质"
>
<Gauge size={18} className={qualityMenuOpen ? 'text-rose-400' : 'text-white/80'} />
<span className="hidden text-[10px] font-medium text-white/80 sm:inline">
{selectedQualityLabel || '画质'}
</span>
</button>
{qualityMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-48 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
{qualityGroups.map((group) => (
<div key={group.key}>
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
{group.label}
</p>
{group.items.map((quality) => {
const current = quality.id === selectedQuality
return (
<button
key={`${quality.source}-${quality.id}`}
type="button"
onClick={() => {
onSelectQuality(quality)
setQualityMenuOpen(false)
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
current
? 'text-rose-400 hover:bg-white/10'
: quality.requires_vip
? 'text-white/55 hover:bg-white/10'
: 'text-white/85 hover:bg-white/10'
}`}
title={quality.note || quality.label}
>
<span className="truncate">{quality.label}</span>
{quality.requires_transcode && (
<span className="ml-auto flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-amber-300">
<Loader2 size={10} />
转码
</span>
)}
{quality.requires_vip && <Lock size={11} className="ml-auto text-amber-300" />}
{current && !quality.requires_transcode && (
<Check size={13} className="ml-auto text-rose-400" />
)}
</button>
)
})}
</div>
))}
</div>
)}
</div>
)}
{subs.length > 0 && (
<div className="relative" ref={subtitleMenuRef}>
<button
onClick={() => setSubtitleMenuOpen((v) => !v)}
onClick={() => {
setQualityMenuOpen(false)
setSubtitleMenuOpen((v) => !v)
}}
className="rounded-full p-1.5 transition hover:bg-white/15"
title="字幕"
>
@@ -852,4 +949,4 @@ export function PlayerControls({
</div>
</>
)
}
}
+297 -13
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import { cloudHlsURL, hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import {
danmakuAPI,
type DanmakuAnime,
@@ -17,7 +17,7 @@ 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 type { Media, PlaybackInfo, PlaybackQuality } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import {
@@ -124,6 +124,16 @@ export function PlayerPage() {
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)
@@ -407,6 +417,17 @@ export function PlayerPage() {
setMedia(null)
setLoadError('')
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([])
@@ -478,6 +499,117 @@ export function PlayerPage() {
// 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。
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(() => {
@@ -524,7 +656,14 @@ export function PlayerPage() {
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
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()) {
@@ -545,6 +684,15 @@ export function PlayerPage() {
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
@@ -558,7 +706,19 @@ export function PlayerPage() {
})
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')
@@ -614,31 +774,36 @@ export function PlayerPage() {
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
cloudWaiting,
hlsSource,
hlsUnavailable,
hlsStartSec,
mediaId,
mode,
playbackProvider,
selectedQuality,
setPlaybackMode,
switchToLocalHLS,
teardownHls,
])
// 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') return
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
return () => {
stopHLSJob(mediaId)
}
}, [mediaId, mode])
}, [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') return
if (!mediaId || mode !== 'hls' || hlsSource !== 'local') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [mediaId, mode])
}, [hlsSource, mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
@@ -944,6 +1109,11 @@ export function PlayerPage() {
}, [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 ?? [])
const showQuality = mode !== 'direct' && qualityOptions.length > 0
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
useEffect(() => {
@@ -975,9 +1145,36 @@ export function PlayerPage() {
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
setHlsStartSec(0)
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()
}
} else {
setCloudWaiting(false)
}
setPlaybackMode(next)
}, [isDirectStream, mode, setPlaybackMode])
}, [
isDirectStream,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
@@ -1019,6 +1216,10 @@ export function PlayerPage() {
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) {
@@ -1029,7 +1230,42 @@ export function PlayerPage() {
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
}, [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) => {
@@ -1097,18 +1333,45 @@ export function PlayerPage() {
} 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 转码')
setPlaybackMode('hls')
switchToLocalHLS(current?.currentTime || 0)
}
}
clearFallbackTimer()
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
}, [
clearFallbackTimer,
directOnly,
hlsUnavailable,
mediaId,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
@@ -1183,8 +1446,14 @@ export function PlayerPage() {
hasPlaylist={playlistEpisodes.length > 0}
onTogglePlaylist={togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
qualities={qualityOptions}
selectedQuality={selectedQuality}
onSelectQuality={selectPlaybackQuality}
showQuality={showQuality}
waiting={cloudWaiting}
waitingMessage={cloudWaitMessage}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
@@ -1248,6 +1517,21 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
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
+27 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
import { Loader2 } from 'lucide-react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import type { Media } from '../types'
import type { Media, PlaybackQuality } from '../types'
import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
@@ -178,6 +179,12 @@ type PlayerVideoStageProps = {
knownDuration?: number
streamOffset?: number
onSeekAbsolute?: (seconds: number) => boolean
qualities?: PlaybackQuality[]
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
showQuality?: boolean
waiting?: boolean
waitingMessage?: string
}
export function PlayerVideoStage({
@@ -224,6 +231,12 @@ export function PlayerVideoStage({
knownDuration,
streamOffset,
onSeekAbsolute,
qualities = [],
selectedQuality = '',
onSelectQuality,
showQuality = false,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
@@ -666,6 +679,10 @@ export function PlayerVideoStage({
knownDuration={knownDuration}
streamOffset={streamOffset}
onSeekAbsolute={onSeekAbsolute}
qualities={qualities}
selectedQuality={selectedQuality}
onSelectQuality={onSelectQuality}
showQuality={showQuality}
/>
{danmakuPanel}
{playlistPanel}
@@ -680,6 +697,15 @@ export function PlayerVideoStage({
{playerError}
</div>
) : null}
{waiting ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
</div>
</div>
) : null}
</div>
)
}
+1
View File
@@ -7,4 +7,5 @@ export * from './history'
export * from './library'
export * from './media'
export * from './notifications'
export * from './playback'
export * from './playProfiles'
+31
View File
@@ -0,0 +1,31 @@
export type PlaybackQualitySource = 'cloud' | 'original' | 'local'
export interface PlaybackQuality {
id: string
label: string
height?: number
source: PlaybackQualitySource
available: boolean
requires_transcode?: boolean
requires_vip?: boolean
note?: string
}
export interface PlaybackTranscodeState {
state: 'idle' | 'ready' | 'transcoding' | 'unavailable'
definition?: string
message?: string
retry_after_sec?: number
started_at?: number
}
export interface PlaybackInfo {
media_id: string
provider: string
fallback: string[]
default_quality: string
cloud_qualities?: PlaybackQuality[]
local_qualities: PlaybackQuality[]
transcode: PlaybackTranscodeState
}