/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ 'use client'; import { Heart, Radio, Tv } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { deleteFavorite, generateStorageKey, isFavorited as checkIsFavorited, saveFavorite, savePlayRecord, subscribeToDataUpdates, } from '@/lib/db.client'; import { parseCustomTimeFormat } from '@/lib/time'; import { useLiveSync } from '@/hooks/useLiveSync'; import EpgScrollableRow from '@/components/EpgScrollableRow'; import PageLayout from '@/components/PageLayout'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { interface HTMLVideoElement { hls?: any; } } // 动态导入浏览器专用库 let Artplayer: any = null; let Hls: any = null; // 直播频道接口 interface LiveChannel { id: string; tvgId: string; name: string; logo: string; group: string; url: string; } // 直播源接口 interface LiveSource { key: string; name: string; url: string; // m3u 地址 ua?: string; epg?: string; // 节目单 from: 'config' | 'custom'; channelNumber?: number; disabled?: boolean; } function LivePageClient() { // 动态加载浏览器专用库 useEffect(() => { if (typeof window !== 'undefined') { import('artplayer').then(mod => { Artplayer = mod.default; }); import('hls.js').then(mod => { Hls = mod.default; }); } }, []); // ----------------------------------------------------------------------------- // 状态变量(State) // ----------------------------------------------------------------------------- const [loading, setLoading] = useState(true); const [loadingStage, setLoadingStage] = useState< 'loading' | 'fetching' | 'ready' >('loading'); const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...'); const [error, setError] = useState(null); const searchParams = useSearchParams(); const router = useRouter(); // 直播源相关 const [liveSources, setLiveSources] = useState([]); const [currentSource, setCurrentSource] = useState(null); const currentSourceRef = useRef(null); useEffect(() => { currentSourceRef.current = currentSource; }, [currentSource]); // 频道相关 const [currentChannels, setCurrentChannels] = useState([]); const [currentChannel, setCurrentChannel] = useState(null); useEffect(() => { currentChannelRef.current = currentChannel; }, [currentChannel]); const [needLoadSource] = useState(searchParams.get('source')); const [needLoadChannel] = useState(searchParams.get('id')); // 播放器相关 const [videoUrl, setVideoUrl] = useState(''); const [isVideoLoading, setIsVideoLoading] = useState(false); const [unsupportedType, setUnsupportedType] = useState(null); // 切换直播源状态 const [isSwitchingSource, setIsSwitchingSource] = useState(false); // 分组相关 const [groupedChannels, setGroupedChannels] = useState<{ [key: string]: LiveChannel[] }>({}); const [selectedGroup, setSelectedGroup] = useState(''); // Tab 切换 const [activeTab, setActiveTab] = useState<'channels' | 'sources'>('channels'); // 频道列表收起状态 const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false); // 过滤后的频道列表 const [filteredChannels, setFilteredChannels] = useState([]); // 搜索关键词 const [searchKeyword, setSearchKeyword] = useState(''); // 节目单信息 const [epgData, setEpgData] = useState<{ tvgId: string; source: string; epgUrl: string; programs: Array<{ start: string; end: string; title: string; }>; } | null>(null); // EPG 数据加载状态 const [isEpgLoading, setIsEpgLoading] = useState(false); // 收藏状态 const [favorited, setFavorited] = useState(false); const favoritedRef = useRef(false); const currentChannelRef = useRef(null); // 观影室同步功能 const liveSync = useLiveSync({ currentChannelId: currentChannel?.id || '', currentChannelName: currentChannel?.name || '', currentChannelUrl: currentChannel?.url || '', onChannelChange: (channelId, channelUrl) => { // 房员接收到频道切换指令 if (!currentChannels || !Array.isArray(currentChannels)) return; const channel = currentChannels.find(c => c.id === channelId); if (channel) { handleChannelChange(channel); } }, }); // EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,显示今日节目(18点后包含明天10点前的节目) const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => { if (!programs || programs.length === 0) return programs; // 获取当前时间 const now = new Date(); const currentHour = now.getHours(); // 获取今日日期(只考虑年月日,忽略时间) const today = new Date(); const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); // 如果当前时间超过18点,扩展到明天10点 let endTime = todayEnd; if (currentHour >= 18) { // 明天10点 endTime = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 10, 0, 0); } // 首先过滤出符合时间范围的节目(包括跨天节目) const filteredPrograms = programs.filter(program => { const programStart = parseCustomTimeFormat(program.start); const programEnd = parseCustomTimeFormat(program.end); // 使用时间戳进行比较 const programStartTime = programStart.getTime(); const programEndTime = programEnd.getTime(); const todayStartTime = todayStart.getTime(); const endTimeValue = endTime.getTime(); // 节目的开始时间在范围内,或者节目在范围内播放(开始时间早于范围开始,但结束时间在范围内) return programStartTime < endTimeValue && programEndTime > todayStartTime; }); // 按开始时间排序 const sortedPrograms = [...filteredPrograms].sort((a, b) => { const startA = parseCustomTimeFormat(a.start).getTime(); const startB = parseCustomTimeFormat(b.start).getTime(); return startA - startB; }); const cleanedPrograms: Array<{ start: string; end: string; title: string }> = []; for (let i = 0; i < sortedPrograms.length; i++) { const currentProgram = sortedPrograms[i]; const currentStart = parseCustomTimeFormat(currentProgram.start); const currentEnd = parseCustomTimeFormat(currentProgram.end); // 检查是否与已添加的节目重叠 let hasOverlap = false; for (const existingProgram of cleanedPrograms) { const existingStart = parseCustomTimeFormat(existingProgram.start); const existingEnd = parseCustomTimeFormat(existingProgram.end); // 检查时间重叠(考虑完整的日期和时间) if ( (currentStart >= existingStart && currentStart < existingEnd) || // 当前节目开始时间在已存在节目时间段内 (currentEnd > existingStart && currentEnd <= existingEnd) || // 当前节目结束时间在已存在节目时间段内 (currentStart <= existingStart && currentEnd >= existingEnd) // 当前节目完全包含已存在节目 ) { hasOverlap = true; break; } } // 如果没有重叠,则添加该节目 if (!hasOverlap) { cleanedPrograms.push(currentProgram); } else { // 如果有重叠,检查是否需要替换已存在的节目 for (let j = 0; j < cleanedPrograms.length; j++) { const existingProgram = cleanedPrograms[j]; const existingStart = parseCustomTimeFormat(existingProgram.start); const existingEnd = parseCustomTimeFormat(existingProgram.end); // 检查是否与当前节目重叠(考虑完整的日期和时间) if ( (currentStart >= existingStart && currentStart < existingEnd) || (currentEnd > existingStart && currentEnd <= existingEnd) || (currentStart <= existingStart && currentEnd >= existingEnd) ) { // 计算节目时长 const currentDuration = currentEnd.getTime() - currentStart.getTime(); const existingDuration = existingEnd.getTime() - existingStart.getTime(); // 如果当前节目时间更短,则替换已存在的节目 if (currentDuration < existingDuration) { cleanedPrograms[j] = currentProgram; } break; } } } } return cleanedPrograms; }; // Anime4K超分相关状态 const [webGPUSupported, setWebGPUSupported] = useState(false); const [anime4kEnabled, setAnime4kEnabled] = useState(false); const [anime4kMode, setAnime4kMode] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('anime4k_mode'); if (v !== null) return v; } return 'ModeA'; }); const [anime4kScale, setAnime4kScale] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('anime4k_scale'); if (v !== null) return parseFloat(v); } return 2.0; }); const anime4kRef = useRef(null); const anime4kEnabledRef = useRef(anime4kEnabled); const anime4kModeRef = useRef(anime4kMode); const anime4kScaleRef = useRef(anime4kScale); useEffect(() => { anime4kEnabledRef.current = anime4kEnabled; anime4kModeRef.current = anime4kMode; anime4kScaleRef.current = anime4kScale; }, [anime4kEnabled, anime4kMode, anime4kScale]); // 播放器引用 const artPlayerRef = useRef(null); const artRef = useRef(null); // 分组标签滚动相关 const groupContainerRef = useRef(null); const groupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); const channelListRef = useRef(null); // ----------------------------------------------------------------------------- // 工具函数(Utils) // ----------------------------------------------------------------------------- // 获取直播源列表 const fetchLiveSources = async () => { try { setLoadingStage('fetching'); setLoadingMessage('正在获取直播源...'); // 获取 AdminConfig 中的直播源信息 const response = await fetch('/api/live/sources'); if (!response.ok) { throw new Error('获取直播源失败'); } const result = await response.json(); if (!result.success) { throw new Error(result.error || '获取直播源失败'); } const sources = result.data; setLiveSources(sources); if (sources.length > 0) { // 默认选中第一个源 const firstSource = sources[0]; if (needLoadSource) { const foundSource = sources.find((s: LiveSource) => s.key === needLoadSource); if (foundSource) { setCurrentSource(foundSource); await fetchChannels(foundSource); } else { setCurrentSource(firstSource); await fetchChannels(firstSource); } } else { setCurrentSource(firstSource); await fetchChannels(firstSource); } } setLoadingStage('ready'); setLoadingMessage('✨ 准备就绪...'); setTimeout(() => { setLoading(false); }, 1000); } catch (err) { console.error('获取直播源失败:', err); // 不设置错误,而是显示空状态 setLiveSources([]); setLoading(false); } }; // 获取频道列表 const fetchChannels = async (source: LiveSource) => { try { setIsVideoLoading(true); // 从 cachedLiveChannels 获取频道信息 const response = await fetch(`/api/live/channels?source=${source.key}`); if (!response.ok) { throw new Error('获取频道列表失败'); } const result = await response.json(); if (!result.success) { throw new Error(result.error || '获取频道列表失败'); } const channelsData = result.data; if (!channelsData || channelsData.length === 0) { // 不抛出错误,而是设置空频道列表 setCurrentChannels([]); setGroupedChannels({}); setFilteredChannels([]); // 更新直播源的频道数为 0 setLiveSources(prevSources => prevSources.map(s => s.key === source.key ? { ...s, channelNumber: 0 } : s ) ); setIsVideoLoading(false); return; } // 转换频道数据格式 const channels: LiveChannel[] = channelsData.map((channel: any) => ({ id: channel.id, tvgId: channel.tvgId || channel.name, name: channel.name, logo: channel.logo, group: channel.group || '其他', url: channel.url })); setCurrentChannels(channels); // 更新直播源的频道数 setLiveSources(prevSources => prevSources.map(s => s.key === source.key ? { ...s, channelNumber: channels.length } : s ) ); // 默认选中第一个频道 if (channels.length > 0) { let selectedChannel: LiveChannel | null = null; if (needLoadChannel) { const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel); if (foundChannel) { selectedChannel = foundChannel; setCurrentChannel(foundChannel); setVideoUrl(foundChannel.url); // 延迟滚动到选中的频道 setTimeout(() => { scrollToChannel(foundChannel); }, 200); } else { selectedChannel = channels[0]; setCurrentChannel(channels[0]); setVideoUrl(channels[0].url); } } else { selectedChannel = channels[0]; setCurrentChannel(channels[0]); setVideoUrl(channels[0].url); } // 异步获取初始频道的节目单(不阻塞页面加载) if (selectedChannel) { fetchEpgData(selectedChannel, source); // 保存播放记录 try { await savePlayRecord(`live_${source.key}`, `live_${selectedChannel.id}`, { title: selectedChannel.name, source_name: source.name, year: '', cover: `/api/proxy/logo?url=${encodeURIComponent(selectedChannel.logo)}&source=${source.key}`, index: 1, total_episodes: 1, play_time: 0, total_time: 0, save_time: Date.now(), search_title: '', origin: 'live', }); } catch (err) { console.error('保存播放记录失败:', err); } // 更新URL参数 const newSearchParams = new URLSearchParams(searchParams.toString()); newSearchParams.set('source', source.key); newSearchParams.set('id', selectedChannel.id); const newUrl = `?${newSearchParams.toString()}`; router.replace(newUrl); } } // 按分组组织频道 const grouped = channels.reduce((acc, channel) => { const group = channel.group || '其他'; if (!acc[group]) { acc[group] = []; } acc[group].push(channel); return acc; }, {} as { [key: string]: LiveChannel[] }); setGroupedChannels(grouped); // 默认选中当前加载的channel所在的分组,如果没有则选中第一个分组 let targetGroup = ''; if (needLoadChannel) { const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel); if (foundChannel) { targetGroup = foundChannel.group || '其他'; } } // 如果目标分组不存在,则使用第一个分组 if (!targetGroup || !grouped[targetGroup]) { targetGroup = Object.keys(grouped)[0] || ''; } // 先设置过滤后的频道列表,但不设置选中的分组 setFilteredChannels(targetGroup ? grouped[targetGroup] : channels); // 触发模拟点击分组,让模拟点击来设置分组状态和触发滚动 if (targetGroup) { // 确保切换到频道tab setActiveTab('channels'); // 使用更长的延迟,确保状态更新和DOM渲染完成 setTimeout(() => { simulateGroupClick(targetGroup); }, 500); // 增加延迟时间,确保状态更新和DOM渲染完成 } setIsVideoLoading(false); } catch (err) { console.error('获取频道列表失败:', err); // 不设置错误,而是设置空频道列表 setCurrentChannels([]); setGroupedChannels({}); setFilteredChannels([]); // 更新直播源的频道数为 0 setLiveSources(prevSources => prevSources.map(s => s.key === source.key ? { ...s, channelNumber: 0 } : s ) ); setIsVideoLoading(false); } }; // 切换直播源 const handleSourceChange = async (source: LiveSource) => { try { // 设置切换状态,锁住频道切换器 setIsSwitchingSource(true); // 首先销毁当前播放器 cleanupPlayer(); // 重置不支持的类型状态 setUnsupportedType(null); // 清空节目单信息 setEpgData(null); // 清空搜索关键词 setSearchKeyword(''); setCurrentSource(source); await fetchChannels(source); // 更新URL参数 - 切换直播源时清除频道id,因为新的直播源会有不同的频道列表 const newSearchParams = new URLSearchParams(searchParams.toString()); newSearchParams.set('source', source.key); newSearchParams.delete('id'); // 清除频道id const newUrl = `?${newSearchParams.toString()}`; router.replace(newUrl); } catch (err) { console.error('切换直播源失败:', err); // 不设置错误,保持当前状态 } finally { // 切换完成,解锁频道切换器 setIsSwitchingSource(false); // 自动切换到频道 tab setActiveTab('channels'); } }; // 获取节目单信息的辅助函数 const fetchEpgData = async (channel: LiveChannel, source: LiveSource) => { if (channel.tvgId && source) { try { setIsEpgLoading(true); // 开始加载 EPG 数据 const response = await fetch(`/api/live/epg?source=${source.key}&tvgId=${channel.tvgId}`); if (response.ok) { const result = await response.json(); if (result.success) { // 清洗EPG数据,去除重叠的节目 const cleanedData = { ...result.data, programs: cleanEpgData(result.data.programs) }; setEpgData(cleanedData); } } } catch (error) { console.error('获取节目单信息失败:', error); } finally { setIsEpgLoading(false); // 无论成功失败都结束加载状态 } } else { // 如果没有 tvgId 或 source,清空 EPG 数据 setEpgData(null); setIsEpgLoading(false); } }; // 切换频道 const handleChannelChange = async (channel: LiveChannel) => { // 如果正在切换直播源,则禁用频道切换 if (isSwitchingSource) return; // 首先销毁当前播放器 cleanupPlayer(); // 重置不支持的类型状态 setUnsupportedType(null); setCurrentChannel(channel); setVideoUrl(channel.url); // 更新URL参数 if (currentSource) { const newSearchParams = new URLSearchParams(searchParams.toString()); newSearchParams.set('source', currentSource.key); newSearchParams.set('id', channel.id); const newUrl = `?${newSearchParams.toString()}`; router.replace(newUrl); } // 自动滚动到选中的频道位置 setTimeout(() => { scrollToChannel(channel); }, 100); // 获取节目单信息 if (currentSource) { await fetchEpgData(channel, currentSource); } // 保存播放记录 if (currentSource) { try { await savePlayRecord(`live_${currentSource.key}`, `live_${channel.id}`, { title: channel.name, source_name: currentSource.name, year: '', cover: `/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource.key}`, index: 1, total_episodes: 1, play_time: 0, total_time: 0, save_time: Date.now(), search_title: '', origin: 'live', }); } catch (err) { console.error('保存播放记录失败:', err); } } }; // 滚动到指定频道位置的函数 const scrollToChannel = (channel: LiveChannel) => { if (!channelListRef.current) return; // 使用 data 属性来查找频道元素 const targetElement = channelListRef.current.querySelector(`[data-channel-id="${channel.id}"]`) as HTMLButtonElement; if (targetElement) { // 计算滚动位置,使频道居中显示 const container = channelListRef.current; const containerRect = container.getBoundingClientRect(); const elementRect = targetElement.getBoundingClientRect(); // 计算目标滚动位置 const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2); // 平滑滚动到目标位置 container.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' }); } }; // 模拟点击分组的函数 const simulateGroupClick = (group: string, retryCount = 0) => { if (!groupContainerRef.current) { if (retryCount < 10) { setTimeout(() => { simulateGroupClick(group, retryCount + 1); }, 200); return; } else { return; } } // 直接通过 data-group 属性查找目标按钮 const targetButton = groupContainerRef.current.querySelector(`[data-group="${group}"]`) as HTMLButtonElement; if (targetButton) { // 手动设置分组状态,确保状态一致性 setSelectedGroup(group); // 触发点击事件 (targetButton as HTMLButtonElement).click(); } }; // 初始化Anime4K超分 const initAnime4K = async () => { if (!artPlayerRef.current?.video) return; let frameRequestId: number | null = null; let outputCanvas: HTMLCanvasElement | null = null; try { if (anime4kRef.current) { anime4kRef.current.stop?.(); anime4kRef.current = null; } const video = artPlayerRef.current.video as HTMLVideoElement; // 等待视频元数据加载完成 if (!video.videoWidth || !video.videoHeight) { console.warn('视频尺寸未就绪,等待loadedmetadata事件'); await new Promise((resolve) => { const handler = () => { video.removeEventListener('loadedmetadata', handler); resolve(); }; video.addEventListener('loadedmetadata', handler); if (video.videoWidth && video.videoHeight) { video.removeEventListener('loadedmetadata', handler); resolve(); } }); } if (!video.videoWidth || !video.videoHeight) { throw new Error('无法获取视频尺寸'); } // 检测是否为Firefox const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); // 创建输出canvas outputCanvas = document.createElement('canvas'); const container = artPlayerRef.current.template.$video.parentElement; const scale = anime4kScaleRef.current; outputCanvas.width = Math.floor(video.videoWidth * scale); outputCanvas.height = Math.floor(video.videoHeight * scale); if (!outputCanvas.width || !outputCanvas.height || !isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) { throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}`); } outputCanvas.style.position = 'absolute'; outputCanvas.style.top = '0'; outputCanvas.style.left = '0'; outputCanvas.style.width = '100%'; outputCanvas.style.height = '100%'; outputCanvas.style.objectFit = 'contain'; outputCanvas.style.cursor = 'pointer'; outputCanvas.style.zIndex = '1'; outputCanvas.style.backgroundColor = 'transparent'; // Firefox兼容性处理 let sourceCanvas: HTMLCanvasElement | null = null; let sourceCtx: CanvasRenderingContext2D | null = null; if (isFirefox) { sourceCanvas = document.createElement('canvas'); const canvasW = Math.floor(video.videoWidth); const canvasH = Math.floor(video.videoHeight); sourceCanvas.width = canvasW; sourceCanvas.height = canvasH; if (!sourceCanvas.width || !sourceCanvas.height) { throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`); } sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true, alpha: false }); if (!sourceCtx) { throw new Error('无法创建2D上下文'); } if (video.readyState >= video.HAVE_CURRENT_DATA) { sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height); } } // 监听点击和双击事件 const handleCanvasClick = () => { if (artPlayerRef.current) { artPlayerRef.current.toggle(); } }; const handleCanvasDblClick = () => { if (artPlayerRef.current) { artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen; } }; outputCanvas.addEventListener('click', handleCanvasClick); outputCanvas.addEventListener('dblclick', handleCanvasDblClick); // 隐藏原始video video.style.opacity = '0'; video.style.pointerEvents = 'none'; video.style.position = 'absolute'; video.style.zIndex = '-1'; container.insertBefore(outputCanvas, video); // Firefox视频帧捕获 if (isFirefox && sourceCtx && sourceCanvas) { const captureVideoFrame = () => { if (sourceCtx && sourceCanvas && video.readyState >= video.HAVE_CURRENT_DATA) { sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height); } frameRequestId = requestAnimationFrame(captureVideoFrame); }; captureVideoFrame(); } // 动态导入anime4k-webgpu const { render: anime4kRender, ModeA, ModeB, ModeC, ModeAA, ModeBB, ModeCA } = await import( /* webpackChunkName: "anime4k-webgpu" */ /* webpackMode: "lazy" */ 'anime4k-webgpu' ); let ModeClass: any; const modeName = anime4kModeRef.current; switch (modeName) { case 'ModeA': ModeClass = ModeA; break; case 'ModeB': ModeClass = ModeB; break; case 'ModeC': ModeClass = ModeC; break; case 'ModeAA': ModeClass = ModeAA; break; case 'ModeBB': ModeClass = ModeBB; break; case 'ModeCA': ModeClass = ModeCA; break; default: ModeClass = ModeA; } const renderConfig: any = { video: isFirefox ? sourceCanvas : video, canvas: outputCanvas, pipelineBuilder: (device: GPUDevice, inputTexture: GPUTexture) => { if (!outputCanvas) { throw new Error('outputCanvas is null in pipelineBuilder'); } const mode = new ModeClass({ device, inputTexture, nativeDimensions: { width: Math.floor(video.videoWidth), height: Math.floor(video.videoHeight), }, targetDimensions: { width: Math.floor(outputCanvas.width), height: Math.floor(outputCanvas.height), }, }); return [mode]; }, }; const controller = await anime4kRender(renderConfig); anime4kRef.current = { controller, canvas: outputCanvas, sourceCanvas: isFirefox ? sourceCanvas : null, frameRequestId: isFirefox ? frameRequestId : null, handleCanvasClick, handleCanvasDblClick, }; console.log('Anime4K超分已启用,模式:', anime4kModeRef.current, '倍数:', scale); if (artPlayerRef.current) { artPlayerRef.current.notice.show = `超分已启用 (${anime4kModeRef.current}, ${scale}x)`; } } catch (err) { console.error('初始化Anime4K失败:', err); // 清理已创建的资源 if (frameRequestId) { cancelAnimationFrame(frameRequestId); } if (outputCanvas && outputCanvas.parentNode) { outputCanvas.parentNode.removeChild(outputCanvas); } if (artPlayerRef.current?.video) { artPlayerRef.current.video.style.opacity = '1'; artPlayerRef.current.video.style.pointerEvents = 'auto'; artPlayerRef.current.video.style.position = ''; artPlayerRef.current.video.style.zIndex = ''; } // 显示错误信息 if (artPlayerRef.current) { const errorMsg = err instanceof Error ? err.message : '未知错误'; artPlayerRef.current.notice.show = '超分启用失败:' + errorMsg; } // 重新抛出错误,让调用者知道失败了 throw err; } }; // 清理Anime4K const cleanupAnime4K = async () => { if (anime4kRef.current) { try { if (anime4kRef.current.frameRequestId) { cancelAnimationFrame(anime4kRef.current.frameRequestId); } anime4kRef.current.controller?.stop?.(); if (anime4kRef.current.canvas) { if (anime4kRef.current.handleCanvasClick) { anime4kRef.current.canvas.removeEventListener('click', anime4kRef.current.handleCanvasClick); } if (anime4kRef.current.handleCanvasDblClick) { anime4kRef.current.canvas.removeEventListener('dblclick', anime4kRef.current.handleCanvasDblClick); } } if (anime4kRef.current.canvas && anime4kRef.current.canvas.parentNode) { anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas); } if (anime4kRef.current.sourceCanvas) { const ctx = anime4kRef.current.sourceCanvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height); } } anime4kRef.current = null; if (artPlayerRef.current?.video) { artPlayerRef.current.video.style.opacity = '1'; artPlayerRef.current.video.style.pointerEvents = 'auto'; artPlayerRef.current.video.style.position = ''; artPlayerRef.current.video.style.zIndex = ''; } console.log('Anime4K已清理'); } catch (err) { console.warn('清理Anime4K时出错:', err); } } }; // 切换Anime4K状态 const toggleAnime4K = async (enabled: boolean) => { try { if (enabled) { // 检查视频是否准备好 if (!artPlayerRef.current?.video) { if (artPlayerRef.current) { artPlayerRef.current.notice.show = '视频未准备好,请稍后再试'; } return false; } await initAnime4K(); } else { await cleanupAnime4K(); } setAnime4kEnabled(enabled); localStorage.setItem('enable_anime4k', String(enabled)); return enabled; } catch (err) { console.error('切换超分状态失败:', err); if (artPlayerRef.current) { artPlayerRef.current.notice.show = '切换超分状态失败'; } return !enabled; // 返回原来的状态 } }; // 更改Anime4K模式 const changeAnime4KMode = async (mode: string) => { try { setAnime4kMode(mode); localStorage.setItem('anime4k_mode', mode); if (anime4kEnabledRef.current) { // 检查视频是否准备好 if (!artPlayerRef.current?.video) { if (artPlayerRef.current) { artPlayerRef.current.notice.show = '视频未准备好,请稍后再试'; } return; } await cleanupAnime4K(); await initAnime4K(); } } catch (err) { console.error('更改超分模式失败:', err); if (artPlayerRef.current) { artPlayerRef.current.notice.show = '更改超分模式失败'; } } }; // 更改Anime4K分辨率倍数 const changeAnime4KScale = async (scale: number) => { try { setAnime4kScale(scale); localStorage.setItem('anime4k_scale', scale.toString()); if (anime4kEnabledRef.current) { // 检查视频是否准备好 if (!artPlayerRef.current?.video) { if (artPlayerRef.current) { artPlayerRef.current.notice.show = '视频未准备好,请稍后再试'; } return; } await cleanupAnime4K(); await initAnime4K(); } } catch (err) { console.error('更改超分倍数失败:', err); if (artPlayerRef.current) { artPlayerRef.current.notice.show = '更改超分倍数失败'; } } }; // 清理播放器资源的统一函数 const cleanupPlayer = () => { // 重置不支持的类型状态 setUnsupportedType(null); // 清理Anime4K cleanupAnime4K(); if (artPlayerRef.current) { try { // 先暂停播放 if (artPlayerRef.current.video) { artPlayerRef.current.video.pause(); artPlayerRef.current.video.src = ''; artPlayerRef.current.video.load(); } // 销毁 HLS 实例 if (artPlayerRef.current.video && artPlayerRef.current.video.hls) { artPlayerRef.current.video.hls.destroy(); artPlayerRef.current.video.hls = null; } // 销毁 FLV 实例 - 增强清理逻辑 if (artPlayerRef.current.video && artPlayerRef.current.video.flv) { try { // 先停止加载 if (artPlayerRef.current.video.flv.unload) { artPlayerRef.current.video.flv.unload(); } // 销毁播放器 artPlayerRef.current.video.flv.destroy(); // 确保引用被清空 artPlayerRef.current.video.flv = null; } catch (flvError) { console.warn('FLV实例销毁时出错:', flvError); // 强制清空引用 artPlayerRef.current.video.flv = null; } } // 移除所有事件监听器 artPlayerRef.current.off('ready'); artPlayerRef.current.off('loadstart'); artPlayerRef.current.off('loadeddata'); artPlayerRef.current.off('canplay'); artPlayerRef.current.off('waiting'); artPlayerRef.current.off('error'); // 销毁 ArtPlayer 实例 artPlayerRef.current.destroy(); artPlayerRef.current = null; } catch (err) { console.warn('清理播放器资源时出错:', err); artPlayerRef.current = null; } } }; // 确保视频源正确设置 const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => { if (!video || !url) return; const sources = Array.from(video.getElementsByTagName('source')); const existed = sources.some((s) => s.src === url); if (!existed) { // 移除旧的 source,保持唯一 sources.forEach((s) => s.remove()); const sourceEl = document.createElement('source'); sourceEl.src = url; video.appendChild(sourceEl); } // 始终允许远程播放(AirPlay / Cast) video.disableRemotePlayback = false; // 如果曾经有禁用属性,移除之 if (video.hasAttribute('disableRemotePlayback')) { video.removeAttribute('disableRemotePlayback'); } }; // 过滤频道(根据分组和搜索关键词) const filterChannels = (group: string, keyword: string) => { if (!currentChannels || !Array.isArray(currentChannels)) return []; let filtered = currentChannels.filter(channel => channel.group === group); // 如果有搜索关键词,进一步过滤 if (keyword.trim()) { filtered = filtered.filter(channel => channel.name.toLowerCase().includes(keyword.toLowerCase()) ); } return filtered; }; // 切换分组 const handleGroupChange = (group: string) => { // 如果正在切换直播源,则禁用分组切换 if (isSwitchingSource) return; setSelectedGroup(group); const filtered = filterChannels(group, searchKeyword); setFilteredChannels(filtered); // 如果当前选中的频道在新的分组中,自动滚动到该频道位置 if (currentChannel && filtered.some(channel => channel.id === currentChannel.id)) { setTimeout(() => { scrollToChannel(currentChannel); }, 100); } else { // 否则滚动到频道列表顶端 if (channelListRef.current) { channelListRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } } }; // 处理搜索 const handleSearch = (keyword: string) => { setSearchKeyword(keyword); if (!selectedGroup) return; // 先在当前分组搜索 const filtered = filterChannels(selectedGroup, keyword); // 如果当前分组没有匹配的频道,且有搜索关键词,轮询所有分组 if (filtered.length === 0 && keyword.trim() && groupedChannels) { const groups = Object.keys(groupedChannels); // 轮询所有分组,找到第一个有匹配频道的分组 for (const group of groups) { const groupFiltered = filterChannels(group, keyword); if (groupFiltered.length > 0) { // 找到有匹配频道的分组,自动切换 setSelectedGroup(group); setFilteredChannels(groupFiltered); // 滚动到频道列表顶端 if (channelListRef.current) { channelListRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } return; } } } // 如果当前分组有匹配的频道,或者所有分组都没有匹配的频道,使用当前分组的结果 setFilteredChannels(filtered); }; // 切换收藏 const handleToggleFavorite = async () => { if (!currentSourceRef.current || !currentChannelRef.current) return; try { const currentFavorited = favoritedRef.current; const newFavorited = !currentFavorited; // 立即更新状态 setFavorited(newFavorited); favoritedRef.current = newFavorited; // 异步执行收藏操作 try { if (newFavorited) { // 如果未收藏,添加收藏 await saveFavorite(`live_${currentSourceRef.current.key}`, `live_${currentChannelRef.current.id}`, { title: currentChannelRef.current.name, source_name: currentSourceRef.current.name, year: '', cover: `/api/proxy/logo?url=${encodeURIComponent(currentChannelRef.current.logo)}&source=${currentSourceRef.current.key}`, total_episodes: 1, save_time: Date.now(), search_title: '', origin: 'live', }); } else { // 如果已收藏,删除收藏 await deleteFavorite(`live_${currentSourceRef.current.key}`, `live_${currentChannelRef.current.id}`); } } catch (err) { console.error('收藏操作失败:', err); // 如果操作失败,回滚状态 setFavorited(currentFavorited); favoritedRef.current = currentFavorited; } } catch (err) { console.error('切换收藏失败:', err); } }; // 检测WebGPU支持 useEffect(() => { const checkWebGPUSupport = async () => { if (typeof navigator === 'undefined' || !('gpu' in navigator)) { setWebGPUSupported(false); console.log('WebGPU不支持:浏览器不支持WebGPU API'); return; } try { const adapter = await (navigator as any).gpu.requestAdapter(); if (!adapter) { setWebGPUSupported(false); console.log('WebGPU不支持:无法获取GPU适配器'); return; } setWebGPUSupported(true); console.log('WebGPU支持检测:✅ 支持'); } catch (err) { setWebGPUSupported(false); console.log('WebGPU不支持:', err); } }; checkWebGPUSupport(); }, []); // 初始化 useEffect(() => { fetchLiveSources(); }, []); // 检查收藏状态 useEffect(() => { if (!currentSource || !currentChannel) return; (async () => { try { const fav = await checkIsFavorited(`live_${currentSource.key}`, `live_${currentChannel.id}`); setFavorited(fav); favoritedRef.current = fav; } catch (err) { console.error('检查收藏状态失败:', err); } })(); }, [currentSource, currentChannel]); // 监听收藏数据更新事件 useEffect(() => { if (!currentSource || !currentChannel) return; const unsubscribe = subscribeToDataUpdates( 'favoritesUpdated', (favorites: Record) => { const key = generateStorageKey(`live_${currentSource.key}`, `live_${currentChannel.id}`); const isFav = !!favorites[key]; setFavorited(isFav); favoritedRef.current = isFav; } ); return unsubscribe; }, [currentSource, currentChannel]); // 当分组切换时,将激活的分组标签滚动到视口中间 useEffect(() => { if (!selectedGroup || !groupContainerRef.current || !groupedChannels) return; const groupKeys = Object.keys(groupedChannels); const groupIndex = groupKeys.indexOf(selectedGroup); if (groupIndex === -1) return; const btn = groupButtonRefs.current?.[groupIndex]; const container = groupContainerRef.current; if (btn && container) { // 手动计算滚动位置,只滚动分组标签容器 const containerRect = container.getBoundingClientRect(); const btnRect = btn.getBoundingClientRect(); const scrollLeft = container.scrollLeft; // 计算按钮相对于容器的位置 const btnLeft = btnRect.left - containerRect.left + scrollLeft; const btnWidth = btnRect.width; const containerWidth = containerRect.width; // 计算目标滚动位置,使按钮居中 const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2; // 平滑滚动到目标位置 container.scrollTo({ left: targetScrollLeft, behavior: 'smooth', }); } }, [selectedGroup, groupedChannels]); function m3u8Loader(video: HTMLVideoElement, url: string) { if (!Hls) { console.error('HLS.js 未加载'); return; } class CustomHlsJsLoader extends Hls.DefaultConfig.loader { constructor(config: any) { super(config); const load = this.load.bind(this); this.load = function (context: any, config: any, callbacks: any) { // 所有的请求都带一个 source 参数 try { const url = new URL(context.url); url.searchParams.set('moontv-source', currentSourceRef.current?.key || ''); context.url = url.toString(); } catch (error) { // ignore } // 拦截manifest和level请求 if ( (context as any).type === 'manifest' || (context as any).type === 'level' ) { // 判断是否浏览器直连 const isLiveDirectConnectStr = localStorage.getItem('liveDirectConnect'); const isLiveDirectConnect = isLiveDirectConnectStr === 'true'; if (isLiveDirectConnect) { // 浏览器直连,使用 URL 对象处理参数 try { const url = new URL(context.url); url.searchParams.set('allowCORS', 'true'); context.url = url.toString(); } catch (error) { // 如果 URL 解析失败,回退到字符串拼接 context.url = context.url + '&allowCORS=true'; } } } // 执行原始load方法 load(context, config, callbacks); }; } } // 清理之前的 HLS 实例 if (video.hls) { try { video.hls.destroy(); video.hls = null; } catch (err) { console.warn('清理 HLS 实例时出错:', err); } } const hls = new Hls({ debug: false, enableWorker: true, lowLatencyMode: true, maxBufferLength: 30, backBufferLength: 30, maxBufferSize: 60 * 1000 * 1000, loader: CustomHlsJsLoader, }); hls.loadSource(url); hls.attachMedia(video); video.hls = hls; hls.on(Hls.Events.ERROR, function (event: any, data: any) { console.error('HLS Error:', event, data); if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: // hls.recoverMediaError(); break; default: hls.destroy(); break; } } }); } // 播放器初始化 useEffect(() => { const preload = async () => { if ( !Artplayer || !Hls || !videoUrl || !artRef.current || !currentChannel ) { return; } console.log('视频URL:', videoUrl); // 销毁之前的播放器实例并创建新的 if (artPlayerRef.current) { cleanupPlayer(); } // precheck type let type = 'm3u8'; try { const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`; const precheckResponse = await fetch(precheckUrl); if (!precheckResponse.ok) { console.error('预检查失败:', precheckResponse.statusText); setIsVideoLoading(false); return; } const precheckResult = await precheckResponse.json(); if (precheckResult?.success && precheckResult?.type) { type = precheckResult.type; } else { console.error('预检查返回无效结果:', precheckResult); setIsVideoLoading(false); return; } } catch (err) { console.error('预检查异常:', err); setIsVideoLoading(false); return; } // 如果不是 m3u8 类型,设置不支持的类型并返回 if (type !== 'm3u8') { setUnsupportedType(type); setIsVideoLoading(false); return; } // 重置不支持的类型 setUnsupportedType(null); const customType = { m3u8: m3u8Loader }; const targetUrl = `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`; try { // 创建新的播放器实例 Artplayer.USE_RAF = true; artPlayerRef.current = new Artplayer({ container: artRef.current, url: targetUrl, poster: currentChannel.logo, volume: 0.7, isLive: true, // 设置为直播模式 muted: false, autoplay: true, pip: true, autoSize: false, autoMini: false, screenshot: false, setting: webGPUSupported, // 只有支持WebGPU时才显示设置按钮 loop: false, flip: false, playbackRate: false, aspectRatio: false, fullscreen: true, fullscreenWeb: true, subtitleOffset: false, miniProgressBar: false, mutex: true, playsInline: true, autoPlayback: false, airplay: true, theme: '#22c55e', lang: 'zh-cn', hotkey: false, fastForward: false, // 直播不需要快进 autoOrientation: true, lock: true, moreVideoAttr: { crossOrigin: 'anonymous', preload: 'metadata', }, type: type, customType: customType, icons: { loading: '', }, settings: [ ...(webGPUSupported ? [ { name: 'Anime4K超分', html: 'Anime4K超分', icon: '', switch: anime4kEnabledRef.current, onSwitch: async function (item: any) { const newVal = !item.switch; const result = await toggleAnime4K(newVal); return result; }, }, { name: '超分模式', html: '超分模式', selector: [ { html: 'ModeA (快速)', value: 'ModeA', default: anime4kModeRef.current === 'ModeA', }, { html: 'ModeB (平衡)', value: 'ModeB', default: anime4kModeRef.current === 'ModeB', }, { html: 'ModeC (质量)', value: 'ModeC', default: anime4kModeRef.current === 'ModeC', }, { html: 'ModeAA (增强快速)', value: 'ModeAA', default: anime4kModeRef.current === 'ModeAA', }, { html: 'ModeBB (增强平衡)', value: 'ModeBB', default: anime4kModeRef.current === 'ModeBB', }, { html: 'ModeCA (最高质量)', value: 'ModeCA', default: anime4kModeRef.current === 'ModeCA', }, ], onSelect: async function (item: any) { await changeAnime4KMode(item.value); return item.html; }, }, { name: '超分倍数', html: '超分倍数', selector: [ { html: '1.5x', value: '1.5', default: anime4kScaleRef.current === 1.5, }, { html: '2.0x', value: '2.0', default: anime4kScaleRef.current === 2.0, }, { html: '3.0x', value: '3.0', default: anime4kScaleRef.current === 3.0, }, { html: '4.0x', value: '4.0', default: anime4kScaleRef.current === 4.0, }, ], onSelect: async function (item: any) { await changeAnime4KScale(parseFloat(item.value)); return item.html; }, } ] : []), ], }); // 监听播放器事件 artPlayerRef.current.on('ready', () => { setError(null); setIsVideoLoading(false); }); artPlayerRef.current.on('loadstart', () => { setIsVideoLoading(true); }); artPlayerRef.current.on('loadeddata', () => { setIsVideoLoading(false); }); artPlayerRef.current.on('canplay', () => { setIsVideoLoading(false); }); artPlayerRef.current.on('waiting', () => { setIsVideoLoading(true); }); artPlayerRef.current.on('error', (err: any) => { console.error('播放器错误:', err); }); if (artPlayerRef.current?.video) { ensureVideoSource( artPlayerRef.current.video as HTMLVideoElement, targetUrl ); } } catch (err) { console.error('创建播放器失败:', err); // 不设置错误,只记录日志 } } preload(); }, [Artplayer, Hls, videoUrl, currentChannel, loading]); // 清理播放器资源 useEffect(() => { return () => { cleanupPlayer(); }; }, []); // 页面卸载时的额外清理 useEffect(() => { const handleBeforeUnload = () => { cleanupPlayer(); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); cleanupPlayer(); }; }, []); // 全局快捷键处理 useEffect(() => { const handleKeyboardShortcuts = (e: KeyboardEvent) => { // 忽略输入框中的按键事件 if ( (e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA' ) return; // 上箭头 = 音量+ if (e.key === 'ArrowUp') { if (artPlayerRef.current && artPlayerRef.current.volume < 1) { artPlayerRef.current.volume = Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10; artPlayerRef.current.notice.show = `音量: ${Math.round( artPlayerRef.current.volume * 100 )}`; e.preventDefault(); } } // 下箭头 = 音量- if (e.key === 'ArrowDown') { if (artPlayerRef.current && artPlayerRef.current.volume > 0) { artPlayerRef.current.volume = Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10; artPlayerRef.current.notice.show = `音量: ${Math.round( artPlayerRef.current.volume * 100 )}`; e.preventDefault(); } } // 空格 = 播放/暂停 if (e.key === ' ') { if (artPlayerRef.current) { artPlayerRef.current.toggle(); e.preventDefault(); } } // f 键 = 切换全屏 if (e.key === 'f' || e.key === 'F') { if (artPlayerRef.current) { artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen; e.preventDefault(); } } }; document.addEventListener('keydown', handleKeyboardShortcuts); return () => { document.removeEventListener('keydown', handleKeyboardShortcuts); }; }, []); if (loading) { return (
{/* 动画直播图标 */}
📺
{/* 旋转光环 */}
{/* 浮动粒子效果 */}
{/* 进度指示器 */}
{/* 进度条 */}
{/* 加载消息 */}

{loadingMessage}

); } if (error) { return (
{/* 错误图标 */}
😵
{/* 脉冲效果 */}
{/* 错误信息 */}

哎呀,出现了一些问题

{error}

请检查网络连接或尝试刷新页面

{/* 操作按钮 */}
); } return (
{/* 第一行:页面标题 */}

{currentSource?.name} {currentSource && currentChannel && ( {` > ${currentChannel.name}`} )} {currentSource && !currentChannel && ( {` > ${currentSource.name}`} )}

{/* 第二行:播放器和频道列表 */}
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
{/* 播放器 */}
{/* 不支持的直播类型提示 */} {unsupportedType && (
⚠️

暂不支持的直播流类型

当前频道直播流类型:{unsupportedType.toUpperCase()}

目前仅支持 M3U8 格式的直播流

请尝试其他频道

)} {/* 视频加载蒙层 */} {isVideoLoading && (
📺

🔄 IPTV 加载中...

)}
{/* 外部播放器按钮 - 观影室同步状态下隐藏 */} {videoUrl && !liveSync.isInRoom && (
{/* 网页播放 */} {/* PotPlayer */} {/* VLC */} {/* MPV */} {/* MX Player */} {/* nPlayer */} {/* IINA */}
)}
{/* 频道列表 */}
{/* 主要的 Tab 切换 */}
setActiveTab('channels')} className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'channels' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' } `.trim()} > 频道
setActiveTab('sources')} className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'sources' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' } `.trim()} > 直播源
{/* 频道 Tab 内容 */} {activeTab === 'channels' && ( <> {/* 搜索框 */}
handleSearch(e.target.value)} placeholder='搜索频道...' disabled={isSwitchingSource} className={`w-full px-3 py-2 pl-9 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all ${ isSwitchingSource ? 'opacity-50 cursor-not-allowed' : '' }`} /> {searchKeyword && ( )}
{/* 分组标签 */}
{/* 切换状态提示 */} {isSwitchingSource && (
切换直播源中...
)}
{ // 鼠标进入分组标签区域时,添加滚轮事件监听 const container = groupContainerRef.current; if (container) { const handleWheel = (e: WheelEvent) => { if (container.scrollWidth > container.clientWidth) { e.preventDefault(); container.scrollLeft += e.deltaY; } }; container.addEventListener('wheel', handleWheel, { passive: false }); // 将事件处理器存储在容器上,以便后续移除 (container as any)._wheelHandler = handleWheel; } }} onMouseLeave={() => { // 鼠标离开分组标签区域时,移除滚轮事件监听 const container = groupContainerRef.current; if (container && (container as any)._wheelHandler) { container.removeEventListener('wheel', (container as any)._wheelHandler); delete (container as any)._wheelHandler; } }} >
{groupedChannels && Object.keys(groupedChannels).map((group, index) => ( ))}
{/* 频道列表 */}
{filteredChannels?.length > 0 ? ( filteredChannels.map(channel => { const isActive = channel.id === currentChannel?.id; return ( ); }) ) : (
{searchKeyword ? ( ) : ( )}

{searchKeyword ? '未找到匹配的频道' : '暂无可用频道'}

{searchKeyword ? '请尝试其他搜索关键词' : '请选择其他直播源或稍后再试'}

)}
)} {/* 直播源 Tab 内容 */} {activeTab === 'sources' && (
{liveSources?.length > 0 ? ( liveSources.map((source) => { const isCurrentSource = source.key === currentSource?.key; return (
!isCurrentSource && handleSourceChange(source)} className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative ${isCurrentSource ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer' }`.trim()} > {/* 图标 */}
{/* 信息 */}
{source.name}
{!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
{/* 当前标识 */} {isCurrentSource && (
)}
); }) ) : (

暂无可用直播源

请检查网络连接或联系管理员添加直播源

)}
)}
{/* 当前频道信息 */} {currentChannel && (
{/* 频道图标+名称 - 在小屏幕上占100%,大屏幕占20% */}
{currentChannel.logo ? ( {currentChannel.name} ) : ( )}

{currentChannel.name}

{currentSource?.name} {' > '} {currentChannel.group}

{/* EPG节目单 */}
)}
); } // FavoriteIcon 组件 const FavoriteIcon = ({ filled }: { filled: boolean }) => { if (filled) { return ( ); } return ( ); }; export default function LivePage() { return ; }