优化tv详情页
This commit is contained in:
+462
-29
@@ -2,13 +2,91 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Loader2, Play, Server } from 'lucide-react';
|
import { ArrowLeft, Loader2, Play, Server } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
import {
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
import TVLayout from '@/components/tv/TVLayout';
|
import TVLayout from '@/components/tv/TVLayout';
|
||||||
import { fetchTVDetail } from '@/components/tv/player/utils';
|
import {
|
||||||
|
fetchTVDetail,
|
||||||
|
resolveTVEpisodeUrl,
|
||||||
|
} from '@/components/tv/player/utils';
|
||||||
|
|
||||||
|
type SourceTestInfo = {
|
||||||
|
quality: string;
|
||||||
|
loadSpeed: string;
|
||||||
|
pingTime: number;
|
||||||
|
bitrate: string;
|
||||||
|
score: number;
|
||||||
|
status: 'testing' | 'ok' | 'fail';
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSpeedKBps(speed: string) {
|
||||||
|
const match = speed.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
|
||||||
|
if (!match) return 0;
|
||||||
|
const value = Number.parseFloat(match[1]);
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return match[2] === 'MB/s' ? value * 1024 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityScore(quality: string) {
|
||||||
|
switch (quality) {
|
||||||
|
case '4K':
|
||||||
|
return 100;
|
||||||
|
case '2K':
|
||||||
|
return 85;
|
||||||
|
case '1080p':
|
||||||
|
return 75;
|
||||||
|
case '720p':
|
||||||
|
return 60;
|
||||||
|
case '480p':
|
||||||
|
return 40;
|
||||||
|
case 'SD':
|
||||||
|
return 20;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceKey(item: Pick<SearchResult, 'source' | 'id'>) {
|
||||||
|
return `${item.source}-${item.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceStatusRank(info?: SourceTestInfo) {
|
||||||
|
if (info?.status === 'ok') return 0;
|
||||||
|
if (info?.status === 'testing') return 1;
|
||||||
|
if (info?.status === 'fail') return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSourcesByTests(
|
||||||
|
sourceList: SearchResult[],
|
||||||
|
testMap: Record<string, SourceTestInfo>
|
||||||
|
) {
|
||||||
|
return [...sourceList].sort((a, b) => {
|
||||||
|
const infoA = testMap[sourceKey(a)];
|
||||||
|
const infoB = testMap[sourceKey(b)];
|
||||||
|
const rankA = sourceStatusRank(infoA);
|
||||||
|
const rankB = sourceStatusRank(infoB);
|
||||||
|
if (rankA !== rankB) return rankA - rankB;
|
||||||
|
if (
|
||||||
|
infoA?.status === 'ok' &&
|
||||||
|
infoB?.status === 'ok' &&
|
||||||
|
infoA.score !== infoB.score
|
||||||
|
) {
|
||||||
|
return infoB.score - infoA.score;
|
||||||
|
}
|
||||||
|
return (b.weight ?? 0) - (a.weight ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function TVDetailClient() {
|
function TVDetailClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,6 +95,14 @@ function TVDetailClient() {
|
|||||||
const [sources, setSources] = useState<SearchResult[]>([]);
|
const [sources, setSources] = useState<SearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [testingSources, setTestingSources] = useState(false);
|
||||||
|
const testedSourcesSignatureRef = useRef('');
|
||||||
|
const sourceTestsRef = useRef<Record<string, SourceTestInfo>>({});
|
||||||
|
const speedSamplesRef = useRef<number[]>([]);
|
||||||
|
const pingSamplesRef = useRef<number[]>([]);
|
||||||
|
const [sourceTests, setSourceTests] = useState<
|
||||||
|
Record<string, SourceTestInfo>
|
||||||
|
>({});
|
||||||
|
|
||||||
const source = searchParams.get('source');
|
const source = searchParams.get('source');
|
||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
@@ -38,50 +124,339 @@ function TVDetailClient() {
|
|||||||
setError(err instanceof Error ? err.message : '加载详情失败');
|
setError(err instanceof Error ? err.message : '加载详情失败');
|
||||||
})
|
})
|
||||||
.finally(() => alive && setLoading(false));
|
.finally(() => alive && setLoading(false));
|
||||||
return () => { alive = false; };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
}, [source, id, title, fileName]);
|
}, [source, id, title, fileName]);
|
||||||
|
|
||||||
const poster = useMemo(() => detail?.poster ? processImageUrl(detail.poster) : '', [detail?.poster]);
|
const poster = useMemo(
|
||||||
|
() => (detail?.poster ? processImageUrl(detail.poster) : ''),
|
||||||
|
[detail?.poster]
|
||||||
|
);
|
||||||
|
|
||||||
const play = (episode = 0, target = detail) => {
|
useEffect(() => {
|
||||||
|
sourceTestsRef.current = sourceTests;
|
||||||
|
}, [sourceTests]);
|
||||||
|
|
||||||
|
const rankedSources = useMemo(
|
||||||
|
() => sortSourcesByTests(sources, sourceTests),
|
||||||
|
[sourceTests, sources]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bestSource = rankedSources[0] || detail;
|
||||||
|
|
||||||
|
const play = (target = detail, episode?: number) => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
source: target.source,
|
source: target.source,
|
||||||
id: target.id,
|
id: target.id,
|
||||||
title: target.title,
|
title: target.title,
|
||||||
index: String(episode),
|
|
||||||
});
|
});
|
||||||
if (fileName) qs.set('fileName', fileName);
|
if (typeof episode === 'number') qs.set('index', String(episode));
|
||||||
|
if (fileName && target.source === source && target.id === id)
|
||||||
|
qs.set('fileName', fileName);
|
||||||
router.push(`/tv/play?${qs.toString()}`);
|
router.push(`/tv/play?${qs.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calculateSourceScore = useCallback(
|
||||||
|
(
|
||||||
|
testResult: Pick<SourceTestInfo, 'quality' | 'loadSpeed' | 'pingTime'>,
|
||||||
|
maxSpeed: number,
|
||||||
|
minPing: number,
|
||||||
|
maxPing: number,
|
||||||
|
weight = 0
|
||||||
|
) => {
|
||||||
|
const speed = parseSpeedKBps(testResult.loadSpeed);
|
||||||
|
const speedScore =
|
||||||
|
speed > 0 ? Math.min(100, (speed / maxSpeed) * 100) : 30;
|
||||||
|
const pingScore = (() => {
|
||||||
|
const ping = testResult.pingTime;
|
||||||
|
if (ping <= 0) return 0;
|
||||||
|
if (maxPing === minPing) return 100;
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, ((maxPing - ping) / (maxPing - minPing)) * 100)
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.round(
|
||||||
|
(qualityScore(testResult.quality) * 0.4 +
|
||||||
|
speedScore * 0.4 +
|
||||||
|
pingScore * 0.2 +
|
||||||
|
weight) *
|
||||||
|
100
|
||||||
|
) / 100
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPlayableSource = useCallback(async (item: SearchResult) => {
|
||||||
|
if (item.episodes?.length) return item;
|
||||||
|
const data = await fetchTVDetail({
|
||||||
|
source: item.source,
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
});
|
||||||
|
return data.detail;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const testSource = useCallback(
|
||||||
|
async (
|
||||||
|
item: SearchResult
|
||||||
|
): Promise<{
|
||||||
|
item: SearchResult;
|
||||||
|
result: Omit<SourceTestInfo, 'score' | 'status'> | null;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const playable = await fetchPlayableSource(item);
|
||||||
|
const rawUrl =
|
||||||
|
playable.episodes?.[
|
||||||
|
Math.min(1, Math.max(0, playable.episodes.length - 1))
|
||||||
|
];
|
||||||
|
if (!rawUrl) throw new Error('无播放地址');
|
||||||
|
const testUrl = await resolveTVEpisodeUrl(
|
||||||
|
rawUrl,
|
||||||
|
playable.source,
|
||||||
|
playable.proxyMode
|
||||||
|
);
|
||||||
|
const result = await getVideoResolutionFromM3u8(testUrl, 5000);
|
||||||
|
return { item: playable, result };
|
||||||
|
} catch {
|
||||||
|
return { item, result: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchPlayableSource]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSourceTest = useCallback(
|
||||||
|
(item: SearchResult, info: SourceTestInfo) => {
|
||||||
|
const key = sourceKey(item);
|
||||||
|
const nextTests = { ...sourceTestsRef.current, [key]: info };
|
||||||
|
sourceTestsRef.current = nextTests;
|
||||||
|
setSourceTests(nextTests);
|
||||||
|
setSources((currentSources) => {
|
||||||
|
const nextSources = currentSources.map((sourceItem) =>
|
||||||
|
sourceKey(sourceItem) === key ? item : sourceItem
|
||||||
|
);
|
||||||
|
return sortSourcesByTests(nextSources, nextTests);
|
||||||
|
});
|
||||||
|
setDetail((current) => {
|
||||||
|
if (!current || sourceKey(current) !== key) return current;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const testAllSources = useCallback(
|
||||||
|
async (candidateSources = sources) => {
|
||||||
|
if (candidateSources.length === 0 || testingSources) return null;
|
||||||
|
setTestingSources(true);
|
||||||
|
speedSamplesRef.current = [];
|
||||||
|
pingSamplesRef.current = [];
|
||||||
|
|
||||||
|
const initialTests: Record<string, SourceTestInfo> = {};
|
||||||
|
candidateSources.forEach((item) => {
|
||||||
|
initialTests[sourceKey(item)] = {
|
||||||
|
quality: '测速中',
|
||||||
|
loadSpeed: '测量中...',
|
||||||
|
pingTime: 0,
|
||||||
|
bitrate: '未知',
|
||||||
|
score: 0,
|
||||||
|
status: 'testing',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
sourceTestsRef.current = initialTests;
|
||||||
|
setSourceTests(initialTests);
|
||||||
|
setSources((currentSources) =>
|
||||||
|
sortSourcesByTests(currentSources, initialTests)
|
||||||
|
);
|
||||||
|
|
||||||
|
let completedCount = 0;
|
||||||
|
let bestSource: SearchResult | null = null;
|
||||||
|
let nextIndex = 0;
|
||||||
|
const maxConcurrency = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(Math.ceil(candidateSources.length / 2), 8)
|
||||||
|
);
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (nextIndex < candidateSources.length) {
|
||||||
|
const currentIndex = nextIndex++;
|
||||||
|
const originalItem = candidateSources[currentIndex];
|
||||||
|
const { item, result } = await testSource(originalItem);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const speed = parseSpeedKBps(result.loadSpeed);
|
||||||
|
if (speed > 0) speedSamplesRef.current.push(speed);
|
||||||
|
if (result.pingTime > 0)
|
||||||
|
pingSamplesRef.current.push(result.pingTime);
|
||||||
|
const maxSpeed =
|
||||||
|
speedSamplesRef.current.length > 0
|
||||||
|
? Math.max(...speedSamplesRef.current)
|
||||||
|
: 1024;
|
||||||
|
const minPing =
|
||||||
|
pingSamplesRef.current.length > 0
|
||||||
|
? Math.min(...pingSamplesRef.current)
|
||||||
|
: 50;
|
||||||
|
const maxPing =
|
||||||
|
pingSamplesRef.current.length > 0
|
||||||
|
? Math.max(...pingSamplesRef.current)
|
||||||
|
: 1000;
|
||||||
|
const info: SourceTestInfo = {
|
||||||
|
...result,
|
||||||
|
score: calculateSourceScore(
|
||||||
|
result,
|
||||||
|
maxSpeed,
|
||||||
|
minPing,
|
||||||
|
maxPing,
|
||||||
|
item.weight ?? 0
|
||||||
|
),
|
||||||
|
status: 'ok',
|
||||||
|
};
|
||||||
|
updateSourceTest(item, info);
|
||||||
|
const currentBest = sortSourcesByTests(
|
||||||
|
[item, ...(bestSource ? [bestSource] : [])],
|
||||||
|
{ ...sourceTestsRef.current, [sourceKey(item)]: info }
|
||||||
|
)[0];
|
||||||
|
bestSource = currentBest || item;
|
||||||
|
} else {
|
||||||
|
updateSourceTest(item, {
|
||||||
|
quality: '失败',
|
||||||
|
loadSpeed: '不可用',
|
||||||
|
pingTime: 0,
|
||||||
|
bitrate: '未知',
|
||||||
|
score: -1,
|
||||||
|
status: 'fail',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
completedCount += 1;
|
||||||
|
if (completedCount === candidateSources.length) {
|
||||||
|
setTestingSources(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(
|
||||||
|
{ length: Math.min(maxConcurrency, candidateSources.length) },
|
||||||
|
() => worker()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
sortSourcesByTests(candidateSources, sourceTestsRef.current)[0] ||
|
||||||
|
bestSource
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTestingSources(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
calculateSourceScore,
|
||||||
|
sources,
|
||||||
|
testSource,
|
||||||
|
testingSources,
|
||||||
|
updateSourceTest,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || sources.length <= 1) return;
|
||||||
|
const signature = Array.from(new Set(sources.map(sourceKey)))
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
if (!signature || testedSourcesSignatureRef.current === signature) return;
|
||||||
|
testedSourcesSignatureRef.current = signature;
|
||||||
|
testAllSources(sources).catch(() => setTestingSources(false));
|
||||||
|
}, [loading, sources, testAllSources]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <TVLayout><div className='mt-20 flex items-center justify-center gap-4 text-3xl text-slate-200'><Loader2 className='h-10 w-10 animate-spin text-rose-500' />正在加载详情...</div></TVLayout>;
|
return (
|
||||||
|
<TVLayout>
|
||||||
|
<div className='mt-20 flex items-center justify-center gap-4 text-3xl text-slate-200'>
|
||||||
|
<Loader2 className='h-10 w-10 animate-spin text-rose-500' />
|
||||||
|
正在加载详情...
|
||||||
|
</div>
|
||||||
|
</TVLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !detail) {
|
if (error || !detail) {
|
||||||
return <TVLayout><section className='rounded-[36px] border border-red-500/40 bg-red-950/40 p-10 text-3xl font-bold text-red-100'>{error || '详情不存在'}</section></TVLayout>;
|
return (
|
||||||
|
<TVLayout>
|
||||||
|
<section className='rounded-[36px] border border-red-500/40 bg-red-950/40 p-10 text-3xl font-bold text-red-100'>
|
||||||
|
{error || '详情不存在'}
|
||||||
|
</section>
|
||||||
|
</TVLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TVLayout>
|
<TVLayout>
|
||||||
<section className='relative overflow-hidden rounded-[44px] border border-white/10 bg-slate-950/80 p-8 shadow-2xl shadow-black/70'>
|
<section className='relative overflow-hidden rounded-[44px] border border-white/10 bg-slate-950/80 p-8 shadow-2xl shadow-black/70'>
|
||||||
{poster && <img src={poster} alt='' className='absolute inset-0 h-full w-full object-cover opacity-20 blur-xl' />}
|
{poster && (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt=''
|
||||||
|
className='absolute inset-0 h-full w-full object-cover opacity-20 blur-xl'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className='relative grid grid-cols-[300px_1fr] gap-10'>
|
<div className='relative grid grid-cols-[300px_1fr] gap-10'>
|
||||||
<div className='overflow-hidden rounded-[32px] bg-slate-900 shadow-2xl shadow-black/70'>
|
<div className='overflow-hidden rounded-[32px] bg-slate-900 shadow-2xl shadow-black/70'>
|
||||||
{poster ? <img src={poster} alt={detail.title} className='aspect-[2/3] h-full w-full object-cover' /> : <div className='aspect-[2/3]' />}
|
{poster ? (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt={detail.title}
|
||||||
|
className='aspect-[2/3] h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='aspect-[2/3]' />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='py-2'>
|
<div className='py-2'>
|
||||||
<button onClick={() => router.back()} className='tv-focusable mb-6 flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-3 text-xl font-bold outline-none'><ArrowLeft className='h-6 w-6' />返回</button>
|
<button
|
||||||
<h1 className='text-6xl font-black tracking-tight text-white'>{detail.title}</h1>
|
onClick={() => router.back()}
|
||||||
|
className='tv-focusable mb-6 flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-3 text-xl font-bold outline-none'
|
||||||
|
>
|
||||||
|
<ArrowLeft className='h-6 w-6' />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<h1 className='text-6xl font-black tracking-tight text-white'>
|
||||||
|
{detail.title}
|
||||||
|
</h1>
|
||||||
<div className='mt-4 flex flex-wrap gap-3 text-xl font-bold text-slate-200'>
|
<div className='mt-4 flex flex-wrap gap-3 text-xl font-bold text-slate-200'>
|
||||||
<span className='rounded-full bg-rose-600 px-4 py-2'>{detail.source_name || detail.source}</span>
|
<span className='rounded-full bg-rose-600 px-4 py-2'>
|
||||||
{detail.year && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.year}</span>}
|
{detail.source_name || detail.source}
|
||||||
{detail.type_name && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.type_name}</span>}
|
</span>
|
||||||
{detail.vod_remarks && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.vod_remarks}</span>}
|
{detail.year && (
|
||||||
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{detail.year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.type_name && (
|
||||||
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{detail.type_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.vod_remarks && (
|
||||||
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{detail.vod_remarks}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{detail.desc && <p className='mt-6 line-clamp-5 max-w-5xl text-2xl leading-relaxed text-slate-300'>{detail.desc}</p>}
|
{detail.desc && (
|
||||||
<button onClick={() => play(0)} className='tv-focusable mt-8 flex cursor-pointer items-center gap-3 rounded-3xl bg-rose-600 px-9 py-5 text-3xl font-black text-white outline-none'>
|
<p className='mt-6 line-clamp-5 max-w-5xl text-2xl leading-relaxed text-slate-300'>
|
||||||
|
{detail.desc}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => play(bestSource)}
|
||||||
|
className='tv-focusable mt-8 flex cursor-pointer items-center gap-3 rounded-3xl bg-rose-600 px-9 py-5 text-3xl font-black text-white outline-none'
|
||||||
|
>
|
||||||
<Play className='h-9 w-9 fill-current' /> 立即播放
|
<Play className='h-9 w-9 fill-current' /> 立即播放
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,13 +465,60 @@ function TVDetailClient() {
|
|||||||
|
|
||||||
{sources.length > 1 && (
|
{sources.length > 1 && (
|
||||||
<section className='mt-10 rounded-[36px] border border-white/10 bg-white/[0.04] p-6'>
|
<section className='mt-10 rounded-[36px] border border-white/10 bg-white/[0.04] p-6'>
|
||||||
<h2 className='mb-5 text-4xl font-black'>播放源</h2>
|
<div className='mb-5 flex flex-wrap items-center justify-between gap-4'>
|
||||||
|
<h2 className='text-4xl font-black'>播放源</h2>
|
||||||
|
<div className='flex items-center gap-3 rounded-2xl bg-white/10 px-5 py-3 text-xl font-black text-slate-100'>
|
||||||
|
{testingSources && (
|
||||||
|
<Loader2 className='h-6 w-6 animate-spin text-amber-300' />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{testingSources
|
||||||
|
? '正在自动优选测速并排序...'
|
||||||
|
: Object.keys(sourceTests).length > 0
|
||||||
|
? '已按优选测速结果自动排序'
|
||||||
|
: '将自动优选测速排序'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className='flex flex-wrap gap-4 px-4 py-4'>
|
<div className='flex flex-wrap gap-4 px-4 py-4'>
|
||||||
{sources.map((item) => (
|
{rankedSources.map((item, index) => {
|
||||||
<button key={`${item.source}-${item.id}`} onClick={() => setDetail(item)} className={`tv-focusable flex min-w-[180px] cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-2xl px-6 py-4 text-2xl font-bold outline-none ${detail.source === item.source && detail.id === item.id ? 'bg-rose-600 text-white' : 'bg-white/10 text-slate-200'}`}>
|
const info = sourceTests[sourceKey(item)];
|
||||||
<Server className='h-6 w-6' /> {item.source_name || item.source}
|
const active =
|
||||||
</button>
|
detail.source === item.source && detail.id === item.id;
|
||||||
))}
|
return (
|
||||||
|
<button
|
||||||
|
key={`${item.source}-${item.id}`}
|
||||||
|
onClick={() => play(item)}
|
||||||
|
className={`tv-focusable flex min-w-[210px] cursor-pointer flex-col items-start justify-center gap-1 whitespace-nowrap rounded-2xl px-6 py-4 text-left text-2xl font-bold outline-none ${
|
||||||
|
active
|
||||||
|
? 'bg-rose-600 text-white'
|
||||||
|
: 'bg-white/10 text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<Server className='h-6 w-6' /> #{index + 1}{' '}
|
||||||
|
{item.source_name || item.source}
|
||||||
|
</span>
|
||||||
|
{info && (
|
||||||
|
<span
|
||||||
|
className={`text-base font-bold ${
|
||||||
|
info.status === 'ok'
|
||||||
|
? 'text-emerald-200'
|
||||||
|
: info.status === 'testing'
|
||||||
|
? 'text-amber-200'
|
||||||
|
: 'text-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{info.status === 'ok'
|
||||||
|
? `${info.quality} · ${info.loadSpeed}`
|
||||||
|
: info.status === 'testing'
|
||||||
|
? '测速中...'
|
||||||
|
: '测试失败'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -104,8 +526,15 @@ function TVDetailClient() {
|
|||||||
<section className='mt-10 rounded-[36px] border border-white/10 bg-white/[0.04] p-6'>
|
<section className='mt-10 rounded-[36px] border border-white/10 bg-white/[0.04] p-6'>
|
||||||
<h2 className='mb-5 text-4xl font-black'>选集</h2>
|
<h2 className='mb-5 text-4xl font-black'>选集</h2>
|
||||||
<div className='grid grid-cols-3 gap-4 md:grid-cols-5 lg:grid-cols-8'>
|
<div className='grid grid-cols-3 gap-4 md:grid-cols-5 lg:grid-cols-8'>
|
||||||
{(detail.episodes_titles?.length ? detail.episodes_titles : detail.episodes).map((ep, index) => (
|
{(detail.episodes_titles?.length
|
||||||
<button key={`${ep}-${index}`} onClick={() => play(index)} className='tv-focusable min-h-20 cursor-pointer rounded-2xl bg-white/10 px-4 py-3 text-xl font-black text-white outline-none'>
|
? detail.episodes_titles
|
||||||
|
: detail.episodes
|
||||||
|
).map((ep, index) => (
|
||||||
|
<button
|
||||||
|
key={`${ep}-${index}`}
|
||||||
|
onClick={() => play(bestSource, index)}
|
||||||
|
className='tv-focusable min-h-20 cursor-pointer rounded-2xl bg-white/10 px-4 py-3 text-xl font-black text-white outline-none'
|
||||||
|
>
|
||||||
{detail.episodes_titles?.[index] || `第 ${index + 1} 集`}
|
{detail.episodes_titles?.[index] || `第 ${index + 1} 集`}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -116,5 +545,9 @@ function TVDetailClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TVDetailPage() {
|
export default function TVDetailPage() {
|
||||||
return <Suspense fallback={null}><TVDetailClient /></Suspense>;
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<TVDetailClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+799
-192
@@ -1,15 +1,65 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, SlidersHorizontal, X, Volume2, VolumeX } from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
Heart,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
ListVideo,
|
||||||
|
Loader2,
|
||||||
|
Maximize,
|
||||||
|
MessageCircle,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldOff,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
SlidersHorizontal,
|
||||||
|
X,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, type Dispatch, type FocusEvent, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
Suspense,
|
||||||
|
type Dispatch,
|
||||||
|
type FocusEvent,
|
||||||
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { deleteFavorite, deletePlayRecord, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client';
|
import {
|
||||||
|
deleteFavorite,
|
||||||
|
generateStorageKey,
|
||||||
|
getAllPlayRecords,
|
||||||
|
getSkipConfig,
|
||||||
|
isFavorited,
|
||||||
|
saveFavorite,
|
||||||
|
savePlayRecord,
|
||||||
|
} from '@/lib/db.client';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, loadDanmakuDisplayState, saveDanmakuDisplayState, searchAnime } from '@/lib/danmaku/api';
|
import {
|
||||||
|
convertDanmakuFormat,
|
||||||
|
getDanmakuById,
|
||||||
|
getEpisodes,
|
||||||
|
initDanmakuModule,
|
||||||
|
loadDanmakuDisplayState,
|
||||||
|
saveDanmakuDisplayState,
|
||||||
|
searchAnime,
|
||||||
|
} from '@/lib/danmaku/api';
|
||||||
|
|
||||||
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
||||||
import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils';
|
import {
|
||||||
|
fetchTVDetail,
|
||||||
|
formatTVTime,
|
||||||
|
resolveTVEpisodeUrl,
|
||||||
|
} from '@/components/tv/player/utils';
|
||||||
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
||||||
|
|
||||||
const TV_DANMAKU_LANES = 8;
|
const TV_DANMAKU_LANES = 8;
|
||||||
@@ -38,9 +88,18 @@ function loadTVDanmakuSettings(): TVDanmakuSettings {
|
|||||||
if (!saved) return DEFAULT_TV_DANMAKU_SETTINGS;
|
if (!saved) return DEFAULT_TV_DANMAKU_SETTINGS;
|
||||||
const parsed = JSON.parse(saved) as Partial<TVDanmakuSettings>;
|
const parsed = JSON.parse(saved) as Partial<TVDanmakuSettings>;
|
||||||
return {
|
return {
|
||||||
fontSize: typeof parsed.fontSize === 'number' ? parsed.fontSize : DEFAULT_TV_DANMAKU_SETTINGS.fontSize,
|
fontSize:
|
||||||
displayArea: typeof parsed.displayArea === 'number' ? parsed.displayArea : DEFAULT_TV_DANMAKU_SETTINGS.displayArea,
|
typeof parsed.fontSize === 'number'
|
||||||
opacity: typeof parsed.opacity === 'number' ? parsed.opacity : DEFAULT_TV_DANMAKU_SETTINGS.opacity,
|
? parsed.fontSize
|
||||||
|
: DEFAULT_TV_DANMAKU_SETTINGS.fontSize,
|
||||||
|
displayArea:
|
||||||
|
typeof parsed.displayArea === 'number'
|
||||||
|
? parsed.displayArea
|
||||||
|
: DEFAULT_TV_DANMAKU_SETTINGS.displayArea,
|
||||||
|
opacity:
|
||||||
|
typeof parsed.opacity === 'number'
|
||||||
|
? parsed.opacity
|
||||||
|
: DEFAULT_TV_DANMAKU_SETTINGS.opacity,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_TV_DANMAKU_SETTINGS;
|
return DEFAULT_TV_DANMAKU_SETTINGS;
|
||||||
@@ -53,7 +112,10 @@ function getTVDanmakuDuration(text: string) {
|
|||||||
|
|
||||||
function blurTVPlayerControl() {
|
function blurTVPlayerControl() {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active instanceof HTMLElement && active.closest('[data-tv-player-control]')) {
|
if (
|
||||||
|
active instanceof HTMLElement &&
|
||||||
|
active.closest('[data-tv-player-control]')
|
||||||
|
) {
|
||||||
active.blur();
|
active.blur();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +123,11 @@ function blurTVPlayerControl() {
|
|||||||
function scrollFocusedControlIntoView(event: FocusEvent<HTMLElement>) {
|
function scrollFocusedControlIntoView(event: FocusEvent<HTMLElement>) {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof HTMLElement) {
|
if (target instanceof HTMLElement) {
|
||||||
target.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
|
target.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,22 +152,28 @@ function updateTVDanmakuSetting(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDanmakuSettingField(target: HTMLElement | null): keyof TVDanmakuSettings | null {
|
function getDanmakuSettingField(
|
||||||
if (!(target instanceof HTMLInputElement) || target.type !== 'range') return null;
|
target: HTMLElement | null
|
||||||
|
): keyof TVDanmakuSettings | null {
|
||||||
|
if (!(target instanceof HTMLInputElement) || target.type !== 'range')
|
||||||
|
return null;
|
||||||
const field = target.dataset.tvDanmakuField;
|
const field = target.dataset.tvDanmakuField;
|
||||||
if (field === 'fontSize' || field === 'displayArea' || field === 'opacity') return field;
|
if (field === 'fontSize' || field === 'displayArea' || field === 'opacity')
|
||||||
|
return field;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFocusableElementsInScope(scope: HTMLElement) {
|
function getFocusableElementsInScope(scope: HTMLElement) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
scope.querySelectorAll<HTMLElement>([
|
scope.querySelectorAll<HTMLElement>(
|
||||||
'button:not([disabled])',
|
[
|
||||||
'input:not([disabled])',
|
'button:not([disabled])',
|
||||||
'select:not([disabled])',
|
'input:not([disabled])',
|
||||||
'textarea:not([disabled])',
|
'select:not([disabled])',
|
||||||
'[tabindex]:not([tabindex="-1"])',
|
'textarea:not([disabled])',
|
||||||
].join(','))
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',')
|
||||||
|
)
|
||||||
).filter((element) => !element.closest('[data-tv-no-focus="true"]'));
|
).filter((element) => !element.closest('[data-tv-no-focus="true"]'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +188,10 @@ function moveFocusWithinScope(scope: HTMLElement, direction: 'up' | 'down') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextIndex = direction === 'down'
|
const nextIndex =
|
||||||
? Math.min(elements.length - 1, index + 1)
|
direction === 'down'
|
||||||
: Math.max(0, index - 1);
|
? Math.min(elements.length - 1, index + 1)
|
||||||
|
: Math.max(0, index - 1);
|
||||||
elements[nextIndex]?.focus({ preventScroll: true });
|
elements[nextIndex]?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +221,11 @@ function TVPlayClient() {
|
|||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [showVolumeHint, setShowVolumeHint] = useState(false);
|
const [showVolumeHint, setShowVolumeHint] = useState(false);
|
||||||
const [seekHint, setSeekHint] = useState<{ current: number; duration: number; delta: number } | null>(null);
|
const [seekHint, setSeekHint] = useState<{
|
||||||
|
current: number;
|
||||||
|
duration: number;
|
||||||
|
delta: number;
|
||||||
|
} | null>(null);
|
||||||
const [adFilterEnabled, setAdFilterEnabled] = useState(() => {
|
const [adFilterEnabled, setAdFilterEnabled] = useState(() => {
|
||||||
if (typeof window === 'undefined') return true;
|
if (typeof window === 'undefined') return true;
|
||||||
const saved = localStorage.getItem('enable_blockad');
|
const saved = localStorage.getItem('enable_blockad');
|
||||||
@@ -161,24 +238,36 @@ function TVPlayClient() {
|
|||||||
const legacySaved = localStorage.getItem('tv_danmaku_enabled');
|
const legacySaved = localStorage.getItem('tv_danmaku_enabled');
|
||||||
return legacySaved === null ? true : legacySaved === 'true';
|
return legacySaved === null ? true : legacySaved === 'true';
|
||||||
});
|
});
|
||||||
const [danmakuItems, setDanmakuItems] = useState<Array<{ text: string; time: number; color: string; mode: number }>>([]);
|
const [danmakuItems, setDanmakuItems] = useState<
|
||||||
const [danmakuSettings, setDanmakuSettings] = useState<TVDanmakuSettings>(() => loadTVDanmakuSettings());
|
Array<{ text: string; time: number; color: string; mode: number }>
|
||||||
const [activeDanmakuItems, setActiveDanmakuItems] = useState<Array<{
|
>([]);
|
||||||
id: string;
|
const [danmakuSettings, setDanmakuSettings] = useState<TVDanmakuSettings>(
|
||||||
text: string;
|
() => loadTVDanmakuSettings()
|
||||||
time: number;
|
);
|
||||||
color: string;
|
const [activeDanmakuItems, setActiveDanmakuItems] = useState<
|
||||||
duration: number;
|
Array<{
|
||||||
lane: number;
|
id: string;
|
||||||
}>>([]);
|
text: string;
|
||||||
|
time: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
lane: number;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
const [playbackRate, setPlaybackRate] = useState(() => {
|
const [playbackRate, setPlaybackRate] = useState(() => {
|
||||||
if (typeof window === 'undefined') return 1;
|
if (typeof window === 'undefined') return 1;
|
||||||
return Number(localStorage.getItem('tv_playback_rate') || '1') || 1;
|
return Number(localStorage.getItem('tv_playback_rate') || '1') || 1;
|
||||||
});
|
});
|
||||||
const [skipConfig, setSkipConfig] = useState<{ enable?: boolean; intro_time?: number; outro_time?: number } | null>(null);
|
const [skipConfig, setSkipConfig] = useState<{
|
||||||
|
enable?: boolean;
|
||||||
|
intro_time?: number;
|
||||||
|
outro_time?: number;
|
||||||
|
} | null>(null);
|
||||||
const [time, setTime] = useState({ current: 0, duration: 0 });
|
const [time, setTime] = useState({ current: 0, duration: 0 });
|
||||||
const timeRef = useRef({ current: 0, duration: 0 });
|
const timeRef = useRef({ current: 0, duration: 0 });
|
||||||
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const danmakuFontSizeInputRef = useRef<HTMLInputElement | null>(null);
|
const danmakuFontSizeInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const digitTimerRef = useRef<number | null>(null);
|
const digitTimerRef = useRef<number | null>(null);
|
||||||
@@ -187,7 +276,6 @@ function TVPlayClient() {
|
|||||||
const seekHintTimerRef = useRef<number | null>(null);
|
const seekHintTimerRef = useRef<number | null>(null);
|
||||||
const spawnedDanmakuRef = useRef<Set<string>>(new Set());
|
const spawnedDanmakuRef = useRef<Set<string>>(new Set());
|
||||||
const lastDanmakuTimeRef = useRef(0);
|
const lastDanmakuTimeRef = useRef(0);
|
||||||
const suppressPlayRecordSaveKeyRef = useRef<string | null>(null);
|
|
||||||
const skippedIntroRef = useRef('');
|
const skippedIntroRef = useRef('');
|
||||||
const skippedOutroRef = useRef('');
|
const skippedOutroRef = useRef('');
|
||||||
const lastSavedRef = useRef<{
|
const lastSavedRef = useRef<{
|
||||||
@@ -215,14 +303,24 @@ function TVPlayClient() {
|
|||||||
setSources(data.sources);
|
setSources(data.sources);
|
||||||
const maxIndex = Math.max(0, (data.detail.episodes?.length || 1) - 1);
|
const maxIndex = Math.max(0, (data.detail.episodes?.length || 1) - 1);
|
||||||
const explicitIndex = searchParams.has('index');
|
const explicitIndex = searchParams.has('index');
|
||||||
let safeIndex = Math.max(0, Math.min(initialIndex || data.detail.initialEpisodeIndex || 0, maxIndex));
|
let safeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
initialIndex || data.detail.initialEpisodeIndex || 0,
|
||||||
|
maxIndex
|
||||||
|
)
|
||||||
|
);
|
||||||
if (!explicitIndex && data.detail.source && data.detail.id) {
|
if (!explicitIndex && data.detail.source && data.detail.id) {
|
||||||
getAllPlayRecords()
|
getAllPlayRecords()
|
||||||
.then((records) => {
|
.then((records) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
const record = records[generateStorageKey(data.detail.source, data.detail.id)];
|
const record =
|
||||||
|
records[generateStorageKey(data.detail.source, data.detail.id)];
|
||||||
if (record?.index) {
|
if (record?.index) {
|
||||||
const rememberedIndex = Math.max(0, Math.min(maxIndex, record.index - 1));
|
const rememberedIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(maxIndex, record.index - 1)
|
||||||
|
);
|
||||||
setEpisodeIndex(rememberedIndex);
|
setEpisodeIndex(rememberedIndex);
|
||||||
setStartTime(record.play_time > 1 ? record.play_time : 0);
|
setStartTime(record.play_time > 1 ? record.play_time : 0);
|
||||||
}
|
}
|
||||||
@@ -233,9 +331,15 @@ function TVPlayClient() {
|
|||||||
}
|
}
|
||||||
setEpisodeIndex(safeIndex);
|
setEpisodeIndex(safeIndex);
|
||||||
})
|
})
|
||||||
.catch((err) => alive && setError(err instanceof Error ? err.message : '加载播放信息失败'))
|
.catch(
|
||||||
|
(err) =>
|
||||||
|
alive &&
|
||||||
|
setError(err instanceof Error ? err.message : '加载播放信息失败')
|
||||||
|
)
|
||||||
.finally(() => alive && setLoading(false));
|
.finally(() => alive && setLoading(false));
|
||||||
return () => { alive = false; };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
}, [source, id, title, fileName, initialIndex]);
|
}, [source, id, title, fileName, initialIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -246,26 +350,38 @@ function TVPlayClient() {
|
|||||||
setVideoUrl('');
|
setVideoUrl('');
|
||||||
setPlaybackError(false);
|
setPlaybackError(false);
|
||||||
try {
|
try {
|
||||||
const url = await resolveTVEpisodeUrl(detail.episodes[episodeIndex], detail.source, detail.proxyMode);
|
const url = await resolveTVEpisodeUrl(
|
||||||
|
detail.episodes[episodeIndex],
|
||||||
|
detail.source,
|
||||||
|
detail.proxyMode
|
||||||
|
);
|
||||||
if (alive) setVideoUrl(url);
|
if (alive) setVideoUrl(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (alive) setError(err instanceof Error ? err.message : '获取播放地址失败');
|
if (alive)
|
||||||
|
setError(err instanceof Error ? err.message : '获取播放地址失败');
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) setResolving(false);
|
if (alive) setResolving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
return () => { alive = false; };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
}, [detail, episodeIndex, retryNonce]);
|
}, [detail, episodeIndex, retryNonce]);
|
||||||
|
|
||||||
const episodeTitle = useMemo(() => detail?.episodes_titles?.[episodeIndex] || `第 ${episodeIndex + 1} 集`, [detail, episodeIndex]);
|
const episodeTitle = useMemo(
|
||||||
|
() =>
|
||||||
|
detail?.episodes_titles?.[episodeIndex] || `第 ${episodeIndex + 1} 集`,
|
||||||
|
[detail, episodeIndex]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initDanmakuModule();
|
initDanmakuModule();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') localStorage.setItem('enable_blockad', String(adFilterEnabled));
|
if (typeof window !== 'undefined')
|
||||||
|
localStorage.setItem('enable_blockad', String(adFilterEnabled));
|
||||||
}, [adFilterEnabled]);
|
}, [adFilterEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -276,11 +392,15 @@ function TVPlayClient() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(TV_DANMAKU_SETTINGS_KEY, JSON.stringify(danmakuSettings));
|
localStorage.setItem(
|
||||||
|
TV_DANMAKU_SETTINGS_KEY,
|
||||||
|
JSON.stringify(danmakuSettings)
|
||||||
|
);
|
||||||
}, [danmakuSettings]);
|
}, [danmakuSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate));
|
if (typeof window !== 'undefined')
|
||||||
|
localStorage.setItem('tv_playback_rate', String(playbackRate));
|
||||||
}, [playbackRate]);
|
}, [playbackRate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,23 +416,42 @@ function TVPlayClient() {
|
|||||||
const anime = search.animes?.[0];
|
const anime = search.animes?.[0];
|
||||||
if (!alive || !anime?.animeId) return;
|
if (!alive || !anime?.animeId) return;
|
||||||
const eps = await getEpisodes(anime.animeId);
|
const eps = await getEpisodes(anime.animeId);
|
||||||
const ep = eps.bangumi?.episodes?.[Math.min(episodeIndex, Math.max(0, (eps.bangumi?.episodes?.length || 1) - 1))];
|
const ep =
|
||||||
|
eps.bangumi?.episodes?.[
|
||||||
|
Math.min(
|
||||||
|
episodeIndex,
|
||||||
|
Math.max(0, (eps.bangumi?.episodes?.length || 1) - 1)
|
||||||
|
)
|
||||||
|
];
|
||||||
if (!alive || !ep?.episodeId) return;
|
if (!alive || !ep?.episodeId) return;
|
||||||
const comments = await getDanmakuById(ep.episodeId, detail.title, episodeIndex, undefined, {
|
const comments = await getDanmakuById(
|
||||||
animeId: anime.animeId,
|
ep.episodeId,
|
||||||
animeTitle: anime.animeTitle,
|
detail.title,
|
||||||
episodeTitle: ep.episodeTitle,
|
episodeIndex,
|
||||||
searchKeyword: title || detail.title,
|
undefined,
|
||||||
});
|
{
|
||||||
|
animeId: anime.animeId,
|
||||||
|
animeTitle: anime.animeTitle,
|
||||||
|
episodeTitle: ep.episodeTitle,
|
||||||
|
searchKeyword: title || detail.title,
|
||||||
|
}
|
||||||
|
);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
lastDanmakuTimeRef.current = Math.max(0, timeRef.current.current - TV_DANMAKU_SEEK_WINDOW - 1);
|
lastDanmakuTimeRef.current = Math.max(
|
||||||
setDanmakuItems(convertDanmakuFormat(comments).slice(0, TV_DANMAKU_MAX_ITEMS));
|
0,
|
||||||
|
timeRef.current.current - TV_DANMAKU_SEEK_WINDOW - 1
|
||||||
|
);
|
||||||
|
setDanmakuItems(
|
||||||
|
convertDanmakuFormat(comments).slice(0, TV_DANMAKU_MAX_ITEMS)
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
if (alive) setDanmakuItems([]);
|
if (alive) setDanmakuItems([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadDanmaku();
|
loadDanmaku();
|
||||||
return () => { alive = false; };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
}, [danmakuEnabled, detail?.title, episodeIndex, title]);
|
}, [danmakuEnabled, detail?.title, episodeIndex, title]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -326,7 +465,9 @@ function TVPlayClient() {
|
|||||||
const current = time.current;
|
const current = time.current;
|
||||||
const previous = lastDanmakuTimeRef.current;
|
const previous = lastDanmakuTimeRef.current;
|
||||||
const jumped = current < previous - 1 || current - previous > 2;
|
const jumped = current < previous - 1 || current - previous > 2;
|
||||||
const spawnWindow = jumped ? TV_DANMAKU_SEEK_WINDOW : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2);
|
const spawnWindow = jumped
|
||||||
|
? TV_DANMAKU_SEEK_WINDOW
|
||||||
|
: Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2);
|
||||||
|
|
||||||
const spawned = spawnedDanmakuRef.current;
|
const spawned = spawnedDanmakuRef.current;
|
||||||
if (jumped) spawned.clear();
|
if (jumped) spawned.clear();
|
||||||
@@ -343,7 +484,9 @@ function TVPlayClient() {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const delta = jumped ? Math.abs(item.time - current) : current - item.time;
|
const delta = jumped
|
||||||
|
? Math.abs(item.time - current)
|
||||||
|
: current - item.time;
|
||||||
return delta >= 0 && delta <= spawnWindow && !spawned.has(item.id);
|
return delta >= 0 && delta <= spawnWindow && !spawned.has(item.id);
|
||||||
})
|
})
|
||||||
.slice(0, TV_DANMAKU_LANES);
|
.slice(0, TV_DANMAKU_LANES);
|
||||||
@@ -363,8 +506,12 @@ function TVPlayClient() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail?.source || !detail?.id) return;
|
if (!detail?.source || !detail?.id) return;
|
||||||
isFavorited(detail.source, detail.id).then(setFavorited).catch(() => undefined);
|
isFavorited(detail.source, detail.id)
|
||||||
getSkipConfig(detail.source, detail.id).then(setSkipConfig).catch(() => setSkipConfig(null));
|
.then(setFavorited)
|
||||||
|
.catch(() => undefined);
|
||||||
|
getSkipConfig(detail.source, detail.id)
|
||||||
|
.then(setSkipConfig)
|
||||||
|
.catch(() => setSkipConfig(null));
|
||||||
}, [detail?.source, detail?.id]);
|
}, [detail?.source, detail?.id]);
|
||||||
|
|
||||||
const switchEpisode = (next: number) => {
|
const switchEpisode = (next: number) => {
|
||||||
@@ -377,35 +524,50 @@ function TVPlayClient() {
|
|||||||
setShowPanel(true);
|
setShowPanel(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTime = useCallback((current: number, duration: number) => {
|
const onTime = useCallback(
|
||||||
const next = { current, duration };
|
(current: number, duration: number) => {
|
||||||
timeRef.current = next;
|
const next = { current, duration };
|
||||||
setTime(next);
|
timeRef.current = next;
|
||||||
|
setTime(next);
|
||||||
|
|
||||||
if (!skipConfig?.enable || !duration) return;
|
if (!skipConfig?.enable || !duration) return;
|
||||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
if (!video) return;
|
'[data-tv-player-root] video'
|
||||||
const episodeKey = `${detail?.source || ''}-${detail?.id || ''}-${episodeIndex}`;
|
);
|
||||||
const intro = Math.max(0, skipConfig.intro_time || 0);
|
if (!video) return;
|
||||||
if (intro > 1 && current > 0.5 && current < intro && skippedIntroRef.current !== episodeKey) {
|
const episodeKey = `${detail?.source || ''}-${
|
||||||
skippedIntroRef.current = episodeKey;
|
detail?.id || ''
|
||||||
video.currentTime = intro;
|
}-${episodeIndex}`;
|
||||||
return;
|
const intro = Math.max(0, skipConfig.intro_time || 0);
|
||||||
}
|
if (
|
||||||
const outroRaw = skipConfig.outro_time || 0;
|
intro > 1 &&
|
||||||
const outroStart = outroRaw < 0 ? duration - Math.abs(outroRaw) : duration - outroRaw;
|
current > 0.5 &&
|
||||||
if (outroRaw !== 0 && outroStart > 0 && current >= outroStart && skippedOutroRef.current !== episodeKey) {
|
current < intro &&
|
||||||
skippedOutroRef.current = episodeKey;
|
skippedIntroRef.current !== episodeKey
|
||||||
switchEpisode(episodeIndex + 1);
|
) {
|
||||||
}
|
skippedIntroRef.current = episodeKey;
|
||||||
}, [detail, episodeIndex, skipConfig]);
|
video.currentTime = intro;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const outroRaw = skipConfig.outro_time || 0;
|
||||||
|
const outroStart =
|
||||||
|
outroRaw < 0 ? duration - Math.abs(outroRaw) : duration - outroRaw;
|
||||||
|
if (
|
||||||
|
outroRaw !== 0 &&
|
||||||
|
outroStart > 0 &&
|
||||||
|
current >= outroStart &&
|
||||||
|
skippedOutroRef.current !== episodeKey
|
||||||
|
) {
|
||||||
|
skippedOutroRef.current = episodeKey;
|
||||||
|
switchEpisode(episodeIndex + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[detail, episodeIndex, skipConfig]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const saveProgress = () => {
|
const saveProgress = () => {
|
||||||
const storageKey = generateStorageKey(detail.source, detail.id);
|
|
||||||
if (suppressPlayRecordSaveKeyRef.current === storageKey) return;
|
|
||||||
|
|
||||||
const playTime = Math.floor(timeRef.current.current || 0);
|
const playTime = Math.floor(timeRef.current.current || 0);
|
||||||
const totalTime = Math.floor(timeRef.current.duration || 0);
|
const totalTime = Math.floor(timeRef.current.duration || 0);
|
||||||
|
|
||||||
@@ -455,23 +617,34 @@ function TVPlayClient() {
|
|||||||
};
|
};
|
||||||
}, [detail, episodeIndex, title]);
|
}, [detail, episodeIndex, title]);
|
||||||
|
|
||||||
const showSeekOverlay = (current: number, duration: number, delta: number) => {
|
const showSeekOverlay = (
|
||||||
|
current: number,
|
||||||
|
duration: number,
|
||||||
|
delta: number
|
||||||
|
) => {
|
||||||
setSeekHint({ current, duration, delta });
|
setSeekHint({ current, duration, delta });
|
||||||
if (seekHintTimerRef.current) window.clearTimeout(seekHintTimerRef.current);
|
if (seekHintTimerRef.current) window.clearTimeout(seekHintTimerRef.current);
|
||||||
seekHintTimerRef.current = window.setTimeout(() => setSeekHint(null), 1200);
|
seekHintTimerRef.current = window.setTimeout(() => setSeekHint(null), 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekBy = (delta: number, showOverlay = false) => {
|
const seekBy = (delta: number, showOverlay = false) => {
|
||||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
|
'[data-tv-player-root] video'
|
||||||
|
);
|
||||||
if (!video || !Number.isFinite(video.duration)) return;
|
if (!video || !Number.isFinite(video.duration)) return;
|
||||||
const duration = video.duration || 0;
|
const duration = video.duration || 0;
|
||||||
const next = Math.max(0, Math.min(duration, (video.currentTime || 0) + delta));
|
const next = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(duration, (video.currentTime || 0) + delta)
|
||||||
|
);
|
||||||
video.currentTime = next;
|
video.currentTime = next;
|
||||||
if (showOverlay) showSeekOverlay(next, duration, delta);
|
if (showOverlay) showSeekOverlay(next, duration, delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekTo = (value: number) => {
|
const seekTo = (value: number) => {
|
||||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
|
'[data-tv-player-root] video'
|
||||||
|
);
|
||||||
if (!video || !Number.isFinite(video.duration)) return;
|
if (!video || !Number.isFinite(video.duration)) return;
|
||||||
const duration = video.duration || 0;
|
const duration = video.duration || 0;
|
||||||
const next = Math.max(0, Math.min(duration, value));
|
const next = Math.max(0, Math.min(duration, value));
|
||||||
@@ -481,7 +654,9 @@ function TVPlayClient() {
|
|||||||
|
|
||||||
const setVideoVolume = (next: number) => {
|
const setVideoVolume = (next: number) => {
|
||||||
const safe = Math.max(0, Math.min(1, next));
|
const safe = Math.max(0, Math.min(1, next));
|
||||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
|
'[data-tv-player-root] video'
|
||||||
|
);
|
||||||
if (video) {
|
if (video) {
|
||||||
video.volume = safe;
|
video.volume = safe;
|
||||||
video.muted = safe <= 0;
|
video.muted = safe <= 0;
|
||||||
@@ -489,12 +664,18 @@ function TVPlayClient() {
|
|||||||
setVolume(safe);
|
setVolume(safe);
|
||||||
setMuted(safe <= 0);
|
setMuted(safe <= 0);
|
||||||
setShowVolumeHint(true);
|
setShowVolumeHint(true);
|
||||||
if (volumeHintTimerRef.current) window.clearTimeout(volumeHintTimerRef.current);
|
if (volumeHintTimerRef.current)
|
||||||
volumeHintTimerRef.current = window.setTimeout(() => setShowVolumeHint(false), 1200);
|
window.clearTimeout(volumeHintTimerRef.current);
|
||||||
|
volumeHintTimerRef.current = window.setTimeout(
|
||||||
|
() => setShowVolumeHint(false),
|
||||||
|
1200
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
|
'[data-tv-player-root] video'
|
||||||
|
);
|
||||||
const next = !muted;
|
const next = !muted;
|
||||||
if (video) video.muted = next;
|
if (video) video.muted = next;
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
@@ -503,7 +684,8 @@ function TVPlayClient() {
|
|||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
const root = document.querySelector<HTMLElement>('[data-tv-player-root]');
|
const root = document.querySelector<HTMLElement>('[data-tv-player-root]');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
|
if (document.fullscreenElement)
|
||||||
|
document.exitFullscreen().catch(() => undefined);
|
||||||
else root.requestFullscreen?.().catch(() => undefined);
|
else root.requestFullscreen?.().catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -547,7 +729,9 @@ function TVPlayClient() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoUrl) return;
|
if (!videoUrl) return;
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
|
'[data-tv-player-root] video'
|
||||||
|
);
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
video.volume = volume;
|
video.volume = volume;
|
||||||
video.muted = muted;
|
video.muted = muted;
|
||||||
@@ -568,27 +752,34 @@ function TVPlayClient() {
|
|||||||
setShowEpisodes(false);
|
setShowEpisodes(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
const currentPlayTime = Math.floor(timeRef.current.current || 0);
|
|
||||||
const oldSource = detail.source;
|
|
||||||
const oldId = detail.id;
|
|
||||||
const oldStorageKey = generateStorageKey(oldSource, oldId);
|
|
||||||
suppressPlayRecordSaveKeyRef.current = oldStorageKey;
|
|
||||||
try {
|
try {
|
||||||
let next = item;
|
let next = item;
|
||||||
if (!item.episodes?.length) {
|
if (!item.episodes?.length) {
|
||||||
const data = await fetchTVDetail({ source: item.source, id: item.id, title: item.title });
|
const data = await fetchTVDetail({
|
||||||
|
source: item.source,
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
});
|
||||||
next = data.detail;
|
next = data.detail;
|
||||||
}
|
}
|
||||||
const targetIndex = Math.max(0, Math.min(episodeIndex, Math.max(0, (next.episodes?.length || 1) - 1)));
|
const maxIndex = Math.max(0, (next.episodes?.length || 1) - 1);
|
||||||
|
let targetIndex = Math.max(0, Math.min(episodeIndex, maxIndex));
|
||||||
|
let targetStartTime = 0;
|
||||||
|
try {
|
||||||
|
const records = await getAllPlayRecords();
|
||||||
|
const record = records[generateStorageKey(next.source, next.id)];
|
||||||
|
if (record?.index) {
|
||||||
|
targetIndex = Math.max(0, Math.min(maxIndex, record.index - 1));
|
||||||
|
targetStartTime = record.play_time > 1 ? record.play_time : 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 读取播放记录失败时保留当前集数,避免切源失败。
|
||||||
|
}
|
||||||
setDetail(next);
|
setDetail(next);
|
||||||
setEpisodeIndex(targetIndex);
|
setEpisodeIndex(targetIndex);
|
||||||
setStartTime(currentPlayTime > 1 ? currentPlayTime : 0);
|
setStartTime(targetStartTime);
|
||||||
setEpisodePage(Math.floor(targetIndex / 30));
|
setEpisodePage(Math.floor(targetIndex / 30));
|
||||||
if (!(next.source === oldSource && next.id === oldId)) {
|
|
||||||
await deletePlayRecord(oldSource, oldId);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
suppressPlayRecordSaveKeyRef.current = null;
|
|
||||||
setError(err instanceof Error ? err.message : '切换播放源失败');
|
setError(err instanceof Error ? err.message : '切换播放源失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -597,13 +788,17 @@ function TVPlayClient() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showDetail) {
|
if (showDetail) {
|
||||||
window.requestAnimationFrame(() => detailCloseButtonRef.current?.focus({ preventScroll: true }));
|
window.requestAnimationFrame(() =>
|
||||||
|
detailCloseButtonRef.current?.focus({ preventScroll: true })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [showDetail]);
|
}, [showDetail]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showDanmakuSettings) {
|
if (showDanmakuSettings) {
|
||||||
window.requestAnimationFrame(() => danmakuFontSizeInputRef.current?.focus({ preventScroll: true }));
|
window.requestAnimationFrame(() =>
|
||||||
|
danmakuFontSizeInputRef.current?.focus({ preventScroll: true })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [showDanmakuSettings]);
|
}, [showDanmakuSettings]);
|
||||||
|
|
||||||
@@ -624,7 +819,10 @@ function TVPlayClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93;
|
const isMenuKey =
|
||||||
|
event.key === 'ContextMenu' ||
|
||||||
|
event.key === 'Menu' ||
|
||||||
|
event.keyCode === 93;
|
||||||
if (isMenuKey) {
|
if (isMenuKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (showPanel || showEpisodes || showDanmakuSettings) {
|
if (showPanel || showEpisodes || showDanmakuSettings) {
|
||||||
@@ -651,7 +849,9 @@ function TVPlayClient() {
|
|||||||
}
|
}
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]'));
|
const isControlFocused =
|
||||||
|
active instanceof HTMLElement &&
|
||||||
|
Boolean(active.closest('[data-tv-player-control]'));
|
||||||
if (!showPanel && !showEpisodes) {
|
if (!showPanel && !showEpisodes) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
@@ -668,14 +868,22 @@ function TVPlayClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
if (
|
||||||
|
!showPanel &&
|
||||||
|
!showEpisodes &&
|
||||||
|
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const base = event.repeat ? 30 : 10;
|
const base = event.repeat ? 30 : 10;
|
||||||
seekBy(event.key === 'ArrowLeft' ? -base : base, true);
|
seekBy(event.key === 'ArrowLeft' ? -base : base, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showPanel && !showEpisodes && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
if (
|
||||||
|
!showPanel &&
|
||||||
|
!showEpisodes &&
|
||||||
|
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05));
|
setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05));
|
||||||
return;
|
return;
|
||||||
@@ -696,15 +904,25 @@ function TVPlayClient() {
|
|||||||
} else if (showPanel) {
|
} else if (showPanel) {
|
||||||
setShowPanel(false);
|
setShowPanel(false);
|
||||||
blurTVPlayerControl();
|
blurTVPlayerControl();
|
||||||
}
|
} else router.back();
|
||||||
else router.back();
|
|
||||||
}
|
}
|
||||||
if (event.key === 'PageUp') switchEpisode(episodeIndex - 1);
|
if (event.key === 'PageUp') switchEpisode(episodeIndex - 1);
|
||||||
if (event.key === 'PageDown') switchEpisode(episodeIndex + 1);
|
if (event.key === 'PageDown') switchEpisode(episodeIndex + 1);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey, true);
|
window.addEventListener('keydown', onKey, true);
|
||||||
return () => window.removeEventListener('keydown', onKey, true);
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
}, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDanmakuSettings, showDetail, showEpisodes, showPanel, volume]);
|
}, [
|
||||||
|
detail?.episodes?.length,
|
||||||
|
digitBuffer,
|
||||||
|
episodeIndex,
|
||||||
|
revealPanel,
|
||||||
|
router,
|
||||||
|
showDanmakuSettings,
|
||||||
|
showDetail,
|
||||||
|
showEpisodes,
|
||||||
|
showPanel,
|
||||||
|
volume,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showEpisodes) return;
|
if (!showEpisodes) return;
|
||||||
@@ -730,22 +948,46 @@ function TVPlayClient() {
|
|||||||
const visibleEpisodeIndexes = useMemo(() => {
|
const visibleEpisodeIndexes = useMemo(() => {
|
||||||
const total = detail?.episodes?.length || 0;
|
const total = detail?.episodes?.length || 0;
|
||||||
const start = Math.max(0, Math.min(episodePage, episodePages - 1)) * 30;
|
const start = Math.max(0, Math.min(episodePage, episodePages - 1)) * 30;
|
||||||
return Array.from({ length: Math.max(0, Math.min(30, total - start)) }, (_, idx) => start + idx);
|
return Array.from(
|
||||||
|
{ length: Math.max(0, Math.min(30, total - start)) },
|
||||||
|
(_, idx) => start + idx
|
||||||
|
);
|
||||||
}, [detail?.episodes?.length, episodePage, episodePages]);
|
}, [detail?.episodes?.length, episodePage, episodePages]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className='fixed inset-0 flex items-center justify-center bg-black text-3xl font-bold text-white'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />正在进入电视播放...</main>;
|
return (
|
||||||
|
<main className='fixed inset-0 flex items-center justify-center bg-black text-3xl font-bold text-white'>
|
||||||
|
<Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />
|
||||||
|
正在进入电视播放...
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !detail) {
|
if (error || !detail) {
|
||||||
return (
|
return (
|
||||||
<main className='fixed inset-0 flex items-center justify-center bg-black p-10 text-center text-white'>
|
<main className='fixed inset-0 flex items-center justify-center bg-black p-10 text-center text-white'>
|
||||||
<section role='alert' className='max-w-3xl rounded-[36px] border border-red-500/40 bg-red-950/50 p-10 shadow-2xl shadow-red-950/40'>
|
<section
|
||||||
|
role='alert'
|
||||||
|
className='max-w-3xl rounded-[36px] border border-red-500/40 bg-red-950/50 p-10 shadow-2xl shadow-red-950/40'
|
||||||
|
>
|
||||||
<AlertTriangle className='mx-auto mb-5 h-16 w-16 text-red-300' />
|
<AlertTriangle className='mx-auto mb-5 h-16 w-16 text-red-300' />
|
||||||
<h1 className='text-4xl font-black text-red-100'>{error || '播放信息不存在'}</h1>
|
<h1 className='text-4xl font-black text-red-100'>
|
||||||
|
{error || '播放信息不存在'}
|
||||||
|
</h1>
|
||||||
<div className='mt-8 flex justify-center gap-4'>
|
<div className='mt-8 flex justify-center gap-4'>
|
||||||
<button onClick={() => window.location.reload()} className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><RotateCcw className='h-7 w-7' />重试</button>
|
<button
|
||||||
<button onClick={() => router.back()} className='tv-focusable rounded-2xl bg-white/10 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-white/40'>返回</button>
|
onClick={() => window.location.reload()}
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'
|
||||||
|
>
|
||||||
|
<RotateCcw className='h-7 w-7' />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className='tv-focusable rounded-2xl bg-white/10 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-white/40'
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -753,9 +995,35 @@ function TVPlayClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main data-tv-player-root data-tv-controls-open={showPanel || showEpisodes || showDetail || showDanmakuSettings ? 'true' : 'false'} className='fixed inset-0 overflow-hidden bg-black text-white'>
|
<main
|
||||||
{videoUrl ? <TVNativeVideo key={`${videoUrl}-${retryNonce}`} url={videoUrl} poster={detail.poster} title={detail.title} onTime={onTime} command={toggleCommand} startTime={startTime} onError={() => setPlaybackError(true)} onPlayingChange={setIsPlaying} onBufferingChange={setIsBuffering} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
|
data-tv-player-root
|
||||||
<div className='flex h-full w-full items-center justify-center text-3xl font-bold text-white'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />{resolving ? '正在解析播放地址...' : '准备播放...'}</div>
|
data-tv-controls-open={
|
||||||
|
showPanel || showEpisodes || showDetail || showDanmakuSettings
|
||||||
|
? 'true'
|
||||||
|
: 'false'
|
||||||
|
}
|
||||||
|
className='fixed inset-0 overflow-hidden bg-black text-white'
|
||||||
|
>
|
||||||
|
{videoUrl ? (
|
||||||
|
<TVNativeVideo
|
||||||
|
key={`${videoUrl}-${retryNonce}`}
|
||||||
|
url={videoUrl}
|
||||||
|
poster={detail.poster}
|
||||||
|
title={detail.title}
|
||||||
|
onTime={onTime}
|
||||||
|
command={toggleCommand}
|
||||||
|
startTime={startTime}
|
||||||
|
onError={() => setPlaybackError(true)}
|
||||||
|
onPlayingChange={setIsPlaying}
|
||||||
|
onBufferingChange={setIsBuffering}
|
||||||
|
adFilterEnabled={adFilterEnabled}
|
||||||
|
playbackRate={playbackRate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-full w-full items-center justify-center text-3xl font-bold text-white'>
|
||||||
|
<Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />
|
||||||
|
{resolving ? '正在解析播放地址...' : '准备播放...'}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeDanmakuItems.length > 0 && (
|
{activeDanmakuItems.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -767,7 +1035,9 @@ function TVPlayClient() {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className='absolute whitespace-nowrap text-3xl font-black drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
|
className='absolute whitespace-nowrap text-3xl font-black drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
|
||||||
onAnimationEnd={() => {
|
onAnimationEnd={() => {
|
||||||
setActiveDanmakuItems((prev) => prev.filter((active) => active.id !== item.id));
|
setActiveDanmakuItems((prev) =>
|
||||||
|
prev.filter((active) => active.id !== item.id)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
top: `${item.lane * 12}%`,
|
top: `${item.lane * 12}%`,
|
||||||
@@ -775,7 +1045,8 @@ function TVPlayClient() {
|
|||||||
fontSize: `${danmakuSettings.fontSize}px`,
|
fontSize: `${danmakuSettings.fontSize}px`,
|
||||||
opacity: danmakuSettings.opacity,
|
opacity: danmakuSettings.opacity,
|
||||||
animation: `tv-danmaku ${item.duration}s linear forwards`,
|
animation: `tv-danmaku ${item.duration}s linear forwards`,
|
||||||
animationPlayState: isPlaying && !isBuffering ? 'running' : 'paused',
|
animationPlayState:
|
||||||
|
isPlaying && !isBuffering ? 'running' : 'paused',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
@@ -783,66 +1054,241 @@ function TVPlayClient() {
|
|||||||
))}
|
))}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
@keyframes tv-danmaku {
|
@keyframes tv-danmaku {
|
||||||
from { transform: translateX(100vw); }
|
from {
|
||||||
to { transform: translateX(-120%); }
|
transform: translateX(100vw);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{playbackError && (
|
{playbackError && (
|
||||||
<div role='alert' className='absolute inset-0 z-30 flex items-center justify-center bg-black/72 p-8 text-white backdrop-blur-sm'>
|
<div
|
||||||
|
role='alert'
|
||||||
|
className='absolute inset-0 z-30 flex items-center justify-center bg-black/72 p-8 text-white backdrop-blur-sm'
|
||||||
|
>
|
||||||
<section className='max-w-3xl rounded-[36px] border border-white/10 bg-slate-950/92 p-9 text-center shadow-2xl shadow-black/70'>
|
<section className='max-w-3xl rounded-[36px] border border-white/10 bg-slate-950/92 p-9 text-center shadow-2xl shadow-black/70'>
|
||||||
<AlertTriangle className='mx-auto mb-5 h-14 w-14 text-amber-300' />
|
<AlertTriangle className='mx-auto mb-5 h-14 w-14 text-amber-300' />
|
||||||
<h2 className='text-4xl font-black'>当前视频加载失败</h2>
|
<h2 className='text-4xl font-black'>当前视频加载失败</h2>
|
||||||
<p className='mt-3 text-2xl text-slate-300'>可以重试当前地址,或打开选集与线路面板切换播放源。</p>
|
<p className='mt-3 text-2xl text-slate-300'>
|
||||||
|
可以重试当前地址,或打开选集与线路面板切换播放源。
|
||||||
|
</p>
|
||||||
<div className='mt-8 flex justify-center gap-4'>
|
<div className='mt-8 flex justify-center gap-4'>
|
||||||
<button onClick={() => { setPlaybackError(false); setRetryNonce((v) => v + 1); }} className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><RotateCcw className='h-7 w-7' />重试</button>
|
<button
|
||||||
<button onClick={() => { setPlaybackError(false); setShowPanel(true); setShowEpisodes(true); }} className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-white/10 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-white/40'><ListVideo className='h-7 w-7' />换源/选集</button>
|
onClick={() => {
|
||||||
|
setPlaybackError(false);
|
||||||
|
setRetryNonce((v) => v + 1);
|
||||||
|
}}
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'
|
||||||
|
>
|
||||||
|
<RotateCcw className='h-7 w-7' />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybackError(false);
|
||||||
|
setShowPanel(true);
|
||||||
|
setShowEpisodes(true);
|
||||||
|
}}
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-white/10 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-white/40'
|
||||||
|
>
|
||||||
|
<ListVideo className='h-7 w-7' />
|
||||||
|
换源/选集
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`absolute inset-0 pointer-events-none transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
|
<div
|
||||||
|
className={`absolute inset-0 pointer-events-none transition-opacity duration-300 ${
|
||||||
|
showPanel ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className='absolute inset-x-0 top-0 h-44 bg-gradient-to-b from-black/90 to-transparent' />
|
<div className='absolute inset-x-0 top-0 h-44 bg-gradient-to-b from-black/90 to-transparent' />
|
||||||
<div className='absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-black/95 to-transparent' />
|
<div className='absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-black/95 to-transparent' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`absolute left-8 right-8 top-8 flex items-center justify-between transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
|
<div
|
||||||
<button onClick={() => router.back()} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-2xl font-black outline-none backdrop-blur'><ArrowLeft className='h-7 w-7' />返回</button>
|
className={`absolute left-8 right-8 top-8 flex items-center justify-between transition-opacity duration-300 ${
|
||||||
|
showPanel ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-2xl font-black outline-none backdrop-blur'
|
||||||
|
>
|
||||||
|
<ArrowLeft className='h-7 w-7' />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
<div className='rounded-2xl bg-black/70 px-6 py-4 text-right backdrop-blur'>
|
<div className='rounded-2xl bg-black/70 px-6 py-4 text-right backdrop-blur'>
|
||||||
<div className='max-w-[60vw] truncate text-3xl font-black'>{detail.title}</div>
|
<div className='max-w-[60vw] truncate text-3xl font-black'>
|
||||||
<div className='mt-1 text-xl text-slate-300'>{episodeTitle} · {detail.source_name}</div>
|
{detail.title}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xl text-slate-300'>
|
||||||
|
{episodeTitle} · {detail.source_name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tv-player-control className={`absolute bottom-8 left-8 right-8 transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'pointer-events-none opacity-0'}`}>
|
<div
|
||||||
|
data-tv-player-control
|
||||||
|
className={`absolute bottom-8 left-8 right-8 transition-opacity duration-300 ${
|
||||||
|
showPanel ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className='rounded-[28px] bg-black/75 p-4 backdrop-blur'>
|
<div className='rounded-[28px] bg-black/75 p-4 backdrop-blur'>
|
||||||
<div onFocusCapture={scrollFocusedControlIntoView} className='flex items-center gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_button]:shrink-0 [&_button]:whitespace-nowrap'>
|
<div
|
||||||
<button onClick={() => switchEpisode(episodeIndex - 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipBack className='h-6 w-6' />上一集</button>
|
onFocusCapture={scrollFocusedControlIntoView}
|
||||||
<button onClick={() => setToggleCommand((value) => value + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none'>
|
className='flex items-center gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_button]:shrink-0 [&_button]:whitespace-nowrap'
|
||||||
{isPlaying ? <Pause className='h-6 w-6' /> : <Play className='h-6 w-6 fill-current' />}
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => switchEpisode(episodeIndex - 1)}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'
|
||||||
|
>
|
||||||
|
<SkipBack className='h-6 w-6' />
|
||||||
|
上一集
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setToggleCommand((value) => value + 1)}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none'
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className='h-6 w-6' />
|
||||||
|
) : (
|
||||||
|
<Play className='h-6 w-6 fill-current' />
|
||||||
|
)}
|
||||||
{isPlaying ? '暂停' : '播放'}
|
{isPlaying ? '暂停' : '播放'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => switchEpisode(episodeIndex + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'>下一集<SkipForward className='h-6 w-6' /></button>
|
<button
|
||||||
<button onClick={() => setShowEpisodes((v) => !v)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><ListVideo className='h-6 w-6' />选集</button>
|
onClick={() => switchEpisode(episodeIndex + 1)}
|
||||||
<button onClick={toggleFavorite} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${favorited ? 'bg-rose-600' : 'bg-white/10'}`}><Heart className={`h-6 w-6 ${favorited ? 'fill-current' : ''}`} />收藏</button>
|
data-tv-player-control
|
||||||
<button onClick={() => { setShowDetail(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><Info className='h-6 w-6' />详情</button>
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'
|
||||||
<button onClick={() => setDanmakuEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${danmakuEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><MessageCircle className='h-6 w-6' />弹幕</button>
|
>
|
||||||
<button onClick={() => { setShowEpisodes(false); setShowDanmakuSettings(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SlidersHorizontal className='h-6 w-6' />弹幕设置</button>
|
下一集
|
||||||
<button onClick={() => setAdFilterEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${adFilterEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><ShieldOff className='h-6 w-6' />去广告</button>
|
<SkipForward className='h-6 w-6' />
|
||||||
<button onClick={cyclePlaybackRate} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'>{playbackRate}x</button>
|
</button>
|
||||||
<button onClick={toggleMute} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none' title='静音'>{muted ? <VolumeX className='h-6 w-6' /> : <Volume2 className='h-6 w-6' />}</button>
|
<button
|
||||||
<button onClick={toggleFullscreen} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none'><Maximize className='h-6 w-6' /></button>
|
onClick={() => setShowEpisodes((v) => !v)}
|
||||||
<span className='shrink-0 whitespace-nowrap px-2 text-xl font-bold text-slate-200'>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</span>
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'
|
||||||
|
>
|
||||||
|
<ListVideo className='h-6 w-6' />
|
||||||
|
选集
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFavorite}
|
||||||
|
data-tv-player-control
|
||||||
|
className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${
|
||||||
|
favorited ? 'bg-rose-600' : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart className={`h-6 w-6 ${favorited ? 'fill-current' : ''}`} />
|
||||||
|
收藏
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDetail(true);
|
||||||
|
revealPanel();
|
||||||
|
}}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'
|
||||||
|
>
|
||||||
|
<Info className='h-6 w-6' />
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDanmakuEnabled((v) => !v)}
|
||||||
|
data-tv-player-control
|
||||||
|
className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${
|
||||||
|
danmakuEnabled ? 'bg-rose-600' : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageCircle className='h-6 w-6' />
|
||||||
|
弹幕
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEpisodes(false);
|
||||||
|
setShowDanmakuSettings(true);
|
||||||
|
revealPanel();
|
||||||
|
}}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className='h-6 w-6' />
|
||||||
|
弹幕设置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAdFilterEnabled((v) => !v)}
|
||||||
|
data-tv-player-control
|
||||||
|
className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${
|
||||||
|
adFilterEnabled ? 'bg-rose-600' : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldOff className='h-6 w-6' />
|
||||||
|
去广告
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cyclePlaybackRate}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'
|
||||||
|
>
|
||||||
|
{playbackRate}x
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none'
|
||||||
|
title='静音'
|
||||||
|
>
|
||||||
|
{muted ? (
|
||||||
|
<VolumeX className='h-6 w-6' />
|
||||||
|
) : (
|
||||||
|
<Volume2 className='h-6 w-6' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none'
|
||||||
|
>
|
||||||
|
<Maximize className='h-6 w-6' />
|
||||||
|
</button>
|
||||||
|
<span className='shrink-0 whitespace-nowrap px-2 text-xl font-bold text-slate-200'>
|
||||||
|
{formatTVTime(time.current)} / {formatTVTime(time.duration)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-4 rounded-3xl bg-black/70 p-4 backdrop-blur'>
|
<div className='mt-4 rounded-3xl bg-black/70 p-4 backdrop-blur'>
|
||||||
<input aria-label='播放进度' data-tv-no-focus='true' tabIndex={-1} type='range' min='0' max={Math.max(1, time.duration || 1)} step='1' value={Math.min(time.current, time.duration || time.current)} onChange={(e) => seekTo(Number(e.target.value))} className='h-3 w-full cursor-pointer accent-rose-600' />
|
<input
|
||||||
|
aria-label='播放进度'
|
||||||
|
data-tv-no-focus='true'
|
||||||
|
tabIndex={-1}
|
||||||
|
type='range'
|
||||||
|
min='0'
|
||||||
|
max={Math.max(1, time.duration || 1)}
|
||||||
|
step='1'
|
||||||
|
value={Math.min(time.current, time.duration || time.current)}
|
||||||
|
onChange={(e) => seekTo(Number(e.target.value))}
|
||||||
|
className='h-3 w-full cursor-pointer accent-rose-600'
|
||||||
|
/>
|
||||||
<div className='mt-2 flex items-center justify-between text-lg font-bold text-slate-200'>
|
<div className='mt-2 flex items-center justify-between text-lg font-bold text-slate-200'>
|
||||||
<span>{formatTVTime(time.current)}</span>
|
<span>{formatTVTime(time.current)}</span>
|
||||||
<span>{time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'}</span>
|
<span>
|
||||||
|
{time.duration
|
||||||
|
? `${Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((time.current / time.duration) * 100)
|
||||||
|
)}%`
|
||||||
|
: '0%'}
|
||||||
|
</span>
|
||||||
<span>{formatTVTime(time.duration)}</span>
|
<span>{formatTVTime(time.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -850,24 +1296,66 @@ function TVPlayClient() {
|
|||||||
|
|
||||||
{showEpisodes && (
|
{showEpisodes && (
|
||||||
<aside className='absolute bottom-40 right-8 max-h-[55vh] w-[560px] overflow-y-auto rounded-[34px] border border-white/10 bg-slate-950/92 p-6 shadow-2xl shadow-black/70 backdrop-blur-2xl'>
|
<aside className='absolute bottom-40 right-8 max-h-[55vh] w-[560px] overflow-y-auto rounded-[34px] border border-white/10 bg-slate-950/92 p-6 shadow-2xl shadow-black/70 backdrop-blur-2xl'>
|
||||||
<h2 className='mb-5 flex items-center gap-3 text-3xl font-black'><Layers className='h-8 w-8 text-rose-500' />选集与线路</h2>
|
<h2 className='mb-5 flex items-center gap-3 text-3xl font-black'>
|
||||||
|
<Layers className='h-8 w-8 text-rose-500' />
|
||||||
|
选集与线路
|
||||||
|
</h2>
|
||||||
<div className='mb-6'>
|
<div className='mb-6'>
|
||||||
<div className='mb-2 text-xl font-black text-slate-300'>播放源</div>
|
<div className='mb-2 text-xl font-black text-slate-300'>播放源</div>
|
||||||
<div className='flex gap-3 overflow-x-auto px-2 py-3 [scrollbar-width:none]'>
|
<div className='flex gap-3 overflow-x-auto px-2 py-3 [scrollbar-width:none]'>
|
||||||
{sources.length > 0 ? sources.map((item) => <button key={`${item.source}-${item.id}`} onClick={() => switchSource(item)} data-tv-player-control className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none focus:ring-4 focus:ring-rose-300 ${detail.source === item.source && detail.id === item.id ? 'bg-rose-600' : 'bg-white/10'}`}>{item.source_name || item.source}</button>) : <span className='text-xl text-slate-400'>暂无其他播放源</span>}
|
{sources.length > 0 ? (
|
||||||
|
sources.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`${item.source}-${item.id}`}
|
||||||
|
onClick={() => switchSource(item)}
|
||||||
|
data-tv-player-control
|
||||||
|
className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none focus:ring-4 focus:ring-rose-300 ${
|
||||||
|
detail.source === item.source && detail.id === item.id
|
||||||
|
? 'bg-rose-600'
|
||||||
|
: 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.source_name || item.source}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className='text-xl text-slate-400'>暂无其他播放源</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{episodePages > 1 && (
|
{episodePages > 1 && (
|
||||||
<div className='mb-5 flex gap-3 overflow-x-auto px-2 py-2 [scrollbar-width:none]'>
|
<div className='mb-5 flex gap-3 overflow-x-auto px-2 py-2 [scrollbar-width:none]'>
|
||||||
{Array.from({ length: episodePages }, (_, page) => (
|
{Array.from({ length: episodePages }, (_, page) => (
|
||||||
<button key={page} onClick={() => setEpisodePage(page)} data-tv-player-control className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${page === episodePage ? 'bg-rose-600' : 'bg-white/10'}`}>
|
<button
|
||||||
{page * 30 + 1}-{Math.min((page + 1) * 30, detail.episodes.length)}
|
key={page}
|
||||||
|
onClick={() => setEpisodePage(page)}
|
||||||
|
data-tv-player-control
|
||||||
|
className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${
|
||||||
|
page === episodePage ? 'bg-rose-600' : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page * 30 + 1}-
|
||||||
|
{Math.min((page + 1) * 30, detail.episodes.length)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className='grid grid-cols-4 gap-3'>
|
<div className='grid grid-cols-4 gap-3'>
|
||||||
{visibleEpisodeIndexes.map((index) => <button key={index} ref={(el) => { episodeButtonRefs.current[index] = el; }} onClick={() => switchEpisode(index)} data-tv-player-control className={`tv-focusable min-h-16 cursor-pointer rounded-2xl px-3 py-3 text-lg font-black outline-none focus:ring-4 focus:ring-rose-300 ${index === episodeIndex ? 'bg-rose-600' : 'bg-white/10'}`}>{detail.episodes_titles?.[index] || `第 ${index + 1} 集`}</button>)}
|
{visibleEpisodeIndexes.map((index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
ref={(el) => {
|
||||||
|
episodeButtonRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
onClick={() => switchEpisode(index)}
|
||||||
|
data-tv-player-control
|
||||||
|
className={`tv-focusable min-h-16 cursor-pointer rounded-2xl px-3 py-3 text-lg font-black outline-none focus:ring-4 focus:ring-rose-300 ${
|
||||||
|
index === episodeIndex ? 'bg-rose-600' : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{detail.episodes_titles?.[index] || `第 ${index + 1} 集`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
@@ -880,15 +1368,26 @@ function TVPlayClient() {
|
|||||||
className='w-[720px] max-w-[92vw] rounded-[34px] border border-white/10 bg-slate-950/95 p-7 text-white shadow-2xl shadow-black/80'
|
className='w-[720px] max-w-[92vw] rounded-[34px] border border-white/10 bg-slate-950/95 p-7 text-white shadow-2xl shadow-black/80'
|
||||||
onKeyDownCapture={(event) => {
|
onKeyDownCapture={(event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement) || !target.closest('[data-tv-danmaku-settings]')) return;
|
if (
|
||||||
|
!(target instanceof HTMLElement) ||
|
||||||
|
!target.closest('[data-tv-danmaku-settings]')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||||
if (target instanceof HTMLInputElement && target.type === 'range') {
|
if (
|
||||||
|
target instanceof HTMLInputElement &&
|
||||||
|
target.type === 'range'
|
||||||
|
) {
|
||||||
const field = getDanmakuSettingField(target);
|
const field = getDanmakuSettingField(target);
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
updateTVDanmakuSetting(field, event.key === 'ArrowRight' ? 1 : -1, setDanmakuSettings);
|
updateTVDanmakuSetting(
|
||||||
|
field,
|
||||||
|
event.key === 'ArrowRight' ? 1 : -1,
|
||||||
|
setDanmakuSettings
|
||||||
|
);
|
||||||
revealPanel();
|
revealPanel();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -897,21 +1396,39 @@ function TVPlayClient() {
|
|||||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
moveFocusWithinScope(event.currentTarget as HTMLElement, event.key === 'ArrowDown' ? 'down' : 'up');
|
moveFocusWithinScope(
|
||||||
|
event.currentTarget as HTMLElement,
|
||||||
|
event.key === 'ArrowDown' ? 'down' : 'up'
|
||||||
|
);
|
||||||
revealPanel();
|
revealPanel();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='mb-7 flex items-center justify-between gap-5'>
|
<div className='mb-7 flex items-center justify-between gap-5'>
|
||||||
<h2 className='flex items-center gap-3 text-3xl font-black'><SlidersHorizontal className='h-8 w-8 text-rose-500' />弹幕设置</h2>
|
<h2 className='flex items-center gap-3 text-3xl font-black'>
|
||||||
<button onClick={() => { setShowDanmakuSettings(false); blurTVPlayerControl(); }} data-tv-player-control className='tv-focusable flex shrink-0 cursor-pointer items-center gap-2 whitespace-nowrap rounded-2xl bg-white/10 px-5 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><X className='h-7 w-7' />关闭</button>
|
<SlidersHorizontal className='h-8 w-8 text-rose-500' />
|
||||||
|
弹幕设置
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDanmakuSettings(false);
|
||||||
|
blurTVPlayerControl();
|
||||||
|
}}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex shrink-0 cursor-pointer items-center gap-2 whitespace-nowrap rounded-2xl bg-white/10 px-5 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'
|
||||||
|
>
|
||||||
|
<X className='h-7 w-7' />
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-7'>
|
<div className='space-y-7'>
|
||||||
<label className='block'>
|
<label className='block'>
|
||||||
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
|
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
|
||||||
<span>字号</span>
|
<span>字号</span>
|
||||||
<span className='text-rose-200'>{danmakuSettings.fontSize}px</span>
|
<span className='text-rose-200'>
|
||||||
|
{danmakuSettings.fontSize}px
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={danmakuFontSizeInputRef}
|
ref={danmakuFontSizeInputRef}
|
||||||
@@ -923,7 +1440,12 @@ function TVPlayClient() {
|
|||||||
max='46'
|
max='46'
|
||||||
step='1'
|
step='1'
|
||||||
value={danmakuSettings.fontSize}
|
value={danmakuSettings.fontSize}
|
||||||
onChange={(event) => setDanmakuSettings((prev) => ({ ...prev, fontSize: Number(event.target.value) }))}
|
onChange={(event) =>
|
||||||
|
setDanmakuSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fontSize: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
|
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -931,7 +1453,9 @@ function TVPlayClient() {
|
|||||||
<label className='block'>
|
<label className='block'>
|
||||||
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
|
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
|
||||||
<span>显示区域</span>
|
<span>显示区域</span>
|
||||||
<span className='text-rose-200'>{danmakuSettings.displayArea}%</span>
|
<span className='text-rose-200'>
|
||||||
|
{danmakuSettings.displayArea}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
aria-label='弹幕显示区域'
|
aria-label='弹幕显示区域'
|
||||||
@@ -942,7 +1466,12 @@ function TVPlayClient() {
|
|||||||
max='72'
|
max='72'
|
||||||
step='2'
|
step='2'
|
||||||
value={danmakuSettings.displayArea}
|
value={danmakuSettings.displayArea}
|
||||||
onChange={(event) => setDanmakuSettings((prev) => ({ ...prev, displayArea: Number(event.target.value) }))}
|
onChange={(event) =>
|
||||||
|
setDanmakuSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
displayArea: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
|
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -950,7 +1479,9 @@ function TVPlayClient() {
|
|||||||
<label className='block'>
|
<label className='block'>
|
||||||
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
|
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
|
||||||
<span>不透明度</span>
|
<span>不透明度</span>
|
||||||
<span className='text-rose-200'>{Math.round(danmakuSettings.opacity * 100)}%</span>
|
<span className='text-rose-200'>
|
||||||
|
{Math.round(danmakuSettings.opacity * 100)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
aria-label='弹幕不透明度'
|
aria-label='弹幕不透明度'
|
||||||
@@ -961,7 +1492,12 @@ function TVPlayClient() {
|
|||||||
max='100'
|
max='100'
|
||||||
step='5'
|
step='5'
|
||||||
value={Math.round(danmakuSettings.opacity * 100)}
|
value={Math.round(danmakuSettings.opacity * 100)}
|
||||||
onChange={(event) => setDanmakuSettings((prev) => ({ ...prev, opacity: Number(event.target.value) / 100 }))}
|
onChange={(event) =>
|
||||||
|
setDanmakuSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
opacity: Number(event.target.value) / 100,
|
||||||
|
}))
|
||||||
|
}
|
||||||
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
|
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -971,43 +1507,110 @@ function TVPlayClient() {
|
|||||||
)}
|
)}
|
||||||
{showDetail && (
|
{showDetail && (
|
||||||
<div className='absolute inset-0 z-40 flex items-center justify-center bg-black/72 p-10 backdrop-blur-sm'>
|
<div className='absolute inset-0 z-40 flex items-center justify-center bg-black/72 p-10 backdrop-blur-sm'>
|
||||||
<section data-tv-player-control className='max-h-[82vh] w-[980px] max-w-[92vw] overflow-y-auto rounded-[42px] border border-white/10 bg-slate-950/95 p-8 text-white shadow-2xl shadow-black/80'>
|
<section
|
||||||
|
data-tv-player-control
|
||||||
|
className='max-h-[82vh] w-[980px] max-w-[92vw] overflow-y-auto rounded-[42px] border border-white/10 bg-slate-950/95 p-8 text-white shadow-2xl shadow-black/80'
|
||||||
|
>
|
||||||
<div className='mb-6 flex items-start justify-between gap-6'>
|
<div className='mb-6 flex items-start justify-between gap-6'>
|
||||||
<div>
|
<div>
|
||||||
<h2 className='text-5xl font-black'>{detail.title}</h2>
|
<h2 className='text-5xl font-black'>{detail.title}</h2>
|
||||||
<div className='mt-4 flex flex-wrap gap-3 text-xl font-bold text-slate-200'>
|
<div className='mt-4 flex flex-wrap gap-3 text-xl font-bold text-slate-200'>
|
||||||
<span className='rounded-full bg-rose-600 px-4 py-2'>{detail.source_name || detail.source}</span>
|
<span className='rounded-full bg-rose-600 px-4 py-2'>
|
||||||
{detail.year && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.year}</span>}
|
{detail.source_name || detail.source}
|
||||||
{detail.type_name && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.type_name}</span>}
|
</span>
|
||||||
{detail.vod_remarks && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.vod_remarks}</span>}
|
{detail.year && (
|
||||||
<span className='rounded-full bg-white/10 px-4 py-2'>{episodeTitle}</span>
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{detail.year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.type_name && (
|
||||||
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{detail.type_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.vod_remarks && (
|
||||||
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{detail.vod_remarks}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className='rounded-full bg-white/10 px-4 py-2'>
|
||||||
|
{episodeTitle}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button ref={detailCloseButtonRef} onClick={() => setShowDetail(false)} data-tv-player-control className='tv-focusable flex shrink-0 cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><X className='h-7 w-7' />关闭</button>
|
<button
|
||||||
|
ref={detailCloseButtonRef}
|
||||||
|
onClick={() => setShowDetail(false)}
|
||||||
|
data-tv-player-control
|
||||||
|
className='tv-focusable flex shrink-0 cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'
|
||||||
|
>
|
||||||
|
<X className='h-7 w-7' />
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{detail.poster && <img src={detail.poster} alt='' className='float-left mr-7 mb-4 h-72 w-48 rounded-3xl object-cover shadow-xl shadow-black/50' />}
|
{detail.poster && (
|
||||||
<p className='whitespace-pre-line text-2xl leading-relaxed text-slate-200'>{detail.desc || '暂无详情简介'}</p>
|
<img
|
||||||
|
src={detail.poster}
|
||||||
|
alt=''
|
||||||
|
className='float-left mr-7 mb-4 h-72 w-48 rounded-3xl object-cover shadow-xl shadow-black/50'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className='whitespace-pre-line text-2xl leading-relaxed text-slate-200'>
|
||||||
|
{detail.desc || '暂无详情简介'}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{digitBuffer && <div className='absolute right-10 top-32 rounded-3xl bg-black/75 px-7 py-5 text-5xl font-black text-white shadow-2xl'>第 {digitBuffer} 集</div>}
|
{digitBuffer && (
|
||||||
|
<div className='absolute right-10 top-32 rounded-3xl bg-black/75 px-7 py-5 text-5xl font-black text-white shadow-2xl'>
|
||||||
|
第 {digitBuffer} 集
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showVolumeHint && !showPanel && !showEpisodes && (
|
{showVolumeHint && !showPanel && !showEpisodes && (
|
||||||
<div className='absolute right-10 top-1/2 flex -translate-y-1/2 flex-col items-center gap-4 rounded-3xl bg-black/80 px-6 py-7 text-3xl font-black text-white shadow-2xl backdrop-blur'>
|
<div className='absolute right-10 top-1/2 flex -translate-y-1/2 flex-col items-center gap-4 rounded-3xl bg-black/80 px-6 py-7 text-3xl font-black text-white shadow-2xl backdrop-blur'>
|
||||||
{muted || volume <= 0 ? <VolumeX className='h-10 w-10' /> : <Volume2 className='h-10 w-10' />}
|
{muted || volume <= 0 ? (
|
||||||
|
<VolumeX className='h-10 w-10' />
|
||||||
|
) : (
|
||||||
|
<Volume2 className='h-10 w-10' />
|
||||||
|
)}
|
||||||
<div className='relative h-56 w-4 overflow-hidden rounded-full bg-white/20'>
|
<div className='relative h-56 w-4 overflow-hidden rounded-full bg-white/20'>
|
||||||
<div className='absolute bottom-0 left-0 right-0 rounded-full bg-rose-600' style={{ height: `${Math.round((muted ? 0 : volume) * 100)}%` }} />
|
<div
|
||||||
|
className='absolute bottom-0 left-0 right-0 rounded-full bg-rose-600'
|
||||||
|
style={{ height: `${Math.round((muted ? 0 : volume) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='min-w-16 text-center'>
|
||||||
|
{Math.round((muted ? 0 : volume) * 100)}
|
||||||
</div>
|
</div>
|
||||||
<div className='min-w-16 text-center'>{Math.round((muted ? 0 : volume) * 100)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{seekHint && !showPanel && !showEpisodes && (
|
{seekHint && !showPanel && !showEpisodes && (
|
||||||
<div className='absolute bottom-16 left-1/2 w-[720px] max-w-[86vw] -translate-x-1/2 rounded-[34px] bg-black/82 px-8 py-6 text-white shadow-2xl backdrop-blur'>
|
<div className='absolute bottom-16 left-1/2 w-[720px] max-w-[86vw] -translate-x-1/2 rounded-[34px] bg-black/82 px-8 py-6 text-white shadow-2xl backdrop-blur'>
|
||||||
<div className='mb-4 flex items-center justify-between text-3xl font-black'>
|
<div className='mb-4 flex items-center justify-between text-3xl font-black'>
|
||||||
<span>{seekHint.delta > 0 ? `快进 ${seekHint.delta}s` : seekHint.delta < 0 ? `快退 ${Math.abs(seekHint.delta)}s` : '定位进度'}</span>
|
<span>
|
||||||
<span>{formatTVTime(seekHint.current)} / {formatTVTime(seekHint.duration)}</span>
|
{seekHint.delta > 0
|
||||||
|
? `快进 ${seekHint.delta}s`
|
||||||
|
: seekHint.delta < 0
|
||||||
|
? `快退 ${Math.abs(seekHint.delta)}s`
|
||||||
|
: '定位进度'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatTVTime(seekHint.current)} /{' '}
|
||||||
|
{formatTVTime(seekHint.duration)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='h-3 overflow-hidden rounded-full bg-white/20'>
|
<div className='h-3 overflow-hidden rounded-full bg-white/20'>
|
||||||
<div className='h-full rounded-full bg-rose-600' style={{ width: seekHint.duration ? `${Math.min(100, (seekHint.current / seekHint.duration) * 100)}%` : '0%' }} />
|
<div
|
||||||
|
className='h-full rounded-full bg-rose-600'
|
||||||
|
style={{
|
||||||
|
width: seekHint.duration
|
||||||
|
? `${Math.min(
|
||||||
|
100,
|
||||||
|
(seekHint.current / seekHint.duration) * 100
|
||||||
|
)}%`
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1017,5 +1620,9 @@ function TVPlayClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TVPlayPage() {
|
export default function TVPlayPage() {
|
||||||
return <Suspense fallback={null}><TVPlayClient /></Suspense>;
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<TVPlayClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user