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 { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
const [configContent, setConfigContent] = useState('');
|
const [configContent, setConfigContent] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [autoUpdate, setAutoUpdate] = useState(false);
|
const [autoUpdate, setAutoUpdate] = useState(false);
|
||||||
const [fetching, setFetching] = useState(false);
|
|
||||||
const [lastCheckTime, setLastCheckTime] = useState<string>('');
|
const [lastCheckTime, setLastCheckTime] = useState<string>('');
|
||||||
|
|
||||||
|
|
||||||
@@ -3396,8 +3394,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
DisableYellowFilter: false,
|
DisableYellowFilter: false,
|
||||||
FluidSearch: true,
|
FluidSearch: true,
|
||||||
});
|
});
|
||||||
// 保存状态
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// 豆瓣数据源相关状态
|
// 豆瓣数据源相关状态
|
||||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||||
|
|||||||
@@ -519,7 +519,10 @@ async function refreshRecordAndFavorites() {
|
|||||||
|
|
||||||
// 收藏
|
// 收藏
|
||||||
try {
|
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;
|
const totalFavorites = Object.keys(favorites).length;
|
||||||
let processedFavorites = 0;
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||||
|
|
||||||
|
let response: Response | null = null;
|
||||||
|
let responseUsed = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
|
||||||
const response = await fetch(decodedUrl, {
|
response = await fetch(decodedUrl, {
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -45,6 +48,7 @@ export async function GET(request: Request) {
|
|||||||
// 获取最终的响应URL(处理重定向后的URL)
|
// 获取最终的响应URL(处理重定向后的URL)
|
||||||
const finalUrl = response.url;
|
const finalUrl = response.url;
|
||||||
const m3u8Content = await response.text();
|
const m3u8Content = await response.text();
|
||||||
|
responseUsed = true; // 标记 response 已被使用
|
||||||
|
|
||||||
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
|
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
|
||||||
const baseUrl = getBaseUrl(finalUrl);
|
const baseUrl = getBaseUrl(finalUrl);
|
||||||
@@ -77,6 +81,16 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
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';
|
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||||
|
|
||||||
|
let response: Response | null = null;
|
||||||
|
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
const response = await fetch(decodedUrl, {
|
response = await fetch(decodedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': ua,
|
'User-Agent': ua,
|
||||||
},
|
},
|
||||||
@@ -43,8 +46,97 @@ export async function GET(request: Request) {
|
|||||||
if (contentLength) {
|
if (contentLength) {
|
||||||
headers.set('Content-Length', 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) {
|
} 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 });
|
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+277
-27
@@ -4,9 +4,17 @@
|
|||||||
|
|
||||||
import Artplayer from 'artplayer';
|
import Artplayer from 'artplayer';
|
||||||
import Hls from 'hls.js';
|
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 { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteFavorite,
|
||||||
|
generateStorageKey,
|
||||||
|
isFavorited as checkIsFavorited,
|
||||||
|
saveFavorite,
|
||||||
|
subscribeToDataUpdates,
|
||||||
|
} from '@/lib/db.client';
|
||||||
import { parseCustomTimeFormat } from '@/lib/time';
|
import { parseCustomTimeFormat } from '@/lib/time';
|
||||||
|
|
||||||
import EpgScrollableRow from '@/components/EpgScrollableRow';
|
import EpgScrollableRow from '@/components/EpgScrollableRow';
|
||||||
@@ -52,6 +60,9 @@ function LivePageClient() {
|
|||||||
const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...');
|
const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 直播源相关
|
// 直播源相关
|
||||||
const [liveSources, setLiveSources] = useState<LiveSource[]>([]);
|
const [liveSources, setLiveSources] = useState<LiveSource[]>([]);
|
||||||
const [currentSource, setCurrentSource] = useState<LiveSource | null>(null);
|
const [currentSource, setCurrentSource] = useState<LiveSource | null>(null);
|
||||||
@@ -63,6 +74,12 @@ function LivePageClient() {
|
|||||||
// 频道相关
|
// 频道相关
|
||||||
const [currentChannels, setCurrentChannels] = useState<LiveChannel[]>([]);
|
const [currentChannels, setCurrentChannels] = useState<LiveChannel[]>([]);
|
||||||
const [currentChannel, setCurrentChannel] = useState<LiveChannel | null>(null);
|
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('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
@@ -100,6 +117,11 @@ function LivePageClient() {
|
|||||||
// EPG 数据加载状态
|
// EPG 数据加载状态
|
||||||
const [isEpgLoading, setIsEpgLoading] = useState(false);
|
const [isEpgLoading, setIsEpgLoading] = useState(false);
|
||||||
|
|
||||||
|
// 收藏状态
|
||||||
|
const [favorited, setFavorited] = useState(false);
|
||||||
|
const favoritedRef = useRef(false);
|
||||||
|
const currentChannelRef = useRef<LiveChannel | null>(null);
|
||||||
|
|
||||||
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
|
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
|
||||||
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
|
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
|
||||||
if (!programs || programs.length === 0) return programs;
|
if (!programs || programs.length === 0) return programs;
|
||||||
@@ -134,8 +156,6 @@ function LivePageClient() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cleanedPrograms: Array<{ start: string; end: string; title: string }> = [];
|
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++) {
|
for (let i = 0; i < sortedPrograms.length; i++) {
|
||||||
const currentProgram = sortedPrograms[i];
|
const currentProgram = sortedPrograms[i];
|
||||||
@@ -183,8 +203,6 @@ function LivePageClient() {
|
|||||||
// 如果当前节目时间更短,则替换已存在的节目
|
// 如果当前节目时间更短,则替换已存在的节目
|
||||||
if (currentDuration < existingDuration) {
|
if (currentDuration < existingDuration) {
|
||||||
cleanedPrograms[j] = currentProgram;
|
cleanedPrograms[j] = currentProgram;
|
||||||
} else {
|
|
||||||
removedCount++;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -231,8 +249,19 @@ function LivePageClient() {
|
|||||||
if (sources.length > 0) {
|
if (sources.length > 0) {
|
||||||
// 默认选中第一个源
|
// 默认选中第一个源
|
||||||
const firstSource = sources[0];
|
const firstSource = sources[0];
|
||||||
setCurrentSource(firstSource);
|
if (needLoadSource) {
|
||||||
await fetchChannels(firstSource);
|
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');
|
setLoadingStage('ready');
|
||||||
@@ -246,6 +275,17 @@ function LivePageClient() {
|
|||||||
// 不设置错误,而是显示空状态
|
// 不设置错误,而是显示空状态
|
||||||
setLiveSources([]);
|
setLiveSources([]);
|
||||||
setLoading(false);
|
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) {
|
if (channels.length > 0) {
|
||||||
setCurrentChannel(channels[0]);
|
if (needLoadChannel) {
|
||||||
setVideoUrl(channels[0].url);
|
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);
|
setGroupedChannels(grouped);
|
||||||
|
|
||||||
// 默认选中第一个分组
|
// 默认选中当前加载的channel所在的分组,如果没有则选中第一个分组
|
||||||
const firstGroup = Object.keys(grouped)[0] || '';
|
let targetGroup = '';
|
||||||
setSelectedGroup(firstGroup);
|
if (needLoadChannel) {
|
||||||
setFilteredChannels(firstGroup ? grouped[firstGroup] : channels);
|
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);
|
setIsVideoLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -386,6 +464,11 @@ function LivePageClient() {
|
|||||||
setCurrentChannel(channel);
|
setCurrentChannel(channel);
|
||||||
setVideoUrl(channel.url);
|
setVideoUrl(channel.url);
|
||||||
|
|
||||||
|
// 自动滚动到选中的频道位置
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToChannel(channel);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// 获取节目单信息
|
// 获取节目单信息
|
||||||
if (channel.tvgId && currentSource) {
|
if (channel.tvgId && currentSource) {
|
||||||
try {
|
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 = () => {
|
const cleanupPlayer = () => {
|
||||||
// 重置不支持的类型状态
|
// 重置不支持的类型状态
|
||||||
@@ -500,12 +632,60 @@ function LivePageClient() {
|
|||||||
const filtered = currentChannels.filter(channel => channel.group === group);
|
const filtered = currentChannels.filter(channel => channel.group === group);
|
||||||
setFilteredChannels(filtered);
|
setFilteredChannels(filtered);
|
||||||
|
|
||||||
// 滚动到频道列表顶端
|
// 如果当前选中的频道在新的分组中,自动滚动到该频道位置
|
||||||
if (channelListRef.current) {
|
if (currentChannel && filtered.some(channel => channel.id === currentChannel.id)) {
|
||||||
channelListRef.current.scrollTo({
|
setTimeout(() => {
|
||||||
top: 0,
|
scrollToChannel(currentChannel);
|
||||||
behavior: 'smooth'
|
}, 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();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedGroup || !groupContainerRef.current) return;
|
if (!selectedGroup || !groupContainerRef.current) return;
|
||||||
@@ -1038,7 +1249,7 @@ function LivePageClient() {
|
|||||||
|
|
||||||
{/* 不支持的直播类型提示 */}
|
{/* 不支持的直播类型提示 */}
|
||||||
{unsupportedType && (
|
{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='text-center max-w-md mx-auto px-6'>
|
||||||
<div className='relative mb-8'>
|
<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'>
|
<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>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<h3 className='text-xl font-semibold text-white'>
|
<h3 className='text-xl font-semibold text-white'>
|
||||||
暂不支持的直播类型
|
暂不支持的直播流类型
|
||||||
</h3>
|
</h3>
|
||||||
<div className='bg-orange-500/20 border border-orange-500/30 rounded-lg p-4'>
|
<div className='bg-orange-500/20 border border-orange-500/30 rounded-lg p-4'>
|
||||||
<p className='text-orange-300 font-medium'>
|
<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>
|
||||||
<p className='text-orange-200 text-sm mt-2'>
|
<p className='text-sm text-orange-200 mt-2'>
|
||||||
目前仅支持 M3U8 格式的直播流
|
目前仅支持 M3U8 格式的直播流
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1068,7 +1279,7 @@ function LivePageClient() {
|
|||||||
|
|
||||||
{/* 视频加载蒙层 */}
|
{/* 视频加载蒙层 */}
|
||||||
{isVideoLoading && (
|
{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='text-center max-w-md mx-auto px-6'>
|
||||||
<div className='relative mb-8'>
|
<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'>
|
<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) => (
|
{Object.keys(groupedChannels).map((group, index) => (
|
||||||
<button
|
<button
|
||||||
key={group}
|
key={group}
|
||||||
|
data-group={group}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
groupButtonRefs.current[index] = el;
|
groupButtonRefs.current[index] = el;
|
||||||
}}
|
}}
|
||||||
@@ -1197,6 +1409,7 @@ function LivePageClient() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
|
data-channel-id={channel.id}
|
||||||
onClick={() => handleChannelChange(channel)}
|
onClick={() => handleChannelChange(channel)}
|
||||||
disabled={isSwitchingSource}
|
disabled={isSwitchingSource}
|
||||||
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
|
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
|
||||||
@@ -1328,9 +1541,21 @@ function LivePageClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex-1 min-w-0'>
|
||||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
<div className='flex items-center gap-3'>
|
||||||
{currentChannel.name}
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||||
</h3>
|
{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'>
|
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
|
||||||
{currentSource?.name} {' > '} {currentChannel.group}
|
{currentSource?.name} {' > '} {currentChannel.group}
|
||||||
</p>
|
</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() {
|
export default function LivePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function HomeClient() {
|
|||||||
source_name: string;
|
source_name: string;
|
||||||
currentEpisode?: number;
|
currentEpisode?: number;
|
||||||
search_title?: string;
|
search_title?: string;
|
||||||
|
origin?: 'vod' | 'live';
|
||||||
};
|
};
|
||||||
|
|
||||||
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
|
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
|
||||||
@@ -133,6 +134,7 @@ function HomeClient() {
|
|||||||
source_name: fav.source_name,
|
source_name: fav.source_name,
|
||||||
currentEpisode,
|
currentEpisode,
|
||||||
search_title: fav?.search_title,
|
search_title: fav?.search_title,
|
||||||
|
origin: fav?.origin,
|
||||||
} as FavoriteItem;
|
} as FavoriteItem;
|
||||||
});
|
});
|
||||||
setFavoriteItems(sorted);
|
setFavoriteItems(sorted);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { X } from 'lucide-react';
|
import { Radio, X } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ interface MobileActionSheetProps {
|
|||||||
sourceName?: string; // 播放源名称
|
sourceName?: string; // 播放源名称
|
||||||
currentEpisode?: number; // 当前集数
|
currentEpisode?: number; // 当前集数
|
||||||
totalEpisodes?: number; // 总集数
|
totalEpisodes?: number; // 总集数
|
||||||
|
origin?: 'vod' | 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||||
@@ -35,6 +36,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||||||
sourceName,
|
sourceName,
|
||||||
currentEpisode,
|
currentEpisode,
|
||||||
totalEpisodes,
|
totalEpisodes,
|
||||||
|
origin = 'vod',
|
||||||
}) => {
|
}) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
@@ -217,7 +219,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||||||
src={poster}
|
src={poster}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className={origin === 'live' ? 'object-contain' : 'object-cover'}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,6 +231,9 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||||||
</h3>
|
</h3>
|
||||||
{sourceName && (
|
{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">
|
<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}
|
{sourceName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
/* 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 Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, {
|
import React, {
|
||||||
@@ -46,6 +46,7 @@ export interface VideoCardProps {
|
|||||||
type?: string;
|
type?: string;
|
||||||
isBangumi?: boolean;
|
isBangumi?: boolean;
|
||||||
isAggregate?: boolean;
|
isAggregate?: boolean;
|
||||||
|
origin?: 'vod' | 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VideoCardHandle = {
|
export type VideoCardHandle = {
|
||||||
@@ -74,6 +75,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
type = '',
|
type = '',
|
||||||
isBangumi = false,
|
isBangumi = false,
|
||||||
isAggregate = false,
|
isAggregate = false,
|
||||||
|
origin = 'vod',
|
||||||
}: VideoCardProps,
|
}: VideoCardProps,
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -221,7 +223,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
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}` : ''
|
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
|
||||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
||||||
router.push(url);
|
router.push(url);
|
||||||
@@ -234,6 +240,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
origin,
|
||||||
from,
|
from,
|
||||||
actualSource,
|
actualSource,
|
||||||
actualId,
|
actualId,
|
||||||
@@ -247,9 +254,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 新标签页播放处理函数
|
// 新标签页播放处理函数
|
||||||
const handlePlayInNewTab = useCallback(() => {
|
const handlePlayInNewTab = useCallback(() => {
|
||||||
if (from === 'douban' || (isAggregate && !actualSource && !actualId)) {
|
if (origin === 'live' && actualSource && actualId) {
|
||||||
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
|
// 直播内容跳转到直播页面
|
||||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
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');
|
window.open(url, '_blank');
|
||||||
} else if (actualSource && actualId) {
|
} else if (actualSource && actualId) {
|
||||||
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||||
@@ -260,6 +270,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
origin,
|
||||||
from,
|
from,
|
||||||
actualSource,
|
actualSource,
|
||||||
actualId,
|
actualId,
|
||||||
@@ -356,7 +367,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
if (config.showPlayButton) {
|
if (config.showPlayButton) {
|
||||||
actions.push({
|
actions.push({
|
||||||
id: 'play',
|
id: 'play',
|
||||||
label: '播放',
|
label: origin === 'live' ? '观看直播' : '播放',
|
||||||
icon: <PlayCircleIcon size={20} />,
|
icon: <PlayCircleIcon size={20} />,
|
||||||
onClick: handleClick,
|
onClick: handleClick,
|
||||||
color: 'primary' as const,
|
color: 'primary' as const,
|
||||||
@@ -365,7 +376,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
// 新标签页播放
|
// 新标签页播放
|
||||||
actions.push({
|
actions.push({
|
||||||
id: 'play-new-tab',
|
id: 'play-new-tab',
|
||||||
label: '新标签页播放',
|
label: origin === 'live' ? '新标签页观看' : '新标签页播放',
|
||||||
icon: <ExternalLink size={20} />,
|
icon: <ExternalLink size={20} />,
|
||||||
onClick: handlePlayInNewTab,
|
onClick: handlePlayInNewTab,
|
||||||
color: 'default' as const,
|
color: 'default' as const,
|
||||||
@@ -521,7 +532,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
{/* 海报容器 */}
|
{/* 海报容器 */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -539,7 +550,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
src={processImageUrl(actualPoster)}
|
src={processImageUrl(actualPoster)}
|
||||||
alt={actualTitle}
|
alt={actualTitle}
|
||||||
fill
|
fill
|
||||||
className='object-cover'
|
className={origin === 'live' ? 'object-contain' : 'object-cover'}
|
||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
onLoadingComplete={() => setIsLoading(true)}
|
onLoadingComplete={() => setIsLoading(true)}
|
||||||
@@ -1004,6 +1015,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{origin === 'live' && (
|
||||||
|
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
|
||||||
|
)}
|
||||||
{source_name}
|
{source_name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1023,6 +1037,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
sourceName={source_name}
|
sourceName={source_name}
|
||||||
currentEpisode={currentEpisode}
|
currentEpisode={currentEpisode}
|
||||||
totalEpisodes={actualEpisodes}
|
totalEpisodes={actualEpisodes}
|
||||||
|
origin={origin}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface Favorite {
|
|||||||
total_episodes: number;
|
total_episodes: number;
|
||||||
save_time: number;
|
save_time: number;
|
||||||
search_title?: string;
|
search_title?: string;
|
||||||
|
origin?: 'vod' | 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 缓存数据结构 ----
|
// ---- 缓存数据结构 ----
|
||||||
@@ -1357,7 +1358,7 @@ export function subscribeToDataUpdates<T>(
|
|||||||
callback: (data: T) => void
|
callback: (data: T) => void
|
||||||
): () => void {
|
): () => void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return () => {};
|
return () => { };
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = (event: CustomEvent) => {
|
const handleUpdate = (event: CustomEvent) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Favorite {
|
|||||||
cover: string;
|
cover: string;
|
||||||
save_time: number; // 记录保存时间(时间戳)
|
save_time: number; // 记录保存时间(时间戳)
|
||||||
search_title: string; // 搜索时使用的标题
|
search_title: string; // 搜索时使用的标题
|
||||||
|
origin?: 'vod' | 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储接口
|
// 存储接口
|
||||||
|
|||||||
Reference in New Issue
Block a user