Merge branch 'dev'
This commit is contained in:
@@ -63,3 +63,6 @@ public/workbox-*.js.map
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.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
|
## [216.0.0] - 2026-03-30
|
||||||
### Added
|
### 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://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||||
|
|
||||||
|
[](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus)
|
||||||
|
|
||||||
**一键部署到 Zeabur**
|
**一键部署到 Zeabur**
|
||||||
|
|
||||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
[](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.rooms = new Map();
|
||||||
this.members = new Map();
|
this.members = new Map();
|
||||||
this.socketToRoom = new Map();
|
this.socketToRoom = new Map();
|
||||||
|
this.screenHelpers = new Map();
|
||||||
|
this.helperToRoom = new Map();
|
||||||
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
|
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
|
||||||
this.cleanupInterval = null;
|
this.cleanupInterval = null;
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
@@ -55,6 +57,7 @@ class WatchRoomServer {
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
|
roomType: data.roomType || 'sync',
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
ownerName: data.userName,
|
ownerName: data.userName,
|
||||||
ownerToken: ownerToken, // 保存房主令牌
|
ownerToken: ownerToken, // 保存房主令牌
|
||||||
@@ -131,6 +134,14 @@ class WatchRoomServer {
|
|||||||
|
|
||||||
const roomMembers = this.members.get(data.roomId);
|
const roomMembers = this.members.get(data.roomId);
|
||||||
if (roomMembers) {
|
if (roomMembers) {
|
||||||
|
if (isOwner) {
|
||||||
|
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
|
||||||
|
if (existingMember.isOwner && memberId !== userId) {
|
||||||
|
roomMembers.delete(memberId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
roomMembers.set(userId, member);
|
roomMembers.set(userId, member);
|
||||||
room.memberCount = roomMembers.size;
|
room.memberCount = roomMembers.size;
|
||||||
this.rooms.set(data.roomId, room);
|
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) => {
|
socket.on('chat:message', (data) => {
|
||||||
const roomInfo = this.socketToRoom.get(socket.id);
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
@@ -347,6 +466,19 @@ class WatchRoomServer {
|
|||||||
// 断开连接
|
// 断开连接
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
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);
|
this.handleLeaveRoom(socket);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -425,6 +557,11 @@ class WatchRoomServer {
|
|||||||
|
|
||||||
this.rooms.delete(roomId);
|
this.rooms.delete(roomId);
|
||||||
this.members.delete(roomId);
|
this.members.delete(roomId);
|
||||||
|
const helperSocketId = this.screenHelpers.get(roomId);
|
||||||
|
if (helperSocketId) {
|
||||||
|
this.helperToRoom.delete(helperSocketId);
|
||||||
|
this.screenHelpers.delete(roomId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startCleanupTimer() {
|
startCleanupTimer() {
|
||||||
|
|||||||
+625
-628
@@ -30,13 +30,13 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Cloud,
|
||||||
Database,
|
Database,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Globe,
|
Globe,
|
||||||
Mail,
|
Mail,
|
||||||
Music,
|
|
||||||
Palette,
|
Palette,
|
||||||
Settings,
|
Settings,
|
||||||
Tv,
|
Tv,
|
||||||
@@ -341,6 +341,7 @@ interface SiteConfig {
|
|||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
DanmakuSourceType?: 'builtin' | 'custom';
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
@@ -358,6 +359,8 @@ interface SiteConfig {
|
|||||||
MagnetAcgripReverseProxy?: string;
|
MagnetAcgripReverseProxy?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
EnableRegistration?: boolean;
|
EnableRegistration?: boolean;
|
||||||
|
RequireRegistrationInviteCode?: boolean;
|
||||||
|
RegistrationInviteCode?: string;
|
||||||
RegistrationRequireTurnstile?: boolean;
|
RegistrationRequireTurnstile?: boolean;
|
||||||
LoginRequireTurnstile?: boolean;
|
LoginRequireTurnstile?: boolean;
|
||||||
TurnstileSiteKey?: string;
|
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 媒体库配置组件 - 多源管理版本
|
// Emby 媒体库配置组件 - 多源管理版本
|
||||||
const EmbyConfigComponent = ({
|
const EmbyConfigComponent = ({
|
||||||
config,
|
config,
|
||||||
@@ -7555,349 +7771,8 @@ const ThemeConfigComponent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 音乐配置组件
|
// 音乐配置组件(已停用)
|
||||||
const MusicConfigComponent = ({
|
// 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 SiteConfigComponent = ({
|
const SiteConfigComponent = ({
|
||||||
@@ -7921,7 +7796,8 @@ const SiteConfigComponent = ({
|
|||||||
DoubanImageProxy: '',
|
DoubanImageProxy: '',
|
||||||
DisableYellowFilter: false,
|
DisableYellowFilter: false,
|
||||||
FluidSearch: true,
|
FluidSearch: true,
|
||||||
DanmakuApiBase: 'http://localhost:9321',
|
DanmakuSourceType: 'builtin',
|
||||||
|
DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321',
|
||||||
DanmakuApiToken: '87654321',
|
DanmakuApiToken: '87654321',
|
||||||
TMDBApiKey: '',
|
TMDBApiKey: '',
|
||||||
TMDBProxy: '',
|
TMDBProxy: '',
|
||||||
@@ -8016,6 +7892,7 @@ const SiteConfigComponent = ({
|
|||||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||||
|
DanmakuSourceType: config.SiteConfig.DanmakuSourceType || 'custom',
|
||||||
DanmakuApiBase:
|
DanmakuApiBase:
|
||||||
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||||
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||||
@@ -8565,57 +8442,102 @@ const SiteConfigComponent = ({
|
|||||||
弹幕配置
|
弹幕配置
|
||||||
</summary>
|
</summary>
|
||||||
<div className='mt-4 space-y-4'>
|
<div className='mt-4 space-y-4'>
|
||||||
{/* 弹幕 API 地址 */}
|
<div className='inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800'>
|
||||||
<div>
|
<button
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
type='button'
|
||||||
弹幕 API 地址
|
onClick={() =>
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
placeholder='http://localhost:9321'
|
|
||||||
value={siteSettings.DanmakuApiBase}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSiteSettings((prev) => ({
|
setSiteSettings((prev) => ({
|
||||||
...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'
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||||
/>
|
siteSettings.DanmakuSourceType !== 'custom'
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
|
||||||
弹幕服务器的 API 地址,默认为 http://localhost:9321。API部署参考
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
|
||||||
<a
|
}`}
|
||||||
href='https://github.com/huangxd-/danmu_api.git'
|
>
|
||||||
target='_blank'
|
内置源
|
||||||
rel='noopener noreferrer'
|
</button>
|
||||||
className='text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
|
<button
|
||||||
>
|
type='button'
|
||||||
danmu_api
|
onClick={() =>
|
||||||
</a>
|
setSiteSettings((prev) => ({
|
||||||
</p>
|
...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>
|
</div>
|
||||||
|
|
||||||
{/* 弹幕 API Token */}
|
{siteSettings.DanmakuSourceType !== 'custom' && (
|
||||||
<div>
|
<p className='text-xs text-amber-600 dark:text-amber-400'>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -9054,6 +8976,8 @@ const RegistrationConfigComponent = ({
|
|||||||
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false);
|
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false);
|
||||||
const [registrationSettings, setRegistrationSettings] = useState<{
|
const [registrationSettings, setRegistrationSettings] = useState<{
|
||||||
EnableRegistration: boolean;
|
EnableRegistration: boolean;
|
||||||
|
RequireRegistrationInviteCode: boolean;
|
||||||
|
RegistrationInviteCode: string;
|
||||||
RegistrationRequireTurnstile: boolean;
|
RegistrationRequireTurnstile: boolean;
|
||||||
LoginRequireTurnstile: boolean;
|
LoginRequireTurnstile: boolean;
|
||||||
TurnstileSiteKey: string;
|
TurnstileSiteKey: string;
|
||||||
@@ -9071,6 +8995,8 @@ const RegistrationConfigComponent = ({
|
|||||||
OIDCMinTrustLevel: number;
|
OIDCMinTrustLevel: number;
|
||||||
}>({
|
}>({
|
||||||
EnableRegistration: false,
|
EnableRegistration: false,
|
||||||
|
RequireRegistrationInviteCode: false,
|
||||||
|
RegistrationInviteCode: '',
|
||||||
RegistrationRequireTurnstile: false,
|
RegistrationRequireTurnstile: false,
|
||||||
LoginRequireTurnstile: false,
|
LoginRequireTurnstile: false,
|
||||||
TurnstileSiteKey: '',
|
TurnstileSiteKey: '',
|
||||||
@@ -9092,6 +9018,8 @@ const RegistrationConfigComponent = ({
|
|||||||
if (config?.SiteConfig) {
|
if (config?.SiteConfig) {
|
||||||
setRegistrationSettings({
|
setRegistrationSettings({
|
||||||
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
||||||
|
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
|
||||||
|
RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '',
|
||||||
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
||||||
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
||||||
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
||||||
@@ -9140,10 +9068,18 @@ const RegistrationConfigComponent = ({
|
|||||||
throw new Error('配置未加载');
|
throw new Error('配置未加载');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
registrationSettings.RequireRegistrationInviteCode &&
|
||||||
|
!registrationSettings.RegistrationInviteCode.trim()
|
||||||
|
) {
|
||||||
|
throw new Error('已开启注册邀请码时,邀请码不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
// 合并站点配置和注册配置
|
// 合并站点配置和注册配置
|
||||||
const updatedSiteConfig = {
|
const updatedSiteConfig = {
|
||||||
...config.SiteConfig,
|
...config.SiteConfig,
|
||||||
...registrationSettings,
|
...registrationSettings,
|
||||||
|
RegistrationInviteCode: registrationSettings.RegistrationInviteCode.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const resp = await fetch('/api/admin/site', {
|
const resp = await fetch('/api/admin/site', {
|
||||||
@@ -9182,205 +9118,269 @@ const RegistrationConfigComponent = ({
|
|||||||
注册配置
|
注册配置
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 开启注册 */}
|
<details open className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<div>
|
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||||
<div className='flex items-center justify-between'>
|
基础注册设置
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
</summary>
|
||||||
开启注册
|
<div className='mt-4 space-y-4'>
|
||||||
</label>
|
<div>
|
||||||
<button
|
<div className='flex items-center justify-between'>
|
||||||
type='button'
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
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 ${
|
</label>
|
||||||
registrationSettings.EnableRegistration
|
<button
|
||||||
? buttonStyles.toggleOn
|
type='button'
|
||||||
: buttonStyles.toggleOff
|
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
|
||||||
<span
|
? buttonStyles.toggleOn
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
: buttonStyles.toggleOff
|
||||||
buttonStyles.toggleThumb
|
}`}
|
||||||
} transition-transform ${
|
>
|
||||||
registrationSettings.EnableRegistration
|
<span
|
||||||
? buttonStyles.toggleThumbOn
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
: buttonStyles.toggleThumbOff
|
buttonStyles.toggleThumb
|
||||||
}`}
|
} transition-transform ${
|
||||||
/>
|
registrationSettings.EnableRegistration
|
||||||
</button>
|
? 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>
|
</div>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
</details>
|
||||||
开启后登录页面将显示注册按钮,允许用户自行注册账号。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 注册启用Cloudflare Turnstile */}
|
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<div>
|
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||||
<div className='flex items-center justify-between'>
|
安全设置
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
</summary>
|
||||||
注册启用Cloudflare Turnstile
|
<div className='mt-4 space-y-4'>
|
||||||
</label>
|
<div>
|
||||||
<button
|
<div className='flex items-center justify-between'>
|
||||||
type='button'
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
要求注册邀请码
|
||||||
onClick={() =>
|
</label>
|
||||||
setRegistrationSettings((prev) => ({
|
<button
|
||||||
...prev,
|
type='button'
|
||||||
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
|
onClick={() =>
|
||||||
}))
|
setRegistrationSettings((prev) => ({
|
||||||
}
|
...prev,
|
||||||
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 ${
|
RequireRegistrationInviteCode: !prev.RequireRegistrationInviteCode,
|
||||||
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
|
}))
|
||||||
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
}
|
||||||
: registrationSettings.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 ${
|
||||||
? buttonStyles.toggleOn
|
registrationSettings.RequireRegistrationInviteCode
|
||||||
: buttonStyles.toggleOff
|
? buttonStyles.toggleOn
|
||||||
}`}
|
: buttonStyles.toggleOff
|
||||||
>
|
}`}
|
||||||
<span
|
>
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
<span
|
||||||
buttonStyles.toggleThumb
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
} transition-transform ${
|
buttonStyles.toggleThumb
|
||||||
registrationSettings.RegistrationRequireTurnstile
|
} transition-transform ${
|
||||||
? buttonStyles.toggleThumbOn
|
registrationSettings.RequireRegistrationInviteCode
|
||||||
: buttonStyles.toggleThumbOff
|
? 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>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
</div>
|
仅普通注册生效;开启邀请码注册时不能为空。
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
</p>
|
||||||
开启后注册时需要通过Cloudflare Turnstile人机验证。
|
</div>
|
||||||
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
|
|
||||||
<span className='text-orange-500 dark:text-orange-400'> 需要先配置Site Key和Secret Key才能启用。</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 登录启用Cloudflare Turnstile */}
|
<div>
|
||||||
<div>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex items-center justify-between'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
注册启用Cloudflare Turnstile
|
||||||
登录启用Cloudflare Turnstile
|
</label>
|
||||||
</label>
|
<button
|
||||||
<button
|
type='button'
|
||||||
type='button'
|
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
||||||
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
|
onClick={() =>
|
||||||
onClick={() =>
|
setRegistrationSettings((prev) => ({
|
||||||
setRegistrationSettings((prev) => ({
|
...prev,
|
||||||
...prev,
|
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
|
||||||
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 ${
|
||||||
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
|
||||||
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
|
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
||||||
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
|
: registrationSettings.RegistrationRequireTurnstile
|
||||||
: registrationSettings.LoginRequireTurnstile
|
? buttonStyles.toggleOn
|
||||||
? buttonStyles.toggleOn
|
: buttonStyles.toggleOff
|
||||||
: buttonStyles.toggleOff
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<span
|
||||||
<span
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
buttonStyles.toggleThumb
|
||||||
buttonStyles.toggleThumb
|
} transition-transform ${
|
||||||
} transition-transform ${
|
registrationSettings.RegistrationRequireTurnstile
|
||||||
registrationSettings.LoginRequireTurnstile
|
? buttonStyles.toggleThumbOn
|
||||||
? buttonStyles.toggleThumbOn
|
: buttonStyles.toggleThumbOff
|
||||||
: 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>
|
</div>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
</details>
|
||||||
开启后登录时需要通过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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC配置 */}
|
{/* OIDC配置 */}
|
||||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||||
OIDC配置
|
OIDC配置
|
||||||
</h3>
|
</summary>
|
||||||
|
<div className='mt-4 space-y-4'>
|
||||||
{/* 启用OIDC登录 */}
|
{/* 启用OIDC登录 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
启用OIDC登录
|
启用OIDC登录
|
||||||
@@ -9413,10 +9413,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
开启后登录页面将显示OIDC登录按钮
|
开启后登录页面将显示OIDC登录按钮
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 启用OIDC注册 */}
|
{/* 启用OIDC注册 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
启用OIDC注册
|
启用OIDC注册
|
||||||
@@ -9449,10 +9449,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
开启后允许通过OIDC方式注册新用户(需要先启用OIDC登录)
|
开启后允许通过OIDC方式注册新用户(需要先启用OIDC登录)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC Issuer */}
|
{/* OIDC Issuer */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
OIDC Issuer URL(可选)
|
OIDC Issuer URL(可选)
|
||||||
</label>
|
</label>
|
||||||
@@ -9514,10 +9514,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置
|
OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Authorization Endpoint */}
|
{/* Authorization Endpoint */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
Authorization Endpoint(授权端点)
|
Authorization Endpoint(授权端点)
|
||||||
</label>
|
</label>
|
||||||
@@ -9536,10 +9536,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
用户授权的端点URL
|
用户授权的端点URL
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token Endpoint */}
|
{/* Token Endpoint */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
Token Endpoint(Token端点)
|
Token Endpoint(Token端点)
|
||||||
</label>
|
</label>
|
||||||
@@ -9558,10 +9558,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
交换授权码获取token的端点URL
|
交换授权码获取token的端点URL
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* UserInfo Endpoint */}
|
{/* UserInfo Endpoint */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
UserInfo Endpoint(用户信息端点)
|
UserInfo Endpoint(用户信息端点)
|
||||||
</label>
|
</label>
|
||||||
@@ -9580,10 +9580,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
获取用户信息的端点URL
|
获取用户信息的端点URL
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC Client ID */}
|
{/* OIDC Client ID */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
OIDC Client ID
|
OIDC Client ID
|
||||||
</label>
|
</label>
|
||||||
@@ -9602,10 +9602,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
在OIDC提供商处注册应用后获得的Client ID
|
在OIDC提供商处注册应用后获得的Client ID
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC Client Secret */}
|
{/* OIDC Client Secret */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
OIDC Client Secret
|
OIDC Client Secret
|
||||||
</label>
|
</label>
|
||||||
@@ -9624,10 +9624,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
在OIDC提供商处注册应用后获得的Client Secret
|
在OIDC提供商处注册应用后获得的Client Secret
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC Redirect URI - 只读显示 */}
|
{/* OIDC Redirect URI - 只读显示 */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
OIDC Redirect URI(回调地址)
|
OIDC Redirect URI(回调地址)
|
||||||
</label>
|
</label>
|
||||||
@@ -9657,10 +9657,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI
|
这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC登录按钮文字 */}
|
{/* OIDC登录按钮文字 */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
OIDC登录按钮文字
|
OIDC登录按钮文字
|
||||||
</label>
|
</label>
|
||||||
@@ -9679,10 +9679,10 @@ const RegistrationConfigComponent = ({
|
|||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
自定义OIDC登录按钮显示的文字,如"使用企业账号登录"、"使用SSO登录"等。留空则显示默认文字"使用OIDC登录"
|
自定义OIDC登录按钮显示的文字,如"使用企业账号登录"、"使用SSO登录"等。留空则显示默认文字"使用OIDC登录"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OIDC最低信任等级 */}
|
{/* OIDC最低信任等级 */}
|
||||||
<div>
|
<div>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
最低信任等级
|
最低信任等级
|
||||||
</label>
|
</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'
|
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'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
|
仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
@@ -12603,6 +12604,7 @@ function AdminPageClient() {
|
|||||||
sourceScriptLab: false,
|
sourceScriptLab: false,
|
||||||
mediaLibrary: false,
|
mediaLibrary: false,
|
||||||
openListConfig: false,
|
openListConfig: false,
|
||||||
|
netDiskConfig: false,
|
||||||
embyConfig: false,
|
embyConfig: false,
|
||||||
xiaoyaConfig: false,
|
xiaoyaConfig: false,
|
||||||
animeSubscription: false,
|
animeSubscription: false,
|
||||||
@@ -12610,7 +12612,6 @@ function AdminPageClient() {
|
|||||||
liveSource: false,
|
liveSource: false,
|
||||||
webLive: false,
|
webLive: false,
|
||||||
siteConfig: false,
|
siteConfig: false,
|
||||||
musicConfig: false,
|
|
||||||
registrationConfig: false,
|
registrationConfig: false,
|
||||||
categoryConfig: false,
|
categoryConfig: false,
|
||||||
configFile: false,
|
configFile: false,
|
||||||
@@ -12949,21 +12950,6 @@ function AdminPageClient() {
|
|||||||
/>
|
/>
|
||||||
</CollapsibleTab>
|
</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
|
<CollapsibleTab
|
||||||
title='视频源配置'
|
title='视频源配置'
|
||||||
@@ -13081,6 +13067,17 @@ function AdminPageClient() {
|
|||||||
>
|
>
|
||||||
<AnimeSubscriptionComponent config={config} refreshConfig={fetchConfig} />
|
<AnimeSubscriptionComponent config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</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>
|
</div>
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export default function AdvancedRecommendationPage() {
|
|||||||
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
|
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '获取推荐失败');
|
setError(err instanceof Error ? err.message : '获取推荐失败');
|
||||||
|
setHasMore(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingVideos(false);
|
setIsLoadingVideos(false);
|
||||||
}
|
}
|
||||||
@@ -119,7 +120,7 @@ export default function AdvancedRecommendationPage() {
|
|||||||
}, [selectedSource, page]);
|
}, [selectedSource, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
|
if (!loadMoreRef.current || !hasMore || isLoadingVideos || !!error) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -132,7 +133,7 @@ export default function AdvancedRecommendationPage() {
|
|||||||
|
|
||||||
observer.observe(loadMoreRef.current);
|
observer.observe(loadMoreRef.current);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMore, isLoadingVideos]);
|
}, [error, hasMore, isLoadingVideos]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout activePath='/advanced-recommendation'>
|
<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,
|
DoubanImageProxy,
|
||||||
DisableYellowFilter,
|
DisableYellowFilter,
|
||||||
FluidSearch,
|
FluidSearch,
|
||||||
|
DanmakuSourceType,
|
||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
@@ -58,6 +59,8 @@ export async function POST(request: NextRequest) {
|
|||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
EnableRegistration,
|
EnableRegistration,
|
||||||
|
RequireRegistrationInviteCode,
|
||||||
|
RegistrationInviteCode,
|
||||||
RegistrationRequireTurnstile,
|
RegistrationRequireTurnstile,
|
||||||
LoginRequireTurnstile,
|
LoginRequireTurnstile,
|
||||||
TurnstileSiteKey,
|
TurnstileSiteKey,
|
||||||
@@ -84,6 +87,7 @@ export async function POST(request: NextRequest) {
|
|||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
DanmakuSourceType?: 'builtin' | 'custom';
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
@@ -103,6 +107,8 @@ export async function POST(request: NextRequest) {
|
|||||||
CustomAdFilterCode?: string;
|
CustomAdFilterCode?: string;
|
||||||
CustomAdFilterVersion?: number;
|
CustomAdFilterVersion?: number;
|
||||||
EnableRegistration?: boolean;
|
EnableRegistration?: boolean;
|
||||||
|
RequireRegistrationInviteCode?: boolean;
|
||||||
|
RegistrationInviteCode?: string;
|
||||||
RegistrationRequireTurnstile?: boolean;
|
RegistrationRequireTurnstile?: boolean;
|
||||||
LoginRequireTurnstile?: boolean;
|
LoginRequireTurnstile?: boolean;
|
||||||
TurnstileSiteKey?: string;
|
TurnstileSiteKey?: string;
|
||||||
@@ -132,6 +138,9 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof DoubanImageProxy !== 'string' ||
|
typeof DoubanImageProxy !== 'string' ||
|
||||||
typeof DisableYellowFilter !== 'boolean' ||
|
typeof DisableYellowFilter !== 'boolean' ||
|
||||||
typeof FluidSearch !== 'boolean' ||
|
typeof FluidSearch !== 'boolean' ||
|
||||||
|
(DanmakuSourceType !== undefined &&
|
||||||
|
DanmakuSourceType !== 'builtin' &&
|
||||||
|
DanmakuSourceType !== 'custom') ||
|
||||||
typeof DanmakuApiBase !== 'string' ||
|
typeof DanmakuApiBase !== 'string' ||
|
||||||
typeof DanmakuApiToken !== 'string' ||
|
typeof DanmakuApiToken !== 'string' ||
|
||||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||||
@@ -148,6 +157,8 @@ export async function POST(request: NextRequest) {
|
|||||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||||
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
|
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
|
||||||
|
(RequireRegistrationInviteCode !== undefined && typeof RequireRegistrationInviteCode !== 'boolean') ||
|
||||||
|
(RegistrationInviteCode !== undefined && typeof RegistrationInviteCode !== 'string') ||
|
||||||
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
|
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
|
||||||
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
|
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
|
||||||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
|
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
|
||||||
@@ -189,6 +200,7 @@ export async function POST(request: NextRequest) {
|
|||||||
DoubanImageProxy,
|
DoubanImageProxy,
|
||||||
DisableYellowFilter,
|
DisableYellowFilter,
|
||||||
FluidSearch,
|
FluidSearch,
|
||||||
|
DanmakuSourceType,
|
||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
@@ -208,6 +220,8 @@ export async function POST(request: NextRequest) {
|
|||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
EnableRegistration,
|
EnableRegistration,
|
||||||
|
RequireRegistrationInviteCode,
|
||||||
|
RegistrationInviteCode,
|
||||||
RegistrationRequireTurnstile,
|
RegistrationRequireTurnstile,
|
||||||
LoginRequireTurnstile,
|
LoginRequireTurnstile,
|
||||||
TurnstileSiteKey,
|
TurnstileSiteKey,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -50,13 +51,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 从数据库读取弹幕配置
|
// 从数据库读取弹幕配置
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||||
|
|
||||||
// 构建 API URL
|
|
||||||
const baseUrl =
|
|
||||||
DanmakuApiToken === '87654321'
|
|
||||||
? DanmakuApiBase
|
|
||||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
|
||||||
|
|
||||||
let apiUrl: string;
|
let apiUrl: string;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -28,13 +29,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 从数据库读取弹幕配置
|
// 从数据库读取弹幕配置
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||||
|
|
||||||
// 构建 API URL
|
|
||||||
const baseUrl =
|
|
||||||
DanmakuApiToken === '87654321'
|
|
||||||
? DanmakuApiBase
|
|
||||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
|
||||||
|
|
||||||
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
|
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -25,13 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 从数据库读取弹幕配置
|
// 从数据库读取弹幕配置
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||||
|
|
||||||
// 构建 API URL
|
|
||||||
const baseUrl =
|
|
||||||
DanmakuApiToken === '87654321'
|
|
||||||
? DanmakuApiBase
|
|
||||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
|
||||||
|
|
||||||
const apiUrl = `${baseUrl}/api/v2/match`;
|
const apiUrl = `${baseUrl}/api/v2/match`;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -24,13 +25,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 从数据库读取弹幕配置
|
// 从数据库读取弹幕配置
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
|
||||||
|
|
||||||
// 构建 API URL
|
|
||||||
const baseUrl =
|
|
||||||
DanmakuApiToken === '87654321'
|
|
||||||
? DanmakuApiBase
|
|
||||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
|
||||||
|
|
||||||
const apiUrl = `${baseUrl}/api/v2/search/anime?keyword=${encodeURIComponent(keyword)}`;
|
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('未找到已完成的播放链接');
|
throw new Error('未找到已完成的播放链接');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果指定了 format=json,返回 JSON 格式
|
// 如果指定了 format=json,尝试解析到最终直链后再返回 JSON
|
||||||
if (format === 'json') {
|
if (format === 'json') {
|
||||||
|
const resolvedQualities = await Promise.all(
|
||||||
|
qualities.map(async (quality: any) => ({
|
||||||
|
...quality,
|
||||||
|
url: await getFinalUrl(quality.url),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
url: qualities[0].url,
|
url: resolvedQualities[0].url,
|
||||||
qualities
|
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') {
|
if (!username || typeof username !== 'string') {
|
||||||
@@ -69,6 +69,9 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (inviteCode !== undefined && typeof inviteCode !== 'string') {
|
||||||
|
return NextResponse.json({ error: '邀请码格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
// 验证用户名格式(只允许字母、数字、下划线,长度3-20)
|
// 验证用户名格式(只允许字母、数字、下划线,长度3-20)
|
||||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
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;
|
let releaseLock: (() => void) | null = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
WatchRoom: watchRoomConfig,
|
WatchRoom: watchRoomConfig,
|
||||||
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
||||||
|
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
|
||||||
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
||||||
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
||||||
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
const sourceCode = searchParams.get('source');
|
const sourceCode = searchParams.get('source');
|
||||||
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
||||||
|
const title = searchParams.get('title');
|
||||||
|
|
||||||
if (!id || !sourceCode) {
|
if (!id || !sourceCode) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
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
|
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
||||||
if (sourceCode === 'openlist') {
|
if (sourceCode === 'openlist') {
|
||||||
try {
|
try {
|
||||||
@@ -340,8 +476,9 @@ export async function GET(request: NextRequest) {
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
const pageSize = 100;
|
const pageSize = 100;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
while (true) {
|
while (hasMore) {
|
||||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
||||||
|
|
||||||
if (listResponse.code !== 200) {
|
if (listResponse.code !== 200) {
|
||||||
@@ -351,10 +488,7 @@ export async function GET(request: NextRequest) {
|
|||||||
total = listResponse.data.total;
|
total = listResponse.data.total;
|
||||||
allFiles.push(...listResponse.data.content);
|
allFiles.push(...listResponse.data.content);
|
||||||
|
|
||||||
if (allFiles.length >= total) {
|
hasMore = allFiles.length < total;
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage++;
|
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 progressThumbPresetId = '';
|
||||||
let progressThumbCustomUrl = '';
|
let progressThumbCustomUrl = '';
|
||||||
let enableRegistration = false;
|
let enableRegistration = false;
|
||||||
|
let requireRegistrationInviteCode = false;
|
||||||
let loginRequireTurnstile = false;
|
let loginRequireTurnstile = false;
|
||||||
let registrationRequireTurnstile = false;
|
let registrationRequireTurnstile = false;
|
||||||
let turnstileSiteKey = '';
|
let turnstileSiteKey = '';
|
||||||
@@ -125,6 +126,7 @@ export default async function RootLayout({
|
|||||||
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
|
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
|
||||||
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
|
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
|
||||||
enableRegistration = config.SiteConfig.EnableRegistration || false;
|
enableRegistration = config.SiteConfig.EnableRegistration || false;
|
||||||
|
requireRegistrationInviteCode = config.SiteConfig.RequireRegistrationInviteCode || false;
|
||||||
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
|
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
|
||||||
registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false;
|
registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false;
|
||||||
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
|
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
|
||||||
@@ -195,6 +197,7 @@ export default async function RootLayout({
|
|||||||
PROGRESS_THUMB_PRESET_ID: progressThumbPresetId,
|
PROGRESS_THUMB_PRESET_ID: progressThumbPresetId,
|
||||||
PROGRESS_THUMB_CUSTOM_URL: progressThumbCustomUrl,
|
PROGRESS_THUMB_CUSTOM_URL: progressThumbCustomUrl,
|
||||||
ENABLE_REGISTRATION: enableRegistration,
|
ENABLE_REGISTRATION: enableRegistration,
|
||||||
|
REQUIRE_REGISTRATION_INVITE_CODE: requireRegistrationInviteCode,
|
||||||
LOGIN_REQUIRE_TURNSTILE: loginRequireTurnstile,
|
LOGIN_REQUIRE_TURNSTILE: loginRequireTurnstile,
|
||||||
REGISTRATION_REQUIRE_TURNSTILE: registrationRequireTurnstile,
|
REGISTRATION_REQUIRE_TURNSTILE: registrationRequireTurnstile,
|
||||||
TURNSTILE_SITE_KEY: turnstileSiteKey,
|
TURNSTILE_SITE_KEY: turnstileSiteKey,
|
||||||
|
|||||||
+14
-21
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'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 Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
@@ -66,7 +66,6 @@ function HomeClient() {
|
|||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||||
const [musicEnabled, setMusicEnabled] = useState(false);
|
|
||||||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||||||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && announcement) {
|
if (typeof window !== 'undefined' && announcement) {
|
||||||
@@ -613,17 +604,19 @@ function HomeClient() {
|
|||||||
<LinkIcon size={18} />
|
<LinkIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 音乐视听入口 */}
|
{/* 音乐视听入口(暂时隐藏,后续可能恢复) */}
|
||||||
{musicEnabled && (
|
{/**
|
||||||
<Link href='/music'>
|
* {musicEnabled && (
|
||||||
<button
|
* <Link href='/music'>
|
||||||
className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
|
* <button
|
||||||
title='音乐视听'
|
* className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
|
||||||
>
|
* title='音乐视听'
|
||||||
<Music size={20} />
|
* >
|
||||||
</button>
|
* <Music size={20} />
|
||||||
</Link>
|
* </button>
|
||||||
)}
|
* </Link>
|
||||||
|
* )}
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* 源站寻片入口 */}
|
{/* 源站寻片入口 */}
|
||||||
{sourceSearchEnabled && (
|
{sourceSearchEnabled && (
|
||||||
|
|||||||
+149
-41
@@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer';
|
|||||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import PansouSearch from '@/components/PansouSearch';
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
import { useSite } from '@/components/SiteProvider';
|
import { useSite } from '@/components/SiteProvider';
|
||||||
import SmartRecommendations from '@/components/SmartRecommendations';
|
import SmartRecommendations from '@/components/SmartRecommendations';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
@@ -572,6 +573,13 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 纠错后的描述信息(用于显示,不触发 detail 更新)
|
// 纠错后的描述信息(用于显示,不触发 detail 更新)
|
||||||
const [correctedDesc, setCorrectedDesc] = useState<string>('');
|
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')
|
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
|
||||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||||
@@ -579,6 +587,11 @@ function PlayPageClient() {
|
|||||||
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
||||||
const isDirectPlay = currentSource === 'directplay';
|
const isDirectPlay = currentSource === 'directplay';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQuarkTempTMDBMeta(null);
|
||||||
|
setPendingQuarkTempTMDBData(null);
|
||||||
|
}, [currentSource, currentId]);
|
||||||
|
|
||||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||||
if (source.startsWith('emby_')) {
|
if (source.startsWith('emby_')) {
|
||||||
@@ -1187,14 +1200,18 @@ function PlayPageClient() {
|
|||||||
const detCacheAge = Date.now() - detTimestamp;
|
const detCacheAge = Date.now() - detTimestamp;
|
||||||
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
|
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
|
||||||
|
|
||||||
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
if (detCacheAge < detCacheMaxAge && data) {
|
||||||
console.log('使用缓存的TMDB详情数据');
|
if (data.backdrop) {
|
||||||
setTmdbBackdrop(processImageUrl(data.backdrop));
|
setTmdbBackdrop(processImageUrl(data.backdrop));
|
||||||
|
} else {
|
||||||
|
setTmdbBackdrop(null);
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有豆瓣ID,使用TMDb数据补充
|
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||||
if (!videoDoubanId || videoDoubanId === 0) {
|
if (!videoDoubanId || videoDoubanId === 0) {
|
||||||
populateDoubanFieldsFromTMDB(data);
|
populateDoubanFieldsFromTMDB(data);
|
||||||
}
|
}
|
||||||
|
populatePlayMetadataFromTMDB(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1215,7 +1232,6 @@ function PlayPageClient() {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log('获取TMDB详情失败');
|
|
||||||
setTmdbBackdrop(null);
|
setTmdbBackdrop(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1224,45 +1240,94 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
if (result.backdrop) {
|
if (result.backdrop) {
|
||||||
setTmdbBackdrop(processImageUrl(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 {
|
} else {
|
||||||
setTmdbBackdrop(null);
|
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) {
|
} catch (error) {
|
||||||
console.error('获取TMDB背景图失败:', error);
|
console.error('获取TMDB背景图失败:', error);
|
||||||
setTmdbBackdrop(null);
|
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数据填充豆瓣字段
|
// 辅助函数:使用TMDb数据填充豆瓣字段
|
||||||
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
|
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
|
||||||
// 设置评分
|
// 设置评分
|
||||||
@@ -1296,6 +1361,45 @@ function PlayPageClient() {
|
|||||||
fetchTMDBBackdrop();
|
fetchTMDBBackdrop();
|
||||||
}, [videoTitle, videoDoubanId, isDirectPlay]);
|
}, [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('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
@@ -1413,6 +1517,7 @@ function PlayPageClient() {
|
|||||||
!isM3u8LikeUrl(videoUrl) &&
|
!isM3u8LikeUrl(videoUrl) &&
|
||||||
(
|
(
|
||||||
detail.source === 'openlist' ||
|
detail.source === 'openlist' ||
|
||||||
|
detail.source === 'quark-temp' ||
|
||||||
detail.source === 'xiaoya' ||
|
detail.source === 'xiaoya' ||
|
||||||
detail.source.startsWith('emby')
|
detail.source.startsWith('emby')
|
||||||
)
|
)
|
||||||
@@ -8871,12 +8976,12 @@ function PlayPageClient() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
|
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
|
||||||
{(doubanYear || detail?.year || videoYear) && (
|
{(doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear) && (
|
||||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
<span>{doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear}</span>
|
||||||
)}
|
)}
|
||||||
{detail?.source_name && (
|
{detail?.source_name && (
|
||||||
<span
|
<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}
|
onClick={fetchCurrentSourceVideoInfo}
|
||||||
>
|
>
|
||||||
@@ -8896,7 +9001,7 @@ function PlayPageClient() {
|
|||||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{/* 剧情简介 */}
|
{/* 剧情简介 */}
|
||||||
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
|
{(doubanCardSubtitle || quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc) && (
|
||||||
<div
|
<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' : ''}`}
|
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' }}
|
style={{ whiteSpace: 'pre-line' }}
|
||||||
@@ -8907,7 +9012,7 @@ function PlayPageClient() {
|
|||||||
{doubanCardSubtitle}
|
{doubanCardSubtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{correctedDesc || detail?.desc}
|
{quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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'>
|
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
||||||
{videoCover ? (
|
{videoCover ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<ProxyImage
|
||||||
src={processImageUrl(videoCover)}
|
originalSrc={videoCover}
|
||||||
alt={videoTitle}
|
alt={videoTitle}
|
||||||
className='w-full h-full object-cover'
|
className='w-full h-full object-cover'
|
||||||
/>
|
/>
|
||||||
@@ -9161,6 +9266,7 @@ function PlayPageClient() {
|
|||||||
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
|
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
|
||||||
// 如果有豆瓣ID且不为0,传入doubanId
|
// 如果有豆瓣ID且不为0,传入doubanId
|
||||||
detail.source === 'openlist' ||
|
detail.source === 'openlist' ||
|
||||||
|
detail.source === 'quark-temp' ||
|
||||||
detail.source?.startsWith('emby') ||
|
detail.source?.startsWith('emby') ||
|
||||||
detail.source === 'xiaoya'
|
detail.source === 'xiaoya'
|
||||||
? undefined
|
? undefined
|
||||||
@@ -9171,6 +9277,7 @@ function PlayPageClient() {
|
|||||||
tmdbId={
|
tmdbId={
|
||||||
// 特殊源使用 tmdb
|
// 特殊源使用 tmdb
|
||||||
detail.source === 'openlist' ||
|
detail.source === 'openlist' ||
|
||||||
|
detail.source === 'quark-temp' ||
|
||||||
detail.source?.startsWith('emby') ||
|
detail.source?.startsWith('emby') ||
|
||||||
detail.source === 'xiaoya'
|
detail.source === 'xiaoya'
|
||||||
? detail.tmdb_id
|
? detail.tmdb_id
|
||||||
@@ -9182,6 +9289,7 @@ function PlayPageClient() {
|
|||||||
// 非特殊源使用 cms 数据
|
// 非特殊源使用 cms 数据
|
||||||
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
|
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
|
||||||
detail.source !== 'openlist' &&
|
detail.source !== 'openlist' &&
|
||||||
|
detail.source !== 'quark-temp' &&
|
||||||
!detail.source?.startsWith('emby') &&
|
!detail.source?.startsWith('emby') &&
|
||||||
detail.source !== 'xiaoya' &&
|
detail.source !== 'xiaoya' &&
|
||||||
!(detail.douban_id && detail.douban_id !== 0)
|
!(detail.douban_id && detail.douban_id !== 0)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function RegisterPageClient() {
|
|||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -108,6 +109,7 @@ function RegisterPageClient() {
|
|||||||
// 设置站点配置
|
// 设置站点配置
|
||||||
const config = {
|
const config = {
|
||||||
EnableRegistration: runtimeConfig?.ENABLE_REGISTRATION || false,
|
EnableRegistration: runtimeConfig?.ENABLE_REGISTRATION || false,
|
||||||
|
RequireRegistrationInviteCode: runtimeConfig?.REQUIRE_REGISTRATION_INVITE_CODE || false,
|
||||||
RegistrationRequireTurnstile: runtimeConfig?.REGISTRATION_REQUIRE_TURNSTILE || false,
|
RegistrationRequireTurnstile: runtimeConfig?.REGISTRATION_REQUIRE_TURNSTILE || false,
|
||||||
TurnstileSiteKey: runtimeConfig?.TURNSTILE_SITE_KEY || '',
|
TurnstileSiteKey: runtimeConfig?.TURNSTILE_SITE_KEY || '',
|
||||||
};
|
};
|
||||||
@@ -167,6 +169,11 @@ function RegisterPageClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) {
|
||||||
|
setError('请输入邀请码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('两次输入的密码不一致');
|
setError('两次输入的密码不一致');
|
||||||
return;
|
return;
|
||||||
@@ -191,6 +198,7 @@ function RegisterPageClient() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
inviteCode: siteConfig?.RequireRegistrationInviteCode ? inviteCode.trim() : undefined,
|
||||||
turnstileToken: siteConfig?.RegistrationRequireTurnstile ? turnstileToken : undefined,
|
turnstileToken: siteConfig?.RegistrationRequireTurnstile ? turnstileToken : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -340,6 +348,27 @@ function RegisterPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Cloudflare Turnstile */}
|
||||||
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
|
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
|
||||||
<div id='turnstile-container' className='flex justify-center'></div>
|
<div id='turnstile-container' className='flex justify-center'></div>
|
||||||
@@ -354,6 +383,7 @@ function RegisterPageClient() {
|
|||||||
type='submit'
|
type='submit'
|
||||||
disabled={
|
disabled={
|
||||||
!username || !password || !confirmPassword || loading ||
|
!username || !password || !confirmPassword || loading ||
|
||||||
|
(siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) ||
|
||||||
(siteConfig?.RegistrationRequireTurnstile && !turnstileToken)
|
(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'
|
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 ImageViewer from '@/components/ImageViewer';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import PansouSearch from '@/components/PansouSearch';
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
import SearchResultFilter, {
|
import SearchResultFilter, {
|
||||||
SearchFilterCategory,
|
SearchFilterCategory,
|
||||||
} from '@/components/SearchResultFilter';
|
} from '@/components/SearchResultFilter';
|
||||||
@@ -829,9 +830,8 @@ function SearchPageClient() {
|
|||||||
>
|
>
|
||||||
<div className='flex items-start gap-4'>
|
<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'>
|
<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 */}
|
<ProxyImage
|
||||||
<img
|
originalSrc={item.poster}
|
||||||
src={processImageUrl(item.poster)}
|
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
+164
-22
@@ -8,12 +8,41 @@ import { useEffect,useState } from 'react';
|
|||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
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';
|
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() {
|
export default function WatchRoomPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const watchRoom = useWatchRoomContext();
|
const watchRoom = useWatchRoomContext();
|
||||||
@@ -34,6 +63,7 @@ export default function WatchRoomPage() {
|
|||||||
description: '',
|
description: '',
|
||||||
password: '',
|
password: '',
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
roomType: 'sync' as RoomType,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 加入房间表单
|
// 加入房间表单
|
||||||
@@ -47,28 +77,42 @@ export default function WatchRoomPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [joinLoading, setJoinLoading] = 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;
|
if (!isConnected) return;
|
||||||
|
|
||||||
setLoading(true);
|
if (showLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const roomList = await getRoomList();
|
const roomList = await getRoomList();
|
||||||
setRooms(roomList);
|
setRooms(roomList);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoom] Failed to load rooms:', error);
|
console.error('[WatchRoom] Failed to load rooms:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (showLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换到房间列表 tab 时加载房间
|
// 切换到房间列表 tab 时加载房间
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'list') {
|
if (activeTab === 'list') {
|
||||||
loadRooms();
|
loadRooms(true);
|
||||||
// 每5秒刷新一次
|
// 每5秒刷新一次
|
||||||
const interval = setInterval(loadRooms, 5000);
|
const interval = setInterval(() => loadRooms(false), 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [activeTab, isConnected]);
|
}, [activeTab, isConnected]);
|
||||||
@@ -77,10 +121,18 @@ export default function WatchRoomPage() {
|
|||||||
const handleCreateRoom = async (e: React.FormEvent) => {
|
const handleCreateRoom = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createForm.roomName.trim()) {
|
if (!createForm.roomName.trim()) {
|
||||||
alert('请输入房间名称');
|
showToast('请输入房间名称', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createForm.roomType === 'screen') {
|
||||||
|
const supportError = getScreenShareHostSupportError();
|
||||||
|
if (supportError) {
|
||||||
|
showToast(`当前设备无法创建屏幕共享房间:${supportError}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
try {
|
try {
|
||||||
await createRoom({
|
await createRoom({
|
||||||
@@ -88,6 +140,7 @@ export default function WatchRoomPage() {
|
|||||||
description: createForm.description.trim(),
|
description: createForm.description.trim(),
|
||||||
password: createForm.password.trim() || undefined,
|
password: createForm.password.trim() || undefined,
|
||||||
isPublic: createForm.isPublic,
|
isPublic: createForm.isPublic,
|
||||||
|
roomType: createForm.roomType,
|
||||||
userName: currentUsername,
|
userName: currentUsername,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,9 +150,10 @@ export default function WatchRoomPage() {
|
|||||||
description: '',
|
description: '',
|
||||||
password: '',
|
password: '',
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
roomType: 'sync',
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.message || '创建房间失败');
|
showToast(error.message || '创建房间失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false);
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
@@ -110,10 +164,19 @@ export default function WatchRoomPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const targetRoomId = roomId || joinForm.roomId.trim().toUpperCase();
|
const targetRoomId = roomId || joinForm.roomId.trim().toUpperCase();
|
||||||
if (!targetRoomId) {
|
if (!targetRoomId) {
|
||||||
alert('请输入房间ID');
|
showToast('请输入房间ID', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetRoom = rooms.find((room) => room.id === targetRoomId);
|
||||||
|
if (targetRoom?.roomType === 'screen') {
|
||||||
|
const supportError = getScreenShareViewerSupportError();
|
||||||
|
if (supportError) {
|
||||||
|
showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setJoinLoading(true);
|
setJoinLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await joinRoom({
|
const result = await joinRoom({
|
||||||
@@ -131,7 +194,7 @@ export default function WatchRoomPage() {
|
|||||||
// 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新
|
// 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新
|
||||||
// 跳转逻辑会在 useEffect 中处理
|
// 跳转逻辑会在 useEffect 中处理
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.message || '加入房间失败');
|
showToast(error.message || '加入房间失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setJoinLoading(false);
|
setJoinLoading(false);
|
||||||
}
|
}
|
||||||
@@ -141,6 +204,11 @@ export default function WatchRoomPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentRoom || isOwner) return;
|
if (!currentRoom || isOwner) return;
|
||||||
|
|
||||||
|
if (currentRoom.roomType === 'screen') {
|
||||||
|
router.push('/watch-room/screen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 房员加入房间后,不立即跳转
|
// 房员加入房间后,不立即跳转
|
||||||
// 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用)
|
// 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用)
|
||||||
// 这样可以避免房主已经离开play页面但状态未清除的情况
|
// 这样可以避免房主已经离开play页面但状态未清除的情况
|
||||||
@@ -153,6 +221,8 @@ export default function WatchRoomPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentRoom || isOwner) return;
|
if (!currentRoom || isOwner) return;
|
||||||
|
|
||||||
|
if (currentRoom.roomType === 'screen') return;
|
||||||
|
|
||||||
const handlePlayChange = (state: any) => {
|
const handlePlayChange = (state: any) => {
|
||||||
if (state.type === 'play') {
|
if (state.type === 'play') {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -196,8 +266,23 @@ export default function WatchRoomPage() {
|
|||||||
}
|
}
|
||||||
}, [currentRoom, isOwner, router, socket]);
|
}, [currentRoom, isOwner, router, socket]);
|
||||||
|
|
||||||
|
// 屏幕共享房间创建/加入后直接进入共享页
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentRoom?.roomType === 'screen') {
|
||||||
|
router.push('/watch-room/screen');
|
||||||
|
}
|
||||||
|
}, [currentRoom?.id, currentRoom?.roomType, router]);
|
||||||
|
|
||||||
// 从房间列表加入房间
|
// 从房间列表加入房间
|
||||||
const handleJoinFromList = (room: Room) => {
|
const handleJoinFromList = (room: Room) => {
|
||||||
|
if (room.roomType === 'screen') {
|
||||||
|
const supportError = getScreenShareViewerSupportError();
|
||||||
|
if (supportError) {
|
||||||
|
showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setJoinForm({
|
setJoinForm({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
password: '',
|
password: '',
|
||||||
@@ -237,7 +322,9 @@ export default function WatchRoomPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-bold mb-1">
|
<h3 className="text-lg font-bold mb-1">
|
||||||
{currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
|
{currentRoom.roomType === 'screen'
|
||||||
|
? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享'
|
||||||
|
: currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-white/80">
|
<p className="text-sm text-white/80">
|
||||||
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
|
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
|
||||||
@@ -246,12 +333,14 @@ export default function WatchRoomPage() {
|
|||||||
<p className="text-xs text-white/90 mt-1">
|
<p className="text-xs text-white/90 mt-1">
|
||||||
{currentRoom.currentState.type === 'play'
|
{currentRoom.currentState.type === 'play'
|
||||||
? `${currentRoom.currentState.videoName || '未知视频'}`
|
? `${currentRoom.currentState.videoName || '未知视频'}`
|
||||||
: `${currentRoom.currentState.channelName || '未知频道'}`}
|
: currentRoom.currentState.type === 'live'
|
||||||
|
? `${currentRoom.currentState.channelName || '未知频道'}`
|
||||||
|
: '屏幕共享进行中'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!currentRoom.currentState && (
|
{!currentRoom.currentState && (
|
||||||
<p className="text-xs text-white/70 mt-1">
|
<p className="text-xs text-white/70 mt-1">
|
||||||
当房主开始播放时,您将自动跟随
|
{currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,6 +369,8 @@ export default function WatchRoomPage() {
|
|||||||
// 普通 live 格式,导航到 live 页面
|
// 普通 live 格式,导航到 live 页面
|
||||||
router.push(`/live?id=${state.channelId}`);
|
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"
|
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>
|
</h1>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
与好友一起看视频,实时同步播放
|
与好友一起看视频,支持进度同步或屏幕共享
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,7 +451,7 @@ export default function WatchRoomPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||||
<p className="text-blue-100 text-xs mb-1">房间号</p>
|
<p className="text-blue-100 text-xs mb-1">房间号</p>
|
||||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</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-blue-100 text-xs mb-1">成员数</p>
|
||||||
<p className="text-xl font-bold">{members.length} 人</p>
|
<p className="text-xl font-bold">{members.length} 人</p>
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<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">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作
|
💡 {currentRoom.roomType === 'screen'
|
||||||
|
? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
|
||||||
|
: '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,6 +568,38 @@ export default function WatchRoomPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createLoading || !createForm.roomName.trim()}
|
disabled={createLoading || !createForm.roomName.trim()}
|
||||||
@@ -486,7 +615,7 @@ export default function WatchRoomPage() {
|
|||||||
{!currentRoom && (
|
{!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">
|
<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">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<strong>提示:</strong>创建房间后,您将成为房主。所有成员的播放进度将自动跟随您的操作。
|
<strong>提示:</strong>创建房间后,您将成为房主。进度同步房会跟随播放状态,屏幕共享房会进入独立共享页。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -518,7 +647,7 @@ export default function WatchRoomPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||||
<p className="text-green-100 text-xs mb-1">房间号</p>
|
<p className="text-green-100 text-xs mb-1">房间号</p>
|
||||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</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-green-100 text-xs mb-1">成员数</p>
|
||||||
<p className="text-xl font-bold">{members.length} 人</p>
|
<p className="text-xl font-bold">{members.length} 人</p>
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<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">
|
<p className="text-sm text-green-800 dark:text-green-200">
|
||||||
💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
💡 {currentRoom.roomType === 'screen'
|
||||||
|
? '这是屏幕共享房间,进入后即可观看房主共享画面'
|
||||||
|
: isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,7 +752,7 @@ export default function WatchRoomPage() {
|
|||||||
{!currentRoom && (
|
{!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">
|
<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">
|
<p className="text-sm text-green-800 dark:text-green-200">
|
||||||
<strong>提示:</strong>加入房间后,您的播放进度将自动跟随房主的操作。
|
<strong>提示:</strong>加入进度同步房后将跟随播放,加入屏幕共享房后会进入共享页面。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -633,7 +768,7 @@ export default function WatchRoomPage() {
|
|||||||
找到 <span className="font-medium text-gray-900 dark:text-gray-100">{rooms.length}</span> 个公开房间
|
找到 <span className="font-medium text-gray-900 dark:text-gray-100">{rooms.length}</span> 个公开房间
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadRooms}
|
onClick={() => loadRooms(true)}
|
||||||
disabled={loading}
|
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"
|
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>房主</span>
|
||||||
<span className="font-medium">{room.ownerName}</span>
|
<span className="font-medium">{room.ownerName}</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||||
<span>创建时间</span>
|
<span>创建时间</span>
|
||||||
<span>{formatTime(room.createdAt)}</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">
|
<p className="text-xs text-blue-700 dark:text-blue-300 truncate">
|
||||||
{room.currentState.type === 'play'
|
{room.currentState.type === 'play'
|
||||||
? `正在播放: ${room.currentState.videoName}`
|
? `正在播放: ${room.currentState.videoName}`
|
||||||
: `正在观看: ${room.currentState.channelName}`}
|
: room.currentState.type === 'live'
|
||||||
|
? `正在观看: ${room.currentState.channelName}`
|
||||||
|
: '正在共享屏幕'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -733,6 +874,7 @@ export default function WatchRoomPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{toast && <Toast {...toast} />}
|
||||||
</PageLayout>
|
</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 { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||||
import { getDoubanDetail } from '@/lib/douban.client';
|
import { getDoubanDetail } from '@/lib/douban.client';
|
||||||
import { processImageUrl } from '@/lib/utils';
|
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
interface BannerCarouselProps {
|
interface BannerCarouselProps {
|
||||||
autoPlayInterval?: number; // 自动播放间隔(毫秒)
|
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) => {
|
const getImageUrl = (path: string | null) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
// 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理
|
// 如果是完整URL(TX数据源或豆瓣),直接返回原始地址
|
||||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
return processImageUrl(path);
|
return path;
|
||||||
}
|
}
|
||||||
// 否则使用TMDB的URL拼接,并通过processImageUrl处理
|
// 否则使用TMDB的URL拼接原始地址
|
||||||
return processImageUrl(getTMDBImageUrl(path, 'original'));
|
return getTMDBImageUrl(path, 'original');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取视频URL(处理豆瓣视频代理)
|
// 获取视频URL(处理豆瓣视频代理)
|
||||||
@@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* 显示图片 */
|
/* 显示图片 */
|
||||||
<Image
|
<ProxyImage
|
||||||
src={getImageUrl(item.backdrop_path || item.poster_path)}
|
originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
loading={index === 0 ? 'eager' : 'lazy'}
|
||||||
priority={index === 0}
|
|
||||||
sizes="100vw"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 渐变遮罩 */}
|
{/* 渐变遮罩 */}
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ export default function DanmakuPanel({
|
|||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const initializedRef = useRef(false); // 标记是否已初始化过
|
const initializedRef = useRef(false); // 标记是否已初始化过
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const episodeGroupContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const episodeGroupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
const [episodeGroupIndex, setEpisodeGroupIndex] = useState(0);
|
const [episodeGroupIndex, setEpisodeGroupIndex] = useState(0);
|
||||||
const [episodeDescending, setEpisodeDescending] = useState(false);
|
const [episodeDescending, setEpisodeDescending] = useState(false);
|
||||||
const [episodeViewMode, setEpisodeViewMode] = useState<'list' | 'grid'>('list');
|
const [episodeViewMode, setEpisodeViewMode] = useState<'list' | 'grid'>('list');
|
||||||
|
const [isEpisodeGroupHovered, setIsEpisodeGroupHovered] = useState(false);
|
||||||
const episodesPerGroup = 50;
|
const episodesPerGroup = 50;
|
||||||
|
|
||||||
// 搜索弹幕
|
// 搜索弹幕
|
||||||
@@ -230,6 +233,62 @@ export default function DanmakuPanel({
|
|||||||
return String(episodeNumber);
|
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 (
|
return (
|
||||||
<div className='flex h-full flex-col overflow-hidden'>
|
<div className='flex h-full flex-col overflow-hidden'>
|
||||||
{/* 搜索区域 - 固定在顶部 */}
|
{/* 搜索区域 - 固定在顶部 */}
|
||||||
@@ -348,12 +407,20 @@ export default function DanmakuPanel({
|
|||||||
{!isLoadingEpisodes && episodes.length > 0 && (
|
{!isLoadingEpisodes && episodes.length > 0 && (
|
||||||
<div className='pb-4'>
|
<div className='pb-4'>
|
||||||
<div className='mb-4 border-b border-gray-300 dark:border-gray-700'>
|
<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) => {
|
{episodeGroups.map((label, idx) => {
|
||||||
const isActive = idx === displayEpisodeGroupIndex;
|
const isActive = idx === displayEpisodeGroupIndex;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
|
ref={(el) => {
|
||||||
|
episodeGroupButtonRefs.current[idx] = el;
|
||||||
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setEpisodeGroupIndex(
|
setEpisodeGroupIndex(
|
||||||
episodeDescending ? episodeGroupCount - 1 - idx : idx
|
episodeDescending ? episodeGroupCount - 1 - idx : idx
|
||||||
|
|||||||
+364
-84
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 Image from 'next/image';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
|||||||
import { processImageUrl } from '@/lib/utils';
|
import { processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
import ImageViewer from '@/components/ImageViewer';
|
import ImageViewer from '@/components/ImageViewer';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -69,6 +70,16 @@ interface Episode {
|
|||||||
air_date: string;
|
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> = ({
|
const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -100,6 +111,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
||||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<string>('');
|
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);
|
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
|
// 确保组件在客户端挂载后才渲染 Portal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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(() => {
|
useEffect(() => {
|
||||||
let animationId: number;
|
let animationId: number;
|
||||||
@@ -185,6 +276,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setShowGallery(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
// 阻止背景滚动(仅在非抽屉模式下)
|
// 阻止背景滚动(仅在非抽屉模式下)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && !useDrawer) {
|
if (isVisible && !useDrawer) {
|
||||||
@@ -277,7 +374,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: title,
|
title: title,
|
||||||
intro: cmsData.desc,
|
intro: cmsData.desc,
|
||||||
episodesCount: cmsData.episodes?.length,
|
episodesCount: cmsData.episodes?.length,
|
||||||
poster: poster ? processImageUrl(poster) : poster,
|
poster: poster,
|
||||||
};
|
};
|
||||||
setDetailData(data);
|
setDetailData(data);
|
||||||
setOriginalDetailData(data);
|
setOriginalDetailData(data);
|
||||||
@@ -297,7 +394,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: data.title || title,
|
title: data.title || title,
|
||||||
intro: data.desc || '',
|
intro: data.desc || '',
|
||||||
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
||||||
poster: data.poster ? processImageUrl(data.poster) : poster,
|
poster: data.poster || poster,
|
||||||
year: data.year,
|
year: data.year,
|
||||||
};
|
};
|
||||||
setDetailData(detailData);
|
setDetailData(detailData);
|
||||||
@@ -327,7 +424,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: data.name_cn || data.name,
|
title: data.name_cn || data.name,
|
||||||
originalTitle: data.name,
|
originalTitle: data.name,
|
||||||
year: data.date ? data.date.substring(0, 4) : undefined,
|
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
|
rating: data.rating
|
||||||
? {
|
? {
|
||||||
value: data.rating.score,
|
value: data.rating.score,
|
||||||
@@ -358,7 +455,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
originalTitle: data.original_title,
|
originalTitle: data.original_title,
|
||||||
year: data.year,
|
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
|
rating: data.rating
|
||||||
? {
|
? {
|
||||||
value: data.rating.value,
|
value: data.rating.value,
|
||||||
@@ -777,7 +874,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
...prev,
|
...prev,
|
||||||
title: episodesData.name || season?.name || prev.title,
|
title: episodesData.name || season?.name || prev.title,
|
||||||
intro: episodesData.overview || season?.overview || prev.overview,
|
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,
|
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
|
||||||
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
||||||
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
|
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;
|
if (!isVisible || !mounted) return null;
|
||||||
|
|
||||||
const content = useDrawer ? (
|
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="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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
<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">
|
<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'}
|
{currentSource === 'tmdb' && 'TMDB'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentSource !== 'tmdb' && (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
{galleryEntryButton}
|
||||||
onClick={handleToggleSource}
|
{currentSource !== 'tmdb' && (
|
||||||
disabled={loading}
|
<button
|
||||||
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"
|
onClick={handleToggleSource}
|
||||||
>
|
disabled={loading}
|
||||||
切换到 TMDB
|
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"
|
||||||
</button>
|
>
|
||||||
)}
|
切换到 TMDB
|
||||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={handleToggleSource}
|
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||||
disabled={loading}
|
<button
|
||||||
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"
|
onClick={handleToggleSource}
|
||||||
>
|
disabled={loading}
|
||||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
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"
|
||||||
</button>
|
>
|
||||||
)}
|
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -976,11 +1241,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{/* 海报和基本信息 */}
|
{/* 海报和基本信息 */}
|
||||||
<div className="flex gap-6 mb-6">
|
<div className="flex gap-6 mb-6">
|
||||||
{detailData.poster && (
|
{detailData.poster && (
|
||||||
<div
|
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
||||||
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"
|
<div
|
||||||
onClick={() => handleImageClick(detailData.poster!)}
|
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!)}
|
||||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
>
|
||||||
|
<ProxyImage
|
||||||
|
originalSrc={detailData.poster}
|
||||||
|
alt={detailData.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{galleryEntryButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -1103,13 +1376,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{actor.profile_path ? (
|
{actor.profile_path ? (
|
||||||
<div
|
<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"
|
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
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||||
alt={actor.name}
|
alt={actor.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1283,13 +1554,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{episode.still_path && (
|
{episode.still_path && (
|
||||||
<div
|
<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"
|
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
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
<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">
|
<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'}
|
{currentSource === 'tmdb' && 'TMDB'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentSource !== 'tmdb' && (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
{galleryEntryButton}
|
||||||
onClick={handleToggleSource}
|
{currentSource !== 'tmdb' && (
|
||||||
disabled={loading}
|
<button
|
||||||
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"
|
onClick={handleToggleSource}
|
||||||
>
|
disabled={loading}
|
||||||
切换到 TMDB
|
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"
|
||||||
</button>
|
>
|
||||||
)}
|
切换到 TMDB
|
||||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={handleToggleSource}
|
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||||
disabled={loading}
|
<button
|
||||||
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"
|
onClick={handleToggleSource}
|
||||||
>
|
disabled={loading}
|
||||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
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"
|
||||||
</button>
|
>
|
||||||
)}
|
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1368,6 +1641,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图片查看器 */}
|
{/* 图片查看器 */}
|
||||||
|
{galleryModal}
|
||||||
{showImageViewer && (
|
{showImageViewer && (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
isOpen={showImageViewer}
|
isOpen={showImageViewer}
|
||||||
@@ -1480,11 +1754,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{/* 海报和基本信息 */}
|
{/* 海报和基本信息 */}
|
||||||
<div className="flex gap-6 mb-6">
|
<div className="flex gap-6 mb-6">
|
||||||
{detailData.poster && (
|
{detailData.poster && (
|
||||||
<div
|
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
||||||
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"
|
<div
|
||||||
onClick={() => handleImageClick(detailData.poster!)}
|
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!)}
|
||||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
>
|
||||||
|
<ProxyImage
|
||||||
|
originalSrc={detailData.poster}
|
||||||
|
alt={detailData.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{galleryEntryButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -1607,13 +1889,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{actor.profile_path ? (
|
{actor.profile_path ? (
|
||||||
<div
|
<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"
|
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
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||||
alt={actor.name}
|
alt={actor.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1787,13 +2067,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{episode.still_path && (
|
{episode.still_path && (
|
||||||
<div
|
<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"
|
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
|
<ProxyImage
|
||||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1872,6 +2151,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图片查看器 */}
|
{/* 图片查看器 */}
|
||||||
|
{galleryModal}
|
||||||
{showImageViewer && (
|
{showImageViewer && (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
isOpen={showImageViewer}
|
isOpen={showImageViewer}
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import React, {
|
|||||||
|
|
||||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||||
|
|
||||||
import DanmakuPanel from '@/components/DanmakuPanel';
|
import DanmakuPanel from '@/components/DanmakuPanel';
|
||||||
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
// 定义视频信息类型
|
// 定义视频信息类型
|
||||||
interface VideoInfo {
|
interface VideoInfo {
|
||||||
@@ -870,10 +871,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
{source.source === 'directplay' ? (
|
{source.source === 'directplay' ? (
|
||||||
<LinkIcon className='w-6 h-6 text-blue-500' />
|
<LinkIcon className='w-6 h-6 text-blue-500' />
|
||||||
) : source.poster ? (
|
) : source.poster ? (
|
||||||
<img
|
<ProxyImage
|
||||||
src={processImageUrl(source.poster)}
|
originalSrc={source.poster}
|
||||||
alt={source.title}
|
alt={source.title}
|
||||||
className='w-full h-full object-cover'
|
className='w-full h-full object-cover'
|
||||||
|
retryOnError={false}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.style.display = 'none';
|
target.style.display = 'none';
|
||||||
@@ -940,7 +942,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
{/* 源名称和集数信息 - 垂直居中 */}
|
{/* 源名称和集数信息 - 垂直居中 */}
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
|
<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-yellow-500'
|
||||||
: 'border-gray-500/60'
|
: 'border-gray-500/60'
|
||||||
}`}>
|
}`}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
interface ImageViewerProps {
|
interface ImageViewerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -151,18 +152,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<ProxyImage
|
||||||
src={imageUrl}
|
originalSrc={imageUrl}
|
||||||
alt={alt}
|
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"
|
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100vw',
|
maxWidth: '100vw',
|
||||||
maxHeight: '100vh',
|
maxHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
priority
|
loading="eager"
|
||||||
quality={100}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
};
|
};
|
||||||
const currentActive = activePath ?? getCurrentFullPath();
|
const currentActive = activePath ?? getCurrentFullPath();
|
||||||
|
|
||||||
|
if (pathname === '/watch-room/screen') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [navItems, setNavItems] = useState([
|
const [navItems, setNavItems] = useState([
|
||||||
{ icon: Home, label: '首页', href: '/' },
|
{ icon: Home, label: '首页', href: '/' },
|
||||||
{
|
{
|
||||||
|
|||||||
+286
-181
@@ -2,8 +2,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react';
|
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';
|
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
|
||||||
|
|
||||||
interface PansouSearchProps {
|
interface PansouSearchProps {
|
||||||
@@ -51,11 +53,15 @@ export default function PansouSearch({
|
|||||||
triggerSearch,
|
triggerSearch,
|
||||||
onError,
|
onError,
|
||||||
}: PansouSearchProps) {
|
}: PansouSearchProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [results, setResults] = useState<PansouSearchResult | null>(null);
|
const [results, setResults] = useState<PansouSearchResult | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||||
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
|
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 () => {
|
const searchPansou = useCallback(async () => {
|
||||||
@@ -118,205 +124,304 @@ export default function PansouSearch({
|
|||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const handleQuarkTransfer = async (link: PansouLink) => {
|
||||||
return (
|
try {
|
||||||
<div className='flex items-center justify-center py-12'>
|
setTransferingUrl(link.url);
|
||||||
<div className='text-center'>
|
const response = await fetch('/api/netdisk/quark/transfer', {
|
||||||
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
|
method: 'POST',
|
||||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
headers: {
|
||||||
正在搜索网盘资源...
|
'Content-Type': 'application/json',
|
||||||
</p>
|
},
|
||||||
|
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>
|
||||||
</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 (
|
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' />
|
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
找到 <span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span> 个资源
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 网盘类型过滤器 */}
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
<button
|
<button
|
||||||
onClick={searchPansou}
|
onClick={() => setSelectedType('all')}
|
||||||
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'
|
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>
|
</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>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!results || results.total === 0 || !results.merged_by_type) {
|
{/* 按网盘类型分类显示 */}
|
||||||
return (
|
{filteredCloudTypes.map((cloudType) => {
|
||||||
<div className='flex items-center justify-center py-12'>
|
const links = results.merged_by_type?.[cloudType];
|
||||||
<div className='text-center'>
|
if (!links || links.length === 0) return null;
|
||||||
<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 typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
||||||
|
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
||||||
// 过滤显示的网盘类型
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={cloudType} className='space-y-3'>
|
||||||
key={type}
|
{/* 网盘类型标题 */}
|
||||||
onClick={() => setSelectedType(type)}
|
<div className='flex items-center gap-2'>
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
|
||||||
selectedType === type
|
{typeName}
|
||||||
? 'bg-green-600 text-white dark:bg-green-600'
|
</span>
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
}`}
|
{links.length} 个链接
|
||||||
>
|
</span>
|
||||||
{typeName} ({count})
|
</div>
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 按网盘类型分类显示 */}
|
{/* 链接列表 */}
|
||||||
{filteredCloudTypes.map((cloudType) => {
|
<div className='space-y-2'>
|
||||||
const links = results.merged_by_type?.[cloudType];
|
{links.map((link: PansouLink, index: number) => (
|
||||||
if (!links || links.length === 0) return null;
|
<div
|
||||||
|
key={`${cloudType}-${index}`}
|
||||||
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
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'
|
||||||
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
>
|
||||||
|
{/* 资源标题 */}
|
||||||
return (
|
{link.note && (
|
||||||
<div key={cloudType} className='space-y-3'>
|
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
{/* 网盘类型标题 */}
|
{link.note}
|
||||||
<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>
|
</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>
|
</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>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 图片预览 */}
|
||||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
{link.images && link.images.length > 0 && (
|
||||||
<button
|
<div className='mt-3 flex gap-2 overflow-x-auto'>
|
||||||
onClick={() => handleCopy(
|
{link.images.map((img, imgIndex) => (
|
||||||
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
|
<img
|
||||||
link.url
|
key={imgIndex}
|
||||||
)}
|
src={img}
|
||||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
alt=''
|
||||||
title='复制链接'
|
className='h-20 w-auto rounded object-cover'
|
||||||
>
|
loading='lazy'
|
||||||
{copiedUrl === link.url ? (
|
/>
|
||||||
<span className='text-xs text-green-600 dark:text-green-400'>已复制</span>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<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>
|
||||||
|
))}
|
||||||
{/* 图片预览 */}
|
</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 pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const watchRoomContext = useWatchRoomContextSafe();
|
const watchRoomContext = useWatchRoomContextSafe();
|
||||||
|
|
||||||
|
if (pathname === '/watch-room/screen') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
|
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
|
||||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
||||||
const [enableTrailers, setEnableTrailers] = useState(false);
|
const [enableTrailers, setEnableTrailers] = useState(false);
|
||||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||||
|
const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
|
||||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||||
|
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
|
||||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||||
|
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
|
||||||
|
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
|
||||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||||
|
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
|
||||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
|
||||||
|
useState(false);
|
||||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||||
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
||||||
@@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => {
|
|||||||
setDoubanProxyUrl(defaultDoubanProxy);
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDoubanDataSourceBackup = localStorage.getItem(
|
||||||
|
'doubanDataSourceBackup'
|
||||||
|
);
|
||||||
|
setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
|
||||||
|
|
||||||
|
const savedDoubanProxyUrlBackup = localStorage.getItem(
|
||||||
|
'doubanProxyUrlBackup'
|
||||||
|
);
|
||||||
|
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
|
||||||
|
|
||||||
const savedDoubanImageProxyType = localStorage.getItem(
|
const savedDoubanImageProxyType = localStorage.getItem(
|
||||||
'doubanImageProxyType'
|
'doubanImageProxyType'
|
||||||
);
|
);
|
||||||
@@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => {
|
|||||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
|
||||||
|
'doubanImageProxyTypeBackup'
|
||||||
|
);
|
||||||
|
setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
|
||||||
|
|
||||||
|
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
|
||||||
|
'doubanImageProxyUrlBackup'
|
||||||
|
);
|
||||||
|
setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
|
||||||
|
|
||||||
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
|
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
|
||||||
if (savedTmdbImageBaseUrl !== null) {
|
if (savedTmdbImageBaseUrl !== null) {
|
||||||
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
|
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
|
||||||
@@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isDoubanDropdownOpen]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (isDoubanImageProxyDropdownOpen) {
|
if (isDoubanImageProxyDropdownOpen) {
|
||||||
@@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isDoubanImageProxyDropdownOpen]);
|
}, [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 = () => {
|
const handleMenuClick = () => {
|
||||||
setIsOpen(!isOpen);
|
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) => {
|
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||||
setDoubanImageProxyType(value);
|
setDoubanImageProxyType(value);
|
||||||
if (typeof window !== 'undefined') {
|
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) => {
|
const handleDoubanImageProxyUrlChange = (value: string) => {
|
||||||
setDoubanImageProxyUrl(value);
|
setDoubanImageProxyUrl(value);
|
||||||
if (typeof window !== 'undefined') {
|
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) => {
|
const handleTmdbImageBaseUrlChange = (value: string) => {
|
||||||
setTmdbImageBaseUrl(value);
|
setTmdbImageBaseUrl(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
setEnableTrailers(false);
|
setEnableTrailers(false);
|
||||||
setDoubanProxyUrl(defaultDoubanProxy);
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
setDoubanDataSource(defaultDoubanProxyType);
|
setDoubanDataSource(defaultDoubanProxyType);
|
||||||
|
setDoubanDataSourceBackup('direct');
|
||||||
|
setDoubanProxyUrlBackup('');
|
||||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||||
|
setDoubanImageProxyTypeBackup('server');
|
||||||
|
setDoubanImageProxyUrlBackup('');
|
||||||
setTmdbImageBaseUrl('https://image.tmdb.org');
|
setTmdbImageBaseUrl('https://image.tmdb.org');
|
||||||
setBufferStrategy('medium');
|
setBufferStrategy('medium');
|
||||||
setNextEpisodePreCache(true);
|
setNextEpisodePreCache(true);
|
||||||
@@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('enableTrailers', 'false');
|
localStorage.setItem('enableTrailers', 'false');
|
||||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||||
|
localStorage.setItem('doubanDataSourceBackup', 'direct');
|
||||||
|
localStorage.setItem('doubanProxyUrlBackup', '');
|
||||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||||
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
||||||
|
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
|
||||||
|
localStorage.setItem('doubanImageProxyUrlBackup', '');
|
||||||
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
|
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
|
||||||
localStorage.setItem('bufferStrategy', 'medium');
|
localStorage.setItem('bufferStrategy', 'medium');
|
||||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||||
@@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => {
|
|||||||
value={doubanProxyUrl}
|
value={doubanProxyUrl}
|
||||||
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
|
|||||||
handleDoubanImageProxyUrlChange(e.target.value)
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
saveFavorite,
|
saveFavorite,
|
||||||
subscribeToDataUpdates,
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { processImageUrl, base58Decode } from '@/lib/utils';
|
import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
|
|
||||||
import AIChatPanel from '@/components/AIChatPanel';
|
import AIChatPanel from '@/components/AIChatPanel';
|
||||||
@@ -750,8 +750,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
setShowImageViewer(true);
|
setShowImageViewer(true);
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
if (tryApplyDoubanImageFallback(img, actualPoster)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 图片加载失败时的重试机制
|
// 图片加载失败时的重试机制
|
||||||
const img = e.target as HTMLImageElement;
|
|
||||||
if (!img.dataset.retried) {
|
if (!img.dataset.retried) {
|
||||||
img.dataset.retried = 'true';
|
img.dataset.retried = 'true';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1043,7 +1047,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
|
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={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
@@ -1367,7 +1371,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{config.showSourceName && source_name && !cmsData && (
|
{config.showSourceName && source_name && !cmsData && (
|
||||||
<span
|
<span
|
||||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
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={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
@@ -1598,7 +1602,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
<ImageViewer
|
<ImageViewer
|
||||||
isOpen={showImageViewer}
|
isOpen={showImageViewer}
|
||||||
onClose={() => setShowImageViewer(false)}
|
onClose={() => setShowImageViewer(false)}
|
||||||
imageUrl={processImageUrl(actualPoster)}
|
imageUrl={actualPoster}
|
||||||
alt={actualTitle}
|
alt={actualTitle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import Toast, { ToastProps } from '@/components/Toast';
|
|||||||
|
|
||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
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
|
// Import type from watch-room-socket
|
||||||
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
|
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 {
|
interface WatchRoomContextType {
|
||||||
socket: WatchRoomSocket | null;
|
socket: WatchRoomSocket | null;
|
||||||
@@ -31,12 +33,14 @@ interface WatchRoomContextType {
|
|||||||
description: string;
|
description: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
roomType: RoomType;
|
||||||
userName: string;
|
userName: string;
|
||||||
}) => Promise<Room>;
|
}) => Promise<Room>;
|
||||||
joinRoom: (data: {
|
joinRoom: (data: {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
ownerToken?: string;
|
||||||
}) => Promise<{ room: Room; members: Member[] }>;
|
}) => Promise<{ room: Room; members: Member[] }>;
|
||||||
leaveRoom: () => void;
|
leaveRoom: () => void;
|
||||||
getRoomList: () => Promise<Room[]>;
|
getRoomList: () => Promise<Room[]>;
|
||||||
@@ -51,6 +55,8 @@ interface WatchRoomContextType {
|
|||||||
pause: () => void;
|
pause: () => void;
|
||||||
changeVideo: (state: any) => void;
|
changeVideo: (state: any) => void;
|
||||||
changeLiveChannel: (state: any) => void;
|
changeLiveChannel: (state: any) => void;
|
||||||
|
startScreenShare: (state: ScreenState) => void;
|
||||||
|
stopScreenShare: () => void;
|
||||||
clearRoomState: () => void;
|
clearRoomState: () => void;
|
||||||
|
|
||||||
// 重连
|
// 重连
|
||||||
@@ -82,6 +88,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||||
const [reconnectFailed, setReconnectFailed] = useState(false);
|
const [reconnectFailed, setReconnectFailed] = useState(false);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// 处理房间删除的回调
|
// 处理房间删除的回调
|
||||||
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
||||||
@@ -119,6 +126,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
|
|
||||||
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
|
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(() => {
|
useEffect(() => {
|
||||||
const checkLoginStatus = () => {
|
const checkLoginStatus = () => {
|
||||||
@@ -156,6 +172,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
roomId: info.roomId,
|
roomId: info.roomId,
|
||||||
password: info.password,
|
password: info.password,
|
||||||
userName: info.userName,
|
userName: info.userName,
|
||||||
|
ownerToken: info.ownerToken,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
|
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
|
||||||
@@ -169,6 +186,19 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (shouldDisableWatchRoomConnection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDisableWatchRoomConnection) {
|
||||||
|
setConfig({
|
||||||
|
enabled: false,
|
||||||
|
serverType: 'internal',
|
||||||
|
});
|
||||||
|
setIsEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
// 使用公共 API 获取观影室配置(不需要管理员权限)
|
// 使用公共 API 获取观影室配置(不需要管理员权限)
|
||||||
@@ -253,12 +283,14 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
|
}, [isLoggedIn, shouldDisableWatchRoomConnection]); // 添加 isLoggedIn 作为依赖
|
||||||
|
|
||||||
// 清理
|
// 仅在 Provider 卸载时断开,避免路由切换时误断开房间连接
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
watchRoom.disconnect();
|
watchRoom.disconnect();
|
||||||
};
|
};
|
||||||
}, [isLoggedIn]); // 添加 isLoggedIn 作为依赖
|
}, []);
|
||||||
|
|
||||||
const contextValue: WatchRoomContextType = {
|
const contextValue: WatchRoomContextType = {
|
||||||
socket: watchRoom.socket,
|
socket: watchRoom.socket,
|
||||||
@@ -281,6 +313,8 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
pause: watchRoom.pause,
|
pause: watchRoom.pause,
|
||||||
changeVideo: watchRoom.changeVideo,
|
changeVideo: watchRoom.changeVideo,
|
||||||
changeLiveChannel: watchRoom.changeLiveChannel,
|
changeLiveChannel: watchRoom.changeLiveChannel,
|
||||||
|
startScreenShare: watchRoom.startScreenShare,
|
||||||
|
stopScreenShare: watchRoom.stopScreenShare,
|
||||||
clearRoomState: watchRoom.clearRoomState,
|
clearRoomState: watchRoom.clearRoomState,
|
||||||
manualReconnect,
|
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,
|
Member,
|
||||||
PlayState,
|
PlayState,
|
||||||
Room,
|
Room,
|
||||||
|
RoomType,
|
||||||
|
ScreenState,
|
||||||
StoredRoomInfo,
|
StoredRoomInfo,
|
||||||
WatchRoomConfig,
|
WatchRoomConfig,
|
||||||
} from '@/types/watch-room';
|
} from '@/types/watch-room';
|
||||||
@@ -28,9 +30,15 @@ export function useWatchRoom(
|
|||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
const [isOwner, setIsOwner] = useState(false);
|
const [isOwner, setIsOwner] = useState(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const rejoinInFlightRef = useRef(false);
|
||||||
|
|
||||||
// 重新加入房间(自动重连)
|
// 重新加入房间(自动重连)
|
||||||
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
|
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
|
||||||
|
if (rejoinInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejoinInFlightRef.current = true;
|
||||||
console.log('[WatchRoom] Auto-rejoining room:', info);
|
console.log('[WatchRoom] Auto-rejoining room:', info);
|
||||||
try {
|
try {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
@@ -62,9 +70,21 @@ export function useWatchRoom(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoom] Failed to rejoin room:', error);
|
console.error('[WatchRoom] Failed to rejoin room:', error);
|
||||||
clearStoredRoomInfo();
|
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) => {
|
const connect = useCallback(async (config: WatchRoomConfig) => {
|
||||||
try {
|
try {
|
||||||
@@ -76,15 +96,13 @@ export function useWatchRoom(
|
|||||||
const storedInfo = getStoredRoomInfo();
|
const storedInfo = getStoredRoomInfo();
|
||||||
if (storedInfo) {
|
if (storedInfo) {
|
||||||
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
|
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
scheduleRejoin(storedInfo);
|
||||||
rejoinRoom(storedInfo);
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoom] Failed to connect:', error);
|
console.error('[WatchRoom] Failed to connect:', error);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
}
|
}
|
||||||
}, [rejoinRoom]);
|
}, [scheduleRejoin]);
|
||||||
|
|
||||||
// 断开连接
|
// 断开连接
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
@@ -97,11 +115,12 @@ export function useWatchRoom(
|
|||||||
setCurrentRoom(null);
|
setCurrentRoom(null);
|
||||||
setMembers([]);
|
setMembers([]);
|
||||||
setChatMessages([]);
|
setChatMessages([]);
|
||||||
|
setIsOwner(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 创建房间
|
// 创建房间
|
||||||
const createRoom = useCallback(
|
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();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||||
throw new Error('Not connected');
|
throw new Error('Not connected');
|
||||||
@@ -140,7 +159,7 @@ export function useWatchRoom(
|
|||||||
|
|
||||||
// 加入房间
|
// 加入房间
|
||||||
const joinRoom = useCallback(
|
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();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||||
throw new Error('Not connected');
|
throw new Error('Not connected');
|
||||||
@@ -160,7 +179,7 @@ export function useWatchRoom(
|
|||||||
isOwner: isRoomOwner,
|
isOwner: isRoomOwner,
|
||||||
userName: data.userName,
|
userName: data.userName,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
ownerToken: isRoomOwner ? response.room.ownerToken : undefined,
|
ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
resolve({ room: response.room, members: response.members });
|
resolve({ room: response.room, members: response.members });
|
||||||
@@ -295,6 +314,25 @@ export function useWatchRoom(
|
|||||||
[isOwner]
|
[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 clearRoomState = useCallback(() => {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
@@ -322,7 +360,11 @@ export function useWatchRoom(
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('room:member-joined', (member) => {
|
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) => {
|
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) => {
|
socket.on('chat:message', (message) => {
|
||||||
setChatMessages((prev) => [...prev, message]);
|
setChatMessages((prev) => [...prev, message]);
|
||||||
@@ -381,6 +436,10 @@ export function useWatchRoom(
|
|||||||
// 连接状态
|
// 连接状态
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
const storedInfo = getStoredRoomInfo();
|
||||||
|
if (storedInfo) {
|
||||||
|
scheduleRejoin(storedInfo);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
@@ -395,12 +454,14 @@ export function useWatchRoom(
|
|||||||
socket.off('play:update');
|
socket.off('play:update');
|
||||||
socket.off('play:change');
|
socket.off('play:change');
|
||||||
socket.off('live:change');
|
socket.off('live:change');
|
||||||
|
socket.off('screen:start');
|
||||||
|
socket.off('screen:stop');
|
||||||
socket.off('chat:message');
|
socket.off('chat:message');
|
||||||
socket.off('state:cleared');
|
socket.off('state:cleared');
|
||||||
socket.off('connect');
|
socket.off('connect');
|
||||||
socket.off('disconnect');
|
socket.off('disconnect');
|
||||||
};
|
};
|
||||||
}, [socket, currentRoom, onRoomDeleted, onStateCleared]);
|
}, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]);
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -431,6 +492,8 @@ export function useWatchRoom(
|
|||||||
pause,
|
pause,
|
||||||
changeVideo,
|
changeVideo,
|
||||||
changeLiveChannel,
|
changeLiveChannel,
|
||||||
|
startScreenShare,
|
||||||
|
stopScreenShare,
|
||||||
clearRoomState,
|
clearRoomState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface AdminConfig {
|
|||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
// 弹幕配置
|
// 弹幕配置
|
||||||
|
DanmakuSourceType?: 'builtin' | 'custom';
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
@@ -42,6 +43,8 @@ export interface AdminConfig {
|
|||||||
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
|
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
|
||||||
// 注册相关配置
|
// 注册相关配置
|
||||||
EnableRegistration?: boolean; // 开启注册
|
EnableRegistration?: boolean; // 开启注册
|
||||||
|
RequireRegistrationInviteCode?: boolean; // 注册时要求邀请码
|
||||||
|
RegistrationInviteCode?: string; // 通用注册邀请码
|
||||||
RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile
|
RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile
|
||||||
LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile
|
LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile
|
||||||
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
||||||
@@ -140,6 +143,15 @@ export interface AdminConfig {
|
|||||||
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
|
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
|
||||||
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
|
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
|
||||||
};
|
};
|
||||||
|
NetDiskConfig?: {
|
||||||
|
Quark?: {
|
||||||
|
Enabled: boolean;
|
||||||
|
Cookie: string;
|
||||||
|
SavePath: string;
|
||||||
|
PlayTempSavePath: string;
|
||||||
|
OpenListTempPath: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
AIConfig?: {
|
AIConfig?: {
|
||||||
Enabled: boolean; // 是否启用AI问片功能
|
Enabled: boolean; // 是否启用AI问片功能
|
||||||
Provider: 'openai' | 'claude' | 'custom'; // AI服务提供商
|
Provider: 'openai' | 'claude' | 'custom'; // AI服务提供商
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const changelog: ChangelogEntry[] = [
|
export const changelog: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "217.0.0",
|
||||||
|
date: "2026-04-10",
|
||||||
|
added: [
|
||||||
|
"新增注册邀请码功能",
|
||||||
|
"增加弹幕内置源",
|
||||||
|
"增加netlify部署支持",
|
||||||
|
"观影室增加屏幕共享功能",
|
||||||
|
"详情面板新增照片墙功能",
|
||||||
|
"新增夸克网盘的转存与播放功能",
|
||||||
|
"豆瓣数据源增加备用源功能"
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"移除音乐功能"
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"修复弹幕选集分组无法鼠标滑轮滚动",
|
||||||
|
"修复高级推荐报错无限刷新"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "216.0.0",
|
version: "216.0.0",
|
||||||
date: "2026-03-30",
|
date: "2026-03-30",
|
||||||
|
|||||||
+80
-3
@@ -4,6 +4,8 @@ import { db } from '@/lib/db';
|
|||||||
|
|
||||||
import { AdminConfig } from './admin.types';
|
import { AdminConfig } from './admin.types';
|
||||||
|
|
||||||
|
const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
|
||||||
|
|
||||||
export interface ApiSite {
|
export interface ApiSite {
|
||||||
key: string;
|
key: string;
|
||||||
api: string;
|
api: string;
|
||||||
@@ -223,6 +225,9 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
cfgFile = {} as ConfigFileStruct;
|
cfgFile = {} as ConfigFileStruct;
|
||||||
}
|
}
|
||||||
|
const hasCustomDanmakuEnv = Boolean(
|
||||||
|
process.env.DANMAKU_API_BASE || process.env.DANMAKU_API_TOKEN
|
||||||
|
);
|
||||||
const adminConfig: AdminConfig = {
|
const adminConfig: AdminConfig = {
|
||||||
ConfigFile: configSource,
|
ConfigFile: configSource,
|
||||||
ConfigSubscribtion: subConfig,
|
ConfigSubscribtion: subConfig,
|
||||||
@@ -245,7 +250,10 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
FluidSearch:
|
FluidSearch:
|
||||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
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',
|
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
||||||
@@ -263,6 +271,14 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
MagnetAcgripReverseProxy: '',
|
MagnetAcgripReverseProxy: '',
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
|
EnableRegistration: false,
|
||||||
|
RequireRegistrationInviteCode: false,
|
||||||
|
RegistrationInviteCode: '',
|
||||||
|
RegistrationRequireTurnstile: false,
|
||||||
|
LoginRequireTurnstile: false,
|
||||||
|
TurnstileSiteKey: '',
|
||||||
|
TurnstileSecretKey: '',
|
||||||
|
DefaultUserTags: [],
|
||||||
},
|
},
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: [],
|
Users: [],
|
||||||
@@ -431,7 +447,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
DoubanImageProxy: '',
|
DoubanImageProxy: '',
|
||||||
DisableYellowFilter: false,
|
DisableYellowFilter: false,
|
||||||
FluidSearch: true,
|
FluidSearch: true,
|
||||||
DanmakuApiBase: 'http://localhost:9321',
|
DanmakuSourceType: 'builtin',
|
||||||
|
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
|
||||||
DanmakuApiToken: '87654321',
|
DanmakuApiToken: '87654321',
|
||||||
PansouApiUrl: '',
|
PansouApiUrl: '',
|
||||||
PansouUsername: '',
|
PansouUsername: '',
|
||||||
@@ -442,11 +459,25 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
MagnetDmhyReverseProxy: '',
|
MagnetDmhyReverseProxy: '',
|
||||||
MagnetAcgripReverseProxy: '',
|
MagnetAcgripReverseProxy: '',
|
||||||
EnableComments: false,
|
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) {
|
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) {
|
if (!adminConfig.SiteConfig.DanmakuApiToken) {
|
||||||
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
||||||
@@ -455,6 +486,30 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (adminConfig.SiteConfig.EnableComments === undefined) {
|
if (adminConfig.SiteConfig.EnableComments === undefined) {
|
||||||
adminConfig.SiteConfig.EnableComments = false;
|
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) {
|
if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) {
|
||||||
adminConfig.SiteConfig.PansouKeywordBlocklist = '';
|
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) {
|
if (!adminConfig.MusicConfig) {
|
||||||
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; // 允许其他字段
|
[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 请求
|
* 带超时的 fetch 请求
|
||||||
*/
|
*/
|
||||||
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
|
|||||||
| 'cors-anywhere'
|
| 'cors-anywhere'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
|
backupProxyType:
|
||||||
|
| 'direct'
|
||||||
|
| 'cors-proxy-zwei'
|
||||||
|
| 'cmliussss-cdn-tencent'
|
||||||
|
| 'cmliussss-cdn-ali'
|
||||||
|
| 'cors-anywhere'
|
||||||
|
| 'custom';
|
||||||
|
backupProxyUrl: string;
|
||||||
} {
|
} {
|
||||||
const doubanProxyType =
|
const doubanProxyType =
|
||||||
localStorage.getItem('doubanDataSource') ||
|
localStorage.getItem('doubanDataSource') ||
|
||||||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
|
|||||||
localStorage.getItem('doubanProxyUrl') ||
|
localStorage.getItem('doubanProxyUrl') ||
|
||||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
(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 {
|
return {
|
||||||
proxyType: doubanProxyType,
|
proxyType: primaryConfig.proxyType,
|
||||||
proxyUrl: doubanProxy,
|
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,
|
list: list,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 触发全局错误提示
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('globalError', {
|
|
||||||
detail: { message: '获取豆瓣分类数据失败' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,25 +363,34 @@ export async function getDoubanCategories(
|
|||||||
params: DoubanCategoriesParams
|
params: DoubanCategoriesParams
|
||||||
): Promise<DoubanResult> {
|
): Promise<DoubanResult> {
|
||||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanCategories(params, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanCategories(params, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(
|
||||||
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
|
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||||
case 'custom':
|
);
|
||||||
return fetchDoubanCategories(params, proxyUrl);
|
if (!response.ok) {
|
||||||
case 'direct':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
default:
|
}
|
||||||
const response = await fetch(
|
return response.json();
|
||||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return response.json();
|
return fetchDoubanCategories(
|
||||||
|
params,
|
||||||
|
requestProxyUrl,
|
||||||
|
useTencentCDN,
|
||||||
|
useAliCDN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dispatchDoubanGlobalError('获取豆瓣分类数据失败');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,25 +405,34 @@ export async function getDoubanList(
|
|||||||
params: DoubanListParams
|
params: DoubanListParams
|
||||||
): Promise<DoubanResult> {
|
): Promise<DoubanResult> {
|
||||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanList(params, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanList(params, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(
|
||||||
return fetchDoubanList(params, 'https://cors-anywhere.com/');
|
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||||
case 'custom':
|
);
|
||||||
return fetchDoubanList(params, proxyUrl);
|
if (!response.ok) {
|
||||||
case 'direct':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
default:
|
}
|
||||||
const response = await fetch(
|
return response.json();
|
||||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return response.json();
|
return fetchDoubanList(
|
||||||
|
params,
|
||||||
|
requestProxyUrl,
|
||||||
|
useTencentCDN,
|
||||||
|
useAliCDN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dispatchDoubanGlobalError('获取豆瓣列表数据失败');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
|
|||||||
list: list,
|
list: list,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 触发全局错误提示
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('globalError', {
|
|
||||||
detail: { message: '获取豆瓣列表数据失败' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
|
|||||||
platform,
|
platform,
|
||||||
sort,
|
sort,
|
||||||
} = params;
|
} = params;
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanRecommends(params, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanRecommends(params, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(
|
||||||
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
|
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||||
case 'custom':
|
);
|
||||||
return fetchDoubanRecommends(params, proxyUrl);
|
if (!response.ok) {
|
||||||
case 'direct':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
default:
|
}
|
||||||
const response = await fetch(
|
return response.json();
|
||||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
}
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
const doubanData: DoubanDetailApiResponse = await response.json();
|
||||||
return doubanData;
|
return doubanData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 触发全局错误提示
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('globalError', {
|
|
||||||
detail: { message: '获取豆瓣详情数据失败' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
|
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
|
|||||||
export async function getDoubanDetail(
|
export async function getDoubanDetail(
|
||||||
id: string
|
id: string
|
||||||
): Promise<DoubanDetailApiResponse> {
|
): Promise<DoubanDetailApiResponse> {
|
||||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
|
||||||
switch (proxyType) {
|
getDoubanProxyConfig();
|
||||||
case 'cors-proxy-zwei':
|
try {
|
||||||
return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/');
|
return await requestDoubanWithFallback(
|
||||||
case 'cmliussss-cdn-tencent':
|
{ proxyType, proxyUrl },
|
||||||
return fetchDoubanDetail(id, '', true, false);
|
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
|
||||||
case 'cmliussss-cdn-ali':
|
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
|
||||||
return fetchDoubanDetail(id, '', false, true);
|
if (useDirectApi) {
|
||||||
case 'cors-anywhere':
|
const response = await fetch(`/api/douban/detail?id=${id}`);
|
||||||
return fetchDoubanDetail(id, 'https://cors-anywhere.com/');
|
if (!response.ok) {
|
||||||
case 'custom':
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
return fetchDoubanDetail(id, proxyUrl);
|
}
|
||||||
case 'direct':
|
return response.json();
|
||||||
default:
|
}
|
||||||
const response = await fetch(`/api/douban/detail?id=${id}`);
|
|
||||||
if (!response.ok) {
|
return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
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 };
|
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 he from 'he';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
|
|
||||||
function getDoubanImageProxyConfig(): {
|
export type DoubanImageProxyType =
|
||||||
proxyType:
|
|
||||||
| 'direct'
|
| 'direct'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'img3'
|
| 'img3'
|
||||||
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
|
|||||||
| 'cmliussss-cdn-ali'
|
| 'cmliussss-cdn-ali'
|
||||||
| 'baidu'
|
| 'baidu'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
|
function normalizeDoubanImageProxyConfig(
|
||||||
|
proxyType: DoubanImageProxyType,
|
||||||
|
proxyUrl: string
|
||||||
|
): {
|
||||||
|
proxyType: DoubanImageProxyType;
|
||||||
proxyUrl: string;
|
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') {
|
if (typeof window === 'undefined') {
|
||||||
return {
|
return {
|
||||||
proxyType: 'cmliussss-cdn-tencent',
|
proxyType: 'cmliussss-cdn-tencent',
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
|
backupProxyType: 'server',
|
||||||
|
backupProxyUrl: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
|
|||||||
localStorage.getItem('doubanImageProxyUrl') ||
|
localStorage.getItem('doubanImageProxyUrl') ||
|
||||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
(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 {
|
return {
|
||||||
proxyType: doubanImageProxyType,
|
proxyType: primaryConfig.proxyType,
|
||||||
proxyUrl: doubanImageProxy,
|
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,根据用户设置使用相应的代理
|
* 处理图片 URL,根据用户设置使用相应的代理
|
||||||
*/
|
*/
|
||||||
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||||
switch (proxyType) {
|
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
|
|||||||
// 在 Node.js 环境中使用 Buffer
|
// 在 Node.js 环境中使用 Buffer
|
||||||
return Buffer.from(bytes).toString('utf-8');
|
return Buffer.from(bytes).toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
const CURRENT_VERSION = '216.0.0';
|
const CURRENT_VERSION = '217.0.0';
|
||||||
|
|
||||||
// 导出当前版本号供其他地方使用
|
// 导出当前版本号供其他地方使用
|
||||||
export { CURRENT_VERSION };
|
export { CURRENT_VERSION };
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export class WatchRoomServer {
|
|||||||
private rooms: Map<string, Room> = new Map();
|
private rooms: Map<string, Room> = new Map();
|
||||||
private members: Map<string, Map<string, Member>> = new Map(); // roomId -> userId -> Member
|
private members: Map<string, Map<string, Member>> = new Map(); // roomId -> userId -> Member
|
||||||
private socketToRoom: Map<string, RoomMemberInfo> = new Map(); // socketId -> RoomMemberInfo
|
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;
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
|
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
|
||||||
@@ -40,6 +42,7 @@ export class WatchRoomServer {
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
|
roomType: data.roomType || 'sync',
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
ownerName: data.userName,
|
ownerName: data.userName,
|
||||||
ownerToken: ownerToken, // 保存房主令牌
|
ownerToken: ownerToken, // 保存房主令牌
|
||||||
@@ -89,15 +92,33 @@ export class WatchRoomServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = socket.id;
|
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 = {
|
const member: Member = {
|
||||||
id: userId,
|
id: userId,
|
||||||
name: data.userName,
|
name: data.userName,
|
||||||
isOwner: false,
|
isOwner,
|
||||||
lastHeartbeat: Date.now(),
|
lastHeartbeat: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const roomMembers = this.members.get(data.roomId);
|
const roomMembers = this.members.get(data.roomId);
|
||||||
if (roomMembers) {
|
if (roomMembers) {
|
||||||
|
if (isOwner) {
|
||||||
|
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
|
||||||
|
if (existingMember.isOwner && memberId !== userId) {
|
||||||
|
roomMembers.delete(memberId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
roomMembers.set(userId, member);
|
roomMembers.set(userId, member);
|
||||||
room.memberCount = roomMembers.size;
|
room.memberCount = roomMembers.size;
|
||||||
this.rooms.set(data.roomId, room);
|
this.rooms.set(data.roomId, room);
|
||||||
@@ -107,7 +128,7 @@ export class WatchRoomServer {
|
|||||||
roomId: data.roomId,
|
roomId: data.roomId,
|
||||||
userId,
|
userId,
|
||||||
userName: data.userName,
|
userName: data.userName,
|
||||||
isOwner: false,
|
isOwner,
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.join(data.roomId);
|
socket.join(data.roomId);
|
||||||
@@ -115,7 +136,7 @@ export class WatchRoomServer {
|
|||||||
// 通知房间内其他成员
|
// 通知房间内其他成员
|
||||||
socket.to(data.roomId).emit('room:member-joined', member);
|
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() || []);
|
const members = Array.from(roomMembers?.values() || []);
|
||||||
callback({ success: true, room, members });
|
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) => {
|
socket.on('chat:message', (data) => {
|
||||||
const roomInfo = this.socketToRoom.get(socket.id);
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
@@ -303,6 +429,19 @@ export class WatchRoomServer {
|
|||||||
// 断开连接
|
// 断开连接
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
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);
|
this.handleLeaveRoom(socket);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -348,6 +487,11 @@ export class WatchRoomServer {
|
|||||||
this.io.to(roomId).emit('room:deleted');
|
this.io.to(roomId).emit('room:deleted');
|
||||||
this.rooms.delete(roomId);
|
this.rooms.delete(roomId);
|
||||||
this.members.delete(roomId);
|
this.members.delete(roomId);
|
||||||
|
const helperSocketId = this.screenHelpers.get(roomId);
|
||||||
|
if (helperSocketId) {
|
||||||
|
this.helperToRoom.delete(helperSocketId);
|
||||||
|
this.screenHelpers.delete(roomId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定时清理房间(房主断开5分钟后删除)
|
// 定时清理房间(房主断开5分钟后删除)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>
|
|||||||
class WatchRoomSocketManager {
|
class WatchRoomSocketManager {
|
||||||
private socket: WatchRoomSocket | null = null;
|
private socket: WatchRoomSocket | null = null;
|
||||||
private config: WatchRoomConfig | null = null;
|
private config: WatchRoomConfig | null = null;
|
||||||
|
private connectionPromise: Promise<WatchRoomSocket> | null = null;
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
|
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
|
||||||
private lastHeartbeatResponse: number = Date.now();
|
private lastHeartbeatResponse: number = Date.now();
|
||||||
@@ -25,6 +26,37 @@ class WatchRoomSocketManager {
|
|||||||
return this.socket;
|
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;
|
this.config = config;
|
||||||
|
|
||||||
const socketOptions = {
|
const socketOptions = {
|
||||||
@@ -72,8 +104,9 @@ class WatchRoomSocketManager {
|
|||||||
// 设置浏览器可见性监听
|
// 设置浏览器可见性监听
|
||||||
this.setupVisibilityListener();
|
this.setupVisibilityListener();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
this.connectionPromise = new Promise((resolve, reject) => {
|
||||||
if (!this.socket) {
|
if (!this.socket) {
|
||||||
|
this.connectionPromise = null;
|
||||||
reject(new Error('Socket not initialized'));
|
reject(new Error('Socket not initialized'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -82,6 +115,7 @@ class WatchRoomSocketManager {
|
|||||||
this.socket.once('connect', () => {
|
this.socket.once('connect', () => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[WatchRoom] Connected to server');
|
console.log('[WatchRoom] Connected to server');
|
||||||
|
this.connectionPromise = null;
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
resolve(this.socket);
|
resolve(this.socket);
|
||||||
}
|
}
|
||||||
@@ -90,9 +124,12 @@ class WatchRoomSocketManager {
|
|||||||
this.socket.once('connect_error', (error) => {
|
this.socket.once('connect_error', (error) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('[WatchRoom] Connection error:', error);
|
console.error('[WatchRoom] Connection error:', error);
|
||||||
|
this.connectionPromise = null;
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.connectionPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
@@ -122,6 +159,8 @@ class WatchRoomSocketManager {
|
|||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.connectionPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSocket(): WatchRoomSocket | null {
|
getSocket(): WatchRoomSocket | null {
|
||||||
|
|||||||
+29
-1
@@ -6,15 +6,18 @@ export interface Room {
|
|||||||
description: string;
|
description: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
roomType: RoomType;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
ownerToken: string; // 房主令牌,用于重连时验证身份
|
ownerToken: string; // 房主令牌,用于重连时验证身份
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
currentState: PlayState | LiveState | null;
|
currentState: PlayState | LiveState | ScreenState | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastOwnerHeartbeat: number;
|
lastOwnerHeartbeat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RoomType = 'sync' | 'screen';
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,6 +45,14 @@ export interface LiveState {
|
|||||||
channelUrl: string;
|
channelUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScreenState {
|
||||||
|
type: 'screen';
|
||||||
|
status: 'idle' | 'sharing';
|
||||||
|
ownerName: string;
|
||||||
|
hasAudio?: boolean;
|
||||||
|
startedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -73,6 +84,12 @@ export interface ServerToClientEvents {
|
|||||||
'play:pause': () => void;
|
'play:pause': () => void;
|
||||||
'play:change': (state: PlayState) => void;
|
'play:change': (state: PlayState) => void;
|
||||||
'live:change': (state: LiveState) => 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;
|
'chat:message': (message: ChatMessage) => void;
|
||||||
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||||
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||||
@@ -90,6 +107,7 @@ export interface ClientToServerEvents {
|
|||||||
description: string;
|
description: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
roomType: RoomType;
|
||||||
userName: string;
|
userName: string;
|
||||||
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
|
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
|
||||||
|
|
||||||
@@ -111,6 +129,16 @@ export interface ClientToServerEvents {
|
|||||||
'play:change': (state: PlayState) => void;
|
'play:change': (state: PlayState) => void;
|
||||||
|
|
||||||
'live:change': (state: LiveState) => 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;
|
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user