继续完善tv播放页
This commit is contained in:
+120
-14
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Radio } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2, Radio, Search } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Favorite, getAllFavorites, getAllPlayRecords, PlayRecord } from '@/lib/db.client';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
|
||||
type LiveSource = { key: string; name: string };
|
||||
@@ -15,28 +17,94 @@ export default function TVLivePage() {
|
||||
const [source, setSource] = useState<string>('');
|
||||
const [channels, setChannels] = useState<LiveChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedGroup, setSelectedGroup] = useState('全部');
|
||||
const [query, setQuery] = useState('');
|
||||
const [visibleCount, setVisibleCount] = useState(120);
|
||||
const [quickChannels, setQuickChannels] = useState<Array<{ source: string; id: string; title: string; cover?: string; type: '最近' | '收藏' }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/live/sources')
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('获取直播源失败');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const list = data.data || [];
|
||||
setSources(list);
|
||||
if (list[0]?.key) setSource(list[0].key);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '获取直播源失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAllPlayRecords().catch(() => ({} as Record<string, PlayRecord>)),
|
||||
getAllFavorites().catch(() => ({} as Record<string, Favorite>)),
|
||||
]).then(([records, favorites]) => {
|
||||
const recents = Object.entries(records)
|
||||
.filter(([, record]) => record.origin === 'live')
|
||||
.sort((a, b) => (b[1].save_time || 0) - (a[1].save_time || 0))
|
||||
.slice(0, 8)
|
||||
.map(([key, record]) => {
|
||||
const plus = key.indexOf('+');
|
||||
return {
|
||||
source: key.slice(0, plus).replace(/^live_/, ''),
|
||||
id: key.slice(plus + 1).replace(/^live_/, ''),
|
||||
title: record.title,
|
||||
cover: record.cover,
|
||||
type: '最近' as const,
|
||||
};
|
||||
});
|
||||
const favs = Object.entries(favorites)
|
||||
.filter(([, favorite]) => favorite.origin === 'live')
|
||||
.sort((a, b) => (b[1].save_time || 0) - (a[1].save_time || 0))
|
||||
.slice(0, 8)
|
||||
.map(([key, favorite]) => {
|
||||
const plus = key.indexOf('+');
|
||||
return {
|
||||
source: key.slice(0, plus).replace(/^live_/, ''),
|
||||
id: key.slice(plus + 1).replace(/^live_/, ''),
|
||||
title: favorite.title,
|
||||
cover: favorite.cover,
|
||||
type: '收藏' as const,
|
||||
};
|
||||
});
|
||||
setQuickChannels([...favs, ...recents].slice(0, 12));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSelectedGroup('全部');
|
||||
setVisibleCount(120);
|
||||
fetch(`/api/live/channels?source=${encodeURIComponent(source)}`)
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
if (r.status === 401 || r.status === 403) throw new Error('无权限访问电视直播,请先登录或检查权限');
|
||||
if (!r.ok) throw new Error('获取频道列表失败');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => setChannels(data.data || []))
|
||||
.catch((err) => {
|
||||
setChannels([]);
|
||||
setError(err instanceof Error ? err.message : '获取频道列表失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [source]);
|
||||
|
||||
const groups = useMemo(() => Array.from(new Set(channels.map((c) => c.group || '其他'))).slice(0, 12), [channels]);
|
||||
const groups = useMemo(() => ['全部', ...Array.from(new Set(channels.map((c) => c.group || '其他')))], [channels]);
|
||||
const filteredChannels = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return channels.filter((channel) => {
|
||||
const groupMatched = selectedGroup === '全部' || (channel.group || '其他') === selectedGroup;
|
||||
const queryMatched = !keyword || channel.name.toLowerCase().includes(keyword) || (channel.group || '').toLowerCase().includes(keyword);
|
||||
return groupMatched && queryMatched;
|
||||
});
|
||||
}, [channels, query, selectedGroup]);
|
||||
const visibleChannels = useMemo(() => filteredChannels.slice(0, visibleCount), [filteredChannels, visibleCount]);
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
@@ -53,20 +121,58 @@ export default function TVLivePage() {
|
||||
<button key={item.key} onClick={() => setSource(item.key)} className={`cursor-pointer rounded-2xl px-6 py-4 text-2xl font-bold outline-none transition tv-focusable ${source === item.key ? 'bg-rose-600 text-white' : 'bg-white/8 text-slate-200 hover:bg-white/12'}`}>{item.name}</button>
|
||||
))}
|
||||
</div>
|
||||
<label className='mt-5 flex h-20 items-center gap-4 rounded-3xl border border-white/10 bg-white/10 px-6 focus-within:border-rose-500'>
|
||||
<Search className='h-8 w-8 text-slate-300' />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setVisibleCount(120); }}
|
||||
placeholder='搜索频道或分类'
|
||||
className='tv-focusable h-16 flex-1 bg-transparent text-2xl font-bold text-white outline-none placeholder:text-slate-500'
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{loading ? <div className='mt-16 flex justify-center gap-4 text-2xl text-slate-300'><Loader2 className='h-8 w-8 animate-spin' />正在加载频道...</div> : (
|
||||
<div className='mt-10 grid grid-cols-[280px_1fr] gap-6'>
|
||||
<aside className='rounded-[32px] border border-white/10 bg-white/[0.04] p-4'>
|
||||
{groups.map((group) => <div key={group} className='rounded-2xl px-5 py-4 text-2xl font-bold text-slate-200'>{group}</div>)}
|
||||
</aside>
|
||||
<section className='grid grid-cols-2 gap-4 lg:grid-cols-4'>
|
||||
{channels.slice(0, 80).map((channel) => (
|
||||
<button key={channel.id} onClick={() => router.push(`/tv/live/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(channel.id)}`)} className='flex min-h-28 cursor-pointer items-center gap-4 rounded-3xl border border-white/10 bg-white/[0.06] p-5 text-left outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
{channel.logo ? <img src={channel.logo} alt='' className='h-14 w-14 rounded-xl object-contain' /> : <Radio className='h-12 w-12 text-rose-400' />}
|
||||
<div><div className='line-clamp-1 text-2xl font-black'>{channel.name}</div><div className='mt-1 text-lg text-slate-400'>{channel.group || '直播频道'}</div></div>
|
||||
{quickChannels.length > 0 && (
|
||||
<section className='mt-8 rounded-[34px] border border-white/10 bg-white/[0.04] p-6'>
|
||||
<h2 className='mb-5 text-3xl font-black'>常用频道</h2>
|
||||
<div className='flex gap-4 overflow-x-auto px-2 py-2 [scrollbar-width:none]'>
|
||||
{quickChannels.map((item) => (
|
||||
<button key={`${item.type}-${item.source}-${item.id}`} onClick={() => router.push(`/tv/live/play?source=${encodeURIComponent(item.source)}&id=${encodeURIComponent(item.id)}`)} className='tv-focusable flex min-w-[220px] cursor-pointer items-center gap-3 rounded-3xl bg-white/10 p-4 text-left outline-none focus:ring-4 focus:ring-rose-300'>
|
||||
{item.cover ? <img src={item.cover} alt='' className='h-12 w-12 rounded-xl object-contain' /> : <Radio className='h-10 w-10 text-rose-400' />}
|
||||
<div><div className='line-clamp-1 text-xl font-black'>{item.title}</div><div className='text-base text-slate-400'>{item.type}</div></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<section role='alert' className='mt-10 rounded-[34px] border border-red-500/40 bg-red-950/45 p-8 text-red-100'>
|
||||
<div className='flex items-center gap-4 text-3xl font-black'><AlertTriangle className='h-10 w-10' />{error}</div>
|
||||
<button onClick={() => window.location.reload()} className='tv-focusable mt-6 cursor-pointer rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black text-white outline-none focus:ring-4 focus:ring-rose-300'>重试</button>
|
||||
</section>
|
||||
) : loading ? <div className='mt-16 flex justify-center gap-4 text-2xl text-slate-300'><Loader2 className='h-8 w-8 animate-spin' />正在加载频道...</div> : (
|
||||
<div className='mt-10 grid grid-cols-[280px_1fr] gap-6'>
|
||||
<aside className='rounded-[32px] border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='max-h-[70vh] space-y-2 overflow-y-auto pr-2'>
|
||||
{groups.map((group) => <button key={group} onClick={() => { setSelectedGroup(group); setVisibleCount(120); }} className={`tv-focusable w-full cursor-pointer rounded-2xl px-5 py-4 text-left text-2xl font-bold outline-none focus:ring-4 focus:ring-rose-300 ${selectedGroup === group ? 'bg-rose-600 text-white' : 'text-slate-200 hover:bg-white/10'}`}>{group}</button>)}
|
||||
</div>
|
||||
</aside>
|
||||
<section>
|
||||
<div className='mb-4 text-2xl font-bold text-slate-300'>{selectedGroup} · {filteredChannels.length} 个频道</div>
|
||||
<div className='grid grid-cols-2 gap-4 lg:grid-cols-4'>
|
||||
{visibleChannels.map((channel, index) => (
|
||||
<button key={channel.id} onClick={() => router.push(`/tv/live/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(channel.id)}`)} className='tv-focusable flex min-h-28 cursor-pointer items-center gap-4 rounded-3xl border border-white/10 bg-white/[0.06] p-5 text-left outline-none transition hover:bg-white/12 focus:ring-4 focus:ring-rose-300'>
|
||||
{channel.logo ? <img src={channel.logo} alt='' className='h-14 w-14 rounded-xl object-contain' /> : <Radio className='h-12 w-12 text-rose-400' />}
|
||||
<div><div className='line-clamp-1 text-2xl font-black'>{channel.name}</div><div className='mt-1 text-lg text-slate-400'>#{index + 1} · {channel.group || '直播频道'}</div></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < filteredChannels.length && (
|
||||
<button onClick={() => setVisibleCount((v) => v + 120)} className='tv-focusable mx-auto mt-8 block cursor-pointer rounded-3xl bg-white/10 px-10 py-5 text-2xl font-black text-white outline-none focus:ring-4 focus:ring-rose-300'>
|
||||
加载更多频道
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+243
-16
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, Loader2, Radio, Star } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, Clock, Heart, Loader2, Maximize, Radio, RotateCcw, Search, Star, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { savePlayRecord } from '@/lib/db.client';
|
||||
import { deleteFavorite, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client';
|
||||
|
||||
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
||||
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
||||
|
||||
type LiveSource = { key: string; name: string; proxyMode?: 'full' | 'm3u8-only' | 'direct' };
|
||||
type LiveChannel = { id: string; tvgId?: string; name: string; logo?: string; group?: string; url: string };
|
||||
type EpgProgram = { start: string; end: string; title: string };
|
||||
|
||||
function getLogoUrl(logo?: string, source?: string) {
|
||||
if (!logo) return '';
|
||||
@@ -41,19 +42,38 @@ function TVLivePlayClient() {
|
||||
const [error, setError] = useState('');
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const [selectedGroup, setSelectedGroup] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [digitBuffer, setDigitBuffer] = useState('');
|
||||
const [playbackError, setPlaybackError] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [epgPrograms, setEpgPrograms] = useState<EpgProgram[]>([]);
|
||||
const [epgLoading, setEpgLoading] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const channelButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const digitTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch('/api/live/sources')
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
if (r.status === 401 || r.status === 403) throw new Error('无权限访问电视直播,请先登录或检查权限');
|
||||
if (!r.ok) throw new Error('获取直播源失败');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
const list = data.data || [];
|
||||
setSources(list);
|
||||
const selected = list.find((s: LiveSource) => s.key === needSource) || list[0] || null;
|
||||
setSource(selected);
|
||||
if (!selected) setLoading(false);
|
||||
})
|
||||
.catch(() => setError('获取直播源失败'));
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '获取直播源失败');
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [needSource]);
|
||||
|
||||
@@ -61,8 +81,13 @@ function TVLivePlayClient() {
|
||||
if (!source) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetch(`/api/live/channels?source=${encodeURIComponent(source.key)}`)
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
if (r.status === 401 || r.status === 403) throw new Error('无权限访问电视直播,请先登录或检查权限');
|
||||
if (!r.ok) throw new Error('获取频道列表失败');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
const list = (data.data || []).map((item: any) => ({
|
||||
@@ -78,7 +103,7 @@ function TVLivePlayClient() {
|
||||
setChannel(selected);
|
||||
setSelectedGroup(selected?.group || list[0]?.group || '');
|
||||
})
|
||||
.catch(() => setError('获取频道列表失败'))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '获取频道列表失败'))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [source, needChannel]);
|
||||
@@ -86,6 +111,9 @@ function TVLivePlayClient() {
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!channel) return;
|
||||
setVideoUrl('');
|
||||
setPlaybackError(false);
|
||||
setRetryCount(0);
|
||||
resolveLiveUrl(channel.url, source).then((url) => alive && setVideoUrl(url));
|
||||
if (source) {
|
||||
savePlayRecord(`live_${source.key}`, `live_${channel.id}`, {
|
||||
@@ -105,20 +133,156 @@ function TVLivePlayClient() {
|
||||
return () => { alive = false; };
|
||||
}, [channel, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source || !channel) return;
|
||||
isFavorited(`live_${source.key}`, `live_${channel.id}`).then(setFavorited).catch(() => undefined);
|
||||
}, [channel, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source || !channel?.tvgId) {
|
||||
setEpgPrograms([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setEpgLoading(true);
|
||||
fetch(`/api/live/epg?source=${encodeURIComponent(source.key)}&tvgId=${encodeURIComponent(channel.tvgId)}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
setEpgPrograms((data?.data?.programs || []).slice(0, 12));
|
||||
})
|
||||
.catch(() => alive && setEpgPrograms([]))
|
||||
.finally(() => alive && setEpgLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [channel?.tvgId, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playbackError || !channel || retryCount >= 3) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setRetryCount((value) => value + 1);
|
||||
setPlaybackError(false);
|
||||
setVideoUrl('');
|
||||
resolveLiveUrl(channel.url, source).then(setVideoUrl).catch(() => setPlaybackError(true));
|
||||
}, 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [channel, playbackError, retryCount, source]);
|
||||
|
||||
const groups = useMemo(() => Array.from(new Set(channels.map((item) => item.group || '其他'))), [channels]);
|
||||
const filteredChannels = useMemo(() => channels.filter((item) => (item.group || '其他') === selectedGroup), [channels, selectedGroup]);
|
||||
const filteredChannels = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return channels.filter((item) => {
|
||||
const groupMatched = (item.group || '其他') === selectedGroup;
|
||||
const queryMatched = !keyword || item.name.toLowerCase().includes(keyword) || (item.group || '').toLowerCase().includes(keyword);
|
||||
return groupMatched && queryMatched;
|
||||
});
|
||||
}, [channels, query, selectedGroup]);
|
||||
|
||||
const precheckChannel = async (next: LiveChannel) => {
|
||||
if (!source) return;
|
||||
try {
|
||||
await fetch(`/api/live/precheck?url=${encodeURIComponent(next.url)}&moontv-source=${encodeURIComponent(source.key)}`, { cache: 'no-store' });
|
||||
} catch {
|
||||
// 预检查失败不阻止切台,播放器错误层会给出重试/换台。
|
||||
}
|
||||
};
|
||||
|
||||
const switchChannel = (next: LiveChannel) => {
|
||||
precheckChannel(next);
|
||||
setChannel(next);
|
||||
setSelectedGroup(next.group || '其他');
|
||||
setShowPanel(true);
|
||||
if (source) router.replace(`/tv/live/play?source=${encodeURIComponent(source.key)}&id=${encodeURIComponent(next.id)}`);
|
||||
};
|
||||
|
||||
const switchSource = (next: LiveSource) => {
|
||||
setSource(next);
|
||||
setChannel(null);
|
||||
setChannels([]);
|
||||
setSelectedGroup('');
|
||||
setQuery('');
|
||||
setShowPanel(true);
|
||||
router.replace(`/tv/live/play?source=${encodeURIComponent(next.key)}`);
|
||||
};
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
if (!source || !channel) return;
|
||||
if (favorited) {
|
||||
await deleteFavorite(`live_${source.key}`, `live_${channel.id}`);
|
||||
setFavorited(false);
|
||||
} else {
|
||||
await saveFavorite(`live_${source.key}`, `live_${channel.id}`, {
|
||||
title: channel.name,
|
||||
source_name: source.name,
|
||||
year: '',
|
||||
cover: getLogoUrl(channel.logo, source.key),
|
||||
total_episodes: 1,
|
||||
save_time: Date.now(),
|
||||
search_title: channel.name,
|
||||
origin: 'live',
|
||||
});
|
||||
setFavorited(true);
|
||||
}
|
||||
};
|
||||
|
||||
const setVideoVolume = (next: number) => {
|
||||
const safe = Math.max(0, Math.min(1, next));
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (video) {
|
||||
video.volume = safe;
|
||||
video.muted = safe <= 0;
|
||||
}
|
||||
setVolume(safe);
|
||||
setMuted(safe <= 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
const next = !muted;
|
||||
if (video) video.muted = next;
|
||||
setMuted(next);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const root = document.querySelector<HTMLElement>('[data-tv-player-root]');
|
||||
if (!root) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
|
||||
else root.requestFullscreen?.().catch(() => undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (!video) return;
|
||||
video.volume = volume;
|
||||
video.muted = muted;
|
||||
});
|
||||
}, [muted, videoUrl, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') setShowPanel((v) => !v);
|
||||
if (/^[0-9]$/.test(event.key) && channels.length) {
|
||||
event.preventDefault();
|
||||
const nextBuffer = `${digitBuffer}${event.key}`.slice(-4);
|
||||
setDigitBuffer(nextBuffer);
|
||||
if (digitTimerRef.current) window.clearTimeout(digitTimerRef.current);
|
||||
digitTimerRef.current = window.setTimeout(() => {
|
||||
const target = Number(nextBuffer);
|
||||
const next = channels[target - 1];
|
||||
if (next) switchChannel(next);
|
||||
setDigitBuffer('');
|
||||
}, 850);
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-live-control]'));
|
||||
if (!isControlFocused) {
|
||||
event.preventDefault();
|
||||
setShowPanel((v) => !v);
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (showPanel) setShowPanel(false);
|
||||
else router.back();
|
||||
}
|
||||
@@ -133,19 +297,52 @@ function TVLivePlayClient() {
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [channel?.id, channels, router, showPanel, source]);
|
||||
}, [channel?.id, channels, digitBuffer, router, showPanel, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPanel || !channel?.id) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
channelButtonRefs.current[channel.id]?.focus({ preventScroll: true });
|
||||
channelButtonRefs.current[channel.id]?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
});
|
||||
}, [channel?.id, selectedGroup, showPanel]);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
if (error || !channel) {
|
||||
return <main className='fixed inset-0 flex items-center justify-center bg-black p-10 text-center text-3xl font-black text-red-100'>{error || '没有可播放频道'}</main>;
|
||||
return (
|
||||
<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'>
|
||||
<AlertTriangle className='mx-auto mb-5 h-16 w-16 text-red-300' />
|
||||
<h1 className='text-4xl font-black text-red-100'>{error || '没有可播放频道'}</h1>
|
||||
<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 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>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white' onMouseMove={() => setShowPanel(true)}>
|
||||
{videoUrl ? <TVNativeVideo url={videoUrl} poster={getLogoUrl(channel.logo, source?.key)} live title={channel.name} /> : <div className='flex h-full items-center justify-center text-3xl font-bold'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />正在解析直播地址...</div>}
|
||||
{videoUrl ? <TVNativeVideo key={videoUrl} url={videoUrl} poster={getLogoUrl(channel.logo, source?.key)} live title={channel.name} onError={() => setPlaybackError(true)} /> : <div className='flex h-full items-center justify-center text-3xl font-bold'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />正在解析直播地址...</div>}
|
||||
|
||||
{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'>
|
||||
<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' />
|
||||
<h2 className='text-4xl font-black'>当前频道播放失败</h2>
|
||||
<p className='mt-3 text-2xl text-slate-300'>{retryCount < 3 ? `正在自动重连(${retryCount + 1}/3)...` : '可以重试当前频道,或打开频道面板切换频道/直播源。'}</p>
|
||||
<div className='mt-8 flex justify-center gap-4'>
|
||||
<button onClick={() => { setPlaybackError(false); setVideoUrl(''); if (channel) resolveLiveUrl(channel.url, source).then(setVideoUrl); }} 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); }} 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>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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' />
|
||||
@@ -153,29 +350,59 @@ function TVLivePlayClient() {
|
||||
</div>
|
||||
|
||||
<div 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()} 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>
|
||||
<button onClick={() => router.back()} data-tv-live-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 focus:ring-4 focus:ring-rose-300'><ArrowLeft className='h-7 w-7' />返回</button>
|
||||
<div className='flex items-center gap-4 rounded-2xl bg-black/70 px-6 py-4 backdrop-blur'>
|
||||
{channel.logo ? <img src={getLogoUrl(channel.logo, source?.key)} alt='' className='h-12 w-12 rounded-xl object-contain' /> : <Radio className='h-10 w-10 text-rose-500' />}
|
||||
<div><div className='text-3xl font-black'>{channel.name}</div><div className='text-xl text-slate-300'>{source?.name} · {channel.group}</div></div>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<button onClick={toggleMute} data-tv-live-control className='tv-focusable rounded-2xl bg-black/70 p-4 outline-none backdrop-blur focus:ring-4 focus:ring-rose-300'>{muted ? <VolumeX className='h-7 w-7' /> : <Volume2 className='h-7 w-7' />}</button>
|
||||
<input aria-label='直播音量' data-tv-live-control type='range' min='0' max='1' step='0.05' value={muted ? 0 : volume} onChange={(e) => setVideoVolume(Number(e.target.value))} className='tv-focusable w-28 accent-rose-600' />
|
||||
<button onClick={toggleFullscreen} data-tv-live-control className='tv-focusable rounded-2xl bg-black/70 p-4 outline-none backdrop-blur focus:ring-4 focus:ring-rose-300'><Maximize className='h-7 w-7' /></button>
|
||||
<button onClick={toggleFavorite} data-tv-live-control className={`tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl px-5 py-4 text-2xl font-black outline-none backdrop-blur focus:ring-4 focus:ring-rose-300 ${favorited ? 'bg-rose-600' : 'bg-black/70'}`}><Heart className={`h-7 w-7 ${favorited ? 'fill-current' : ''}`} />收藏</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<aside className='absolute bottom-8 left-8 top-28 grid w-[620px] grid-cols-[190px_1fr] gap-4 rounded-[34px] border border-white/10 bg-slate-950/88 p-5 shadow-2xl shadow-black/70 backdrop-blur-2xl'>
|
||||
<aside data-tv-live-control className='absolute bottom-8 left-8 top-28 grid w-[720px] grid-cols-[220px_1fr] gap-4 rounded-[34px] border border-white/10 bg-slate-950/88 p-5 shadow-2xl shadow-black/70 backdrop-blur-2xl'>
|
||||
<div className='overflow-y-auto pr-2'>
|
||||
{sources.length > 1 && (
|
||||
<>
|
||||
<h2 className='mb-3 text-2xl font-black'>直播源</h2>
|
||||
<div className='mb-5 space-y-3'>
|
||||
{sources.map((item) => <button key={item.key} onClick={() => switchSource(item)} className={`tv-focusable w-full cursor-pointer rounded-2xl px-4 py-4 text-left text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${source?.key === item.key ? 'bg-rose-600' : 'bg-white/10'}`}>{item.name}</button>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h2 className='mb-4 text-2xl font-black'>分类</h2>
|
||||
<div className='space-y-3'>
|
||||
{groups.map((group) => <button key={group} onClick={() => setSelectedGroup(group)} className={`tv-focusable w-full cursor-pointer rounded-2xl px-4 py-4 text-left text-xl font-black outline-none ${selectedGroup === group ? 'bg-rose-600' : 'bg-white/10'}`}>{group}</button>)}
|
||||
{groups.map((group) => <button key={group} onClick={() => setSelectedGroup(group)} className={`tv-focusable w-full cursor-pointer rounded-2xl px-4 py-4 text-left text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${selectedGroup === group ? 'bg-rose-600' : 'bg-white/10'}`}>{group}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='overflow-y-auto pr-2'>
|
||||
<h2 className='mb-4 flex items-center gap-2 text-2xl font-black'><Star className='h-6 w-6 text-rose-500' />频道</h2>
|
||||
<section className='mb-4 rounded-2xl bg-white/[0.06] p-4'>
|
||||
<h3 className='mb-3 flex items-center gap-2 text-xl font-black text-slate-100'><Clock className='h-5 w-5 text-rose-400' />节目单</h3>
|
||||
{epgLoading ? <div className='text-lg text-slate-400'>正在加载 EPG...</div> : epgPrograms.length > 0 ? (
|
||||
<div className='max-h-36 space-y-2 overflow-y-auto pr-2'>
|
||||
{epgPrograms.map((program, index) => <div key={`${program.start}-${index}`} className='rounded-xl bg-black/25 px-3 py-2 text-base text-slate-200'><span className='mr-2 text-slate-400'>{program.start?.slice(8, 12)}-{program.end?.slice(8, 12)}</span>{program.title}</div>)}
|
||||
</div>
|
||||
) : <div className='text-lg text-slate-400'>暂无节目单</div>}
|
||||
</section>
|
||||
<label className='mb-4 flex h-14 items-center gap-3 rounded-2xl border border-white/10 bg-white/10 px-4 focus-within:border-rose-500'>
|
||||
<Search className='h-6 w-6 text-slate-300' />
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder='搜索频道' className='tv-focusable h-12 flex-1 bg-transparent text-xl font-bold text-white outline-none placeholder:text-slate-500' />
|
||||
</label>
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
{filteredChannels.map((item) => <button key={item.id} onClick={() => switchChannel(item)} className={`tv-focusable flex min-h-18 cursor-pointer items-center gap-3 rounded-2xl px-4 py-3 text-left text-xl font-black outline-none ${item.id === channel.id ? 'bg-rose-600' : 'bg-white/10'}`}>{item.logo ? <img src={getLogoUrl(item.logo, source?.key)} alt='' className='h-9 w-9 rounded-lg object-contain' /> : <Radio className='h-8 w-8 text-rose-400' />}<span className='line-clamp-1'>{item.name}</span></button>)}
|
||||
{filteredChannels.map((item) => {
|
||||
const absoluteIndex = channels.findIndex((c) => c.id === item.id) + 1;
|
||||
return <button key={item.id} ref={(el) => { channelButtonRefs.current[item.id] = el; }} onClick={() => switchChannel(item)} className={`tv-focusable flex min-h-16 cursor-pointer items-center gap-3 rounded-2xl px-4 py-3 text-left text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${item.id === channel.id ? 'bg-rose-600' : 'bg-white/10'}`}>{item.logo ? <img src={getLogoUrl(item.logo, source?.key)} alt='' className='h-9 w-9 rounded-lg object-contain' /> : <Radio className='h-8 w-8 text-rose-400' />}<span className='min-w-12 text-slate-300'>#{absoluteIndex}</span><span className='line-clamp-1'>{item.name}</span></button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
{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>}
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
|
||||
+504
-38
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, Layers, ListVideo, Loader2, Pause, SkipBack, SkipForward } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { savePlayRecord } from '@/lib/db.client';
|
||||
import { deleteFavorite, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, searchAnime } from '@/lib/danmaku/api';
|
||||
|
||||
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
||||
import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils';
|
||||
@@ -23,9 +24,52 @@ function TVPlayClient() {
|
||||
const [error, setError] = useState('');
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const [showEpisodes, setShowEpisodes] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [toggleCommand, setToggleCommand] = useState(0);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const [startTime, setStartTime] = useState(0);
|
||||
const [digitBuffer, setDigitBuffer] = useState('');
|
||||
const [episodePage, setEpisodePage] = useState(0);
|
||||
const [playbackError, setPlaybackError] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [showVolumeHint, setShowVolumeHint] = useState(false);
|
||||
const [seekHint, setSeekHint] = useState<{ current: number; duration: number; delta: number } | null>(null);
|
||||
const [adFilterEnabled, setAdFilterEnabled] = useState(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
const saved = localStorage.getItem('enable_blockad');
|
||||
return saved === null ? true : saved === 'true';
|
||||
});
|
||||
const [danmakuEnabled, setDanmakuEnabled] = useState(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
const saved = localStorage.getItem('tv_danmaku_enabled');
|
||||
return saved === null ? true : saved === 'true';
|
||||
});
|
||||
const [danmakuItems, setDanmakuItems] = useState<Array<{ text: string; time: number; color: string; mode: number }>>([]);
|
||||
const [playbackRate, setPlaybackRate] = useState(() => {
|
||||
if (typeof window === 'undefined') return 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 [time, setTime] = useState({ current: 0, duration: 0 });
|
||||
const timeRef = useRef({ current: 0, duration: 0 });
|
||||
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const digitTimerRef = useRef<number | null>(null);
|
||||
const idleTimerRef = useRef<number | null>(null);
|
||||
const volumeHintTimerRef = useRef<number | null>(null);
|
||||
const seekHintTimerRef = useRef<number | null>(null);
|
||||
const skippedIntroRef = useRef('');
|
||||
const skippedOutroRef = useRef('');
|
||||
const lastSavedRef = useRef<{
|
||||
source: string;
|
||||
id: string;
|
||||
index: number;
|
||||
playTime: number;
|
||||
totalTime: number;
|
||||
} | null>(null);
|
||||
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
@@ -42,7 +86,24 @@ function TVPlayClient() {
|
||||
if (!alive) return;
|
||||
setDetail(data.detail);
|
||||
setSources(data.sources);
|
||||
const safeIndex = Math.max(0, Math.min(initialIndex || data.detail.initialEpisodeIndex || 0, 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');
|
||||
let safeIndex = Math.max(0, Math.min(initialIndex || data.detail.initialEpisodeIndex || 0, maxIndex));
|
||||
if (!explicitIndex && data.detail.source && data.detail.id) {
|
||||
getAllPlayRecords()
|
||||
.then((records) => {
|
||||
if (!alive) return;
|
||||
const record = records[generateStorageKey(data.detail.source, data.detail.id)];
|
||||
if (record?.index) {
|
||||
const rememberedIndex = Math.max(0, Math.min(maxIndex, record.index - 1));
|
||||
setEpisodeIndex(rememberedIndex);
|
||||
setStartTime(record.play_time > 1 ? record.play_time : 0);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
setStartTime(0);
|
||||
}
|
||||
setEpisodeIndex(safeIndex);
|
||||
})
|
||||
.catch((err) => alive && setError(err instanceof Error ? err.message : '加载播放信息失败'))
|
||||
@@ -56,6 +117,7 @@ function TVPlayClient() {
|
||||
if (!detail?.episodes?.[episodeIndex]) return;
|
||||
setResolving(true);
|
||||
setVideoUrl('');
|
||||
setPlaybackError(false);
|
||||
try {
|
||||
const url = await resolveTVEpisodeUrl(detail.episodes[episodeIndex], detail.source, detail.proxyMode);
|
||||
if (alive) setVideoUrl(url);
|
||||
@@ -67,19 +129,122 @@ function TVPlayClient() {
|
||||
}
|
||||
resolve();
|
||||
return () => { alive = false; };
|
||||
}, [detail, episodeIndex]);
|
||||
}, [detail, episodeIndex, retryNonce]);
|
||||
|
||||
const episodeTitle = useMemo(() => detail?.episodes_titles?.[episodeIndex] || `第 ${episodeIndex + 1} 集`, [detail, episodeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
initDanmakuModule();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('enable_blockad', String(adFilterEnabled));
|
||||
}, [adFilterEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('tv_danmaku_enabled', String(danmakuEnabled));
|
||||
}, [danmakuEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate));
|
||||
}, [playbackRate]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function loadDanmaku() {
|
||||
setDanmakuItems([]);
|
||||
if (!danmakuEnabled || !detail?.title) return;
|
||||
try {
|
||||
const search = await searchAnime(title || detail.title);
|
||||
const anime = search.animes?.[0];
|
||||
if (!alive || !anime?.animeId) return;
|
||||
const eps = await getEpisodes(anime.animeId);
|
||||
const ep = eps.bangumi?.episodes?.[Math.min(episodeIndex, Math.max(0, (eps.bangumi?.episodes?.length || 1) - 1))];
|
||||
if (!alive || !ep?.episodeId) return;
|
||||
const comments = await getDanmakuById(ep.episodeId, detail.title, episodeIndex, undefined, {
|
||||
animeId: anime.animeId,
|
||||
animeTitle: anime.animeTitle,
|
||||
episodeTitle: ep.episodeTitle,
|
||||
searchKeyword: title || detail.title,
|
||||
});
|
||||
if (!alive) return;
|
||||
setDanmakuItems(convertDanmakuFormat(comments).slice(0, 250));
|
||||
} catch {
|
||||
if (alive) setDanmakuItems([]);
|
||||
}
|
||||
}
|
||||
loadDanmaku();
|
||||
return () => { alive = false; };
|
||||
}, [danmakuEnabled, detail?.title, episodeIndex, title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail?.source || !detail?.id) return;
|
||||
isFavorited(detail.source, detail.id).then(setFavorited).catch(() => undefined);
|
||||
getSkipConfig(detail.source, detail.id).then(setSkipConfig).catch(() => setSkipConfig(null));
|
||||
}, [detail?.source, detail?.id]);
|
||||
|
||||
const switchEpisode = (next: number) => {
|
||||
if (!detail) return;
|
||||
const max = detail.episodes.length - 1;
|
||||
const target = Math.max(0, Math.min(max, next));
|
||||
setStartTime(0);
|
||||
setEpisodeIndex(target);
|
||||
setEpisodePage(Math.floor(target / 30));
|
||||
setShowPanel(true);
|
||||
};
|
||||
|
||||
const onTime = useCallback((current: number, duration: number) => {
|
||||
const next = { current, duration };
|
||||
timeRef.current = next;
|
||||
setTime(next);
|
||||
}, []);
|
||||
|
||||
if (!skipConfig?.enable || !duration) return;
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (!video) return;
|
||||
const episodeKey = `${detail?.source || ''}-${detail?.id || ''}-${episodeIndex}`;
|
||||
const intro = Math.max(0, skipConfig.intro_time || 0);
|
||||
if (intro > 1 && current > 0.5 && current < intro && skippedIntroRef.current !== episodeKey) {
|
||||
skippedIntroRef.current = episodeKey;
|
||||
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(() => {
|
||||
if (!detail) return;
|
||||
const timer = window.setInterval(() => {
|
||||
const saveProgress = () => {
|
||||
const playTime = Math.floor(timeRef.current.current || 0);
|
||||
const totalTime = Math.floor(timeRef.current.duration || 0);
|
||||
|
||||
// 参考 /play:无有效进度时不保存;同一秒/同一集重复触发不保存,避免网络里刷 /api/playrecords。
|
||||
if (playTime <= 0 && totalTime <= 0) return;
|
||||
|
||||
const last = lastSavedRef.current;
|
||||
if (
|
||||
last &&
|
||||
last.source === detail.source &&
|
||||
last.id === detail.id &&
|
||||
last.index === episodeIndex + 1 &&
|
||||
last.playTime === playTime &&
|
||||
last.totalTime === totalTime
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSavedRef.current = {
|
||||
source: detail.source,
|
||||
id: detail.id,
|
||||
index: episodeIndex + 1,
|
||||
playTime,
|
||||
totalTime,
|
||||
};
|
||||
|
||||
savePlayRecord(detail.source, detail.id, {
|
||||
title: detail.title,
|
||||
source_name: detail.source_name,
|
||||
@@ -87,24 +252,128 @@ function TVPlayClient() {
|
||||
cover: detail.poster || '',
|
||||
index: episodeIndex + 1,
|
||||
total_episodes: detail.episodes?.length || 1,
|
||||
play_time: Math.floor(timeRef.current.current || 0),
|
||||
total_time: Math.floor(timeRef.current.duration || 0),
|
||||
play_time: playTime,
|
||||
total_time: totalTime,
|
||||
save_time: Date.now(),
|
||||
search_title: title || detail.title,
|
||||
}).catch(() => undefined);
|
||||
}, 10000);
|
||||
return () => window.clearInterval(timer);
|
||||
};
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
saveProgress();
|
||||
}, 20000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
saveProgress();
|
||||
};
|
||||
}, [detail, episodeIndex, title]);
|
||||
|
||||
const switchEpisode = (next: number) => {
|
||||
if (!detail) return;
|
||||
const max = detail.episodes.length - 1;
|
||||
setEpisodeIndex(Math.max(0, Math.min(max, next)));
|
||||
setShowPanel(true);
|
||||
const showSeekOverlay = (current: number, duration: number, delta: number) => {
|
||||
setSeekHint({ current, duration, delta });
|
||||
if (seekHintTimerRef.current) window.clearTimeout(seekHintTimerRef.current);
|
||||
seekHintTimerRef.current = window.setTimeout(() => setSeekHint(null), 1200);
|
||||
};
|
||||
|
||||
const switchSource = async (item: SearchResult) => {
|
||||
const seekBy = (delta: number, showOverlay = false) => {
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (!video || !Number.isFinite(video.duration)) return;
|
||||
const duration = video.duration || 0;
|
||||
const next = Math.max(0, Math.min(duration, (video.currentTime || 0) + delta));
|
||||
video.currentTime = next;
|
||||
if (showOverlay) showSeekOverlay(next, duration, delta);
|
||||
};
|
||||
|
||||
const seekTo = (value: number) => {
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (!video || !Number.isFinite(video.duration)) return;
|
||||
const duration = video.duration || 0;
|
||||
const next = Math.max(0, Math.min(duration, value));
|
||||
video.currentTime = next;
|
||||
showSeekOverlay(next, duration, 0);
|
||||
};
|
||||
|
||||
const setVideoVolume = (next: number) => {
|
||||
const safe = Math.max(0, Math.min(1, next));
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (video) {
|
||||
video.volume = safe;
|
||||
video.muted = safe <= 0;
|
||||
}
|
||||
setVolume(safe);
|
||||
setMuted(safe <= 0);
|
||||
setShowVolumeHint(true);
|
||||
if (volumeHintTimerRef.current) window.clearTimeout(volumeHintTimerRef.current);
|
||||
volumeHintTimerRef.current = window.setTimeout(() => setShowVolumeHint(false), 1200);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
const next = !muted;
|
||||
if (video) video.muted = next;
|
||||
setMuted(next);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const root = document.querySelector<HTMLElement>('[data-tv-player-root]');
|
||||
if (!root) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
|
||||
else root.requestFullscreen?.().catch(() => undefined);
|
||||
};
|
||||
|
||||
const revealPanel = useCallback(() => {
|
||||
setShowPanel(true);
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setShowPanel(false);
|
||||
setShowEpisodes(false);
|
||||
}, 10000);
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
if (!detail) return;
|
||||
if (favorited) {
|
||||
await deleteFavorite(detail.source, detail.id);
|
||||
setFavorited(false);
|
||||
} else {
|
||||
await saveFavorite(detail.source, detail.id, {
|
||||
title: detail.title,
|
||||
source_name: detail.source_name || detail.source,
|
||||
year: detail.year || '',
|
||||
cover: detail.poster || '',
|
||||
total_episodes: detail.episodes?.length || 1,
|
||||
save_time: Date.now(),
|
||||
search_title: title || detail.title,
|
||||
vod_remarks: detail.vod_remarks,
|
||||
});
|
||||
setFavorited(true);
|
||||
}
|
||||
};
|
||||
|
||||
const cyclePlaybackRate = () => {
|
||||
const rates = [0.75, 1, 1.25, 1.5, 2];
|
||||
const currentIndex = rates.findIndex((rate) => rate === playbackRate);
|
||||
setPlaybackRate(rates[(currentIndex + 1 + rates.length) % rates.length]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
|
||||
if (!video) return;
|
||||
video.volume = volume;
|
||||
video.muted = muted;
|
||||
});
|
||||
}, [muted, videoUrl, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showPanel || showEpisodes) revealPanel();
|
||||
return () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current);
|
||||
};
|
||||
}, [revealPanel, showEpisodes, showPanel]);
|
||||
|
||||
const switchSource = async (item: SearchResult) => {
|
||||
revealPanel();
|
||||
setShowEpisodes(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -113,8 +382,11 @@ function TVPlayClient() {
|
||||
const data = await fetchTVDetail({ source: item.source, id: item.id, title: item.title });
|
||||
next = data.detail;
|
||||
}
|
||||
const targetIndex = Math.max(0, Math.min(episodeIndex, Math.max(0, (next.episodes?.length || 1) - 1)));
|
||||
setDetail(next);
|
||||
setEpisodeIndex(0);
|
||||
setEpisodeIndex(targetIndex);
|
||||
setStartTime(0);
|
||||
setEpisodePage(Math.floor(targetIndex / 30));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '切换播放源失败');
|
||||
} finally {
|
||||
@@ -122,8 +394,44 @@ function TVPlayClient() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showDetail) {
|
||||
window.requestAnimationFrame(() => detailCloseButtonRef.current?.focus({ preventScroll: true }));
|
||||
}
|
||||
}, [showDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (showDetail && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setShowDetail(false);
|
||||
revealPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93;
|
||||
if (isMenuKey) {
|
||||
event.preventDefault();
|
||||
if (showPanel || showEpisodes) {
|
||||
setShowPanel(false);
|
||||
setShowEpisodes(false);
|
||||
} else {
|
||||
revealPanel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^[0-9]$/.test(event.key) && detail?.episodes?.length) {
|
||||
event.preventDefault();
|
||||
const nextBuffer = `${digitBuffer}${event.key}`.slice(-3);
|
||||
setDigitBuffer(nextBuffer);
|
||||
if (digitTimerRef.current) window.clearTimeout(digitTimerRef.current);
|
||||
digitTimerRef.current = window.setTimeout(() => {
|
||||
const target = Number(nextBuffer);
|
||||
if (target > 0) switchEpisode(target - 1);
|
||||
setDigitBuffer('');
|
||||
}, 850);
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]'));
|
||||
@@ -134,21 +442,30 @@ function TVPlayClient() {
|
||||
}
|
||||
if (!isControlFocused) {
|
||||
event.preventDefault();
|
||||
if (showEpisodes) {
|
||||
setShowEpisodes(false);
|
||||
} else {
|
||||
setShowPanel(false);
|
||||
}
|
||||
if (showPanel || showEpisodes) revealPanel();
|
||||
}
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
if (!showPanel && !showEpisodes) {
|
||||
setShowPanel(true);
|
||||
}
|
||||
|
||||
if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
||||
event.preventDefault();
|
||||
const base = event.repeat ? 30 : 10;
|
||||
seekBy(event.key === 'ArrowLeft' ? -base : base, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showPanel && !showEpisodes && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
event.preventDefault();
|
||||
setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05));
|
||||
return;
|
||||
}
|
||||
|
||||
if (showPanel || showEpisodes) {
|
||||
revealPanel();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (showEpisodes) setShowEpisodes(false);
|
||||
if (showDetail) setShowDetail(false);
|
||||
else if (showEpisodes) setShowEpisodes(false);
|
||||
else if (showPanel) setShowPanel(false);
|
||||
else router.back();
|
||||
}
|
||||
@@ -157,21 +474,96 @@ function TVPlayClient() {
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [episodeIndex, router, showEpisodes, showPanel]);
|
||||
}, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDetail, showEpisodes, showPanel, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEpisodes) return;
|
||||
const targetPage = Math.floor(episodeIndex / 30);
|
||||
setEpisodePage(targetPage);
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
window.requestAnimationFrame(() => {
|
||||
episodeButtonRefs.current[episodeIndex]?.focus({ preventScroll: true });
|
||||
});
|
||||
}, [episodeIndex, showEpisodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEpisodes) {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
}, [showEpisodes]);
|
||||
|
||||
const episodePages = useMemo(() => {
|
||||
const total = detail?.episodes?.length || 0;
|
||||
return Math.max(1, Math.ceil(total / 30));
|
||||
}, [detail?.episodes?.length]);
|
||||
|
||||
const visibleEpisodeIndexes = useMemo(() => {
|
||||
const total = detail?.episodes?.length || 0;
|
||||
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);
|
||||
}, [detail?.episodes?.length, episodePage, episodePages]);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
if (error || !detail) {
|
||||
return <main className='fixed inset-0 flex items-center justify-center bg-black p-10 text-center text-3xl font-black text-red-100'>{error || '播放信息不存在'}</main>;
|
||||
return (
|
||||
<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'>
|
||||
<AlertTriangle className='mx-auto mb-5 h-16 w-16 text-red-300' />
|
||||
<h1 className='text-4xl font-black text-red-100'>{error || '播放信息不存在'}</h1>
|
||||
<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 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>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white' onMouseMove={() => setShowPanel(true)}>
|
||||
{videoUrl ? <TVNativeVideo url={videoUrl} poster={detail.poster} title={detail.title} onTime={onTime} command={toggleCommand} /> : (
|
||||
<main data-tv-player-root 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} 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>
|
||||
)}
|
||||
{danmakuEnabled && danmakuItems.length > 0 && (
|
||||
<div className='pointer-events-none absolute inset-x-0 top-12 z-10 h-[42vh] overflow-hidden'>
|
||||
{danmakuItems.filter((item) => Math.abs(item.time - time.current) < 0.35).slice(0, 8).map((item, idx) => (
|
||||
<div
|
||||
key={`${item.time}-${idx}-${item.text}`}
|
||||
className='absolute whitespace-nowrap text-3xl font-black drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
|
||||
style={{
|
||||
top: `${(idx % 8) * 12}%`,
|
||||
color: item.color || '#fff',
|
||||
animation: `tv-danmaku ${Math.max(6, 12 - Math.min(6, item.text.length / 6))}s linear forwards`,
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
<style jsx>{`
|
||||
@keyframes tv-danmaku {
|
||||
from { transform: translateX(100vw); }
|
||||
to { transform: translateX(-120%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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'>
|
||||
<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' />
|
||||
<h2 className='text-4xl font-black'>当前视频加载失败</h2>
|
||||
<p className='mt-3 text-2xl text-slate-300'>可以重试当前地址,或打开选集与线路面板切换播放源。</p>
|
||||
<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 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>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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' />
|
||||
@@ -187,29 +579,103 @@ function TVPlayClient() {
|
||||
</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 className='mb-5 h-2 overflow-hidden rounded-full bg-white/20'>
|
||||
<div className='h-full rounded-full bg-rose-600' style={{ width: time.duration ? `${Math.min(100, (time.current / time.duration) * 100)}%` : '0%' }} />
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-5 rounded-[28px] bg-black/75 p-4 backdrop-blur'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<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={() => setShowPanel(false)} 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'><Pause 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 ? '暂停' : '播放'}
|
||||
</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 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>
|
||||
<button onClick={() => { setShowEpisodes(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'><Layers 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={() => 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>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 text-xl font-bold text-slate-200'>
|
||||
<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>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-4 rounded-3xl bg-black/70 p-4 backdrop-blur'>
|
||||
<input aria-label='播放进度' data-tv-player-control 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='tv-focusable 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'>
|
||||
<span>{formatTVTime(time.current)}</span>
|
||||
<span>{time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'}</span>
|
||||
<span>{formatTVTime(time.duration)}</span>
|
||||
</div>
|
||||
<div className='text-xl font-bold text-slate-200'>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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'>
|
||||
<h2 className='mb-5 flex items-center gap-3 text-3xl font-black'><Layers className='h-8 w-8 text-rose-500' />选集与线路</h2>
|
||||
{sources.length > 1 && <div className='mb-6 flex gap-3 overflow-x-auto px-2 py-3 [scrollbar-width:none]'>{sources.map((item) => <button key={`${item.source}-${item.id}`} onClick={() => switchSource(item)} data-tv-player-control className={`tv-focusable cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none ${detail.source === item.source && detail.id === item.id ? 'bg-rose-600' : 'bg-white/10'}`}>{item.source_name || item.source}</button>)}</div>}
|
||||
<div className='mb-6'>
|
||||
<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]'>
|
||||
{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>
|
||||
{episodePages > 1 && (
|
||||
<div className='mb-5 flex gap-3 overflow-x-auto px-2 py-2 [scrollbar-width:none]'>
|
||||
{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'}`}>
|
||||
{page * 30 + 1}-{Math.min((page + 1) * 30, detail.episodes.length)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className='grid grid-cols-4 gap-3'>
|
||||
{detail.episodes.map((_, index) => <button key={index} 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 ${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>
|
||||
</aside>
|
||||
)}
|
||||
{showDetail && (
|
||||
<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'>
|
||||
<div className='mb-6 flex items-start justify-between gap-6'>
|
||||
<div>
|
||||
<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'>
|
||||
<span className='rounded-full bg-rose-600 px-4 py-2'>{detail.source_name || detail.source}</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>}
|
||||
<span className='rounded-full bg-white/10 px-4 py-2'>{episodeTitle}</span>
|
||||
</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>
|
||||
</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' />}
|
||||
<p className='whitespace-pre-line text-2xl leading-relaxed text-slate-200'>{detail.desc || '暂无详情简介'}</p>
|
||||
</section>
|
||||
</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 && (
|
||||
<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' />}
|
||||
<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>
|
||||
<div className='min-w-16 text-center'>{Math.round((muted ? 0 : volume) * 100)}</div>
|
||||
</div>
|
||||
)}
|
||||
{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='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>{formatTVTime(seekHint.current)} / {formatTVTime(seekHint.duration)}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -128,11 +128,12 @@ const keys = {
|
||||
home: { key: 'Home', code: 'Home', keyCode: 36 },
|
||||
};
|
||||
|
||||
function fireRemoteKey(name: keyof typeof keys) {
|
||||
function fireRemoteKey(name: keyof typeof keys, repeat = false) {
|
||||
const cfg = keys[name];
|
||||
const eventInit: KeyboardEventInit = {
|
||||
key: cfg.key,
|
||||
code: cfg.code,
|
||||
repeat,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
};
|
||||
@@ -157,20 +158,50 @@ function fireRemoteKey(name: keyof typeof keys) {
|
||||
function RemoteButton({
|
||||
label,
|
||||
onClick,
|
||||
onRepeat,
|
||||
repeatable = false,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onRepeat?: () => void;
|
||||
repeatable?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const delayRef = useRef<number | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
const clearRepeat = () => {
|
||||
if (delayRef.current) window.clearTimeout(delayRef.current);
|
||||
if (intervalRef.current) window.clearInterval(intervalRef.current);
|
||||
delayRef.current = null;
|
||||
intervalRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => clearRepeat, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
onClick={() => {
|
||||
if (!repeatable) onClick();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
if (!repeatable) return;
|
||||
onClick();
|
||||
clearRepeat();
|
||||
delayRef.current = window.setTimeout(() => {
|
||||
intervalRef.current = window.setInterval(onRepeat || onClick, 130);
|
||||
}, 360);
|
||||
}}
|
||||
onPointerUp={clearRepeat}
|
||||
onPointerCancel={clearRepeat}
|
||||
onPointerLeave={clearRepeat}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className={`flex cursor-pointer items-center justify-center rounded-2xl border border-white/10 bg-white/10 text-white shadow-lg shadow-black/30 outline-none transition hover:bg-white/20 active:scale-95 focus-visible:ring-4 focus-visible:ring-rose-500/70 ${className}`}
|
||||
>
|
||||
@@ -267,23 +298,23 @@ export default function TVVirtualRemote() {
|
||||
</RemoteButton>
|
||||
|
||||
<div />
|
||||
<RemoteButton label='上' onClick={() => fireRemoteKey('up')} className='h-16'>
|
||||
<RemoteButton label='上' onClick={() => fireRemoteKey('up')} onRepeat={() => fireRemoteKey('up', true)} repeatable className='h-16'>
|
||||
<ChevronUp className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
<div />
|
||||
|
||||
<RemoteButton label='左' onClick={() => fireRemoteKey('left')} className='h-16'>
|
||||
<RemoteButton label='左' onClick={() => fireRemoteKey('left')} onRepeat={() => fireRemoteKey('left', true)} repeatable className='h-16'>
|
||||
<ChevronLeft className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
<RemoteButton label='确认' onClick={() => fireRemoteKey('ok')} className='h-16 rounded-full bg-white text-black hover:bg-slate-200'>
|
||||
<CornerDownLeft className='h-8 w-8' />
|
||||
</RemoteButton>
|
||||
<RemoteButton label='右' onClick={() => fireRemoteKey('right')} className='h-16'>
|
||||
<RemoteButton label='右' onClick={() => fireRemoteKey('right')} onRepeat={() => fireRemoteKey('right', true)} repeatable className='h-16'>
|
||||
<ChevronRight className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
|
||||
<div />
|
||||
<RemoteButton label='下' onClick={() => fireRemoteKey('down')} className='h-16'>
|
||||
<RemoteButton label='下' onClick={() => fireRemoteKey('down')} onRepeat={() => fireRemoteKey('down', true)} repeatable className='h-16'>
|
||||
<ChevronDown className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
<div />
|
||||
|
||||
@@ -11,18 +11,54 @@ declare global {
|
||||
}
|
||||
|
||||
function getSourceType(url: string): 'm3u8' | 'flv' | 'native' {
|
||||
const lower = url.toLowerCase().split('?')[0];
|
||||
if (lower.includes('.m3u8') || lower.includes('.m3u')) return 'm3u8';
|
||||
if (lower.endsWith('.flv') || url.toLowerCase().includes('.flv?')) return 'flv';
|
||||
const lower = url.toLowerCase();
|
||||
const path = lower.split('?')[0];
|
||||
// 代理地址通常是 /api/proxy/vod/m3u8?url=...,真实 m3u8 在 query 中;
|
||||
// 不能只看 ? 前路径,否则会被当成 native,浏览器只请求 index.m3u8 而不会交给 hls.js 拉 ts。
|
||||
if (path.includes('.m3u8') || path.includes('.m3u') || lower.includes('/m3u8') || lower.includes('m3u8') || lower.includes('.m3u')) return 'm3u8';
|
||||
if (path.endsWith('.flv') || lower.includes('.flv?')) return 'flv';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
function filterAdsFromM3U8(m3u8Content: string): string {
|
||||
if (!m3u8Content) return '';
|
||||
const adKeywords = ['sponsor', '/ad/', '/ads/', 'advert', 'advertisement', '/adjump', 'redtraffic'];
|
||||
const lines = m3u8Content.split('\n');
|
||||
const filteredLines: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.includes('#EXT-X-DISCONTINUITY')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.includes('#EXTINF:') && i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1];
|
||||
const isAd = adKeywords.some((keyword) => nextLine.toLowerCase().includes(keyword));
|
||||
if (isAd) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filteredLines.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
return filteredLines.join('\n');
|
||||
}
|
||||
|
||||
export default function TVNativeVideo({
|
||||
url,
|
||||
poster,
|
||||
live = false,
|
||||
title,
|
||||
onTime,
|
||||
onError: onPlaybackError,
|
||||
onPlayingChange,
|
||||
adFilterEnabled = false,
|
||||
playbackRate = 1,
|
||||
startTime = 0,
|
||||
command,
|
||||
className = '',
|
||||
}: {
|
||||
@@ -31,14 +67,34 @@ export default function TVNativeVideo({
|
||||
live?: boolean;
|
||||
title?: string;
|
||||
onTime?: (current: number, duration: number) => void;
|
||||
onError?: () => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
adFilterEnabled?: boolean;
|
||||
playbackRate?: number;
|
||||
startTime?: number;
|
||||
command?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const onTimeRef = useRef<typeof onTime>(onTime);
|
||||
const onPlaybackErrorRef = useRef<typeof onPlaybackError>(onPlaybackError);
|
||||
const onPlayingChangeRef = useRef<typeof onPlayingChange>(onPlayingChange);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
onTimeRef.current = onTime;
|
||||
}, [onTime]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlaybackErrorRef.current = onPlaybackError;
|
||||
}, [onPlaybackError]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlayingChangeRef.current = onPlayingChange;
|
||||
}, [onPlayingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
@@ -78,11 +134,32 @@ export default function TVNativeVideo({
|
||||
if (disposed) return;
|
||||
const Hls = HlsModule.default;
|
||||
if (Hls.isSupported()) {
|
||||
const CustomLoader = adFilterEnabled
|
||||
? class TVAdFilterLoader extends Hls.DefaultConfig.loader {
|
||||
constructor(config: any) {
|
||||
super(config);
|
||||
const load = this.load.bind(this);
|
||||
this.load = (context: any, config: any, callbacks: any) => {
|
||||
if (context?.type === 'manifest' || context?.type === 'level') {
|
||||
const onSuccess = callbacks.onSuccess;
|
||||
callbacks.onSuccess = (response: any, stats: any, context: any, networkDetails: any) => {
|
||||
if (typeof response?.data === 'string') {
|
||||
response.data = filterAdsFromM3U8(response.data);
|
||||
}
|
||||
return onSuccess(response, stats, context, networkDetails);
|
||||
};
|
||||
}
|
||||
load(context, config, callbacks);
|
||||
};
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: live,
|
||||
backBufferLength: live ? 10 : 30,
|
||||
maxBufferLength: live ? 18 : 45,
|
||||
...(CustomLoader ? { loader: CustomLoader } : {}),
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(videoEl);
|
||||
@@ -108,6 +185,7 @@ export default function TVNativeVideo({
|
||||
|
||||
videoEl.setAttribute('playsinline', 'true');
|
||||
videoEl.setAttribute('webkit-playsinline', 'true');
|
||||
videoEl.playbackRate = playbackRate;
|
||||
videoEl.muted = false;
|
||||
playSafely();
|
||||
} catch (err) {
|
||||
@@ -119,15 +197,38 @@ export default function TVNativeVideo({
|
||||
|
||||
attach();
|
||||
|
||||
const onLoaded = () => setLoading(false);
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
let seekedInitialTime = false;
|
||||
const seekToInitialTime = () => {
|
||||
if (live || seekedInitialTime || !startTime || startTime <= 1) return;
|
||||
const duration = videoEl.duration || 0;
|
||||
const safeTime = duration > 30 ? Math.min(startTime, Math.max(0, duration - 8)) : startTime;
|
||||
try {
|
||||
videoEl.currentTime = safeTime;
|
||||
seekedInitialTime = true;
|
||||
} catch {
|
||||
// ignore unsupported seek state
|
||||
}
|
||||
};
|
||||
const onLoaded = () => {
|
||||
seekToInitialTime();
|
||||
setLoading(false);
|
||||
};
|
||||
const onPlay = () => {
|
||||
setPlaying(true);
|
||||
onPlayingChangeRef.current?.(true);
|
||||
};
|
||||
const onPause = () => {
|
||||
setPlaying(false);
|
||||
onPlayingChangeRef.current?.(false);
|
||||
};
|
||||
const onError = () => {
|
||||
setLoading(false);
|
||||
setError('视频加载失败,请尝试切换线路或频道');
|
||||
onPlaybackErrorRef.current?.();
|
||||
};
|
||||
const onTimeUpdate = () => onTime?.(videoEl.currentTime || 0, videoEl.duration || 0);
|
||||
const onTimeUpdate = () => onTimeRef.current?.(videoEl.currentTime || 0, videoEl.duration || 0);
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', seekToInitialTime);
|
||||
videoEl.addEventListener('loadeddata', onLoaded);
|
||||
videoEl.addEventListener('canplay', onLoaded);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
@@ -137,6 +238,7 @@ export default function TVNativeVideo({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
videoEl.removeEventListener('loadedmetadata', seekToInitialTime);
|
||||
videoEl.removeEventListener('loadeddata', onLoaded);
|
||||
videoEl.removeEventListener('canplay', onLoaded);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
@@ -145,7 +247,12 @@ export default function TVNativeVideo({
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
cleanup();
|
||||
};
|
||||
}, [url, live, onTime]);
|
||||
}, [url, live, startTime, adFilterEnabled, playbackRate]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (video) video.playbackRate = playbackRate;
|
||||
}, [playbackRate]);
|
||||
|
||||
const toggle = () => {
|
||||
const video = videoRef.current;
|
||||
|
||||
@@ -14,7 +14,24 @@ export async function fetchTVDetail(params: {
|
||||
const res = await fetch(`/api/source-detail?${qs.toString()}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('获取视频详情失败');
|
||||
const detail = (await res.json()) as SearchResult;
|
||||
return { detail, sources: [detail] };
|
||||
let sources: SearchResult[] = [detail];
|
||||
const searchTitle = title || detail.title;
|
||||
if (searchTitle) {
|
||||
try {
|
||||
const searchRes = await fetch(`/api/search?q=${encodeURIComponent(searchTitle)}`, { cache: 'no-store' });
|
||||
if (searchRes.ok) {
|
||||
const data = await searchRes.json();
|
||||
const list = (data.results || []) as SearchResult[];
|
||||
sources = [
|
||||
detail,
|
||||
...list.filter((item) => !(item.source === detail.source && item.id === detail.id)),
|
||||
];
|
||||
}
|
||||
} catch {
|
||||
// 换源搜索失败不影响当前播放
|
||||
}
|
||||
}
|
||||
return { detail, sources };
|
||||
}
|
||||
|
||||
if (!title) throw new Error('缺少片名');
|
||||
|
||||
Reference in New Issue
Block a user