From 134628f7734a2978583cdd1fe3d4da4ee5bd5bf1 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 29 May 2026 01:06:35 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E5=AE=8C=E5=96=84tv=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 107 ++++++++++ src/app/api/auth/qr/image/route.ts | 36 ++++ src/app/globals.css | 7 +- src/app/tv/detail/page.tsx | 120 +++++++++++ src/app/tv/live/play/page.tsx | 177 +++++++++++++++-- src/app/tv/login/page.tsx | 2 +- src/app/tv/play/page.tsx | 221 ++++++++++++++++++--- src/components/tv/TVCard.tsx | 2 +- src/components/tv/TVVirtualRemote.tsx | 25 ++- src/components/tv/player/TVNativeVideo.tsx | 194 ++++++++++++++++++ src/components/tv/player/utils.ts | 69 +++++++ 12 files changed, 909 insertions(+), 53 deletions(-) create mode 100644 src/app/api/auth/qr/image/route.ts create mode 100644 src/app/tv/detail/page.tsx create mode 100644 src/components/tv/player/TVNativeVideo.tsx create mode 100644 src/components/tv/player/utils.ts diff --git a/package.json b/package.json index 3518e0b..5d35bcb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c1f076..5d3e90e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -4129,6 +4138,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -4370,6 +4382,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -4444,6 +4460,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: 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'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6835,6 +6858,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.1: 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'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -7097,6 +7128,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: 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'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -8046,6 +8083,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: 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'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -8156,6 +8200,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: 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} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -12472,6 +12524,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.0.3 + '@types/react-dom@18.3.7(@types/react@18.3.27)': dependencies: '@types/react': 18.3.27 @@ -13652,6 +13708,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -13866,6 +13928,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.2.0: @@ -13928,6 +13992,8 @@ snapshots: diff@4.0.2: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -16741,6 +16807,8 @@ snapshots: dependencies: find-up: 4.1.0 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): @@ -16893,6 +16961,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -17060,6 +17134,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resolve-cwd@3.0.0: @@ -17232,6 +17308,8 @@ snapshots: server-only@0.0.1: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -18297,6 +18375,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -18466,6 +18546,12 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -18532,6 +18618,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -18542,10 +18630,29 @@ snapshots: yaml@2.8.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + 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 + yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/src/app/api/auth/qr/image/route.ts b/src/app/api/auth/qr/image/route.ts new file mode 100644 index 0000000..3df29ab --- /dev/null +++ b/src/app/api/auth/qr/image/route.ts @@ -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 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 3c5b7d6..71ecb66 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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) { diff --git a/src/app/tv/detail/page.tsx b/src/app/tv/detail/page.tsx new file mode 100644 index 0000000..8543247 --- /dev/null +++ b/src/app/tv/detail/page.tsx @@ -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(null); + const [sources, setSources] = useState([]); + 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
正在加载详情...
; + } + + if (error || !detail) { + return
{error || '详情不存在'}
; + } + + return ( + +
+ {poster && } +
+
+ {poster ? {detail.title} :
} +
+
+ +

{detail.title}

+
+ {detail.source_name || detail.source} + {detail.year && {detail.year}} + {detail.type_name && {detail.type_name}} + {detail.vod_remarks && {detail.vod_remarks}} +
+ {detail.desc &&

{detail.desc}

} + +
+
+
+ + {sources.length > 1 && ( +
+

播放源

+
+ {sources.map((item) => ( + + ))} +
+
+ )} + +
+

选集

+
+ {(detail.episodes_titles?.length ? detail.episodes_titles : detail.episodes).map((ep, index) => ( + + ))} +
+
+
+ ); +} + +export default function TVDetailPage() { + return ; +} diff --git a/src/app/tv/live/play/page.tsx b/src/app/tv/live/play/page.tsx index 970cc71..138a4dc 100644 --- a/src/app/tv/live/play/page.tsx +++ b/src/app/tv/live/play/page.tsx @@ -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([]); + const [source, setSource] = useState(null); + const [channels, setChannels] = useState([]); + const [channel, setChannel] = useState(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
正在进入电视直播...
; + } + + if (error || !channel) { + return
{error || '没有可播放频道'}
; + } return ( -
setShowPanel(true)}> -