feat: support save live channel to favorite

This commit is contained in:
shinya
2025-08-26 16:36:44 +08:00
parent fa46e05c7c
commit 15b20603b8
12 changed files with 434 additions and 49 deletions
+277 -27
View File
@@ -4,9 +4,17 @@
import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Radio, Tv } from 'lucide-react';
import { Heart, Radio, Tv } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
import {
deleteFavorite,
generateStorageKey,
isFavorited as checkIsFavorited,
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { parseCustomTimeFormat } from '@/lib/time';
import EpgScrollableRow from '@/components/EpgScrollableRow';
@@ -52,6 +60,9 @@ function LivePageClient() {
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);
@@ -63,6 +74,12 @@ function LivePageClient() {
// 频道相关
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('');
@@ -100,6 +117,11 @@ function LivePageClient() {
// EPG 数据加载状态
const [isEpgLoading, setIsEpgLoading] = useState(false);
// 收藏状态
const [favorited, setFavorited] = useState(false);
const favoritedRef = useRef(false);
const currentChannelRef = useRef<LiveChannel | null>(null);
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
if (!programs || programs.length === 0) return programs;
@@ -134,8 +156,6 @@ function LivePageClient() {
});
const cleanedPrograms: Array<{ start: string; end: string; title: string }> = [];
let removedCount = 0;
const dateFilteredCount = programs.length - todayPrograms.length;
for (let i = 0; i < sortedPrograms.length; i++) {
const currentProgram = sortedPrograms[i];
@@ -183,8 +203,6 @@ function LivePageClient() {
// 如果当前节目时间更短,则替换已存在的节目
if (currentDuration < existingDuration) {
cleanedPrograms[j] = currentProgram;
} else {
removedCount++;
}
break;
}
@@ -231,8 +249,19 @@ function LivePageClient() {
if (sources.length > 0) {
// 默认选中第一个源
const firstSource = sources[0];
setCurrentSource(firstSource);
await fetchChannels(firstSource);
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');
@@ -246,6 +275,17 @@ function LivePageClient() {
// 不设置错误,而是显示空状态
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);
}
};
@@ -304,8 +344,23 @@ function LivePageClient() {
// 默认选中第一个频道
if (channels.length > 0) {
setCurrentChannel(channels[0]);
setVideoUrl(channels[0].url);
if (needLoadChannel) {
const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel);
if (foundChannel) {
setCurrentChannel(foundChannel);
setVideoUrl(foundChannel.url);
// 延迟滚动到选中的频道
setTimeout(() => {
scrollToChannel(foundChannel);
}, 200);
} else {
setCurrentChannel(channels[0]);
setVideoUrl(channels[0].url);
}
} else {
setCurrentChannel(channels[0]);
setVideoUrl(channels[0].url);
}
}
// 按分组组织频道
@@ -320,10 +375,33 @@ function LivePageClient() {
setGroupedChannels(grouped);
// 默认选中第一个分组
const firstGroup = Object.keys(grouped)[0] || '';
setSelectedGroup(firstGroup);
setFilteredChannels(firstGroup ? grouped[firstGroup] : channels);
// 默认选中当前加载的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) {
@@ -386,6 +464,11 @@ function LivePageClient() {
setCurrentChannel(channel);
setVideoUrl(channel.url);
// 自动滚动到选中的频道位置
setTimeout(() => {
scrollToChannel(channel);
}, 100);
// 获取节目单信息
if (channel.tvgId && currentSource) {
try {
@@ -414,6 +497,55 @@ function LivePageClient() {
}
};
// 滚动到指定频道位置的函数
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();
}
};
// 清理播放器资源的统一函数
const cleanupPlayer = () => {
// 重置不支持的类型状态
@@ -500,12 +632,60 @@ function LivePageClient() {
const filtered = currentChannels.filter(channel => channel.group === group);
setFilteredChannels(filtered);
// 滚动到频道列表顶端
if (channelListRef.current) {
channelListRef.current.scrollTo({
top: 0,
behavior: 'smooth'
});
// 如果当前选中的频道在新的分组中,自动滚动到频道位置
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 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);
}
};
@@ -514,6 +694,37 @@ function LivePageClient() {
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;
@@ -1038,7 +1249,7 @@ function LivePageClient() {
{/* 不支持的直播类型提示 */}
{unsupportedType && (
<div className='absolute inset-0 bg-black/90 backdrop-blur-sm rounded-xl flex items-center justify-center z-[600] transition-all duration-300'>
<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'>
@@ -1048,13 +1259,13 @@ function LivePageClient() {
</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>
<span className='text-white font-bold'>{unsupportedType.toUpperCase()}</span>
</p>
<p className='text-orange-200 text-sm mt-2'>
<p className='text-sm text-orange-200 mt-2'>
M3U8
</p>
</div>
@@ -1068,7 +1279,7 @@ function LivePageClient() {
{/* 视频加载蒙层 */}
{isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
<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'>
@@ -1163,6 +1374,7 @@ function LivePageClient() {
{Object.keys(groupedChannels).map((group, index) => (
<button
key={group}
data-group={group}
ref={(el) => {
groupButtonRefs.current[index] = el;
}}
@@ -1197,6 +1409,7 @@ function LivePageClient() {
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
@@ -1328,9 +1541,21 @@ function LivePageClient() {
)}
</div>
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
{currentChannel.name}
</h3>
<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>
@@ -1352,6 +1577,31 @@ function LivePageClient() {
);
}
// 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>}>