继续完善tv模式
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const data = new URL(request.url).searchParams.get('data') || '';
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Missing data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const svg = await QRCode.toString(data, {
|
||||
type: 'svg',
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || '二维码生成失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -280,8 +280,9 @@ div[data-media-provider] video {
|
||||
scroll-margin-left: 48px;
|
||||
scroll-margin-right: 48px;
|
||||
transform-origin: center;
|
||||
scale: 1;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
scale 180ms ease,
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
@@ -292,7 +293,7 @@ div[data-media-provider] video {
|
||||
.tv-focusable:focus-visible,
|
||||
.tv-focused {
|
||||
outline: none !important;
|
||||
transform: scale(1.055);
|
||||
scale: 1.055;
|
||||
background-color: rgba(15, 23, 42, 0.96) !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow:
|
||||
@@ -314,7 +315,7 @@ div[data-media-provider] video {
|
||||
}
|
||||
|
||||
.tv-focusable:active {
|
||||
transform: scale(1.03);
|
||||
scale: 1.03;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, Loader2, Play, Server } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
import { fetchTVDetail } from '@/components/tv/player/utils';
|
||||
|
||||
function TVDetailClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [detail, setDetail] = useState<SearchResult | null>(null);
|
||||
const [sources, setSources] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
const title = searchParams.get('title');
|
||||
const fileName = searchParams.get('fileName');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchTVDetail({ source, id, title, fileName })
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
setDetail(data.detail);
|
||||
setSources(data.sources);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!alive) return;
|
||||
setError(err instanceof Error ? err.message : '加载详情失败');
|
||||
})
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [source, id, title, fileName]);
|
||||
|
||||
const poster = useMemo(() => detail?.poster ? processImageUrl(detail.poster) : '', [detail?.poster]);
|
||||
|
||||
const play = (episode = 0, target = detail) => {
|
||||
if (!target) return;
|
||||
const qs = new URLSearchParams({
|
||||
source: target.source,
|
||||
id: target.id,
|
||||
title: target.title,
|
||||
index: String(episode),
|
||||
});
|
||||
if (fileName) qs.set('fileName', fileName);
|
||||
router.push(`/tv/play?${qs.toString()}`);
|
||||
};
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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='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' />}
|
||||
<div className='relative grid grid-cols-[300px_1fr] gap-10'>
|
||||
<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]' />}
|
||||
</div>
|
||||
<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>
|
||||
<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'>
|
||||
<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>}
|
||||
</div>
|
||||
{detail.desc && <p className='mt-6 line-clamp-5 max-w-5xl text-2xl leading-relaxed text-slate-300'>{detail.desc}</p>}
|
||||
<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'>
|
||||
<Play className='h-9 w-9 fill-current' /> 立即播放
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sources.length > 1 && (
|
||||
<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='flex flex-wrap gap-4 px-4 py-4'>
|
||||
{sources.map((item) => (
|
||||
<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'}`}>
|
||||
<Server className='h-6 w-6' /> {item.source_name || item.source}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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='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) => (
|
||||
<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?.[index] || `第 ${index + 1} 集`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TVDetailPage() {
|
||||
return <Suspense fallback={null}><TVDetailClient /></Suspense>;
|
||||
}
|
||||
+164
-13
@@ -1,30 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ExternalLink, Radio } from 'lucide-react';
|
||||
import { ArrowLeft, Loader2, Radio, Star } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { 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 };
|
||||
|
||||
function getLogoUrl(logo?: string, source?: string) {
|
||||
if (!logo) return '';
|
||||
if (!source) return logo;
|
||||
return `/api/proxy/logo?url=${encodeURIComponent(logo)}&source=${encodeURIComponent(source)}`;
|
||||
}
|
||||
|
||||
async function resolveLiveUrl(rawUrl: string, source?: LiveSource | null) {
|
||||
const proxyMode = source?.proxyMode || 'full';
|
||||
const lower = rawUrl.toLowerCase();
|
||||
const isM3u8 = lower.includes('.m3u8') || lower.includes('.m3u');
|
||||
if (!isM3u8 || proxyMode === 'direct') return rawUrl;
|
||||
return `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}`;
|
||||
}
|
||||
|
||||
function TVLivePlayClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const needSource = searchParams.get('source');
|
||||
const needChannel = searchParams.get('id');
|
||||
|
||||
const [sources, setSources] = useState<LiveSource[]>([]);
|
||||
const [source, setSource] = useState<LiveSource | null>(null);
|
||||
const [channels, setChannels] = useState<LiveChannel[]>([]);
|
||||
const [channel, setChannel] = useState<LiveChannel | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const originalUrl = useMemo(() => `/live?${new URLSearchParams(searchParams.toString()).toString()}`, [searchParams]);
|
||||
const [selectedGroup, setSelectedGroup] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch('/api/live/sources')
|
||||
.then((r) => 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);
|
||||
})
|
||||
.catch(() => setError('获取直播源失败'));
|
||||
return () => { alive = false; };
|
||||
}, [needSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetch(`/api/live/channels?source=${encodeURIComponent(source.key)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
const list = (data.data || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
tvgId: item.tvgId || item.name,
|
||||
name: item.name,
|
||||
logo: item.logo,
|
||||
group: item.group || '其他',
|
||||
url: item.url,
|
||||
}));
|
||||
setChannels(list);
|
||||
const selected = list.find((c: LiveChannel) => c.id === needChannel) || list[0] || null;
|
||||
setChannel(selected);
|
||||
setSelectedGroup(selected?.group || list[0]?.group || '');
|
||||
})
|
||||
.catch(() => setError('获取频道列表失败'))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [source, needChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!channel) return;
|
||||
resolveLiveUrl(channel.url, source).then((url) => alive && setVideoUrl(url));
|
||||
if (source) {
|
||||
savePlayRecord(`live_${source.key}`, `live_${channel.id}`, {
|
||||
title: channel.name,
|
||||
source_name: source.name,
|
||||
year: '',
|
||||
cover: getLogoUrl(channel.logo, source.key),
|
||||
index: 1,
|
||||
total_episodes: 1,
|
||||
play_time: 0,
|
||||
total_time: 0,
|
||||
save_time: Date.now(),
|
||||
search_title: channel.name,
|
||||
origin: 'live',
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
return () => { alive = false; };
|
||||
}, [channel, 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 switchChannel = (next: LiveChannel) => {
|
||||
setChannel(next);
|
||||
setSelectedGroup(next.group || '其他');
|
||||
setShowPanel(true);
|
||||
if (source) router.replace(`/tv/live/play?source=${encodeURIComponent(source.key)}&id=${encodeURIComponent(next.id)}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') setShowPanel((v) => !v);
|
||||
if (event.key === 'Escape') {
|
||||
if (showPanel) setShowPanel(false);
|
||||
else router.back();
|
||||
}
|
||||
if (event.key === 'PageUp' || event.key === 'PageDown') {
|
||||
const currentIndex = channels.findIndex((item) => item.id === channel?.id);
|
||||
if (currentIndex >= 0) {
|
||||
const nextIndex = event.key === 'PageUp' ? currentIndex - 1 : currentIndex + 1;
|
||||
const next = channels[Math.max(0, Math.min(channels.length - 1, nextIndex))];
|
||||
if (next) switchChannel(next);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [channel?.id, channels, router, showPanel, source]);
|
||||
|
||||
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 overflow-hidden bg-black text-white' onMouseMove={() => setShowPanel(true)}>
|
||||
<iframe src={originalUrl} title='TV 直播播放器' className='h-full w-full border-0 bg-black' allow='autoplay; fullscreen; picture-in-picture' allowFullScreen />
|
||||
<div className={`absolute left-6 right-6 top-6 flex items-center justify-between transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => router.back()} className='flex cursor-pointer items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-2xl font-black outline-none backdrop-blur transition hover:bg-white/15 tv-focusable'>
|
||||
<ArrowLeft className='h-7 w-7' /> 返回频道
|
||||
</button>
|
||||
<div className='flex items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-xl font-bold backdrop-blur'><Radio className='h-6 w-6 text-rose-400' /> TV 全屏直播页</div>
|
||||
<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>}
|
||||
|
||||
<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-y-0 left-0 w-[560px] bg-gradient-to-r from-black/90 to-transparent' />
|
||||
</div>
|
||||
<div className={`absolute bottom-8 left-1/2 flex -translate-x-1/2 gap-4 rounded-3xl bg-black/75 p-4 backdrop-blur transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => setShowPanel(false)} className='cursor-pointer rounded-2xl bg-white/10 px-6 py-4 text-xl font-bold outline-none hover:bg-white/20 tv-focusable'>隐藏浮层</button>
|
||||
<a href={originalUrl} className='flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none hover:bg-rose-500 tv-focusable'><ExternalLink className='h-6 w-6' /> 原直播页</a>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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'>
|
||||
<div className='overflow-y-auto pr-2'>
|
||||
<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>)}
|
||||
</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>
|
||||
<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>)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function TVLoginPage() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [qr, redirect, router]);
|
||||
|
||||
const qrImg = qr ? `https://api.qrserver.com/v1/create-qr-code/?size=360x360&margin=16&data=${encodeURIComponent(qr.qrUrl)}` : '';
|
||||
const qrImg = qr ? `/api/auth/qr/image?data=${encodeURIComponent(qr.qrUrl)}` : '';
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
|
||||
+196
-25
@@ -1,44 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ExternalLink, Layers, Maximize2 } from 'lucide-react';
|
||||
import { ArrowLeft, Layers, ListVideo, Loader2, Pause, SkipBack, SkipForward } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { savePlayRecord } from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
||||
import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils';
|
||||
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
||||
|
||||
function TVPlayClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [detail, setDetail] = useState<SearchResult | null>(null);
|
||||
const [sources, setSources] = useState<SearchResult[]>([]);
|
||||
const [episodeIndex, setEpisodeIndex] = useState(0);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const [showEpisodes, setShowEpisodes] = useState(false);
|
||||
const [toggleCommand, setToggleCommand] = useState(0);
|
||||
const [time, setTime] = useState({ current: 0, duration: 0 });
|
||||
const timeRef = useRef({ current: 0, duration: 0 });
|
||||
|
||||
const originalUrl = useMemo(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
return `/play?${params.toString()}`;
|
||||
}, [searchParams]);
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
const title = searchParams.get('title');
|
||||
const fileName = searchParams.get('fileName');
|
||||
const initialIndex = Number(searchParams.get('index') || '0');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchTVDetail({ source, id, title, fileName })
|
||||
.then((data) => {
|
||||
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)));
|
||||
setEpisodeIndex(safeIndex);
|
||||
})
|
||||
.catch((err) => alive && setError(err instanceof Error ? err.message : '加载播放信息失败'))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [source, id, title, fileName, initialIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function resolve() {
|
||||
if (!detail?.episodes?.[episodeIndex]) return;
|
||||
setResolving(true);
|
||||
setVideoUrl('');
|
||||
try {
|
||||
const url = await resolveTVEpisodeUrl(detail.episodes[episodeIndex], detail.source, detail.proxyMode);
|
||||
if (alive) setVideoUrl(url);
|
||||
} catch (err) {
|
||||
if (alive) setError(err instanceof Error ? err.message : '获取播放地址失败');
|
||||
} finally {
|
||||
if (alive) setResolving(false);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
return () => { alive = false; };
|
||||
}, [detail, episodeIndex]);
|
||||
|
||||
const episodeTitle = useMemo(() => detail?.episodes_titles?.[episodeIndex] || `第 ${episodeIndex + 1} 集`, [detail, episodeIndex]);
|
||||
|
||||
const onTime = useCallback((current: number, duration: number) => {
|
||||
const next = { current, duration };
|
||||
timeRef.current = next;
|
||||
setTime(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
const timer = window.setInterval(() => {
|
||||
savePlayRecord(detail.source, detail.id, {
|
||||
title: detail.title,
|
||||
source_name: detail.source_name,
|
||||
year: detail.year || '',
|
||||
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),
|
||||
save_time: Date.now(),
|
||||
search_title: title || detail.title,
|
||||
}).catch(() => undefined);
|
||||
}, 10000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [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 switchSource = async (item: SearchResult) => {
|
||||
setShowPanel(true);
|
||||
setShowEpisodes(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
let next = item;
|
||||
if (!item.episodes?.length) {
|
||||
const data = await fetchTVDetail({ source: item.source, id: item.id, title: item.title });
|
||||
next = data.detail;
|
||||
}
|
||||
setDetail(next);
|
||||
setEpisodeIndex(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '切换播放源失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]'));
|
||||
if (!showPanel && !showEpisodes) {
|
||||
event.preventDefault();
|
||||
setToggleCommand((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
if (!isControlFocused) {
|
||||
event.preventDefault();
|
||||
if (showEpisodes) {
|
||||
setShowEpisodes(false);
|
||||
} else {
|
||||
setShowPanel(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
if (!showPanel && !showEpisodes) {
|
||||
setShowPanel(true);
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (showEpisodes) setShowEpisodes(false);
|
||||
else if (showPanel) setShowPanel(false);
|
||||
else router.back();
|
||||
}
|
||||
if (event.key === 'PageUp') switchEpisode(episodeIndex - 1);
|
||||
if (event.key === 'PageDown') switchEpisode(episodeIndex + 1);
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [episodeIndex, router, showEpisodes, 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 || !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 overflow-hidden bg-black text-white' onMouseMove={() => setShowControls(true)}>
|
||||
<iframe src={originalUrl} title='TV 播放器' className='h-full w-full border-0 bg-black' allow='autoplay; fullscreen; picture-in-picture' allowFullScreen />
|
||||
<div className={`pointer-events-none absolute inset-0 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className='absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/85 to-transparent' />
|
||||
<div className='absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-black/85 to-transparent' />
|
||||
<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} /> : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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 bottom-0 h-56 bg-gradient-to-t from-black/95 to-transparent' />
|
||||
</div>
|
||||
<div className={`absolute left-6 right-6 top-6 flex items-center justify-between transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => router.back()} className='flex cursor-pointer items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-2xl font-black outline-none backdrop-blur transition hover:bg-white/15 tv-focusable'>
|
||||
<ArrowLeft className='h-7 w-7' /> 返回
|
||||
</button>
|
||||
<div className='flex items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-xl font-bold text-slate-200 backdrop-blur'>
|
||||
<Maximize2 className='h-6 w-6 text-rose-400' /> TV 全屏播放页
|
||||
|
||||
<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()} 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='max-w-[60vw] truncate text-3xl font-black'>{detail.title}</div>
|
||||
<div className='mt-1 text-xl text-slate-300'>{episodeTitle} · {detail.source_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`absolute bottom-8 left-1/2 flex -translate-x-1/2 items-center gap-4 rounded-3xl bg-black/75 p-4 backdrop-blur transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => setShowControls(false)} className='flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-6 py-4 text-xl font-bold outline-none hover:bg-white/20 tv-focusable'>
|
||||
<Layers className='h-6 w-6' /> 隐藏浮层
|
||||
</button>
|
||||
<a href={originalUrl} className='flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none hover:bg-rose-500 tv-focusable'>
|
||||
<ExternalLink className='h-6 w-6' /> 原播放页
|
||||
</a>
|
||||
|
||||
<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={() => 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>
|
||||
</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='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>)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TVItem } from './types';
|
||||
export default function TVCard({ item }: { item: TVItem }) {
|
||||
const router = useRouter();
|
||||
const poster = item.poster ? processImageUrl(item.poster) : '';
|
||||
const playUrl = item.href || `/tv/play?title=${encodeURIComponent(item.title)}${
|
||||
const playUrl = item.href || `/tv/detail?title=${encodeURIComponent(item.title)}${
|
||||
item.year ? `&year=${encodeURIComponent(item.year)}` : ''
|
||||
}${item.type ? `&stype=${item.type}` : ''}`;
|
||||
|
||||
|
||||
@@ -38,16 +38,15 @@ function getFocusableElements() {
|
||||
function focusElement(element: HTMLElement) {
|
||||
element.focus({ preventScroll: true });
|
||||
|
||||
const isInFixedChrome = Boolean(element.closest('header, [data-tv-remote]'));
|
||||
|
||||
// 先让浏览器处理横向滚动行,再额外修正固定顶部导航遮挡。
|
||||
element.scrollIntoView({
|
||||
block: isInFixedChrome ? 'nearest' : 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
const isInFixedChrome = Boolean(element.closest('header, [data-tv-remote], [data-tv-player-control], [data-tv-player-root]'));
|
||||
|
||||
// 播放页浮层是 fixed/absolute,不能 scrollIntoView,否则会把全屏播放器滚出视口。
|
||||
if (!isInFixedChrome) {
|
||||
element.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
window.requestAnimationFrame(() => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const safeTop = 150;
|
||||
@@ -218,8 +217,14 @@ export default function TVVirtualRemote() {
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
window.history.back();
|
||||
const path = window.location.pathname;
|
||||
const playerPage = path === '/tv/play' || path === '/tv/live/play';
|
||||
// 播放页需要优先用返回键关闭选集/频道面板,不能被全局遥控器直接 history.back。
|
||||
if (!playerPage) {
|
||||
event.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
declare global {
|
||||
interface HTMLVideoElement {
|
||||
hls?: any;
|
||||
flv?: any;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
return 'native';
|
||||
}
|
||||
|
||||
export default function TVNativeVideo({
|
||||
url,
|
||||
poster,
|
||||
live = false,
|
||||
title,
|
||||
onTime,
|
||||
command,
|
||||
className = '',
|
||||
}: {
|
||||
url: string;
|
||||
poster?: string;
|
||||
live?: boolean;
|
||||
title?: string;
|
||||
onTime?: (current: number, duration: number) => void;
|
||||
command?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
const videoEl = video;
|
||||
|
||||
let disposed = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setPlaying(false);
|
||||
|
||||
const cleanup = () => {
|
||||
if (videoEl.hls) {
|
||||
videoEl.hls.destroy();
|
||||
videoEl.hls = null;
|
||||
}
|
||||
if (videoEl.flv) {
|
||||
videoEl.flv.destroy();
|
||||
videoEl.flv = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
};
|
||||
|
||||
const playSafely = () => {
|
||||
videoEl.play().catch(() => {
|
||||
// 浏览器阻止自动播放时,等待用户按 OK/点击播放
|
||||
});
|
||||
};
|
||||
|
||||
async function attach() {
|
||||
cleanup();
|
||||
const type = getSourceType(url);
|
||||
|
||||
try {
|
||||
if (type === 'm3u8' && !videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
const HlsModule = await import('hls.js');
|
||||
if (disposed) return;
|
||||
const Hls = HlsModule.default;
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: live,
|
||||
backBufferLength: live ? 10 : 30,
|
||||
maxBufferLength: live ? 18 : 45,
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(videoEl);
|
||||
videoEl.hls = hls;
|
||||
} else {
|
||||
videoEl.src = url;
|
||||
}
|
||||
} else if (type === 'flv') {
|
||||
const flvModule = await import('flv.js');
|
||||
if (disposed) return;
|
||||
const flvjs = flvModule.default;
|
||||
if (flvjs.isSupported()) {
|
||||
const flv = flvjs.createPlayer({ type: 'flv', url, isLive: live });
|
||||
flv.attachMediaElement(videoEl);
|
||||
flv.load();
|
||||
videoEl.flv = flv;
|
||||
} else {
|
||||
videoEl.src = url;
|
||||
}
|
||||
} else {
|
||||
videoEl.src = url;
|
||||
}
|
||||
|
||||
videoEl.setAttribute('playsinline', 'true');
|
||||
videoEl.setAttribute('webkit-playsinline', 'true');
|
||||
videoEl.muted = false;
|
||||
playSafely();
|
||||
} catch (err) {
|
||||
console.error('[TVNativeVideo] attach failed:', err);
|
||||
setError('播放器初始化失败');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
attach();
|
||||
|
||||
const onLoaded = () => setLoading(false);
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
const onError = () => {
|
||||
setLoading(false);
|
||||
setError('视频加载失败,请尝试切换线路或频道');
|
||||
};
|
||||
const onTimeUpdate = () => onTime?.(videoEl.currentTime || 0, videoEl.duration || 0);
|
||||
|
||||
videoEl.addEventListener('loadeddata', onLoaded);
|
||||
videoEl.addEventListener('canplay', onLoaded);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('error', onError);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
videoEl.removeEventListener('loadeddata', onLoaded);
|
||||
videoEl.removeEventListener('canplay', onLoaded);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('error', onError);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
cleanup();
|
||||
};
|
||||
}, [url, live, onTime]);
|
||||
|
||||
const toggle = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) video.play().catch(() => undefined);
|
||||
else video.pause();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (command) toggle();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [command]);
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full bg-black ${className}`}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
poster={poster}
|
||||
className='h-full w-full bg-black object-contain'
|
||||
controls={false}
|
||||
playsInline
|
||||
preload='auto'
|
||||
onClick={toggle}
|
||||
aria-label={title || 'TV 视频播放器'}
|
||||
/>
|
||||
{loading && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-2xl font-bold text-white'>
|
||||
<Loader2 className='mr-3 h-9 w-9 animate-spin text-rose-500' /> 正在缓冲...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-black/70 p-8 text-center text-3xl font-black text-white'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggle}
|
||||
className='tv-focusable absolute left-1/2 top-1/2 flex h-24 w-24 -translate-x-1/2 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/35 text-white opacity-0 outline-none backdrop-blur transition hover:opacity-100 focus:opacity-100'
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{playing ? <Pause className='h-12 w-12' /> : <Play className='h-12 w-12 fill-current' />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
export async function fetchTVDetail(params: {
|
||||
source?: string | null;
|
||||
id?: string | null;
|
||||
title?: string | null;
|
||||
fileName?: string | null;
|
||||
}): Promise<{ detail: SearchResult; sources: SearchResult[] }> {
|
||||
const { source, id, title, fileName } = params;
|
||||
|
||||
if (source && id) {
|
||||
const qs = new URLSearchParams({ source, id, title: title || '' });
|
||||
if (fileName) qs.set('fileName', fileName);
|
||||
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] };
|
||||
}
|
||||
|
||||
if (!title) throw new Error('缺少片名');
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(title)}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('搜索播放源失败');
|
||||
const data = await res.json();
|
||||
const sources = (data.results || []) as SearchResult[];
|
||||
if (sources.length === 0) throw new Error('未找到播放源');
|
||||
let detail = sources[0];
|
||||
if (!detail.episodes?.length) {
|
||||
const qs = new URLSearchParams({ source: detail.source, id: detail.id, title: detail.title || title });
|
||||
const detailRes = await fetch(`/api/source-detail?${qs.toString()}`, { cache: 'no-store' });
|
||||
if (detailRes.ok) detail = (await detailRes.json()) as SearchResult;
|
||||
}
|
||||
return { detail, sources };
|
||||
}
|
||||
|
||||
export async function resolveTVEpisodeUrl(rawUrl: string, source?: string, proxyMode?: boolean) {
|
||||
let url = rawUrl;
|
||||
const lazyPrefixes = [
|
||||
'/api/xiaoya/play',
|
||||
'/api/openlist/play',
|
||||
'/api/netdisk/115/play',
|
||||
'/api/netdisk/123/play',
|
||||
'/api/netdisk/quark/play',
|
||||
'/api/netdisk/uc/play',
|
||||
'/api/netdisk/baidu/play',
|
||||
'/api/source-script/play',
|
||||
];
|
||||
if (lazyPrefixes.some((prefix) => url.startsWith(prefix))) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
const res = await fetch(`${url}${separator}format=json`, { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (data.url) url = data.url;
|
||||
}
|
||||
|
||||
const isM3u8 = url.toLowerCase().includes('.m3u') || !url.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
||||
if (proxyMode && source && isM3u8 && !url.startsWith('/api/proxy/')) {
|
||||
return `/api/proxy/vod/m3u8?url=${encodeURIComponent(url)}&source=${encodeURIComponent(source)}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function formatTVTime(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '00:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return h > 0
|
||||
? `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user