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
-4
View File
@@ -3151,10 +3151,8 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [configContent, setConfigContent] = useState('');
const [saving, setSaving] = useState(false);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [autoUpdate, setAutoUpdate] = useState(false);
const [fetching, setFetching] = useState(false);
const [lastCheckTime, setLastCheckTime] = useState<string>('');
@@ -3396,8 +3394,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
DisableYellowFilter: false,
FluidSearch: true,
});
// 保存状态
const [saving, setSaving] = useState(false);
// 豆瓣数据源相关状态
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
+4 -1
View File
@@ -519,7 +519,10 @@ async function refreshRecordAndFavorites() {
// 收藏
try {
const favorites = await db.getAllFavorites(user);
let favorites = await db.getAllFavorites(user);
favorites = Object.fromEntries(
Object.entries(favorites).filter(([_, fav]) => fav.origin !== 'live')
);
const totalFavorites = Object.keys(favorites).length;
let processedFavorites = 0;
+4 -1
View File
@@ -1,6 +1,9 @@
import { getConfig } from '@/lib/config';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
+4 -1
View File
@@ -1,6 +1,9 @@
import { getConfig } from '@/lib/config';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: Request) {
+15 -1
View File
@@ -23,10 +23,13 @@ export async function GET(request: Request) {
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
let response: Response | null = null;
let responseUsed = false;
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
response = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
credentials: 'same-origin',
@@ -45,6 +48,7 @@ export async function GET(request: Request) {
// 获取最终的响应URL(处理重定向后的URL)
const finalUrl = response.url;
const m3u8Content = await response.text();
responseUsed = true; // 标记 response 已被使用
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
const baseUrl = getBaseUrl(finalUrl);
@@ -77,6 +81,16 @@ export async function GET(request: Request) {
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
} finally {
// 确保 response 被正确关闭以释放资源
if (response && !responseUsed) {
try {
response.body?.cancel();
} catch (error) {
// 忽略关闭时的错误
console.warn('Failed to close response body:', error);
}
}
}
}
+94 -2
View File
@@ -21,9 +21,12 @@ export async function GET(request: Request) {
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
let response: Response | null = null;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
@@ -43,8 +46,97 @@ export async function GET(request: Request) {
if (contentLength) {
headers.set('Content-Length', contentLength);
}
return new Response(response.body, { headers });
// 使用流式传输,避免占用内存
const stream = new ReadableStream({
start(controller) {
if (!response?.body) {
controller.close();
return;
}
reader = response.body.getReader();
const isCancelled = false;
function pump() {
if (isCancelled || !reader) {
return;
}
reader.read().then(({ done, value }) => {
if (isCancelled) {
return;
}
if (done) {
controller.close();
cleanup();
return;
}
controller.enqueue(value);
pump();
}).catch((error) => {
if (!isCancelled) {
controller.error(error);
cleanup();
}
});
}
function cleanup() {
if (reader) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
}
}
pump();
},
cancel() {
// 当流被取消时,确保释放所有资源
if (reader) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略取消时的错误
}
}
}
});
return new Response(stream, { headers });
} catch (error) {
// 确保在错误情况下也释放资源
if (reader) {
try {
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
} catch (e) {
// 忽略错误
}
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略错误
}
}
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
}
+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>}>
+2
View File
@@ -62,6 +62,7 @@ function HomeClient() {
source_name: string;
currentEpisode?: number;
search_title?: string;
origin?: 'vod' | 'live';
};
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
@@ -133,6 +134,7 @@ function HomeClient() {
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
origin: fav?.origin,
} as FavoriteItem;
});
setFavoriteItems(sorted);