Merge branch 'dev'
This commit is contained in:
@@ -63,3 +63,6 @@ public/workbox-*.js.map
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# local scripts
|
||||
scripts/tvbox/
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
## [217.0.0] - 2026-04-10
|
||||
### Added
|
||||
- 新增注册邀请码功能
|
||||
- 增加弹幕内置源
|
||||
- 增加netlify部署支持
|
||||
- 观影室增加屏幕共享功能
|
||||
- 详情面板新增照片墙功能
|
||||
- 新增夸克网盘的转存与播放功能
|
||||
- 豆瓣数据源增加备用源功能
|
||||
|
||||
### Changed
|
||||
- 移除音乐功能
|
||||
|
||||
### Fixed
|
||||
- 修复弹幕选集分组无法鼠标滑轮滚动
|
||||
- 修复高级推荐报错无限刷新
|
||||
|
||||
## [216.0.0] - 2026-03-30
|
||||
### Added
|
||||
- 新增视频源脚本
|
||||
|
||||
@@ -88,10 +88,12 @@
|
||||
|
||||
## 部署
|
||||
|
||||
本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
|
||||
本项目**支持 Docker、Vercel、Netlify 和 Cloudflare Workers 平台** 部署。
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
[](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
**一键部署到 Zeabur**
|
||||
|
||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
216.0.0
|
||||
|
||||
217.0.0
|
||||
|
||||
@@ -32,6 +32,8 @@ class WatchRoomServer {
|
||||
this.rooms = new Map();
|
||||
this.members = new Map();
|
||||
this.socketToRoom = new Map();
|
||||
this.screenHelpers = new Map();
|
||||
this.helperToRoom = new Map();
|
||||
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
|
||||
this.cleanupInterval = null;
|
||||
this.setupEventHandlers();
|
||||
@@ -55,6 +57,7 @@ class WatchRoomServer {
|
||||
description: data.description,
|
||||
password: data.password,
|
||||
isPublic: data.isPublic,
|
||||
roomType: data.roomType || 'sync',
|
||||
ownerId: userId,
|
||||
ownerName: data.userName,
|
||||
ownerToken: ownerToken, // 保存房主令牌
|
||||
@@ -131,6 +134,14 @@ class WatchRoomServer {
|
||||
|
||||
const roomMembers = this.members.get(data.roomId);
|
||||
if (roomMembers) {
|
||||
if (isOwner) {
|
||||
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
|
||||
if (existingMember.isOwner && memberId !== userId) {
|
||||
roomMembers.delete(memberId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
roomMembers.set(userId, member);
|
||||
room.memberCount = roomMembers.size;
|
||||
this.rooms.set(data.roomId, room);
|
||||
@@ -260,6 +271,114 @@ class WatchRoomServer {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:helper-register', (data, callback) => {
|
||||
try {
|
||||
const room = this.rooms.get(data.roomId);
|
||||
if (!room) {
|
||||
callback({ success: false, error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.ownerToken !== data.ownerToken) {
|
||||
callback({ success: false, error: '房主身份验证失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHelperSocketId = this.screenHelpers.get(data.roomId);
|
||||
if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
|
||||
this.helperToRoom.delete(oldHelperSocketId);
|
||||
}
|
||||
|
||||
this.screenHelpers.set(data.roomId, socket.id);
|
||||
this.helperToRoom.set(socket.id, data.roomId);
|
||||
callback({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Error registering screen helper:', error);
|
||||
callback({ success: false, error: '注册共享控制窗口失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 开始屏幕共享
|
||||
socket.on('screen:start', (state) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = state;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:start', state);
|
||||
}
|
||||
});
|
||||
|
||||
// 停止屏幕共享
|
||||
socket.on('screen:stop', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = null;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:stop');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:viewer-ready', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
if (!roomInfo) return;
|
||||
|
||||
const room = this.rooms.get(roomInfo.roomId);
|
||||
if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
|
||||
|
||||
const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
|
||||
this.io.to(targetSocketId).emit('screen:viewer-ready', {
|
||||
userId: socket.id,
|
||||
});
|
||||
});
|
||||
|
||||
// 屏幕共享 WebRTC 信令
|
||||
socket.on('screen:offer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:offer', {
|
||||
userId: socket.id,
|
||||
offer: data.offer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:answer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:answer', {
|
||||
userId: socket.id,
|
||||
answer: data.answer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:ice', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:ice', {
|
||||
userId: socket.id,
|
||||
candidate: data.candidate,
|
||||
});
|
||||
});
|
||||
|
||||
// 聊天消息
|
||||
socket.on('chat:message', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
@@ -347,6 +466,19 @@ class WatchRoomServer {
|
||||
// 断开连接
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (helperRoomId) {
|
||||
this.helperToRoom.delete(socket.id);
|
||||
if (this.screenHelpers.get(helperRoomId) === socket.id) {
|
||||
this.screenHelpers.delete(helperRoomId);
|
||||
const room = this.rooms.get(helperRoomId);
|
||||
if (room && room.currentState?.type === 'screen') {
|
||||
room.currentState = null;
|
||||
this.rooms.set(helperRoomId, room);
|
||||
this.io.to(helperRoomId).emit('screen:stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleLeaveRoom(socket);
|
||||
});
|
||||
});
|
||||
@@ -425,6 +557,11 @@ class WatchRoomServer {
|
||||
|
||||
this.rooms.delete(roomId);
|
||||
this.members.delete(roomId);
|
||||
const helperSocketId = this.screenHelpers.get(roomId);
|
||||
if (helperSocketId) {
|
||||
this.helperToRoom.delete(helperSocketId);
|
||||
this.screenHelpers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
startCleanupTimer() {
|
||||
|
||||
+625
-628
@@ -30,13 +30,13 @@ import {
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cloud,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Mail,
|
||||
Music,
|
||||
Palette,
|
||||
Settings,
|
||||
Tv,
|
||||
@@ -341,6 +341,7 @@ interface SiteConfig {
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
DanmakuSourceType?: 'builtin' | 'custom';
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
TMDBApiKey?: string;
|
||||
@@ -358,6 +359,8 @@ interface SiteConfig {
|
||||
MagnetAcgripReverseProxy?: string;
|
||||
EnableComments: boolean;
|
||||
EnableRegistration?: boolean;
|
||||
RequireRegistrationInviteCode?: boolean;
|
||||
RegistrationInviteCode?: string;
|
||||
RegistrationRequireTurnstile?: boolean;
|
||||
LoginRequireTurnstile?: boolean;
|
||||
TurnstileSiteKey?: string;
|
||||
@@ -3496,6 +3499,219 @@ const OpenListConfigComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const NetDiskConfigComponent = ({
|
||||
config,
|
||||
refreshConfig,
|
||||
}: {
|
||||
config: AdminConfig | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
}) => {
|
||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||
const { isLoading, withLoading } = useLoadingState();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [cookie, setCookie] = useState('');
|
||||
const [savePath, setSavePath] = useState('/');
|
||||
const [playTempSavePath, setPlayTempSavePath] = useState('/');
|
||||
const [openListTempPath, setOpenListTempPath] = useState('/');
|
||||
|
||||
useEffect(() => {
|
||||
const quark = config?.NetDiskConfig?.Quark;
|
||||
setEnabled(quark?.Enabled || false);
|
||||
setCookie(quark?.Cookie || '');
|
||||
setSavePath(quark?.SavePath || '/');
|
||||
setPlayTempSavePath(quark?.PlayTempSavePath || '/');
|
||||
setOpenListTempPath(quark?.OpenListTempPath || '/');
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await withLoading('saveNetDisk', async () => {
|
||||
const response = await fetch('/api/admin/netdisk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'save',
|
||||
Quark: {
|
||||
Enabled: enabled,
|
||||
Cookie: cookie,
|
||||
SavePath: savePath,
|
||||
PlayTempSavePath: playTempSavePath,
|
||||
OpenListTempPath: openListTempPath,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '保存失败');
|
||||
}
|
||||
|
||||
showSuccess('保存成功', showAlert);
|
||||
await refreshConfig();
|
||||
});
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
await withLoading('validateNetDisk', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/netdisk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'validate',
|
||||
Quark: {
|
||||
Cookie: cookie,
|
||||
SavePath: savePath,
|
||||
PlayTempSavePath: playTempSavePath,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '校验失败');
|
||||
}
|
||||
|
||||
showSuccess(data.message || '夸克 Cookie 可读', showAlert);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '校验失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||
夸克网盘
|
||||
</summary>
|
||||
<div className='mt-4 space-y-4'>
|
||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Cloud className='w-5 h-5 text-blue-600 dark:text-blue-400' />
|
||||
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
|
||||
夸克网盘说明
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
|
||||
<p>• 转存:把整个分享保存到夸克正式目录。</p>
|
||||
<p>• 立即播放:将分享内所有视频文件转存到临时播放目录,再通过 OpenList 临时目录直接播放。</p>
|
||||
<p>• OpenList 临时目录必须映射到夸克临时播放目录,否则立即播放无法找到文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
启用夸克网盘
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
开启后,网盘搜索中的夸克资源会显示“立即播放”和“转存”按钮
|
||||
</p>
|
||||
</div>
|
||||
<label className='relative inline-flex items-center cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className='sr-only peer'
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Cookie
|
||||
</label>
|
||||
<textarea
|
||||
value={cookie}
|
||||
onChange={(e) => setCookie(e.target.value)}
|
||||
disabled={!enabled}
|
||||
rows={5}
|
||||
placeholder='粘贴夸克网盘 Cookie'
|
||||
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
转存位置
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={savePath}
|
||||
onChange={(e) => setSavePath(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder='/影视/正式转存'
|
||||
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
播放临时转存位置
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={playTempSavePath}
|
||||
onChange={(e) => setPlayTempSavePath(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder='/影视/.play-temp'
|
||||
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OpenList 临时目录
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={openListTempPath}
|
||||
onChange={(e) => setOpenListTempPath(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder='/Quark/影视/.play-temp'
|
||||
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
OpenList 中能访问到临时播放目录的路径。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={!enabled || !cookie || isLoading('validateNetDisk')}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
{isLoading('validateNetDisk') ? '校验中...' : '校验夸克配置'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveNetDisk')}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
{isLoading('saveNetDisk') ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
onConfirm={alertModal.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Emby 媒体库配置组件 - 多源管理版本
|
||||
const EmbyConfigComponent = ({
|
||||
config,
|
||||
@@ -7555,349 +7771,8 @@ const ThemeConfigComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 音乐配置组件
|
||||
const MusicConfigComponent = ({
|
||||
config,
|
||||
refreshConfig,
|
||||
}: {
|
||||
config: AdminConfig | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
}) => {
|
||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||
const { isLoading, withLoading } = useLoadingState();
|
||||
|
||||
const [musicSettings, setMusicSettings] = useState({
|
||||
TuneHubEnabled: false,
|
||||
TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
|
||||
TuneHubApiKey: '',
|
||||
OpenListCacheEnabled: false,
|
||||
OpenListCacheURL: '',
|
||||
OpenListCacheUsername: '',
|
||||
OpenListCachePassword: '',
|
||||
OpenListCachePath: '/music-cache',
|
||||
OpenListCacheProxyEnabled: true,
|
||||
});
|
||||
|
||||
// 从配置加载音乐设置
|
||||
useEffect(() => {
|
||||
if (config?.MusicConfig) {
|
||||
setMusicSettings({
|
||||
TuneHubEnabled: config.MusicConfig.TuneHubEnabled ?? false,
|
||||
TuneHubBaseUrl: config.MusicConfig.TuneHubBaseUrl ?? 'https://tunehub.sayqz.com/api',
|
||||
TuneHubApiKey: config.MusicConfig.TuneHubApiKey ?? '',
|
||||
OpenListCacheEnabled: config.MusicConfig.OpenListCacheEnabled ?? false,
|
||||
OpenListCacheURL: config.MusicConfig.OpenListCacheURL ?? '',
|
||||
OpenListCacheUsername: config.MusicConfig.OpenListCacheUsername ?? '',
|
||||
OpenListCachePassword: config.MusicConfig.OpenListCachePassword ?? '',
|
||||
OpenListCachePath: config.MusicConfig.OpenListCachePath ?? '/music-cache',
|
||||
OpenListCacheProxyEnabled: config.MusicConfig.OpenListCacheProxyEnabled ?? true,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await withLoading('saveMusicConfig', async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/music', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...musicSettings }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || '保存失败');
|
||||
}
|
||||
|
||||
showAlert({ type: 'success', title: '保存成功', message: '音乐配置已更新', timer: 2000 });
|
||||
await refreshConfig();
|
||||
} catch (error: any) {
|
||||
showAlert({ type: 'error', title: '保存失败', message: error.message || '未知错误', showConfirm: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* TuneHub 音乐配置 */}
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
TuneHub 音乐配置
|
||||
</h3>
|
||||
|
||||
{/* 开启音乐功能 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
开启音乐功能
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
TuneHubEnabled: !prev.TuneHubEnabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
musicSettings.TuneHubEnabled
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
musicSettings.TuneHubEnabled
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后将在首页显示音乐视听入口,支持网易云、QQ音乐、酷我音乐
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TuneHub Base URL */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
TuneHub API 地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='https://tunehub.sayqz.com/api'
|
||||
value={musicSettings.TuneHubBaseUrl}
|
||||
onChange={(e) =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
TuneHubBaseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
TuneHub API 的基础地址,默认为 https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TuneHub API Key */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
TuneHub API Key
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='th_your_api_key_here'
|
||||
value={musicSettings.TuneHubApiKey}
|
||||
onChange={(e) =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
TuneHubApiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
用于解析歌曲播放链接的 API Key(消耗积分)。搜索、榜单、歌单等功能不需要 Key。也可以通过环境变量 TUNEHUB_API_KEY 配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenList 缓存配置 */}
|
||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
OpenList 缓存配置
|
||||
</h3>
|
||||
|
||||
{/* 开启 OpenList 缓存 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
开启 OpenList 缓存
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
OpenListCacheEnabled: !prev.OpenListCacheEnabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
musicSettings.OpenListCacheEnabled
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
musicSettings.OpenListCacheEnabled
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后将音乐解析结果(播放链接、歌词、元信息)和音频文件缓存到 OpenList,减少 API 调用次数并支持离线播放
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OpenList URL */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OpenList 服务器地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='https://your-openlist-server.com'
|
||||
value={musicSettings.OpenListCacheURL}
|
||||
onChange={(e) =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
OpenListCacheURL: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
OpenList 服务器的完整地址(例如:https://your-openlist-server.com)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OpenList 用户名 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OpenList 用户名
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='admin'
|
||||
value={musicSettings.OpenListCacheUsername}
|
||||
onChange={(e) =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
OpenListCacheUsername: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OpenList 密码 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OpenList 密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='••••••••'
|
||||
value={musicSettings.OpenListCachePassword}
|
||||
onChange={(e) =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
OpenListCachePassword: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
用于登录 OpenList 并获取访问权限
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OpenList 缓存目录 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
缓存目录路径
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='/music-cache'
|
||||
value={musicSettings.OpenListCachePath}
|
||||
onChange={(e) =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
OpenListCachePath: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
音乐缓存在 OpenList 中的存储目录(例如:/music-cache)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 缓存代理返回开关 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
启用缓存代理返回
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setMusicSettings((prev) => ({
|
||||
...prev,
|
||||
OpenListCacheProxyEnabled: !prev.OpenListCacheProxyEnabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
musicSettings.OpenListCacheProxyEnabled
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
musicSettings.OpenListCacheProxyEnabled
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后,如果 OpenList 有缓存,将通过代理方式返回给前端,并设置永久缓存头,提升加载速度
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveMusicConfig')}
|
||||
className={`px-4 py-2 ${
|
||||
isLoading('saveMusicConfig')
|
||||
? buttonStyles.disabled
|
||||
: buttonStyles.success
|
||||
} rounded-lg transition-colors`}
|
||||
>
|
||||
{isLoading('saveMusicConfig') ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹窗 */}
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// 音乐配置组件(已停用)
|
||||
// const MusicConfigComponent = (...) => { ... }
|
||||
|
||||
// 新增站点配置组件
|
||||
const SiteConfigComponent = ({
|
||||
@@ -7921,7 +7796,8 @@ const SiteConfigComponent = ({
|
||||
DoubanImageProxy: '',
|
||||
DisableYellowFilter: false,
|
||||
FluidSearch: true,
|
||||
DanmakuApiBase: 'http://localhost:9321',
|
||||
DanmakuSourceType: 'builtin',
|
||||
DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321',
|
||||
DanmakuApiToken: '87654321',
|
||||
TMDBApiKey: '',
|
||||
TMDBProxy: '',
|
||||
@@ -8016,6 +7892,7 @@ const SiteConfigComponent = ({
|
||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||
DanmakuSourceType: config.SiteConfig.DanmakuSourceType || 'custom',
|
||||
DanmakuApiBase:
|
||||
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||
@@ -8565,57 +8442,102 @@ const SiteConfigComponent = ({
|
||||
弹幕配置
|
||||
</summary>
|
||||
<div className='mt-4 space-y-4'>
|
||||
{/* 弹幕 API 地址 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
弹幕 API 地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='http://localhost:9321'
|
||||
value={siteSettings.DanmakuApiBase}
|
||||
onChange={(e) =>
|
||||
<div className='inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuApiBase: e.target.value,
|
||||
DanmakuSourceType: 'builtin',
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
弹幕服务器的 API 地址,默认为 http://localhost:9321。API部署参考
|
||||
<a
|
||||
href='https://github.com/huangxd-/danmu_api.git'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
danmu_api
|
||||
</a>
|
||||
</p>
|
||||
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
siteSettings.DanmakuSourceType !== 'custom'
|
||||
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
内置源
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuSourceType: 'custom',
|
||||
}))
|
||||
}
|
||||
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
siteSettings.DanmakuSourceType === 'custom'
|
||||
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
自定义源
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹幕 API Token */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
弹幕 API Token
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='87654321'
|
||||
value={siteSettings.DanmakuApiToken}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuApiToken: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
弹幕服务器的访问令牌,默认为 87654321
|
||||
{siteSettings.DanmakuSourceType !== 'custom' && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400'>
|
||||
⚠️ 内置弹幕源为多人共享服务,稳定性可能受使用高峰影响,建议自行部署后使用自定义源。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{siteSettings.DanmakuSourceType === 'custom' && (
|
||||
<>
|
||||
{/* 弹幕 API 地址 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
弹幕 API 地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='http://localhost:9321'
|
||||
value={siteSettings.DanmakuApiBase}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuApiBase: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
自定义弹幕服务器的 API 地址。API部署参考
|
||||
<a
|
||||
href='https://github.com/huangxd-/danmu_api.git'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
danmu_api
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 弹幕 API Token */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
弹幕 API Token
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='87654321'
|
||||
value={siteSettings.DanmakuApiToken}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuApiToken: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
自定义弹幕服务器的访问令牌,默认为 87654321
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -9054,6 +8976,8 @@ const RegistrationConfigComponent = ({
|
||||
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false);
|
||||
const [registrationSettings, setRegistrationSettings] = useState<{
|
||||
EnableRegistration: boolean;
|
||||
RequireRegistrationInviteCode: boolean;
|
||||
RegistrationInviteCode: string;
|
||||
RegistrationRequireTurnstile: boolean;
|
||||
LoginRequireTurnstile: boolean;
|
||||
TurnstileSiteKey: string;
|
||||
@@ -9071,6 +8995,8 @@ const RegistrationConfigComponent = ({
|
||||
OIDCMinTrustLevel: number;
|
||||
}>({
|
||||
EnableRegistration: false,
|
||||
RequireRegistrationInviteCode: false,
|
||||
RegistrationInviteCode: '',
|
||||
RegistrationRequireTurnstile: false,
|
||||
LoginRequireTurnstile: false,
|
||||
TurnstileSiteKey: '',
|
||||
@@ -9092,6 +9018,8 @@ const RegistrationConfigComponent = ({
|
||||
if (config?.SiteConfig) {
|
||||
setRegistrationSettings({
|
||||
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
||||
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
|
||||
RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '',
|
||||
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
||||
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
||||
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
||||
@@ -9140,10 +9068,18 @@ const RegistrationConfigComponent = ({
|
||||
throw new Error('配置未加载');
|
||||
}
|
||||
|
||||
if (
|
||||
registrationSettings.RequireRegistrationInviteCode &&
|
||||
!registrationSettings.RegistrationInviteCode.trim()
|
||||
) {
|
||||
throw new Error('已开启注册邀请码时,邀请码不能为空');
|
||||
}
|
||||
|
||||
// 合并站点配置和注册配置
|
||||
const updatedSiteConfig = {
|
||||
...config.SiteConfig,
|
||||
...registrationSettings,
|
||||
RegistrationInviteCode: registrationSettings.RegistrationInviteCode.trim(),
|
||||
};
|
||||
|
||||
const resp = await fetch('/api/admin/site', {
|
||||
@@ -9182,205 +9118,269 @@ const RegistrationConfigComponent = ({
|
||||
注册配置
|
||||
</h3>
|
||||
|
||||
{/* 开启注册 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
开启注册
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleRegistrationToggle(!registrationSettings.EnableRegistration)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
registrationSettings.EnableRegistration
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.EnableRegistration
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<details open className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||
基础注册设置
|
||||
</summary>
|
||||
<div className='mt-4 space-y-4'>
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
开启注册
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleRegistrationToggle(!registrationSettings.EnableRegistration)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
registrationSettings.EnableRegistration
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.EnableRegistration
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后登录页面将显示注册按钮,允许用户自行注册账号。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
默认用户组
|
||||
</label>
|
||||
<select
|
||||
value={registrationSettings.DefaultUserTags && registrationSettings.DefaultUserTags.length > 0 ? registrationSettings.DefaultUserTags[0] : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
DefaultUserTags: value ? [value] : [],
|
||||
}));
|
||||
}}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
>
|
||||
<option value=''>无用户组(无限制)</option>
|
||||
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
{tag.enabledApis && tag.enabledApis.length > 0
|
||||
? ` (${tag.enabledApis.length} 个源)`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后登录页面将显示注册按钮,允许用户自行注册账号。
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* 注册启用Cloudflare Turnstile */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
注册启用Cloudflare Turnstile
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
||||
onClick={() =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
|
||||
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
||||
: registrationSettings.RegistrationRequireTurnstile
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.RegistrationRequireTurnstile
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||
安全设置
|
||||
</summary>
|
||||
<div className='mt-4 space-y-4'>
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
要求注册邀请码
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
RequireRegistrationInviteCode: !prev.RequireRegistrationInviteCode,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
registrationSettings.RequireRegistrationInviteCode
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.RequireRegistrationInviteCode
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后,普通注册必须填写管理员设置的统一邀请码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
通用注册邀请码
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='请输入通用注册邀请码'
|
||||
value={registrationSettings.RegistrationInviteCode || ''}
|
||||
onChange={(e) =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
RegistrationInviteCode: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后注册时需要通过Cloudflare Turnstile人机验证。
|
||||
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
|
||||
<span className='text-orange-500 dark:text-orange-400'> 需要先配置Site Key和Secret Key才能启用。</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
仅普通注册生效;开启邀请码注册时不能为空。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 登录启用Cloudflare Turnstile */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
登录启用Cloudflare Turnstile
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
||||
onClick={() =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
LoginRequireTurnstile: !prev.LoginRequireTurnstile,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
|
||||
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
||||
: registrationSettings.LoginRequireTurnstile
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.LoginRequireTurnstile
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
注册启用Cloudflare Turnstile
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
||||
onClick={() =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
|
||||
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
||||
: registrationSettings.RegistrationRequireTurnstile
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.RegistrationRequireTurnstile
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后注册时需要通过Cloudflare Turnstile人机验证。
|
||||
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
|
||||
<span className='text-orange-500 dark:text-orange-400'> 需要先配置Site Key和Secret Key才能启用。</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
登录启用Cloudflare Turnstile
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
||||
onClick={() =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
LoginRequireTurnstile: !prev.LoginRequireTurnstile,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
|
||||
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
||||
: registrationSettings.LoginRequireTurnstile
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
registrationSettings.LoginRequireTurnstile
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后登录时需要通过Cloudflare Turnstile人机验证。
|
||||
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
|
||||
<span className='text-orange-500 dark:text-orange-400'> 需要先配置Site Key和Secret Key才能启用。</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Cloudflare Turnstile Site Key
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='请输入Cloudflare Turnstile Site Key'
|
||||
value={registrationSettings.TurnstileSiteKey || ''}
|
||||
onChange={(e) =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
TurnstileSiteKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
</button>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
在Cloudflare Dashboard中获取的Site Key(公钥)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Cloudflare Turnstile Secret Key
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='请输入Cloudflare Turnstile Secret Key'
|
||||
value={registrationSettings.TurnstileSecretKey || ''}
|
||||
onChange={(e) =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
TurnstileSecretKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
在Cloudflare Dashboard中获取的Secret Key(私钥),用于服务端验证
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后登录时需要通过Cloudflare Turnstile人机验证。
|
||||
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
|
||||
<span className='text-orange-500 dark:text-orange-400'> 需要先配置Site Key和Secret Key才能启用。</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cloudflare Turnstile Site Key */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Cloudflare Turnstile Site Key
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='请输入Cloudflare Turnstile Site Key'
|
||||
value={registrationSettings.TurnstileSiteKey || ''}
|
||||
onChange={(e) =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
TurnstileSiteKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
在Cloudflare Dashboard中获取的Site Key(公钥)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cloudflare Turnstile Secret Key */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Cloudflare Turnstile Secret Key
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='请输入Cloudflare Turnstile Secret Key'
|
||||
value={registrationSettings.TurnstileSecretKey || ''}
|
||||
onChange={(e) =>
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
TurnstileSecretKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
在Cloudflare Dashboard中获取的Secret Key(私钥),用于服务端验证
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 默认用户组 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
默认用户组
|
||||
</label>
|
||||
<select
|
||||
value={registrationSettings.DefaultUserTags && registrationSettings.DefaultUserTags.length > 0 ? registrationSettings.DefaultUserTags[0] : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setRegistrationSettings((prev) => ({
|
||||
...prev,
|
||||
DefaultUserTags: value ? [value] : [],
|
||||
}));
|
||||
}}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
>
|
||||
<option value=''>无用户组(无限制)</option>
|
||||
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => (
|
||||
<option key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
{tag.enabledApis && tag.enabledApis.length > 0
|
||||
? ` (${tag.enabledApis.length} 个源)`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* OIDC配置 */}
|
||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||
OIDC配置
|
||||
</h3>
|
||||
|
||||
{/* 启用OIDC登录 */}
|
||||
<div>
|
||||
</summary>
|
||||
<div className='mt-4 space-y-4'>
|
||||
{/* 启用OIDC登录 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
启用OIDC登录
|
||||
@@ -9413,10 +9413,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后登录页面将显示OIDC登录按钮
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 启用OIDC注册 */}
|
||||
<div>
|
||||
{/* 启用OIDC注册 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
启用OIDC注册
|
||||
@@ -9449,10 +9449,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后允许通过OIDC方式注册新用户(需要先启用OIDC登录)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC Issuer */}
|
||||
<div>
|
||||
{/* OIDC Issuer */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OIDC Issuer URL(可选)
|
||||
</label>
|
||||
@@ -9514,10 +9514,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authorization Endpoint */}
|
||||
<div>
|
||||
{/* Authorization Endpoint */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Authorization Endpoint(授权端点)
|
||||
</label>
|
||||
@@ -9536,10 +9536,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
用户授权的端点URL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Endpoint */}
|
||||
<div>
|
||||
{/* Token Endpoint */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Token Endpoint(Token端点)
|
||||
</label>
|
||||
@@ -9558,10 +9558,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
交换授权码获取token的端点URL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UserInfo Endpoint */}
|
||||
<div>
|
||||
{/* UserInfo Endpoint */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
UserInfo Endpoint(用户信息端点)
|
||||
</label>
|
||||
@@ -9580,10 +9580,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
获取用户信息的端点URL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC Client ID */}
|
||||
<div>
|
||||
{/* OIDC Client ID */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OIDC Client ID
|
||||
</label>
|
||||
@@ -9602,10 +9602,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
在OIDC提供商处注册应用后获得的Client ID
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC Client Secret */}
|
||||
<div>
|
||||
{/* OIDC Client Secret */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OIDC Client Secret
|
||||
</label>
|
||||
@@ -9624,10 +9624,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
在OIDC提供商处注册应用后获得的Client Secret
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC Redirect URI - 只读显示 */}
|
||||
<div>
|
||||
{/* OIDC Redirect URI - 只读显示 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OIDC Redirect URI(回调地址)
|
||||
</label>
|
||||
@@ -9657,10 +9657,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC登录按钮文字 */}
|
||||
<div>
|
||||
{/* OIDC登录按钮文字 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
OIDC登录按钮文字
|
||||
</label>
|
||||
@@ -9679,10 +9679,10 @@ const RegistrationConfigComponent = ({
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
自定义OIDC登录按钮显示的文字,如"使用企业账号登录"、"使用SSO登录"等。留空则显示默认文字"使用OIDC登录"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC最低信任等级 */}
|
||||
<div>
|
||||
{/* OIDC最低信任等级 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
最低信任等级
|
||||
</label>
|
||||
@@ -9700,11 +9700,12 @@ const RegistrationConfigComponent = ({
|
||||
}
|
||||
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
|
||||
</p>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
@@ -12603,6 +12604,7 @@ function AdminPageClient() {
|
||||
sourceScriptLab: false,
|
||||
mediaLibrary: false,
|
||||
openListConfig: false,
|
||||
netDiskConfig: false,
|
||||
embyConfig: false,
|
||||
xiaoyaConfig: false,
|
||||
animeSubscription: false,
|
||||
@@ -12610,7 +12612,6 @@ function AdminPageClient() {
|
||||
liveSource: false,
|
||||
webLive: false,
|
||||
siteConfig: false,
|
||||
musicConfig: false,
|
||||
registrationConfig: false,
|
||||
categoryConfig: false,
|
||||
configFile: false,
|
||||
@@ -12949,21 +12950,6 @@ function AdminPageClient() {
|
||||
/>
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 音乐配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='音乐配置'
|
||||
icon={
|
||||
<Music
|
||||
size={20}
|
||||
className='text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
}
|
||||
isExpanded={expandedTabs.musicConfig}
|
||||
onToggle={() => toggleTab('musicConfig')}
|
||||
>
|
||||
<MusicConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 视频源配置标签 */}
|
||||
<CollapsibleTab
|
||||
title='视频源配置'
|
||||
@@ -13081,6 +13067,17 @@ function AdminPageClient() {
|
||||
>
|
||||
<AnimeSubscriptionComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
<CollapsibleTab
|
||||
title='网盘配置'
|
||||
icon={
|
||||
<Cloud size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
isExpanded={expandedTabs.netDiskConfig}
|
||||
onToggle={() => toggleTab('netDiskConfig')}
|
||||
>
|
||||
<NetDiskConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
</div>
|
||||
</CollapsibleTab>
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export default function AdvancedRecommendationPage() {
|
||||
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取推荐失败');
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setIsLoadingVideos(false);
|
||||
}
|
||||
@@ -119,7 +120,7 @@ export default function AdvancedRecommendationPage() {
|
||||
}, [selectedSource, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
|
||||
if (!loadMoreRef.current || !hasMore || isLoadingVideos || !!error) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -132,7 +133,7 @@ export default function AdvancedRecommendationPage() {
|
||||
|
||||
observer.observe(loadMoreRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoadingVideos]);
|
||||
}, [error, hasMore, isLoadingVideos]);
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/advanced-recommendation'>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
assertQuarkCookieHeaderSafe,
|
||||
normalizeQuarkCookie,
|
||||
validateQuarkCookieReadable,
|
||||
} from '@/lib/netdisk/quark.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function requireOwner(username: string | undefined) {
|
||||
return username === process.env.USERNAME;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行管理员配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!requireOwner(authInfo.username)) {
|
||||
const userInfo = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, Quark } = body;
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
if (action === 'save') {
|
||||
const normalizedCookie = Quark?.Cookie ? assertQuarkCookieHeaderSafe(Quark.Cookie) : '';
|
||||
|
||||
adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
|
||||
adminConfig.NetDiskConfig.Quark = {
|
||||
Enabled: Boolean(Quark?.Enabled),
|
||||
Cookie: normalizedCookie,
|
||||
SavePath: Quark?.SavePath || '/',
|
||||
PlayTempSavePath: Quark?.PlayTempSavePath || '/',
|
||||
OpenListTempPath: Quark?.OpenListTempPath || '/',
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
await setCachedConfig(adminConfig);
|
||||
|
||||
return NextResponse.json({ success: true, message: '保存成功' });
|
||||
}
|
||||
|
||||
if (action === 'validate') {
|
||||
if (!Quark?.Cookie) {
|
||||
return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
|
||||
}
|
||||
|
||||
await validateQuarkCookieReadable(normalizeQuarkCookie(Quark.Cookie));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '夸克cookie正常',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('[Admin NetDisk] 操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
DanmakuSourceType,
|
||||
DanmakuApiBase,
|
||||
DanmakuApiToken,
|
||||
TMDBApiKey,
|
||||
@@ -58,6 +59,8 @@ export async function POST(request: NextRequest) {
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
EnableRegistration,
|
||||
RequireRegistrationInviteCode,
|
||||
RegistrationInviteCode,
|
||||
RegistrationRequireTurnstile,
|
||||
LoginRequireTurnstile,
|
||||
TurnstileSiteKey,
|
||||
@@ -84,6 +87,7 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
DanmakuSourceType?: 'builtin' | 'custom';
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
TMDBApiKey?: string;
|
||||
@@ -103,6 +107,8 @@ export async function POST(request: NextRequest) {
|
||||
CustomAdFilterCode?: string;
|
||||
CustomAdFilterVersion?: number;
|
||||
EnableRegistration?: boolean;
|
||||
RequireRegistrationInviteCode?: boolean;
|
||||
RegistrationInviteCode?: string;
|
||||
RegistrationRequireTurnstile?: boolean;
|
||||
LoginRequireTurnstile?: boolean;
|
||||
TurnstileSiteKey?: string;
|
||||
@@ -132,6 +138,9 @@ export async function POST(request: NextRequest) {
|
||||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean' ||
|
||||
typeof FluidSearch !== 'boolean' ||
|
||||
(DanmakuSourceType !== undefined &&
|
||||
DanmakuSourceType !== 'builtin' &&
|
||||
DanmakuSourceType !== 'custom') ||
|
||||
typeof DanmakuApiBase !== 'string' ||
|
||||
typeof DanmakuApiToken !== 'string' ||
|
||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||
@@ -148,6 +157,8 @@ export async function POST(request: NextRequest) {
|
||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
|
||||
(RequireRegistrationInviteCode !== undefined && typeof RequireRegistrationInviteCode !== 'boolean') ||
|
||||
(RegistrationInviteCode !== undefined && typeof RegistrationInviteCode !== 'string') ||
|
||||
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
|
||||
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
|
||||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
|
||||
@@ -189,6 +200,7 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
DanmakuSourceType,
|
||||
DanmakuApiBase,
|
||||
DanmakuApiToken,
|
||||
TMDBApiKey,
|
||||
@@ -208,6 +220,8 @@ export async function POST(request: NextRequest) {
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
EnableRegistration,
|
||||
RequireRegistrationInviteCode,
|
||||
RegistrationInviteCode,
|
||||
RegistrationRequireTurnstile,
|
||||
LoginRequireTurnstile,
|
||||
TurnstileSiteKey,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -50,13 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||
|
||||
let apiUrl: string;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -28,13 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||
|
||||
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -25,13 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||
|
||||
const apiUrl = `${baseUrl}/api/v2/match`;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -24,13 +25,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||
|
||||
const apiUrl = `${baseUrl}/api/v2/search/anime?keyword=${encodeURIComponent(keyword)}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { createQuarkInstantPlayFolder } from '@/lib/netdisk/quark.client';
|
||||
import { base58Encode } from '@/lib/utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function joinPath(...parts: string[]) {
|
||||
const joined = parts
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
.replace(/\/+/g, '/');
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shareUrl, passcode, title } = await request.json();
|
||||
if (!shareUrl) {
|
||||
return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const quarkConfig = config.NetDiskConfig?.Quark;
|
||||
|
||||
if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
|
||||
return NextResponse.json({ error: '夸克网盘未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createQuarkInstantPlayFolder(quarkConfig.Cookie, {
|
||||
shareUrl,
|
||||
passcode,
|
||||
playTempSavePath: quarkConfig.PlayTempSavePath,
|
||||
title,
|
||||
});
|
||||
|
||||
if (!result.folderName) {
|
||||
throw new Error('未生成临时播放目录');
|
||||
}
|
||||
|
||||
const openlistFolderPath = joinPath(
|
||||
quarkConfig.OpenListTempPath,
|
||||
result.folderName
|
||||
);
|
||||
|
||||
if (
|
||||
config.OpenListConfig?.Enabled &&
|
||||
config.OpenListConfig.URL &&
|
||||
config.OpenListConfig.Username &&
|
||||
config.OpenListConfig.Password
|
||||
) {
|
||||
try {
|
||||
const { OpenListClient } = await import('@/lib/openlist.client');
|
||||
const openListClient = new OpenListClient(
|
||||
config.OpenListConfig.URL,
|
||||
config.OpenListConfig.Username,
|
||||
config.OpenListConfig.Password
|
||||
);
|
||||
await openListClient.refreshDirectory(quarkConfig.OpenListTempPath || '/');
|
||||
await openListClient.refreshDirectory(openlistFolderPath);
|
||||
} catch (refreshError) {
|
||||
console.warn('[quark instant-play] 刷新 OpenList 临时目录失败:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
source: 'quark-temp',
|
||||
id: base58Encode(openlistFolderPath),
|
||||
title: title || result.folderName,
|
||||
openlistFolderPath,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '立即播放失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { transferQuarkShare } from '@/lib/netdisk/quark.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shareUrl, passcode } = await request.json();
|
||||
if (!shareUrl) {
|
||||
return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const quarkConfig = config.NetDiskConfig?.Quark;
|
||||
|
||||
if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
|
||||
return NextResponse.json({ error: '夸克网盘未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await transferQuarkShare(quarkConfig.Cookie, {
|
||||
shareUrl,
|
||||
passcode,
|
||||
savePath: quarkConfig.SavePath,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '转存失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -168,11 +168,18 @@ export async function GET(request: NextRequest) {
|
||||
throw new Error('未找到已完成的播放链接');
|
||||
}
|
||||
|
||||
// 如果指定了 format=json,返回 JSON 格式
|
||||
// 如果指定了 format=json,尝试解析到最终直链后再返回 JSON
|
||||
if (format === 'json') {
|
||||
const resolvedQualities = await Promise.all(
|
||||
qualities.map(async (quality: any) => ({
|
||||
...quality,
|
||||
url: await getFinalUrl(quality.url),
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
url: qualities[0].url,
|
||||
qualities
|
||||
url: resolvedQualities[0].url,
|
||||
qualities: resolvedQualities,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password, turnstileToken } = await req.json();
|
||||
const { username, password, inviteCode, turnstileToken } = await req.json();
|
||||
|
||||
// 验证输入
|
||||
if (!username || typeof username !== 'string') {
|
||||
@@ -69,6 +69,9 @@ export async function POST(req: NextRequest) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
if (inviteCode !== undefined && typeof inviteCode !== 'string') {
|
||||
return NextResponse.json({ error: '邀请码格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证用户名格式(只允许字母、数字、下划线,长度3-20)
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||
@@ -94,6 +97,23 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (siteConfig.RequireRegistrationInviteCode) {
|
||||
const expectedInviteCode = (siteConfig.RegistrationInviteCode || '').trim();
|
||||
if (!expectedInviteCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '服务器未配置邀请码' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!inviteCode || inviteCode.trim() !== expectedInviteCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户名锁,防止并发注册
|
||||
let releaseLock: (() => void) | null = null;
|
||||
try {
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
WatchRoom: watchRoomConfig,
|
||||
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
||||
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
|
||||
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
||||
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
||||
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
||||
const title = searchParams.get('title');
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
@@ -274,6 +275,141 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceCode === 'quark-temp') {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
throw new Error('OpenList 未配置或未启用');
|
||||
}
|
||||
|
||||
const { base58Decode } = await import('@/lib/utils');
|
||||
const { OpenListClient } = await import('@/lib/openlist.client');
|
||||
const { parseVideoFileName } = await import('@/lib/video-parser');
|
||||
|
||||
const folderPath = base58Decode(id);
|
||||
if (!folderPath) {
|
||||
throw new Error('无效的临时播放目录');
|
||||
}
|
||||
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
||||
|
||||
const listTempDirectory = async (currentPath: string, page: number, pageSize: number) => {
|
||||
const load = async (refresh = false) => client.listDirectory(currentPath, page, pageSize, refresh);
|
||||
|
||||
let response = await load(page === 1);
|
||||
if (response.code === 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
|
||||
await client.refreshDirectory(parentPath);
|
||||
response = await load(true);
|
||||
|
||||
if (response.code !== 200) {
|
||||
const message = response.message || '目录不存在或 OpenList 路径未映射';
|
||||
throw new Error(`读取临时目录失败: ${message}(路径: ${currentPath})`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const collectFiles = async (currentPath: string): Promise<Array<{ path: string; name: string }>> => {
|
||||
const allFiles: Array<{ path: string; name: string }> = [];
|
||||
let currentPage = 1;
|
||||
const pageSize = 100;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await listTempDirectory(currentPath, currentPage, pageSize);
|
||||
|
||||
for (const item of response.data.content) {
|
||||
const itemPath = `${currentPath}${currentPath.endsWith('/') ? '' : '/'}${item.name}`;
|
||||
if (item.is_dir) {
|
||||
const nested = await collectFiles(itemPath);
|
||||
allFiles.push(...nested);
|
||||
} else if (
|
||||
!item.name.startsWith('.') &&
|
||||
videoExtensions.some((ext) => item.name.toLowerCase().endsWith(ext))
|
||||
) {
|
||||
allFiles.push({
|
||||
path: itemPath,
|
||||
name: item.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = !(
|
||||
response.data.content.length < pageSize ||
|
||||
currentPage * pageSize >= response.data.total
|
||||
);
|
||||
currentPage += 1;
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
};
|
||||
|
||||
const files = await collectFiles(folderPath);
|
||||
if (files.length === 0) {
|
||||
throw new Error('临时播放目录中没有视频文件');
|
||||
}
|
||||
|
||||
const episodes = files
|
||||
.map((file, index) => {
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
const fileDir = file.path.substring(0, file.path.lastIndexOf('/')) || '/';
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileDir,
|
||||
episode: parsed.episode || index + 1,
|
||||
title:
|
||||
parsed.title ||
|
||||
(parsed.episode ? `第${parsed.episode}集` : file.name),
|
||||
isOVA: parsed.isOVA,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.isOVA && !b.isOVA) return 1;
|
||||
if (!a.isOVA && b.isOVA) return -1;
|
||||
return a.episode !== b.episode
|
||||
? a.episode - b.episode
|
||||
: a.fileName.localeCompare(b.fileName);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
source: 'quark-temp',
|
||||
source_name: '夸克临时播放',
|
||||
id,
|
||||
title: title || folderPath.split('/').filter(Boolean).pop() || '夸克临时播放',
|
||||
poster: '',
|
||||
year: '',
|
||||
douban_id: 0,
|
||||
desc: `临时播放目录:${folderPath}`,
|
||||
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(ep.fileDir)}&fileName=${encodeURIComponent(ep.fileName)}`),
|
||||
episodes_titles: episodes.map((ep) => ep.title),
|
||||
proxyMode: false,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
||||
if (sourceCode === 'openlist') {
|
||||
try {
|
||||
@@ -340,8 +476,9 @@ export async function GET(request: NextRequest) {
|
||||
let currentPage = 1;
|
||||
const pageSize = 100;
|
||||
let total = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (true) {
|
||||
while (hasMore) {
|
||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
||||
|
||||
if (listResponse.code !== 200) {
|
||||
@@ -351,10 +488,7 @@ export async function GET(request: NextRequest) {
|
||||
total = listResponse.data.total;
|
||||
allFiles.push(...listResponse.data.content);
|
||||
|
||||
if (allFiles.length >= total) {
|
||||
break;
|
||||
}
|
||||
|
||||
hasMore = allFiles.length < total;
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getTMDBImages } from '@/lib/tmdb.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/tmdb/images?id=xxx&type=movie|tv&page=1&pageSize=24
|
||||
* 获取 TMDB 照片墙数据,并在服务端分页
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const type = searchParams.get('type') || 'movie';
|
||||
const pageParam = searchParams.get('page');
|
||||
const pageSizeParam = searchParams.get('pageSize');
|
||||
const page = pageParam ? Math.max(parseInt(pageParam, 10), 1) : null;
|
||||
const pageSize = pageSizeParam ? Math.min(Math.max(parseInt(pageSizeParam, 10), 1), 60) : null;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: '缺少ID参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type !== 'movie' && type !== 'tv') {
|
||||
return NextResponse.json({ error: '类型参数必须是movie或tv' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
||||
|
||||
if (!tmdbApiKey) {
|
||||
return NextResponse.json({ error: 'TMDB API Key 未配置' }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await getTMDBImages(
|
||||
tmdbApiKey,
|
||||
parseInt(id, 10),
|
||||
type as 'movie' | 'tv',
|
||||
tmdbProxy,
|
||||
tmdbReverseProxy
|
||||
);
|
||||
|
||||
if (response.code !== 200 || !response.images) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB 图片信息获取失败', code: response.code },
|
||||
{ status: response.code }
|
||||
);
|
||||
}
|
||||
|
||||
const backdrops = (response.images.backdrops || []).map((item: any) => ({
|
||||
...item,
|
||||
imageType: 'backdrop' as const,
|
||||
}));
|
||||
const posters = (response.images.posters || []).map((item: any) => ({
|
||||
...item,
|
||||
imageType: 'poster' as const,
|
||||
}));
|
||||
|
||||
const allImages = [...backdrops, ...posters].sort((a, b) => {
|
||||
const voteDiff = (b.vote_average || 0) - (a.vote_average || 0);
|
||||
if (voteDiff !== 0) return voteDiff;
|
||||
return (b.vote_count || 0) - (a.vote_count || 0);
|
||||
});
|
||||
|
||||
const total = allImages.length;
|
||||
|
||||
if (!page || !pageSize) {
|
||||
return NextResponse.json({
|
||||
total,
|
||||
list: allImages,
|
||||
});
|
||||
}
|
||||
|
||||
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
const list = allImages.slice(start, start + pageSize);
|
||||
|
||||
return NextResponse.json({
|
||||
page: safePage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
list,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TMDB图片信息获取失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取图片信息失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export default async function RootLayout({
|
||||
let progressThumbPresetId = '';
|
||||
let progressThumbCustomUrl = '';
|
||||
let enableRegistration = false;
|
||||
let requireRegistrationInviteCode = false;
|
||||
let loginRequireTurnstile = false;
|
||||
let registrationRequireTurnstile = false;
|
||||
let turnstileSiteKey = '';
|
||||
@@ -125,6 +126,7 @@ export default async function RootLayout({
|
||||
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
|
||||
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
|
||||
enableRegistration = config.SiteConfig.EnableRegistration || false;
|
||||
requireRegistrationInviteCode = config.SiteConfig.RequireRegistrationInviteCode || false;
|
||||
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
|
||||
registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false;
|
||||
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
|
||||
@@ -195,6 +197,7 @@ export default async function RootLayout({
|
||||
PROGRESS_THUMB_PRESET_ID: progressThumbPresetId,
|
||||
PROGRESS_THUMB_CUSTOM_URL: progressThumbCustomUrl,
|
||||
ENABLE_REGISTRATION: enableRegistration,
|
||||
REQUIRE_REGISTRATION_INVITE_CODE: requireRegistrationInviteCode,
|
||||
LOGIN_REQUIRE_TURNSTILE: loginRequireTurnstile,
|
||||
REGISTRATION_REQUIRE_TURNSTILE: registrationRequireTurnstile,
|
||||
TURNSTILE_SITE_KEY: turnstileSiteKey,
|
||||
|
||||
+14
-21
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
||||
import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
@@ -66,7 +66,6 @@ function HomeClient() {
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||
const [musicEnabled, setMusicEnabled] = useState(false);
|
||||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
||||
|
||||
@@ -149,14 +148,6 @@ function HomeClient() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查音乐功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled = (window as any).RUNTIME_CONFIG?.TUNEHUB_ENABLED === true;
|
||||
setMusicEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查公告弹窗状态
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && announcement) {
|
||||
@@ -613,17 +604,19 @@ function HomeClient() {
|
||||
<LinkIcon size={18} />
|
||||
</button>
|
||||
|
||||
{/* 音乐视听入口 */}
|
||||
{musicEnabled && (
|
||||
<Link href='/music'>
|
||||
<button
|
||||
className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
|
||||
title='音乐视听'
|
||||
>
|
||||
<Music size={20} />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
{/* 音乐视听入口(暂时隐藏,后续可能恢复) */}
|
||||
{/**
|
||||
* {musicEnabled && (
|
||||
* <Link href='/music'>
|
||||
* <button
|
||||
* className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
|
||||
* title='音乐视听'
|
||||
* >
|
||||
* <Music size={20} />
|
||||
* </button>
|
||||
* </Link>
|
||||
* )}
|
||||
*/}
|
||||
|
||||
{/* 源站寻片入口 */}
|
||||
{sourceSearchEnabled && (
|
||||
|
||||
+149
-41
@@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer';
|
||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import PansouSearch from '@/components/PansouSearch';
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import SmartRecommendations from '@/components/SmartRecommendations';
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
@@ -572,6 +573,13 @@ function PlayPageClient() {
|
||||
|
||||
// 纠错后的描述信息(用于显示,不触发 detail 更新)
|
||||
const [correctedDesc, setCorrectedDesc] = useState<string>('');
|
||||
const [quarkTempTMDBMeta, setQuarkTempTMDBMeta] = useState<{
|
||||
desc?: string;
|
||||
poster?: string;
|
||||
year?: string;
|
||||
tmdbId?: number;
|
||||
} | null>(null);
|
||||
const [pendingQuarkTempTMDBData, setPendingQuarkTempTMDBData] = useState<any | null>(null);
|
||||
|
||||
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
|
||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||
@@ -579,6 +587,11 @@ function PlayPageClient() {
|
||||
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
||||
const isDirectPlay = currentSource === 'directplay';
|
||||
|
||||
useEffect(() => {
|
||||
setQuarkTempTMDBMeta(null);
|
||||
setPendingQuarkTempTMDBData(null);
|
||||
}, [currentSource, currentId]);
|
||||
|
||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||
if (source.startsWith('emby_')) {
|
||||
@@ -1187,14 +1200,18 @@ function PlayPageClient() {
|
||||
const detCacheAge = Date.now() - detTimestamp;
|
||||
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
|
||||
|
||||
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
||||
console.log('使用缓存的TMDB详情数据');
|
||||
setTmdbBackdrop(processImageUrl(data.backdrop));
|
||||
if (detCacheAge < detCacheMaxAge && data) {
|
||||
if (data.backdrop) {
|
||||
setTmdbBackdrop(processImageUrl(data.backdrop));
|
||||
} else {
|
||||
setTmdbBackdrop(null);
|
||||
}
|
||||
|
||||
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||
if (!videoDoubanId || videoDoubanId === 0) {
|
||||
populateDoubanFieldsFromTMDB(data);
|
||||
}
|
||||
populatePlayMetadataFromTMDB(data);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1215,7 +1232,6 @@ function PlayPageClient() {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('获取TMDB详情失败');
|
||||
setTmdbBackdrop(null);
|
||||
return;
|
||||
}
|
||||
@@ -1224,45 +1240,94 @@ function PlayPageClient() {
|
||||
|
||||
if (result.backdrop) {
|
||||
setTmdbBackdrop(processImageUrl(result.backdrop));
|
||||
|
||||
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||
if (!videoDoubanId || videoDoubanId === 0) {
|
||||
populateDoubanFieldsFromTMDB(result);
|
||||
}
|
||||
|
||||
// 保存title到tmdbId的映射到localStorage(1个月)
|
||||
if (result.tmdbId) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
mappingCacheKey,
|
||||
JSON.stringify({
|
||||
tmdbId: result.tmdbId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
// 保存TMDB详情数据到localStorage(1天)
|
||||
const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
|
||||
localStorage.setItem(
|
||||
detailsCacheKey,
|
||||
JSON.stringify({
|
||||
data: result,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('保存缓存失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setTmdbBackdrop(null);
|
||||
}
|
||||
|
||||
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||
if (!videoDoubanId || videoDoubanId === 0) {
|
||||
populateDoubanFieldsFromTMDB(result);
|
||||
}
|
||||
populatePlayMetadataFromTMDB(result);
|
||||
|
||||
// 保存title到tmdbId的映射到localStorage(1个月)
|
||||
if (result.tmdbId) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
mappingCacheKey,
|
||||
JSON.stringify({
|
||||
tmdbId: result.tmdbId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
// 保存TMDB详情数据到localStorage(1天)
|
||||
const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
|
||||
localStorage.setItem(
|
||||
detailsCacheKey,
|
||||
JSON.stringify({
|
||||
data: result,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('保存缓存失败:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取TMDB背景图失败:', error);
|
||||
setTmdbBackdrop(null);
|
||||
}
|
||||
};
|
||||
|
||||
const populatePlayMetadataFromTMDB = (tmdbData: any) => {
|
||||
const currentDetail = detailRef.current;
|
||||
if (!currentDetail || currentDetail.source !== 'quark-temp') {
|
||||
setPendingQuarkTempTMDBData(tmdbData);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdbYear = tmdbData.releaseDate?.split('-')[0] || '';
|
||||
const shouldReplaceDesc = !currentDetail.desc || currentDetail.desc.startsWith('临时播放目录:');
|
||||
|
||||
const resolvedTmdbId = typeof tmdbData.tmdbId === 'string'
|
||||
? Number(String(tmdbData.tmdbId).split(':')[1] || 0)
|
||||
: tmdbData.tmdbId;
|
||||
|
||||
setQuarkTempTMDBMeta({
|
||||
desc: shouldReplaceDesc ? (tmdbData.overview || currentDetail.desc) : currentDetail.desc,
|
||||
poster: currentDetail.poster || tmdbData.poster || '',
|
||||
year: currentDetail.year || tmdbYear,
|
||||
tmdbId: currentDetail.tmdb_id || resolvedTmdbId,
|
||||
});
|
||||
|
||||
setDetail((prev) => {
|
||||
if (!prev || prev.source !== 'quark-temp') {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
poster: prev.poster || tmdbData.poster || '',
|
||||
year: prev.year || tmdbYear,
|
||||
desc: shouldReplaceDesc ? (tmdbData.overview || prev.desc) : prev.desc,
|
||||
tmdb_id: prev.tmdb_id || resolvedTmdbId,
|
||||
};
|
||||
});
|
||||
|
||||
if (tmdbData.overview && (!correctedDesc || currentDetail.desc?.startsWith('临时播放目录:'))) {
|
||||
setCorrectedDesc(tmdbData.overview);
|
||||
}
|
||||
|
||||
if (tmdbData.poster && !currentDetail.poster) {
|
||||
setVideoCover(processImageUrl(tmdbData.poster));
|
||||
}
|
||||
|
||||
if (tmdbYear && !currentDetail.year) {
|
||||
setVideoYear(tmdbYear);
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:使用TMDb数据填充豆瓣字段
|
||||
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
|
||||
// 设置评分
|
||||
@@ -1296,6 +1361,45 @@ function PlayPageClient() {
|
||||
fetchTMDBBackdrop();
|
||||
}, [videoTitle, videoDoubanId, isDirectPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pendingQuarkTempTMDBData &&
|
||||
detail?.source === 'quark-temp'
|
||||
) {
|
||||
const pending = pendingQuarkTempTMDBData;
|
||||
setPendingQuarkTempTMDBData(null);
|
||||
const tmdbYear = pending.releaseDate?.split('-')[0] || '';
|
||||
const shouldReplaceDesc = !detail.desc || detail.desc.startsWith('临时播放目录:');
|
||||
const resolvedTmdbId = typeof pending.tmdbId === 'string'
|
||||
? Number(String(pending.tmdbId).split(':')[1] || 0)
|
||||
: pending.tmdbId;
|
||||
|
||||
setQuarkTempTMDBMeta({
|
||||
desc: shouldReplaceDesc ? (pending.overview || detail.desc) : detail.desc,
|
||||
poster: detail.poster || pending.poster || '',
|
||||
year: detail.year || tmdbYear,
|
||||
tmdbId: detail.tmdb_id || resolvedTmdbId,
|
||||
});
|
||||
|
||||
setDetail((prev) => prev && prev.source === 'quark-temp' ? {
|
||||
...prev,
|
||||
poster: prev.poster || pending.poster || '',
|
||||
year: prev.year || tmdbYear,
|
||||
desc: shouldReplaceDesc ? (pending.overview || prev.desc) : prev.desc,
|
||||
tmdb_id: prev.tmdb_id || resolvedTmdbId,
|
||||
} : prev);
|
||||
|
||||
if (pending.poster && !detail.poster) {
|
||||
setVideoCover(processImageUrl(pending.poster));
|
||||
}
|
||||
if (tmdbYear && !detail.year) {
|
||||
setVideoYear(tmdbYear);
|
||||
}
|
||||
if (pending.overview) {
|
||||
setCorrectedDesc(pending.overview);
|
||||
}
|
||||
}
|
||||
}, [pendingQuarkTempTMDBData, detail]);
|
||||
|
||||
// 视频播放地址
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
@@ -1413,6 +1517,7 @@ function PlayPageClient() {
|
||||
!isM3u8LikeUrl(videoUrl) &&
|
||||
(
|
||||
detail.source === 'openlist' ||
|
||||
detail.source === 'quark-temp' ||
|
||||
detail.source === 'xiaoya' ||
|
||||
detail.source.startsWith('emby')
|
||||
)
|
||||
@@ -8871,12 +8976,12 @@ function PlayPageClient() {
|
||||
</span>
|
||||
)}
|
||||
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
|
||||
{(doubanYear || detail?.year || videoYear) && (
|
||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
||||
{(doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear) && (
|
||||
<span>{doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear}</span>
|
||||
)}
|
||||
{detail?.source_name && (
|
||||
<span
|
||||
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'quark-temp' ? 'border-purple-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||
}`}
|
||||
onClick={fetchCurrentSourceVideoInfo}
|
||||
>
|
||||
@@ -8896,7 +9001,7 @@ function PlayPageClient() {
|
||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||
</div>
|
||||
{/* 剧情简介 */}
|
||||
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
|
||||
{(doubanCardSubtitle || quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc) && (
|
||||
<div
|
||||
className={`mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide ${tmdbBackdrop ? 'text-white' : ''}`}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
@@ -8907,7 +9012,7 @@ function PlayPageClient() {
|
||||
{doubanCardSubtitle}
|
||||
</div>
|
||||
)}
|
||||
{correctedDesc || detail?.desc}
|
||||
{quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -8919,8 +9024,8 @@ function PlayPageClient() {
|
||||
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
||||
{videoCover ? (
|
||||
<>
|
||||
<img
|
||||
src={processImageUrl(videoCover)}
|
||||
<ProxyImage
|
||||
originalSrc={videoCover}
|
||||
alt={videoTitle}
|
||||
className='w-full h-full object-cover'
|
||||
/>
|
||||
@@ -9161,6 +9266,7 @@ function PlayPageClient() {
|
||||
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
|
||||
// 如果有豆瓣ID且不为0,传入doubanId
|
||||
detail.source === 'openlist' ||
|
||||
detail.source === 'quark-temp' ||
|
||||
detail.source?.startsWith('emby') ||
|
||||
detail.source === 'xiaoya'
|
||||
? undefined
|
||||
@@ -9171,6 +9277,7 @@ function PlayPageClient() {
|
||||
tmdbId={
|
||||
// 特殊源使用 tmdb
|
||||
detail.source === 'openlist' ||
|
||||
detail.source === 'quark-temp' ||
|
||||
detail.source?.startsWith('emby') ||
|
||||
detail.source === 'xiaoya'
|
||||
? detail.tmdb_id
|
||||
@@ -9182,6 +9289,7 @@ function PlayPageClient() {
|
||||
// 非特殊源使用 cms 数据
|
||||
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
|
||||
detail.source !== 'openlist' &&
|
||||
detail.source !== 'quark-temp' &&
|
||||
!detail.source?.startsWith('emby') &&
|
||||
detail.source !== 'xiaoya' &&
|
||||
!(detail.douban_id && detail.douban_id !== 0)
|
||||
|
||||
@@ -73,6 +73,7 @@ function RegisterPageClient() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -108,6 +109,7 @@ function RegisterPageClient() {
|
||||
// 设置站点配置
|
||||
const config = {
|
||||
EnableRegistration: runtimeConfig?.ENABLE_REGISTRATION || false,
|
||||
RequireRegistrationInviteCode: runtimeConfig?.REQUIRE_REGISTRATION_INVITE_CODE || false,
|
||||
RegistrationRequireTurnstile: runtimeConfig?.REGISTRATION_REQUIRE_TURNSTILE || false,
|
||||
TurnstileSiteKey: runtimeConfig?.TURNSTILE_SITE_KEY || '',
|
||||
};
|
||||
@@ -167,6 +169,11 @@ function RegisterPageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) {
|
||||
setError('请输入邀请码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
@@ -191,6 +198,7 @@ function RegisterPageClient() {
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
inviteCode: siteConfig?.RequireRegistrationInviteCode ? inviteCode.trim() : undefined,
|
||||
turnstileToken: siteConfig?.RegistrationRequireTurnstile ? turnstileToken : undefined,
|
||||
}),
|
||||
});
|
||||
@@ -340,6 +348,27 @@ function RegisterPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{siteConfig?.RequireRegistrationInviteCode && (
|
||||
<div>
|
||||
<label htmlFor='inviteCode' className='sr-only'>
|
||||
邀请码
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<User className='h-5 w-5 text-gray-400 dark:text-gray-500' />
|
||||
</div>
|
||||
<input
|
||||
id='inviteCode'
|
||||
type='text'
|
||||
className='block w-full rounded-lg border-0 py-3 pl-10 pr-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
|
||||
placeholder='输入邀请码'
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cloudflare Turnstile */}
|
||||
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
|
||||
<div id='turnstile-container' className='flex justify-center'></div>
|
||||
@@ -354,6 +383,7 @@ function RegisterPageClient() {
|
||||
type='submit'
|
||||
disabled={
|
||||
!username || !password || !confirmPassword || loading ||
|
||||
(siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) ||
|
||||
(siteConfig?.RegistrationRequireTurnstile && !turnstileToken)
|
||||
}
|
||||
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
@@ -38,6 +38,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import ImageViewer from '@/components/ImageViewer';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import PansouSearch from '@/components/PansouSearch';
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
import SearchResultFilter, {
|
||||
SearchFilterCategory,
|
||||
} from '@/components/SearchResultFilter';
|
||||
@@ -829,9 +830,8 @@ function SearchPageClient() {
|
||||
>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={processImageUrl(item.poster)}
|
||||
<ProxyImage
|
||||
originalSrc={item.poster}
|
||||
alt={item.title}
|
||||
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
||||
loading='lazy'
|
||||
|
||||
+164
-22
@@ -8,12 +8,41 @@ import { useEffect,useState } from 'react';
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||
|
||||
import type { Room } from '@/types/watch-room';
|
||||
import type { Room, RoomType } from '@/types/watch-room';
|
||||
|
||||
type TabType = 'create' | 'join' | 'list';
|
||||
|
||||
function getScreenShareHostSupportError() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
return '当前环境不是安全上下文(HTTPS/localhost),不支持屏幕共享';
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||
return '当前浏览器不支持屏幕共享';
|
||||
}
|
||||
|
||||
if (typeof window.RTCPeerConnection === 'undefined') {
|
||||
return '当前浏览器不支持实时屏幕传输';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getScreenShareViewerSupportError() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
if (typeof window.RTCPeerConnection === 'undefined') {
|
||||
return '当前浏览器不支持实时屏幕传输';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function WatchRoomPage() {
|
||||
const router = useRouter();
|
||||
const watchRoom = useWatchRoomContext();
|
||||
@@ -34,6 +63,7 @@ export default function WatchRoomPage() {
|
||||
description: '',
|
||||
password: '',
|
||||
isPublic: true,
|
||||
roomType: 'sync' as RoomType,
|
||||
});
|
||||
|
||||
// 加入房间表单
|
||||
@@ -47,28 +77,42 @@ export default function WatchRoomPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [joinLoading, setJoinLoading] = useState(false);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
const showToast = (message: string, type: ToastProps['type'] = 'info') => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
duration: 3000,
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
};
|
||||
|
||||
// 加载房间列表
|
||||
const loadRooms = async () => {
|
||||
const loadRooms = async (showLoading = false) => {
|
||||
if (!isConnected) return;
|
||||
|
||||
setLoading(true);
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const roomList = await getRoomList();
|
||||
setRooms(roomList);
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Failed to load rooms:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到房间列表 tab 时加载房间
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') {
|
||||
loadRooms();
|
||||
loadRooms(true);
|
||||
// 每5秒刷新一次
|
||||
const interval = setInterval(loadRooms, 5000);
|
||||
const interval = setInterval(() => loadRooms(false), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, isConnected]);
|
||||
@@ -77,10 +121,18 @@ export default function WatchRoomPage() {
|
||||
const handleCreateRoom = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.roomName.trim()) {
|
||||
alert('请输入房间名称');
|
||||
showToast('请输入房间名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (createForm.roomType === 'screen') {
|
||||
const supportError = getScreenShareHostSupportError();
|
||||
if (supportError) {
|
||||
showToast(`当前设备无法创建屏幕共享房间:${supportError}`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
await createRoom({
|
||||
@@ -88,6 +140,7 @@ export default function WatchRoomPage() {
|
||||
description: createForm.description.trim(),
|
||||
password: createForm.password.trim() || undefined,
|
||||
isPublic: createForm.isPublic,
|
||||
roomType: createForm.roomType,
|
||||
userName: currentUsername,
|
||||
});
|
||||
|
||||
@@ -97,9 +150,10 @@ export default function WatchRoomPage() {
|
||||
description: '',
|
||||
password: '',
|
||||
isPublic: true,
|
||||
roomType: 'sync',
|
||||
});
|
||||
} catch (error: any) {
|
||||
alert(error.message || '创建房间失败');
|
||||
showToast(error.message || '创建房间失败', 'error');
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
@@ -110,10 +164,19 @@ export default function WatchRoomPage() {
|
||||
e.preventDefault();
|
||||
const targetRoomId = roomId || joinForm.roomId.trim().toUpperCase();
|
||||
if (!targetRoomId) {
|
||||
alert('请输入房间ID');
|
||||
showToast('请输入房间ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRoom = rooms.find((room) => room.id === targetRoomId);
|
||||
if (targetRoom?.roomType === 'screen') {
|
||||
const supportError = getScreenShareViewerSupportError();
|
||||
if (supportError) {
|
||||
showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setJoinLoading(true);
|
||||
try {
|
||||
const result = await joinRoom({
|
||||
@@ -131,7 +194,7 @@ export default function WatchRoomPage() {
|
||||
// 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新
|
||||
// 跳转逻辑会在 useEffect 中处理
|
||||
} catch (error: any) {
|
||||
alert(error.message || '加入房间失败');
|
||||
showToast(error.message || '加入房间失败', 'error');
|
||||
} finally {
|
||||
setJoinLoading(false);
|
||||
}
|
||||
@@ -141,6 +204,11 @@ export default function WatchRoomPage() {
|
||||
useEffect(() => {
|
||||
if (!currentRoom || isOwner) return;
|
||||
|
||||
if (currentRoom.roomType === 'screen') {
|
||||
router.push('/watch-room/screen');
|
||||
return;
|
||||
}
|
||||
|
||||
// 房员加入房间后,不立即跳转
|
||||
// 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用)
|
||||
// 这样可以避免房主已经离开play页面但状态未清除的情况
|
||||
@@ -153,6 +221,8 @@ export default function WatchRoomPage() {
|
||||
useEffect(() => {
|
||||
if (!currentRoom || isOwner) return;
|
||||
|
||||
if (currentRoom.roomType === 'screen') return;
|
||||
|
||||
const handlePlayChange = (state: any) => {
|
||||
if (state.type === 'play') {
|
||||
const params = new URLSearchParams({
|
||||
@@ -196,8 +266,23 @@ export default function WatchRoomPage() {
|
||||
}
|
||||
}, [currentRoom, isOwner, router, socket]);
|
||||
|
||||
// 屏幕共享房间创建/加入后直接进入共享页
|
||||
useEffect(() => {
|
||||
if (currentRoom?.roomType === 'screen') {
|
||||
router.push('/watch-room/screen');
|
||||
}
|
||||
}, [currentRoom?.id, currentRoom?.roomType, router]);
|
||||
|
||||
// 从房间列表加入房间
|
||||
const handleJoinFromList = (room: Room) => {
|
||||
if (room.roomType === 'screen') {
|
||||
const supportError = getScreenShareViewerSupportError();
|
||||
if (supportError) {
|
||||
showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setJoinForm({
|
||||
roomId: room.id,
|
||||
password: '',
|
||||
@@ -237,7 +322,9 @@ export default function WatchRoomPage() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold mb-1">
|
||||
{currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
|
||||
{currentRoom.roomType === 'screen'
|
||||
? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享'
|
||||
: currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
|
||||
</h3>
|
||||
<p className="text-sm text-white/80">
|
||||
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
|
||||
@@ -246,12 +333,14 @@ export default function WatchRoomPage() {
|
||||
<p className="text-xs text-white/90 mt-1">
|
||||
{currentRoom.currentState.type === 'play'
|
||||
? `${currentRoom.currentState.videoName || '未知视频'}`
|
||||
: `${currentRoom.currentState.channelName || '未知频道'}`}
|
||||
: currentRoom.currentState.type === 'live'
|
||||
? `${currentRoom.currentState.channelName || '未知频道'}`
|
||||
: '屏幕共享进行中'}
|
||||
</p>
|
||||
)}
|
||||
{!currentRoom.currentState && (
|
||||
<p className="text-xs text-white/70 mt-1">
|
||||
当房主开始播放时,您将自动跟随
|
||||
{currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,6 +369,8 @@ export default function WatchRoomPage() {
|
||||
// 普通 live 格式,导航到 live 页面
|
||||
router.push(`/live?id=${state.channelId}`);
|
||||
}
|
||||
} else if (state.type === 'screen') {
|
||||
router.push('/watch-room/screen');
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2 bg-white text-blue-600 font-medium rounded-lg hover:bg-white/90 transition-colors whitespace-nowrap"
|
||||
@@ -303,7 +394,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
与好友一起看视频,实时同步播放
|
||||
与好友一起看视频,支持进度同步或屏幕共享
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +451,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-blue-100 text-xs mb-1">房间号</p>
|
||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
|
||||
@@ -369,6 +460,10 @@ export default function WatchRoomPage() {
|
||||
<p className="text-blue-100 text-xs mb-1">成员数</p>
|
||||
<p className="text-xl font-bold">{members.length} 人</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-blue-100 text-xs mb-1">房间类型</p>
|
||||
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -402,7 +497,9 @@ export default function WatchRoomPage() {
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作
|
||||
💡 {currentRoom.roomType === 'screen'
|
||||
? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
|
||||
: '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,6 +568,38 @@ export default function WatchRoomPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间类型
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateForm({ ...createForm, roomType: 'sync' })}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
createForm.roomType === 'sync'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">进度同步</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">统一播放进度(适合双方网络稳定的情况)</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateForm({ ...createForm, roomType: 'screen' })}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
createForm.roomType === 'screen'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">屏幕共享</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">房员直接观看房主共享的浏览器画面(适合完全实时同步的情况)</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading || !createForm.roomName.trim()}
|
||||
@@ -486,7 +615,7 @@ export default function WatchRoomPage() {
|
||||
{!currentRoom && (
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>提示:</strong>创建房间后,您将成为房主。所有成员的播放进度将自动跟随您的操作。
|
||||
<strong>提示:</strong>创建房间后,您将成为房主。进度同步房会跟随播放状态,屏幕共享房会进入独立共享页。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -518,7 +647,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-green-100 text-xs mb-1">房间号</p>
|
||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
|
||||
@@ -527,6 +656,10 @@ export default function WatchRoomPage() {
|
||||
<p className="text-green-100 text-xs mb-1">成员数</p>
|
||||
<p className="text-xl font-bold">{members.length} 人</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-green-100 text-xs mb-1">房间类型</p>
|
||||
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -560,7 +693,9 @@ export default function WatchRoomPage() {
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
||||
💡 {currentRoom.roomType === 'screen'
|
||||
? '这是屏幕共享房间,进入后即可观看房主共享画面'
|
||||
: isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -617,7 +752,7 @@ export default function WatchRoomPage() {
|
||||
{!currentRoom && (
|
||||
<div className="mt-6 bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
<strong>提示:</strong>加入房间后,您的播放进度将自动跟随房主的操作。
|
||||
<strong>提示:</strong>加入进度同步房后将跟随播放,加入屏幕共享房后会进入共享页面。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -633,7 +768,7 @@ export default function WatchRoomPage() {
|
||||
找到 <span className="font-medium text-gray-900 dark:text-gray-100">{rooms.length}</span> 个公开房间
|
||||
</p>
|
||||
<button
|
||||
onClick={loadRooms}
|
||||
onClick={() => loadRooms(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -704,6 +839,10 @@ export default function WatchRoomPage() {
|
||||
<span>房主</span>
|
||||
<span className="font-medium">{room.ownerName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>类型</span>
|
||||
<span>{room.roomType === 'screen' ? '屏幕共享' : '进度同步'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>创建时间</span>
|
||||
<span>{formatTime(room.createdAt)}</span>
|
||||
@@ -713,7 +852,9 @@ export default function WatchRoomPage() {
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 truncate">
|
||||
{room.currentState.type === 'play'
|
||||
? `正在播放: ${room.currentState.videoName}`
|
||||
: `正在观看: ${room.currentState.channelName}`}
|
||||
: room.currentState.type === 'live'
|
||||
? `正在观看: ${room.currentState.channelName}`
|
||||
: '正在共享屏幕'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -733,6 +874,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{toast && <Toast {...toast} />}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { Monitor, MonitorPlay, Users } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||
import { screenShareQualityOptions, type ScreenShareQualityPreset, useScreenShare } from '@/hooks/useScreenShare';
|
||||
|
||||
const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_';
|
||||
const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect';
|
||||
const SCREEN_SHARE_QUALITY_KEY = 'watch_room_screen_quality';
|
||||
|
||||
function getScreenShareHostSupportError() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
return '当前环境不是安全上下文(HTTPS/localhost),不支持屏幕共享';
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||
return '当前浏览器不支持屏幕共享';
|
||||
}
|
||||
|
||||
if (typeof window.RTCPeerConnection === 'undefined') {
|
||||
return '当前浏览器不支持实时屏幕传输';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getScreenShareViewerSupportError() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
if (typeof window.RTCPeerConnection === 'undefined') {
|
||||
return '当前浏览器不支持实时屏幕传输';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function WatchRoomScreenPage() {
|
||||
const router = useRouter();
|
||||
const watchRoom = useWatchRoomContext();
|
||||
const { currentRoom, members, leaveRoom } = watchRoom;
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
const [qualityPreset, setQualityPreset] = useState<ScreenShareQualityPreset>('smooth');
|
||||
const {
|
||||
currentRoom: screenRoom,
|
||||
isOwner,
|
||||
isSharing,
|
||||
isStarting,
|
||||
error,
|
||||
captureSettings,
|
||||
localVideoRef,
|
||||
remoteVideoRef,
|
||||
startSharing,
|
||||
stopSharing,
|
||||
} = useScreenShare(qualityPreset);
|
||||
|
||||
const showToast = (message: string, type: ToastProps['type'] = 'info') => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
duration: 3000,
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
};
|
||||
|
||||
const openDetachedPage = useCallback(() => {
|
||||
window.open('/', '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const saved = window.localStorage.getItem(SCREEN_SHARE_QUALITY_KEY);
|
||||
if (saved === 'smooth' || saved === 'hd' || saved === 'ultra') {
|
||||
setQualityPreset(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(SCREEN_SHARE_QUALITY_KEY, qualityPreset);
|
||||
}, [qualityPreset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentRoom) {
|
||||
router.replace('/watch-room');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRoom.roomType !== 'screen') {
|
||||
router.replace('/watch-room');
|
||||
}
|
||||
}, [currentRoom, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenRoom || screenRoom.roomType !== 'screen') return;
|
||||
|
||||
const supportError = isOwner
|
||||
? getScreenShareHostSupportError()
|
||||
: getScreenShareViewerSupportError();
|
||||
if (supportError) {
|
||||
showToast(`当前设备无法使用屏幕共享房间:${supportError}`, 'error');
|
||||
leaveRoom();
|
||||
router.replace('/watch-room');
|
||||
}
|
||||
}, [isOwner, leaveRoom, router, screenRoom?.id, screenRoom?.roomType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenRoom || !isOwner) return;
|
||||
|
||||
localStorage.setItem(WATCH_ROOM_NO_CONNECT_KEY, '1');
|
||||
const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`;
|
||||
if (!sessionStorage.getItem(key)) {
|
||||
sessionStorage.setItem(key, '1');
|
||||
openDetachedPage();
|
||||
}
|
||||
|
||||
return () => {
|
||||
localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY);
|
||||
};
|
||||
}, [isOwner, openDetachedPage, screenRoom?.id]);
|
||||
|
||||
if (!screenRoom || screenRoom.roomType !== 'screen') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
if (isOwner && isSharing) {
|
||||
stopSharing(true);
|
||||
}
|
||||
leaveRoom();
|
||||
router.push('/watch-room');
|
||||
};
|
||||
|
||||
const captureSettingsText = captureSettings
|
||||
? [
|
||||
captureSettings.width && captureSettings.height
|
||||
? `${captureSettings.width}x${captureSettings.height}`
|
||||
: '分辨率未知',
|
||||
captureSettings.frameRate ? `${Math.round(captureSettings.frameRate)} fps` : '帧率未知',
|
||||
].join(' / ')
|
||||
: '未开始';
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200'>
|
||||
<div className='mx-auto flex min-h-screen max-w-7xl flex-col gap-4 px-4 py-4 lg:px-8'>
|
||||
<div className='flex items-center justify-between gap-4 rounded-2xl border border-gray-200 bg-white/90 px-5 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-semibold'>
|
||||
<Monitor className='h-6 w-6 text-blue-500' />
|
||||
屏幕共享观影室
|
||||
</h1>
|
||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
房间:{screenRoom.name} · 房主:{screenRoom.ownerName}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isOwner && (
|
||||
<Link
|
||||
href='/'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openDetachedPage();
|
||||
}}
|
||||
className='rounded-lg bg-blue-500 px-4 py-2 text-white'
|
||||
>
|
||||
新开主页
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
className='rounded-lg bg-gray-200 px-4 py-2 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
|
||||
>
|
||||
离开房间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid flex-1 grid-cols-1 gap-4 xl:grid-cols-[1fr_320px]'>
|
||||
<div className='relative flex min-h-[420px] items-center justify-center overflow-hidden rounded-2xl border border-gray-200 bg-black dark:border-gray-800'>
|
||||
{isOwner ? (
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className='h-full w-full bg-black object-contain'
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={remoteVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls
|
||||
className='h-full w-full bg-black object-contain'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSharing && (
|
||||
<div className='absolute px-6 text-center text-white'>
|
||||
<MonitorPlay className='mx-auto mb-3 h-12 w-12 text-white/70' />
|
||||
<p className='text-lg font-medium'>
|
||||
{isOwner ? '点击开始共享,向房员推送浏览器画面' : '等待房主开始共享屏幕'}
|
||||
</p>
|
||||
{isOwner && (
|
||||
<p className='mt-2 text-sm text-white/70'>
|
||||
本页不要关闭;已尝试为你新开一个主页标签页方便继续浏览。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900'>
|
||||
<h2 className='mb-3 font-semibold'>共享状态</h2>
|
||||
<div className='space-y-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||
<p>类型:屏幕共享</p>
|
||||
<p>状态:{isSharing ? '共享中' : '未开始'}</p>
|
||||
<p>成员:{members.length} 人</p>
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div className='mt-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||
实际采集:{captureSettingsText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className='mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<div className='mt-4'>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
共享画质
|
||||
</label>
|
||||
<select
|
||||
value={qualityPreset}
|
||||
onChange={(event) => setQualityPreset(event.target.value as ScreenShareQualityPreset)}
|
||||
disabled={isStarting || isSharing}
|
||||
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 disabled:cursor-not-allowed disabled:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:disabled:bg-gray-900'
|
||||
>
|
||||
{screenShareQualityOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
画质越高越清晰,但更依赖网络和设备性能。共享开始后不可切换。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-4 flex gap-3'>
|
||||
{isOwner ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startSharing()}
|
||||
disabled={isStarting || isSharing}
|
||||
className='flex-1 rounded-lg bg-blue-500 px-4 py-2 text-white disabled:bg-gray-400'
|
||||
>
|
||||
{isStarting ? '启动中...' : isSharing ? '共享中' : '开始共享'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopSharing(true)}
|
||||
disabled={!isSharing}
|
||||
className='rounded-lg bg-red-500 px-4 py-2 text-white disabled:bg-gray-400'
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className='rounded-lg bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'>
|
||||
房员无需操作,房主开始共享后会自动显示画面。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900'>
|
||||
<h2 className='mb-3 flex items-center gap-2 font-semibold'>
|
||||
<Users className='h-4 w-4' />
|
||||
房间成员
|
||||
</h2>
|
||||
<div className='space-y-2'>
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className='flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-800/70'
|
||||
>
|
||||
<span className='text-sm'>{member.name}</span>
|
||||
{member.isOwner && (
|
||||
<span className='rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300'>
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200'>
|
||||
建议使用桌面版 Chrome / Edge,并优先共享标签页。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toast && <Toast {...toast} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useRef,useState } from 'react';
|
||||
|
||||
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
interface BannerCarouselProps {
|
||||
autoPlayInterval?: number; // 自动播放间隔(毫秒)
|
||||
@@ -67,15 +68,15 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图片URL(处理TX完整URL和TMDB路径)
|
||||
// 获取图片原始URL(处理TX完整URL和TMDB路径)
|
||||
const getImageUrl = (path: string | null) => {
|
||||
if (!path) return '';
|
||||
// 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理
|
||||
// 如果是完整URL(TX数据源或豆瓣),直接返回原始地址
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return processImageUrl(path);
|
||||
return path;
|
||||
}
|
||||
// 否则使用TMDB的URL拼接,并通过processImageUrl处理
|
||||
return processImageUrl(getTMDBImageUrl(path, 'original'));
|
||||
// 否则使用TMDB的URL拼接原始地址
|
||||
return getTMDBImageUrl(path, 'original');
|
||||
};
|
||||
|
||||
// 获取视频URL(处理豆瓣视频代理)
|
||||
@@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
</div>
|
||||
) : (
|
||||
/* 显示图片 */
|
||||
<Image
|
||||
src={getImageUrl(item.backdrop_path || item.poster_path)}
|
||||
<ProxyImage
|
||||
originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority={index === 0}
|
||||
sizes="100vw"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
)}
|
||||
{/* 渐变遮罩 */}
|
||||
|
||||
@@ -35,9 +35,12 @@ export default function DanmakuPanel({
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const initializedRef = useRef(false); // 标记是否已初始化过
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const episodeGroupContainerRef = useRef<HTMLDivElement>(null);
|
||||
const episodeGroupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [episodeGroupIndex, setEpisodeGroupIndex] = useState(0);
|
||||
const [episodeDescending, setEpisodeDescending] = useState(false);
|
||||
const [episodeViewMode, setEpisodeViewMode] = useState<'list' | 'grid'>('list');
|
||||
const [isEpisodeGroupHovered, setIsEpisodeGroupHovered] = useState(false);
|
||||
const episodesPerGroup = 50;
|
||||
|
||||
// 搜索弹幕
|
||||
@@ -230,6 +233,62 @@ export default function DanmakuPanel({
|
||||
return String(episodeNumber);
|
||||
}, []);
|
||||
|
||||
const preventPageScroll = useCallback((e: WheelEvent) => {
|
||||
if (isEpisodeGroupHovered) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [isEpisodeGroupHovered]);
|
||||
|
||||
const handleEpisodeGroupWheel = useCallback((e: WheelEvent) => {
|
||||
if (!isEpisodeGroupHovered || !episodeGroupContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = episodeGroupContainerRef.current;
|
||||
if (container.scrollWidth <= container.clientWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
container.scrollBy({
|
||||
left: e.deltaY * 2,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [isEpisodeGroupHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEpisodeGroupHovered) {
|
||||
document.addEventListener('wheel', preventPageScroll, { passive: false });
|
||||
document.addEventListener('wheel', handleEpisodeGroupWheel, { passive: false });
|
||||
} else {
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleEpisodeGroupWheel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleEpisodeGroupWheel);
|
||||
};
|
||||
}, [handleEpisodeGroupWheel, isEpisodeGroupHovered, preventPageScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const btn = episodeGroupButtonRefs.current[displayEpisodeGroupIndex];
|
||||
const container = episodeGroupContainerRef.current;
|
||||
if (!btn || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const btnLeft = btnRect.left - containerRect.left + container.scrollLeft;
|
||||
const targetScrollLeft = btnLeft - (containerRect.width - btnRect.width) / 2;
|
||||
|
||||
container.scrollTo({
|
||||
left: targetScrollLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [displayEpisodeGroupIndex]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden'>
|
||||
{/* 搜索区域 - 固定在顶部 */}
|
||||
@@ -348,12 +407,20 @@ export default function DanmakuPanel({
|
||||
{!isLoadingEpisodes && episodes.length > 0 && (
|
||||
<div className='pb-4'>
|
||||
<div className='mb-4 border-b border-gray-300 dark:border-gray-700'>
|
||||
<div className='flex items-center gap-4 overflow-x-auto pb-3'>
|
||||
<div
|
||||
ref={episodeGroupContainerRef}
|
||||
className='flex items-center gap-4 overflow-x-auto pb-3'
|
||||
onMouseEnter={() => setIsEpisodeGroupHovered(true)}
|
||||
onMouseLeave={() => setIsEpisodeGroupHovered(false)}
|
||||
>
|
||||
{episodeGroups.map((label, idx) => {
|
||||
const isActive = idx === displayEpisodeGroupIndex;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
ref={(el) => {
|
||||
episodeGroupButtonRefs.current[idx] = el;
|
||||
}}
|
||||
onClick={() =>
|
||||
setEpisodeGroupIndex(
|
||||
episodeDescending ? episodeGroupCount - 1 - idx : idx
|
||||
|
||||
+364
-84
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, Clock, ExternalLink, Film,Globe, Star, Tag, Users, X } from 'lucide-react';
|
||||
import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import ImageViewer from '@/components/ImageViewer';
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
interface DetailPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -69,6 +70,16 @@ interface Episode {
|
||||
air_date: string;
|
||||
}
|
||||
|
||||
interface GalleryImage {
|
||||
file_path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
vote_average?: number;
|
||||
vote_count?: number;
|
||||
iso_639_1?: string | null;
|
||||
imageType: 'backdrop' | 'poster';
|
||||
}
|
||||
|
||||
const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -100,6 +111,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<string>('');
|
||||
const [showGallery, setShowGallery] = useState(false);
|
||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||
const [galleryError, setGalleryError] = useState<string | null>(null);
|
||||
const [galleryImages, setGalleryImages] = useState<GalleryImage[]>([]);
|
||||
const [galleryTotal, setGalleryTotal] = useState(0);
|
||||
const [galleryScrollTop, setGalleryScrollTop] = useState(0);
|
||||
const [galleryViewportHeight, setGalleryViewportHeight] = useState(0);
|
||||
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
|
||||
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// 数据源状态管理
|
||||
@@ -151,11 +171,82 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
setShowImageViewer(true);
|
||||
};
|
||||
|
||||
const galleryTmdbId = detailData?.tmdbId || tmdbId;
|
||||
const galleryMediaType = detailData?.mediaType || type;
|
||||
const canShowGalleryEntry = !!galleryTmdbId && !!galleryMediaType;
|
||||
|
||||
const fetchGalleryImages = async () => {
|
||||
if (!galleryTmdbId || !galleryMediaType) return;
|
||||
|
||||
setGalleryLoading(true);
|
||||
setGalleryError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tmdb/images?id=${galleryTmdbId}&type=${galleryMediaType}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取照片墙失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setGalleryImages(data.list || []);
|
||||
setGalleryTotal(data.total || 0);
|
||||
} catch (err) {
|
||||
console.error('获取照片墙失败:', err);
|
||||
setGalleryError(err instanceof Error ? err.message : '获取照片墙失败');
|
||||
} finally {
|
||||
setGalleryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
setShowGallery(true);
|
||||
};
|
||||
|
||||
// 确保组件在客户端挂载后才渲染 Portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showGallery) {
|
||||
setGalleryImages([]);
|
||||
setGalleryError(null);
|
||||
setGalleryLoading(false);
|
||||
setGalleryTotal(0);
|
||||
setGalleryScrollTop(0);
|
||||
setGalleryViewportHeight(0);
|
||||
setGalleryViewportWidth(0);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchGalleryImages();
|
||||
}, [showGallery, galleryTmdbId, galleryMediaType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showGallery || !galleryScrollRef.current) return;
|
||||
|
||||
const element = galleryScrollRef.current;
|
||||
|
||||
const updateMetrics = () => {
|
||||
setGalleryViewportHeight(element.clientHeight);
|
||||
setGalleryViewportWidth(element.clientWidth);
|
||||
setGalleryScrollTop(element.scrollTop);
|
||||
};
|
||||
|
||||
updateMetrics();
|
||||
element.addEventListener('scroll', updateMetrics, { passive: true });
|
||||
const resizeObserver = new ResizeObserver(updateMetrics);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', updateMetrics);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showGallery]);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
let animationId: number;
|
||||
@@ -185,6 +276,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setShowGallery(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 阻止背景滚动(仅在非抽屉模式下)
|
||||
useEffect(() => {
|
||||
if (isVisible && !useDrawer) {
|
||||
@@ -277,7 +374,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
title: title,
|
||||
intro: cmsData.desc,
|
||||
episodesCount: cmsData.episodes?.length,
|
||||
poster: poster ? processImageUrl(poster) : poster,
|
||||
poster: poster,
|
||||
};
|
||||
setDetailData(data);
|
||||
setOriginalDetailData(data);
|
||||
@@ -297,7 +394,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
title: data.title || title,
|
||||
intro: data.desc || '',
|
||||
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
||||
poster: data.poster ? processImageUrl(data.poster) : poster,
|
||||
poster: data.poster || poster,
|
||||
year: data.year,
|
||||
};
|
||||
setDetailData(detailData);
|
||||
@@ -327,7 +424,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
title: data.name_cn || data.name,
|
||||
originalTitle: data.name,
|
||||
year: data.date ? data.date.substring(0, 4) : undefined,
|
||||
poster: data.images?.large ? processImageUrl(data.images.large) : poster,
|
||||
poster: data.images?.large || poster,
|
||||
rating: data.rating
|
||||
? {
|
||||
value: data.rating.score,
|
||||
@@ -358,7 +455,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
title: data.title,
|
||||
originalTitle: data.original_title,
|
||||
year: data.year,
|
||||
poster: (data.pic?.large || data.pic?.normal) ? processImageUrl(data.pic?.large || data.pic?.normal) : poster,
|
||||
poster: data.pic?.large || data.pic?.normal || poster,
|
||||
rating: data.rating
|
||||
? {
|
||||
value: data.rating.value,
|
||||
@@ -777,7 +874,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
...prev,
|
||||
title: episodesData.name || season?.name || prev.title,
|
||||
intro: episodesData.overview || season?.overview || prev.overview,
|
||||
poster: season?.poster_path ? processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')) : prev.poster,
|
||||
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
|
||||
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
|
||||
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
||||
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
|
||||
@@ -887,6 +984,171 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const galleryEntryButton = canShowGalleryEntry ? (
|
||||
<button
|
||||
onClick={openGallery}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors"
|
||||
>
|
||||
<Images size={16} />
|
||||
照片墙
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const virtualGalleryLayout = React.useMemo(() => {
|
||||
if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
|
||||
return {
|
||||
visibleItems: [] as Array<GalleryImage & { top: number; left: number; renderWidth: number; renderHeight: number; index: number }>,
|
||||
totalHeight: 0,
|
||||
usedWidth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const gap = 4;
|
||||
const overscan = 800;
|
||||
const horizontalPadding = 32;
|
||||
const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
|
||||
const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
|
||||
const columnWidth = Math.floor((width - gap * (columnCount - 1)) / columnCount);
|
||||
const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||
const columnHeights = new Array(columnCount).fill(0);
|
||||
|
||||
const items = galleryImages.map((image, index) => {
|
||||
let targetColumn = 0;
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
if (columnHeights[i] < columnHeights[targetColumn]) {
|
||||
targetColumn = i;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = image.width && image.height ? image.height / image.width : (image.imageType === 'poster' ? 1.5 : 0.5625);
|
||||
const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
|
||||
const top = columnHeights[targetColumn];
|
||||
const left = targetColumn * (columnWidth + gap);
|
||||
|
||||
columnHeights[targetColumn] += renderHeight + gap;
|
||||
|
||||
return {
|
||||
...image,
|
||||
index,
|
||||
top,
|
||||
left,
|
||||
renderWidth: columnWidth,
|
||||
renderHeight,
|
||||
};
|
||||
});
|
||||
|
||||
const totalHeight = Math.max(...columnHeights, 0);
|
||||
const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
|
||||
const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan;
|
||||
const visibleItems = items.filter(item => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom);
|
||||
|
||||
return { visibleItems, totalHeight, usedWidth };
|
||||
}, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
|
||||
|
||||
const galleryBody = (
|
||||
<div ref={galleryScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden p-4">
|
||||
{galleryLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!galleryLoading && galleryError && (
|
||||
<div className="text-center py-12 text-red-500 dark:text-red-400">{galleryError}</div>
|
||||
)}
|
||||
|
||||
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">暂无图片</div>
|
||||
)}
|
||||
|
||||
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
|
||||
>
|
||||
{virtualGalleryLayout.visibleItems.map((image) => {
|
||||
const imageUrl = getTMDBImageUrl(
|
||||
image.file_path,
|
||||
image.imageType === 'poster' ? 'w500' : 'original'
|
||||
);
|
||||
const thumbUrl = getTMDBImageUrl(
|
||||
image.file_path,
|
||||
image.imageType === 'poster' ? 'w342' : 'w780'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${image.imageType}-${image.file_path}-${image.index}`}
|
||||
className="group absolute"
|
||||
style={{
|
||||
top: image.top,
|
||||
left: image.left,
|
||||
width: image.renderWidth,
|
||||
height: image.renderHeight,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(imageUrl)}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={thumbUrl}
|
||||
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
|
||||
{image.imageType === 'poster' ? '海报' : '剧照'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const galleryHeader = (
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">照片墙</h3>
|
||||
{!galleryLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {galleryTotal} 张
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGallery(false)}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="关闭照片墙"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const galleryModal = showGallery ? (useDrawer ? (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none">
|
||||
<div className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}>
|
||||
{galleryHeader}
|
||||
{galleryBody}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setShowGallery(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{galleryHeader}
|
||||
{galleryBody}
|
||||
</div>
|
||||
</div>
|
||||
)) : null;
|
||||
|
||||
if (!isVisible || !mounted) return null;
|
||||
|
||||
const content = useDrawer ? (
|
||||
@@ -938,7 +1200,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 数据源显示和切换 - 错误时也显示 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
@@ -948,24 +1210,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{currentSource === 'tmdb' && 'TMDB'}
|
||||
</span>
|
||||
</div>
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{galleryEntryButton}
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -976,11 +1241,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{/* 海报和基本信息 */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
{detailData.poster && (
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
||||
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={detailData.poster}
|
||||
alt={detailData.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{galleryEntryButton}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -1103,13 +1376,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{actor.profile_path ? (
|
||||
<div
|
||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||
alt={actor.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1221,14 +1493,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
||||
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||
alt={season.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1283,13 +1554,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{episode.still_path && (
|
||||
<div
|
||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||
alt={episode.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1332,7 +1602,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 数据源显示和切换 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
@@ -1342,24 +1612,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{currentSource === 'tmdb' && 'TMDB'}
|
||||
</span>
|
||||
</div>
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{galleryEntryButton}
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1368,6 +1641,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{galleryModal}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
@@ -1480,11 +1754,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{/* 海报和基本信息 */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
{detailData.poster && (
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
||||
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={detailData.poster}
|
||||
alt={detailData.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{galleryEntryButton}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -1607,13 +1889,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{actor.profile_path ? (
|
||||
<div
|
||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||
alt={actor.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1725,14 +2006,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
||||
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||
alt={season.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1787,13 +2067,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{episode.still_path && (
|
||||
<div
|
||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||
alt={episode.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1872,6 +2151,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{galleryModal}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
|
||||
@@ -12,10 +12,11 @@ import React, {
|
||||
|
||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||
|
||||
import DanmakuPanel from '@/components/DanmakuPanel';
|
||||
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
// 定义视频信息类型
|
||||
interface VideoInfo {
|
||||
@@ -870,10 +871,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
{source.source === 'directplay' ? (
|
||||
<LinkIcon className='w-6 h-6 text-blue-500' />
|
||||
) : source.poster ? (
|
||||
<img
|
||||
src={processImageUrl(source.poster)}
|
||||
<ProxyImage
|
||||
originalSrc={source.poster}
|
||||
alt={source.title}
|
||||
className='w-full h-full object-cover'
|
||||
retryOnError={false}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
@@ -940,7 +942,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
{/* 源名称和集数信息 - 垂直居中 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
|
||||
source.source === 'xiaoya' ? 'border-blue-500' : source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
|
||||
source.source === 'xiaoya' ? 'border-blue-500' : source.source === 'quark-temp' ? 'border-purple-500' : source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
|
||||
? 'border-yellow-500'
|
||||
: 'border-gray-500/60'
|
||||
}`}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
interface ImageViewerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -151,18 +152,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
<ProxyImage
|
||||
originalSrc={imageUrl}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={1800}
|
||||
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
|
||||
style={{
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
}}
|
||||
priority
|
||||
quality={100}
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,10 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
};
|
||||
const currentActive = activePath ?? getCurrentFullPath();
|
||||
|
||||
if (pathname === '/watch-room/screen') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [navItems, setNavItems] = useState([
|
||||
{ icon: Home, label: '首页', href: '/' },
|
||||
{
|
||||
|
||||
+286
-181
@@ -2,8 +2,10 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
|
||||
|
||||
interface PansouSearchProps {
|
||||
@@ -51,11 +53,15 @@ export default function PansouSearch({
|
||||
triggerSearch,
|
||||
onError,
|
||||
}: PansouSearchProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<PansouSearchResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
|
||||
const [transferingUrl, setTransferingUrl] = useState<string | null>(null);
|
||||
const [playingUrl, setPlayingUrl] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
// 提取搜索函数,以便在重试时调用
|
||||
const searchPansou = useCallback(async () => {
|
||||
@@ -118,205 +124,304 @@ export default function PansouSearch({
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
正在搜索网盘资源...
|
||||
</p>
|
||||
const handleQuarkTransfer = async (link: PansouLink) => {
|
||||
try {
|
||||
setTransferingUrl(link.url);
|
||||
const response = await fetch('/api/netdisk/quark/transfer', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shareUrl: link.url,
|
||||
passcode: link.password || '',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '转存失败');
|
||||
}
|
||||
|
||||
setToast({
|
||||
message: `转存成功,已保存到:${data.targetPath}`,
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} catch (err: any) {
|
||||
setToast({
|
||||
message: err?.message || '转存失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} finally {
|
||||
setTransferingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuarkInstantPlay = async (link: PansouLink) => {
|
||||
try {
|
||||
setPlayingUrl(link.url);
|
||||
const response = await fetch('/api/netdisk/quark/instant-play', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shareUrl: link.url,
|
||||
passcode: link.password || '',
|
||||
title: link.note || keyword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '立即播放失败');
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/play?source=quark-temp&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
setToast({
|
||||
message: err?.message || '立即播放失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} finally {
|
||||
setPlayingUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
正在搜索网盘资源...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
||||
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
<button
|
||||
onClick={searchPansou}
|
||||
className='mt-4 inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors'
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!results || results.total === 0 || !results.merged_by_type) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
未找到相关资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cloudTypes = Object.keys(results.merged_by_type || {});
|
||||
|
||||
// 过滤显示的网盘类型
|
||||
const filteredCloudTypes = selectedType === 'all'
|
||||
? cloudTypes
|
||||
: cloudTypes.filter(type => type === selectedType);
|
||||
|
||||
// 计算每种网盘类型的数量
|
||||
const typeStats = cloudTypes.map(type => ({
|
||||
type,
|
||||
count: results.merged_by_type?.[type]?.length || 0,
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
||||
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
<>
|
||||
{/* 搜索结果统计 */}
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
找到 <span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span> 个资源
|
||||
</div>
|
||||
|
||||
{/* 网盘类型过滤器 */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button
|
||||
onClick={searchPansou}
|
||||
className='mt-4 inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors'
|
||||
onClick={() => setSelectedType('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedType === 'all'
|
||||
? 'bg-green-600 text-white dark:bg-green-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
重试
|
||||
全部 ({results.total})
|
||||
</button>
|
||||
{typeStats.map(({ type, count }) => {
|
||||
const typeName = CLOUD_TYPE_NAMES[type] || type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedType === type
|
||||
? 'bg-green-600 text-white dark:bg-green-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{typeName} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!results || results.total === 0 || !results.merged_by_type) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
未找到相关资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* 按网盘类型分类显示 */}
|
||||
{filteredCloudTypes.map((cloudType) => {
|
||||
const links = results.merged_by_type?.[cloudType];
|
||||
if (!links || links.length === 0) return null;
|
||||
|
||||
const cloudTypes = Object.keys(results.merged_by_type || {});
|
||||
|
||||
// 过滤显示的网盘类型
|
||||
const filteredCloudTypes = selectedType === 'all'
|
||||
? cloudTypes
|
||||
: cloudTypes.filter(type => type === selectedType);
|
||||
|
||||
// 计算每种网盘类型的数量
|
||||
const typeStats = cloudTypes.map(type => ({
|
||||
type,
|
||||
count: results.merged_by_type?.[type]?.length || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* 搜索结果统计 */}
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
找到 <span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span> 个资源
|
||||
</div>
|
||||
|
||||
{/* 网盘类型过滤器 */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button
|
||||
onClick={() => setSelectedType('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedType === 'all'
|
||||
? 'bg-green-600 text-white dark:bg-green-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
全部 ({results.total})
|
||||
</button>
|
||||
{typeStats.map(({ type, count }) => {
|
||||
const typeName = CLOUD_TYPE_NAMES[type] || type;
|
||||
const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others;
|
||||
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
||||
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedType === type
|
||||
? 'bg-green-600 text-white dark:bg-green-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{typeName} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div key={cloudType} className='space-y-3'>
|
||||
{/* 网盘类型标题 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
|
||||
{typeName}
|
||||
</span>
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{links.length} 个链接
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 按网盘类型分类显示 */}
|
||||
{filteredCloudTypes.map((cloudType) => {
|
||||
const links = results.merged_by_type?.[cloudType];
|
||||
if (!links || links.length === 0) return null;
|
||||
|
||||
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
||||
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
||||
|
||||
return (
|
||||
<div key={cloudType} className='space-y-3'>
|
||||
{/* 网盘类型标题 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
|
||||
{typeName}
|
||||
</span>
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{links.length} 个链接
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 链接列表 */}
|
||||
<div className='space-y-2'>
|
||||
{links.map((link: PansouLink, index: number) => (
|
||||
<div
|
||||
key={`${cloudType}-${index}`}
|
||||
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
|
||||
>
|
||||
{/* 资源标题 */}
|
||||
{link.note && (
|
||||
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
{link.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 链接和密码 */}
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 truncate'>
|
||||
{link.url}
|
||||
{/* 链接列表 */}
|
||||
<div className='space-y-2'>
|
||||
{links.map((link: PansouLink, index: number) => (
|
||||
<div
|
||||
key={`${cloudType}-${index}`}
|
||||
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
|
||||
>
|
||||
{/* 资源标题 */}
|
||||
{link.note && (
|
||||
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
{link.note}
|
||||
</div>
|
||||
{link.password && (
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 mt-1'>
|
||||
提取码: <span className='font-mono font-semibold'>{link.password}</span>
|
||||
)}
|
||||
|
||||
{/* 链接和密码 */}
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 truncate'>
|
||||
{link.url}
|
||||
</div>
|
||||
{link.password && (
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 mt-1'>
|
||||
提取码: <span className='font-mono font-semibold'>{link.password}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
{cloudType === 'quark' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleQuarkInstantPlay(link)}
|
||||
disabled={playingUrl === link.url}
|
||||
className='px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
|
||||
title='立即播放'
|
||||
>
|
||||
{playingUrl === link.url ? '处理中...' : '立即播放'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuarkTransfer(link)}
|
||||
disabled={transferingUrl === link.url}
|
||||
className='px-2 py-1 rounded-md bg-purple-600 hover:bg-purple-700 text-white text-xs transition-colors disabled:opacity-60'
|
||||
title='转存到配置目录'
|
||||
>
|
||||
{transferingUrl === link.url ? '转存中...' : '转存'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopy(
|
||||
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
|
||||
link.url
|
||||
)}
|
||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
title='复制链接'
|
||||
>
|
||||
{copiedUrl === link.url ? (
|
||||
<span className='text-xs text-green-600 dark:text-green-400'>已复制</span>
|
||||
) : (
|
||||
<Copy className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenLink(link.url)}
|
||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
title='打开链接'
|
||||
>
|
||||
<ExternalLink className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 来源和时间 */}
|
||||
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{link.source && (
|
||||
<span>来源: {link.source}</span>
|
||||
)}
|
||||
{link.datetime && (
|
||||
<span>{new Date(link.datetime).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
<button
|
||||
onClick={() => handleCopy(
|
||||
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
|
||||
link.url
|
||||
)}
|
||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
title='复制链接'
|
||||
>
|
||||
{copiedUrl === link.url ? (
|
||||
<span className='text-xs text-green-600 dark:text-green-400'>已复制</span>
|
||||
) : (
|
||||
<Copy className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenLink(link.url)}
|
||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
title='打开链接'
|
||||
>
|
||||
<ExternalLink className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 来源和时间 */}
|
||||
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{link.source && (
|
||||
<span>来源: {link.source}</span>
|
||||
)}
|
||||
{link.datetime && (
|
||||
<span>{new Date(link.datetime).toLocaleDateString()}</span>
|
||||
{/* 图片预览 */}
|
||||
{link.images && link.images.length > 0 && (
|
||||
<div className='mt-3 flex gap-2 overflow-x-auto'>
|
||||
{link.images.map((img, imgIndex) => (
|
||||
<img
|
||||
key={imgIndex}
|
||||
src={img}
|
||||
alt=''
|
||||
className='h-20 w-auto rounded object-cover'
|
||||
loading='lazy'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
{link.images && link.images.length > 0 && (
|
||||
<div className='mt-3 flex gap-2 overflow-x-auto'>
|
||||
{link.images.map((img, imgIndex) => (
|
||||
<img
|
||||
key={imgIndex}
|
||||
src={img}
|
||||
alt=''
|
||||
className='h-20 w-auto rounded object-cover'
|
||||
loading='lazy'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
{renderBody()}
|
||||
</div>
|
||||
{toast && <Toast {...toast} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
|
||||
|
||||
interface ProxyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
originalSrc: string;
|
||||
displaySrc?: string;
|
||||
retryDelay?: number;
|
||||
retryOnError?: boolean;
|
||||
}
|
||||
|
||||
const ProxyImage: React.FC<ProxyImageProps> = ({
|
||||
originalSrc,
|
||||
displaySrc,
|
||||
retryDelay = 2000,
|
||||
retryOnError = true,
|
||||
onError,
|
||||
src: _src,
|
||||
...props
|
||||
}) => {
|
||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const img = e.currentTarget;
|
||||
|
||||
if (tryApplyDoubanImageFallback(img, originalSrc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryOnError && !img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
window.setTimeout(() => {
|
||||
img.src = displaySrc || processImageUrl(originalSrc);
|
||||
}, retryDelay);
|
||||
}
|
||||
|
||||
onError?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
src={displaySrc || processImageUrl(originalSrc)}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyImage;
|
||||
@@ -63,6 +63,10 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const watchRoomContext = useWatchRoomContextSafe();
|
||||
|
||||
if (pathname === '/watch-room/screen') {
|
||||
return null;
|
||||
}
|
||||
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (
|
||||
|
||||
@@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => {
|
||||
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
||||
const [enableTrailers, setEnableTrailers] = useState(false);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||
const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
|
||||
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
|
||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
|
||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||
useState(false);
|
||||
const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
|
||||
useState(false);
|
||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
||||
@@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => {
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
}
|
||||
|
||||
const savedDoubanDataSourceBackup = localStorage.getItem(
|
||||
'doubanDataSourceBackup'
|
||||
);
|
||||
setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
|
||||
|
||||
const savedDoubanProxyUrlBackup = localStorage.getItem(
|
||||
'doubanProxyUrlBackup'
|
||||
);
|
||||
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
|
||||
|
||||
const savedDoubanImageProxyType = localStorage.getItem(
|
||||
'doubanImageProxyType'
|
||||
);
|
||||
@@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => {
|
||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||
}
|
||||
|
||||
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
|
||||
'doubanImageProxyTypeBackup'
|
||||
);
|
||||
setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
|
||||
|
||||
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
|
||||
'doubanImageProxyUrlBackup'
|
||||
);
|
||||
setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
|
||||
|
||||
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
|
||||
if (savedTmdbImageBaseUrl !== null) {
|
||||
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
|
||||
@@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
}, [isDoubanDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDoubanBackupDropdownOpen) {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-dropdown="douban-datasource-backup"]')) {
|
||||
setIsDoubanBackupDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isDoubanBackupDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isDoubanBackupDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDoubanImageProxyDropdownOpen) {
|
||||
@@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
}, [isDoubanImageProxyDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDoubanImageProxyBackupDropdownOpen) {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-dropdown="douban-image-proxy-backup"]')) {
|
||||
setIsDoubanImageProxyBackupDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isDoubanImageProxyBackupDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isDoubanImageProxyBackupDropdownOpen]);
|
||||
|
||||
const handleMenuClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
@@ -1049,6 +1110,13 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanDataSourceBackupChange = (value: string) => {
|
||||
setDoubanDataSourceBackup(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanDataSourceBackup', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||
setDoubanImageProxyType(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1056,6 +1124,20 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyTypeBackupChange = (value: string) => {
|
||||
setDoubanImageProxyTypeBackup(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanImageProxyTypeBackup', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanProxyUrlBackupChange = (value: string) => {
|
||||
setDoubanProxyUrlBackup(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanProxyUrlBackup', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyUrlChange = (value: string) => {
|
||||
setDoubanImageProxyUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1063,6 +1145,13 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyUrlBackupChange = (value: string) => {
|
||||
setDoubanImageProxyUrlBackup(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('doubanImageProxyUrlBackup', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTmdbImageBaseUrlChange = (value: string) => {
|
||||
setTmdbImageBaseUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => {
|
||||
setEnableTrailers(false);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanDataSourceBackup('direct');
|
||||
setDoubanProxyUrlBackup('');
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||
setDoubanImageProxyTypeBackup('server');
|
||||
setDoubanImageProxyUrlBackup('');
|
||||
setTmdbImageBaseUrl('https://image.tmdb.org');
|
||||
setBufferStrategy('medium');
|
||||
setNextEpisodePreCache(true);
|
||||
@@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('enableTrailers', 'false');
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanDataSourceBackup', 'direct');
|
||||
localStorage.setItem('doubanProxyUrlBackup', '');
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
||||
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
|
||||
localStorage.setItem('doubanImageProxyUrlBackup', '');
|
||||
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
|
||||
localStorage.setItem('bufferStrategy', 'medium');
|
||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||
@@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => {
|
||||
value={doubanProxyUrl}
|
||||
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
||||
/>
|
||||
{!doubanProxyUrl.trim() && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||
未填写地址时将自动按直连处理
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-3'>
|
||||
<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>
|
||||
<div
|
||||
className='relative'
|
||||
data-dropdown='douban-datasource-backup'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setIsDoubanBackupDropdownOpen(!isDoubanBackupDropdownOpen)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
doubanDataSourceOptions.find(
|
||||
(option) => option.value === doubanDataSourceBackup
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanBackupDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isDoubanBackupDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{doubanDataSourceOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleDoubanDataSourceBackupChange(option.value);
|
||||
setIsDoubanBackupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSourceBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanDataSourceBackup === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doubanDataSourceBackup === 'custom' && (
|
||||
<div className='space-y-3'>
|
||||
<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>
|
||||
<input
|
||||
type='text'
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={doubanProxyUrlBackup}
|
||||
onChange={(e) =>
|
||||
handleDoubanProxyUrlBackupChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{!doubanProxyUrlBackup.trim() && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||
未填写地址时备用渠道将自动按直连处理
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
|
||||
handleDoubanImageProxyUrlChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{!doubanImageProxyUrl.trim() && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||
未填写地址时将自动按服务器代理处理
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-3'>
|
||||
<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>
|
||||
<div
|
||||
className='relative'
|
||||
data-dropdown='douban-image-proxy-backup'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setIsDoubanImageProxyBackupDropdownOpen(
|
||||
!isDoubanImageProxyBackupDropdownOpen
|
||||
)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
doubanImageProxyTypeOptions.find(
|
||||
(option) => option.value === doubanImageProxyTypeBackup
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyBackupDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isDoubanImageProxyBackupDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{doubanImageProxyTypeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleDoubanImageProxyTypeBackupChange(option.value);
|
||||
setIsDoubanImageProxyBackupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyTypeBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanImageProxyTypeBackup === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doubanImageProxyTypeBackup === 'custom' && (
|
||||
<div className='space-y-3'>
|
||||
<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>
|
||||
<input
|
||||
type='text'
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={doubanImageProxyUrlBackup}
|
||||
onChange={(e) =>
|
||||
handleDoubanImageProxyUrlBackupChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{!doubanImageProxyUrlBackup.trim() && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||
未填写地址时备用图片渠道将自动按服务器代理处理
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
saveFavorite,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { processImageUrl, base58Decode } from '@/lib/utils';
|
||||
import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
|
||||
import AIChatPanel from '@/components/AIChatPanel';
|
||||
@@ -750,8 +750,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (tryApplyDoubanImageFallback(img, actualPoster)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片加载失败时的重试机制
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (!img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
setTimeout(() => {
|
||||
@@ -1043,7 +1047,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
|
||||
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : origin === 'live' ? 'border-red-500' : 'border-white/60'
|
||||
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'quark-temp' ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : origin === 'live' ? 'border-red-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
@@ -1367,7 +1371,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
{config.showSourceName && source_name && !cmsData && (
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'quark-temp' ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
@@ -1598,7 +1602,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={processImageUrl(actualPoster)}
|
||||
imageUrl={actualPoster}
|
||||
alt={actualTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,10 +9,12 @@ import Toast, { ToastProps } from '@/components/Toast';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import type { ChatMessage, Member, Room, WatchRoomConfig } from '@/types/watch-room';
|
||||
import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room';
|
||||
|
||||
// Import type from watch-room-socket
|
||||
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
|
||||
const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect';
|
||||
const WATCH_ROOM_SCREEN_PATH = '/watch-room/screen';
|
||||
|
||||
interface WatchRoomContextType {
|
||||
socket: WatchRoomSocket | null;
|
||||
@@ -31,12 +33,14 @@ interface WatchRoomContextType {
|
||||
description: string;
|
||||
password?: string;
|
||||
isPublic: boolean;
|
||||
roomType: RoomType;
|
||||
userName: string;
|
||||
}) => Promise<Room>;
|
||||
joinRoom: (data: {
|
||||
roomId: string;
|
||||
password?: string;
|
||||
userName: string;
|
||||
ownerToken?: string;
|
||||
}) => Promise<{ room: Room; members: Member[] }>;
|
||||
leaveRoom: () => void;
|
||||
getRoomList: () => Promise<Room[]>;
|
||||
@@ -51,6 +55,8 @@ interface WatchRoomContextType {
|
||||
pause: () => void;
|
||||
changeVideo: (state: any) => void;
|
||||
changeLiveChannel: (state: any) => void;
|
||||
startScreenShare: (state: ScreenState) => void;
|
||||
stopScreenShare: () => void;
|
||||
clearRoomState: () => void;
|
||||
|
||||
// 重连
|
||||
@@ -82,6 +88,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
const [reconnectFailed, setReconnectFailed] = useState(false);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState<boolean | null>(null);
|
||||
|
||||
// 处理房间删除的回调
|
||||
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
||||
@@ -119,6 +126,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
|
||||
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setShouldDisableWatchRoomConnection(
|
||||
window.location.pathname !== WATCH_ROOM_SCREEN_PATH
|
||||
&& window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1'
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 检查登录状态
|
||||
useEffect(() => {
|
||||
const checkLoginStatus = () => {
|
||||
@@ -156,6 +172,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
roomId: info.roomId,
|
||||
password: info.password,
|
||||
userName: info.userName,
|
||||
ownerToken: info.ownerToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
|
||||
@@ -169,6 +186,19 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
if (shouldDisableWatchRoomConnection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDisableWatchRoomConnection) {
|
||||
setConfig({
|
||||
enabled: false,
|
||||
serverType: 'internal',
|
||||
});
|
||||
setIsEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
// 使用公共 API 获取观影室配置(不需要管理员权限)
|
||||
@@ -253,12 +283,14 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, [isLoggedIn, shouldDisableWatchRoomConnection]); // 添加 isLoggedIn 作为依赖
|
||||
|
||||
// 清理
|
||||
// 仅在 Provider 卸载时断开,避免路由切换时误断开房间连接
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
watchRoom.disconnect();
|
||||
};
|
||||
}, [isLoggedIn]); // 添加 isLoggedIn 作为依赖
|
||||
}, []);
|
||||
|
||||
const contextValue: WatchRoomContextType = {
|
||||
socket: watchRoom.socket,
|
||||
@@ -281,6 +313,8 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
pause: watchRoom.pause,
|
||||
changeVideo: watchRoom.changeVideo,
|
||||
changeLiveChannel: watchRoom.changeLiveChannel,
|
||||
startScreenShare: watchRoom.startScreenShare,
|
||||
stopScreenShare: watchRoom.stopScreenShare,
|
||||
clearRoomState: watchRoom.clearRoomState,
|
||||
manualReconnect,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||
|
||||
import type { ScreenState } from '@/types/watch-room';
|
||||
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
|
||||
export type ScreenShareQualityPreset = 'smooth' | 'hd' | 'ultra';
|
||||
|
||||
const SCREEN_SHARE_CONSTRAINTS: Record<
|
||||
ScreenShareQualityPreset,
|
||||
{
|
||||
label: string;
|
||||
frameRate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
> = {
|
||||
smooth: {
|
||||
label: '流畅 720p / 15fps',
|
||||
frameRate: 15,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
hd: {
|
||||
label: '高清 1080p / 30fps',
|
||||
frameRate: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
ultra: {
|
||||
label: '超清 1440p / 30fps',
|
||||
frameRate: 30,
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
},
|
||||
};
|
||||
|
||||
export const screenShareQualityOptions = Object.entries(SCREEN_SHARE_CONSTRAINTS).map(
|
||||
([value, preset]) => ({
|
||||
value: value as ScreenShareQualityPreset,
|
||||
label: preset.label,
|
||||
})
|
||||
);
|
||||
|
||||
export interface ScreenShareCaptureSettings {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
frameRate: number | null;
|
||||
}
|
||||
|
||||
export function useScreenShare(qualityPreset: ScreenShareQualityPreset = 'smooth') {
|
||||
const watchRoom = useWatchRoomContextSafe();
|
||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const displayStreamRef = useRef<MediaStream | null>(null);
|
||||
const remoteStreamRef = useRef<MediaStream | null>(null);
|
||||
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||
const stoppingRef = useRef(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [captureSettings, setCaptureSettings] = useState<ScreenShareCaptureSettings | null>(null);
|
||||
|
||||
const currentRoom = watchRoom?.currentRoom || null;
|
||||
const socket = watchRoom?.socket || null;
|
||||
const isConnected = watchRoom?.isConnected || false;
|
||||
const isOwner = watchRoom?.isOwner || false;
|
||||
const members = watchRoom?.members || [];
|
||||
const currentState = currentRoom?.currentState;
|
||||
const isSharing = currentState?.type === 'screen' && currentState.status === 'sharing';
|
||||
|
||||
const closePeerConnection = useCallback((userId: string) => {
|
||||
const pc = peerConnectionsRef.current.get(userId);
|
||||
if (!pc) return;
|
||||
|
||||
pc.onicecandidate = null;
|
||||
pc.ontrack = null;
|
||||
pc.close();
|
||||
peerConnectionsRef.current.delete(userId);
|
||||
}, []);
|
||||
|
||||
const clearRemoteVideo = useCallback(() => {
|
||||
remoteStreamRef.current = null;
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanupSharingResources = useCallback(() => {
|
||||
if (stoppingRef.current) return;
|
||||
stoppingRef.current = true;
|
||||
|
||||
peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId));
|
||||
peerConnectionsRef.current.clear();
|
||||
|
||||
if (displayStreamRef.current) {
|
||||
displayStreamRef.current.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
displayStreamRef.current = null;
|
||||
}
|
||||
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = null;
|
||||
}
|
||||
|
||||
setCaptureSettings(null);
|
||||
clearRemoteVideo();
|
||||
stoppingRef.current = false;
|
||||
}, [clearRemoteVideo, closePeerConnection]);
|
||||
|
||||
const stopSharing = useCallback((notifyServer = true) => {
|
||||
cleanupSharingResources();
|
||||
|
||||
if (notifyServer && isOwner) {
|
||||
watchRoom?.stopScreenShare();
|
||||
}
|
||||
}, [cleanupSharingResources, isOwner, watchRoom]);
|
||||
|
||||
const createPeerConnection = useCallback((userId: string, ownerMode: boolean) => {
|
||||
const existing = peerConnectionsRef.current.get(userId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && socket) {
|
||||
socket.emit('screen:ice', {
|
||||
targetUserId: userId,
|
||||
candidate: event.candidate.toJSON(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (ownerMode && displayStreamRef.current) {
|
||||
displayStreamRef.current.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, displayStreamRef.current!);
|
||||
});
|
||||
} else {
|
||||
pc.ontrack = (event) => {
|
||||
const stream = event.streams[0];
|
||||
remoteStreamRef.current = stream;
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
peerConnectionsRef.current.set(userId, pc);
|
||||
return pc;
|
||||
}, [socket]);
|
||||
|
||||
const sendOfferToMember = useCallback(async (memberId: string) => {
|
||||
if (!socket || !displayStreamRef.current) return;
|
||||
|
||||
try {
|
||||
const pc = createPeerConnection(memberId, true);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
socket.emit('screen:offer', {
|
||||
targetUserId: memberId,
|
||||
offer,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to send offer:', err);
|
||||
setError('无法建立屏幕共享连接');
|
||||
}
|
||||
}, [createPeerConnection, socket]);
|
||||
|
||||
const startSharing = useCallback(async () => {
|
||||
if (!watchRoom || !currentRoom || !isOwner) return;
|
||||
|
||||
setIsStarting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const constraints = SCREEN_SHARE_CONSTRAINTS[qualityPreset];
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
frameRate: constraints.frameRate,
|
||||
width: { ideal: constraints.width },
|
||||
height: { ideal: constraints.height },
|
||||
},
|
||||
audio: true,
|
||||
});
|
||||
|
||||
displayStreamRef.current = stream;
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = stream;
|
||||
}
|
||||
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
const settings = videoTrack.getSettings();
|
||||
setCaptureSettings({
|
||||
width: typeof settings.width === 'number' ? settings.width : null,
|
||||
height: typeof settings.height === 'number' ? settings.height : null,
|
||||
frameRate: typeof settings.frameRate === 'number' ? settings.frameRate : null,
|
||||
});
|
||||
videoTrack.onended = () => {
|
||||
stopSharing(true);
|
||||
};
|
||||
}
|
||||
|
||||
const state: ScreenState = {
|
||||
type: 'screen',
|
||||
status: 'sharing',
|
||||
ownerName: currentRoom.ownerName,
|
||||
hasAudio: stream.getAudioTracks().length > 0,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
watchRoom.startScreenShare(state);
|
||||
|
||||
await Promise.all(
|
||||
members.filter((member) => !member.isOwner).map((member) => sendOfferToMember(member.id))
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[ScreenShare] Failed to start sharing:', err);
|
||||
setError(err?.message || '开启屏幕共享失败');
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [currentRoom, isOwner, members, qualityPreset, sendOfferToMember, stopSharing, watchRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom) return;
|
||||
|
||||
const handleOffer = async (data: { userId: string; offer: RTCSessionDescriptionInit }) => {
|
||||
if (isOwner) return;
|
||||
|
||||
try {
|
||||
const pc = createPeerConnection(data.userId, false);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
socket.emit('screen:answer', {
|
||||
targetUserId: data.userId,
|
||||
answer,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to handle offer:', err);
|
||||
setError('接收共享画面失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswer = async (data: { userId: string; answer: RTCSessionDescriptionInit }) => {
|
||||
if (!isOwner) return;
|
||||
|
||||
const pc = peerConnectionsRef.current.get(data.userId);
|
||||
if (!pc) return;
|
||||
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to handle answer:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIce = async (data: { userId: string; candidate: RTCIceCandidateInit }) => {
|
||||
const pc = peerConnectionsRef.current.get(data.userId);
|
||||
if (!pc) return;
|
||||
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to handle ICE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScreenStop = () => {
|
||||
if (!isOwner) {
|
||||
peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId));
|
||||
peerConnectionsRef.current.clear();
|
||||
clearRemoteVideo();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSocketDisconnect = () => {
|
||||
if (!isOwner) {
|
||||
peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId));
|
||||
peerConnectionsRef.current.clear();
|
||||
clearRemoteVideo();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewerReady = (data: { userId: string }) => {
|
||||
if (!isOwner || !displayStreamRef.current) return;
|
||||
sendOfferToMember(data.userId);
|
||||
};
|
||||
|
||||
socket.on('screen:offer', handleOffer);
|
||||
socket.on('screen:answer', handleAnswer);
|
||||
socket.on('screen:ice', handleIce);
|
||||
socket.on('screen:stop', handleScreenStop);
|
||||
socket.on('screen:viewer-ready', handleViewerReady);
|
||||
socket.on('disconnect', handleSocketDisconnect);
|
||||
|
||||
return () => {
|
||||
socket.off('screen:offer', handleOffer);
|
||||
socket.off('screen:answer', handleAnswer);
|
||||
socket.off('screen:ice', handleIce);
|
||||
socket.off('screen:stop', handleScreenStop);
|
||||
socket.off('screen:viewer-ready', handleViewerReady);
|
||||
socket.off('disconnect', handleSocketDisconnect);
|
||||
};
|
||||
}, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOwner || !isSharing || !displayStreamRef.current) return;
|
||||
|
||||
members
|
||||
.filter((member) => !member.isOwner)
|
||||
.forEach((member) => {
|
||||
if (!peerConnectionsRef.current.has(member.id)) {
|
||||
sendOfferToMember(member.id);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(peerConnectionsRef.current.keys()).forEach((userId) => {
|
||||
const stillInRoom = members.some((member) => member.id === userId && !member.isOwner);
|
||||
if (!stillInRoom) {
|
||||
closePeerConnection(userId);
|
||||
}
|
||||
});
|
||||
}, [closePeerConnection, isOwner, isSharing, members, sendOfferToMember]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupSharingResources();
|
||||
};
|
||||
}, [cleanupSharingResources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom || isOwner || !isConnected) return;
|
||||
if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return;
|
||||
|
||||
socket.emit('screen:viewer-ready');
|
||||
}, [currentRoom, currentState, isConnected, isOwner, socket]);
|
||||
|
||||
return {
|
||||
currentRoom,
|
||||
isOwner,
|
||||
isSharing,
|
||||
isStarting,
|
||||
error,
|
||||
captureSettings,
|
||||
localVideoRef,
|
||||
remoteVideoRef,
|
||||
startSharing,
|
||||
stopSharing,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
Member,
|
||||
PlayState,
|
||||
Room,
|
||||
RoomType,
|
||||
ScreenState,
|
||||
StoredRoomInfo,
|
||||
WatchRoomConfig,
|
||||
} from '@/types/watch-room';
|
||||
@@ -28,9 +30,15 @@ export function useWatchRoom(
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [isOwner, setIsOwner] = useState(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const rejoinInFlightRef = useRef(false);
|
||||
|
||||
// 重新加入房间(自动重连)
|
||||
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
|
||||
if (rejoinInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
rejoinInFlightRef.current = true;
|
||||
console.log('[WatchRoom] Auto-rejoining room:', info);
|
||||
try {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
@@ -62,9 +70,21 @@ export function useWatchRoom(
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Failed to rejoin room:', error);
|
||||
clearStoredRoomInfo();
|
||||
} finally {
|
||||
rejoinInFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleRejoin = useCallback((info: StoredRoomInfo, delay = 300) => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
rejoinRoom(info);
|
||||
}, delay);
|
||||
}, [rejoinRoom]);
|
||||
|
||||
// 连接到服务器
|
||||
const connect = useCallback(async (config: WatchRoomConfig) => {
|
||||
try {
|
||||
@@ -76,15 +96,13 @@ export function useWatchRoom(
|
||||
const storedInfo = getStoredRoomInfo();
|
||||
if (storedInfo) {
|
||||
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
rejoinRoom(storedInfo);
|
||||
}, 1000);
|
||||
scheduleRejoin(storedInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Failed to connect:', error);
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [rejoinRoom]);
|
||||
}, [scheduleRejoin]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
@@ -97,11 +115,12 @@ export function useWatchRoom(
|
||||
setCurrentRoom(null);
|
||||
setMembers([]);
|
||||
setChatMessages([]);
|
||||
setIsOwner(false);
|
||||
}, []);
|
||||
|
||||
// 创建房间
|
||||
const createRoom = useCallback(
|
||||
async (data: { name: string; description: string; password?: string; isPublic: boolean; userName: string }) => {
|
||||
async (data: { name: string; description: string; password?: string; isPublic: boolean; roomType: RoomType; userName: string }) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||
throw new Error('Not connected');
|
||||
@@ -140,7 +159,7 @@ export function useWatchRoom(
|
||||
|
||||
// 加入房间
|
||||
const joinRoom = useCallback(
|
||||
async (data: { roomId: string; password?: string; userName: string }) => {
|
||||
async (data: { roomId: string; password?: string; userName: string; ownerToken?: string }) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||
throw new Error('Not connected');
|
||||
@@ -160,7 +179,7 @@ export function useWatchRoom(
|
||||
isOwner: isRoomOwner,
|
||||
userName: data.userName,
|
||||
password: data.password,
|
||||
ownerToken: isRoomOwner ? response.room.ownerToken : undefined,
|
||||
ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
resolve({ room: response.room, members: response.members });
|
||||
@@ -295,6 +314,25 @@ export function useWatchRoom(
|
||||
[isOwner]
|
||||
);
|
||||
|
||||
// 开始屏幕共享
|
||||
const startScreenShare = useCallback(
|
||||
(state: ScreenState) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !isOwner) return;
|
||||
|
||||
sock.emit('screen:start', state);
|
||||
},
|
||||
[isOwner]
|
||||
);
|
||||
|
||||
// 停止屏幕共享
|
||||
const stopScreenShare = useCallback(() => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !isOwner) return;
|
||||
|
||||
sock.emit('screen:stop');
|
||||
}, [isOwner]);
|
||||
|
||||
// 清除房间播放状态(房主离开播放/直播页面时调用)
|
||||
const clearRoomState = useCallback(() => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
@@ -322,7 +360,11 @@ export function useWatchRoom(
|
||||
});
|
||||
|
||||
socket.on('room:member-joined', (member) => {
|
||||
setMembers((prev) => [...prev, member]);
|
||||
setMembers((prev) => {
|
||||
const next = prev.filter((existing) => existing.id !== member.id);
|
||||
next.push(member);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('room:member-left', (userId) => {
|
||||
@@ -362,6 +404,19 @@ export function useWatchRoom(
|
||||
}
|
||||
});
|
||||
|
||||
// 屏幕共享事件
|
||||
socket.on('screen:start', (state) => {
|
||||
if (currentRoom) {
|
||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:stop', () => {
|
||||
if (currentRoom) {
|
||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: null } : null));
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天事件
|
||||
socket.on('chat:message', (message) => {
|
||||
setChatMessages((prev) => [...prev, message]);
|
||||
@@ -381,6 +436,10 @@ export function useWatchRoom(
|
||||
// 连接状态
|
||||
socket.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
const storedInfo = getStoredRoomInfo();
|
||||
if (storedInfo) {
|
||||
scheduleRejoin(storedInfo);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
@@ -395,12 +454,14 @@ export function useWatchRoom(
|
||||
socket.off('play:update');
|
||||
socket.off('play:change');
|
||||
socket.off('live:change');
|
||||
socket.off('screen:start');
|
||||
socket.off('screen:stop');
|
||||
socket.off('chat:message');
|
||||
socket.off('state:cleared');
|
||||
socket.off('connect');
|
||||
socket.off('disconnect');
|
||||
};
|
||||
}, [socket, currentRoom, onRoomDeleted, onStateCleared]);
|
||||
}, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
@@ -431,6 +492,8 @@ export function useWatchRoom(
|
||||
pause,
|
||||
changeVideo,
|
||||
changeLiveChannel,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
clearRoomState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AdminConfig {
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
// 弹幕配置
|
||||
DanmakuSourceType?: 'builtin' | 'custom';
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
// TMDB配置
|
||||
@@ -42,6 +43,8 @@ export interface AdminConfig {
|
||||
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
|
||||
// 注册相关配置
|
||||
EnableRegistration?: boolean; // 开启注册
|
||||
RequireRegistrationInviteCode?: boolean; // 注册时要求邀请码
|
||||
RegistrationInviteCode?: string; // 通用注册邀请码
|
||||
RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile
|
||||
LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile
|
||||
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
||||
@@ -140,6 +143,15 @@ export interface AdminConfig {
|
||||
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
|
||||
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
|
||||
};
|
||||
NetDiskConfig?: {
|
||||
Quark?: {
|
||||
Enabled: boolean;
|
||||
Cookie: string;
|
||||
SavePath: string;
|
||||
PlayTempSavePath: string;
|
||||
OpenListTempPath: string;
|
||||
};
|
||||
};
|
||||
AIConfig?: {
|
||||
Enabled: boolean; // 是否启用AI问片功能
|
||||
Provider: 'openai' | 'claude' | 'custom'; // AI服务提供商
|
||||
|
||||
@@ -10,6 +10,26 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "217.0.0",
|
||||
date: "2026-04-10",
|
||||
added: [
|
||||
"新增注册邀请码功能",
|
||||
"增加弹幕内置源",
|
||||
"增加netlify部署支持",
|
||||
"观影室增加屏幕共享功能",
|
||||
"详情面板新增照片墙功能",
|
||||
"新增夸克网盘的转存与播放功能",
|
||||
"豆瓣数据源增加备用源功能"
|
||||
],
|
||||
changed: [
|
||||
"移除音乐功能"
|
||||
],
|
||||
fixed: [
|
||||
"修复弹幕选集分组无法鼠标滑轮滚动",
|
||||
"修复高级推荐报错无限刷新"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "216.0.0",
|
||||
date: "2026-03-30",
|
||||
|
||||
+80
-3
@@ -4,6 +4,8 @@ import { db } from '@/lib/db';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
|
||||
const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
api: string;
|
||||
@@ -223,6 +225,9 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
} catch (e) {
|
||||
cfgFile = {} as ConfigFileStruct;
|
||||
}
|
||||
const hasCustomDanmakuEnv = Boolean(
|
||||
process.env.DANMAKU_API_BASE || process.env.DANMAKU_API_TOKEN
|
||||
);
|
||||
const adminConfig: AdminConfig = {
|
||||
ConfigFile: configSource,
|
||||
ConfigSubscribtion: subConfig,
|
||||
@@ -245,7 +250,10 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
FluidSearch:
|
||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||
// 弹幕配置
|
||||
DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
|
||||
DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin',
|
||||
DanmakuApiBase:
|
||||
process.env.DANMAKU_API_BASE ||
|
||||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
|
||||
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||
// TMDB配置
|
||||
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
||||
@@ -263,6 +271,14 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
MagnetAcgripReverseProxy: '',
|
||||
// 评论功能开关
|
||||
EnableComments: false,
|
||||
EnableRegistration: false,
|
||||
RequireRegistrationInviteCode: false,
|
||||
RegistrationInviteCode: '',
|
||||
RegistrationRequireTurnstile: false,
|
||||
LoginRequireTurnstile: false,
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
DefaultUserTags: [],
|
||||
},
|
||||
UserConfig: {
|
||||
Users: [],
|
||||
@@ -431,7 +447,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
DoubanImageProxy: '',
|
||||
DisableYellowFilter: false,
|
||||
FluidSearch: true,
|
||||
DanmakuApiBase: 'http://localhost:9321',
|
||||
DanmakuSourceType: 'builtin',
|
||||
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
|
||||
DanmakuApiToken: '87654321',
|
||||
PansouApiUrl: '',
|
||||
PansouUsername: '',
|
||||
@@ -442,11 +459,25 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
MagnetDmhyReverseProxy: '',
|
||||
MagnetAcgripReverseProxy: '',
|
||||
EnableComments: false,
|
||||
EnableRegistration: false,
|
||||
RequireRegistrationInviteCode: false,
|
||||
RegistrationInviteCode: '',
|
||||
RegistrationRequireTurnstile: false,
|
||||
LoginRequireTurnstile: false,
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
DefaultUserTags: [],
|
||||
};
|
||||
}
|
||||
// 确保弹幕配置存在
|
||||
if (adminConfig.SiteConfig.DanmakuSourceType === undefined) {
|
||||
adminConfig.SiteConfig.DanmakuSourceType = 'custom';
|
||||
}
|
||||
if (!adminConfig.SiteConfig.DanmakuApiBase) {
|
||||
adminConfig.SiteConfig.DanmakuApiBase = 'http://localhost:9321';
|
||||
adminConfig.SiteConfig.DanmakuApiBase =
|
||||
adminConfig.SiteConfig.DanmakuSourceType === 'builtin'
|
||||
? BUILTIN_DANMAKU_API_BASE
|
||||
: 'http://localhost:9321';
|
||||
}
|
||||
if (!adminConfig.SiteConfig.DanmakuApiToken) {
|
||||
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
||||
@@ -455,6 +486,30 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (adminConfig.SiteConfig.EnableComments === undefined) {
|
||||
adminConfig.SiteConfig.EnableComments = false;
|
||||
}
|
||||
if (adminConfig.SiteConfig.EnableRegistration === undefined) {
|
||||
adminConfig.SiteConfig.EnableRegistration = false;
|
||||
}
|
||||
if (adminConfig.SiteConfig.RequireRegistrationInviteCode === undefined) {
|
||||
adminConfig.SiteConfig.RequireRegistrationInviteCode = false;
|
||||
}
|
||||
if (adminConfig.SiteConfig.RegistrationInviteCode === undefined) {
|
||||
adminConfig.SiteConfig.RegistrationInviteCode = '';
|
||||
}
|
||||
if (adminConfig.SiteConfig.RegistrationRequireTurnstile === undefined) {
|
||||
adminConfig.SiteConfig.RegistrationRequireTurnstile = false;
|
||||
}
|
||||
if (adminConfig.SiteConfig.LoginRequireTurnstile === undefined) {
|
||||
adminConfig.SiteConfig.LoginRequireTurnstile = false;
|
||||
}
|
||||
if (adminConfig.SiteConfig.TurnstileSiteKey === undefined) {
|
||||
adminConfig.SiteConfig.TurnstileSiteKey = '';
|
||||
}
|
||||
if (adminConfig.SiteConfig.TurnstileSecretKey === undefined) {
|
||||
adminConfig.SiteConfig.TurnstileSecretKey = '';
|
||||
}
|
||||
if (adminConfig.SiteConfig.DefaultUserTags === undefined) {
|
||||
adminConfig.SiteConfig.DefaultUserTags = [];
|
||||
}
|
||||
if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) {
|
||||
adminConfig.SiteConfig.PansouKeywordBlocklist = '';
|
||||
}
|
||||
@@ -563,6 +618,28 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig) {
|
||||
adminConfig.NetDiskConfig = {
|
||||
Quark: {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
SavePath: '/',
|
||||
PlayTempSavePath: '/',
|
||||
OpenListTempPath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig.Quark) {
|
||||
adminConfig.NetDiskConfig.Quark = {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
SavePath: '/',
|
||||
PlayTempSavePath: '/',
|
||||
OpenListTempPath: '/',
|
||||
};
|
||||
}
|
||||
|
||||
// 确保音乐配置存在
|
||||
if (!adminConfig.MusicConfig) {
|
||||
adminConfig.MusicConfig = {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AdminConfig } from '@/lib/admin.types';
|
||||
|
||||
export const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
|
||||
export const BUILTIN_DANMAKU_API_TOKEN = '87654321';
|
||||
|
||||
function trimTrailingSlash(value: string) {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function getDanmakuApiBaseUrl(siteConfig: AdminConfig['SiteConfig']) {
|
||||
if (siteConfig.DanmakuSourceType === 'builtin') {
|
||||
return BUILTIN_DANMAKU_API_BASE;
|
||||
}
|
||||
|
||||
const base = trimTrailingSlash(siteConfig.DanmakuApiBase || 'http://localhost:9321');
|
||||
const token = (siteConfig.DanmakuApiToken || BUILTIN_DANMAKU_API_TOKEN).trim();
|
||||
|
||||
return token === BUILTIN_DANMAKU_API_TOKEN ? base : `${base}/${token}`;
|
||||
}
|
||||
+244
-98
@@ -88,6 +88,36 @@ interface DoubanDetailApiResponse {
|
||||
[key: string]: any; // 允许其他字段
|
||||
}
|
||||
|
||||
type DoubanProxyType =
|
||||
| 'direct'
|
||||
| 'cors-proxy-zwei'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'cors-anywhere'
|
||||
| 'custom';
|
||||
|
||||
function normalizeDoubanProxyConfig(
|
||||
proxyType: DoubanProxyType,
|
||||
proxyUrl: string
|
||||
): {
|
||||
proxyType: DoubanProxyType;
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const normalizedProxyUrl = proxyUrl.trim();
|
||||
|
||||
if (proxyType === 'custom' && !normalizedProxyUrl) {
|
||||
return {
|
||||
proxyType: 'direct',
|
||||
proxyUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
proxyType,
|
||||
proxyUrl: normalizedProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的 fetch 请求
|
||||
*/
|
||||
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
|
||||
| 'cors-anywhere'
|
||||
| 'custom';
|
||||
proxyUrl: string;
|
||||
backupProxyType:
|
||||
| 'direct'
|
||||
| 'cors-proxy-zwei'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'cors-anywhere'
|
||||
| 'custom';
|
||||
backupProxyUrl: string;
|
||||
} {
|
||||
const doubanProxyType =
|
||||
localStorage.getItem('doubanDataSource') ||
|
||||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
|
||||
localStorage.getItem('doubanProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
||||
'';
|
||||
const doubanProxyBackupType =
|
||||
(localStorage.getItem('doubanDataSourceBackup') as DoubanProxyType | null) ||
|
||||
'direct';
|
||||
const doubanProxyBackupUrl =
|
||||
localStorage.getItem('doubanProxyUrlBackup') || '';
|
||||
const primaryConfig = normalizeDoubanProxyConfig(doubanProxyType, doubanProxy);
|
||||
const backupConfig = normalizeDoubanProxyConfig(
|
||||
doubanProxyBackupType,
|
||||
doubanProxyBackupUrl
|
||||
);
|
||||
return {
|
||||
proxyType: doubanProxyType,
|
||||
proxyUrl: doubanProxy,
|
||||
proxyType: primaryConfig.proxyType,
|
||||
proxyUrl: primaryConfig.proxyUrl,
|
||||
backupProxyType: backupConfig.proxyType,
|
||||
backupProxyUrl: backupConfig.proxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDoubanRequester(
|
||||
proxyType: DoubanProxyType,
|
||||
proxyUrl: string
|
||||
): {
|
||||
useDirectApi: boolean;
|
||||
requestProxyUrl: string;
|
||||
useTencentCDN: boolean;
|
||||
useAliCDN: boolean;
|
||||
} {
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return {
|
||||
useDirectApi: false,
|
||||
requestProxyUrl: 'https://ciao-cors.is-an.org/',
|
||||
useTencentCDN: false,
|
||||
useAliCDN: false,
|
||||
};
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return {
|
||||
useDirectApi: false,
|
||||
requestProxyUrl: '',
|
||||
useTencentCDN: true,
|
||||
useAliCDN: false,
|
||||
};
|
||||
case 'cmliussss-cdn-ali':
|
||||
return {
|
||||
useDirectApi: false,
|
||||
requestProxyUrl: '',
|
||||
useTencentCDN: false,
|
||||
useAliCDN: true,
|
||||
};
|
||||
case 'cors-anywhere':
|
||||
return {
|
||||
useDirectApi: false,
|
||||
requestProxyUrl: 'https://cors-anywhere.com/',
|
||||
useTencentCDN: false,
|
||||
useAliCDN: false,
|
||||
};
|
||||
case 'custom':
|
||||
return {
|
||||
useDirectApi: false,
|
||||
requestProxyUrl: proxyUrl,
|
||||
useTencentCDN: false,
|
||||
useAliCDN: false,
|
||||
};
|
||||
case 'direct':
|
||||
default:
|
||||
return {
|
||||
useDirectApi: true,
|
||||
requestProxyUrl: '',
|
||||
useTencentCDN: false,
|
||||
useAliCDN: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function requestDoubanWithFallback<T>(
|
||||
primary: { proxyType: DoubanProxyType; proxyUrl: string },
|
||||
backup: { proxyType: DoubanProxyType; proxyUrl: string },
|
||||
runner: (requester: ReturnType<typeof buildDoubanRequester>) => Promise<T>
|
||||
): Promise<T> {
|
||||
const primaryRequester = buildDoubanRequester(primary.proxyType, primary.proxyUrl);
|
||||
const backupRequester = buildDoubanRequester(backup.proxyType, backup.proxyUrl);
|
||||
|
||||
try {
|
||||
return await runner(primaryRequester);
|
||||
} catch (primaryError) {
|
||||
const sameStrategy =
|
||||
primary.proxyType === backup.proxyType && primary.proxyUrl === backup.proxyUrl;
|
||||
if (sameStrategy) {
|
||||
throw primaryError;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[Douban] 主渠道失败,切换备用渠道: ${primary.proxyType} -> ${backup.proxyType}`,
|
||||
primaryError
|
||||
);
|
||||
return runner(backupRequester);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchDoubanGlobalError(message: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器端豆瓣分类数据获取函数
|
||||
*/
|
||||
@@ -211,14 +352,6 @@ export async function fetchDoubanCategories(
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣分类数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -230,25 +363,34 @@ export async function getDoubanCategories(
|
||||
params: DoubanCategoriesParams
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanCategories(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanCategories(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanCategories(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||
);
|
||||
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||
getDoubanProxyConfig();
|
||||
try {
|
||||
return await requestDoubanWithFallback(
|
||||
{ proxyType, proxyUrl },
|
||||
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||
if (useDirectApi) {
|
||||
const response = await fetch(
|
||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return fetchDoubanCategories(
|
||||
params,
|
||||
requestProxyUrl,
|
||||
useTencentCDN,
|
||||
useAliCDN
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
dispatchDoubanGlobalError('获取豆瓣分类数据失败');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,25 +405,34 @@ export async function getDoubanList(
|
||||
params: DoubanListParams
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanList(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanList(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanList(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanList(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||
);
|
||||
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||
getDoubanProxyConfig();
|
||||
try {
|
||||
return await requestDoubanWithFallback(
|
||||
{ proxyType, proxyUrl },
|
||||
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||
if (useDirectApi) {
|
||||
const response = await fetch(
|
||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return fetchDoubanList(
|
||||
params,
|
||||
requestProxyUrl,
|
||||
useTencentCDN,
|
||||
useAliCDN
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
dispatchDoubanGlobalError('获取豆瓣列表数据失败');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣列表数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
|
||||
platform,
|
||||
sort,
|
||||
} = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanRecommends(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanRecommends(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanRecommends(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||
);
|
||||
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||
getDoubanProxyConfig();
|
||||
try {
|
||||
return await requestDoubanWithFallback(
|
||||
{ proxyType, proxyUrl },
|
||||
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||
if (useDirectApi) {
|
||||
const response = await fetch(
|
||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return fetchDoubanRecommends(
|
||||
params,
|
||||
requestProxyUrl,
|
||||
useTencentCDN,
|
||||
useAliCDN
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
dispatchDoubanGlobalError('获取豆瓣推荐数据失败');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,14 +696,6 @@ export async function fetchDoubanDetail(
|
||||
const doubanData: DoubanDetailApiResponse = await response.json();
|
||||
return doubanData;
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣详情数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
|
||||
export async function getDoubanDetail(
|
||||
id: string
|
||||
): Promise<DoubanDetailApiResponse> {
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanDetail(id, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanDetail(id, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanDetail(id, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanDetail(id, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(`/api/douban/detail?id=${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||
getDoubanProxyConfig();
|
||||
try {
|
||||
return await requestDoubanWithFallback(
|
||||
{ proxyType, proxyUrl },
|
||||
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||
if (useDirectApi) {
|
||||
const response = await fetch(`/api/douban/detail?id=${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
|
||||
}
|
||||
return response.json();
|
||||
);
|
||||
} catch (error) {
|
||||
dispatchDoubanGlobalError('获取豆瓣详情数据失败');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
const QUARK_SHARE_API_BASE = 'https://drive-h.quark.cn/1/clouddrive';
|
||||
const QUARK_DRIVE_API_BASE = 'https://drive-pc.quark.cn/1/clouddrive';
|
||||
const QUARK_QUERY = 'pr=ucpro&fr=pc';
|
||||
|
||||
export interface QuarkShareLinkInfo {
|
||||
pwdId: string;
|
||||
passcode: string;
|
||||
}
|
||||
|
||||
export interface QuarkShareItem {
|
||||
fid: string;
|
||||
fileName: string;
|
||||
dir: boolean;
|
||||
shareFidToken?: string;
|
||||
pdirFid?: string;
|
||||
}
|
||||
|
||||
export interface QuarkTransferTaskResult {
|
||||
taskId?: string;
|
||||
fileCount: number;
|
||||
targetPath: string;
|
||||
folderName?: string;
|
||||
skipped?: boolean;
|
||||
reused?: boolean;
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = [
|
||||
'.mp4',
|
||||
'.mkv',
|
||||
'.avi',
|
||||
'.m3u8',
|
||||
'.flv',
|
||||
'.ts',
|
||||
'.mov',
|
||||
'.wmv',
|
||||
'.webm',
|
||||
'.rmvb',
|
||||
'.rm',
|
||||
'.mpg',
|
||||
'.mpeg',
|
||||
'.3gp',
|
||||
'.f4v',
|
||||
'.m4v',
|
||||
'.vob',
|
||||
];
|
||||
|
||||
function buildApiUrl(base: string, path: string, query = '') {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${base}${normalizedPath}?${QUARK_QUERY}${query ? `&${query}` : ''}`;
|
||||
}
|
||||
|
||||
function getHeaders(cookie: string): HeadersInit {
|
||||
return {
|
||||
'content-type': 'application/json',
|
||||
cookie,
|
||||
origin: 'https://pan.quark.cn',
|
||||
referer: 'https://pan.quark.cn/',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQuarkCookie(cookie: string): string {
|
||||
return cookie
|
||||
.replace(/;/g, ';')
|
||||
.replace(/:/g, ':')
|
||||
.replace(/,/g, ',')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function assertQuarkCookieHeaderSafe(cookie: string): string {
|
||||
const normalized = normalizeQuarkCookie(cookie);
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
if (normalized.charCodeAt(i) > 255) {
|
||||
throw new Error('夸克 Cookie 含有非法字符,请确认没有中文标点、中文空格或说明文字');
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === '/') return '/';
|
||||
return `/${trimmed.replace(/^\/+|\/+$/g, '')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts: string[]) {
|
||||
const joined = parts
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
.replace(/\/+/g, '/');
|
||||
return normalizePath(joined);
|
||||
}
|
||||
|
||||
function sanitizeFolderName(name: string) {
|
||||
return (name || 'quark-temp')
|
||||
.replace(/[<>:"/\\|?*]/g, ' ')
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function parseJson(response: Response) {
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`夸克接口返回异常:${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOk(data: any, fallbackMessage: string) {
|
||||
if (data?.code === 0 || data?.code === 200 || data?.status === 200) {
|
||||
return;
|
||||
}
|
||||
throw new Error(data?.message || data?.msg || fallbackMessage);
|
||||
}
|
||||
|
||||
export function parseQuarkShareUrl(url: string, passcode = ''): QuarkShareLinkInfo {
|
||||
const parsed = new URL(url);
|
||||
const pwdId =
|
||||
parsed.pathname.match(/\/s\/([A-Za-z0-9_-]+)/)?.[1] ||
|
||||
parsed.searchParams.get('pwd_id') ||
|
||||
'';
|
||||
|
||||
if (!pwdId) {
|
||||
throw new Error('无法解析夸克分享链接');
|
||||
}
|
||||
|
||||
return {
|
||||
pwdId,
|
||||
passcode:
|
||||
passcode ||
|
||||
parsed.searchParams.get('pwd') ||
|
||||
parsed.searchParams.get('passcode') ||
|
||||
'',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchShareToken(cookie: string, share: QuarkShareLinkInfo) {
|
||||
const response = await fetch(
|
||||
buildApiUrl(QUARK_SHARE_API_BASE, '/share/sharepage/token'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getHeaders(cookie),
|
||||
body: JSON.stringify({
|
||||
pwd_id: share.pwdId,
|
||||
passcode: share.passcode,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await parseJson(response);
|
||||
ensureOk(data, '获取夸克分享 token 失败');
|
||||
|
||||
const stoken =
|
||||
data?.data?.stoken ||
|
||||
data?.data?.share_token ||
|
||||
data?.data?.token;
|
||||
|
||||
if (!stoken) {
|
||||
throw new Error('夸克分享 token 缺失');
|
||||
}
|
||||
|
||||
return {
|
||||
stoken,
|
||||
shareTitle: data?.data?.title || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchShareFolderItems(
|
||||
cookie: string,
|
||||
pwdId: string,
|
||||
stoken: string,
|
||||
pdirFid = '0'
|
||||
): Promise<QuarkShareItem[]> {
|
||||
const query = new URLSearchParams({
|
||||
pwd_id: pwdId,
|
||||
stoken,
|
||||
pdir_fid: pdirFid,
|
||||
_page: '1',
|
||||
_size: '200',
|
||||
_fetch_banner: '0',
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl(QUARK_SHARE_API_BASE, '/share/sharepage/detail', query.toString()),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: getHeaders(cookie),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await parseJson(response);
|
||||
ensureOk(data, '获取夸克分享详情失败');
|
||||
|
||||
const list = data?.data?.list || [];
|
||||
return list.map((item: any) => ({
|
||||
fid: String(item.fid || item.file_id || ''),
|
||||
fileName: String(item.file_name || item.name || ''),
|
||||
dir: Boolean(item.dir || item.is_dir || item.file_type === 0),
|
||||
shareFidToken:
|
||||
item.share_fid_token || item.fid_token || item.share_token || undefined,
|
||||
pdirFid: String(item.pdir_fid || pdirFid || '0'),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchDriveFolderItems(
|
||||
cookie: string,
|
||||
pdirFid = '0',
|
||||
page = 1,
|
||||
size = 200
|
||||
): Promise<any[]> {
|
||||
const query = new URLSearchParams({
|
||||
pdir_fid: pdirFid,
|
||||
_page: String(page),
|
||||
_size: String(size),
|
||||
_sort: 'file_type:asc,file_name:asc',
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl(QUARK_DRIVE_API_BASE, '/file/sort', query.toString()),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: getHeaders(cookie),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await parseJson(response);
|
||||
ensureOk(data, '获取夸克目录列表失败');
|
||||
return data?.data?.list || [];
|
||||
}
|
||||
|
||||
async function fetchAllDriveFolderItems(
|
||||
cookie: string,
|
||||
pdirFid = '0'
|
||||
): Promise<any[]> {
|
||||
const allItems: any[] = [];
|
||||
const pageSize = 200;
|
||||
|
||||
for (let page = 1; page < 100; page += 1) {
|
||||
const items = await fetchDriveFolderItems(cookie, pdirFid, page, pageSize);
|
||||
allItems.push(...items);
|
||||
|
||||
if (items.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
function getDriveItemName(item: any): string {
|
||||
return String(item?.file_name || item?.name || '');
|
||||
}
|
||||
|
||||
function buildInstantPlayFolderName(pwdId: string, title?: string) {
|
||||
const baseName = sanitizeFolderName(title || 'quark-temp') || 'quark-temp';
|
||||
return `${baseName}_${pwdId}`.slice(0, 120);
|
||||
}
|
||||
|
||||
async function findDirectoryByName(
|
||||
cookie: string,
|
||||
parentFid: string,
|
||||
folderName: string
|
||||
): Promise<any | null> {
|
||||
const items = await fetchAllDriveFolderItems(cookie, parentFid);
|
||||
return items.find(
|
||||
(item: any) => Boolean(item.dir || item.is_dir) && getDriveItemName(item) === folderName
|
||||
) || null;
|
||||
}
|
||||
|
||||
export async function validateQuarkCookieReadable(cookie: string): Promise<void> {
|
||||
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
|
||||
await fetchDriveFolderItems(safeCookie, '0');
|
||||
}
|
||||
|
||||
async function createDriveFolder(
|
||||
cookie: string,
|
||||
parentFid: string,
|
||||
folderName: string
|
||||
) {
|
||||
const response = await fetch(buildApiUrl(QUARK_DRIVE_API_BASE, '/file'), {
|
||||
method: 'POST',
|
||||
headers: getHeaders(cookie),
|
||||
body: JSON.stringify({
|
||||
pdir_fid: parentFid,
|
||||
file_name: folderName,
|
||||
dir_path: '',
|
||||
dir_init_lock: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await parseJson(response);
|
||||
ensureOk(data, `创建夸克目录失败:${folderName}`);
|
||||
|
||||
const fid =
|
||||
data?.data?.fid ||
|
||||
data?.data?.file_id ||
|
||||
data?.metadata?.fid;
|
||||
|
||||
if (!fid) {
|
||||
throw new Error(`夸克目录创建成功但未返回 fid:${folderName}`);
|
||||
}
|
||||
|
||||
return String(fid);
|
||||
}
|
||||
|
||||
export async function ensureQuarkDrivePath(
|
||||
cookie: string,
|
||||
inputPath: string
|
||||
): Promise<{ fid: string; path: string }> {
|
||||
const normalized = normalizePath(inputPath);
|
||||
if (normalized === '/') {
|
||||
return { fid: '0', path: normalized };
|
||||
}
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
let currentFid = '0';
|
||||
let currentPath = '';
|
||||
|
||||
for (const segment of segments) {
|
||||
const items = await fetchDriveFolderItems(cookie, currentFid);
|
||||
const existed = items.find(
|
||||
(item: any) =>
|
||||
Boolean(item.dir || item.is_dir) &&
|
||||
String(item.file_name || item.name || '') === segment
|
||||
);
|
||||
|
||||
currentPath = joinPath(currentPath, segment);
|
||||
|
||||
if (existed) {
|
||||
currentFid = String(existed.fid || existed.file_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentFid = await createDriveFolder(cookie, currentFid, segment);
|
||||
}
|
||||
|
||||
return {
|
||||
fid: currentFid,
|
||||
path: currentPath || '/',
|
||||
};
|
||||
}
|
||||
|
||||
async function collectShareItemsRecursive(
|
||||
cookie: string,
|
||||
pwdId: string,
|
||||
stoken: string,
|
||||
pdirFid = '0'
|
||||
): Promise<QuarkShareItem[]> {
|
||||
const items = await fetchShareFolderItems(cookie, pwdId, stoken, pdirFid);
|
||||
const result: QuarkShareItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.dir) {
|
||||
const children = await collectShareItemsRecursive(
|
||||
cookie,
|
||||
pwdId,
|
||||
stoken,
|
||||
item.fid
|
||||
);
|
||||
result.push(...children);
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isVideoFile(fileName: string) {
|
||||
const lower = fileName.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
async function submitSaveTask(
|
||||
cookie: string,
|
||||
share: QuarkShareLinkInfo,
|
||||
stoken: string,
|
||||
toPdirFid: string,
|
||||
items: QuarkShareItem[]
|
||||
) {
|
||||
if (items.length === 0) {
|
||||
throw new Error('没有可保存的文件');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl(QUARK_SHARE_API_BASE, '/share/sharepage/save'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getHeaders(cookie),
|
||||
body: JSON.stringify({
|
||||
pwd_id: share.pwdId,
|
||||
stoken,
|
||||
pdir_fid: '0',
|
||||
to_pdir_fid: toPdirFid,
|
||||
scene: 'link',
|
||||
filelist: items.map((item) => item.fid),
|
||||
fid_list: items.map((item) => item.fid),
|
||||
fid_token_list: items.map((item) => item.shareFidToken || ''),
|
||||
share_fid_token_list: items.map((item) => item.shareFidToken || ''),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await parseJson(response);
|
||||
ensureOk(data, '提交夸克转存任务失败');
|
||||
return data?.data?.task_id ? String(data.data.task_id) : undefined;
|
||||
}
|
||||
|
||||
async function pollTask(cookie: string, taskId: string) {
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
const query = new URLSearchParams({
|
||||
task_id: taskId,
|
||||
retry_index: String(i),
|
||||
});
|
||||
|
||||
const response = await fetch(buildApiUrl(QUARK_SHARE_API_BASE, '/task', query.toString()), {
|
||||
method: 'GET',
|
||||
headers: getHeaders(cookie),
|
||||
});
|
||||
|
||||
const data = await parseJson(response);
|
||||
ensureOk(data, '查询夸克任务状态失败');
|
||||
|
||||
const task = data?.data || {};
|
||||
if (
|
||||
task?.status === 2 ||
|
||||
task?.status === 'finished' ||
|
||||
task?.status === 'success' ||
|
||||
task?.finished_at
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
task?.status === -1 ||
|
||||
task?.status === 'failed' ||
|
||||
task?.err_code
|
||||
) {
|
||||
throw new Error(task?.message || task?.err_msg || '夸克任务执行失败');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
|
||||
throw new Error('夸克任务处理超时');
|
||||
}
|
||||
|
||||
export async function transferQuarkShare(
|
||||
cookie: string,
|
||||
input: {
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
savePath: string;
|
||||
}
|
||||
): Promise<QuarkTransferTaskResult> {
|
||||
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
|
||||
const share = parseQuarkShareUrl(input.shareUrl, input.passcode);
|
||||
const { stoken } = await fetchShareToken(safeCookie, share);
|
||||
const topLevelItems = await fetchShareFolderItems(safeCookie, share.pwdId, stoken, '0');
|
||||
const target = await ensureQuarkDrivePath(safeCookie, input.savePath);
|
||||
const existedItems = await fetchAllDriveFolderItems(safeCookie, target.fid);
|
||||
const existedNames = new Set(existedItems.map((item: any) => getDriveItemName(item)));
|
||||
const pendingItems = topLevelItems.filter((item) => !existedNames.has(item.fileName));
|
||||
|
||||
if (pendingItems.length === 0) {
|
||||
return {
|
||||
fileCount: 0,
|
||||
targetPath: target.path,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const taskId = await submitSaveTask(safeCookie, share, stoken, target.fid, pendingItems);
|
||||
|
||||
if (taskId) {
|
||||
await pollTask(safeCookie, taskId);
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
fileCount: pendingItems.length,
|
||||
targetPath: target.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createQuarkInstantPlayFolder(
|
||||
cookie: string,
|
||||
input: {
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
playTempSavePath: string;
|
||||
title?: string;
|
||||
}
|
||||
): Promise<QuarkTransferTaskResult> {
|
||||
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
|
||||
const share = parseQuarkShareUrl(input.shareUrl, input.passcode);
|
||||
const { stoken, shareTitle } = await fetchShareToken(safeCookie, share);
|
||||
const allItems = await collectShareItemsRecursive(safeCookie, share.pwdId, stoken, '0');
|
||||
const videoItems = allItems.filter((item) => !item.dir && isVideoFile(item.fileName));
|
||||
|
||||
if (videoItems.length === 0) {
|
||||
throw new Error('分享中没有可播放的视频文件');
|
||||
}
|
||||
|
||||
const tempRoot = await ensureQuarkDrivePath(safeCookie, input.playTempSavePath);
|
||||
const folderName = buildInstantPlayFolderName(share.pwdId, input.title || shareTitle);
|
||||
const existedFolder = await findDirectoryByName(safeCookie, tempRoot.fid, folderName);
|
||||
|
||||
if (existedFolder) {
|
||||
return {
|
||||
fileCount: videoItems.length,
|
||||
targetPath: joinPath(tempRoot.path, folderName),
|
||||
folderName,
|
||||
reused: true,
|
||||
};
|
||||
}
|
||||
|
||||
const folderFid = await createDriveFolder(safeCookie, tempRoot.fid, folderName);
|
||||
const taskId = await submitSaveTask(safeCookie, share, stoken, folderFid, videoItems);
|
||||
|
||||
if (taskId) {
|
||||
await pollTask(safeCookie, taskId);
|
||||
}
|
||||
|
||||
const targetPath = joinPath(tempRoot.path, folderName);
|
||||
|
||||
return {
|
||||
taskId,
|
||||
fileCount: videoItems.length,
|
||||
targetPath,
|
||||
folderName,
|
||||
};
|
||||
}
|
||||
@@ -703,3 +703,47 @@ export async function getTMDBCredits(
|
||||
return { code: 500, credits: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TMDB 图片信息
|
||||
* @param apiKey - TMDB API Key
|
||||
* @param mediaId - 媒体ID
|
||||
* @param mediaType - 媒体类型 (movie 或 tv)
|
||||
* @param proxy - 代理服务器地址
|
||||
* @param reverseProxyBaseUrl - 反代 Base URL
|
||||
* @returns 图片信息
|
||||
*/
|
||||
export async function getTMDBImages(
|
||||
apiKey: string,
|
||||
mediaId: number,
|
||||
mediaType: 'movie' | 'tv',
|
||||
proxy?: string,
|
||||
reverseProxyBaseUrl?: string
|
||||
): Promise<{ code: number; images: any }> {
|
||||
try {
|
||||
const actualKey = getNextApiKey(apiKey);
|
||||
if (!actualKey) {
|
||||
return { code: 400, images: null };
|
||||
}
|
||||
|
||||
const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL;
|
||||
const url = `${baseUrl}/3/${mediaType}/${mediaId}/images?api_key=${actualKey}`;
|
||||
|
||||
const response = await universalFetch(url, proxy);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB Images API 请求失败:', response.status, response.statusText);
|
||||
return { code: response.status, images: null };
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
images: data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取 TMDB 图片信息失败:', error);
|
||||
return { code: 500, images: null };
|
||||
}
|
||||
}
|
||||
|
||||
+121
-28
@@ -3,8 +3,7 @@ import bs58 from 'bs58';
|
||||
import he from 'he';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
function getDoubanImageProxyConfig(): {
|
||||
proxyType:
|
||||
export type DoubanImageProxyType =
|
||||
| 'direct'
|
||||
| 'server'
|
||||
| 'img3'
|
||||
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'baidu'
|
||||
| 'custom';
|
||||
|
||||
function normalizeDoubanImageProxyConfig(
|
||||
proxyType: DoubanImageProxyType,
|
||||
proxyUrl: string
|
||||
): {
|
||||
proxyType: DoubanImageProxyType;
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const normalizedProxyUrl = proxyUrl.trim();
|
||||
|
||||
if (proxyType === 'custom' && !normalizedProxyUrl) {
|
||||
return {
|
||||
proxyType: 'server',
|
||||
proxyUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
proxyType,
|
||||
proxyUrl: normalizedProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDoubanImageUrl(
|
||||
originalUrl: string,
|
||||
proxyType: DoubanImageProxyType,
|
||||
proxyUrl: string
|
||||
): string {
|
||||
switch (proxyType) {
|
||||
case 'server':
|
||||
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
||||
case 'img3':
|
||||
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.net'
|
||||
);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.com'
|
||||
);
|
||||
case 'baidu':
|
||||
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
|
||||
case 'custom':
|
||||
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
|
||||
case 'direct':
|
||||
default:
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function getDoubanImageProxyConfig(): {
|
||||
proxyType: DoubanImageProxyType;
|
||||
proxyUrl: string;
|
||||
backupProxyType: DoubanImageProxyType;
|
||||
backupProxyUrl: string;
|
||||
} {
|
||||
// 确保在浏览器环境中执行
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
proxyType: 'cmliussss-cdn-tencent',
|
||||
proxyUrl: '',
|
||||
backupProxyType: 'server',
|
||||
backupProxyUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
|
||||
localStorage.getItem('doubanImageProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
||||
'';
|
||||
const doubanImageProxyBackupType =
|
||||
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
|
||||
'server';
|
||||
const doubanImageProxyBackupUrl =
|
||||
localStorage.getItem('doubanImageProxyUrlBackup') || '';
|
||||
const primaryConfig = normalizeDoubanImageProxyConfig(
|
||||
doubanImageProxyType,
|
||||
doubanImageProxy
|
||||
);
|
||||
const backupConfig = normalizeDoubanImageProxyConfig(
|
||||
doubanImageProxyBackupType,
|
||||
doubanImageProxyBackupUrl
|
||||
);
|
||||
return {
|
||||
proxyType: doubanImageProxyType,
|
||||
proxyUrl: doubanImageProxy,
|
||||
proxyType: primaryConfig.proxyType,
|
||||
proxyUrl: primaryConfig.proxyUrl,
|
||||
backupProxyType: backupConfig.proxyType,
|
||||
backupProxyUrl: backupConfig.proxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
|
||||
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||
getDoubanImageProxyConfig();
|
||||
const primaryUrl = buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||
const backupUrl = buildDoubanImageUrl(
|
||||
originalUrl,
|
||||
backupProxyType,
|
||||
backupProxyUrl
|
||||
);
|
||||
|
||||
if (backupUrl === primaryUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return backupUrl;
|
||||
}
|
||||
|
||||
export function tryApplyDoubanImageFallback(
|
||||
target: HTMLImageElement,
|
||||
originalUrl: string
|
||||
): boolean {
|
||||
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.dataset.doubanBackupTried === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
|
||||
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target.dataset.doubanBackupTried = 'true';
|
||||
target.src = fallbackUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片 URL,根据用户设置使用相应的代理
|
||||
*/
|
||||
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
|
||||
}
|
||||
|
||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'server':
|
||||
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
||||
case 'img3':
|
||||
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.net'
|
||||
);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.com'
|
||||
);
|
||||
case 'baidu':
|
||||
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
|
||||
case 'custom':
|
||||
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||
case 'direct':
|
||||
default:
|
||||
return originalUrl;
|
||||
}
|
||||
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
|
||||
// 在 Node.js 环境中使用 Buffer
|
||||
return Buffer.from(bytes).toString('utf-8');
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const CURRENT_VERSION = '216.0.0';
|
||||
const CURRENT_VERSION = '217.0.0';
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
|
||||
@@ -16,6 +16,8 @@ export class WatchRoomServer {
|
||||
private rooms: Map<string, Room> = new Map();
|
||||
private members: Map<string, Map<string, Member>> = new Map(); // roomId -> userId -> Member
|
||||
private socketToRoom: Map<string, RoomMemberInfo> = new Map(); // socketId -> RoomMemberInfo
|
||||
private screenHelpers: Map<string, string> = new Map(); // roomId -> helperSocketId
|
||||
private helperToRoom: Map<string, string> = new Map(); // helperSocketId -> roomId
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
|
||||
@@ -40,6 +42,7 @@ export class WatchRoomServer {
|
||||
description: data.description,
|
||||
password: data.password,
|
||||
isPublic: data.isPublic,
|
||||
roomType: data.roomType || 'sync',
|
||||
ownerId: userId,
|
||||
ownerName: data.userName,
|
||||
ownerToken: ownerToken, // 保存房主令牌
|
||||
@@ -89,15 +92,33 @@ export class WatchRoomServer {
|
||||
}
|
||||
|
||||
const userId = socket.id;
|
||||
let isOwner = false;
|
||||
|
||||
if (data.ownerToken && data.ownerToken === room.ownerToken) {
|
||||
isOwner = true;
|
||||
room.ownerId = userId;
|
||||
room.lastOwnerHeartbeat = Date.now();
|
||||
this.rooms.set(data.roomId, room);
|
||||
console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`);
|
||||
}
|
||||
|
||||
const member: Member = {
|
||||
id: userId,
|
||||
name: data.userName,
|
||||
isOwner: false,
|
||||
isOwner,
|
||||
lastHeartbeat: Date.now(),
|
||||
};
|
||||
|
||||
const roomMembers = this.members.get(data.roomId);
|
||||
if (roomMembers) {
|
||||
if (isOwner) {
|
||||
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
|
||||
if (existingMember.isOwner && memberId !== userId) {
|
||||
roomMembers.delete(memberId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
roomMembers.set(userId, member);
|
||||
room.memberCount = roomMembers.size;
|
||||
this.rooms.set(data.roomId, room);
|
||||
@@ -107,7 +128,7 @@ export class WatchRoomServer {
|
||||
roomId: data.roomId,
|
||||
userId,
|
||||
userName: data.userName,
|
||||
isOwner: false,
|
||||
isOwner,
|
||||
});
|
||||
|
||||
socket.join(data.roomId);
|
||||
@@ -115,7 +136,7 @@ export class WatchRoomServer {
|
||||
// 通知房间内其他成员
|
||||
socket.to(data.roomId).emit('room:member-joined', member);
|
||||
|
||||
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`);
|
||||
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`);
|
||||
|
||||
const members = Array.from(roomMembers?.values() || []);
|
||||
callback({ success: true, room, members });
|
||||
@@ -199,6 +220,111 @@ export class WatchRoomServer {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:helper-register', (data, callback) => {
|
||||
try {
|
||||
const room = this.rooms.get(data.roomId);
|
||||
if (!room) {
|
||||
callback({ success: false, error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.ownerToken !== data.ownerToken) {
|
||||
callback({ success: false, error: '房主身份验证失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHelperSocketId = this.screenHelpers.get(data.roomId);
|
||||
if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
|
||||
this.helperToRoom.delete(oldHelperSocketId);
|
||||
}
|
||||
|
||||
this.screenHelpers.set(data.roomId, socket.id);
|
||||
this.helperToRoom.set(socket.id, data.roomId);
|
||||
callback({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Error registering screen helper:', error);
|
||||
callback({ success: false, error: '注册共享控制窗口失败' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:start', (state) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = state;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:start', state);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:stop', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = null;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:stop');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:viewer-ready', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
if (!roomInfo) return;
|
||||
|
||||
const room = this.rooms.get(roomInfo.roomId);
|
||||
if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
|
||||
|
||||
const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
|
||||
this.io.to(targetSocketId).emit('screen:viewer-ready', {
|
||||
userId: socket.id,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:offer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:offer', {
|
||||
userId: socket.id,
|
||||
offer: data.offer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:answer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:answer', {
|
||||
userId: socket.id,
|
||||
answer: data.answer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:ice', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:ice', {
|
||||
userId: socket.id,
|
||||
candidate: data.candidate,
|
||||
});
|
||||
});
|
||||
|
||||
// 聊天消息
|
||||
socket.on('chat:message', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
@@ -303,6 +429,19 @@ export class WatchRoomServer {
|
||||
// 断开连接
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (helperRoomId) {
|
||||
this.helperToRoom.delete(socket.id);
|
||||
if (this.screenHelpers.get(helperRoomId) === socket.id) {
|
||||
this.screenHelpers.delete(helperRoomId);
|
||||
const room = this.rooms.get(helperRoomId);
|
||||
if (room && room.currentState?.type === 'screen') {
|
||||
room.currentState = null;
|
||||
this.rooms.set(helperRoomId, room);
|
||||
this.io.to(helperRoomId).emit('screen:stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleLeaveRoom(socket);
|
||||
});
|
||||
});
|
||||
@@ -348,6 +487,11 @@ export class WatchRoomServer {
|
||||
this.io.to(roomId).emit('room:deleted');
|
||||
this.rooms.delete(roomId);
|
||||
this.members.delete(roomId);
|
||||
const helperSocketId = this.screenHelpers.get(roomId);
|
||||
if (helperSocketId) {
|
||||
this.helperToRoom.delete(helperSocketId);
|
||||
this.screenHelpers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// 定时清理房间(房主断开5分钟后删除)
|
||||
|
||||
@@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>
|
||||
class WatchRoomSocketManager {
|
||||
private socket: WatchRoomSocket | null = null;
|
||||
private config: WatchRoomConfig | null = null;
|
||||
private connectionPromise: Promise<WatchRoomSocket> | null = null;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
|
||||
private lastHeartbeatResponse: number = Date.now();
|
||||
@@ -25,6 +26,37 @@ class WatchRoomSocketManager {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
if (this.connectionPromise) {
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.connectionPromise = null;
|
||||
reject(new Error('Socket connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
this.socket!.once('connect', () => {
|
||||
clearTimeout(timeout);
|
||||
this.connectionPromise = null;
|
||||
resolve(this.socket!);
|
||||
});
|
||||
|
||||
this.socket!.once('connect_error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
this.connectionPromise = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (!this.socket!.connected) {
|
||||
this.socket!.connect();
|
||||
}
|
||||
});
|
||||
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
this.config = config;
|
||||
|
||||
const socketOptions = {
|
||||
@@ -72,8 +104,9 @@ class WatchRoomSocketManager {
|
||||
// 设置浏览器可见性监听
|
||||
this.setupVisibilityListener();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
if (!this.socket) {
|
||||
this.connectionPromise = null;
|
||||
reject(new Error('Socket not initialized'));
|
||||
return;
|
||||
}
|
||||
@@ -82,6 +115,7 @@ class WatchRoomSocketManager {
|
||||
this.socket.once('connect', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[WatchRoom] Connected to server');
|
||||
this.connectionPromise = null;
|
||||
if (this.socket) {
|
||||
resolve(this.socket);
|
||||
}
|
||||
@@ -90,9 +124,12 @@ class WatchRoomSocketManager {
|
||||
this.socket.once('connect_error', (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WatchRoom] Connection error:', error);
|
||||
this.connectionPromise = null;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -122,6 +159,8 @@ class WatchRoomSocketManager {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
this.connectionPromise = null;
|
||||
}
|
||||
|
||||
getSocket(): WatchRoomSocket | null {
|
||||
|
||||
+29
-1
@@ -6,15 +6,18 @@ export interface Room {
|
||||
description: string;
|
||||
password?: string;
|
||||
isPublic: boolean;
|
||||
roomType: RoomType;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
ownerToken: string; // 房主令牌,用于重连时验证身份
|
||||
memberCount: number;
|
||||
currentState: PlayState | LiveState | null;
|
||||
currentState: PlayState | LiveState | ScreenState | null;
|
||||
createdAt: number;
|
||||
lastOwnerHeartbeat: number;
|
||||
}
|
||||
|
||||
export type RoomType = 'sync' | 'screen';
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +45,14 @@ export interface LiveState {
|
||||
channelUrl: string;
|
||||
}
|
||||
|
||||
export interface ScreenState {
|
||||
type: 'screen';
|
||||
status: 'idle' | 'sharing';
|
||||
ownerName: string;
|
||||
hasAudio?: boolean;
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -73,6 +84,12 @@ export interface ServerToClientEvents {
|
||||
'play:pause': () => void;
|
||||
'play:change': (state: PlayState) => void;
|
||||
'live:change': (state: LiveState) => void;
|
||||
'screen:start': (state: ScreenState) => void;
|
||||
'screen:stop': () => void;
|
||||
'screen:viewer-ready': (data: { userId: string }) => void;
|
||||
'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
|
||||
'chat:message': (message: ChatMessage) => void;
|
||||
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||
@@ -90,6 +107,7 @@ export interface ClientToServerEvents {
|
||||
description: string;
|
||||
password?: string;
|
||||
isPublic: boolean;
|
||||
roomType: RoomType;
|
||||
userName: string;
|
||||
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
|
||||
|
||||
@@ -111,6 +129,16 @@ export interface ClientToServerEvents {
|
||||
'play:change': (state: PlayState) => void;
|
||||
|
||||
'live:change': (state: LiveState) => void;
|
||||
'screen:helper-register': (data: {
|
||||
roomId: string;
|
||||
ownerToken: string;
|
||||
}, callback: (response: { success: boolean; error?: string }) => void) => void;
|
||||
'screen:start': (state: ScreenState) => void;
|
||||
'screen:stop': () => void;
|
||||
'screen:viewer-ready': () => void;
|
||||
'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
|
||||
|
||||
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user