2348 lines
90 KiB
TypeScript
2348 lines
90 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|