diff --git a/app/api/iptv/route.ts b/app/api/iptv/route.ts index 7f2516a..fa12ade 100644 --- a/app/api/iptv/route.ts +++ b/app/api/iptv/route.ts @@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; -export const runtime = 'edge'; +export const runtime = 'nodejs'; export async function GET(request: NextRequest) { const url = request.nextUrl.searchParams.get('url'); diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts new file mode 100644 index 0000000..f0bb548 --- /dev/null +++ b/app/api/iptv/stream/route.ts @@ -0,0 +1,125 @@ +/** + * IPTV Stream Proxy API Route + * Proxies HLS manifests and media segments to avoid CORS issues. + * For .m3u8 manifests, rewrites URLs to also route through this proxy. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +function resolveUrl(base: string, relative: string): string { + if (relative.startsWith('http://') || relative.startsWith('https://')) { + return relative; + } + try { + return new URL(relative, base).href; + } catch { + // Fallback: manual resolution + const baseUrl = base.substring(0, base.lastIndexOf('/') + 1); + return baseUrl + relative; + } +} + +function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): string { + return content.split('\n').map(line => { + const trimmed = line.trim(); + // Skip empty lines and comments (but process URI= in EXT tags) + if (!trimmed) return line; + + // Rewrite URI="..." in EXT-X-KEY, EXT-X-MAP, etc. + if (trimmed.startsWith('#') && trimmed.includes('URI="')) { + return line.replace(/URI="([^"]+)"/g, (_match, uri) => { + const absoluteUri = resolveUrl(baseUrl, uri); + return `URI="${proxyBase}${encodeURIComponent(absoluteUri)}"`; + }); + } + + // Skip other comment lines + if (trimmed.startsWith('#')) return line; + + // This is a segment/playlist URL line - rewrite it + const absoluteUrl = resolveUrl(baseUrl, trimmed); + return `${proxyBase}${encodeURIComponent(absoluteUrl)}`; + }).join('\n'); +} + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); + } + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)', + 'Accept': '*/*', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Failed to fetch: ${response.status}` }, + { status: response.status } + ); + } + + const contentType = response.headers.get('content-type') || ''; + const isM3u8 = url.includes('.m3u8') || + contentType.includes('mpegurl') || + contentType.includes('x-mpegURL'); + + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }; + + if (isM3u8) { + // Parse and rewrite manifest + const text = await response.text(); + const proxyBase = `/api/iptv/stream?url=`; + const rewritten = rewriteM3u8(text, url, proxyBase); + + return new NextResponse(rewritten, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-cache', + ...corsHeaders, + }, + }); + } else { + // Pipe through media segments directly + const body = response.body; + const forwardContentType = contentType || 'video/mp2t'; + + return new NextResponse(body, { + status: 200, + headers: { + 'Content-Type': forwardContentType, + 'Cache-Control': 'public, max-age=60', + ...corsHeaders, + }, + }); + } + } catch (e) { + return NextResponse.json( + { error: 'Failed to proxy stream' }, + { status: 500 } + ); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }, + }); +} diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 3bb23a7..938fe53 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -2,7 +2,8 @@ /** * IPTVPlayer - Lightweight player for IPTV live streams - * Uses HLS.js for playback with a channel switching sidebar + * Uses HLS.js for playback with a channel switching sidebar. + * Routes streams through /api/iptv/stream proxy to avoid CORS issues. */ import { useRef, useEffect, useState, useCallback } from 'react'; @@ -17,6 +18,10 @@ interface IPTVPlayerProps { onChannelChange: (channel: M3UChannel) => void; } +function getProxiedUrl(url: string): string { + return `/api/iptv/stream?url=${encodeURIComponent(url)}`; +} + export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) { const videoRef = useRef(null); const hlsRef = useRef(null); @@ -37,62 +42,117 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV hlsRef.current = null; } - const url = ch.url; + const originalUrl = ch.url; + const proxiedUrl = getProxiedUrl(originalUrl); - if (url.endsWith('.m3u8') || url.includes('.m3u8')) { - if (Hls.isSupported()) { - const hls = new Hls({ + // Try HLS.js first for all URLs (many IPTV streams are HLS even without .m3u8 extension) + if (Hls.isSupported()) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: true, + liveDurationInfinity: true, + }); + hlsRef.current = hls; + + let triedProxy = false; + + const tryWithProxy = () => { + if (triedProxy) return; + triedProxy = true; + // Retry with proxied URL + hls.destroy(); + const hlsProxy = new Hls({ enableWorker: true, lowLatencyMode: true, liveDurationInfinity: true, }); - hlsRef.current = hls; + hlsRef.current = hlsProxy; - hls.loadSource(url); - hls.attachMedia(video); + hlsProxy.loadSource(proxiedUrl); + hlsProxy.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => { + hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => { setIsLoading(false); video.play().catch(() => {}); }); - hls.on(Hls.Events.ERROR, (_, data) => { + hlsProxy.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { setIsLoading(false); - if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - setError('网络错误,无法加载频道'); - } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { - hls.recoverMediaError(); + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hlsProxy.recoverMediaError(); } else { - setError('播放错误,请尝试其他频道'); + // Last resort: try direct video element + hlsProxy.destroy(); + hlsRef.current = null; + tryDirectVideo(proxiedUrl); } } }); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - // Native HLS (Safari/iOS) - video.src = url; - video.addEventListener('loadedmetadata', () => { + }; + + // First try direct URL + hls.loadSource(originalUrl); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setIsLoading(false); + video.play().catch(() => {}); + }); + + hls.on(Hls.Events.ERROR, (_, data) => { + if (data.fatal) { + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + // Likely CORS - try proxy + tryWithProxy(); + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError(); + } else { + tryWithProxy(); + } + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + // Native HLS (Safari/iOS) - try direct first, fall back to proxy + tryNativeHls(video, originalUrl, proxiedUrl); + } else { + tryDirectVideo(originalUrl); + } + + function tryNativeHls(vid: HTMLVideoElement, url: string, fallbackUrl: string) { + vid.src = url; + const onLoad = () => { + setIsLoading(false); + vid.play().catch(() => {}); + }; + const onError = () => { + vid.removeEventListener('loadedmetadata', onLoad); + // Try proxied URL + vid.src = fallbackUrl; + vid.addEventListener('loadedmetadata', () => { setIsLoading(false); - video.play().catch(() => {}); + vid.play().catch(() => {}); }, { once: true }); - video.addEventListener('error', () => { + vid.addEventListener('error', () => { setIsLoading(false); setError('播放错误'); }, { once: true }); - } else { - setError('您的浏览器不支持 HLS 播放'); + }; + vid.addEventListener('loadedmetadata', onLoad, { once: true }); + vid.addEventListener('error', onError, { once: true }); + } + + function tryDirectVideo(url: string) { + const vid = videoRef.current; + if (!vid) return; + vid.src = url; + vid.addEventListener('loadedmetadata', () => { setIsLoading(false); - } - } else { - // Direct video URL (mp4, etc.) - video.src = url; - video.addEventListener('loadedmetadata', () => { - setIsLoading(false); - video.play().catch(() => {}); + vid.play().catch(() => {}); }, { once: true }); - video.addEventListener('error', () => { + vid.addEventListener('error', () => { setIsLoading(false); - setError('播放错误'); + setError('播放错误,请尝试其他频道'); }, { once: true }); } }, []); diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index 2ca8e87..445c63c 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -93,9 +93,9 @@ export function DesktopMoreMenu({ const calculateMenuPosition = React.useCallback(() => { if (!buttonRef.current || !containerRef.current) return; - if (!isRotated) { - // Normal Mode: Non-rotated (Portrait on Mobile) - // Use Viewport Coordinates but position relative to button (User Request: "Below button") + if (!isRotated && !isFullscreen) { + // Normal Mode: Non-rotated, non-fullscreen + // Use Viewport Coordinates but position relative to button // And use Body Portal to escape container clipping const buttonRect = buttonRef.current.getBoundingClientRect(); const viewportHeight = window.innerHeight; @@ -136,6 +136,47 @@ export function DesktopMoreMenu({ openUpward: openUpward, align: align }); + } else if (isFullscreen && !isRotated) { + // Fullscreen Mode (not rotated): Use container-relative coordinates + // Portal goes to containerRef to stay visible within fullscreen element + let top = 0; + let left = 0; + let el: HTMLElement | null = buttonRef.current; + + while (el && el !== containerRef.current) { + top += el.offsetTop; + left += el.offsetLeft; + el = el.offsetParent as HTMLElement; + } + + const buttonHeight = buttonRef.current.offsetHeight; + const buttonWidth = buttonRef.current.offsetWidth; + const containerWidth = containerRef.current.offsetWidth; + const containerHeight = containerRef.current.offsetHeight; + + const spaceBelow = containerHeight - (top + buttonHeight) - 10; + const spaceAbove = top - 10; + + const estimatedMenuHeight = 450; + const actualMenuHeight = menuRef.current?.offsetHeight || estimatedMenuHeight; + + const openUpward = spaceBelow < Math.min(actualMenuHeight, 300) && spaceAbove > spaceBelow; + const maxHeight = openUpward + ? Math.min(spaceAbove, actualMenuHeight) + : Math.min(spaceBelow, containerHeight * 0.7); + + const isLeftHalf = left < containerWidth / 2; + const align = isLeftHalf ? 'left' : 'right'; + + setMenuPosition({ + top: openUpward + ? top - 10 + : top + buttonHeight + 10, + left: isLeftHalf ? left : left + buttonWidth, + maxHeight: `${maxHeight}px`, + openUpward: openUpward, + align: align + }); } else { // Rotated Mode: Use Container Coordinates (offset loop) and Portal to Container let top = 0; @@ -185,7 +226,7 @@ export function DesktopMoreMenu({ align: align }); } - }, [containerRef, isRotated]); + }, [containerRef, isRotated, isFullscreen]); @@ -583,7 +624,7 @@ export function DesktopMoreMenu({ {/* More Menu Dropdown (Portal) */} {/* More Menu Dropdown (Portal) */} - {showMoreMenu && typeof document !== 'undefined' && createPortal(MenuContent, (isRotated && containerRef.current) ? containerRef.current : document.body)} + {showMoreMenu && typeof document !== 'undefined' && createPortal(MenuContent, ((isRotated || isFullscreen) && containerRef.current) ? containerRef.current : document.body)} ); } diff --git a/components/player/desktop/DesktopSpeedMenu.tsx b/components/player/desktop/DesktopSpeedMenu.tsx index 29e1e45..ee155f0 100644 --- a/components/player/desktop/DesktopSpeedMenu.tsx +++ b/components/player/desktop/DesktopSpeedMenu.tsx @@ -51,9 +51,9 @@ export function DesktopSpeedMenu({ const calculateMenuPosition = React.useCallback(() => { if (!buttonRef.current || !containerRef.current) return; - if (!isRotated) { - // Normal Mode: Non-rotated - // Use Viewport Coordinates but position relative to button (User Request: "Below button") + if (!isRotated && !isFullscreen) { + // Normal Mode: Non-rotated, non-fullscreen + // Use Viewport Coordinates but position relative to button // And use Body Portal to escape container clipping const buttonRect = buttonRef.current.getBoundingClientRect(); const viewportHeight = window.innerHeight; @@ -94,6 +94,46 @@ export function DesktopSpeedMenu({ openUpward: openUpward, align: align }); + } else if (isFullscreen && !isRotated) { + // Fullscreen Mode (not rotated): Use container-relative coordinates + let top = 0; + let left = 0; + let el: HTMLElement | null = buttonRef.current; + + while (el && el !== containerRef.current) { + top += el.offsetTop; + left += el.offsetLeft; + el = el.offsetParent as HTMLElement; + } + + const buttonHeight = buttonRef.current.offsetHeight; + const buttonWidth = buttonRef.current.offsetWidth; + const containerWidth = containerRef.current.offsetWidth; + const containerHeight = containerRef.current.offsetHeight; + + const spaceBelow = containerHeight - (top + buttonHeight) - 10; + const spaceAbove = top - 10; + + const estimatedMenuHeight = 250; + const actualMenuHeight = menuRef.current?.offsetHeight || estimatedMenuHeight; + + const openUpward = spaceBelow < Math.min(actualMenuHeight, 200) && spaceAbove > spaceBelow; + const maxHeight = openUpward + ? Math.min(spaceAbove, actualMenuHeight) + : Math.min(spaceBelow, containerHeight * 0.7); + + const isLeftHalf = left < containerWidth / 2; + const align = isLeftHalf ? 'left' : 'right'; + + setMenuPosition({ + top: openUpward + ? top - 10 + : top + buttonHeight + 10, + left: isLeftHalf ? left : left + buttonWidth, + maxHeight: `${maxHeight}px`, + openUpward: openUpward, + align: align + }); } else { // Rotated Mode: Fullscreen/Landscape forced // Use Container Coordinates (offset loop) and Portal to Container @@ -146,7 +186,7 @@ export function DesktopSpeedMenu({ align: align }); } - }, [containerRef, isRotated]); + }, [containerRef, isRotated, isFullscreen]); @@ -255,7 +295,7 @@ export function DesktopSpeedMenu({ So portaling to containerRef is SAFE and CORRECT. */} {/* Speed Menu (Portal) */} - {showSpeedMenu && typeof document !== 'undefined' && createPortal(MenuContent, (isRotated && containerRef.current) ? containerRef.current : document.body)} + {showSpeedMenu && typeof document !== 'undefined' && createPortal(MenuContent, ((isRotated || isFullscreen) && containerRef.current) ? containerRef.current : document.body)} ); } diff --git a/components/settings/AccountSettings.tsx b/components/settings/AccountSettings.tsx index ba8e105..4595f8f 100644 --- a/components/settings/AccountSettings.tsx +++ b/components/settings/AccountSettings.tsx @@ -24,6 +24,8 @@ export function AccountSettings() { const [showConfigGen, setShowConfigGen] = useState(false); const [configEntries, setConfigEntries] = useState([]); const [copied, setCopied] = useState(false); + const [removedAccounts, setRemovedAccounts] = useState>(new Set()); + const [hasAdminPassword, setHasAdminPassword] = useState(false); useEffect(() => { setSessionState(getSession()); @@ -38,6 +40,7 @@ export function AccountSettings() { .then(res => res.json()) .then(data => { if (data.accounts) setAccounts(data.accounts); + if (data.hasAdminPassword) setHasAdminPassword(data.hasAdminPassword); }) .catch(() => {}); }, []); @@ -79,6 +82,33 @@ export function AccountSettings() { }); }; + // Load existing accounts into config generator (without passwords) + const loadExistingAccounts = () => { + // Filter out removed accounts and the standalone admin password account + const existingEntries: ConfigEntry[] = accounts + .filter((_, i) => !removedAccounts.has(i)) + .filter(a => !(a.name === '管理员' && hasAdminPassword)) + .map(a => ({ + password: '', + name: a.name, + role: a.role, + })); + setConfigEntries(existingEntries); + setShowConfigGen(true); + }; + + // Remove account from visible list and track removal + const handleRemoveAccount = (index: number) => { + setRemovedAccounts(prev => { + const next = new Set(prev); + next.add(index); + return next; + }); + }; + + // Get visible accounts (excluding removed ones) + const visibleAccounts = accounts.filter((_, i) => !removedAccounts.has(i)); + if (!hasAuth && !session) return null; return ( @@ -115,34 +145,69 @@ export function AccountSettings() { )} {/* Account List (Admin only) */} - {isAdmin && accounts.length > 0 && ( + {isAdmin && visibleAccounts.length > 0 && (

已配置的账户

- {accounts.map((account, index) => ( -
-
-
- {account.name.charAt(0)} + {accounts.map((account, index) => { + if (removedAccounts.has(index)) return null; + return ( +
+
+
+ {account.name.charAt(0)} +
+ {account.name} +
+
+ + {account.role === 'admin' ? '管理员' : '观众'} + +
- {account.name}
- - {account.role === 'admin' ? '管理员' : '观众'} - -
- ))} + ); + })}
+ + {/* Notice when accounts have been removed */} + {removedAccounts.size > 0 && ( +
+

+ 已标记移除 {removedAccounts.size} 个账户。请使用下方配置生成器生成新的 ACCOUNTS 环境变量值并更新部署配置。 +

+
+ + +
+
+ )}
)} @@ -154,18 +219,33 @@ export function AccountSettings() { 配置生成器 - +
+ {!showConfigGen && accounts.length > 0 && ( + + )} + +
{showConfigGen && (

添加账户条目后,将生成的 ACCOUNTS 环境变量值复制到部署配置中。 + {configEntries.some(e => !e.password && e.name) && ( + + 注意:导入的账户需要重新输入密码。 + + )}

{/* Entry List */} @@ -178,7 +258,9 @@ export function AccountSettings() { placeholder="密码" value={entry.password} onChange={(e) => updateConfigEntry(index, 'password', e.target.value)} - className="flex-1 px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" + className={`flex-1 px-3 py-1.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] ${ + !entry.password && entry.name ? 'border-amber-500/50' : 'border-[var(--glass-border)]' + }`} />