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

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);

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;

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) {

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) {

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);
}
}
}
}

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 });
}
}

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>}>

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);

View File

@@ -1,4 +1,4 @@
import { X } from 'lucide-react';
import { Radio, X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
@@ -22,6 +22,7 @@ interface MobileActionSheetProps {
sourceName?: string; // 播放源名称
currentEpisode?: number; // 当前集数
totalEpisodes?: number; // 总集数
origin?: 'vod' | 'live';
}
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
@@ -35,6 +36,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
sourceName,
currentEpisode,
totalEpisodes,
origin = 'vod',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
@@ -217,7 +219,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
src={poster}
alt={title}
fill
className="object-cover"
className={origin === 'live' ? 'object-contain' : 'object-cover'}
loading="lazy"
/>
</div>
@@ -229,6 +231,9 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
</h3>
{sourceName && (
<span className="flex-shrink-0 text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
{origin === 'live' && (
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
)}
{sourceName}
</span>
)}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
import { ExternalLink, Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import React, {
@@ -46,6 +46,7 @@ export interface VideoCardProps {
type?: string;
isBangumi?: boolean;
isAggregate?: boolean;
origin?: 'vod' | 'live';
}
export type VideoCardHandle = {
@@ -74,6 +75,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
type = '',
isBangumi = false,
isAggregate = false,
origin = 'vod',
}: VideoCardProps,
ref
) {
@@ -221,7 +223,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
);
const handleClick = useCallback(() => {
if (from === 'douban' || (isAggregate && !actualSource && !actualId)) {
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
router.push(url);
} else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) {
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
router.push(url);
@@ -234,6 +240,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
router.push(url);
}
}, [
origin,
from,
actualSource,
actualId,
@@ -247,9 +254,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 新标签页播放处理函数
const handlePlayInNewTab = useCallback(() => {
if (from === 'douban' || (isAggregate && !actualSource && !actualId)) {
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
window.open(url, '_blank');
} else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) {
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
window.open(url, '_blank');
} else if (actualSource && actualId) {
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
@@ -260,6 +270,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
window.open(url, '_blank');
}
}, [
origin,
from,
actualSource,
actualId,
@@ -356,7 +367,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
if (config.showPlayButton) {
actions.push({
id: 'play',
label: '播放',
label: origin === 'live' ? '观看直播' : '播放',
icon: <PlayCircleIcon size={20} />,
onClick: handleClick,
color: 'primary' as const,
@@ -365,7 +376,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 新标签页播放
actions.push({
id: 'play-new-tab',
label: '新标签页播放',
label: origin === 'live' ? '新标签页观看' : '新标签页播放',
icon: <ExternalLink size={20} />,
onClick: handlePlayInNewTab,
color: 'default' as const,
@@ -521,7 +532,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
>
{/* 海报容器 */}
<div
className='relative aspect-[2/3] overflow-hidden rounded-lg'
className={`relative aspect-[2/3] overflow-hidden rounded-lg ${origin === 'live' ? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80' : ''}`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@@ -539,7 +550,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className='object-cover'
className={origin === 'live' ? 'object-contain' : 'object-cover'}
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
@@ -1004,6 +1015,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
{origin === 'live' && (
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
)}
{source_name}
</span>
</span>
@@ -1023,6 +1037,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
sourceName={source_name}
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
origin={origin}
/>
</>
);

View File

@@ -51,6 +51,7 @@ export interface Favorite {
total_episodes: number;
save_time: number;
search_title?: string;
origin?: 'vod' | 'live';
}
// ---- 缓存数据结构 ----
@@ -1357,7 +1358,7 @@ export function subscribeToDataUpdates<T>(
callback: (data: T) => void
): () => void {
if (typeof window === 'undefined') {
return () => {};
return () => { };
}
const handleUpdate = (event: CustomEvent) => {

View File

@@ -23,6 +23,7 @@ export interface Favorite {
cover: string;
save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题
origin?: 'vod' | 'live';
}
// 存储接口