Merge branch 'dev' of https://github.com/mtvpls/moontvplus-dev into dev
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"flv.js": "^1.6.2",
|
||||||
"framer-motion": "^12.18.1",
|
"framer-motion": "^12.18.1",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"hls.js": "^1.6.10",
|
"hls.js": "^1.6.10",
|
||||||
|
|||||||
Generated
+21
@@ -59,6 +59,9 @@ importers:
|
|||||||
crypto-js:
|
crypto-js:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
|
flv.js:
|
||||||
|
specifier: ^1.6.2
|
||||||
|
version: 1.6.2
|
||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.18.1
|
specifier: ^12.18.1
|
||||||
version: 12.23.26([email protected]([email protected]))([email protected])
|
version: 12.23.26([email protected]([email protected]))([email protected])
|
||||||
@@ -3186,6 +3189,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3455,6 +3461,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -6158,6 +6167,9 @@ packages:
|
|||||||
webpack-cli:
|
webpack-cli:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==}
|
resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==}
|
||||||
|
|
||||||
@@ -10176,6 +10188,8 @@ snapshots:
|
|||||||
is-date-object: 1.1.0
|
is-date-object: 1.1.0
|
||||||
is-symbol: 1.1.1
|
is-symbol: 1.1.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -10527,6 +10541,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
es6-promise: 4.2.8
|
||||||
|
webworkify-webpack: 2.1.5
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
@@ -13960,6 +13979,8 @@ snapshots:
|
|||||||
- esbuild
|
- esbuild
|
||||||
- uglify-js
|
- uglify-js
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.4.24
|
iconv-lite: 0.4.24
|
||||||
|
|||||||
+277
-2
@@ -33,6 +33,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Globe,
|
||||||
Mail,
|
Mail,
|
||||||
Palette,
|
Palette,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -10715,6 +10716,267 @@ const LiveSourceConfig = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 网络直播配置组件
|
||||||
|
const WebLiveConfig = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
|
const [webLiveSources, setWebLiveSources] = useState<any[]>([]);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingSource, setEditingSource] = useState<any | null>(null);
|
||||||
|
const [newSource, setNewSource] = useState({
|
||||||
|
name: '',
|
||||||
|
platform: 'huya',
|
||||||
|
roomId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.WebLiveConfig) {
|
||||||
|
setWebLiveSources(config.WebLiveConfig);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const callApi = async (body: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/admin/web-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 handleAdd = () => {
|
||||||
|
if (!newSource.name || !newSource.platform || !newSource.roomId) return;
|
||||||
|
withLoading('addWebLive', async () => {
|
||||||
|
await callApi({
|
||||||
|
action: 'add',
|
||||||
|
name: newSource.name,
|
||||||
|
platform: newSource.platform,
|
||||||
|
roomId: newSource.roomId,
|
||||||
|
});
|
||||||
|
setNewSource({ name: '', platform: 'huya', roomId: '' });
|
||||||
|
setShowAddForm(false);
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!editingSource || !editingSource.name || !editingSource.roomId) return;
|
||||||
|
withLoading('editWebLive', async () => {
|
||||||
|
await callApi({
|
||||||
|
action: 'edit',
|
||||||
|
key: editingSource.key,
|
||||||
|
name: editingSource.name,
|
||||||
|
platform: editingSource.platform,
|
||||||
|
roomId: editingSource.roomId,
|
||||||
|
});
|
||||||
|
setEditingSource(null);
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: string) => {
|
||||||
|
const target = webLiveSources.find((s) => s.key === key);
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.disabled ? 'enable' : 'disable';
|
||||||
|
withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (key: string) => {
|
||||||
|
withLoading(`deleteWebLive_${key}`, () => callApi({ action: 'delete', key })).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
|
||||||
|
>
|
||||||
|
{showAddForm ? '取消' : '添加网络直播'}
|
||||||
|
</button>
|
||||||
|
</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={newSource.name}
|
||||||
|
onChange={(e) => setNewSource((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'
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newSource.platform}
|
||||||
|
onChange={(e) => setNewSource((prev) => ({ ...prev, platform: 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'
|
||||||
|
>
|
||||||
|
<option value='huya'>虎牙</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='房间ID'
|
||||||
|
value={newSource.roomId}
|
||||||
|
onChange={(e) => setNewSource((prev) => ({ ...prev, roomId: 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={handleAdd}
|
||||||
|
disabled={!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive')}
|
||||||
|
className={`w-full sm:w-auto px-4 py-2 ${
|
||||||
|
!newSource.name || !newSource.platform || !newSource.roomId || isLoading('addWebLive')
|
||||||
|
? buttonStyles.disabled
|
||||||
|
: buttonStyles.success
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading('addWebLive') ? '添加中...' : '添加'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSource && (
|
||||||
|
<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='flex items-center justify-between'>
|
||||||
|
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>编辑: {editingSource.name}</h5>
|
||||||
|
<button onClick={() => setEditingSource(null)} className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>名称</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.name}
|
||||||
|
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, name: e.target.value } : null)}
|
||||||
|
className='w-full 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>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>直播类型</label>
|
||||||
|
<select
|
||||||
|
value={editingSource.platform}
|
||||||
|
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, platform: e.target.value } : null)}
|
||||||
|
className='w-full 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'
|
||||||
|
>
|
||||||
|
<option value='huya'>虎牙</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>房间ID</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={editingSource.roomId}
|
||||||
|
onChange={(e) => setEditingSource((prev: any) => prev ? { ...prev, roomId: e.target.value } : null)}
|
||||||
|
className='w-full 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>
|
||||||
|
<div className='flex justify-end space-x-2'>
|
||||||
|
<button onClick={() => setEditingSource(null)} className={buttonStyles.secondary}>取消</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={!editingSource.name || !editingSource.roomId || isLoading('editWebLive')}
|
||||||
|
className={`${!editingSource.name || !editingSource.roomId || isLoading('editWebLive') ? buttonStyles.disabled : buttonStyles.success}`}
|
||||||
|
>
|
||||||
|
{isLoading('editWebLive') ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='border border-gray-200 dark:border-gray-700 rounded-lg overflow-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||||
|
<tr>
|
||||||
|
<th className='px-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>名称</th>
|
||||||
|
<th className='hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>直播类型</th>
|
||||||
|
<th className='hidden sm:table-cell px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>房间ID</th>
|
||||||
|
<th className='px-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>状态</th>
|
||||||
|
<th className='px-3 sm:px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
{webLiveSources.map((source) => (
|
||||||
|
<tr key={source.key} className='hover:bg-gray-50 dark:hover:bg-gray-800'>
|
||||||
|
<td className='px-3 sm:px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>
|
||||||
|
<div>{source.name}</div>
|
||||||
|
<div className='sm:hidden text-xs text-gray-500 dark:text-gray-400 mt-1'>{source.platform === 'huya' ? '虎牙' : source.platform} · {source.roomId}</div>
|
||||||
|
</td>
|
||||||
|
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.platform === 'huya' ? '虎牙' : source.platform}</td>
|
||||||
|
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.roomId}</td>
|
||||||
|
<td className='px-3 sm:px-6 py-4 whitespace-nowrap'>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full whitespace-nowrap ${!source.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'}`}>
|
||||||
|
{!source.disabled ? '启用中' : '已禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-3 sm:px-6 py-4 text-right text-sm whitespace-nowrap'>
|
||||||
|
<div className='flex flex-col sm:flex-row gap-1 sm:gap-2 items-end sm:items-center justify-end'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(source.key)}
|
||||||
|
disabled={isLoading(`toggleWebLive_${source.key}`)}
|
||||||
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled ? buttonStyles.roundedDanger : buttonStyles.roundedSuccess} ${isLoading(`toggleWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{!source.disabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
{source.from !== 'config' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSource(source)}
|
||||||
|
disabled={isLoading(`editWebLive_${source.key}`)}
|
||||||
|
className={`${buttonStyles.roundedPrimary} ${isLoading(`editWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(source.key)}
|
||||||
|
disabled={isLoading(`deleteWebLive_${source.key}`)}
|
||||||
|
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteWebLive_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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() {
|
function AdminPageClient() {
|
||||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
const { isLoading, withLoading } = useLoadingState();
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
@@ -10732,6 +10994,7 @@ function AdminPageClient() {
|
|||||||
xiaoyaConfig: false,
|
xiaoyaConfig: false,
|
||||||
aiConfig: false,
|
aiConfig: false,
|
||||||
liveSource: false,
|
liveSource: false,
|
||||||
|
webLive: false,
|
||||||
siteConfig: false,
|
siteConfig: false,
|
||||||
registrationConfig: false,
|
registrationConfig: false,
|
||||||
categoryConfig: false,
|
categoryConfig: false,
|
||||||
@@ -11045,9 +11308,9 @@ function AdminPageClient() {
|
|||||||
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
|
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* 直播源配置标签 */}
|
{/* 电视直播源配置标签 */}
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
title='直播源配置'
|
title='电视直播源配置'
|
||||||
icon={
|
icon={
|
||||||
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
|
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
}
|
}
|
||||||
@@ -11057,6 +11320,18 @@ function AdminPageClient() {
|
|||||||
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
{/* 网络直播配置标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='网络直播配置'
|
||||||
|
icon={
|
||||||
|
<Globe size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.webLive}
|
||||||
|
onToggle={() => toggleTab('webLive')}
|
||||||
|
>
|
||||||
|
<WebLiveConfig config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* 私人影库大类 */}
|
{/* 私人影库大类 */}
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
title='私人影库'
|
title='私人影库'
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
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 userInfo = await db.getUserInfoV2(username || '');
|
||||||
|
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.WebLiveConfig) {
|
||||||
|
config.WebLiveConfig = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'add': {
|
||||||
|
const { name, platform, roomId } = body;
|
||||||
|
if (!name || !platform || !roomId) {
|
||||||
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `web_${Date.now()}`;
|
||||||
|
if (config.WebLiveConfig.some((s) => s.key === key)) {
|
||||||
|
return NextResponse.json({ error: 'Key已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
config.WebLiveConfig.push({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
platform,
|
||||||
|
roomId,
|
||||||
|
from: 'custom',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
const { key } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (source.from === 'config') {
|
||||||
|
return NextResponse.json({ error: '无法删除配置文件中的源' }, { status: 400 });
|
||||||
|
}
|
||||||
|
config.WebLiveConfig = config.WebLiveConfig.filter((s) => s.key !== key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'enable': {
|
||||||
|
const { key } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
source.disabled = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'disable': {
|
||||||
|
const { key } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
source.disabled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'edit': {
|
||||||
|
const { key, name, platform, roomId } = body;
|
||||||
|
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (source.from === 'config') {
|
||||||
|
return NextResponse.json({ error: '无法编辑配置文件中的源' }, { status: 400 });
|
||||||
|
}
|
||||||
|
source.name = name;
|
||||||
|
source.platform = platform;
|
||||||
|
source.roomId = roomId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sort': {
|
||||||
|
const { keys } = body;
|
||||||
|
if (!Array.isArray(keys)) {
|
||||||
|
return NextResponse.json({ error: '无效的排序数据' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sortedSources = keys
|
||||||
|
.map((key) => config.WebLiveConfig!.find((s) => s.key === key))
|
||||||
|
.filter((s): s is NonNullable<typeof s> => s !== undefined);
|
||||||
|
config.WebLiveConfig = sortedSources;
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamRes = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Referer': 'https://www.huya.com/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!streamRes.ok) {
|
||||||
|
return NextResponse.json({ error: '无法获取直播流' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(streamRes.body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': streamRes.headers.get('Content-Type') || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : '代理失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
if (!config?.WebLiveConfig) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = config.WebLiveConfig.filter(s => !s.disabled);
|
||||||
|
return NextResponse.json(sources);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : '获取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function getAntiCode(oldAntiCode: string, streamName: string): string {
|
||||||
|
const paramsT = 100;
|
||||||
|
const sdkVersion = 2403051612;
|
||||||
|
const t13 = Date.now();
|
||||||
|
const sdkSid = t13;
|
||||||
|
const initUuid = (Math.floor(t13 % 10000000000 * 1000) + Math.floor(1000 * Math.random())) % 4294967295;
|
||||||
|
const uid = Math.floor(Math.random() * (1400009999999 - 1400000000000 + 1)) + 1400000000000;
|
||||||
|
const seqId = uid + sdkSid;
|
||||||
|
const targetUnixTime = Math.floor((t13 + 110624) / 1000);
|
||||||
|
const wsTime = targetUnixTime.toString(16).toLowerCase();
|
||||||
|
|
||||||
|
const urlQuery = new URLSearchParams(oldAntiCode);
|
||||||
|
const fm = urlQuery.get('fm');
|
||||||
|
if (!fm) return oldAntiCode;
|
||||||
|
|
||||||
|
const wsSecretPf = Buffer.from(decodeURIComponent(fm), 'base64').toString().split('_')[0];
|
||||||
|
const wsSecretHash = crypto.createHash('md5').update(`${seqId}|${urlQuery.get('ctype')}|${paramsT}`).digest('hex');
|
||||||
|
const wsSecret = `${wsSecretPf}_${uid}_${streamName}_${wsSecretHash}_${wsTime}`;
|
||||||
|
const wsSecretMd5 = crypto.createHash('md5').update(wsSecret).digest('hex');
|
||||||
|
|
||||||
|
return `wsSecret=${wsSecretMd5}&wsTime=${wsTime}&seqid=${seqId}&ctype=${urlQuery.get('ctype')}&ver=1&fs=${urlQuery.get('fs')}&uuid=${initUuid}&u=${uid}&t=${paramsT}&sv=${sdkVersion}&sdk_sid=${sdkSid}&codec=264`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const roomId = searchParams.get('roomId');
|
||||||
|
|
||||||
|
if (!platform || !roomId) {
|
||||||
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'huya') {
|
||||||
|
const res = await fetch(`https://www.huya.com/${roomId}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
const match = html.match(/stream:\s*(\{"data".*?),"iWebDefaultBitRate"/);
|
||||||
|
if (!match) {
|
||||||
|
return NextResponse.json({ error: '未找到直播数据' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = JSON.parse(match[1] + '}');
|
||||||
|
const streamInfo = jsonData.data?.[0]?.gameStreamInfoList?.[0];
|
||||||
|
|
||||||
|
if (!streamInfo) {
|
||||||
|
return NextResponse.json({ error: '直播未开启' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sFlvUrl, sStreamName, sFlvUrlSuffix, sFlvAntiCode } = streamInfo;
|
||||||
|
const newAntiCode = getAntiCode(sFlvAntiCode, sStreamName);
|
||||||
|
const streamUrl = `${sFlvUrl}/${sStreamName}.${sFlvUrlSuffix}?${newAntiCode}`;
|
||||||
|
const proxyUrl = `/api/web-live/proxy/proxy.flv?url=${encodeURIComponent(streamUrl)}`;
|
||||||
|
|
||||||
|
return NextResponse.json({ url: proxyUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: '不支持的平台' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : '获取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -607,6 +607,13 @@ function PlayPageClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否禁用了自动装填弹幕
|
||||||
|
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||||
|
if (disableAutoLoad) {
|
||||||
|
console.log('[弹幕] 已禁用自动装填弹幕,跳过自动加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查集数是否有效且是否已改变
|
// 检查集数是否有效且是否已改变
|
||||||
if (currentEpisodeIndex < 0 || !videoTitle) {
|
if (currentEpisodeIndex < 0 || !videoTitle) {
|
||||||
return;
|
return;
|
||||||
@@ -3644,6 +3651,9 @@ function PlayPageClient() {
|
|||||||
// 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑)
|
// 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑)
|
||||||
const preloadNextEpisodeDanmaku = async () => {
|
const preloadNextEpisodeDanmaku = async () => {
|
||||||
try {
|
try {
|
||||||
|
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||||
|
if (disableAutoLoad) return;
|
||||||
|
|
||||||
const title = videoTitleRef.current;
|
const title = videoTitleRef.current;
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return;
|
return;
|
||||||
@@ -3970,6 +3980,9 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 自动搜索并加载弹幕
|
// 自动搜索并加载弹幕
|
||||||
const autoSearchDanmaku = async () => {
|
const autoSearchDanmaku = async () => {
|
||||||
|
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||||
|
if (disableAutoLoad) return;
|
||||||
|
|
||||||
const title = videoTitleRef.current;
|
const title = videoTitleRef.current;
|
||||||
if (!title) {
|
if (!title) {
|
||||||
console.warn('视频标题为空,无法自动搜索弹幕');
|
console.warn('视频标题为空,无法自动搜索弹幕');
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
import { Radio } from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
let Artplayer: any = null;
|
||||||
|
let Hls: any = null;
|
||||||
|
let flvjs: any = null;
|
||||||
|
|
||||||
|
export default function WebLivePage() {
|
||||||
|
const artRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const artPlayerRef = useRef<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingStage, setLoadingStage] = useState<'loading' | 'fetching' | 'ready'>('loading');
|
||||||
|
const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...');
|
||||||
|
const [sources, setSources] = useState<any[]>([]);
|
||||||
|
const [currentSource, setCurrentSource] = useState<any | null>(null);
|
||||||
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'rooms' | 'platforms'>('rooms');
|
||||||
|
const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false);
|
||||||
|
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'referrer';
|
||||||
|
meta.content = 'no-referrer';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('artplayer').then(mod => { Artplayer = mod.default; });
|
||||||
|
import('hls.js').then(mod => { Hls = mod.default; });
|
||||||
|
import('flv.js').then(mod => { flvjs = mod.default; });
|
||||||
|
}
|
||||||
|
fetchSources();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(meta);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSources = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadingStage('loading');
|
||||||
|
setLoadingMessage('正在加载直播源...');
|
||||||
|
const res = await fetch('/api/web-live/sources');
|
||||||
|
if (res.ok) {
|
||||||
|
setLoadingStage('fetching');
|
||||||
|
const data = await res.json();
|
||||||
|
setSources(data);
|
||||||
|
setLoadingStage('ready');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取直播源失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function m3u8Loader(video: HTMLVideoElement, url: string) {
|
||||||
|
if (!Hls) return;
|
||||||
|
const hls = new Hls({ debug: false, enableWorker: true, lowLatencyMode: true });
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
(video as any).hls = hls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flvLoader(video: HTMLVideoElement, url: string) {
|
||||||
|
if (!flvjs) return;
|
||||||
|
const flvPlayer = flvjs.createPlayer({ type: 'flv', url, isLive: true });
|
||||||
|
flvPlayer.attachMediaElement(video);
|
||||||
|
flvPlayer.on(flvjs.Events.ERROR, (errorType: string, errorDetail: string) => {
|
||||||
|
console.error('FLV.js error:', errorType, errorDetail);
|
||||||
|
setErrorMessage(`播放失败: ${errorType} - ${errorDetail}`);
|
||||||
|
setVideoUrl('');
|
||||||
|
});
|
||||||
|
flvPlayer.load();
|
||||||
|
(video as any).flvPlayer = flvPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Artplayer || !Hls || !flvjs || !videoUrl || !artRef.current) return;
|
||||||
|
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
artPlayerRef.current = new Artplayer({
|
||||||
|
container: artRef.current,
|
||||||
|
url: videoUrl,
|
||||||
|
isLive: true,
|
||||||
|
autoplay: true,
|
||||||
|
fullscreen: true,
|
||||||
|
customType: {
|
||||||
|
m3u8: m3u8Loader,
|
||||||
|
flv: flvLoader
|
||||||
|
},
|
||||||
|
icons: { loading: '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 100 100"><circle cx="50" cy="50" fill="none" stroke="currentColor" stroke-width="4" r="35" stroke-dasharray="164.93361431346415 56.97787143782138"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"/></circle></svg>' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
artPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoUrl]);
|
||||||
|
|
||||||
|
const handleSourceClick = async (source: any) => {
|
||||||
|
setCurrentSource(source);
|
||||||
|
setIsVideoLoading(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/web-live/stream?platform=${source.platform}&roomId=${source.roomId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setVideoUrl(data.url);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setErrorMessage(data.error || '获取直播流失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取直播流失败:', err);
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : '获取直播流失败');
|
||||||
|
} finally {
|
||||||
|
setIsVideoLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoomUrl = (source: any) => {
|
||||||
|
if (source.platform === 'huya') {
|
||||||
|
return `https://huya.com/${source.roomId}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const platforms = Array.from(new Set(sources.map(s => s.platform)));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/web-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/web-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 || '网络直播'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<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'
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className='absolute inset-0 bg-black/90 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[600] transition-all duration-300'>
|
||||||
|
<div className='text-center max-w-md mx-auto px-6'>
|
||||||
|
<div className='relative mb-8'>
|
||||||
|
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||||
|
<div className='text-white text-4xl'>⚠️</div>
|
||||||
|
<div className='absolute -inset-2 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl opacity-20 animate-pulse'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<h3 className='text-xl font-semibold text-white'>获取直播流失败</h3>
|
||||||
|
<div className='bg-orange-500/20 border border-orange-500/30 rounded-lg p-4'>
|
||||||
|
<p className='text-orange-300 font-medium'>{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-gray-300'>请尝试其他房间</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isVideoLoading && (
|
||||||
|
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[500] transition-all duration-300'>
|
||||||
|
<div className='text-center max-w-md mx-auto px-6'>
|
||||||
|
<div className='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'>🔄 加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 外部播放器按钮 */}
|
||||||
|
{currentSource && (
|
||||||
|
<div className='mt-3 px-2 lg:flex-shrink-0 flex justify-end'>
|
||||||
|
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-2 border border-gray-200/50 dark:border-gray-700/50 w-full lg:w-auto overflow-x-auto'>
|
||||||
|
<div className='flex gap-1.5 justify-end lg:flex-wrap items-center'>
|
||||||
|
{/* 网页播放 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const roomUrl = getRoomUrl(currentSource);
|
||||||
|
if (roomUrl) {
|
||||||
|
window.open(roomUrl, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='网页播放'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className='w-4 h-4 flex-shrink-0 text-gray-700 dark:text-gray-200'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth={2}
|
||||||
|
d='M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
网页播放
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* PotPlayer */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(`potplayer://${videoUrl}`, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='PotPlayer'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/players/potplayer.png'
|
||||||
|
alt='PotPlayer'
|
||||||
|
className='w-4 h-4 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
PotPlayer
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* VLC */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(`vlc://${videoUrl}`, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='VLC'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/players/vlc.png'
|
||||||
|
alt='VLC'
|
||||||
|
className='w-4 h-4 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
VLC
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* MPV */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(`mpv://${videoUrl}`, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='MPV'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/players/mpv.png'
|
||||||
|
alt='MPV'
|
||||||
|
className='w-4 h-4 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
MPV
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* MX Player */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(
|
||||||
|
`intent://${videoUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
|
||||||
|
currentSource?.name || '直播'
|
||||||
|
)};end`,
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='MX Player'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/players/mxplayer.png'
|
||||||
|
alt='MX Player'
|
||||||
|
className='w-4 h-4 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
MX Player
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* nPlayer */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(`nplayer-${videoUrl}`, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='nPlayer'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/players/nplayer.png'
|
||||||
|
alt='nPlayer'
|
||||||
|
className='w-4 h-4 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
nPlayer
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* IINA */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(
|
||||||
|
`iina://weblink?url=${encodeURIComponent(videoUrl)}`,
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
|
title='IINA'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/players/iina.png'
|
||||||
|
alt='IINA'
|
||||||
|
className='w-4 h-4 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
|
IINA
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</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'>
|
||||||
|
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveTab('rooms')}
|
||||||
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'rooms' ? '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'}`}
|
||||||
|
>
|
||||||
|
房间
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveTab('platforms')}
|
||||||
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'platforms' ? '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'}`}
|
||||||
|
>
|
||||||
|
平台
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'rooms' && (
|
||||||
|
<div className='flex-1 overflow-y-auto space-y-2 pb-4 mt-4'>
|
||||||
|
{sources.length > 0 ? (
|
||||||
|
sources.map((source) => {
|
||||||
|
const isActive = source.key === currentSource?.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={source.key}
|
||||||
|
onClick={() => handleSourceClick(source)}
|
||||||
|
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${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'>
|
||||||
|
<Radio 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'>{source.name}</div>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>房间ID: {source.roomId}</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'>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'platforms' && (
|
||||||
|
<div className='flex flex-col h-full mt-4'>
|
||||||
|
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
|
||||||
|
{platforms.length > 0 ? (
|
||||||
|
platforms.map((platform) => (
|
||||||
|
<div key={platform} className='flex items-start gap-3 px-2 py-3 rounded-lg bg-gray-200/50 dark:bg-white/10'>
|
||||||
|
<div className='w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||||
|
{platform === 'huya' ? (
|
||||||
|
<img src='https://hd.huya.com/favicon.ico' alt='虎牙' className='w-8 h-8' />
|
||||||
|
) : (
|
||||||
|
<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'>
|
||||||
|
{platform === 'huya' ? '虎牙' : platform}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
{sources.filter(s => s.platform === platform).length} 个房间
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, FolderOpen, Home, Radio, Star, Tv, Users } from 'lucide-react';
|
import { Cat, Clover, Film, FolderOpen, Globe, Home, Star, Tv, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -51,10 +51,15 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
href: '/douban?type=show',
|
href: '/douban?type=show',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Radio,
|
icon: Tv,
|
||||||
label: '直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,10 +89,15 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
href: '/douban?type=show',
|
href: '/douban?type=show',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Radio,
|
icon: Tv,
|
||||||
label: '直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, FolderOpen, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react';
|
import { Cat, Clover, Film, FolderOpen, Globe, Home, Menu, Search, Star, Tv, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -143,10 +143,15 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
href: '/douban?type=show',
|
href: '/douban?type=show',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Radio,
|
icon: Tv,
|
||||||
label: '直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -175,10 +180,15 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
href: '/douban?type=show',
|
href: '/douban?type=show',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Radio,
|
icon: Tv,
|
||||||
label: '直播',
|
label: '电视直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
label: '网络直播',
|
||||||
|
href: '/web-live',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||||
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
||||||
|
const [disableAutoLoadDanmaku, setDisableAutoLoadDanmaku] = useState(false);
|
||||||
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
||||||
|
|
||||||
// 邮件通知设置
|
// 邮件通知设置
|
||||||
@@ -402,6 +403,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
setNextEpisodeDanmakuPreload(savedNextEpisodeDanmakuPreload === 'true');
|
setNextEpisodeDanmakuPreload(savedNextEpisodeDanmakuPreload === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDisableAutoLoadDanmaku = localStorage.getItem('disableAutoLoadDanmaku');
|
||||||
|
if (savedDisableAutoLoadDanmaku !== null) {
|
||||||
|
setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
// 加载首页模块配置
|
// 加载首页模块配置
|
||||||
const savedHomeModules = localStorage.getItem('homeModules');
|
const savedHomeModules = localStorage.getItem('homeModules');
|
||||||
if (savedHomeModules !== null) {
|
if (savedHomeModules !== null) {
|
||||||
@@ -758,6 +764,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDisableAutoLoadDanmakuToggle = (value: boolean) => {
|
||||||
|
setDisableAutoLoadDanmaku(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('disableAutoLoadDanmaku', String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchTraditionalToSimplifiedToggle = (value: boolean) => {
|
const handleSearchTraditionalToSimplifiedToggle = (value: boolean) => {
|
||||||
setSearchTraditionalToSimplified(value);
|
setSearchTraditionalToSimplified(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -857,6 +870,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
setBufferStrategy('medium');
|
setBufferStrategy('medium');
|
||||||
setNextEpisodePreCache(true);
|
setNextEpisodePreCache(true);
|
||||||
setNextEpisodeDanmakuPreload(true);
|
setNextEpisodeDanmakuPreload(true);
|
||||||
|
setDisableAutoLoadDanmaku(false);
|
||||||
setHomeModules(defaultHomeModules);
|
setHomeModules(defaultHomeModules);
|
||||||
setSearchTraditionalToSimplified(false);
|
setSearchTraditionalToSimplified(false);
|
||||||
|
|
||||||
@@ -875,6 +889,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('bufferStrategy', 'medium');
|
localStorage.setItem('bufferStrategy', 'medium');
|
||||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||||
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
|
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
|
||||||
|
localStorage.setItem('disableAutoLoadDanmaku', 'false');
|
||||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||||
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
||||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||||
@@ -910,7 +925,8 @@ export const UserMenu: React.FC = () => {
|
|||||||
|
|
||||||
// 检查是否显示管理面板按钮
|
// 检查是否显示管理面板按钮
|
||||||
const showAdminPanel =
|
const showAdminPanel =
|
||||||
authInfo?.role === 'owner' || authInfo?.role === 'admin';
|
(authInfo?.role === 'owner' || authInfo?.role === 'admin') &&
|
||||||
|
storageType !== 'localstorage';
|
||||||
|
|
||||||
// 检查是否显示离线下载按钮
|
// 检查是否显示离线下载按钮
|
||||||
const showOfflineDownload =
|
const showOfflineDownload =
|
||||||
@@ -1814,6 +1830,30 @@ export const UserMenu: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
{isDanmakuSectionOpen && (
|
{isDanmakuSectionOpen && (
|
||||||
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
|
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
|
||||||
|
{/* 禁用自动装填弹幕 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
禁用自动装填弹幕
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
开启后,播放页面不会自动匹配弹幕,只能手动匹配
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={disableAutoLoadDanmaku}
|
||||||
|
onChange={(e) => handleDisableAutoLoadDanmakuToggle(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 className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ export interface AdminConfig {
|
|||||||
channelNumber?: number;
|
channelNumber?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}[];
|
}[];
|
||||||
|
WebLiveConfig?: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
platform: string; // 直播平台类型,如 'huya'
|
||||||
|
roomId: string; // 房间ID
|
||||||
|
from: 'config' | 'custom';
|
||||||
|
disabled?: boolean;
|
||||||
|
}[];
|
||||||
ThemeConfig?: {
|
ThemeConfig?: {
|
||||||
enableBuiltInTheme: boolean; // 是否启用内置主题
|
enableBuiltInTheme: boolean; // 是否启用内置主题
|
||||||
builtInTheme: string; // 内置主题名称
|
builtInTheme: string; // 内置主题名称
|
||||||
|
|||||||
Reference in New Issue
Block a user