From 15b20603b8185e8abfce21d650ba8e06e2e74dad Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 26 Aug 2025 16:36:44 +0800 Subject: [PATCH] feat: support save live channel to favorite --- src/app/admin/page.tsx | 4 - src/app/api/cron/route.ts | 5 +- src/app/api/live/precheck/route.ts | 5 +- src/app/api/proxy/logo/route.ts | 5 +- src/app/api/proxy/m3u8/route.ts | 16 +- src/app/api/proxy/segment/route.ts | 96 ++++++++- src/app/live/page.tsx | 304 ++++++++++++++++++++++++--- src/app/page.tsx | 2 + src/components/MobileActionSheet.tsx | 9 +- src/components/VideoCard.tsx | 33 ++- src/lib/db.client.ts | 3 +- src/lib/types.ts | 1 + 12 files changed, 434 insertions(+), 49 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f460489..a0d7342 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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(''); @@ -3396,8 +3394,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | DisableYellowFilter: false, FluidSearch: true, }); - // 保存状态 - const [saving, setSaving] = useState(false); // 豆瓣数据源相关状态 const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false); diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 374b2ee..62605e5 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -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; diff --git a/src/app/api/live/precheck/route.ts b/src/app/api/live/precheck/route.ts index ba37532..7d24cc5 100644 --- a/src/app/api/live/precheck/route.ts +++ b/src/app/api/live/precheck/route.ts @@ -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) { diff --git a/src/app/api/proxy/logo/route.ts b/src/app/api/proxy/logo/route.ts index 7c6a65f..644b041 100644 --- a/src/app/api/proxy/logo/route.ts +++ b/src/app/api/proxy/logo/route.ts @@ -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) { diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts index 93fff2b..0076227 100644 --- a/src/app/api/proxy/m3u8/route.ts +++ b/src/app/api/proxy/m3u8/route.ts @@ -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); + } + } } } diff --git a/src/app/api/proxy/segment/route.ts b/src/app/api/proxy/segment/route.ts index 76d3075..a21ef7b 100644 --- a/src/app/api/proxy/segment/route.ts +++ b/src/app/api/proxy/segment/route.ts @@ -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 | 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).releaseLock(); + } catch (e) { + // 忽略错误 + } + } + + if (response?.body) { + try { + response.body.cancel(); + } catch (e) { + // 忽略错误 + } + } + return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 }); } } \ No newline at end of file diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx index 0abc48d..16c4f7d 100644 --- a/src/app/live/page.tsx +++ b/src/app/live/page.tsx @@ -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(null); + const searchParams = useSearchParams(); + const router = useRouter(); + // 直播源相关 const [liveSources, setLiveSources] = useState([]); const [currentSource, setCurrentSource] = useState(null); @@ -63,6 +74,12 @@ function LivePageClient() { // 频道相关 const [currentChannels, setCurrentChannels] = useState([]); const [currentChannel, setCurrentChannel] = useState(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(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) => { + 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 && ( -
+
@@ -1048,13 +1259,13 @@ function LivePageClient() {

- 暂不支持的直播类型 + 暂不支持的直播流类型

- 当前直播源类型:{unsupportedType.toUpperCase()} + 当前频道直播流类型:{unsupportedType.toUpperCase()}

-

+

目前仅支持 M3U8 格式的直播流

@@ -1068,7 +1279,7 @@ function LivePageClient() { {/* 视频加载蒙层 */} {isVideoLoading && ( -
+
@@ -1163,6 +1374,7 @@ function LivePageClient() { {Object.keys(groupedChannels).map((group, index) => (
-

- {currentChannel.name} -

+
+

+ {currentChannel.name} +

+ +

{currentSource?.name} {' > '} {currentChannel.group}

@@ -1352,6 +1577,31 @@ function LivePageClient() { ); } +// FavoriteIcon 组件 +const FavoriteIcon = ({ filled }: { filled: boolean }) => { + if (filled) { + return ( + + + + ); + } + return ( + + ); +}; + export default function LivePage() { return ( Loading...
}> diff --git a/src/app/page.tsx b/src/app/page.tsx index 955440b..f364e69 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -62,6 +62,7 @@ function HomeClient() { source_name: string; currentEpisode?: number; search_title?: string; + origin?: 'vod' | 'live'; }; const [favoriteItems, setFavoriteItems] = useState([]); @@ -133,6 +134,7 @@ function HomeClient() { source_name: fav.source_name, currentEpisode, search_title: fav?.search_title, + origin: fav?.origin, } as FavoriteItem; }); setFavoriteItems(sorted); diff --git a/src/components/MobileActionSheet.tsx b/src/components/MobileActionSheet.tsx index 4f4b75a..bc83f5f 100644 --- a/src/components/MobileActionSheet.tsx +++ b/src/components/MobileActionSheet.tsx @@ -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 = ({ @@ -35,6 +36,7 @@ const MobileActionSheet: React.FC = ({ sourceName, currentEpisode, totalEpisodes, + origin = 'vod', }) => { const [isVisible, setIsVisible] = useState(false); const [isAnimating, setIsAnimating] = useState(false); @@ -217,7 +219,7 @@ const MobileActionSheet: React.FC = ({ src={poster} alt={title} fill - className="object-cover" + className={origin === 'live' ? 'object-contain' : 'object-cover'} loading="lazy" />
@@ -229,6 +231,9 @@ const MobileActionSheet: React.FC = ({ {sourceName && ( + {origin === 'live' && ( + + )} {sourceName} )} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 1376745..fcbc77c 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -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(function VideoCard type = '', isBangumi = false, isAggregate = false, + origin = 'vod', }: VideoCardProps, ref ) { @@ -221,7 +223,11 @@ const VideoCard = forwardRef(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(function VideoCard router.push(url); } }, [ + origin, from, actualSource, actualId, @@ -247,9 +254,12 @@ const VideoCard = forwardRef(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(function VideoCard window.open(url, '_blank'); } }, [ + origin, from, actualSource, actualId, @@ -356,7 +367,7 @@ const VideoCard = forwardRef(function VideoCard if (config.showPlayButton) { actions.push({ id: 'play', - label: '播放', + label: origin === 'live' ? '观看直播' : '播放', icon: , onClick: handleClick, color: 'primary' as const, @@ -365,7 +376,7 @@ const VideoCard = forwardRef(function VideoCard // 新标签页播放 actions.push({ id: 'play-new-tab', - label: '新标签页播放', + label: origin === 'live' ? '新标签页观看' : '新标签页播放', icon: , onClick: handlePlayInNewTab, color: 'default' as const, @@ -521,7 +532,7 @@ const VideoCard = forwardRef(function VideoCard > {/* 海报容器 */}
(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(function VideoCard return false; }} > + {origin === 'live' && ( + + )} {source_name} @@ -1023,6 +1037,7 @@ const VideoCard = forwardRef(function VideoCard sourceName={source_name} currentEpisode={currentEpisode} totalEpisodes={actualEpisodes} + origin={origin} /> ); diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index b57c6e8..d5059e3 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -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( callback: (data: T) => void ): () => void { if (typeof window === 'undefined') { - return () => {}; + return () => { }; } const handleUpdate = (event: CustomEvent) => { diff --git a/src/lib/types.ts b/src/lib/types.ts index 357691d..329b275 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -23,6 +23,7 @@ export interface Favorite { cover: string; save_time: number; // 记录保存时间(时间戳) search_title: string; // 搜索时使用的标题 + origin?: 'vod' | 'live'; } // 存储接口