电视直播,聚合同名节目

This commit is contained in:
mtvpls
2026-03-26 14:25:40 +08:00
parent 39d829eab6
commit 8ecb688127

View File

@@ -2,9 +2,9 @@
'use client';
import { Heart, Radio, Tv } from 'lucide-react';
import { GitBranch, Heart, Radio, Tv } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
deleteFavorite,
@@ -43,6 +43,21 @@ interface LiveChannel {
url: string;
}
type MergedChannelItem =
| {
type: 'single';
key: string;
channel: LiveChannel;
}
| {
type: 'merged';
key: string;
name: string;
group: string;
logo: string;
channels: LiveChannel[];
};
// 直播源接口
interface LiveSource {
key: string;
@@ -120,6 +135,7 @@ function LivePageClient() {
// 搜索关键词
const [searchKeyword, setSearchKeyword] = useState('');
const [expandedMergedChannels, setExpandedMergedChannels] = useState<string[]>([]);
// 节目单信息
const [epgData, setEpgData] = useState<{
@@ -1127,6 +1143,69 @@ function LivePageClient() {
return filtered;
};
const mergedChannelItems = useMemo<MergedChannelItem[]>(() => {
if (!filteredChannels || filteredChannels.length === 0) return [];
const mergedMap = new Map<string, {
key: string;
name: string;
group: string;
logo: string;
channels: LiveChannel[];
}>();
const order: string[] = [];
filteredChannels.forEach((channel) => {
const mergedKey = `${channel.group}::${channel.name.trim().toLowerCase()}`;
const existing = mergedMap.get(mergedKey);
if (existing) {
existing.channels.push(channel);
if (!existing.logo && channel.logo) {
existing.logo = channel.logo;
}
return;
}
mergedMap.set(mergedKey, {
key: mergedKey,
name: channel.name,
group: channel.group,
logo: channel.logo,
channels: [channel],
});
order.push(mergedKey);
});
return order.map((key) => {
const item = mergedMap.get(key)!;
if (item.channels.length === 1) {
return {
type: 'single',
key,
channel: item.channels[0],
};
}
return {
type: 'merged',
key,
name: item.name,
group: item.group,
logo: item.logo,
channels: item.channels,
};
});
}, [filteredChannels]);
const toggleMergedChannel = (key: string) => {
setExpandedMergedChannels((prev) => (
prev.includes(key)
? prev.filter(item => item !== key)
: [...prev, key]
));
};
// 切换分组
const handleGroupChange = (group: string) => {
// 如果正在切换直播源,则禁用分组切换
@@ -2212,7 +2291,7 @@ function LivePageClient() {
</div>
{/* 频道列表 */}
<div className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isChannelListCollapsed
<div className={`h-[330px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isChannelListCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}>
@@ -2365,45 +2444,151 @@ function LivePageClient() {
{/* 频道列表 */}
<div ref={channelListRef} className='flex-1 overflow-y-auto space-y-2 pb-4'>
{filteredChannels?.length > 0 ? (
filteredChannels.map(channel => {
const isActive = channel.id === currentChannel?.id;
{mergedChannelItems?.length > 0 ? (
mergedChannelItems.map(item => {
if (item.type === 'single') {
const channel = item.channel;
const isActive = channel.id === currentChannel?.id;
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
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<div className='flex items-center gap-3'>
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{channel.logo ? (
<img
src={getLogoUrl(channel.logo, currentSource?.key || '')}
alt={channel.name}
className='w-full h-full rounded object-contain'
loading="lazy"
/>
) : (
<Tv className='w-5 h-5 text-gray-500' />
)}
</div>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
{channel.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
{channel.group}
</div>
</div>
</div>
</button>
);
}
const isExpanded = expandedMergedChannels.includes(item.key);
const activeLineIndex = item.channels.findIndex(channel => channel.id === currentChannel?.id);
const hasActiveChild = activeLineIndex !== -1;
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
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
<div
key={item.key}
className='space-y-2'
>
<div className='flex items-center gap-3'>
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{channel.logo ? (
<img
src={getLogoUrl(channel.logo, currentSource?.key || '')}
alt={channel.name}
className='w-full h-full rounded object-contain'
loading="lazy"
/>
) : (
<Tv className='w-5 h-5 text-gray-500' />
)}
</div>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
{channel.name}
<button
type='button'
onClick={() => {
handleChannelChange(item.channels[0]);
}}
disabled={isSwitchingSource}
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
? 'opacity-50 cursor-not-allowed'
: hasActiveChild
? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<div className='flex items-center gap-3'>
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{item.logo ? (
<img
src={getLogoUrl(item.logo, currentSource?.key || '')}
alt={item.name}
className='w-full h-full rounded object-contain'
loading='lazy'
/>
) : (
<Tv className='w-5 h-5 text-gray-500' />
)}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
{channel.group}
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={item.name}>
{item.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2'>
<span title={item.group}>{item.group}</span>
<span>·</span>
<span>{item.channels.length} 线</span>
{hasActiveChild && (
<>
<span>·</span>
<span>{`当前线路${activeLineIndex + 1}`}</span>
</>
)}
</div>
</div>
<div className='flex flex-col items-end gap-2 flex-shrink-0'>
<span
onClick={(e) => {
e.stopPropagation();
toggleMergedChannel(item.key);
}}
className='text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300'
>
{isExpanded ? '收起' : '展开'}
</span>
</div>
</div>
</div>
</button>
</button>
{isExpanded && (
<div className='pl-4 space-y-2'>
{item.channels.map((channel, index) => {
const isActive = channel.id === currentChannel?.id;
return (
<button
key={channel.id}
type='button'
data-channel-id={channel.id}
onClick={() => handleChannelChange(channel)}
disabled={isSwitchingSource}
className={`w-full p-3 rounded-lg text-left text-sm transition-all duration-200 ${
isSwitchingSource
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<div className='flex items-center justify-between gap-3'>
<span className='font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2'>
<GitBranch className='w-4 h-4 text-gray-500 dark:text-gray-400' />
{`线路${index + 1}`}
</span>
{isActive && (
<span className='text-xs text-green-600 dark:text-green-400'>
</span>
)}
</div>
</button>
);
})}
</div>
)}
</div>
);
})
) : (