Files
MoonTVPlus/src/app/live/page.tsx
T
2025-12-17 23:42:42 +08:00

2348 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Heart, Radio, Tv } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
import { useLiveSync } from '@/hooks/useLiveSync';
import {
deleteFavorite,
generateStorageKey,
isFavorited as checkIsFavorited,
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { parseCustomTimeFormat } from '@/lib/time';
import EpgScrollableRow from '@/components/EpgScrollableRow';
import PageLayout from '@/components/PageLayout';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
interface HTMLVideoElement {
hls?: any;
}
}
// 直播频道接口
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() {
// -----------------------------------------------------------------------------
// 状态变量(State
// -----------------------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [loadingStage, setLoadingStage] = useState<
'loading' | 'fetching' | 'ready'
>('loading');
const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...');
const [error, setError] = useState<string | null>(null);
const searchParams = useSearchParams();
const router = useRouter();
// 直播源相关
const [liveSources, setLiveSources] = useState<LiveSource[]>([]);
const [currentSource, setCurrentSource] = useState<LiveSource | null>(null);
const currentSourceRef = useRef<LiveSource | null>(null);
useEffect(() => {
currentSourceRef.current = currentSource;
}, [currentSource]);
// 频道相关
const [currentChannels, setCurrentChannels] = useState<LiveChannel[]>([]);
const [currentChannel, setCurrentChannel] = useState<LiveChannel | null>(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<string | null>(null);
// 切换直播源状态
const [isSwitchingSource, setIsSwitchingSource] = useState(false);
// 分组相关
const [groupedChannels, setGroupedChannels] = useState<{ [key: string]: LiveChannel[] }>({});
const [selectedGroup, setSelectedGroup] = useState<string>('');
// Tab 切换
const [activeTab, setActiveTab] = useState<'channels' | 'sources'>('channels');
// 频道列表收起状态
const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false);
// 过滤后的频道列表
const [filteredChannels, setFilteredChannels] = useState<LiveChannel[]>([]);
// 搜索关键词
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<LiveChannel | null>(null);
// 观影室同步功能
const liveSync = useLiveSync({
currentChannelId: currentChannel?.id || '',
currentChannelName: currentChannel?.name || '',
currentChannelUrl: currentChannel?.url || '',
onChannelChange: (channelId, channelUrl) => {
// 房员接收到频道切换指令
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<boolean>(false);
const [anime4kEnabled, setAnime4kEnabled] = useState<boolean>(false);
const [anime4kMode, setAnime4kMode] = useState<string>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('anime4k_mode');
if (v !== null) return v;
}
return 'ModeA';
});
const [anime4kScale, setAnime4kScale] = useState<number>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('anime4k_scale');
if (v !== null) return parseFloat(v);
}
return 2.0;
});
const anime4kRef = useRef<any>(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<any>(null);
const artRef = useRef<HTMLDivElement | null>(null);
// 分组标签滚动相关
const groupContainerRef = useRef<HTMLDivElement>(null);
const groupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const channelListRef = useRef<HTMLDivElement>(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);
} finally {
// 移除 URL 搜索参数中的 source 和 id
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete('source');
newSearchParams.delete('id');
const newUrl = newSearchParams.toString()
? `?${newSearchParams.toString()}`
: window.location.pathname;
router.replace(newUrl);
}
};
// 获取频道列表
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);
}
}
// 按分组组织频道
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);
} 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);
// 自动滚动到选中的频道位置
setTimeout(() => {
scrollToChannel(channel);
}, 100);
// 获取节目单信息
if (currentSource) {
await fetchEpgData(channel, currentSource);
}
};
// 滚动到指定频道位置的函数
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<void>((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('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 (artPlayerRef.current) {
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
}
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 = '';
}
}
};
// 清理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) {
await initAnime4K();
} else {
await cleanupAnime4K();
}
setAnime4kEnabled(enabled);
localStorage.setItem('enable_anime4k', String(enabled));
} catch (err) {
console.error('切换超分状态失败:', err);
}
};
// 更改Anime4K模式
const changeAnime4KMode = async (mode: string) => {
try {
setAnime4kMode(mode);
localStorage.setItem('anime4k_mode', mode);
if (anime4kEnabledRef.current) {
await cleanupAnime4K();
await initAnime4K();
}
} catch (err) {
console.error('更改超分模式失败:', err);
}
};
// 更改Anime4K分辨率倍数
const changeAnime4KScale = async (scale: number) => {
try {
setAnime4kScale(scale);
localStorage.setItem('anime4k_scale', scale.toString());
if (anime4kEnabledRef.current) {
await cleanupAnime4K();
await initAnime4K();
}
} catch (err) {
console.error('更改超分倍数失败:', err);
}
};
// 清理播放器资源的统一函数
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) => {
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;
// 先在当前分组搜索
let filtered = filterChannels(selectedGroup, keyword);
// 如果当前分组没有匹配的频道,且有搜索关键词,轮询所有分组
if (filtered.length === 0 && keyword.trim()) {
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<string, any>) => {
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) 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]);
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);
};
}
}
function m3u8Loader(video: HTMLVideoElement, url: string) {
if (!Hls) {
console.error('HLS.js 未加载');
return;
}
// 清理之前的 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';
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);
return;
}
const precheckResult = await precheckResponse.json();
if (precheckResult.success) {
type = precheckResult.type;
}
// 如果不是 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: true,
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:
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
},
settings: [
...(webGPUSupported ? [
{
name: 'Anime4K超分',
html: 'Anime4K超分',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
switch: anime4kEnabledRef.current,
onSwitch: async function (item: any) {
const newVal = !item.switch;
await toggleAnime4K(newVal);
return newVal;
},
},
{
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 (
<PageLayout activePath='/live'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画直播图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>📺</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'loading' ? 'bg-green-500 scale-125' : 'bg-green-500'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'fetching' ? 'bg-green-500 scale-125' : 'bg-green-500'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready' ? 'bg-green-500 scale-125' : 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'loading' ? '33%' : loadingStage === 'fetching' ? '66%' : '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
return (
<PageLayout activePath='/live'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
哎呀,出现了一些问题
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
请检查网络连接或尝试刷新页面
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-cyan-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-cyan-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
🔄 重新尝试
</button>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout activePath='/live'>
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
{/* 第一行:页面标题 */}
<div className='py-1'>
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2 max-w-[80%]'>
<Radio className='w-5 h-5 text-blue-500 flex-shrink-0' />
<div className='min-w-0 flex-1'>
<div className='truncate'>
{currentSource?.name}
{currentSource && currentChannel && (
<span className='text-gray-500 dark:text-gray-400'>
{` > ${currentChannel.name}`}
</span>
)}
{currentSource && !currentChannel && (
<span className='text-gray-500 dark:text-gray-400'>
{` > ${currentSource.name}`}
</span>
)}
</div>
</div>
</h1>
</div>
{/* 第二行:播放器和频道列表 */}
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
setIsChannelListCollapsed(!isChannelListCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isChannelListCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isChannelListCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isChannelListCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-green-400'
}`}
></div>
</button>
</div>
<div className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isChannelListCollapsed
? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4'
}`}>
{/* 播放器 */}
<div className={`h-full transition-all duration-300 ease-in-out ${isChannelListCollapsed ? 'col-span-1' : 'md:col-span-3'}`}>
<div className='relative w-full h-[300px] lg:h-full'>
<div
ref={artRef}
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30'
></div>
{/* 不支持的直播类型提示 */}
{unsupportedType && (
<div className='absolute inset-0 bg-black/90 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[600] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>⚠️</div>
<div className='absolute -inset-2 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl opacity-20 animate-pulse'></div>
</div>
</div>
<div className='space-y-4'>
<h3 className='text-xl font-semibold text-white'>
暂不支持的直播流类型
</h3>
<div className='bg-orange-500/20 border border-orange-500/30 rounded-lg p-4'>
<p className='text-orange-300 font-medium'>
当前频道直播流类型:<span className='text-white font-bold'>{unsupportedType.toUpperCase()}</span>
</p>
<p className='text-sm text-orange-200 mt-2'>
目前仅支持 M3U8 格式的直播流
</p>
</div>
<p className='text-sm text-gray-300'>
请尝试其他频道
</p>
</div>
</div>
</div>
)}
{/* 视频加载蒙层 */}
{isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>📺</div>
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
</div>
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
🔄 IPTV 加载中...
</p>
</div>
</div>
</div>
)}
</div>
{/* 外部播放器按钮 - 观影室同步状态下隐藏 */}
{videoUrl && !liveSync.isInRoom && (
<div className='mt-3 px-2 lg:flex-shrink-0 flex justify-end'>
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-2 border border-gray-200/50 dark:border-gray-700/50 w-full lg:w-auto overflow-x-auto'>
<div className='flex gap-1.5 justify-end lg:flex-wrap items-center'>
{/* PotPlayer */}
<button
onClick={(e) => {
e.preventDefault();
// 直接使用原始 URL,不使用代理
window.open(`potplayer://${videoUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='PotPlayer'
>
<img
src='/players/potplayer.png'
alt='PotPlayer'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
PotPlayer
</span>
</button>
{/* VLC */}
<button
onClick={(e) => {
e.preventDefault();
// 直接使用原始 URL,不使用代理
window.open(`vlc://${videoUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='VLC'
>
<img
src='/players/vlc.png'
alt='VLC'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
VLC
</span>
</button>
{/* MPV */}
<button
onClick={(e) => {
e.preventDefault();
// 直接使用原始 URL,不使用代理
window.open(`mpv://${videoUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='MPV'
>
<img
src='/players/mpv.png'
alt='MPV'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
MPV
</span>
</button>
{/* MX Player */}
<button
onClick={(e) => {
e.preventDefault();
// 直接使用原始 URL,不使用代理
window.open(
`intent://${videoUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
currentChannel?.name || '直播'
)};end`,
'_blank'
);
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='MX Player'
>
<img
src='/players/mxplayer.png'
alt='MX Player'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
MX Player
</span>
</button>
{/* nPlayer */}
<button
onClick={(e) => {
e.preventDefault();
// 直接使用原始 URL,不使用代理
window.open(`nplayer-${videoUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='nPlayer'
>
<img
src='/players/nplayer.png'
alt='nPlayer'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
nPlayer
</span>
</button>
{/* IINA */}
<button
onClick={(e) => {
e.preventDefault();
// 直接使用原始 URL,不使用代理
window.open(
`iina://weblink?url=${encodeURIComponent(videoUrl)}`,
'_blank'
);
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='IINA'
>
<img
src='/players/iina.png'
alt='IINA'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
IINA
</span>
</button>
</div>
</div>
</div>
)}
</div>
{/* 频道列表 */}
<div className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isChannelListCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}>
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
{/* 主要的 Tab 切换 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'>
<div
onClick={() => 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()}
>
频道
</div>
<div
onClick={() => 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()}
>
直播源
</div>
</div>
{/* 频道 Tab 内容 */}
{activeTab === 'channels' && (
<>
{/* 搜索框 */}
<div className='mb-3 -mx-6 px-6 flex-shrink-0'>
<div className='relative'>
<input
type='text'
value={searchKeyword}
onChange={(e) => 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' : ''
}`}
/>
<svg
className='absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
/>
</svg>
{searchKeyword && (
<button
onClick={() => handleSearch('')}
className='absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
)}
</div>
</div>
{/* 分组标签 */}
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
{/* 切换状态提示 */}
{isSwitchingSource && (
<div className='flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400'>
<div className='w-2 h-2 bg-amber-500 rounded-full animate-pulse'></div>
切换直播源中...
</div>
)}
<div
className='flex-1 overflow-x-auto'
ref={groupContainerRef}
onMouseEnter={() => {
// 鼠标进入分组标签区域时,添加滚轮事件监听
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;
}
}}
>
<div className='flex gap-4 min-w-max'>
{Object.keys(groupedChannels).map((group, index) => (
<button
key={group}
data-group={group}
ref={(el) => {
groupButtonRefs.current[index] = el;
}}
onClick={() => handleGroupChange(group)}
disabled={isSwitchingSource}
className={`w-20 relative py-2 text-sm font-medium transition-colors flex-shrink-0 text-center overflow-hidden
${isSwitchingSource
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
: selectedGroup === group
? 'text-green-500 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
}
`.trim()}
>
<div className='px-1 overflow-hidden whitespace-nowrap' title={group}>
{group}
</div>
{selectedGroup === group && !isSwitchingSource && (
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
)}
</button>
))}
</div>
</div>
</div>
{/* 频道列表 */}
<div ref={channelListRef} className='flex-1 overflow-y-auto space-y-2 pb-4'>
{filteredChannels.length > 0 ? (
filteredChannels.map(channel => {
const isActive = channel.id === currentChannel?.id;
return (
<button
key={channel.id}
data-channel-id={channel.id}
onClick={() => handleChannelChange(channel)}
disabled={isSwitchingSource}
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<div className='flex items-center gap-3'>
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{channel.logo ? (
<img
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
alt={channel.name}
className='w-full h-full rounded object-contain'
loading="lazy"
/>
) : (
<Tv className='w-5 h-5 text-gray-500' />
)}
</div>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
{channel.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
{channel.group}
</div>
</div>
</div>
</button>
);
})
) : (
<div className='flex flex-col items-center justify-center py-12 text-center'>
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
{searchKeyword ? (
<svg
className='w-8 h-8 text-gray-400 dark:text-gray-600'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
/>
</svg>
) : (
<Tv className='w-8 h-8 text-gray-400 dark:text-gray-600' />
)}
</div>
<p className='text-gray-500 dark:text-gray-400 font-medium'>
{searchKeyword ? '未找到匹配的频道' : '暂无可用频道'}
</p>
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
{searchKeyword ? '请尝试其他搜索关键词' : '请选择其他直播源或稍后再试'}
</p>
</div>
)}
</div>
</>
)}
{/* 直播源 Tab 内容 */}
{activeTab === 'sources' && (
<div className='flex flex-col h-full mt-4'>
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
{liveSources.length > 0 ? (
liveSources.map((source) => {
const isCurrentSource = source.key === currentSource?.key;
return (
<div
key={source.key}
onClick={() => !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()}
>
{/* 图标 */}
<div className='w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-lg flex items-center justify-center flex-shrink-0'>
<Radio className='w-6 h-6 text-gray-500' />
</div>
{/* 信息 */}
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
</div>
</div>
{/* 当前标识 */}
{isCurrentSource && (
<div className='absolute top-2 right-2 w-2 h-2 bg-green-500 rounded-full'></div>
)}
</div>
);
})
) : (
<div className='flex flex-col items-center justify-center py-12 text-center'>
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
<Radio className='w-8 h-8 text-gray-400 dark:text-gray-600' />
</div>
<p className='text-gray-500 dark:text-gray-400 font-medium'>
暂无可用直播源
</p>
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
请检查网络连接或联系管理员添加直播源
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* 当前频道信息 */}
{currentChannel && (
<div className='pt-4'>
<div className='flex flex-col lg:flex-row gap-4'>
{/* 频道图标+名称 - 在小屏幕上占100%,大屏幕占20% */}
<div className='w-full flex-shrink-0'>
<div className='flex items-center gap-4'>
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{currentChannel.logo ? (
<img
src={`/api/proxy/logo?url=${encodeURIComponent(currentChannel.logo)}&source=${currentSource?.key || ''}`}
alt={currentChannel.name}
className='w-full h-full rounded object-contain'
loading="lazy"
/>
) : (
<Tv className='w-10 h-10 text-gray-500' />
)}
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
{currentChannel.name}
</h3>
<button
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite();
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
title={favorited ? '取消收藏' : '收藏'}
>
<FavoriteIcon filled={favorited} />
</button>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
{currentSource?.name} {' > '} {currentChannel.group}
</p>
</div>
</div>
</div>
</div>
{/* EPG节目单 */}
<EpgScrollableRow
programs={epgData?.programs || []}
currentTime={new Date()}
isLoading={isEpgLoading}
/>
</div>
)}
</div>
</PageLayout>
);
}
// FavoriteIcon 组件
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
if (filled) {
return (
<svg
className='h-6 w-6'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
fill='#ef4444' /* Tailwind red-500 */
stroke='#ef4444'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
}
return (
<Heart className='h-6 w-6 stroke-[1] text-gray-600 dark:text-gray-300' />
);
};
export default function LivePage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LivePageClient />
</Suspense>
);
}