diff --git a/src/app/tv/live/play/page.tsx b/src/app/tv/live/play/page.tsx index 29ae2aa..7380cb9 100644 --- a/src/app/tv/live/play/page.tsx +++ b/src/app/tv/live/play/page.tsx @@ -4,7 +4,7 @@ import { AlertTriangle, ArrowLeft, Clock, Heart, Loader2, Maximize, Radio, Rotat import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; -import { deleteFavorite, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client'; +import { deleteFavorite, isFavorited, saveFavorite } from '@/lib/db.client'; import TVNativeVideo from '@/components/tv/player/TVNativeVideo'; import TVVirtualRemote from '@/components/tv/TVVirtualRemote'; @@ -168,21 +168,6 @@ function TVLivePlayClient() { if (!alive) return; setUnsupportedError(err instanceof Error ? err.message : '不支持的直播流格式'); }); - 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]); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 3d07ec9..e2063f3 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -33,7 +33,7 @@ import { X, } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; @@ -87,6 +87,7 @@ export const UserMenu: React.FC = () => { const [subscribeEnabled, setSubscribeEnabled] = useState(false); const [subscribeUrl, setSubscribeUrl] = useState(''); const [copySuccess, setCopySuccess] = useState(false); + const [orionBaseUrlCopySuccess, setOrionBaseUrlCopySuccess] = useState(false); const [tvboxToken, setTvboxToken] = useState(''); const [isResettingToken, setIsResettingToken] = useState(false); const [isLoadingSubscribeUrl, setIsLoadingSubscribeUrl] = useState(false); @@ -95,6 +96,15 @@ export const UserMenu: React.FC = () => { const [subscribeYellowFilterEnabled, setSubscribeYellowFilterEnabled] = useState(false); + // Web 电视扫码登录入口(手机摄像头扫描电视端二维码) + const [isTvQrScannerOpen, setIsTvQrScannerOpen] = useState(false); + const [tvQrScannerStatus, setTvQrScannerStatus] = useState(''); + const [tvQrScannerError, setTvQrScannerError] = useState(''); + const tvQrVideoRef = useRef(null); + const tvQrStreamRef = useRef(null); + const tvQrScanStopRef = useRef(false); + const [tvAccessTab, setTvAccessTab] = useState<'tvbox' | 'orion' | 'web'>('tvbox'); + // Body 滚动锁定 - 使用 overflow 方式避免布局问题 useEffect(() => { if ( @@ -107,7 +117,8 @@ export const UserMenu: React.FC = () => { isDeviceManagementOpen || isEcoAppsOpen || isReportOpen || - isDownloadManagementOpen + isDownloadManagementOpen || + isTvQrScannerOpen ) { const body = document.body; const html = document.documentElement; @@ -137,6 +148,7 @@ export const UserMenu: React.FC = () => { isEcoAppsOpen, isReportOpen, isDownloadManagementOpen, + isTvQrScannerOpen, ]); // 设置相关状态 @@ -1112,17 +1124,123 @@ export const UserMenu: React.FC = () => { setPasswordError(''); }; + const stopTvQrScanner = useCallback(() => { + tvQrScanStopRef.current = true; + if (tvQrStreamRef.current) { + tvQrStreamRef.current.getTracks().forEach((track) => track.stop()); + tvQrStreamRef.current = null; + } + if (tvQrVideoRef.current) { + tvQrVideoRef.current.srcObject = null; + } + }, []); + + const closeTvQrScanner = useCallback(() => { + stopTvQrScanner(); + setIsTvQrScannerOpen(false); + setTvQrScannerStatus(''); + setTvQrScannerError(''); + }, [stopTvQrScanner]); + + const handleQrLoginResult = useCallback((rawValue: string) => { + try { + const url = new URL(rawValue, window.location.origin); + if (url.origin !== window.location.origin || url.pathname !== '/qr-login') { + setTvQrScannerError('未识别到本站电视登录二维码,请扫描电视屏幕上的二维码。'); + return false; + } + + const token = url.searchParams.get('token'); + if (!token) { + setTvQrScannerError('二维码缺少登录凭证,请刷新电视端二维码后重试。'); + return false; + } + + setTvQrScannerStatus('识别成功,正在打开确认登录页...'); + stopTvQrScanner(); + window.location.href = `/qr-login?token=${encodeURIComponent(token)}`; + return true; + } catch { + setTvQrScannerError('二维码内容无效,请扫描电视端显示的登录二维码。'); + return false; + } + }, [stopTvQrScanner]); + + const startTvQrScanner = useCallback(async () => { + setIsTvQrScannerOpen(true); + setTvQrScannerError(''); + setTvQrScannerStatus('正在打开手机摄像头...'); + tvQrScanStopRef.current = false; + + if (!navigator.mediaDevices?.getUserMedia) { + setTvQrScannerError('当前浏览器不支持调用摄像头,请使用手机浏览器或系统相机扫描电视端二维码。'); + setTvQrScannerStatus(''); + return; + } + + const BarcodeDetectorCtor = (window as any).BarcodeDetector; + if (!BarcodeDetectorCtor) { + setTvQrScannerError('当前浏览器不支持网页内二维码识别,请使用系统相机扫描电视端二维码。'); + setTvQrScannerStatus(''); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' } }, + audio: false, + }); + tvQrStreamRef.current = stream; + + if (!tvQrVideoRef.current) return; + tvQrVideoRef.current.srcObject = stream; + await tvQrVideoRef.current.play(); + + const detector = new BarcodeDetectorCtor({ formats: ['qr_code'] }); + setTvQrScannerStatus('请将电视屏幕上的登录二维码放入取景框'); + + const scan = async () => { + if (tvQrScanStopRef.current || !tvQrVideoRef.current) return; + try { + const barcodes = await detector.detect(tvQrVideoRef.current); + const rawValue = barcodes?.[0]?.rawValue; + if (rawValue && handleQrLoginResult(rawValue)) return; + } catch (error) { + console.error('二维码识别失败:', error); + } + window.setTimeout(scan, 350); + }; + + scan(); + } catch (error) { + console.error('打开摄像头失败:', error); + setTvQrScannerError('无法打开摄像头,请检查浏览器相机权限后重试。'); + setTvQrScannerStatus(''); + stopTvQrScanner(); + } + }, [handleQrLoginResult, stopTvQrScanner]); + + useEffect(() => { + return () => stopTvQrScanner(); + }, [stopTvQrScanner]); + const handleSubscribe = async () => { setIsOpen(false); setIsSubscribeOpen(true); setCopySuccess(false); - // 懒加载:打开面板时才请求订阅URL - await fetchSubscribeUrl(); + setOrionBaseUrlCopySuccess(false); + // 懒加载: TVBox 订阅启用时才请求订阅 URL + if (subscribeEnabled) { + await fetchSubscribeUrl(); + } else { + setIsLoadingSubscribeUrl(false); + } }; const handleCloseSubscribe = () => { setIsSubscribeOpen(false); setCopySuccess(false); + setOrionBaseUrlCopySuccess(false); }; const handleCopySubscribeUrl = async () => { @@ -1137,6 +1255,18 @@ export const UserMenu: React.FC = () => { } }; + const handleCopyOrionBaseUrl = async () => { + try { + await navigator.clipboard.writeText(window.location.origin); + setOrionBaseUrlCopySuccess(true); + setTimeout(() => { + setOrionBaseUrlCopySuccess(false); + }, 2000); + } catch (error) { + console.error('复制OrionTV Base URL失败:', error); + } + }; + useEffect(() => { if (!tvboxToken || !isSubscribeOpen) return; setSubscribeUrl( @@ -1902,16 +2032,14 @@ export const UserMenu: React.FC = () => { )} - {/* 订阅按钮 */} - {subscribeEnabled && ( - - )} + {/* 电视访问按钮 */} + {/* 生态应用按钮 */} + +
+ {tvQrScannerStatus && ( +

+ {tvQrScannerStatus} +

+ )} + {tvQrScannerError && ( +

+ {tvQrScannerError} +

+ )} +
+ + ) : ( + <> {/* 标题栏 */} -
-

- TVBox订阅 -

+
+
+
+ + TV ACCESS +
+

+ 电视访问 +

+
- {/* 内容 */} -
- {isLoadingSubscribeUrl ? ( - <> - {/* 加载骨架 - 开关 */} -
-
-
-
-
+
+ {[ + { key: 'tvbox' as const, label: 'TVBox 订阅', icon: Rss }, + { key: 'orion' as const, label: 'OrionTV', icon: Download }, + { key: 'web' as const, label: 'Web 电视', icon: Monitor }, + ].map((item) => { + const Icon = item.icon; + const active = tvAccessTab === item.key; + return ( + + ); + })} +
+ + {tvAccessTab === 'tvbox' && ( +
+
+
+
+ +
+
+

+ TVBox 订阅 +

+

+ 复制订阅链接到 TVBox 使用 +

+ + {subscribeEnabled ? '已启用' : '未启用'} + +
- {/* 加载骨架 - 订阅链接 */} -
-
-
-
-
-
-
+ {!subscribeEnabled ? ( +
+ TVBox 订阅功能未启用
- - {/* 加载骨架 - 重置按钮 */} -
-
-
+ ) : isLoadingSubscribeUrl ? ( +
+
+
+
- - ) : ( - <> -
-

- 订阅选项 -

- - - - -
- -
-

- 订阅链接 -

-
- + ) : ( +
+
+
- {(subscribeAdFilterEnabled || - subscribeYellowFilterEnabled) && ( -

- 💡 代理模式已开启,某些源可能因为区域或兼容问题无法播放 + +

+

+ 订阅链接 +

+
+ + +
+ {(subscribeAdFilterEnabled || subscribeYellowFilterEnabled) && ( +

+ 💡 代理模式已开启,某些源可能因为区域或兼容问题无法播放 +

+ )} +
+ +
+ +

+ ⚠️ 重置后旧链接将失效

- )} +

+
+ )} +
+ )} - {/* 重置Token按钮 */} -
- -

- ⚠️ 重置后旧链接将失效 + {tvAccessTab === 'orion' && ( +

+
+ OrionTV +
+

+ OrionTV +

+

+ Android TV 专用客户端

- {/* 消息提示 */} -

- - )} -
+
+

+ 可直接作为 MoonTV Plus 电视端使用,适合安装到 Android TV / 电视盒子。 +

+
+
+ Base URL +
+
+ + +
+

+ 在 OrionTV 中填写该地址作为后端服务地址。 +

+
+ + 下载 OrionTV + + + + )} - {/* 底部说明 */} -
-

- 将订阅链接复制到TVBox应用中使用 -

-
+ {tvAccessTab === 'web' && ( +
+
+
+ +
+
+

+ Web 电视 +

+

+ 手机扫描电视屏幕二维码并确认登录 +

+
+
+

+ 电视端打开 /tv 后会显示二维码;手机在这里打开相机扫描并确认登录。 +

+
+ + +
+ +
+ )} + + )}
diff --git a/src/middleware.ts b/src/middleware.ts index 3e8ea5a..fe1d186 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -146,8 +146,10 @@ function handleAuthFailure( return new NextResponse('Unauthorized', { status: 401 }); } - // 否则重定向到登录页面 - const loginUrl = new URL('/login', request.url); + // TV 端页面未授权时进入电视扫码登录页 + const loginUrl = pathname.startsWith('/tv') + ? new URL('/tv/login', request.url) + : new URL('/login', request.url); // 保留完整的URL,包括查询参数 const fullUrl = `${pathname}${request.nextUrl.search}`; loginUrl.searchParams.set('redirect', fullUrl); @@ -172,6 +174,6 @@ function shouldSkipAuth(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/auth/refresh|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources|tvbox/).*)', + '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|qr-login|warning|tv/login|api/login|api/register|api/logout|api/auth/oidc|api/auth/qr|api/auth/refresh|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources|tvbox/).*)', ], };