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 { seasonLabel, seasonSortOrder, 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(null) const listContainerRef = useRef(null) // 按季分组 const seasonGroups = useMemo(() => { if (!episodes || episodes.length === 0) return [] const seasonsMap = new Map() 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]) => seasonSortOrder(a) - seasonSortOrder(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(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 (
e.stopPropagation()} className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96" > {/* 头部 */}
选集列表 ({episodes.length} 集)
{/* 季选择 Tabs(若有多季) */} {seasonGroups.length > 1 && (
{seasonGroups.map(({ season, episodes: sesEps }) => { const isSelected = selectedSeason === season const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId) return ( ) })}
)} {/* 搜索/过滤单集(当单集数量较多时) */} {listToDisplay.length > 10 && (
setFilterText(e.target.value)} placeholder="搜索集数或标题…" className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs" /> {filterText && ( )}
)} {/* 集数列表 */}
{filteredEpisodes.length === 0 ? (
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
) : ( 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 (
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' }`} > {/* 封面/集号 */}
{ep.backdrop_url || ep.poster_url ? ( ) : ( )} {/* 正在播放动效 / 集数徽标 */} {isPlaying ? (
) : (
)} {/* 角标显示集数 */} {ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
{/* 标题 & 时长 */}

{displayTitle}

{ep.episode_num > 0 && ( 第 {ep.episode_num} 集 )} {durationText && {durationText}}
{isPlaying && (
)}
) }) )}
) } 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() }