mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-14 20:04:43 +08:00
feat: add epg info
This commit is contained in:
52
src/app/api/live/epg/route.ts
Normal file
52
src/app/api/live/epg/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCachedLiveChannels } from '@/lib/live';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceKey = searchParams.get('source');
|
||||
const tvgId = searchParams.get('tvgId');
|
||||
|
||||
if (!sourceKey) {
|
||||
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!tvgId) {
|
||||
return NextResponse.json({ error: '缺少频道tvg-id参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const channelData = await getCachedLiveChannels(sourceKey);
|
||||
|
||||
if (!channelData) {
|
||||
// 频道信息未找到时返回空的节目单数据
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tvgId,
|
||||
source: sourceKey,
|
||||
epgUrl: '',
|
||||
programs: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 从epgs字段中获取对应tvgId的节目单信息
|
||||
const epgData = channelData.epgs[tvgId] || [];
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tvgId,
|
||||
source: sourceKey,
|
||||
epgUrl: channelData.epgUrl,
|
||||
programs: epgData
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取节目单信息失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,18 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
|
||||
const protocol = req.headers.get('x-forwarded-proto') || 'http';
|
||||
// 从 referer 头提取协议信息
|
||||
const referer = req.headers.get('referer');
|
||||
let protocol = 'http';
|
||||
if (referer) {
|
||||
try {
|
||||
const refererUrl = new URL(referer);
|
||||
protocol = refererUrl.protocol.replace(':', '');
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const host = req.headers.get('host');
|
||||
const proxyBase = `${protocol}://${host}/api/proxy`;
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Radio, Tv } from 'lucide-react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
import { parseCustomTimeFormat } from '@/lib/time';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import EpgScrollableRow from '@/components/EpgScrollableRow';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
declare global {
|
||||
@@ -83,6 +85,133 @@ function LivePageClient() {
|
||||
// 过滤后的频道列表
|
||||
const [filteredChannels, setFilteredChannels] = useState<LiveChannel[]>([]);
|
||||
|
||||
// 节目单信息
|
||||
const [epgData, setEpgData] = useState<{
|
||||
tvgId: string;
|
||||
source: string;
|
||||
epgUrl: string;
|
||||
programs: Array<{
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
}>;
|
||||
} | null>(null);
|
||||
|
||||
// EPG 数据加载状态
|
||||
const [isEpgLoading, setIsEpgLoading] = useState(false);
|
||||
|
||||
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
|
||||
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
|
||||
if (!programs || programs.length === 0) return programs;
|
||||
|
||||
console.log(`开始清洗EPG数据,原始节目数量: ${programs.length}`);
|
||||
|
||||
// 获取今日日期(只考虑年月日,忽略时间)
|
||||
const today = new Date();
|
||||
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
||||
|
||||
// 首先过滤出今日的节目(包括跨天节目)
|
||||
const todayPrograms = programs.filter(program => {
|
||||
const programStart = parseCustomTimeFormat(program.start);
|
||||
const programEnd = parseCustomTimeFormat(program.end);
|
||||
|
||||
// 获取节目的日期范围
|
||||
const programStartDate = new Date(programStart.getFullYear(), programStart.getMonth(), programStart.getDate());
|
||||
const programEndDate = new Date(programEnd.getFullYear(), programEnd.getMonth(), programEnd.getDate());
|
||||
|
||||
// 如果节目的开始时间或结束时间在今天,或者节目跨越今天,都算作今天的节目
|
||||
return (
|
||||
(programStartDate >= todayStart && programStartDate < todayEnd) || // 开始时间在今天
|
||||
(programEndDate >= todayStart && programEndDate < todayEnd) || // 结束时间在今天
|
||||
(programStartDate < todayStart && programEndDate >= todayEnd) // 节目跨越今天(跨天节目)
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`过滤今日节目后数量: ${todayPrograms.length}`);
|
||||
|
||||
// 按开始时间排序
|
||||
const sortedPrograms = [...todayPrograms].sort((a, b) => {
|
||||
const startA = parseCustomTimeFormat(a.start).getTime();
|
||||
const startB = parseCustomTimeFormat(b.start).getTime();
|
||||
return startA - startB;
|
||||
});
|
||||
|
||||
const cleanedPrograms: Array<{ start: string; end: string; title: string }> = [];
|
||||
let removedCount = 0;
|
||||
let dateFilteredCount = programs.length - todayPrograms.length;
|
||||
|
||||
for (let i = 0; i < sortedPrograms.length; i++) {
|
||||
const currentProgram = sortedPrograms[i];
|
||||
const currentStart = parseCustomTimeFormat(currentProgram.start);
|
||||
const currentEnd = parseCustomTimeFormat(currentProgram.end);
|
||||
|
||||
// 检查是否与已添加的节目重叠
|
||||
let hasOverlap = false;
|
||||
|
||||
for (const existingProgram of cleanedPrograms) {
|
||||
const existingStart = parseCustomTimeFormat(existingProgram.start);
|
||||
const existingEnd = parseCustomTimeFormat(existingProgram.end);
|
||||
|
||||
// 检查时间重叠(只考虑时间部分,忽略日期)
|
||||
const currentTime = currentStart.getHours() * 60 + currentStart.getMinutes();
|
||||
const currentEndTime = currentEnd.getHours() * 60 + currentEnd.getMinutes();
|
||||
const existingTime = existingStart.getHours() * 60 + existingStart.getMinutes();
|
||||
const existingEndTime = existingEnd.getHours() * 60 + existingEnd.getMinutes();
|
||||
|
||||
if (
|
||||
(currentTime >= existingTime && currentTime < existingEndTime) || // 当前节目开始时间在已存在节目时间段内
|
||||
(currentEndTime > existingTime && currentEndTime <= existingEndTime) || // 当前节目结束时间在已存在节目时间段内
|
||||
(currentTime <= existingTime && currentEndTime >= existingEndTime) // 当前节目完全包含已存在节目
|
||||
) {
|
||||
hasOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有重叠,则添加该节目
|
||||
if (!hasOverlap) {
|
||||
cleanedPrograms.push(currentProgram);
|
||||
} else {
|
||||
// 如果有重叠,检查是否需要替换已存在的节目
|
||||
for (let j = 0; j < cleanedPrograms.length; j++) {
|
||||
const existingProgram = cleanedPrograms[j];
|
||||
const existingStart = parseCustomTimeFormat(existingProgram.start);
|
||||
const existingEnd = parseCustomTimeFormat(existingProgram.end);
|
||||
|
||||
// 检查是否与当前节目重叠(只考虑时间部分)
|
||||
const currentTime = currentStart.getHours() * 60 + currentStart.getMinutes();
|
||||
const currentEndTime = currentEnd.getHours() * 60 + currentEnd.getMinutes();
|
||||
const existingTime = existingStart.getHours() * 60 + existingStart.getMinutes();
|
||||
const existingEndTime = existingEnd.getHours() * 60 + existingEnd.getMinutes();
|
||||
|
||||
if (
|
||||
(currentTime >= existingTime && currentTime < existingEndTime) ||
|
||||
(currentEndTime > existingTime && currentEndTime <= existingEndTime) ||
|
||||
(currentTime <= existingTime && currentEndTime >= existingEndTime)
|
||||
) {
|
||||
// 计算节目时长
|
||||
const currentDuration = currentEnd.getTime() - currentStart.getTime();
|
||||
const existingDuration = existingEnd.getTime() - existingStart.getTime();
|
||||
|
||||
// 如果当前节目时间更短,则替换已存在的节目
|
||||
if (currentDuration < existingDuration) {
|
||||
console.log(`替换重叠节目: "${existingProgram.title}" (${existingDuration}ms) -> "${currentProgram.title}" (${currentDuration}ms)`);
|
||||
cleanedPrograms[j] = currentProgram;
|
||||
} else {
|
||||
console.log(`跳过重叠节目: "${currentProgram.title}" (${currentDuration}ms),保留 "${existingProgram.title}" (${existingDuration}ms)`);
|
||||
removedCount++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`EPG数据清洗完成,清洗后节目数量: ${cleanedPrograms.length},移除重叠节目: ${removedCount}个,过滤非今日节目: ${dateFilteredCount}个`);
|
||||
return cleanedPrograms;
|
||||
};
|
||||
|
||||
// 播放器引用
|
||||
const artPlayerRef = useRef<any>(null);
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -237,6 +366,9 @@ function LivePageClient() {
|
||||
// 设置切换状态,锁住频道切换器
|
||||
setIsSwitchingSource(true);
|
||||
|
||||
// 清空节目单信息
|
||||
setEpgData(null);
|
||||
|
||||
setCurrentSource(source);
|
||||
await fetchChannels(source);
|
||||
} catch (err) {
|
||||
@@ -251,12 +383,40 @@ function LivePageClient() {
|
||||
};
|
||||
|
||||
// 切换频道
|
||||
const handleChannelChange = (channel: LiveChannel) => {
|
||||
const handleChannelChange = async (channel: LiveChannel) => {
|
||||
// 如果正在切换直播源,则禁用频道切换
|
||||
if (isSwitchingSource) return;
|
||||
|
||||
setCurrentChannel(channel);
|
||||
setVideoUrl(channel.url);
|
||||
|
||||
// 获取节目单信息
|
||||
if (channel.tvgId && currentSource) {
|
||||
try {
|
||||
setIsEpgLoading(true); // 开始加载 EPG 数据
|
||||
const response = await fetch(`/api/live/epg?source=${currentSource.key}&tvgId=${channel.tvgId}`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
console.log('节目单信息:', result.data);
|
||||
// 清洗EPG数据,去除重叠的节目
|
||||
const cleanedData = {
|
||||
...result.data,
|
||||
programs: cleanEpgData(result.data.programs)
|
||||
};
|
||||
setEpgData(cleanedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取节目单信息失败:', error);
|
||||
} finally {
|
||||
setIsEpgLoading(false); // 无论成功失败都结束加载状态
|
||||
}
|
||||
} else {
|
||||
// 如果没有 tvgId 或 currentSource,清空 EPG 数据
|
||||
setEpgData(null);
|
||||
setIsEpgLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理播放器资源的统一函数
|
||||
@@ -1032,28 +1192,40 @@ function LivePageClient() {
|
||||
|
||||
{/* 当前频道信息 */}
|
||||
{currentChannel && (
|
||||
<div className='p-4'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
{currentChannel.logo ? (
|
||||
<img
|
||||
src={processImageUrl(currentChannel.logo)}
|
||||
alt={currentChannel.name}
|
||||
className='w-full h-full rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Tv className='w-10 h-10 text-gray-500' />
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{currentChannel.name}
|
||||
</h3>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
{currentSource?.name} • {currentChannel.group}
|
||||
</p>
|
||||
<div className='pt-4'>
|
||||
<div className='flex flex-col lg:flex-row gap-4'>
|
||||
{/* 频道图标+名称 - 在小屏幕上占100%,大屏幕占20% */}
|
||||
<div className='w-full flex-shrink-0'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
{currentChannel.logo ? (
|
||||
<img
|
||||
src={processImageUrl(currentChannel.logo)}
|
||||
alt={currentChannel.name}
|
||||
className='w-full h-full rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Tv className='w-10 h-10 text-gray-500' />
|
||||
)}
|
||||
</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>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
|
||||
{currentSource?.name} {' > '} {currentChannel.group}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EPG节目单 */}
|
||||
<EpgScrollableRow
|
||||
programs={epgData?.programs || []}
|
||||
currentTime={new Date()}
|
||||
isLoading={isEpgLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
290
src/components/EpgScrollableRow.tsx
Normal file
290
src/components/EpgScrollableRow.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Clock, Tv, Target } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { parseCustomTimeFormat, formatTimeToHHMM } from '@/lib/time';
|
||||
|
||||
interface EpgProgram {
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EpgScrollableRowProps {
|
||||
programs: EpgProgram[];
|
||||
currentTime?: Date;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function EpgScrollableRow({
|
||||
programs,
|
||||
currentTime = new Date(),
|
||||
isLoading = false,
|
||||
}: EpgScrollableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [currentPlayingIndex, setCurrentPlayingIndex] = useState<number>(-1);
|
||||
|
||||
// 处理滚轮事件,实现横向滚动
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isHovered && containerRef.current) {
|
||||
e.preventDefault(); // 阻止默认的竖向滚动
|
||||
|
||||
const container = containerRef.current;
|
||||
const scrollAmount = e.deltaY * 4; // 增加滚动速度
|
||||
|
||||
// 根据滚轮方向进行横向滚动
|
||||
container.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 阻止页面竖向滚动
|
||||
const preventPageScroll = (e: WheelEvent) => {
|
||||
if (isHovered) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到正在播放的节目
|
||||
const scrollToCurrentProgram = () => {
|
||||
if (containerRef.current) {
|
||||
const currentProgramIndex = programs.findIndex(program => isCurrentlyPlaying(program));
|
||||
if (currentProgramIndex !== -1) {
|
||||
const programElement = containerRef.current.children[currentProgramIndex] as HTMLElement;
|
||||
if (programElement) {
|
||||
const container = containerRef.current;
|
||||
const programLeft = programElement.offsetLeft;
|
||||
const containerWidth = container.clientWidth;
|
||||
const programWidth = programElement.offsetWidth;
|
||||
|
||||
// 计算滚动位置,使正在播放的节目居中显示
|
||||
const scrollLeft = programLeft - (containerWidth / 2) + (programWidth / 2);
|
||||
|
||||
container.scrollTo({
|
||||
left: Math.max(0, scrollLeft),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered) {
|
||||
// 鼠标悬停时阻止页面滚动
|
||||
document.addEventListener('wheel', preventPageScroll, { passive: false });
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
} else {
|
||||
// 鼠标离开时恢复页面滚动
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, [isHovered]);
|
||||
|
||||
// 组件加载后自动滚动到正在播放的节目
|
||||
useEffect(() => {
|
||||
// 延迟执行,确保DOM完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
// 初始化当前正在播放的节目索引
|
||||
const initialPlayingIndex = programs.findIndex(program => isCurrentlyPlaying(program));
|
||||
setCurrentPlayingIndex(initialPlayingIndex);
|
||||
scrollToCurrentProgram();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [programs, currentTime]);
|
||||
|
||||
// 定时刷新正在播放状态
|
||||
useEffect(() => {
|
||||
// 每分钟刷新一次正在播放状态
|
||||
const interval = setInterval(() => {
|
||||
// 更新当前正在播放的节目索引
|
||||
const newPlayingIndex = programs.findIndex(program => {
|
||||
try {
|
||||
const start = parseCustomTimeFormat(program.start);
|
||||
const end = parseCustomTimeFormat(program.end);
|
||||
return currentTime >= start && currentTime < end;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (newPlayingIndex !== currentPlayingIndex) {
|
||||
setCurrentPlayingIndex(newPlayingIndex);
|
||||
// 如果正在播放的节目发生变化,自动滚动到新位置
|
||||
scrollToCurrentProgram();
|
||||
}
|
||||
}, 60000); // 60秒 = 1分钟
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [programs, currentTime, currentPlayingIndex]);
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeString: string) => {
|
||||
return formatTimeToHHMM(timeString);
|
||||
};
|
||||
|
||||
// 判断节目是否正在播放
|
||||
const isCurrentlyPlaying = (program: EpgProgram) => {
|
||||
try {
|
||||
const start = parseCustomTimeFormat(program.start);
|
||||
const end = parseCustomTimeFormat(program.end);
|
||||
return currentTime >= start && currentTime < end;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载中状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
<div className="w-16 sm:w-20"></div>
|
||||
</div>
|
||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 sm:gap-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="w-5 h-5 sm:w-6 sm:h-6 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<span className="text-sm sm:text-base">加载节目单...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 无节目单状态
|
||||
if (!programs || programs.length === 0) {
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
<div className="w-16 sm:w-20"></div>
|
||||
</div>
|
||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 sm:gap-3 text-gray-400 dark:text-gray-500">
|
||||
<Tv className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="text-sm sm:text-base">暂无节目单数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 mt-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
{currentPlayingIndex !== -1 && (
|
||||
<button
|
||||
onClick={scrollToCurrentProgram}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
|
||||
title="滚动到当前播放位置"
|
||||
>
|
||||
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
|
||||
<span className="hidden sm:inline">当前播放</span>
|
||||
<span className="sm:hidden">当前</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
|
||||
>
|
||||
{programs.map((program, index) => {
|
||||
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
|
||||
const isPlaying = index === currentPlayingIndex;
|
||||
const isFinishedProgram = index < currentPlayingIndex;
|
||||
const isUpcomingProgram = index > currentPlayingIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex-shrink-0 w-36 sm:w-48 p-2 sm:p-3 rounded-lg border transition-all duration-200 flex flex-col min-h-[100px] sm:min-h-[120px] ${isPlaying
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
|
||||
: isFinishedProgram
|
||||
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
: isUpcomingProgram
|
||||
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
|
||||
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{/* 时间显示在顶部 */}
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium ${isPlaying
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: isFinishedProgram
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: isUpcomingProgram
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}`}>
|
||||
{formatTime(program.start)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatTime(program.end)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题在中间,占据剩余空间 */}
|
||||
<div
|
||||
className={`text-xs sm:text-sm font-medium flex-1 ${isPlaying
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: isFinishedProgram
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: isUpcomingProgram
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: '1.4',
|
||||
maxHeight: '2.8em'
|
||||
}}
|
||||
title={program.title}
|
||||
>
|
||||
{program.title}
|
||||
</div>
|
||||
|
||||
{/* 正在播放状态在底部 */}
|
||||
{isPlaying && (
|
||||
<div className="mt-auto pt-1 sm:pt-2 flex items-center gap-1 sm:gap-1.5 flex-shrink-0">
|
||||
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
正在播放
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/lib/live.ts
158
src/lib/live.ts
@@ -1,7 +1,7 @@
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const defaultUA = 'okHttp/Mod-1.1.0'
|
||||
const defaultUA = 'AptvPlayer/1.4.10'
|
||||
|
||||
export interface LiveChannels {
|
||||
channelNumber: number;
|
||||
@@ -13,6 +13,14 @@ export interface LiveChannels {
|
||||
group: string;
|
||||
url: string;
|
||||
}[];
|
||||
epgUrl: string;
|
||||
epgs: {
|
||||
[key: string]: {
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const cachedLiveChannels: { [key: string]: LiveChannels } = {};
|
||||
@@ -58,12 +66,123 @@ export async function refreshLiveChannels(liveInfo: {
|
||||
},
|
||||
});
|
||||
const data = await response.text();
|
||||
const channels = parseM3U(liveInfo.key, data);
|
||||
const result = parseM3U(liveInfo.key, data);
|
||||
const epgUrl = liveInfo.epg || result.tvgUrl;
|
||||
const epgs = await parseEpg(epgUrl, liveInfo.ua || defaultUA, result.channels.map(channel => channel.tvgId).filter(tvgId => tvgId));
|
||||
cachedLiveChannels[liveInfo.key] = {
|
||||
channelNumber: channels.length,
|
||||
channels: channels,
|
||||
channelNumber: result.channels.length,
|
||||
channels: result.channels,
|
||||
epgUrl: epgUrl,
|
||||
epgs: epgs,
|
||||
};
|
||||
return channels.length;
|
||||
return result.channels.length;
|
||||
}
|
||||
|
||||
async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
||||
[key: string]: {
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
}[]
|
||||
}> {
|
||||
if (!epgUrl) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const tvgs = new Set(tvgIds);
|
||||
const result: { [key: string]: { start: string; end: string; title: string }[] } = {};
|
||||
|
||||
try {
|
||||
const response = await fetch(epgUrl, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch EPG from ${epgUrl}: ${response.status}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
// 使用 ReadableStream 逐行处理,避免将整个文件加载到内存
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
console.warn('Response body is not readable');
|
||||
return {};
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentTvgId = '';
|
||||
let currentProgram: { start: string; end: string; title: string } | null = null;
|
||||
let shouldSkipCurrentProgram = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
// 保留最后一行(可能不完整)
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
// 处理完整的行
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// 解析 <programme> 标签
|
||||
if (trimmedLine.startsWith('<programme')) {
|
||||
// 提取 tvg-id
|
||||
const tvgIdMatch = trimmedLine.match(/channel="([^"]*)"/);
|
||||
currentTvgId = tvgIdMatch ? tvgIdMatch[1] : '';
|
||||
|
||||
// 提取开始时间
|
||||
const startMatch = trimmedLine.match(/start="([^"]*)"/);
|
||||
const start = startMatch ? startMatch[1] : '';
|
||||
|
||||
// 提取结束时间
|
||||
const endMatch = trimmedLine.match(/stop="([^"]*)"/);
|
||||
const end = endMatch ? endMatch[1] : '';
|
||||
|
||||
if (currentTvgId && start && end) {
|
||||
currentProgram = { start, end, title: '' };
|
||||
// 优化:如果当前频道不在我们关注的列表中,标记为跳过
|
||||
shouldSkipCurrentProgram = !tvgs.has(currentTvgId);
|
||||
}
|
||||
}
|
||||
// 解析 <title> 标签 - 只有在需要解析当前节目时才处理
|
||||
else if (trimmedLine.startsWith('<title') && currentProgram && !shouldSkipCurrentProgram) {
|
||||
// 处理带有语言属性的title标签,如 <title lang="zh">远方的家2025-60</title>
|
||||
const titleMatch = trimmedLine.match(/<title(?:\s+[^>]*)?>(.*?)<\/title>/);
|
||||
if (titleMatch && currentProgram) {
|
||||
currentProgram.title = titleMatch[1];
|
||||
|
||||
// 保存节目信息(这里不需要再检查tvgs.has,因为shouldSkipCurrentProgram已经确保了相关性)
|
||||
if (!result[currentTvgId]) {
|
||||
result[currentTvgId] = [];
|
||||
}
|
||||
result[currentTvgId].push({ ...currentProgram });
|
||||
|
||||
currentProgram = null;
|
||||
}
|
||||
}
|
||||
// 处理 </programme> 标签
|
||||
else if (trimmedLine === '</programme>') {
|
||||
currentProgram = null;
|
||||
currentTvgId = '';
|
||||
shouldSkipCurrentProgram = false; // 重置跳过标志
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadableStream 会自动关闭,不需要手动调用 close
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing EPG:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,14 +190,17 @@ export async function refreshLiveChannels(liveInfo: {
|
||||
* @param m3uContent M3U文件的内容字符串
|
||||
* @returns 频道信息数组
|
||||
*/
|
||||
export function parseM3U(sourceKey: string, m3uContent: string): {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[] {
|
||||
function parseM3U(sourceKey: string, m3uContent: string): {
|
||||
tvgUrl: string;
|
||||
channels: {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[];
|
||||
} {
|
||||
const channels: {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
@@ -90,10 +212,18 @@ export function parseM3U(sourceKey: string, m3uContent: string): {
|
||||
|
||||
const lines = m3uContent.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
|
||||
let tvgUrl = '';
|
||||
let channelIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// 检查是否是 #EXTM3U 行,提取 tvg-url
|
||||
if (line.startsWith('#EXTM3U')) {
|
||||
const tvgUrlMatch = line.match(/x-tvg-url="([^"]*)"/);
|
||||
tvgUrl = tvgUrlMatch ? tvgUrlMatch[1] : '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否是 #EXTINF 行
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
// 提取 tvg-id
|
||||
@@ -142,7 +272,7 @@ export function parseM3U(sourceKey: string, m3uContent: string): {
|
||||
}
|
||||
}
|
||||
|
||||
return channels;
|
||||
return { tvgUrl, channels };
|
||||
}
|
||||
|
||||
// utils/urlResolver.js
|
||||
|
||||
56
src/lib/time.ts
Normal file
56
src/lib/time.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 时间格式转换函数
|
||||
* 处理形如 "20250824000000 +0800" 的时间格式
|
||||
*/
|
||||
export function parseCustomTimeFormat(timeStr: string): Date {
|
||||
// 如果已经是标准格式,直接返回
|
||||
if (timeStr.includes('T') || timeStr.includes('-')) {
|
||||
return new Date(timeStr);
|
||||
}
|
||||
|
||||
// 处理 "20250824000000 +0800" 格式
|
||||
// 格式说明:YYYYMMDDHHMMSS +ZZZZ
|
||||
const match = timeStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})$/);
|
||||
|
||||
if (match) {
|
||||
const [, year, month, day, hour, minute, second, timezone] = match;
|
||||
|
||||
// 创建ISO格式的时间字符串
|
||||
const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}${timezone}`;
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
// 如果格式不匹配,尝试其他常见格式
|
||||
return new Date(timeStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间为 HH:MM 格式
|
||||
*/
|
||||
export function formatTimeToHHMM(timeString: string): string {
|
||||
try {
|
||||
const date = parseCustomTimeFormat(timeString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return timeString; // 如果解析失败,返回原始字符串
|
||||
}
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return timeString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断时间是否有效
|
||||
*/
|
||||
export function isValidTime(timeString: string): boolean {
|
||||
try {
|
||||
const date = parseCustomTimeFormat(timeString);
|
||||
return !isNaN(date.getTime());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user