继续完善tv模式

This commit is contained in:
mtvpls
2026-05-29 01:06:35 +08:00
parent 6e5fb5796c
commit 134628f773
12 changed files with 909 additions and 53 deletions
+2
View File
@@ -33,6 +33,7 @@
"@heroicons/react": "^2.2.0",
"@types/crypto-js": "^4.2.2",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@upstash/redis": "^1.25.0",
"@vercel/postgres": "^0.10.0",
"@vidstack/react": "^1.12.13",
@@ -63,6 +64,7 @@
"nprogress": "^0.2.0",
"opencc-js": "^1.0.5",
"parse-torrent-name": "^0.5.4",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
+107
View File
@@ -32,6 +32,9 @@ importers:
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@upstash/redis':
specifier: ^1.25.0
version: 1.36.0
@@ -122,6 +125,9 @@ importers:
parse-torrent-name:
specifier: ^0.5.4
version: 0.5.4
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^18.3.1
version: 18.3.1
@@ -3190,6 +3196,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/[email protected]':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/[email protected]':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@@ -4129,6 +4138,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
[email protected]:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
[email protected]:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -4370,6 +4382,10 @@ packages:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -4444,6 +4460,9 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
[email protected]:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
[email protected]:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -6619,6 +6638,10 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
[email protected]:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6835,6 +6858,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
@@ -6958,6 +6986,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
[email protected]:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -7097,6 +7128,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
[email protected]:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
[email protected]:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -7956,6 +7990,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
[email protected]:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -8046,6 +8083,10 @@ packages:
'@cloudflare/workers-types':
optional: true
[email protected]:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -8137,6 +8178,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
[email protected]:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
[email protected]:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -8156,6 +8200,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@@ -8164,6 +8212,10 @@ packages:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
[email protected]:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
@@ -12472,6 +12524,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 24.0.3
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 18.3.27
@@ -13652,6 +13708,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
[email protected]:
dependencies:
string-width: 4.2.3
@@ -13866,6 +13928,8 @@ snapshots:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -13928,6 +13992,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
path-type: 4.0.0
@@ -16741,6 +16807,8 @@ snapshots:
dependencies:
find-up: 4.1.0
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
@@ -16893,6 +16961,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
[email protected]:
dependencies:
side-channel: 1.1.0
@@ -17060,6 +17134,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -17232,6 +17308,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
define-data-property: 1.1.4
@@ -18297,6 +18375,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
[email protected]: {}
[email protected]:
dependencies:
available-typed-arrays: 1.0.7
@@ -18466,6 +18546,12 @@ snapshots:
- bufferutil
- utf-8-validate
[email protected]:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
[email protected]:
dependencies:
ansi-styles: 4.3.0
@@ -18532,6 +18618,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -18542,10 +18630,29 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
[email protected]:
dependencies:
cliui: 7.0.4
+36
View File
@@ -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
View File
@@ -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) {
+120
View File
@@ -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
View File
@@ -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>
);
+1 -1
View File
@@ -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
View File
@@ -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>
);
+1 -1
View File
@@ -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}` : ''}`;
+10 -5
View File
@@ -38,16 +38,15 @@ function getFocusableElements() {
function focusElement(element: HTMLElement) {
element.focus({ preventScroll: true });
const isInFixedChrome = Boolean(element.closest('header, [data-tv-remote]'));
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: isInFixedChrome ? 'nearest' : 'nearest',
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
if (!isInFixedChrome) {
window.requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
const safeTop = 150;
@@ -218,9 +217,15 @@ export default function TVVirtualRemote() {
}
if (event.key === 'Escape') {
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') {
event.preventDefault();
+194
View File
@@ -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>
);
}
+69
View File
@@ -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')}`;
}