feat: implement iptv
This commit is contained in:
+1
-1
@@ -34,7 +34,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"framer-motion": "^12.18.1",
|
||||
"he": "^1.2.0",
|
||||
"hls.js": "^1.6.6",
|
||||
"hls.js": "^1.6.10",
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"next": "^14.2.23",
|
||||
|
||||
Generated
+5
-5
@@ -54,8 +54,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
hls.js:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
specifier: ^1.6.10
|
||||
version: 1.6.10
|
||||
lucide-react:
|
||||
specifier: ^0.438.0
|
||||
version: 0.438.0([email protected])
|
||||
@@ -3008,8 +3008,8 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-S4uTCwTHOtImW+/jxMjzG7udbHy5z682YQRbm/4f7VXuVNEoGBRjPJnD3Fxrufomdhzdtv24KnxRhPMXSvL6Fw==}
|
||||
[email protected].10:
|
||||
resolution: {integrity: sha512-16XHorwFNh+hYazYxDNXBLEm5aRoU+oxMX6qVnkbGH3hJil4xLav3/M6NH92VkD1qSOGKXeSm+5unuawPXK6OQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||
@@ -8526,7 +8526,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].6: {}
|
||||
[email protected].10: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Tv,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
@@ -251,6 +252,18 @@ interface DataSource {
|
||||
from: 'config' | 'custom';
|
||||
}
|
||||
|
||||
// 直播源数据类型
|
||||
interface LiveDataSource {
|
||||
name: string;
|
||||
key: string;
|
||||
url: string;
|
||||
ua?: string;
|
||||
epg?: string;
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
from: 'config' | 'custom';
|
||||
}
|
||||
|
||||
// 自定义分类数据类型
|
||||
interface CustomCategory {
|
||||
name?: string;
|
||||
@@ -3858,6 +3871,425 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
);
|
||||
};
|
||||
|
||||
// 直播源配置组件
|
||||
const LiveSourceConfig = ({
|
||||
config,
|
||||
refreshConfig,
|
||||
}: {
|
||||
config: AdminConfig | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
}) => {
|
||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [orderChanged, setOrderChanged] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [newLiveSource, setNewLiveSource] = useState<LiveDataSource>({
|
||||
name: '',
|
||||
key: '',
|
||||
url: '',
|
||||
ua: '',
|
||||
epg: '',
|
||||
disabled: false,
|
||||
from: 'custom',
|
||||
});
|
||||
|
||||
// dnd-kit 传感器
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5, // 轻微位移即可触发
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
if (config?.LiveConfig) {
|
||||
setLiveSources(config.LiveConfig);
|
||||
// 进入时重置 orderChanged
|
||||
setOrderChanged(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// 通用 API 请求
|
||||
const callLiveSourceApi = async (body: Record<string, any>) => {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/live', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...body }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `操作失败: ${resp.status}`);
|
||||
}
|
||||
|
||||
// 成功后刷新配置
|
||||
await refreshConfig();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : '操作失败', showAlert);
|
||||
throw err; // 向上抛出方便调用处判断
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnable = (key: string) => {
|
||||
const target = liveSources.find((s) => s.key === key);
|
||||
if (!target) return;
|
||||
const action = target.disabled ? 'enable' : 'disable';
|
||||
callLiveSourceApi({ action, key }).catch(() => {
|
||||
console.error('操作失败', action, key);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (key: string) => {
|
||||
callLiveSourceApi({ action: 'delete', key }).catch(() => {
|
||||
console.error('操作失败', 'delete', key);
|
||||
});
|
||||
};
|
||||
|
||||
// 刷新直播源
|
||||
const handleRefreshLiveSources = async () => {
|
||||
if (isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const response = await fetch('/api/admin/live/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || `刷新失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 刷新成功后重新获取配置
|
||||
await refreshConfig();
|
||||
showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLiveSource = () => {
|
||||
if (!newLiveSource.name || !newLiveSource.key || !newLiveSource.url) return;
|
||||
callLiveSourceApi({
|
||||
action: 'add',
|
||||
key: newLiveSource.key,
|
||||
name: newLiveSource.name,
|
||||
url: newLiveSource.url,
|
||||
ua: newLiveSource.ua,
|
||||
epg: newLiveSource.epg,
|
||||
})
|
||||
.then(() => {
|
||||
setNewLiveSource({
|
||||
name: '',
|
||||
key: '',
|
||||
url: '',
|
||||
epg: '',
|
||||
ua: '',
|
||||
disabled: false,
|
||||
from: 'custom',
|
||||
});
|
||||
setShowAddForm(false);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('操作失败', 'add', newLiveSource);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = liveSources.findIndex((s) => s.key === active.id);
|
||||
const newIndex = liveSources.findIndex((s) => s.key === over.id);
|
||||
setLiveSources((prev) => arrayMove(prev, oldIndex, newIndex));
|
||||
setOrderChanged(true);
|
||||
};
|
||||
|
||||
const handleSaveOrder = () => {
|
||||
const order = liveSources.map((s) => s.key);
|
||||
callLiveSourceApi({ action: 'sort', order })
|
||||
.then(() => {
|
||||
setOrderChanged(false);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('操作失败', 'sort', order);
|
||||
});
|
||||
};
|
||||
|
||||
// 可拖拽行封装 (dnd-kit)
|
||||
const DraggableRow = ({ liveSource }: { liveSource: LiveDataSource }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: liveSource.key });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
||||
>
|
||||
<td
|
||||
className='px-2 py-4 cursor-grab text-gray-400'
|
||||
style={{ touchAction: 'none' }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||
{liveSource.name}
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||
{liveSource.key}
|
||||
</td>
|
||||
<td
|
||||
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
|
||||
title={liveSource.url}
|
||||
>
|
||||
{liveSource.url}
|
||||
</td>
|
||||
<td
|
||||
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
|
||||
title={liveSource.epg || '-'}
|
||||
>
|
||||
{liveSource.epg || '-'}
|
||||
</td>
|
||||
<td
|
||||
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
|
||||
title={liveSource.ua || '-'}
|
||||
>
|
||||
{liveSource.ua || '-'}
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-center'>
|
||||
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${!liveSource.disabled
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{!liveSource.disabled ? '启用中' : '已禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||
<button
|
||||
onClick={() => handleToggleEnable(liveSource.key)}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled
|
||||
? buttonStyles.roundedDanger
|
||||
: buttonStyles.roundedSuccess
|
||||
} transition-colors`}
|
||||
>
|
||||
{!liveSource.disabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
{liveSource.from !== 'config' && (
|
||||
<button
|
||||
onClick={() => handleDelete(liveSource.key)}
|
||||
className={buttonStyles.roundedSecondary}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* 添加直播源表单 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
直播源列表
|
||||
</h4>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<button
|
||||
onClick={handleRefreshLiveSources}
|
||||
disabled={isRefreshing}
|
||||
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing
|
||||
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
|
||||
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
|
||||
}`}
|
||||
>
|
||||
<span>{isRefreshing ? '刷新中...' : '刷新直播源'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
||||
>
|
||||
{showAddForm ? '取消' : '添加直播源'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='名称'
|
||||
value={newLiveSource.name}
|
||||
onChange={(e) =>
|
||||
setNewLiveSource((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Key'
|
||||
value={newLiveSource.key}
|
||||
onChange={(e) =>
|
||||
setNewLiveSource((prev) => ({ ...prev, key: e.target.value }))
|
||||
}
|
||||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='M3U 地址'
|
||||
value={newLiveSource.url}
|
||||
onChange={(e) =>
|
||||
setNewLiveSource((prev) => ({ ...prev, url: e.target.value }))
|
||||
}
|
||||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='节目单地址(选填)'
|
||||
value={newLiveSource.epg}
|
||||
onChange={(e) =>
|
||||
setNewLiveSource((prev) => ({ ...prev, epg: e.target.value }))
|
||||
}
|
||||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='自定义 UA(选填)'
|
||||
value={newLiveSource.ua}
|
||||
onChange={(e) =>
|
||||
setNewLiveSource((prev) => ({ ...prev, ua: e.target.value }))
|
||||
}
|
||||
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
onClick={handleAddLiveSource}
|
||||
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url}
|
||||
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url ? buttonStyles.disabled : buttonStyles.success}`}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 直播源表格 */}
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="live-source-list">
|
||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className='w-8' />
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
名称
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
Key
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
M3U 地址
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
节目单地址
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
自定义 UA
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
频道数
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
状态
|
||||
</th>
|
||||
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
autoScroll={false}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
>
|
||||
<SortableContext
|
||||
items={liveSources.map((s) => s.key)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{liveSources.map((liveSource) => (
|
||||
<DraggableRow key={liveSource.key} liveSource={liveSource} />
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 保存排序按钮 */}
|
||||
{orderChanged && (
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
onClick={handleSaveOrder}
|
||||
className={`px-3 py-1.5 text-sm ${buttonStyles.primary}`}
|
||||
>
|
||||
保存排序
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通用弹窗组件 */}
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function AdminPageClient() {
|
||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||
const [config, setConfig] = useState<AdminConfig | null>(null);
|
||||
@@ -3868,6 +4300,7 @@ function AdminPageClient() {
|
||||
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
||||
userConfig: false,
|
||||
videoSource: false,
|
||||
liveSource: false,
|
||||
siteConfig: false,
|
||||
categoryConfig: false,
|
||||
configFile: false,
|
||||
@@ -4042,6 +4475,18 @@ function AdminPageClient() {
|
||||
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 直播源配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='直播源配置'
|
||||
icon={
|
||||
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
isExpanded={expandedTabs.liveSource}
|
||||
onToggle={() => toggleTab('liveSource')}
|
||||
>
|
||||
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 分类配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='分类配置'
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const liveInfo of config.LiveConfig || []) {
|
||||
if (liveInfo.disabled) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '直播源刷新成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('直播源刷新失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '刷新失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable no-console,no-case-declarations */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, key, name, url, ua, epg } = body;
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 确保 LiveConfig 存在
|
||||
if (!config.LiveConfig) {
|
||||
config.LiveConfig = [];
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add':
|
||||
// 检查是否已存在相同的 key
|
||||
if (config.LiveConfig.some((l) => l.key === key)) {
|
||||
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
const liveInfo = {
|
||||
key: key as string,
|
||||
name: name as string,
|
||||
url: url as string,
|
||||
ua: ua || '',
|
||||
epg: epg || '',
|
||||
from: 'custom' as 'custom' | 'config',
|
||||
channelNumber: 0,
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
|
||||
// 添加新的直播源
|
||||
config.LiveConfig.push(liveInfo);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
// 删除直播源
|
||||
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
|
||||
if (deleteIndex === -1) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const liveSource = config.LiveConfig[deleteIndex];
|
||||
if (liveSource.from === 'config') {
|
||||
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
|
||||
}
|
||||
|
||||
deleteCachedLiveChannels(key);
|
||||
|
||||
config.LiveConfig.splice(deleteIndex, 1);
|
||||
break;
|
||||
|
||||
case 'enable':
|
||||
// 启用直播源
|
||||
const enableSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!enableSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
enableSource.disabled = false;
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
// 禁用直播源
|
||||
const disableSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!disableSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
disableSource.disabled = true;
|
||||
break;
|
||||
|
||||
case 'sort':
|
||||
// 排序直播源
|
||||
const { order } = body;
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 创建新的排序后的数组
|
||||
const sortedLiveConfig: typeof config.LiveConfig = [];
|
||||
order.forEach((key) => {
|
||||
const source = config.LiveConfig?.find((l) => l.key === key);
|
||||
if (source) {
|
||||
sortedLiveConfig.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加未在排序列表中的直播源(保持原有顺序)
|
||||
config.LiveConfig.forEach((source) => {
|
||||
if (!order.includes(source.key)) {
|
||||
sortedLiveConfig.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
config.LiveConfig = sortedLiveConfig;
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -358,9 +359,25 @@ async function cronJob() {
|
||||
|
||||
// 执行其他定时任务
|
||||
await refreshConfig();
|
||||
await refreshAllLiveChannels();
|
||||
await refreshRecordAndFavorites();
|
||||
}
|
||||
|
||||
async function refreshAllLiveChannels() {
|
||||
const config = await getConfig();
|
||||
for (const liveInfo of config.LiveConfig || []) {
|
||||
if (liveInfo.disabled) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfig() {
|
||||
let config = await getConfig();
|
||||
if (config && config.ConfigSubscribtion && config.ConfigSubscribtion.URL && config.ConfigSubscribtion.AutoUpdate) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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');
|
||||
|
||||
if (!sourceKey) {
|
||||
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const channelData = await getCachedLiveChannels(sourceKey);
|
||||
|
||||
if (!channelData) {
|
||||
return NextResponse.json({ error: '频道信息未找到' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: channelData.channels
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取频道信息失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log(request.url)
|
||||
try {
|
||||
const config = await getConfig();
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置未找到' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 过滤出所有非 disabled 的直播源
|
||||
const liveSources = (config.LiveConfig || []).filter(source => !source.disabled);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: liveSources
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取直播源失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取直播源失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable no-console,@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) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const source = searchParams.get('moontv-source');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log(decodedUrl);
|
||||
const response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||
}
|
||||
const keyData = await response.arrayBuffer();
|
||||
return new Response(keyData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const allowCORS = searchParams.get('allowCORS') === 'true';
|
||||
const source = searchParams.get('moontv-source');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
const response = await fetch(decodedUrl, {
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 获取最终的响应URL(处理重定向后的URL)
|
||||
const finalUrl = response.url;
|
||||
const m3u8Content = await response.text();
|
||||
|
||||
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
|
||||
const baseUrl = getBaseUrl(finalUrl);
|
||||
|
||||
// 重写 M3U8 内容
|
||||
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
return new Response(modifiedContent, { headers });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
|
||||
const protocol = req.headers.get('x-forwarded-proto') || 'http';
|
||||
const host = req.headers.get('host');
|
||||
const proxyBase = `${protocol}://${host}/api/proxy`;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const rewrittenLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i].trim();
|
||||
|
||||
// 处理 TS 片段 URL 和其他媒体文件
|
||||
if (line && !line.startsWith('#')) {
|
||||
const resolvedUrl = resolveUrl(baseUrl, line);
|
||||
// 检查是否为 mp4 格式
|
||||
const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4');
|
||||
const proxyUrl = (isMp4 || allowCORS) ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
rewrittenLines.push(proxyUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理 EXT-X-MAP 标签中的 URI
|
||||
if (line.startsWith('#EXT-X-MAP:')) {
|
||||
line = rewriteMapUri(line, baseUrl, proxyBase);
|
||||
}
|
||||
|
||||
// 处理 EXT-X-KEY 标签中的 URI
|
||||
if (line.startsWith('#EXT-X-KEY:')) {
|
||||
line = rewriteKeyUri(line, baseUrl, proxyBase);
|
||||
}
|
||||
|
||||
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
||||
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
rewrittenLines.push(line);
|
||||
// 下一行通常是 M3U8 URL
|
||||
if (i + 1 < lines.length) {
|
||||
i++;
|
||||
const nextLine = lines[i].trim();
|
||||
if (nextLine && !nextLine.startsWith('#')) {
|
||||
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
||||
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
rewrittenLines.push(proxyUrl);
|
||||
} else {
|
||||
rewrittenLines.push(nextLine);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rewrittenLines.push(line);
|
||||
}
|
||||
|
||||
return rewrittenLines.join('\n');
|
||||
}
|
||||
|
||||
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
const originalUri = uriMatch[1];
|
||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||
// 检查是否为 mp4 格式,如果是则走 proxyBase
|
||||
const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4');
|
||||
const proxyUrl = isMp4 ? `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}` : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
const originalUri = uriMatch[1];
|
||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable no-console,@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) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const source = searchParams.get('moontv-source');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'video/mp2t');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
return new Response(response.body, { headers });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -0,0 +1,1070 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
||||
|
||||
'use client';
|
||||
|
||||
import Artplayer from 'artplayer';
|
||||
import Hls from 'hls.js';
|
||||
import { Radio, Tv } from 'lucide-react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
declare global {
|
||||
interface HTMLVideoElement {
|
||||
hls?: any;
|
||||
}
|
||||
}
|
||||
|
||||
// 直播频道接口
|
||||
interface LiveChannel {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// 直播源接口
|
||||
interface LiveSource {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string; // m3u 地址
|
||||
ua?: string;
|
||||
epg?: string; // 节目单
|
||||
from: 'config' | 'custom';
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function LivePageClient() {
|
||||
// -----------------------------------------------------------------------------
|
||||
// 状态变量(State)
|
||||
// -----------------------------------------------------------------------------
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingStage, setLoadingStage] = useState<
|
||||
'loading' | 'fetching' | 'ready'
|
||||
>('loading');
|
||||
const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 直播源相关
|
||||
const [liveSources, setLiveSources] = useState<LiveSource[]>([]);
|
||||
const [currentSource, setCurrentSource] = useState<LiveSource | null>(null);
|
||||
const currentSourceRef = useRef<LiveSource | null>(null);
|
||||
useEffect(() => {
|
||||
currentSourceRef.current = currentSource;
|
||||
}, [currentSource]);
|
||||
|
||||
// 频道相关
|
||||
const [currentChannels, setCurrentChannels] = useState<LiveChannel[]>([]);
|
||||
const [currentChannel, setCurrentChannel] = useState<LiveChannel | null>(null);
|
||||
|
||||
// 播放器相关
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
|
||||
// 切换直播源状态
|
||||
const [isSwitchingSource, setIsSwitchingSource] = useState(false);
|
||||
|
||||
// 分组相关
|
||||
const [groupedChannels, setGroupedChannels] = useState<{ [key: string]: LiveChannel[] }>({});
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>('');
|
||||
|
||||
// Tab 切换
|
||||
const [activeTab, setActiveTab] = useState<'channels' | 'sources'>('channels');
|
||||
|
||||
// 频道列表收起状态
|
||||
const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false);
|
||||
|
||||
// 过滤后的频道列表
|
||||
const [filteredChannels, setFilteredChannels] = useState<LiveChannel[]>([]);
|
||||
|
||||
// 播放器引用
|
||||
const artPlayerRef = useRef<any>(null);
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 分组标签滚动相关
|
||||
const groupContainerRef = useRef<HTMLDivElement>(null);
|
||||
const groupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 获取直播源列表
|
||||
const fetchLiveSources = async () => {
|
||||
try {
|
||||
setLoadingStage('fetching');
|
||||
setLoadingMessage('正在获取直播源...');
|
||||
|
||||
// 获取 AdminConfig 中的直播源信息
|
||||
const response = await fetch('/api/live/sources');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取直播源失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '获取直播源失败');
|
||||
}
|
||||
|
||||
const sources = result.data;
|
||||
setLiveSources(sources);
|
||||
|
||||
if (sources.length > 0) {
|
||||
// 默认选中第一个源
|
||||
const firstSource = sources[0];
|
||||
setCurrentSource(firstSource);
|
||||
await fetchChannels(firstSource);
|
||||
}
|
||||
|
||||
setLoadingStage('ready');
|
||||
setLoadingMessage('✨ 准备就绪...');
|
||||
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('获取直播源失败:', err);
|
||||
// 不设置错误,而是显示空状态
|
||||
setLiveSources([]);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取频道列表
|
||||
const fetchChannels = async (source: LiveSource) => {
|
||||
try {
|
||||
setIsVideoLoading(true);
|
||||
|
||||
// 从 cachedLiveChannels 获取频道信息
|
||||
const response = await fetch(`/api/live/channels?source=${source.key}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取频道列表失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '获取频道列表失败');
|
||||
}
|
||||
|
||||
const channelsData = result.data;
|
||||
if (!channelsData || channelsData.length === 0) {
|
||||
// 不抛出错误,而是设置空频道列表
|
||||
setCurrentChannels([]);
|
||||
setGroupedChannels({});
|
||||
setFilteredChannels([]);
|
||||
|
||||
// 更新直播源的频道数为 0
|
||||
setLiveSources(prevSources =>
|
||||
prevSources.map(s =>
|
||||
s.key === source.key ? { ...s, channelNumber: 0 } : s
|
||||
)
|
||||
);
|
||||
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换频道数据格式
|
||||
const channels: LiveChannel[] = channelsData.map((channel: any) => ({
|
||||
id: channel.id,
|
||||
tvgId: channel.tvgId || channel.name,
|
||||
name: channel.name,
|
||||
logo: channel.logo,
|
||||
group: channel.group || '其他',
|
||||
url: channel.url
|
||||
}));
|
||||
|
||||
setCurrentChannels(channels);
|
||||
|
||||
// 更新直播源的频道数
|
||||
setLiveSources(prevSources =>
|
||||
prevSources.map(s =>
|
||||
s.key === source.key ? { ...s, channelNumber: channels.length } : s
|
||||
)
|
||||
);
|
||||
|
||||
// 默认选中第一个频道
|
||||
if (channels.length > 0) {
|
||||
setCurrentChannel(channels[0]);
|
||||
setVideoUrl(channels[0].url);
|
||||
}
|
||||
|
||||
// 按分组组织频道
|
||||
const grouped = channels.reduce((acc, channel) => {
|
||||
const group = channel.group || '其他';
|
||||
if (!acc[group]) {
|
||||
acc[group] = [];
|
||||
}
|
||||
acc[group].push(channel);
|
||||
return acc;
|
||||
}, {} as { [key: string]: LiveChannel[] });
|
||||
|
||||
setGroupedChannels(grouped);
|
||||
|
||||
// 默认选中第一个分组
|
||||
const firstGroup = Object.keys(grouped)[0] || '';
|
||||
setSelectedGroup(firstGroup);
|
||||
setFilteredChannels(firstGroup ? grouped[firstGroup] : channels);
|
||||
|
||||
setIsVideoLoading(false);
|
||||
} catch (err) {
|
||||
console.error('获取频道列表失败:', err);
|
||||
// 不设置错误,而是设置空频道列表
|
||||
setCurrentChannels([]);
|
||||
setGroupedChannels({});
|
||||
setFilteredChannels([]);
|
||||
|
||||
// 更新直播源的频道数为 0
|
||||
setLiveSources(prevSources =>
|
||||
prevSources.map(s =>
|
||||
s.key === source.key ? { ...s, channelNumber: 0 } : s
|
||||
)
|
||||
);
|
||||
|
||||
setIsVideoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换直播源
|
||||
const handleSourceChange = async (source: LiveSource) => {
|
||||
try {
|
||||
// 设置切换状态,锁住频道切换器
|
||||
setIsSwitchingSource(true);
|
||||
|
||||
setCurrentSource(source);
|
||||
await fetchChannels(source);
|
||||
} catch (err) {
|
||||
console.error('切换直播源失败:', err);
|
||||
// 不设置错误,保持当前状态
|
||||
} finally {
|
||||
// 切换完成,解锁频道切换器
|
||||
setIsSwitchingSource(false);
|
||||
// 自动切换到频道 tab
|
||||
setActiveTab('channels');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换频道
|
||||
const handleChannelChange = (channel: LiveChannel) => {
|
||||
// 如果正在切换直播源,则禁用频道切换
|
||||
if (isSwitchingSource) return;
|
||||
|
||||
setCurrentChannel(channel);
|
||||
setVideoUrl(channel.url);
|
||||
};
|
||||
|
||||
// 清理播放器资源的统一函数
|
||||
const cleanupPlayer = () => {
|
||||
if (artPlayerRef.current) {
|
||||
try {
|
||||
// 销毁 HLS 实例
|
||||
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
|
||||
artPlayerRef.current.video.hls.destroy();
|
||||
}
|
||||
|
||||
// 销毁 ArtPlayer 实例
|
||||
artPlayerRef.current.destroy();
|
||||
artPlayerRef.current = null;
|
||||
} catch (err) {
|
||||
console.warn('清理播放器资源时出错:', err);
|
||||
artPlayerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 确保视频源正确设置
|
||||
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
|
||||
if (!video || !url) return;
|
||||
const sources = Array.from(video.getElementsByTagName('source'));
|
||||
const existed = sources.some((s) => s.src === url);
|
||||
if (!existed) {
|
||||
// 移除旧的 source,保持唯一
|
||||
sources.forEach((s) => s.remove());
|
||||
const sourceEl = document.createElement('source');
|
||||
sourceEl.src = url;
|
||||
video.appendChild(sourceEl);
|
||||
}
|
||||
|
||||
// 始终允许远程播放(AirPlay / Cast)
|
||||
video.disableRemotePlayback = false;
|
||||
// 如果曾经有禁用属性,移除之
|
||||
if (video.hasAttribute('disableRemotePlayback')) {
|
||||
video.removeAttribute('disableRemotePlayback');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换分组
|
||||
const handleGroupChange = (group: string) => {
|
||||
// 如果正在切换直播源,则禁用分组切换
|
||||
if (isSwitchingSource) return;
|
||||
|
||||
setSelectedGroup(group);
|
||||
const filtered = currentChannels.filter(channel => channel.group === group);
|
||||
setFilteredChannels(filtered);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
fetchLiveSources();
|
||||
}, []);
|
||||
|
||||
// 当分组切换时,将激活的分组标签滚动到视口中间
|
||||
useEffect(() => {
|
||||
if (!selectedGroup || !groupContainerRef.current) return;
|
||||
|
||||
const groupKeys = Object.keys(groupedChannels);
|
||||
const groupIndex = groupKeys.indexOf(selectedGroup);
|
||||
if (groupIndex === -1) return;
|
||||
|
||||
const btn = groupButtonRefs.current[groupIndex];
|
||||
const container = groupContainerRef.current;
|
||||
if (btn && container) {
|
||||
// 手动计算滚动位置,只滚动分组标签容器
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const scrollLeft = container.scrollLeft;
|
||||
|
||||
// 计算按钮相对于容器的位置
|
||||
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
|
||||
const btnWidth = btnRect.width;
|
||||
const containerWidth = containerRect.width;
|
||||
|
||||
// 计算目标滚动位置,使按钮居中
|
||||
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
container.scrollTo({
|
||||
left: targetScrollLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [selectedGroup, groupedChannels]);
|
||||
|
||||
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
|
||||
constructor(config: any) {
|
||||
super(config);
|
||||
const load = this.load.bind(this);
|
||||
this.load = function (context: any, config: any, callbacks: any) {
|
||||
// 所有的请求都带一个 source 参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
// 拦截manifest和level请求
|
||||
if (
|
||||
(context as any).type === 'manifest' ||
|
||||
(context as any).type === 'level'
|
||||
) {
|
||||
// 判断是否浏览器直连
|
||||
const isLiveDirectConnectStr = localStorage.getItem('liveDirectConnect');
|
||||
const isLiveDirectConnect = isLiveDirectConnectStr === 'true';
|
||||
if (isLiveDirectConnect) {
|
||||
// 浏览器直连,使用 URL 对象处理参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('allowCORS', 'true');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
// 如果 URL 解析失败,回退到字符串拼接
|
||||
context.url = context.url + '&allowCORS=true';
|
||||
}
|
||||
}
|
||||
}
|
||||
// 执行原始load方法
|
||||
load(context, config, callbacks);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 播放器初始化
|
||||
useEffect(() => {
|
||||
if (
|
||||
!Artplayer ||
|
||||
!Hls ||
|
||||
!videoUrl ||
|
||||
!artRef.current ||
|
||||
!currentChannel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('视频URL:', videoUrl);
|
||||
|
||||
// 销毁之前的播放器实例并创建新的
|
||||
if (artPlayerRef.current) {
|
||||
cleanupPlayer();
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新的播放器实例
|
||||
Artplayer.USE_RAF = true;
|
||||
|
||||
artPlayerRef.current = new Artplayer({
|
||||
container: artRef.current,
|
||||
url: videoUrl.toLowerCase().endsWith('.mp4') ? videoUrl : `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`,
|
||||
poster: currentChannel.logo,
|
||||
volume: 0.7,
|
||||
isLive: true, // 设置为直播模式
|
||||
muted: false,
|
||||
autoplay: true,
|
||||
pip: true,
|
||||
autoSize: false,
|
||||
autoMini: false,
|
||||
screenshot: false,
|
||||
setting: false,
|
||||
loop: false,
|
||||
flip: false,
|
||||
playbackRate: false,
|
||||
aspectRatio: false,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
subtitleOffset: false,
|
||||
miniProgressBar: false,
|
||||
mutex: true,
|
||||
playsInline: true,
|
||||
autoPlayback: false,
|
||||
airplay: true,
|
||||
theme: '#22c55e',
|
||||
lang: 'zh-cn',
|
||||
hotkey: false,
|
||||
fastForward: false, // 直播不需要快进
|
||||
autoOrientation: true,
|
||||
lock: true,
|
||||
moreVideoAttr: {
|
||||
crossOrigin: 'anonymous',
|
||||
preload: 'metadata',
|
||||
},
|
||||
type: videoUrl.toLowerCase().endsWith('.mp4') ? 'mp4' : 'm3u8',
|
||||
// HLS 支持配置
|
||||
customType: {
|
||||
m3u8: function (video: HTMLVideoElement, url: string) {
|
||||
if (!Hls) {
|
||||
console.error('HLS.js 未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.hls) {
|
||||
video.hls.destroy();
|
||||
}
|
||||
const hls = new Hls({
|
||||
debug: false,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
maxBufferSize: 60 * 1000 * 1000,
|
||||
loader: CustomHlsJsLoader,
|
||||
});
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
video.hls = hls;
|
||||
|
||||
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
|
||||
console.error('HLS Error:', event, data);
|
||||
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
loading:
|
||||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||
},
|
||||
});
|
||||
|
||||
// 监听播放器事件
|
||||
artPlayerRef.current.on('ready', () => {
|
||||
setError(null);
|
||||
setIsVideoLoading(false);
|
||||
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('loadstart', () => {
|
||||
setIsVideoLoading(true);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('loadeddata', () => {
|
||||
setIsVideoLoading(false);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('canplay', () => {
|
||||
setIsVideoLoading(false);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('waiting', () => {
|
||||
setIsVideoLoading(true);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('error', (err: any) => {
|
||||
console.error('播放器错误:', err);
|
||||
});
|
||||
|
||||
if (artPlayerRef.current?.video) {
|
||||
const finalUrl = videoUrl.toLowerCase().endsWith('.mp4') ? videoUrl : `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}`;
|
||||
ensureVideoSource(
|
||||
artPlayerRef.current.video as HTMLVideoElement,
|
||||
finalUrl
|
||||
);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('创建播放器失败:', err);
|
||||
// 不设置错误,只记录日志
|
||||
}
|
||||
}, [Artplayer, Hls, videoUrl, currentChannel, loading]);
|
||||
|
||||
// 清理播放器资源
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupPlayer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 全局快捷键处理
|
||||
useEffect(() => {
|
||||
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
|
||||
// 忽略输入框中的按键事件
|
||||
if (
|
||||
(e.target as HTMLElement).tagName === 'INPUT' ||
|
||||
(e.target as HTMLElement).tagName === 'TEXTAREA'
|
||||
)
|
||||
return;
|
||||
|
||||
// 上箭头 = 音量+
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (artPlayerRef.current && artPlayerRef.current.volume < 1) {
|
||||
artPlayerRef.current.volume =
|
||||
Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10;
|
||||
artPlayerRef.current.notice.show = `音量: ${Math.round(
|
||||
artPlayerRef.current.volume * 100
|
||||
)}`;
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// 下箭头 = 音量-
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (artPlayerRef.current && artPlayerRef.current.volume > 0) {
|
||||
artPlayerRef.current.volume =
|
||||
Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10;
|
||||
artPlayerRef.current.notice.show = `音量: ${Math.round(
|
||||
artPlayerRef.current.volume * 100
|
||||
)}`;
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// 空格 = 播放/暂停
|
||||
if (e.key === ' ') {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.toggle();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// f 键 = 切换全屏
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyboardShortcuts);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyboardShortcuts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout activePath='/live'>
|
||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||
<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'>
|
||||
<div className='text-white text-4xl'>📺</div>
|
||||
{/* 旋转光环 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子效果 */}
|
||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
|
||||
<div
|
||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '1s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度指示器 */}
|
||||
<div className='mb-6 w-80 mx-auto'>
|
||||
<div className='flex justify-center space-x-2 mb-4'>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'loading' ? 'bg-green-500 scale-125' : 'bg-green-500'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'fetching' ? 'bg-green-500 scale-125' : 'bg-green-500'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready' ? 'bg-green-500 scale-125' : 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'loading' ? '33%' : loadingStage === 'fetching' ? '66%' : '100%',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载消息 */}
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
|
||||
{loadingMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout activePath='/live'>
|
||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||
<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-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>😵</div>
|
||||
{/* 脉冲效果 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<div className='space-y-4 mb-8'>
|
||||
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
哎呀,出现了一些问题
|
||||
</h2>
|
||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
|
||||
<p className='text-red-600 dark:text-red-400 font-medium'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
请检查网络连接或尝试刷新页面
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className='w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-cyan-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-cyan-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
|
||||
>
|
||||
🔄 重新尝试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/live'>
|
||||
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
|
||||
{/* 第一行:页面标题 */}
|
||||
<div className='py-1'>
|
||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2 max-w-[80%]'>
|
||||
<Radio className='w-5 h-5 text-blue-500 flex-shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate'>
|
||||
{currentSource?.name}
|
||||
{currentSource && currentChannel && (
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
{` > ${currentChannel.name}`}
|
||||
</span>
|
||||
)}
|
||||
{currentSource && !currentChannel && (
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
{` > ${currentSource.name}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 第二行:播放器和频道列表 */}
|
||||
<div className='space-y-2'>
|
||||
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
||||
<div className='hidden lg:flex justify-end'>
|
||||
<button
|
||||
onClick={() =>
|
||||
setIsChannelListCollapsed(!isChannelListCollapsed)
|
||||
}
|
||||
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
|
||||
title={
|
||||
isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isChannelListCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M9 5l7 7-7 7'
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
|
||||
{isChannelListCollapsed ? '显示' : '隐藏'}
|
||||
</span>
|
||||
|
||||
{/* 精致的状态指示点 */}
|
||||
<div
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isChannelListCollapsed
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-green-400'
|
||||
}`}
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isChannelListCollapsed
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 md:grid-cols-4'
|
||||
}`}>
|
||||
{/* 播放器 */}
|
||||
<div className={`h-full transition-all duration-300 ease-in-out ${isChannelListCollapsed ? 'col-span-1' : 'md:col-span-3'}`}>
|
||||
<div className='relative w-full h-[300px] lg:h-full'>
|
||||
<div
|
||||
ref={artRef}
|
||||
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30'
|
||||
></div>
|
||||
|
||||
{/* 视频加载蒙层 */}
|
||||
{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='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'>
|
||||
<div className='text-white text-4xl'>📺</div>
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-white animate-pulse'>
|
||||
🔄 IPTV 加载中...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<div className={`h-[300px] 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'
|
||||
}`}>
|
||||
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||
{/* 主要的 Tab 切换 */}
|
||||
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||
<div
|
||||
onClick={() => setActiveTab('channels')}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${activeTab === 'channels'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
频道
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${activeTab === 'sources'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
直播源
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道 Tab 内容 */}
|
||||
{activeTab === 'channels' && (
|
||||
<>
|
||||
{/* 分组标签 */}
|
||||
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
|
||||
{/* 切换状态提示 */}
|
||||
{isSwitchingSource && (
|
||||
<div className='flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400'>
|
||||
<div className='w-2 h-2 bg-amber-500 rounded-full animate-pulse'></div>
|
||||
切换直播源中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='flex-1 overflow-x-auto'
|
||||
ref={groupContainerRef}
|
||||
onMouseEnter={() => {
|
||||
// 鼠标进入分组标签区域时,添加滚轮事件监听
|
||||
const container = groupContainerRef.current;
|
||||
if (container) {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (container.scrollWidth > container.clientWidth) {
|
||||
e.preventDefault();
|
||||
container.scrollLeft += e.deltaY;
|
||||
}
|
||||
};
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
// 将事件处理器存储在容器上,以便后续移除
|
||||
(container as any)._wheelHandler = handleWheel;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// 鼠标离开分组标签区域时,移除滚轮事件监听
|
||||
const container = groupContainerRef.current;
|
||||
if (container && (container as any)._wheelHandler) {
|
||||
container.removeEventListener('wheel', (container as any)._wheelHandler);
|
||||
delete (container as any)._wheelHandler;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-4 min-w-max'>
|
||||
{Object.keys(groupedChannels).map((group, index) => (
|
||||
<button
|
||||
key={group}
|
||||
ref={(el) => {
|
||||
groupButtonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => handleGroupChange(group)}
|
||||
disabled={isSwitchingSource}
|
||||
className={`w-20 relative py-2 text-sm font-medium transition-colors flex-shrink-0 text-center overflow-hidden
|
||||
${isSwitchingSource
|
||||
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
|
||||
: selectedGroup === group
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
<div className='px-1 overflow-hidden whitespace-nowrap' title={group}>
|
||||
{group}
|
||||
</div>
|
||||
{selectedGroup === group && !isSwitchingSource && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-4'>
|
||||
{filteredChannels.length > 0 ? (
|
||||
filteredChannels.map(channel => {
|
||||
const isActive = channel.id === currentChannel?.id;
|
||||
return (
|
||||
<button
|
||||
key={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={processImageUrl(channel.logo)}
|
||||
alt={channel.name}
|
||||
className='w-full h-full rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
|
||||
<Tv className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
||||
</div>
|
||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
||||
暂无可用频道
|
||||
</p>
|
||||
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
|
||||
请选择其他直播源或稍后再试
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 直播源 Tab 内容 */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className='flex flex-col h-full mt-4'>
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
|
||||
{liveSources.length > 0 ? (
|
||||
liveSources.map((source) => {
|
||||
const isCurrentSource = source.key === currentSource?.key;
|
||||
return (
|
||||
<div
|
||||
key={source.key}
|
||||
onClick={() => !isCurrentSource && handleSourceChange(source)}
|
||||
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
||||
${isCurrentSource
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
|
||||
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||
}`.trim()}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<div className='w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-lg flex items-center justify-center flex-shrink-0'>
|
||||
<Radio className='w-6 h-6 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'>
|
||||
{source.name}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
{!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前标识 */}
|
||||
{isCurrentSource && (
|
||||
<div className='absolute top-2 right-2 w-2 h-2 bg-green-500 rounded-full'></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
|
||||
<Radio className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
||||
</div>
|
||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
||||
暂无可用直播源
|
||||
</p>
|
||||
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
|
||||
请检查网络连接或联系管理员添加直播源
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前频道信息 */}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LivePage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LivePageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -652,6 +652,7 @@ function SearchPageClient() {
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧...'
|
||||
autoComplete="off"
|
||||
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Cat, Clover, Film, Home, Star, Tv } from 'lucide-react';
|
||||
import { Cat, Clover, Film, Home, Radio, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -42,6 +42,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: '直播',
|
||||
href: '/live',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
return (
|
||||
<div className='w-full min-h-screen'>
|
||||
{/* 移动端头部 */}
|
||||
<MobileHeader showBackButton={['/play'].includes(activePath)} />
|
||||
<MobileHeader showBackButton={['/play', '/live'].includes(activePath)} />
|
||||
|
||||
{/* 主要布局容器 */}
|
||||
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
|
||||
@@ -26,7 +26,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
{/* 主内容区域 */}
|
||||
<div className='relative min-w-0 flex-1 transition-all duration-300'>
|
||||
{/* 桌面端左上角返回按钮 */}
|
||||
{['/play'].includes(activePath) && (
|
||||
{['/play', '/live'].includes(activePath) && (
|
||||
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
|
||||
<BackButton />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Cat, Clover, Film, Home, Menu, Search, Star, Tv } from 'lucide-react';
|
||||
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
@@ -145,6 +145,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: '直播',
|
||||
href: '/live',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -66,6 +66,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [fluidSearch, setFluidSearch] = useState(true);
|
||||
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('melody-cdn-sharon');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('melody-cdn-sharon');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
@@ -191,6 +192,11 @@ export const UserMenu: React.FC = () => {
|
||||
} else if (defaultFluidSearch !== undefined) {
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
|
||||
const savedLiveDirectConnect = localStorage.getItem('liveDirectConnect');
|
||||
if (savedLiveDirectConnect !== null) {
|
||||
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -366,6 +372,13 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLiveDirectConnectToggle = (value: boolean) => {
|
||||
setLiveDirectConnect(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('liveDirectConnect', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanDataSourceChange = (value: string) => {
|
||||
setDoubanDataSource(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -426,6 +439,7 @@ export const UserMenu: React.FC = () => {
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
setLiveDirectConnect(false);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
@@ -435,6 +449,7 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
||||
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
@@ -922,6 +937,30 @@ export const UserMenu: React.FC = () => {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 直播视频浏览器直连 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
IPTV 视频浏览器直连
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
开启 IPTV 视频浏览器直连时,需要自备 Allow CORS 插件
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={liveDirectConnect}
|
||||
onChange={(e) => handleLiveDirectConnectToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
|
||||
@@ -672,7 +672,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
{/* 年份徽章 */}
|
||||
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
|
||||
<div
|
||||
className={`absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 left-2`}
|
||||
className="absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 left-2"
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface AdminConfig {
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
LiveConfig?: {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string; // m3u 地址
|
||||
ua?: string;
|
||||
epg?: string; // 节目单
|
||||
from: 'config' | 'custom';
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface ApiSite {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface LiveCfg {
|
||||
name: string;
|
||||
url: string;
|
||||
ua?: string;
|
||||
epg?: string; // 节目单
|
||||
}
|
||||
|
||||
interface ConfigFileStruct {
|
||||
cache_time?: number;
|
||||
api_site?: {
|
||||
@@ -21,6 +28,9 @@ interface ConfigFileStruct {
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}[];
|
||||
lives?: {
|
||||
[key: string]: LiveCfg;
|
||||
}
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
@@ -46,6 +56,7 @@ export const API_CONFIG = {
|
||||
// 在模块加载时根据环境决定配置来源
|
||||
let cachedConfig: AdminConfig;
|
||||
|
||||
|
||||
// 从配置文件补充管理员配置
|
||||
export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||
let fileConfig: ConfigFileStruct;
|
||||
@@ -131,6 +142,43 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.CustomCategories = Array.from(currentCustomCategories.values());
|
||||
|
||||
const livesFromFile = Object.entries(fileConfig.lives || []);
|
||||
const currentLives = new Map(
|
||||
(adminConfig.LiveConfig || []).map((l) => [l.key, l])
|
||||
);
|
||||
livesFromFile.forEach(([key, site]) => {
|
||||
const existingLive = currentLives.get(key);
|
||||
if (existingLive) {
|
||||
existingLive.name = site.name;
|
||||
existingLive.url = site.url;
|
||||
existingLive.ua = site.ua;
|
||||
existingLive.epg = site.epg;
|
||||
} else {
|
||||
// 如果不存在,创建新条目
|
||||
currentLives.set(key, {
|
||||
key,
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
ua: site.ua,
|
||||
epg: site.epg,
|
||||
channelNumber: 0,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查现有 LiveConfig 是否在 fileConfig.lives 中,如果不在则标记为 custom
|
||||
const livesFromFileKeys = new Set(livesFromFile.map(([key]) => key));
|
||||
currentLives.forEach((live) => {
|
||||
if (!livesFromFileKeys.has(live.key)) {
|
||||
live.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.LiveConfig = Array.from(currentLives.values());
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
@@ -176,6 +224,7 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
},
|
||||
SourceConfig: [],
|
||||
CustomCategories: [],
|
||||
LiveConfig: [],
|
||||
};
|
||||
|
||||
// 补充用户信息
|
||||
@@ -220,6 +269,23 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
});
|
||||
});
|
||||
|
||||
// 从配置文件中补充直播源信息
|
||||
Object.entries(cfgFile.lives || []).forEach(([key, live]) => {
|
||||
if (!adminConfig.LiveConfig) {
|
||||
adminConfig.LiveConfig = [];
|
||||
}
|
||||
adminConfig.LiveConfig.push({
|
||||
key,
|
||||
name: live.name,
|
||||
url: live.url,
|
||||
ua: live.ua,
|
||||
epg: live.epg,
|
||||
channelNumber: 0,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
@@ -261,6 +327,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (!adminConfig.CustomCategories || !Array.isArray(adminConfig.CustomCategories)) {
|
||||
adminConfig.CustomCategories = [];
|
||||
}
|
||||
if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) {
|
||||
adminConfig.LiveConfig = [];
|
||||
}
|
||||
|
||||
// 站长变更自检
|
||||
const ownerUser = process.env.USERNAME;
|
||||
@@ -311,6 +380,17 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
seenCustomCategoryKeys.add(category.query + category.type);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 直播源去重
|
||||
const seenLiveKeys = new Set<string>();
|
||||
adminConfig.LiveConfig = adminConfig.LiveConfig.filter((live) => {
|
||||
if (seenLiveKeys.has(live.key)) {
|
||||
return false;
|
||||
}
|
||||
seenLiveKeys.add(live.key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const defaultUA = 'okHttp/Mod-1.1.0'
|
||||
|
||||
export interface LiveChannels {
|
||||
channelNumber: number;
|
||||
channels: {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const cachedLiveChannels: { [key: string]: LiveChannels } = {};
|
||||
|
||||
export function deleteCachedLiveChannels(key: string) {
|
||||
delete cachedLiveChannels[key];
|
||||
}
|
||||
|
||||
export async function getCachedLiveChannels(key: string): Promise<LiveChannels | null> {
|
||||
if (!cachedLiveChannels[key]) {
|
||||
const config = await getConfig();
|
||||
const liveInfo = config.LiveConfig?.find(live => live.key === key);
|
||||
if (!liveInfo) {
|
||||
return null;
|
||||
}
|
||||
const channelNum = await refreshLiveChannels(liveInfo);
|
||||
if (channelNum === 0) {
|
||||
return null;
|
||||
}
|
||||
liveInfo.channelNumber = channelNum;
|
||||
await db.saveAdminConfig(config);
|
||||
}
|
||||
return cachedLiveChannels[key] || null;
|
||||
}
|
||||
|
||||
export async function refreshLiveChannels(liveInfo: {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string;
|
||||
ua?: string;
|
||||
epg?: string;
|
||||
from: 'config' | 'custom';
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
}): Promise<number> {
|
||||
if (cachedLiveChannels[liveInfo.key]) {
|
||||
delete cachedLiveChannels[liveInfo.key];
|
||||
}
|
||||
const ua = liveInfo.ua || defaultUA;
|
||||
const response = await fetch(liveInfo.url, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
const data = await response.text();
|
||||
const channels = parseM3U(liveInfo.key, data);
|
||||
cachedLiveChannels[liveInfo.key] = {
|
||||
channelNumber: channels.length,
|
||||
channels: channels,
|
||||
};
|
||||
return channels.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析M3U文件内容,提取频道信息
|
||||
* @param m3uContent M3U文件的内容字符串
|
||||
* @returns 频道信息数组
|
||||
*/
|
||||
export function parseM3U(sourceKey: string, m3uContent: string): {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[] {
|
||||
const channels: {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[] = [];
|
||||
|
||||
const lines = m3uContent.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
|
||||
let channelIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// 检查是否是 #EXTINF 行
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
// 提取 tvg-id
|
||||
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/);
|
||||
const tvgId = tvgIdMatch ? tvgIdMatch[1] : '';
|
||||
|
||||
// 提取 tvg-name
|
||||
const tvgNameMatch = line.match(/tvg-name="([^"]*)"/);
|
||||
const tvgName = tvgNameMatch ? tvgNameMatch[1] : '';
|
||||
|
||||
// 提取 tvg-logo
|
||||
const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/);
|
||||
const logo = tvgLogoMatch ? tvgLogoMatch[1] : '';
|
||||
|
||||
// 提取 group-title
|
||||
const groupTitleMatch = line.match(/group-title="([^"]*)"/);
|
||||
const group = groupTitleMatch ? groupTitleMatch[1] : '无分组';
|
||||
|
||||
// 提取标题(#EXTINF 行最后的逗号后面的内容)
|
||||
const titleMatch = line.match(/,([^,]*)$/);
|
||||
const title = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
// 优先使用 tvg-name,如果没有则使用标题
|
||||
const name = title || tvgName || '';
|
||||
|
||||
// 检查下一行是否是URL
|
||||
if (i + 1 < lines.length && !lines[i + 1].startsWith('#')) {
|
||||
const url = lines[i + 1];
|
||||
|
||||
// 只有当有名称和URL时才添加到结果中
|
||||
if (name && url) {
|
||||
channels.push({
|
||||
id: `${sourceKey}-${channelIndex}`,
|
||||
tvgId,
|
||||
name,
|
||||
logo,
|
||||
group,
|
||||
url
|
||||
});
|
||||
channelIndex++;
|
||||
}
|
||||
|
||||
// 跳过下一行,因为已经处理了
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
// utils/urlResolver.js
|
||||
export function resolveUrl(baseUrl: string, relativePath: string) {
|
||||
try {
|
||||
// 如果已经是完整的 URL,直接返回
|
||||
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// 如果是协议相对路径 (//example.com/path)
|
||||
if (relativePath.startsWith('//')) {
|
||||
const baseUrlObj = new URL(baseUrl);
|
||||
return `${baseUrlObj.protocol}${relativePath}`;
|
||||
}
|
||||
|
||||
// 使用 URL 构造函数处理相对路径
|
||||
const baseUrlObj = new URL(baseUrl);
|
||||
const resolvedUrl = new URL(relativePath, baseUrlObj);
|
||||
return resolvedUrl.href;
|
||||
} catch (error) {
|
||||
// 降级处理
|
||||
return fallbackUrlResolve(baseUrl, relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackUrlResolve(baseUrl: string, relativePath: string) {
|
||||
// 移除 baseUrl 末尾的文件名,保留目录路径
|
||||
let base = baseUrl;
|
||||
if (!base.endsWith('/')) {
|
||||
base = base.substring(0, base.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
// 处理不同类型的相对路径
|
||||
if (relativePath.startsWith('/')) {
|
||||
// 绝对路径 (/path/to/file)
|
||||
const urlObj = new URL(base);
|
||||
return `${urlObj.protocol}//${urlObj.host}${relativePath}`;
|
||||
} else if (relativePath.startsWith('../')) {
|
||||
// 上级目录相对路径 (../path/to/file)
|
||||
const segments = base.split('/').filter(s => s);
|
||||
const relativeSegments = relativePath.split('/').filter(s => s);
|
||||
|
||||
for (const segment of relativeSegments) {
|
||||
if (segment === '..') {
|
||||
segments.pop();
|
||||
} else if (segment !== '.') {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
const urlObj = new URL(base);
|
||||
return `${urlObj.protocol}//${urlObj.host}/${segments.join('/')}`;
|
||||
} else {
|
||||
// 当前目录相对路径 (file.ts 或 ./file.ts)
|
||||
const cleanRelative = relativePath.startsWith('./') ? relativePath.slice(2) : relativePath;
|
||||
return base + cleanRelative;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 M3U8 的基础 URL
|
||||
export function getBaseUrl(m3u8Url: string) {
|
||||
try {
|
||||
const url = new URL(m3u8Url);
|
||||
// 如果 URL 以 .m3u8 结尾,移除文件名
|
||||
if (url.pathname.endsWith('.m3u8')) {
|
||||
url.pathname = url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1);
|
||||
} else if (!url.pathname.endsWith('/')) {
|
||||
url.pathname += '/';
|
||||
}
|
||||
return url.protocol + "//" + url.host + url.pathname;
|
||||
} catch (error) {
|
||||
return m3u8Url.endsWith('/') ? m3u8Url : m3u8Url + '/';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user