电视直播,聚合同名节目
This commit is contained in:
+222
-37
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Heart, Radio, Tv } from 'lucide-react';
|
import { GitBranch, Heart, Radio, Tv } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
@@ -43,6 +43,21 @@ interface LiveChannel {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MergedChannelItem =
|
||||||
|
| {
|
||||||
|
type: 'single';
|
||||||
|
key: string;
|
||||||
|
channel: LiveChannel;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'merged';
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
logo: string;
|
||||||
|
channels: LiveChannel[];
|
||||||
|
};
|
||||||
|
|
||||||
// 直播源接口
|
// 直播源接口
|
||||||
interface LiveSource {
|
interface LiveSource {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -120,6 +135,7 @@ function LivePageClient() {
|
|||||||
|
|
||||||
// 搜索关键词
|
// 搜索关键词
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [expandedMergedChannels, setExpandedMergedChannels] = useState<string[]>([]);
|
||||||
|
|
||||||
// 节目单信息
|
// 节目单信息
|
||||||
const [epgData, setEpgData] = useState<{
|
const [epgData, setEpgData] = useState<{
|
||||||
@@ -1127,6 +1143,69 @@ function LivePageClient() {
|
|||||||
return filtered;
|
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) => {
|
const handleGroupChange = (group: string) => {
|
||||||
// 如果正在切换直播源,则禁用分组切换
|
// 如果正在切换直播源,则禁用分组切换
|
||||||
@@ -2212,7 +2291,7 @@ function LivePageClient() {
|
|||||||
</div>
|
</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:hidden lg:opacity-0 lg:scale-95'
|
||||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
: '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'>
|
<div ref={channelListRef} className='flex-1 overflow-y-auto space-y-2 pb-4'>
|
||||||
{filteredChannels?.length > 0 ? (
|
{mergedChannelItems?.length > 0 ? (
|
||||||
filteredChannels.map(channel => {
|
mergedChannelItems.map(item => {
|
||||||
const isActive = channel.id === currentChannel?.id;
|
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 (
|
return (
|
||||||
<button
|
<div
|
||||||
key={channel.id}
|
key={item.key}
|
||||||
data-channel-id={channel.id}
|
className='space-y-2'
|
||||||
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'>
|
<button
|
||||||
<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'>
|
type='button'
|
||||||
{channel.logo ? (
|
onClick={() => {
|
||||||
<img
|
handleChannelChange(item.channels[0]);
|
||||||
src={getLogoUrl(channel.logo, currentSource?.key || '')}
|
}}
|
||||||
alt={channel.name}
|
disabled={isSwitchingSource}
|
||||||
className='w-full h-full rounded object-contain'
|
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
|
||||||
loading="lazy"
|
? 'opacity-50 cursor-not-allowed'
|
||||||
/>
|
: hasActiveChild
|
||||||
) : (
|
? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
|
||||||
<Tv className='w-5 h-5 text-gray-500' />
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
)}
|
}`}
|
||||||
</div>
|
>
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex items-center gap-3'>
|
||||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
|
<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.name}
|
{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>
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
|
<div className='flex-1 min-w-0'>
|
||||||
{channel.group}
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user