mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
优化
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react'
|
||||
|
||||
import { toolsAPI, type FFmpegToolInfo, type FFmpegToolsStatus } from '../api/tools'
|
||||
|
||||
// FFToolsPanel 展示 ffmpeg/ffprobe 的安装状态,并提供「一键下载安装」:
|
||||
// 后端自动匹配当前运行环境(OS + 架构),下载到 data 目录并写入设置,
|
||||
// 无需手动填写路径。
|
||||
export function FFToolsPanel({ onInstalled }: { onInstalled?: () => void }) {
|
||||
const [status, setStatus] = useState<FFmpegToolsStatus | null>(null)
|
||||
const [working, setWorking] = useState(false)
|
||||
const pollingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await toolsAPI.ffToolsStatus())
|
||||
} catch {
|
||||
// 网络波动时保持旧状态,不打断页面
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => undefined)
|
||||
}, [load])
|
||||
|
||||
const installing = Boolean(status?.installing)
|
||||
|
||||
// 安装进行中:轮询状态直到结束,完成后刷新设置页(路径字段自动填入)。
|
||||
useEffect(() => {
|
||||
if (!installing || pollingRef.current) return
|
||||
pollingRef.current = true
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const next = await toolsAPI.ffToolsStatus()
|
||||
setStatus(next)
|
||||
if (!next.installing) {
|
||||
window.clearInterval(timer)
|
||||
pollingRef.current = false
|
||||
if (next.error) {
|
||||
toast.error(`FFmpeg 下载安装失败:${next.error}`)
|
||||
} else {
|
||||
toast.success('FFmpeg / FFprobe 下载安装完成,系统已自动使用')
|
||||
onInstalled?.()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败继续等下一轮
|
||||
}
|
||||
}, 1500)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
pollingRef.current = false
|
||||
}
|
||||
}, [installing, onInstalled])
|
||||
|
||||
const install = async () => {
|
||||
setWorking(true)
|
||||
try {
|
||||
const next = await toolsAPI.installFFTools()
|
||||
setStatus(next)
|
||||
if (next.error && !next.installing) {
|
||||
toast.error(next.error)
|
||||
} else if (next.installing) {
|
||||
toast('开始下载,完成后自动生效')
|
||||
} else {
|
||||
toast.success('已检测到可用工具')
|
||||
onInstalled?.()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
(err as { message?: string })?.message ??
|
||||
'启动下载失败',
|
||||
)
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = working || installing
|
||||
const targetLabel = status?.target?.label ?? '当前平台'
|
||||
const ffmpegInstalled = Boolean(status?.ffmpeg?.installed)
|
||||
const ffprobeInstalled = Boolean(status?.ffprobe?.installed)
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-display text-lg font-bold text-ink-600">FFmpeg / FFprobe 工具</div>
|
||||
<div className="text-xs text-sand-500">
|
||||
当前平台:{targetLabel} · 安装目录:
|
||||
<span className="font-mono">{status?.install_dir ?? '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={install}
|
||||
disabled={busy}
|
||||
className="neon-button shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||
{busy ? '下载安装中…' : '下载并安装'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<ToolRow name="FFmpeg" info={status?.ffmpeg} />
|
||||
<ToolRow name="FFprobe" info={status?.ffprobe} />
|
||||
</div>
|
||||
|
||||
{(status?.message || status?.error) && (
|
||||
<div
|
||||
className={
|
||||
status?.error
|
||||
? 'rounded-xl bg-red-50 px-3 py-2 text-xs text-red-600'
|
||||
: 'rounded-xl bg-sand-100/70 px-3 py-2 text-xs text-sand-500'
|
||||
}
|
||||
>
|
||||
{status?.error || status?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ffmpegInstalled || !ffprobeInstalled ? (
|
||||
<div className="text-xs text-sand-500">
|
||||
未检测到可用工具。点击「下载并安装」后,服务端会自动匹配当前系统下载对应版本,安装完成后无需手动填写路径。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolRow({ name, info }: { name: string; info?: FFmpegToolInfo }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white/70 px-3 py-2">
|
||||
{info?.installed ? (
|
||||
<CheckCircle2 size={16} className="shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle size={16} className="shrink-0 text-red-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-ink-600">
|
||||
{name} {info?.installed ? '' : '(未安装)'}
|
||||
</div>
|
||||
{info?.installed && (
|
||||
<div className="truncate font-mono text-[11px] text-ink-50" title={info.path}>
|
||||
{info.path}
|
||||
</div>
|
||||
)}
|
||||
{info?.version && <div className="truncate text-[11px] text-sand-500">{info.version}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
ListVideo,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
Pause,
|
||||
PictureInPicture,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
@@ -34,6 +37,15 @@ type PlayerControlsProps = {
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
hasPrevEpisode?: boolean
|
||||
hasNextEpisode?: boolean
|
||||
onPrevEpisode?: () => void
|
||||
onNextEpisode?: () => void
|
||||
prevEpisodeTitle?: string
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
@@ -44,6 +56,15 @@ export function PlayerControls({
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
hasPrevEpisode = false,
|
||||
hasNextEpisode = false,
|
||||
onPrevEpisode,
|
||||
onNextEpisode,
|
||||
prevEpisodeTitle,
|
||||
nextEpisodeTitle,
|
||||
playlistOpen = false,
|
||||
hasPlaylist = false,
|
||||
onTogglePlaylist,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () =>
|
||||
@@ -69,6 +90,7 @@ export function PlayerControls({
|
||||
const isScrubbingRef = useRef(false)
|
||||
const subtitleMenuOpenRef = useRef(false)
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
controlsHoveredRef.current = controlsHovered
|
||||
@@ -86,6 +108,10 @@ export function PlayerControls({
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
playlistOpenRef.current = playlistOpen
|
||||
}, [playlistOpen])
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
@@ -112,14 +138,16 @@ export function PlayerControls({
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
setUiVisible(false)
|
||||
}
|
||||
@@ -137,7 +165,7 @@ export function PlayerControls({
|
||||
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current) return
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
setUiVisible(false)
|
||||
}
|
||||
@@ -201,7 +229,7 @@ export function PlayerControls({
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen) {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
||||
setUiVisible(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
@@ -211,7 +239,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -297,14 +325,43 @@ export function PlayerControls({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-white">
|
||||
{/* 上一集 */}
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
disabled={!hasPrevEpisode}
|
||||
className={`rounded-full p-1.5 transition ${
|
||||
hasPrevEpisode
|
||||
? 'hover:bg-white/15 text-white cursor-pointer'
|
||||
: 'text-white/30 cursor-not-allowed opacity-40'
|
||||
}`}
|
||||
title={hasPrevEpisode ? (prevEpisodeTitle ? `上一集:${prevEpisodeTitle} ([)` : '上一集 ([)') : '没有上一集'}
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
|
||||
{/* 播放 / 暂停 */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={playing ? '暂停' : '播放'}
|
||||
title={playing ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{playing ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 下一集 */}
|
||||
<button
|
||||
onClick={onNextEpisode}
|
||||
disabled={!hasNextEpisode}
|
||||
className={`rounded-full p-1.5 transition ${
|
||||
hasNextEpisode
|
||||
? 'hover:bg-white/15 text-white cursor-pointer'
|
||||
: 'text-white/30 cursor-not-allowed opacity-40'
|
||||
}`}
|
||||
title={hasNextEpisode ? (nextEpisodeTitle ? `下一集:${nextEpisodeTitle} (])` : '下一集 (])') : '没有下一集'}
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -383,6 +440,27 @@ export function PlayerControls({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选集 / 播放列表按钮 */}
|
||||
{onTogglePlaylist && (
|
||||
<button
|
||||
onClick={onTogglePlaylist}
|
||||
disabled={!hasPlaylist}
|
||||
className={
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
|
||||
(!hasPlaylist
|
||||
? 'bg-white/5 text-white/30 cursor-not-allowed opacity-50'
|
||||
: playlistOpen
|
||||
? 'bg-rose-500 text-white hover:bg-rose-600'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20')
|
||||
}
|
||||
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
|
||||
>
|
||||
<ListVideo size={15} />
|
||||
选集
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 弹幕按钮 */}
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
@@ -401,7 +479,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={muted || volume === 0 ? '取消静音' : '静音'}
|
||||
title={muted || volume === 0 ? '取消静音 (M)' : '静音 (M)'}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
@@ -419,7 +497,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
|
||||
export type SeasonGroup = {
|
||||
season: number
|
||||
episodes: Media[]
|
||||
}
|
||||
|
||||
type PlayerPlaylistPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentMediaId: string
|
||||
episodes: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
}
|
||||
|
||||
export function PlayerPlaylistPanel({
|
||||
open,
|
||||
onClose,
|
||||
currentMediaId,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 按季分组
|
||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||
if (!episodes || episodes.length === 0) return []
|
||||
const seasonsMap = new Map<number, Media[]>()
|
||||
for (const ep of episodes) {
|
||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
||||
if (!seasonsMap.has(s)) seasonsMap.set(s, [])
|
||||
seasonsMap.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasonsMap) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([season, list]) => ({ season, episodes: list }))
|
||||
}, [episodes])
|
||||
|
||||
// 当前播放所在季
|
||||
const currentSeason = useMemo(() => {
|
||||
const found = episodes.find((e) => e.id === currentMediaId)
|
||||
if (!found) return seasonGroups[0]?.season ?? 1
|
||||
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
||||
}, [episodes, currentMediaId, seasonGroups])
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(currentSeason)
|
||||
|
||||
// 当当前播放媒体改变或打开面板时,默认选中当前媒体所在的季
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedSeason(currentSeason)
|
||||
}
|
||||
}, [open, currentSeason])
|
||||
|
||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||
useEffect(() => {
|
||||
if (open && activeItemRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const currentGroup = seasonGroups.find((g) => g.season === selectedSeason) ?? seasonGroups[0]
|
||||
const listToDisplay = currentGroup ? currentGroup.episodes : episodes
|
||||
|
||||
const filteredEpisodes = filterText.trim()
|
||||
? listToDisplay.filter((ep) => {
|
||||
const query = filterText.trim().toLowerCase()
|
||||
const title = (ep.episode_title || ep.title || '').toLowerCase()
|
||||
const epNum = String(ep.episode_num)
|
||||
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
|
||||
})
|
||||
: listToDisplay
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ListVideo size={17} className="text-rose-400" />
|
||||
<span>选集列表</span>
|
||||
<span className="font-mono text-xs font-normal text-white/50">
|
||||
({episodes.length} 集)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 季选择 Tabs(若有多季) */}
|
||||
{seasonGroups.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
|
||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||
const isSelected = selectedSeason === season
|
||||
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
|
||||
return (
|
||||
<button
|
||||
key={season}
|
||||
onClick={() => {
|
||||
setSelectedSeason(season)
|
||||
setFilterText('')
|
||||
}}
|
||||
className={`relative flex shrink-0 items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition ${
|
||||
isSelected
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
|
||||
<span className="text-[10px] opacity-75">({sesEps.length})</span>
|
||||
{isPlayingThisSeason && !isSelected && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||
{listToDisplay.length > 10 && (
|
||||
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
||||
<Search size={13} className="text-white/40 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="搜索集数或标题…"
|
||||
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText('')}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数列表 */}
|
||||
<div
|
||||
ref={listContainerRef}
|
||||
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
|
||||
>
|
||||
{filteredEpisodes.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-white/40">
|
||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||
</div>
|
||||
) : (
|
||||
filteredEpisodes.map((ep) => {
|
||||
const isPlaying = ep.id === currentMediaId
|
||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
|
||||
isPlaying
|
||||
? 'border-rose-500/60 bg-rose-500/20 text-white'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
|
||||
}`}
|
||||
>
|
||||
{/* 封面/集号 */}
|
||||
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Film size={16} className="text-white/40" />
|
||||
)}
|
||||
|
||||
{/* 正在播放动效 / 集数徽标 */}
|
||||
{isPlaying ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={14} className="text-white fill-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角标显示集数 */}
|
||||
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
|
||||
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 & 时长 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={`truncate text-xs font-medium ${
|
||||
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{displayTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="font-mono">第 {ep.episode_num} 集</span>
|
||||
)}
|
||||
{durationText && <span>{durationText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlaying && (
|
||||
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
|
||||
<Check size={13} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getEpisodeTitle(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()
|
||||
}
|
||||
Reference in New Issue
Block a user