diff --git a/src/app/api/proxy/logo/route.ts b/src/app/api/proxy/logo/route.ts index 644b041..dda5df7 100644 --- a/src/app/api/proxy/logo/route.ts +++ b/src/app/api/proxy/logo/route.ts @@ -9,7 +9,7 @@ export const runtime = 'nodejs'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); - const source = searchParams.get('moontv-source'); + const source = searchParams.get('moontv-source') || searchParams.get('source'); if (!imageUrl) { return NextResponse.json({ error: 'Missing image URL' }, { status: 400 }); diff --git a/src/app/tv/live/page.tsx b/src/app/tv/live/page.tsx index e41629e..c1514c4 100644 --- a/src/app/tv/live/page.tsx +++ b/src/app/tv/live/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { AlertTriangle, Loader2, Radio, Search } from 'lucide-react'; +import { AlertTriangle, Loader2, Play, Radio, Search } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; @@ -10,6 +10,16 @@ import TVLayout from '@/components/tv/TVLayout'; type LiveSource = { key: string; name: string }; type LiveChannel = { id: string; name: string; group?: string; logo?: string }; +type LastLiveChannel = { source: string; sourceName?: string; id: string; title: string; group?: string; logo?: string; updatedAt?: number }; + +const TV_LIVE_LAST_CHANNEL_KEY = 'tv_live_last_channel'; + +function getLogoUrl(logo?: string, source?: string) { + if (!logo) return ''; + if (logo.startsWith('/api/proxy/logo')) return logo; + const sourceParam = source ? `&source=${encodeURIComponent(source)}` : ''; + return `/api/proxy/logo?url=${encodeURIComponent(logo)}${sourceParam}`; +} export default function TVLivePage() { const router = useRouter(); @@ -22,8 +32,29 @@ export default function TVLivePage() { const [query, setQuery] = useState(''); const [visibleCount, setVisibleCount] = useState(120); const [quickChannels, setQuickChannels] = useState>([]); + const [lastChannel, setLastChannel] = useState(null); useEffect(() => { + try { + const saved = localStorage.getItem(TV_LIVE_LAST_CHANNEL_KEY); + if (saved) { + const parsed = JSON.parse(saved) as Partial; + if (parsed.source && parsed.id && parsed.title) { + setLastChannel({ + source: parsed.source, + sourceName: parsed.sourceName || '', + id: parsed.id, + title: parsed.title, + group: parsed.group || '', + logo: parsed.logo || '', + updatedAt: parsed.updatedAt, + }); + } + } + } catch { + setLastChannel(null); + } + fetch('/api/live/sources') .then((r) => { if (!r.ok) throw new Error('获取直播源失败'); @@ -108,6 +139,28 @@ export default function TVLivePage() { return ( + {lastChannel && ( +
+ +
+ )} +
@@ -163,7 +216,7 @@ export default function TVLivePage() {
{visibleChannels.map((channel, index) => ( ))} diff --git a/src/app/tv/live/play/page.tsx b/src/app/tv/live/play/page.tsx index 7380cb9..1bb7691 100644 --- a/src/app/tv/live/play/page.tsx +++ b/src/app/tv/live/play/page.tsx @@ -14,10 +14,13 @@ type LiveChannel = { id: string; tvgId?: string; name: string; logo?: string; gr type EpgProgram = { start: string; end: string; title: string }; type TVPlayerSourceType = 'm3u8' | 'flv' | 'native'; +const TV_LIVE_LAST_CHANNEL_KEY = 'tv_live_last_channel'; +const REMOTE_KEY_DEDUPE_MS = 350; + function getLogoUrl(logo?: string, source?: string) { if (!logo) return ''; - if (!source) return logo; - return `/api/proxy/logo?url=${encodeURIComponent(logo)}&source=${encodeURIComponent(source)}`; + const sourceParam = source ? `&source=${encodeURIComponent(source)}` : ''; + return `/api/proxy/logo?url=${encodeURIComponent(logo)}${sourceParam}`; } function getUrlSourceType(rawUrl: string): TVPlayerSourceType | 'unknown' { @@ -38,7 +41,7 @@ async function resolveLiveUrl(rawUrl: string, source?: LiveSource | null): Promi type: 'm3u8', url: proxyMode === 'direct' ? rawUrl - : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}`, + : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}${proxyMode === 'm3u8-only' ? '&allowCORS=true' : ''}`, }; } if (sourceType === 'flv') return { type: 'flv', url: rawUrl }; @@ -60,7 +63,7 @@ async function resolveLiveUrl(rawUrl: string, source?: LiveSource | null): Promi type: 'm3u8', url: proxyMode === 'direct' ? rawUrl - : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source.key)}`, + : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source.key)}${proxyMode === 'm3u8-only' ? '&allowCORS=true' : ''}`, }; } @@ -82,7 +85,7 @@ function TVLivePlayClient() { const [unsupportedError, setUnsupportedError] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [showPanel, setShowPanel] = useState(true); + const [showPanel, setShowPanel] = useState(false); const [selectedGroup, setSelectedGroup] = useState(''); const [query, setQuery] = useState(''); const [digitBuffer, setDigitBuffer] = useState(''); @@ -93,8 +96,14 @@ function TVLivePlayClient() { const [epgLoading, setEpgLoading] = useState(false); const [muted, setMuted] = useState(false); const [volume, setVolume] = useState(1); + const [showVolumeHint, setShowVolumeHint] = useState(false); + const [channelHint, setChannelHint] = useState<{ number: number; name: string } | null>(null); const channelButtonRefs = useRef>({}); const digitTimerRef = useRef(null); + const volumeHintTimerRef = useRef(null); + const channelHintTimerRef = useRef(null); + const menuKeyTimeRef = useRef(0); + const backKeyTimeRef = useRef(0); useEffect(() => { let alive = true; @@ -194,6 +203,26 @@ function TVLivePlayClient() { return () => { alive = false; }; }, [channel?.tvgId, source]); + useEffect(() => { + if (!source || !channel) return; + try { + localStorage.setItem( + TV_LIVE_LAST_CHANNEL_KEY, + JSON.stringify({ + source: source.key, + sourceName: source.name, + id: channel.id, + title: channel.name, + group: channel.group || '', + logo: channel.logo || '', + updatedAt: Date.now(), + }) + ); + } catch { + // ignore storage failures + } + }, [channel, source]); + useEffect(() => { if (!playbackError || !channel || retryCount >= 3) return; const timer = window.setTimeout(() => { @@ -231,12 +260,15 @@ function TVLivePlayClient() { } }; - const switchChannel = (next: LiveChannel) => { + const switchChannel = (next: LiveChannel, revealControls = true) => { precheckChannel(next); setChannel(next); setSelectedGroup(next.group || '其他'); - setShowPanel(true); - if (source) router.replace(`/tv/live/play?source=${encodeURIComponent(source.key)}&id=${encodeURIComponent(next.id)}`); + if (revealControls) setShowPanel(true); + const number = channels.findIndex((item) => item.id === next.id) + 1; + setChannelHint({ number: number > 0 ? number : 1, name: next.name }); + if (channelHintTimerRef.current) window.clearTimeout(channelHintTimerRef.current); + channelHintTimerRef.current = window.setTimeout(() => setChannelHint(null), 5000); }; const switchSource = (next: LiveSource) => { @@ -278,6 +310,9 @@ function TVLivePlayClient() { } setVolume(safe); setMuted(safe <= 0); + setShowVolumeHint(true); + if (volumeHintTimerRef.current) window.clearTimeout(volumeHintTimerRef.current); + volumeHintTimerRef.current = window.setTimeout(() => setShowVolumeHint(false), 1200); }; const toggleMute = () => { @@ -305,7 +340,31 @@ function TVLivePlayClient() { }, [muted, videoUrl, volume]); useEffect(() => { + const togglePanelByRemoteKey = (event: { preventDefault: () => void }) => { + const now = Date.now(); + if (now - menuKeyTimeRef.current < REMOTE_KEY_DEDUPE_MS) return; + menuKeyTimeRef.current = now; + event.preventDefault(); + setShowPanel((value) => !value); + }; + const onKey = (event: KeyboardEvent) => { + const isMenuKey = + event.key === 'ContextMenu' || + event.key === 'Menu' || + event.key === 'BrowserContextMenu' || + event.code === 'ContextMenu' || + event.code === 'Menu' || + event.keyCode === 93 || + event.keyCode === 82; + if (isMenuKey) { + event.stopImmediatePropagation(); + togglePanelByRemoteKey(event); + return; + } + + if (event.type === 'keyup') return; + if (/^[0-9]$/.test(event.key) && channels.length) { event.preventDefault(); const nextBuffer = `${digitBuffer}${event.key}`.slice(-4); @@ -321,15 +380,47 @@ function TVLivePlayClient() { if (event.key === 'Enter') { const active = document.activeElement; const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-live-control]')); + if (!showPanel) { + event.preventDefault(); + event.stopImmediatePropagation(); + setShowPanel(true); + return; + } if (!isControlFocused) { event.preventDefault(); + event.stopImmediatePropagation(); setShowPanel((v) => !v); + return; } } + if (!showPanel && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { + event.preventDefault(); + event.stopImmediatePropagation(); + const currentIndex = channels.findIndex((item) => item.id === channel?.id); + if (currentIndex >= 0) { + const nextIndex = event.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1; + const next = channels[Math.max(0, Math.min(channels.length - 1, nextIndex))]; + if (next && next.id !== channel?.id) switchChannel(next, false); + } + return; + } + if (!showPanel && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + event.preventDefault(); + event.stopImmediatePropagation(); + setVideoVolume(volume + (event.key === 'ArrowRight' ? 0.05 : -0.05)); + return; + } if (event.key === 'Escape') { event.preventDefault(); - if (showPanel) setShowPanel(false); - else router.back(); + event.stopImmediatePropagation(); + const now = Date.now(); + if (showPanel) { + backKeyTimeRef.current = now; + setShowPanel(false); + } else if (now - backKeyTimeRef.current >= 250) { + router.back(); + } + return; } if (event.key === 'PageUp' || event.key === 'PageDown') { const currentIndex = channels.findIndex((item) => item.id === channel?.id); @@ -340,9 +431,13 @@ function TVLivePlayClient() { } } }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [channel?.id, channels, digitBuffer, router, showPanel, source]); + window.addEventListener('keydown', onKey, true); + window.addEventListener('keyup', onKey, true); + return () => { + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('keyup', onKey, true); + }; + }, [channel?.id, channels, digitBuffer, router, showPanel, source, volume]); useEffect(() => { if (!showPanel || !channel?.id) return; @@ -372,7 +467,18 @@ function TVLivePlayClient() { } return ( -
setShowPanel(true)}> +
{ + const now = Date.now(); + if (now - menuKeyTimeRef.current < REMOTE_KEY_DEDUPE_MS) return; + menuKeyTimeRef.current = now; + event.preventDefault(); + setShowPanel((value) => !value); + }} + > {unsupportedError ? (
@@ -404,7 +510,7 @@ function TVLivePlayClient() {
- +
{channel.logo ? : }
{channel.name}
{source?.name} · {channel.group}
@@ -419,21 +525,21 @@ function TVLivePlayClient() { {showPanel && ( )} {digitBuffer &&
频道 {digitBuffer}
} + {channelHint && ( +
+
#{channelHint.number}
+
{channelHint.name}
+
+ )} + {showVolumeHint && !showPanel && ( +
+ {muted || volume <= 0 ? : } +
+
+
+
{Math.round((muted ? 0 : volume) * 100)}
+
+ )}
); diff --git a/src/components/tv/TVVirtualRemote.tsx b/src/components/tv/TVVirtualRemote.tsx index 6033cb0..892b63b 100644 --- a/src/components/tv/TVVirtualRemote.tsx +++ b/src/components/tv/TVVirtualRemote.tsx @@ -44,6 +44,53 @@ function getFocusableElements() { .filter(isVisible); } +function getScrollableParent(element: HTMLElement) { + let current = element.parentElement; + while (current && current !== document.body) { + const style = window.getComputedStyle(current); + const canScrollY = /(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight; + const canScrollX = /(auto|scroll)/.test(style.overflowX) && current.scrollWidth > current.clientWidth; + if (canScrollY || canScrollX) return current; + current = current.parentElement; + } + return null; +} + +function scrollIntoScrollableParent(element: HTMLElement) { + const parent = getScrollableParent(element); + if (!parent) return false; + + const elementRect = element.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + const padding = 16; + + if (elementRect.top < parentRect.top + padding) { + parent.scrollBy({ + top: elementRect.top - parentRect.top - padding, + behavior: 'smooth', + }); + } else if (elementRect.bottom > parentRect.bottom - padding) { + parent.scrollBy({ + top: elementRect.bottom - parentRect.bottom + padding, + behavior: 'smooth', + }); + } + + if (elementRect.left < parentRect.left + padding) { + parent.scrollBy({ + left: elementRect.left - parentRect.left - padding, + behavior: 'smooth', + }); + } else if (elementRect.right > parentRect.right - padding) { + parent.scrollBy({ + left: elementRect.right - parentRect.right + padding, + behavior: 'smooth', + }); + } + + return true; +} + function focusElement(element: HTMLElement) { element.focus({ preventScroll: true }); @@ -67,6 +114,8 @@ function focusElement(element: HTMLElement) { window.scrollBy({ top: rect.bottom - safeBottom, behavior: 'smooth' }); } }); + } else { + scrollIntoScrollableParent(element); } }