mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化界面,优化多版本切换逻辑
This commit is contained in:
@@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV
|
||||
|
||||
type MediaVersionSwitcherProps = {
|
||||
media: Media
|
||||
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
|
||||
mode?: 'detail' | 'player'
|
||||
onSelect?: (version: Media) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MediaVersionSwitcher({
|
||||
media,
|
||||
mode = 'detail',
|
||||
onSelect,
|
||||
className = '',
|
||||
}: MediaVersionSwitcherProps) {
|
||||
/**
|
||||
* 详情页的版本切换。
|
||||
*
|
||||
* 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里
|
||||
* (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。
|
||||
*/
|
||||
export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) {
|
||||
const versions = mediaVersionsOf(media)
|
||||
if (versions.length <= 1) return null
|
||||
const dark = mode === 'player'
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`.trim()}>
|
||||
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
|
||||
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-ink-600">
|
||||
<Layers size={14} className="text-sand-500" />
|
||||
<span>版本({versions.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({
|
||||
const active = version.id === media.id
|
||||
const isStrm = isStrmMedia(version)
|
||||
const label = mediaVersionLabel(version)
|
||||
const btnClass = dark
|
||||
? active
|
||||
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
||||
: active
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||
const btnClass = active
|
||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||
|
||||
const content = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isStrm && (
|
||||
<Cloud
|
||||
size={12}
|
||||
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
||||
aria-label="云端 / STRM"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (mode === 'player') {
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => onSelect?.(version)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={version.id}
|
||||
@@ -77,7 +42,17 @@ export function MediaVersionSwitcher({
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||
title={version.path || version.strm_url}
|
||||
>
|
||||
{content}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isStrm && (
|
||||
<Cloud
|
||||
size={12}
|
||||
className={active ? 'text-brand-500' : 'text-sand-400'}
|
||||
aria-label="云端 / STRM"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{active ? <span className="opacity-75">· 当前</span> : null}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||
// the matched danmaku details (anime title, episode title, comment count,
|
||||
@@ -194,24 +195,26 @@ export function PlayerDanmakuPanel({
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
// 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时
|
||||
// 位置不会跳,用户也不用重新找入口。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto overscroll-contain rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:bottom-auto sm:right-4 sm:top-16 sm:w-80 sm:max-h-[calc(100%-5rem)]"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
|
||||
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
|
||||
<div className={PLAYER_PANEL_HEADER}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
|
||||
<span className="truncate">弹幕设置</span>
|
||||
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5">
|
||||
{/* 是否加载弹幕 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
||||
<span className="text-white/85">加载弹幕</span>
|
||||
@@ -573,6 +576,7 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
||||
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
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
|
||||
@@ -14,20 +21,40 @@ type PlayerPlaylistPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentMediaId: string
|
||||
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
|
||||
currentVersions?: Media[]
|
||||
episodes: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
/** 切换到同一集/同一条目的另一个版本。 */
|
||||
onSelectVersion?: (media: Media) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
|
||||
*
|
||||
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
|
||||
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
|
||||
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
|
||||
* 强调色填充。
|
||||
*
|
||||
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
|
||||
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
|
||||
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
|
||||
* 版本选择器。
|
||||
*/
|
||||
export function PlayerPlaylistPanel({
|
||||
open,
|
||||
onClose,
|
||||
currentMediaId,
|
||||
currentVersions = [],
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
// 按季分组
|
||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||
@@ -46,9 +73,9 @@ export function PlayerPlaylistPanel({
|
||||
.map(([season, list]) => ({ season, episodes: list }))
|
||||
}, [episodes])
|
||||
|
||||
// 当前播放所在季
|
||||
// 当前播放所在季(播放中的可能是某个剧集行的非首个版本,需按版本组比对)
|
||||
const currentSeason = useMemo(() => {
|
||||
const found = episodes.find((e) => e.id === currentMediaId)
|
||||
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])
|
||||
@@ -62,15 +89,19 @@ export function PlayerPlaylistPanel({
|
||||
}
|
||||
}, [open, currentSeason])
|
||||
|
||||
// 换集/换季时收起版本列表:那张列表是针对上一集的,留着会让人误点。
|
||||
useEffect(() => {
|
||||
setVersionsForEpisodeId(null)
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
|
||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||
useEffect(() => {
|
||||
if (open && activeItemRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
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
|
||||
|
||||
@@ -86,35 +117,99 @@ export function PlayerPlaylistPanel({
|
||||
})
|
||||
: 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()}
|
||||
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"
|
||||
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<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} 集)
|
||||
<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="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||
{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(若有多季) */}
|
||||
{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">
|
||||
{hasEpisodeList && seasonGroups.length > 1 && (
|
||||
<div className="no-scrollbar 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) => e.id === currentMediaId)
|
||||
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
||||
return (
|
||||
<button
|
||||
key={season}
|
||||
@@ -140,22 +235,19 @@ export function PlayerPlaylistPanel({
|
||||
)}
|
||||
|
||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||
{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" />
|
||||
{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 outline-none placeholder:text-white/30 text-white text-xs"
|
||||
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"
|
||||
>
|
||||
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
@@ -163,100 +255,146 @@ export function PlayerPlaylistPanel({
|
||||
</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)} 分钟` : ''
|
||||
{/* 集数宫格 */}
|
||||
{hasEpisodeList ? (
|
||||
<div className="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="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 = [
|
||||
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||
displayTitle,
|
||||
durationText,
|
||||
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
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'
|
||||
return (
|
||||
<div key={ep.id} className="relative">
|
||||
<button
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
title={cellTitle}
|
||||
className={`flex 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'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
{isPlaying ? (
|
||||
<Play size={12} className="fill-current" />
|
||||
) : ep.episode_num > 0 ? (
|
||||
ep.episode_num
|
||||
) : (
|
||||
<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 h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] 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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
versionsToSwitch.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center px-4 text-center text-xs text-white/40">
|
||||
暂无可切换的内容
|
||||
</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)) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import { Check, Compass, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||
import { Compass, LogOut, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||
|
||||
import {
|
||||
PLAYER_ICON_BUTTON,
|
||||
PLAYER_SEGMENT,
|
||||
PLAYER_SEGMENT_GROUP,
|
||||
PLAYER_SEGMENT_OFF,
|
||||
PLAYER_SEGMENT_ON,
|
||||
PLAYER_TEXT_BUTTON,
|
||||
} from './playerTheme'
|
||||
import {
|
||||
VR360_DEFAULT_FOV,
|
||||
VR360_MAX_FOV,
|
||||
@@ -74,6 +82,8 @@ type Vr360StageProps = {
|
||||
onError: (message: string) => void
|
||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||
onProfileChange?: (profile: Vr360Profile) => void
|
||||
/** 退出 VR 全景播放(回到普通播放)。不提供时工具条上不显示退出入口。 */
|
||||
onExitVr?: () => void
|
||||
/**
|
||||
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||
@@ -136,6 +146,7 @@ export function Vr360Stage({
|
||||
onReady,
|
||||
onError,
|
||||
onProfileChange,
|
||||
onExitVr,
|
||||
onUiHoldChange,
|
||||
}: Vr360StageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -541,7 +552,7 @@ export function Vr360Stage({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-center gap-1.5 rounded-2xl border border-white/15 bg-black/65 px-2 py-1.5 text-white shadow-2xl backdrop-blur ${
|
||||
className={`flex flex-wrap items-center justify-center gap-0.5 rounded-xl border border-white/10 bg-[#0d0e12]/90 p-1 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
|
||||
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
@@ -557,21 +568,19 @@ export function Vr360Stage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGyro}
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
gyroState === 'active'
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/85 hover:bg-white/20'
|
||||
className={`${PLAYER_TEXT_BUTTON} ${
|
||||
gyroState === 'active' ? 'bg-rose-500 text-white hover:bg-rose-500' : ''
|
||||
}`}
|
||||
title="用手机陀螺仪转动视角"
|
||||
>
|
||||
<Smartphone size={14} />
|
||||
陀螺仪
|
||||
{gyroState === 'active' && <Compass size={13} className="text-white/85" />}
|
||||
{gyroState === 'active' && <Compass size={12} className="text-white/85" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white/85 transition hover:bg-white/20"
|
||||
className={PLAYER_TEXT_BUTTON}
|
||||
title="恢复默认视角与视场角"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
@@ -580,44 +589,53 @@ export function Vr360Stage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
settingsOpen
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/85 hover:bg-white/20'
|
||||
}`}
|
||||
className={`${PLAYER_TEXT_BUTTON} ${settingsOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
title="投影方式与画幅布局"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
画面
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1.15)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="视野拉远"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<span className="w-9 text-center text-[10px] tabular-nums text-white/75">
|
||||
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1 / 1.15)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="视野拉近"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1.15)}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
title="视野拉远"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<span className="w-9 text-center text-[10px] tabular-nums text-white/70">
|
||||
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1 / 1.15)}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
title="视野拉近"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
{onExitVr ? (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitVr}
|
||||
className={`${PLAYER_TEXT_BUTTON} hover:bg-rose-500/20 hover:text-rose-200`}
|
||||
title="退出 VR 全景,回到普通播放"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
退出 VR
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="pointer-events-none rounded-full bg-black/45 px-3 py-1 text-[10px] text-white/70 backdrop-blur">
|
||||
<p className="pointer-events-none rounded-md bg-black/45 px-3 py-1 text-[10px] text-white/65 backdrop-blur">
|
||||
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
|
||||
</p>
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
className={`w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur ${
|
||||
className={`w-[min(92vw,420px)] rounded-xl border border-white/10 bg-[#0d0e12]/95 px-3 py-3 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
@@ -626,46 +644,40 @@ export function Vr360Stage({
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/40">投影方式</p>
|
||||
<div className={PLAYER_SEGMENT_GROUP}>
|
||||
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, projection: value })}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
|
||||
profile.projection === value
|
||||
? 'bg-rose-500/25 text-rose-200'
|
||||
: 'bg-white/5 text-white/75 hover:bg-white/15'
|
||||
className={`${PLAYER_SEGMENT} ${
|
||||
profile.projection === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||
}`}
|
||||
>
|
||||
{profile.projection === value && <Check size={11} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-1.5 mt-2.5 text-[10px] uppercase tracking-wide text-white/45">
|
||||
<p className="mb-1.5 mt-3 text-[10px] uppercase tracking-wide text-white/40">
|
||||
画幅布局
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className={PLAYER_SEGMENT_GROUP}>
|
||||
{VR360_STEREO_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
|
||||
profile.stereo === value
|
||||
? 'bg-rose-500/25 text-rose-200'
|
||||
: 'bg-white/5 text-white/75 hover:bg-white/15'
|
||||
className={`${PLAYER_SEGMENT} ${
|
||||
profile.stereo === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||
}`}
|
||||
>
|
||||
{profile.stereo === value && <Check size={11} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{profile.stereo !== 'mono' && (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-white/45">
|
||||
<p className="mt-2.5 text-[10px] leading-relaxed text-white/45">
|
||||
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
|
||||
头显(WebXR)。
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 播放器浮层的统一视觉语言。
|
||||
*
|
||||
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
|
||||
* 选集抽屉、弹幕面板、VR 工具条、错误提示……以前每一处都自己写一遍圆角、
|
||||
* 边框、透明度,于是同一屏里出现了好几种不同的浮层样式,看起来就「乱」。
|
||||
*
|
||||
* 这里把冒泡、排版、图标按钮等重复的类名收敛成常量:改一次全部同步,
|
||||
* 新加控件也不会再各写一套。
|
||||
*/
|
||||
|
||||
/** 面板底色。比纯黑更「实」,配合 backdrop-blur 后压在画面上仍然清晰可读。 */
|
||||
const PANEL_BG = 'bg-[#0d0e12]/95'
|
||||
|
||||
/** 浮层外圈阴影:暗背景下用大范围柔光,避免出现一圈生硬的黑边。 */
|
||||
const PANEL_SHADOW = 'shadow-[0_10px_38px_rgba(0,0,0,0.6)]'
|
||||
|
||||
/** 小弹层(清晰度/倍速/字幕/设置):贴着操作栏向上弹出。 */
|
||||
export const PLAYER_POPOVER =
|
||||
`z-40 overflow-hidden rounded-xl border border-white/10 ${PANEL_BG} py-1 text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||
|
||||
/**
|
||||
* 右侧抽屉(选集/弹幕设置):整条贴住播放区域右边缘,通到顶底。
|
||||
*
|
||||
* 小屏上留出 12% 的宽度而不是铺满:这样顶栏的返回按钮和标题仍然露在外面,
|
||||
* 用户不用先关抽屉才能返回(铺满时唯一的出口只剩标题栏上的关闭按钮)。
|
||||
*/
|
||||
export const PLAYER_DRAWER =
|
||||
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
|
||||
|
||||
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||
export const PLAYER_ICON_BUTTON =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
|
||||
|
||||
/** 带文字的按钮(清晰度/倍速/选集)。文字按钮和图标按钮同高,基线才对得齐。 */
|
||||
export const PLAYER_TEXT_BUTTON =
|
||||
'flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:px-2 sm:text-xs'
|
||||
|
||||
/** 弹层/面板里的一行选项。 */
|
||||
export const PLAYER_MENU_ITEM =
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs text-white/85 transition hover:bg-white/10 hover:text-white'
|
||||
|
||||
/** 弹层里的小标题(「115 云端」「本地 HLS」这类分组名)。 */
|
||||
export const PLAYER_MENU_LABEL =
|
||||
'px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/35'
|
||||
|
||||
/** 分组之间的分隔线。 */
|
||||
export const PLAYER_MENU_DIVIDER = 'my-1 h-px bg-white/10'
|
||||
|
||||
/** 抽屉/面板的标题栏。 */
|
||||
export const PLAYER_PANEL_HEADER =
|
||||
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-3'
|
||||
|
||||
/** 分段选择(播放方式、投影方式这类互斥选项)。 */
|
||||
export const PLAYER_SEGMENT_GROUP = 'flex items-center gap-1 rounded-lg bg-white/5 p-0.5'
|
||||
export const PLAYER_SEGMENT = 'flex-1 rounded-md px-2 py-1 text-[11px] font-medium transition'
|
||||
export const PLAYER_SEGMENT_ON = 'bg-rose-500 text-white'
|
||||
export const PLAYER_SEGMENT_OFF = 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
|
||||
/** 开关状态的绿色小点(例如弹幕已加载、VR 素材已识别)。 */
|
||||
export const PLAYER_STATUS_DOT = 'h-1.5 w-1.5 shrink-0 rounded-full'
|
||||
|
||||
/**
|
||||
* 进度条/音量条上的隐形 range 输入。
|
||||
*
|
||||
* 视觉部分(轨道、已播放段、圆点手柄)全部自己画,原生 range 只负责三件事:
|
||||
* 点击定位、拖拽、键盘与无障碍语义。所以它必须完全透明——但**不能**
|
||||
* pointer-events-none,否则拖不动。
|
||||
*/
|
||||
export const PLAYER_RANGE_OVERLAY =
|
||||
'absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none ' +
|
||||
'[&::-webkit-slider-runnable-track]:bg-transparent ' +
|
||||
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none ' +
|
||||
'[&::-moz-range-track]:bg-transparent ' +
|
||||
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:border-0 ' +
|
||||
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:bg-transparent'
|
||||
|
||||
/** 自绘的细轨道。悬停时整条轨道加粗,是 B 站进度条最直观的那点反馈。 */
|
||||
export const PLAYER_TRACK =
|
||||
'pointer-events-none absolute inset-x-0 h-[3px] rounded-full bg-white/25 transition-[height] duration-150'
|
||||
export const PLAYER_TRACK_FILL = 'pointer-events-none absolute inset-y-0 left-0 rounded-full'
|
||||
@@ -164,7 +164,7 @@ export function MediaDetailMainContent({
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} mode="detail" />
|
||||
<MediaVersionSwitcher media={media} />
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
@@ -40,8 +41,6 @@ import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import {
|
||||
detectVr360Profile,
|
||||
loadVr360Preference,
|
||||
@@ -1184,9 +1183,12 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [id, media])
|
||||
|
||||
// 选集列表按版本组折叠,行的 id 是组内最优版本;用户切到同一条目的其它
|
||||
// 版本后 media.id 与行 id 不再相等,这里必须按版本组比对,否则上一集/下一集
|
||||
// 与自动连播都会失效。
|
||||
const currentEpisodeIndex = useMemo(() => {
|
||||
if (!media || playlistEpisodes.length === 0) return -1
|
||||
return playlistEpisodes.findIndex((e) => e.id === media.id)
|
||||
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
|
||||
}, [media, playlistEpisodes])
|
||||
|
||||
const prevEpisode = useMemo(() => {
|
||||
@@ -1224,8 +1226,8 @@ export function PlayerPage() {
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
|
||||
const versionList = useMemo(
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
|
||||
const currentVersions = useMemo(
|
||||
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
||||
[id, media],
|
||||
)
|
||||
@@ -1324,7 +1326,47 @@ export function PlayerPage() {
|
||||
playbackInfo?.provider === 'cloud115'
|
||||
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
||||
: (playbackInfo?.local_qualities ?? [])
|
||||
const showQuality = mode !== 'direct' && qualityOptions.length > 0
|
||||
// 直连解码模式下宿主机不转码,档位选择没有意义(远程 Emby 挂载同理):直接隐藏。
|
||||
const showQuality = !directOnly && qualityOptions.length > 0
|
||||
// 清晰度按钮上显示的文字。直接播放时按「原画」呈现,和档位列表里的原画项一致,
|
||||
// 避免出现「明明是原文件却显示 1080P」这种误导。
|
||||
const originalQualityLabel =
|
||||
qualityOptions.find((quality) => quality.source === 'original')?.label || '原画'
|
||||
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
|
||||
const qualityLabel =
|
||||
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
|
||||
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
|
||||
const playbackModeLabel = isDirectStream
|
||||
? isRemoteEmbyID(media?.id)
|
||||
? 'Emby 直连播放'
|
||||
: '直连播放'
|
||||
: directOnly
|
||||
? '客户端直连解码'
|
||||
: mode === 'hls'
|
||||
? 'HLS 转码'
|
||||
: '直接播放'
|
||||
const canTogglePlaybackMode = !isDirectStream && !directOnly
|
||||
|
||||
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
|
||||
const topBarTitle = media?.title?.trim() || ''
|
||||
const topBarSubtitle = useMemo(() => {
|
||||
if (!media) return ''
|
||||
const parts: string[] = []
|
||||
if (prevEpisode || nextEpisode) {
|
||||
const total = playlistEpisodes.length
|
||||
const index = currentEpisodeIndex + 1
|
||||
parts.push(total > 0 ? `第 ${index} / ${total} 集` : `第 ${index} 集`)
|
||||
}
|
||||
if (currentVersions.length > 1) parts.push(`${currentVersions.length} 个版本`)
|
||||
return parts.join(' · ')
|
||||
}, [
|
||||
media,
|
||||
currentEpisodeIndex,
|
||||
currentVersions.length,
|
||||
nextEpisode,
|
||||
playlistEpisodes.length,
|
||||
prevEpisode,
|
||||
])
|
||||
|
||||
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
||||
useEffect(() => {
|
||||
@@ -1616,20 +1658,14 @@ export function PlayerPage() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||
<PlayerTopBar
|
||||
title={topBarTitle}
|
||||
subtitle={topBarSubtitle}
|
||||
directOnly={directOnly}
|
||||
isDirectStream={isDirectStream}
|
||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
onToggleMode={toggleMode}
|
||||
/>
|
||||
{versionList.length > 1 && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
|
||||
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
|
||||
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PlayerVideoStage
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
@@ -1659,7 +1695,8 @@ export function PlayerPage() {
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuSearchTrigger={danmakuSearchTrigger}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onOpenDanmaku={toggleDanmakuOpen}
|
||||
onToggleDanmakuEnabled={danmakuChangeEnabled}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||
@@ -1670,15 +1707,19 @@ export function PlayerPage() {
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||
hasVersions={currentVersions.length > 1}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
knownDuration={media?.duration_sec || 0}
|
||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||
qualities={qualityOptions}
|
||||
qualityLabel={qualityLabel}
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={selectPlaybackQuality}
|
||||
showQuality={showQuality}
|
||||
playbackModeLabel={playbackModeLabel}
|
||||
onTogglePlaybackMode={canTogglePlaybackMode ? toggleMode : undefined}
|
||||
waiting={cloudWaiting}
|
||||
waitingMessage={cloudWaitMessage}
|
||||
vr360={vr360}
|
||||
@@ -1693,8 +1734,10 @@ export function PlayerPage() {
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
|
||||
@@ -1,66 +1,93 @@
|
||||
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { ArrowLeft, Gauge, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
import type { PlayerMode } from './playerPageModel'
|
||||
import { PLAYER_ICON_BUTTON } from '../components/playerTheme'
|
||||
|
||||
// PlayerTopBar — 播放器顶部的极简浮层。
|
||||
//
|
||||
// 以前这里只有「返回」和一个播放方式药丸按钮,用户看不到自己在看什么;而播放方式
|
||||
// 又要和控制栏里的画质/倍速混在一起点。现在改成 B 站那样:左边是返回图标 + 当前
|
||||
// 媒体标题,右边只留一个只读的播放方式状态标签(真正的切换入口收进了控制栏的
|
||||
// 「设置」面板),顶栏因此不再参与「一堆药丸按钮」的视觉噪音。
|
||||
|
||||
type PlayerTopBarProps = {
|
||||
/** 当前播放的媒体标题。 */
|
||||
title?: string
|
||||
/** 标题下的次要信息(集数、版本等)。 */
|
||||
subtitle?: string
|
||||
directOnly: boolean
|
||||
isDirectStream?: boolean
|
||||
directStreamLabel?: string
|
||||
mode: PlayerMode
|
||||
onBack: () => void
|
||||
onToggleMode: () => void
|
||||
}
|
||||
|
||||
export function PlayerTopBar({
|
||||
title,
|
||||
subtitle,
|
||||
directOnly,
|
||||
isDirectStream,
|
||||
directStreamLabel,
|
||||
mode,
|
||||
onBack,
|
||||
onToggleMode,
|
||||
}: PlayerTopBarProps) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
const status = isDirectStream
|
||||
? {
|
||||
icon: <Sparkles size={12} />,
|
||||
label: directStreamLabel || '直连播放',
|
||||
title: directStreamLabel
|
||||
? `${directStreamLabel},默认直连播放,不进行转码`
|
||||
: '远程直连播放,不进行转码',
|
||||
}
|
||||
: directOnly
|
||||
? {
|
||||
icon: <Sparkles size={12} />,
|
||||
label: '客户端直连解码',
|
||||
title: '宿主机不转码,由客户端本地解码直连',
|
||||
}
|
||||
: mode === 'hls'
|
||||
? {
|
||||
icon: <RefreshCw size={12} />,
|
||||
label: 'HLS 转码',
|
||||
title: '当前为 HLS 转码播放,可在控制栏「设置」里切回直接播放',
|
||||
}
|
||||
: {
|
||||
icon: <Gauge size={12} />,
|
||||
label: '直接播放',
|
||||
title: '当前直接播放原始文件,可在控制栏「设置」里切到 HLS 转码',
|
||||
}
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{isDirectStream ? (
|
||||
<span
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
|
||||
>
|
||||
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
|
||||
</span>
|
||||
) : directOnly ? (
|
||||
<span
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
>
|
||||
<Sparkles size={14} /> 客户端直连解码
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleMode}
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 via-black/20 to-transparent px-2.5 pb-10 pt-2.5 sm:px-3.5 sm:pt-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className={`${PLAYER_ICON_BUTTON} pointer-events-auto h-8 w-8 bg-black/35 backdrop-blur hover:bg-black/60 sm:h-9 sm:w-9`}
|
||||
title="返回 (Esc)"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
{title ? (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white/95 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||
{title}
|
||||
</p>
|
||||
{subtitle ? (
|
||||
<p className="truncate text-[11px] text-white/65 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="pointer-events-auto flex shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-[11px] font-medium text-white/80 backdrop-blur"
|
||||
title={status.title}
|
||||
>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import {
|
||||
CircleAlert,
|
||||
Hand,
|
||||
Loader2,
|
||||
Lock,
|
||||
@@ -184,7 +185,10 @@ type PlayerVideoStageProps = {
|
||||
danmakuEpisodeId: number | string | null
|
||||
danmakuSearchTrigger?: number
|
||||
danmakuOpen: boolean
|
||||
onToggleDanmaku: () => void
|
||||
/** 打开弹幕设置面板(搜索弹幕库、调整渲染参数)。 */
|
||||
onOpenDanmaku: () => void
|
||||
/** 操作栏上的「弹」开关:只控制画面上的弹幕是否渲染。 */
|
||||
onToggleDanmakuEnabled: (next: boolean) => void
|
||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||
@@ -200,14 +204,22 @@ type PlayerVideoStageProps = {
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
/** 当前条目存在多个版本(选集面板里提供版本切换,按钮提示随之变化)。 */
|
||||
hasVersions?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
knownDuration?: number
|
||||
streamOffset?: number
|
||||
onSeekAbsolute?: (seconds: number) => boolean
|
||||
qualities?: PlaybackQuality[]
|
||||
/** 清晰度按钮上显示的文字(播放页按当前播放方式算好)。 */
|
||||
qualityLabel?: string
|
||||
selectedQuality?: string
|
||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||
showQuality?: boolean
|
||||
/** 当前播放方式的文字描述,展示在设置面板里。 */
|
||||
playbackModeLabel?: string
|
||||
/** 可切换播放方式时提供;不提供则设置面板里只显示状态。 */
|
||||
onTogglePlaybackMode?: () => void
|
||||
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
|
||||
vr360?: Vr360Profile | null
|
||||
/** 是否由文件名/画幅自动识别出 VR 素材(用于在菜单里提示)。 */
|
||||
@@ -251,7 +263,8 @@ export function PlayerVideoStage({
|
||||
danmakuEpisodeId,
|
||||
danmakuSearchTrigger = 0,
|
||||
danmakuOpen,
|
||||
onToggleDanmaku,
|
||||
onOpenDanmaku,
|
||||
onToggleDanmakuEnabled,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
onDanmakuAlternatives,
|
||||
@@ -265,14 +278,18 @@ export function PlayerVideoStage({
|
||||
nextEpisodeTitle,
|
||||
playlistOpen,
|
||||
hasPlaylist,
|
||||
hasVersions = false,
|
||||
onTogglePlaylist,
|
||||
knownDuration,
|
||||
streamOffset,
|
||||
onSeekAbsolute,
|
||||
qualities = [],
|
||||
qualityLabel = '',
|
||||
selectedQuality = '',
|
||||
onSelectQuality,
|
||||
showQuality = false,
|
||||
playbackModeLabel = '',
|
||||
onTogglePlaybackMode,
|
||||
vr360 = null,
|
||||
vr360Detected = false,
|
||||
onToggleVr360,
|
||||
@@ -780,6 +797,7 @@ export function PlayerVideoStage({
|
||||
onReady={() => setVrReadyMediaId(media.id)}
|
||||
onError={(message) => onVr360Error?.(message)}
|
||||
onProfileChange={onVr360ProfileChange}
|
||||
onExitVr={onToggleVr360}
|
||||
onUiHoldChange={setVrUiHovered}
|
||||
/>
|
||||
) : null}
|
||||
@@ -858,7 +876,8 @@ export function PlayerVideoStage({
|
||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
onToggleDanmakuEnabled={onToggleDanmakuEnabled}
|
||||
onOpenDanmaku={onOpenDanmaku}
|
||||
hasPrevEpisode={hasPrevEpisode}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onPrevEpisode={onPrevEpisode}
|
||||
@@ -867,14 +886,18 @@ export function PlayerVideoStage({
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={hasPlaylist}
|
||||
hasVersions={hasVersions}
|
||||
onTogglePlaylist={onTogglePlaylist}
|
||||
knownDuration={knownDuration}
|
||||
streamOffset={streamOffset}
|
||||
onSeekAbsolute={onSeekAbsolute}
|
||||
qualities={qualities}
|
||||
qualityLabel={qualityLabel}
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={onSelectQuality}
|
||||
showQuality={showQuality}
|
||||
playbackModeLabel={playbackModeLabel}
|
||||
onTogglePlaybackMode={onTogglePlaybackMode}
|
||||
vr360={vr360}
|
||||
vr360Detected={vr360Detected}
|
||||
onToggleVr360={onToggleVr360}
|
||||
@@ -889,7 +912,7 @@ export function PlayerVideoStage({
|
||||
)}
|
||||
{vr360 && media && !vrReady ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/80 px-5 py-3 text-sm text-white shadow-2xl backdrop-blur">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-[#0d0e12]/95 px-4 py-3 text-sm text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||
<Loader2 className="animate-spin text-rose-400" size={18} />
|
||||
正在启动 VR 全景渲染…
|
||||
</div>
|
||||
@@ -899,7 +922,7 @@ export function PlayerVideoStage({
|
||||
一边拖动画面试操作。 */}
|
||||
{vrGuideVisible ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/15 bg-black/85 px-5 py-4 text-white shadow-2xl backdrop-blur">
|
||||
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-5 py-4 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Rotate3d size={16} className="text-rose-400" />
|
||||
VR 全景播放
|
||||
@@ -915,7 +938,7 @@ export function PlayerVideoStage({
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
单击画面:显示或收起操作栏(进度条、音量、画质、倍速、VR 设置)
|
||||
单击画面:显示或收起操作栏(进度条、清晰度、倍速、设置)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
@@ -957,34 +980,35 @@ export function PlayerVideoStage({
|
||||
tabIndex={lockVisible ? 0 : -1}
|
||||
aria-label={locked ? '解锁画面,恢复操作栏' : '锁定画面,隐藏操作栏和进度框'}
|
||||
title={locked ? '点一下解锁,恢复操作栏与进度框' : '锁上后画面轻触无效,只留这个锁'}
|
||||
className={`absolute left-2 top-1/2 z-30 -translate-y-1/2 rounded-full border p-3 shadow-2xl backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||
className={`absolute left-2.5 top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-[0_6px_20px_rgba(0,0,0,0.45)] backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||
lockVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||
} ${
|
||||
locked
|
||||
? // 锁住时按钮退到很淡的状态:既提示「这里是锁」,又不打扰观看。
|
||||
'border-white/10 bg-black/35 text-white/45 hover:bg-black/65 hover:text-white'
|
||||
: 'border-white/15 bg-black/65 text-white hover:bg-black/85'
|
||||
'border-white/5 bg-black/30 text-white/40 hover:bg-black/60 hover:text-white'
|
||||
: 'border-white/10 bg-black/50 text-white/85 hover:bg-black/75 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{locked ? <Lock size={20} /> : <LockOpen size={20} />}
|
||||
{locked ? <Lock size={18} /> : <LockOpen size={18} />}
|
||||
</button>
|
||||
{/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */}
|
||||
{lockHint ? (
|
||||
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-full border border-white/15 bg-black/75 px-3 py-1.5 text-xs text-white/90 shadow-xl backdrop-blur sm:left-20">
|
||||
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-lg border border-white/10 bg-[#0d0e12]/95 px-3 py-1.5 text-xs text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl sm:left-20">
|
||||
已解锁,点画面可显示操作栏
|
||||
</div>
|
||||
) : null}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{playerError}
|
||||
<div className="absolute bottom-20 left-1/2 flex w-[min(92vw,720px)] -translate-x-1/2 items-start gap-2.5 rounded-xl border border-rose-500/25 bg-[#0d0e12]/95 px-4 py-3 text-xs leading-relaxed text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||
<CircleAlert size={15} className="mt-0.5 shrink-0 text-rose-400" />
|
||||
<span className="min-w-0 flex-1">{playerError}</span>
|
||||
</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} />
|
||||
<div className="max-w-md rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-6 py-5 text-center text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)]">
|
||||
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={26} />
|
||||
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
||||
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
|
||||
<p className="mt-1 text-xs text-white/55">转码完成后会自动播放</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { mediaVersionLabel } from './mediaVersion.ts'
|
||||
import {
|
||||
isStrmMedia,
|
||||
mediaVersionFileName,
|
||||
mediaVersionLabel,
|
||||
mediaVersionMatches,
|
||||
mediaVersionSourceLabel,
|
||||
} from './mediaVersion.ts'
|
||||
import type { Media } from '../types/index.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
@@ -41,4 +47,21 @@ const dottedPart = mediaVersionLabel(media({
|
||||
}))
|
||||
check('part2 filename fallback keeps part token', dottedPart.includes('part2'))
|
||||
|
||||
// 折叠后的选集行 id 是最优版本;播放中的可能是组内另一个版本。
|
||||
const local4k = media({ id: 'v-4k', path: '/media/Inception.2010.2160p.mkv', height: 2160 })
|
||||
const local1080 = media({ id: 'v-1080', path: '/media/Inception.2010.1080p.mkv', height: 1080 })
|
||||
const cloudRow = media({ id: 'v-cloud', path: '/strm/Inception.2010.mkv.strm', strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a' })
|
||||
const groupedRow = media({ id: 'v-4k', versions: [local4k, local1080, cloudRow] })
|
||||
|
||||
check('matches own id', mediaVersionMatches(groupedRow, 'v-4k'))
|
||||
check('matches nested version id', mediaVersionMatches(groupedRow, 'v-1080'))
|
||||
check('rejects unrelated id', !mediaVersionMatches(groupedRow, 'v-other'))
|
||||
check('rejects empty id', !mediaVersionMatches(groupedRow, ''))
|
||||
|
||||
check('strm is cloud source', mediaVersionSourceLabel(cloudRow) === '云端直链')
|
||||
check('mkv is local source', mediaVersionSourceLabel(local1080) === '本地文件')
|
||||
check('strm filename drops outer extension', mediaVersionFileName(cloudRow) === 'Inception.2010.mkv')
|
||||
check('missing path has no filename', mediaVersionFileName(media({ path: '' })) === '')
|
||||
check('isStrmMedia detects strm url', isStrmMedia(cloudRow))
|
||||
|
||||
console.log('mediaVersion.test.ts: ok')
|
||||
|
||||
@@ -176,3 +176,30 @@ export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
||||
if (media.versions && media.versions.length > 1) return media.versions
|
||||
return [media]
|
||||
}
|
||||
|
||||
/**
|
||||
* media 自身或它的任一版本是否命中 id。
|
||||
*
|
||||
* 选集列表按「版本组」折叠后,行的 id 是该组首个(最优)版本;用户切到
|
||||
* 组内其它版本时播放中的 id 与行 id 不再相等,必须连带比对 versions,
|
||||
* 否则当前集高亮、上一集/下一集都会失效。
|
||||
*/
|
||||
export function mediaVersionMatches(media: Media | null | undefined, id: string): boolean {
|
||||
if (!media || !id) return false
|
||||
if (media.id === id) return true
|
||||
return Boolean(media.versions?.some((version) => version.id === id))
|
||||
}
|
||||
|
||||
/** 版本来源:云端(STRM / 网盘直链)或本地文件。 */
|
||||
export function mediaVersionSourceLabel(media: Media): string {
|
||||
return isStrmMedia(media) ? '云端直链' : '本地文件'
|
||||
}
|
||||
|
||||
/** 版本文件名(去掉目录,并剥掉 .strm 外层扩展名),用作版本选项的次要说明。 */
|
||||
export function mediaVersionFileName(media: Media): string {
|
||||
const path = (media.path || '').replace(/\\/g, '/').trim()
|
||||
if (!path) return ''
|
||||
const base = path.split('/').filter(Boolean).pop() || ''
|
||||
if (!base) return ''
|
||||
return base.replace(/\.strm$/i, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user