mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
优化
This commit is contained in:
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
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 } from '../api/library'
|
||||
import { mediaAPI, libraryAPI } from '../api/library'
|
||||
import { api, hlsURL, streamURL } from '../api/client'
|
||||
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
|
||||
import { getSeriesKey, isEpisodeLike, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -80,6 +81,10 @@ export function PlayerPage() {
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
|
||||
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
@@ -272,14 +277,160 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [media])
|
||||
|
||||
// ESC = back.
|
||||
// 加载剧集/播放列表
|
||||
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])
|
||||
|
||||
const currentEpisodeIndex = useMemo(() => {
|
||||
if (!media || playlistEpisodes.length === 0) return -1
|
||||
return playlistEpisodes.findIndex((e) => e.id === 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],
|
||||
)
|
||||
|
||||
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) => {
|
||||
if (e.key === 'Escape') goBack()
|
||||
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])
|
||||
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
@@ -344,9 +495,27 @@ export function PlayerPage() {
|
||||
danmakuSearch={danmakuSearch}
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={() => setDanmakuOpen((v) => !v)}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
hasPrevEpisode={Boolean(prevEpisode)}
|
||||
hasNextEpisode={Boolean(nextEpisode)}
|
||||
onPrevEpisode={handlePrevEpisode}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
<PlayerDanmakuPanel
|
||||
open={danmakuOpen}
|
||||
@@ -373,3 +542,41 @@ export function PlayerPage() {
|
||||
</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 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()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ type PlayerVideoStageProps = {
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
playlistPanel?: ReactNode
|
||||
hasPrevEpisode?: boolean
|
||||
hasNextEpisode?: boolean
|
||||
onPrevEpisode?: () => void
|
||||
onNextEpisode?: () => void
|
||||
prevEpisodeTitle?: string
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
@@ -49,6 +60,16 @@ export function PlayerVideoStage({
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
danmakuPanel,
|
||||
playlistPanel,
|
||||
hasPrevEpisode,
|
||||
hasNextEpisode,
|
||||
onPrevEpisode,
|
||||
onNextEpisode,
|
||||
prevEpisodeTitle,
|
||||
nextEpisodeTitle,
|
||||
playlistOpen,
|
||||
hasPlaylist,
|
||||
onTogglePlaylist,
|
||||
}: PlayerVideoStageProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
@@ -114,10 +135,11 @@ export function PlayerVideoStage({
|
||||
setActiveCueText('')
|
||||
return
|
||||
}
|
||||
const trackIdx = subtitleIndex
|
||||
|
||||
const updateCue = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selectedEl = trackEls[subtitleIndex]
|
||||
const selectedEl = trackEls[trackIdx]
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
@@ -151,11 +173,11 @@ export function PlayerVideoStage({
|
||||
const tt = el.track
|
||||
if (tt) {
|
||||
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||
tt.mode = i === subtitleIndex ? 'hidden' : 'disabled'
|
||||
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
|
||||
}
|
||||
})
|
||||
|
||||
const selected = trackEls[subtitleIndex]
|
||||
const selected = trackEls[trackIdx]
|
||||
if (!selected) return
|
||||
|
||||
const tt = selected.track
|
||||
@@ -184,7 +206,7 @@ export function PlayerVideoStage({
|
||||
video.removeEventListener('seeked', updateCue)
|
||||
video.removeEventListener('playing', updateCue)
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selected = trackEls[subtitleIndex]
|
||||
const selected = trackEls[trackIdx]
|
||||
if (selected) {
|
||||
selected.removeEventListener('load', updateCue)
|
||||
if (selected.track) {
|
||||
@@ -241,7 +263,7 @@ export function PlayerVideoStage({
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === subtitleIndex}
|
||||
default={subtitleIndex === index}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
@@ -282,8 +304,18 @@ export function PlayerVideoStage({
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
hasPrevEpisode={hasPrevEpisode}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onPrevEpisode={onPrevEpisode}
|
||||
onNextEpisode={onNextEpisode}
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={hasPlaylist}
|
||||
onTogglePlaylist={onTogglePlaylist}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { FFToolsPanel } from '../components/FFToolsPanel'
|
||||
import { AdultSettingsPanel } from './AdultSettingsPanel'
|
||||
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
|
||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||
@@ -172,6 +173,7 @@ export function SettingsPage() {
|
||||
{group.key === 'api-configs' && <APIConfigsPanel />}
|
||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
|
||||
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
|
||||
@@ -83,12 +83,14 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
label: 'FFmpeg 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffmpeg',
|
||||
hint: '留空时使用系统 PATH 中的 ffmpeg;也可点击上方「下载并安装」自动下载到 data 目录并自动填入,无需手动填写',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.path',
|
||||
label: 'FFprobe 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffprobe',
|
||||
hint: '留空时使用系统 PATH 中的 ffprobe;也可点击上方「下载并安装」自动下载到 data 目录并自动填入',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.max_concurrent',
|
||||
|
||||
@@ -73,8 +73,9 @@ export function useLibraryAdminActions({
|
||||
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|
||||
const msg = serverErr || (err instanceof Error ? err.message : '')
|
||||
toast.error(msg ? `${label}失败:${msg}` : `${label}失败`)
|
||||
} finally {
|
||||
setSeriesToolBusy('')
|
||||
}
|
||||
@@ -146,8 +147,9 @@ export function useLibraryAdminActions({
|
||||
toast.success(`${label}完成:${media.title}`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|
||||
const msg = serverErr || (err instanceof Error ? err.message : '')
|
||||
toast.error(msg ? `${label}失败:${msg}` : `${label}失败`)
|
||||
} finally {
|
||||
setMovieToolBusy('')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user