Files
MeBox/web/src/components/PlayerPlaylistPanel.tsx
T
2026-09-27 13:52:17 +08:00

460 lines
19 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
import type { Media } from '../types'
import { formatEpisodeLabel, formatEpisodeNumber, episodeNumberValue } from '../utils/episodeNumber'
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
import {
isStrmMedia,
mediaVersionFileName,
mediaVersionLabel,
mediaVersionMatches,
mediaVersionSourceLabel,
} from '../utils/mediaVersion'
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
export type SeasonGroup = {
season: number
episodes: Media[]
}
type PlayerPlaylistPanelProps = {
open: boolean
onClose: () => void
currentMediaId: string
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
currentVersions?: Media[]
episodes: Media[]
onSelectEpisode: (media: Media) => void
/** 切换到同一集/同一条目的另一个版本。 */
onSelectVersion?: (media: Media) => void
/**
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
*/
inline?: boolean
}
/**
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
*
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
* 强调色填充。
*
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
* 版本选择器。
*/
export function PlayerPlaylistPanel({
open,
onClose,
currentMediaId,
currentVersions = [],
episodes,
onSelectEpisode,
onSelectVersion,
inline = false,
}: PlayerPlaylistPanelProps) {
const [filterText, setFilterText] = useState('')
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
const activeItemRef = useRef<HTMLButtonElement | 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) => episodeNumberValue(a) - episodeNumberValue(b))
}
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) => mediaVersionMatches(e, 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(() => {
setVersionsForEpisodeId(null)
}, [open, selectedSeason, currentMediaId])
// 当面板打开时,自动平滑滚动到当前播放集的位置
useEffect(() => {
if (!open || versionsForEpisodeId) return
const timer = setTimeout(() => {
activeItemRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, 100)
return () => clearTimeout(timer)
}, [open, selectedSeason, currentMediaId, versionsForEpisodeId])
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 = formatEpisodeNumber(ep)
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
})
: listToDisplay
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
// 只有一集(电影/单条媒体)时,面板就是版本选择器,不再重复列出一行「剧集」。
const hasEpisodeList = episodes.length > 1
const versionsForEpisode = versionsForEpisodeId
? (listToDisplay.find((ep) => ep.id === versionsForEpisodeId)?.versions ?? [])
: []
const expandedEpisode = versionsForEpisodeId
? listToDisplay.find((ep) => ep.id === versionsForEpisodeId)
: undefined
return (
<div
onClick={(e) => e.stopPropagation()}
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
className={
inline
? 'flex flex-col'
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
{inline ? null : (
<div className={PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
</span>
</div>
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
<X size={16} />
</button>
</div>
)}
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
{versionsToSwitch.length > 0 && (
<div className="shrink-0 border-b border-white/10 px-3 py-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-white/55">
<Layers size={12} className="text-rose-400" />
<span>正在播放的版本</span>
</div>
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
{versionsToSwitch.map((version) => (
<VersionOption
key={version.id}
version={version}
active={mediaVersionMatches(version, currentMediaId)}
onSelect={() => onSelectVersion?.(version)}
/>
))}
</div>
</div>
)}
{/* 某一集的版本列表:从宫格角标展开,选完即收起 */}
{expandedEpisode && versionsForEpisode.length > 0 && (
<div className="shrink-0 border-b border-white/10 bg-white/[0.03] px-3 py-2.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-amber-200">
<Layers size={12} className="shrink-0" />
<span className="truncate">
{expandedEpisode.episode_num > 0
? `第 ${expandedEpisode.episode_num} 集 · 选择版本`
: '选择版本'}
</span>
</div>
<button
onClick={() => setVersionsForEpisodeId(null)}
className="shrink-0 rounded p-0.5 text-white/45 transition hover:bg-white/10 hover:text-white"
title="收起版本列表"
>
<X size={13} />
</button>
</div>
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
{versionsForEpisode.map((version) => (
<VersionOption
key={version.id}
version={version}
active={mediaVersionMatches(version, currentMediaId)}
compact
onSelect={() => {
onSelectVersion?.(version)
setVersionsForEpisodeId(null)
}}
/>
))}
</div>
</div>
)}
{/* 季选择 Tabs(若有多季) */}
{hasEpisodeList && seasonGroups.length > 1 && (
<div className="scrollbar-hide flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
{seasonGroups.map(({ season, episodes: sesEps }) => {
const isSelected = selectedSeason === season
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, 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>{seasonLabel(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>
)}
{/* 搜索/过滤单集(当单集数量较多时) */}
{hasEpisodeList && listToDisplay.length > 10 && (
<div className="shrink-0 px-3 pb-1 pt-2.5">
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
<Search size={13} className="shrink-0 text-white/40" />
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="搜索集数或标题…"
className="w-full bg-transparent text-xs text-white outline-none placeholder:text-white/30"
/>
{filterText && (
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
<X size={12} />
</button>
)}
</div>
</div>
)}
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
{hasEpisodeList ? (
<div className={inline ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
{filteredEpisodes.length === 0 ? (
<div className="py-10 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
<div
className={
inline
? 'grid grid-cols-6 gap-1.5'
: 'grid grid-cols-5 gap-1.5 sm:grid-cols-6'
}
>
{filteredEpisodes.map((ep) => {
const isPlaying = mediaVersionMatches(ep, currentMediaId)
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
const displayTitle = getEpisodeTitle(ep, listToDisplay)
const durationText =
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
const cellTitle = [
formatEpisodeLabel(ep),
displayTitle,
durationText,
versions.length > 0 ? `${versions.length} 个版本` : '',
]
.filter(Boolean)
.join(' · ')
return (
<div key={ep.id} className="relative">
<button
ref={isPlaying ? activeItemRef : null}
onClick={() => onSelectEpisode(ep)}
title={cellTitle}
className={`flex ${inline ? 'h-10' : 'h-9'} w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
isPlaying
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
}`}
>
{isPlaying ? (
<Play size={12} className="fill-current" />
) : ep.episode_num > 0 ? (
formatEpisodeNumber(ep)
) : (
<Film size={13} className="text-white/45" />
)}
</button>
{/* 有多个版本:点角标挑版本,点格子本身直接播放默认版本 */}
{versions.length > 0 && (
<button
onClick={(event) => {
event.stopPropagation()
setVersionsForEpisodeId((current) =>
current === ep.id ? null : ep.id,
)
}}
className={`absolute right-0 top-0 flex ${inline ? 'h-5 w-5 text-[10px]' : 'h-3.5 w-3.5 text-[9px]'} items-center justify-center rounded-bl-md rounded-tr-md font-semibold leading-none transition ${
versionsForEpisodeId === ep.id
? 'bg-amber-300 text-black'
: 'bg-black/45 text-amber-200 hover:bg-black/70'
}`}
title={`${versions.length} 个版本,点这里选择`}
>
{versions.length}
</button>
)}
</div>
)
})}
</div>
)}
</div>
) : (
versionsToSwitch.length === 0 && (
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
暂无可切换的内容
</div>
)
)}
</div>
</div>
)
}
/** 单个版本选项:主行是画质/容器/体积,次行是来源与文件名。 */
function VersionOption({
version,
active,
compact = false,
onSelect,
}: {
version: Media
active: boolean
compact?: boolean
onSelect: () => void
}) {
const cloud = isStrmMedia(version)
const label = mediaVersionLabel(version)
const fileName = mediaVersionFileName(version)
const sourceLabel = mediaVersionSourceLabel(version)
// 没有探测数据时(网盘 STRM 常见)主行本身就是文件名,次行再重复一遍没有意义。
const showFileName = fileName !== '' && !labelKey(label).includes(labelKey(fileName))
const activeClass = 'border-rose-500/50 bg-rose-500/15 text-white'
const idleClass =
'border-white/5 bg-white/5 text-white/85 hover:border-white/15 hover:bg-white/10'
return (
<button
type="button"
disabled={active}
onClick={onSelect}
title={version.path || version.strm_url || undefined}
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 text-left transition disabled:cursor-default ${
compact ? 'py-1.5' : 'py-2'
} ${active ? activeClass : idleClass}`}
>
<span className="mt-0.5 shrink-0">
{cloud ? (
<Cloud size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
) : (
<Film size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
)}
</span>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-xs font-medium ${active ? 'text-rose-200' : 'text-white/90'}`}
>
{label}
</span>
<span className="mt-0.5 block truncate text-[10px] text-white/45">
{sourceLabel}
{showFileName ? ` · ${fileName}` : ''}
</span>
</span>
{active && <Check size={13} className="mt-0.5 shrink-0 text-rose-400" />}
</button>
)
}
/** 归一化后比较:忽略大小写与分隔符(. _ - 空格),用于判断两段文案是否只是重复。 */
function labelKey(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
}
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
}
const episodeLabel = formatEpisodeLabel(ep)
return episodeLabel || 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()
}