feat: support save live channel to favorite
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface Favorite {
|
||||
cover: string;
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title: string; // 搜索时使用的标题
|
||||
origin?: 'vod' | 'live';
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
|
||||
Reference in New Issue
Block a user