mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 04:26:36 +08:00
添加115云端解码功能
This commit is contained in:
+12
-2
@@ -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
@@ -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),
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export * from './history'
|
||||
export * from './library'
|
||||
export * from './media'
|
||||
export * from './notifications'
|
||||
export * from './playback'
|
||||
export * from './playProfiles'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user