Merge branch 'dev'

This commit is contained in:
mtvpls
2026-04-10 20:37:00 +08:00
53 changed files with 4841 additions and 1198 deletions
+3
View File
@@ -63,3 +63,6 @@ public/workbox-*.js.map
*.db
*.db-shm
*.db-wal
# local scripts
scripts/tvbox/
+17
View File
@@ -1,3 +1,20 @@
## [217.0.0] - 2026-04-10
### Added
- 新增注册邀请码功能
- 增加弹幕内置源
- 增加netlify部署支持
- 观影室增加屏幕共享功能
- 详情面板新增照片墙功能
- 新增夸克网盘的转存与播放功能
- 豆瓣数据源增加备用源功能
### Changed
- 移除音乐功能
### Fixed
- 修复弹幕选集分组无法鼠标滑轮滚动
- 修复高级推荐报错无限刷新
## [216.0.0] - 2026-03-30
### Added
- 新增视频源脚本
+3 -1
View File
@@ -88,10 +88,12 @@
## 部署
本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
本项目**支持 Docker、Vercel、Netlify 和 Cloudflare Workers 平台** 部署。
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus)
**一键部署到 Zeabur**
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/SCHCAY/deploy)
+1 -2
View File
@@ -1,2 +1 @@
216.0.0
217.0.0
+137
View File
@@ -32,6 +32,8 @@ class WatchRoomServer {
this.rooms = new Map();
this.members = new Map();
this.socketToRoom = new Map();
this.screenHelpers = new Map();
this.helperToRoom = new Map();
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
this.cleanupInterval = null;
this.setupEventHandlers();
@@ -55,6 +57,7 @@ class WatchRoomServer {
description: data.description,
password: data.password,
isPublic: data.isPublic,
roomType: data.roomType || 'sync',
ownerId: userId,
ownerName: data.userName,
ownerToken: ownerToken, // 保存房主令牌
@@ -131,6 +134,14 @@ class WatchRoomServer {
const roomMembers = this.members.get(data.roomId);
if (roomMembers) {
if (isOwner) {
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
if (existingMember.isOwner && memberId !== userId) {
roomMembers.delete(memberId);
}
});
}
roomMembers.set(userId, member);
room.memberCount = roomMembers.size;
this.rooms.set(data.roomId, room);
@@ -260,6 +271,114 @@ class WatchRoomServer {
}
});
socket.on('screen:helper-register', (data, callback) => {
try {
const room = this.rooms.get(data.roomId);
if (!room) {
callback({ success: false, error: '房间不存在' });
return;
}
if (room.ownerToken !== data.ownerToken) {
callback({ success: false, error: '房主身份验证失败' });
return;
}
const oldHelperSocketId = this.screenHelpers.get(data.roomId);
if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
this.helperToRoom.delete(oldHelperSocketId);
}
this.screenHelpers.set(data.roomId, socket.id);
this.helperToRoom.set(socket.id, data.roomId);
callback({ success: true });
} catch (error) {
console.error('[WatchRoom] Error registering screen helper:', error);
callback({ success: false, error: '注册共享控制窗口失败' });
}
});
// 开始屏幕共享
socket.on('screen:start', (state) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
const roomId = roomInfo?.roomId || helperRoomId;
if (!roomId) return;
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
if (roomInfo && !roomInfo.isOwner) return;
const room = this.rooms.get(roomId);
if (room) {
room.currentState = state;
this.rooms.set(roomId, room);
this.io.to(roomId).emit('screen:start', state);
}
});
// 停止屏幕共享
socket.on('screen:stop', () => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
const roomId = roomInfo?.roomId || helperRoomId;
if (!roomId) return;
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
if (roomInfo && !roomInfo.isOwner) return;
const room = this.rooms.get(roomId);
if (room) {
room.currentState = null;
this.rooms.set(roomId, room);
this.io.to(roomId).emit('screen:stop');
}
});
socket.on('screen:viewer-ready', () => {
const roomInfo = this.socketToRoom.get(socket.id);
if (!roomInfo) return;
const room = this.rooms.get(roomInfo.roomId);
if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
this.io.to(targetSocketId).emit('screen:viewer-ready', {
userId: socket.id,
});
});
// 屏幕共享 WebRTC 信令
socket.on('screen:offer', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
if (!roomInfo && !helperRoomId) return;
this.io.to(data.targetUserId).emit('screen:offer', {
userId: socket.id,
offer: data.offer,
});
});
socket.on('screen:answer', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
if (!roomInfo && !helperRoomId) return;
this.io.to(data.targetUserId).emit('screen:answer', {
userId: socket.id,
answer: data.answer,
});
});
socket.on('screen:ice', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
if (!roomInfo && !helperRoomId) return;
this.io.to(data.targetUserId).emit('screen:ice', {
userId: socket.id,
candidate: data.candidate,
});
});
// 聊天消息
socket.on('chat:message', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
@@ -347,6 +466,19 @@ class WatchRoomServer {
// 断开连接
socket.on('disconnect', () => {
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
const helperRoomId = this.helperToRoom.get(socket.id);
if (helperRoomId) {
this.helperToRoom.delete(socket.id);
if (this.screenHelpers.get(helperRoomId) === socket.id) {
this.screenHelpers.delete(helperRoomId);
const room = this.rooms.get(helperRoomId);
if (room && room.currentState?.type === 'screen') {
room.currentState = null;
this.rooms.set(helperRoomId, room);
this.io.to(helperRoomId).emit('screen:stop');
}
}
}
this.handleLeaveRoom(socket);
});
});
@@ -425,6 +557,11 @@ class WatchRoomServer {
this.rooms.delete(roomId);
this.members.delete(roomId);
const helperSocketId = this.screenHelpers.get(roomId);
if (helperSocketId) {
this.helperToRoom.delete(helperSocketId);
this.screenHelpers.delete(roomId);
}
}
startCleanupTimer() {
+625 -628
View File
@@ -30,13 +30,13 @@ import {
CheckCircle,
ChevronDown,
ChevronUp,
Cloud,
Database,
ExternalLink,
FileText,
FolderOpen,
Globe,
Mail,
Music,
Palette,
Settings,
Tv,
@@ -341,6 +341,7 @@ interface SiteConfig {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
TMDBApiKey?: string;
@@ -358,6 +359,8 @@ interface SiteConfig {
MagnetAcgripReverseProxy?: string;
EnableComments: boolean;
EnableRegistration?: boolean;
RequireRegistrationInviteCode?: boolean;
RegistrationInviteCode?: string;
RegistrationRequireTurnstile?: boolean;
LoginRequireTurnstile?: boolean;
TurnstileSiteKey?: string;
@@ -3496,6 +3499,219 @@ const OpenListConfigComponent = ({
);
};
const NetDiskConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false);
const [cookie, setCookie] = useState('');
const [savePath, setSavePath] = useState('/');
const [playTempSavePath, setPlayTempSavePath] = useState('/');
const [openListTempPath, setOpenListTempPath] = useState('/');
useEffect(() => {
const quark = config?.NetDiskConfig?.Quark;
setEnabled(quark?.Enabled || false);
setCookie(quark?.Cookie || '');
setSavePath(quark?.SavePath || '/');
setPlayTempSavePath(quark?.PlayTempSavePath || '/');
setOpenListTempPath(quark?.OpenListTempPath || '/');
}, [config]);
const handleSave = async () => {
await withLoading('saveNetDisk', async () => {
const response = await fetch('/api/admin/netdisk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'save',
Quark: {
Enabled: enabled,
Cookie: cookie,
SavePath: savePath,
PlayTempSavePath: playTempSavePath,
OpenListTempPath: openListTempPath,
},
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '保存失败');
}
showSuccess('保存成功', showAlert);
await refreshConfig();
});
};
const handleValidate = async () => {
await withLoading('validateNetDisk', async () => {
try {
const response = await fetch('/api/admin/netdisk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'validate',
Quark: {
Cookie: cookie,
SavePath: savePath,
PlayTempSavePath: playTempSavePath,
},
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '校验失败');
}
showSuccess(data.message || '夸克 Cookie 可读', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '校验失败', showAlert);
throw error;
}
});
};
return (
<div className='space-y-6'>
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
</summary>
<div className='mt-4 space-y-4'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center gap-2 mb-2'>
<Cloud className='w-5 h-5 text-blue-600 dark:text-blue-400' />
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
</span>
</div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> </p>
<p> OpenList </p>
<p> OpenList </p>
</div>
</div>
<div className='flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className='sr-only peer'
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cookie
</label>
<textarea
value={cookie}
onChange={(e) => setCookie(e.target.value)}
disabled={!enabled}
rows={5}
placeholder='粘贴夸克网盘 Cookie'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
disabled={!enabled}
placeholder='/影视/正式转存'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
value={playTempSavePath}
onChange={(e) => setPlayTempSavePath(e.target.value)}
disabled={!enabled}
placeholder='/影视/.play-temp'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OpenList
</label>
<input
type='text'
value={openListTempPath}
onChange={(e) => setOpenListTempPath(e.target.value)}
disabled={!enabled}
placeholder='/Quark/影视/.play-temp'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList 访
</p>
</div>
<div className='flex gap-3'>
<button
onClick={handleValidate}
disabled={!enabled || !cookie || isLoading('validateNetDisk')}
className={buttonStyles.primary}
>
{isLoading('validateNetDisk') ? '校验中...' : '校验夸克配置'}
</button>
<button
onClick={handleSave}
disabled={isLoading('saveNetDisk')}
className={buttonStyles.success}
>
{isLoading('saveNetDisk') ? '保存中...' : '保存配置'}
</button>
</div>
</div>
</details>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
onConfirm={alertModal.onConfirm}
/>
</div>
);
};
// Emby 媒体库配置组件 - 多源管理版本
const EmbyConfigComponent = ({
config,
@@ -7555,349 +7771,8 @@ const ThemeConfigComponent = ({
);
};
// 音乐配置组件
const MusicConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [musicSettings, setMusicSettings] = useState({
TuneHubEnabled: false,
TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
TuneHubApiKey: '',
OpenListCacheEnabled: false,
OpenListCacheURL: '',
OpenListCacheUsername: '',
OpenListCachePassword: '',
OpenListCachePath: '/music-cache',
OpenListCacheProxyEnabled: true,
});
// 从配置加载音乐设置
useEffect(() => {
if (config?.MusicConfig) {
setMusicSettings({
TuneHubEnabled: config.MusicConfig.TuneHubEnabled ?? false,
TuneHubBaseUrl: config.MusicConfig.TuneHubBaseUrl ?? 'https://tunehub.sayqz.com/api',
TuneHubApiKey: config.MusicConfig.TuneHubApiKey ?? '',
OpenListCacheEnabled: config.MusicConfig.OpenListCacheEnabled ?? false,
OpenListCacheURL: config.MusicConfig.OpenListCacheURL ?? '',
OpenListCacheUsername: config.MusicConfig.OpenListCacheUsername ?? '',
OpenListCachePassword: config.MusicConfig.OpenListCachePassword ?? '',
OpenListCachePath: config.MusicConfig.OpenListCachePath ?? '/music-cache',
OpenListCacheProxyEnabled: config.MusicConfig.OpenListCacheProxyEnabled ?? true,
});
}
}, [config]);
const handleSave = async () => {
await withLoading('saveMusicConfig', async () => {
try {
const resp = await fetch('/api/admin/music', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...musicSettings }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || '保存失败');
}
showAlert({ type: 'success', title: '保存成功', message: '音乐配置已更新', timer: 2000 });
await refreshConfig();
} catch (error: any) {
showAlert({ type: 'error', title: '保存失败', message: error.message || '未知错误', showConfirm: true });
}
});
};
return (
<div className='space-y-6'>
{/* TuneHub 音乐配置 */}
<div className='space-y-4'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
TuneHub
</h3>
{/* 开启音乐功能 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() =>
setMusicSettings((prev) => ({
...prev,
TuneHubEnabled: !prev.TuneHubEnabled,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
musicSettings.TuneHubEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
musicSettings.TuneHubEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
QQ音乐
</p>
</div>
{/* TuneHub Base URL */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
TuneHub API
</label>
<input
type='text'
placeholder='https://tunehub.sayqz.com/api'
value={musicSettings.TuneHubBaseUrl}
onChange={(e) =>
setMusicSettings((prev) => ({
...prev,
TuneHubBaseUrl: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
TuneHub API https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置
</p>
</div>
{/* TuneHub API Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
TuneHub API Key
</label>
<input
type='password'
placeholder='th_your_api_key_here'
value={musicSettings.TuneHubApiKey}
onChange={(e) =>
setMusicSettings((prev) => ({
...prev,
TuneHubApiKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
API Key Key TUNEHUB_API_KEY
</p>
</div>
</div>
{/* OpenList 缓存配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
OpenList
</h3>
{/* 开启 OpenList 缓存 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OpenList
</label>
<button
type='button'
onClick={() =>
setMusicSettings((prev) => ({
...prev,
OpenListCacheEnabled: !prev.OpenListCacheEnabled,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
musicSettings.OpenListCacheEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
musicSettings.OpenListCacheEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList API 线
</p>
</div>
{/* OpenList URL */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OpenList
</label>
<input
type='text'
placeholder='https://your-openlist-server.com'
value={musicSettings.OpenListCacheURL}
onChange={(e) =>
setMusicSettings((prev) => ({
...prev,
OpenListCacheURL: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList https://your-openlist-server.com
</p>
</div>
{/* OpenList 用户名 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OpenList
</label>
<input
type='text'
placeholder='admin'
value={musicSettings.OpenListCacheUsername}
onChange={(e) =>
setMusicSettings((prev) => ({
...prev,
OpenListCacheUsername: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
</div>
{/* OpenList 密码 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OpenList
</label>
<input
type='password'
placeholder='••••••••'
value={musicSettings.OpenListCachePassword}
onChange={(e) =>
setMusicSettings((prev) => ({
...prev,
OpenListCachePassword: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList 访
</p>
</div>
{/* OpenList 缓存目录 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
placeholder='/music-cache'
value={musicSettings.OpenListCachePath}
onChange={(e) =>
setMusicSettings((prev) => ({
...prev,
OpenListCachePath: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList /music-cache
</p>
</div>
{/* 缓存代理返回开关 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() =>
setMusicSettings((prev) => ({
...prev,
OpenListCacheProxyEnabled: !prev.OpenListCacheProxyEnabled,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
musicSettings.OpenListCacheProxyEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
musicSettings.OpenListCacheProxyEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button
onClick={handleSave}
disabled={isLoading('saveMusicConfig')}
className={`px-4 py-2 ${
isLoading('saveMusicConfig')
? buttonStyles.disabled
: buttonStyles.success
} rounded-lg transition-colors`}
>
{isLoading('saveMusicConfig') ? '保存中…' : '保存'}
</button>
</div>
{/* 弹窗 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// 音乐配置组件(已停用)
// const MusicConfigComponent = (...) => { ... }
// 新增站点配置组件
const SiteConfigComponent = ({
@@ -7921,7 +7796,8 @@ const SiteConfigComponent = ({
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
DanmakuApiBase: 'http://localhost:9321',
DanmakuSourceType: 'builtin',
DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321',
DanmakuApiToken: '87654321',
TMDBApiKey: '',
TMDBProxy: '',
@@ -8016,6 +7892,7 @@ const SiteConfigComponent = ({
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
FluidSearch: config.SiteConfig.FluidSearch || true,
DanmakuSourceType: config.SiteConfig.DanmakuSourceType || 'custom',
DanmakuApiBase:
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
@@ -8565,57 +8442,102 @@ const SiteConfigComponent = ({
</summary>
<div className='mt-4 space-y-4'>
{/* 弹幕 API 地址 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API
</label>
<input
type='text'
placeholder='http://localhost:9321'
value={siteSettings.DanmakuApiBase}
onChange={(e) =>
<div className='inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800'>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
DanmakuApiBase: e.target.value,
DanmakuSourceType: 'builtin',
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
API http://localhost:9321。API部署参考
<a
href='https://github.com/huangxd-/danmu_api.git'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
>
danmu_api
</a>
</p>
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
siteSettings.DanmakuSourceType !== 'custom'
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`}
>
</button>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
DanmakuSourceType: 'custom',
}))
}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
siteSettings.DanmakuSourceType === 'custom'
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`}
>
</button>
</div>
{/* 弹幕 API Token */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API Token
</label>
<input
type='text'
placeholder='87654321'
value={siteSettings.DanmakuApiToken}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DanmakuApiToken: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
访 87654321
{siteSettings.DanmakuSourceType !== 'custom' && (
<p className='text-xs text-amber-600 dark:text-amber-400'>
使使
</p>
</div>
)}
{siteSettings.DanmakuSourceType === 'custom' && (
<>
{/* 弹幕 API 地址 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API
</label>
<input
type='text'
placeholder='http://localhost:9321'
value={siteSettings.DanmakuApiBase}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DanmakuApiBase: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
API API部署参考
<a
href='https://github.com/huangxd-/danmu_api.git'
target='_blank'
rel='noopener noreferrer'
className='ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
>
danmu_api
</a>
</p>
</div>
{/* 弹幕 API Token */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API Token
</label>
<input
type='text'
placeholder='87654321'
value={siteSettings.DanmakuApiToken}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DanmakuApiToken: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
访 87654321
</p>
</div>
</>
)}
</div>
</details>
@@ -9054,6 +8976,8 @@ const RegistrationConfigComponent = ({
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false);
const [registrationSettings, setRegistrationSettings] = useState<{
EnableRegistration: boolean;
RequireRegistrationInviteCode: boolean;
RegistrationInviteCode: string;
RegistrationRequireTurnstile: boolean;
LoginRequireTurnstile: boolean;
TurnstileSiteKey: string;
@@ -9071,6 +8995,8 @@ const RegistrationConfigComponent = ({
OIDCMinTrustLevel: number;
}>({
EnableRegistration: false,
RequireRegistrationInviteCode: false,
RegistrationInviteCode: '',
RegistrationRequireTurnstile: false,
LoginRequireTurnstile: false,
TurnstileSiteKey: '',
@@ -9092,6 +9018,8 @@ const RegistrationConfigComponent = ({
if (config?.SiteConfig) {
setRegistrationSettings({
EnableRegistration: config.SiteConfig.EnableRegistration || false,
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '',
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
@@ -9140,10 +9068,18 @@ const RegistrationConfigComponent = ({
throw new Error('配置未加载');
}
if (
registrationSettings.RequireRegistrationInviteCode &&
!registrationSettings.RegistrationInviteCode.trim()
) {
throw new Error('已开启注册邀请码时,邀请码不能为空');
}
// 合并站点配置和注册配置
const updatedSiteConfig = {
...config.SiteConfig,
...registrationSettings,
RegistrationInviteCode: registrationSettings.RegistrationInviteCode.trim(),
};
const resp = await fetch('/api/admin/site', {
@@ -9182,205 +9118,269 @@ const RegistrationConfigComponent = ({
</h3>
{/* 开启注册 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() => handleRegistrationToggle(!registrationSettings.EnableRegistration)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
registrationSettings.EnableRegistration
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.EnableRegistration
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
<details open className='pt-4 border-t border-gray-200 dark:border-gray-700'>
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
</summary>
<div className='mt-4 space-y-4'>
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() => handleRegistrationToggle(!registrationSettings.EnableRegistration)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
registrationSettings.EnableRegistration
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.EnableRegistration
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
value={registrationSettings.DefaultUserTags && registrationSettings.DefaultUserTags.length > 0 ? registrationSettings.DefaultUserTags[0] : ''}
onChange={(e) => {
const value = e.target.value;
setRegistrationSettings((prev) => ({
...prev,
DefaultUserTags: value ? [value] : [],
}));
}}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
>
<option value=''></option>
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
{tag.enabledApis && tag.enabledApis.length > 0
? ` (${tag.enabledApis.length} 个源)`
: ''}
</option>
))}
</select>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
"无用户组"
</p>
</div>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
</details>
{/* 注册启用Cloudflare Turnstile */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile
</label>
<button
type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
onClick={() =>
setRegistrationSettings((prev) => ({
...prev,
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.RegistrationRequireTurnstile
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.RegistrationRequireTurnstile
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
</summary>
<div className='mt-4 space-y-4'>
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() =>
setRegistrationSettings((prev) => ({
...prev,
RequireRegistrationInviteCode: !prev.RequireRegistrationInviteCode,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
registrationSettings.RequireRegistrationInviteCode
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.RequireRegistrationInviteCode
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
placeholder='请输入通用注册邀请码'
value={registrationSettings.RegistrationInviteCode || ''}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
RegistrationInviteCode: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span>
)}
</p>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 登录启用Cloudflare Turnstile */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile
</label>
<button
type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
onClick={() =>
setRegistrationSettings((prev) => ({
...prev,
LoginRequireTurnstile: !prev.LoginRequireTurnstile,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.LoginRequireTurnstile
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.LoginRequireTurnstile
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile
</label>
<button
type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
onClick={() =>
setRegistrationSettings((prev) => ({
...prev,
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.RegistrationRequireTurnstile
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.RegistrationRequireTurnstile
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span>
)}
</p>
</div>
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile
</label>
<button
type='button'
disabled={!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey}
onClick={() =>
setRegistrationSettings((prev) => ({
...prev,
LoginRequireTurnstile: !prev.LoginRequireTurnstile,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
: registrationSettings.LoginRequireTurnstile
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
registrationSettings.LoginRequireTurnstile
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span>
)}
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile Site Key
</label>
<input
type='text'
placeholder='请输入Cloudflare Turnstile Site Key'
value={registrationSettings.TurnstileSiteKey || ''}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
TurnstileSiteKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
</button>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Dashboard中获取的Site Key
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile Secret Key
</label>
<input
type='password'
placeholder='请输入Cloudflare Turnstile Secret Key'
value={registrationSettings.TurnstileSecretKey || ''}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
TurnstileSecretKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Dashboard中获取的Secret Key
</p>
</div>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
{(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
<span className='text-orange-500 dark:text-orange-400'> Site Key和Secret Key才能启用</span>
)}
</p>
</div>
{/* Cloudflare Turnstile Site Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile Site Key
</label>
<input
type='text'
placeholder='请输入Cloudflare Turnstile Site Key'
value={registrationSettings.TurnstileSiteKey || ''}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
TurnstileSiteKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Dashboard中获取的Site Key
</p>
</div>
{/* Cloudflare Turnstile Secret Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile Secret Key
</label>
<input
type='password'
placeholder='请输入Cloudflare Turnstile Secret Key'
value={registrationSettings.TurnstileSecretKey || ''}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
TurnstileSecretKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Dashboard中获取的Secret Key
</p>
</div>
{/* 默认用户组 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
value={registrationSettings.DefaultUserTags && registrationSettings.DefaultUserTags.length > 0 ? registrationSettings.DefaultUserTags[0] : ''}
onChange={(e) => {
const value = e.target.value;
setRegistrationSettings((prev) => ({
...prev,
DefaultUserTags: value ? [value] : [],
}));
}}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
>
<option value=''></option>
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
{tag.enabledApis && tag.enabledApis.length > 0
? ` (${tag.enabledApis.length} 个源)`
: ''}
</option>
))}
</select>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
"无用户组"
</p>
</div>
</details>
</div>
{/* OIDC配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
OIDC配置
</h3>
{/* 启用OIDC登录 */}
<div>
</summary>
<div className='mt-4 space-y-4'>
{/* 启用OIDC登录 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC登录
@@ -9413,10 +9413,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC登录按钮
</p>
</div>
</div>
{/* 启用OIDC注册 */}
<div>
{/* 启用OIDC注册 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC注册
@@ -9449,10 +9449,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC方式注册新用户OIDC登录
</p>
</div>
</div>
{/* OIDC Issuer */}
<div>
{/* OIDC Issuer */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Issuer URL
</label>
@@ -9514,10 +9514,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC提供商的Issuer URL"自动发现"
</p>
</div>
</div>
{/* Authorization Endpoint */}
<div>
{/* Authorization Endpoint */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Authorization Endpoint
</label>
@@ -9536,10 +9536,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
URL
</p>
</div>
</div>
{/* Token Endpoint */}
<div>
{/* Token Endpoint */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Token EndpointToken端点
</label>
@@ -9558,10 +9558,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
token的端点URL
</p>
</div>
</div>
{/* UserInfo Endpoint */}
<div>
{/* UserInfo Endpoint */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
UserInfo Endpoint
</label>
@@ -9580,10 +9580,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
URL
</p>
</div>
</div>
{/* OIDC Client ID */}
<div>
{/* OIDC Client ID */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Client ID
</label>
@@ -9602,10 +9602,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC提供商处注册应用后获得的Client ID
</p>
</div>
</div>
{/* OIDC Client Secret */}
<div>
{/* OIDC Client Secret */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Client Secret
</label>
@@ -9624,10 +9624,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC提供商处注册应用后获得的Client Secret
</p>
</div>
</div>
{/* OIDC Redirect URI - 只读显示 */}
<div>
{/* OIDC Redirect URI - 只读显示 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC Redirect URI
</label>
@@ -9657,10 +9657,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
SITE_BASEOIDC提供商KeycloakAuth0等URI
</p>
</div>
</div>
{/* OIDC登录按钮文字 */}
<div>
{/* OIDC登录按钮文字 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
OIDC登录按钮文字
</label>
@@ -9679,10 +9679,10 @@ const RegistrationConfigComponent = ({
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC登录按钮显示的文字,"使用企业账号登录""使用SSO登录""使用OIDC登录"
</p>
</div>
</div>
{/* OIDC最低信任等级 */}
<div>
{/* OIDC最低信任等级 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
@@ -9700,11 +9700,12 @@ const RegistrationConfigComponent = ({
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
LinuxDo网站有效01-4
</p>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
LinuxDo网站有效01-4
</p>
</div>
</div>
</div>
</details>
{/* 操作按钮 */}
<div className='flex justify-end'>
@@ -12603,6 +12604,7 @@ function AdminPageClient() {
sourceScriptLab: false,
mediaLibrary: false,
openListConfig: false,
netDiskConfig: false,
embyConfig: false,
xiaoyaConfig: false,
animeSubscription: false,
@@ -12610,7 +12612,6 @@ function AdminPageClient() {
liveSource: false,
webLive: false,
siteConfig: false,
musicConfig: false,
registrationConfig: false,
categoryConfig: false,
configFile: false,
@@ -12949,21 +12950,6 @@ function AdminPageClient() {
/>
</CollapsibleTab>
{/* 音乐配置标签 */}
<CollapsibleTab
title='音乐配置'
icon={
<Music
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.musicConfig}
onToggle={() => toggleTab('musicConfig')}
>
<MusicConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 视频源配置标签 */}
<CollapsibleTab
title='视频源配置'
@@ -13081,6 +13067,17 @@ function AdminPageClient() {
>
<AnimeSubscriptionComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
<CollapsibleTab
title='网盘配置'
icon={
<Cloud size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.netDiskConfig}
onToggle={() => toggleTab('netDiskConfig')}
>
<NetDiskConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
</div>
</CollapsibleTab>
+3 -2
View File
@@ -110,6 +110,7 @@ export default function AdvancedRecommendationPage() {
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
} catch (err) {
setError(err instanceof Error ? err.message : '获取推荐失败');
setHasMore(false);
} finally {
setIsLoadingVideos(false);
}
@@ -119,7 +120,7 @@ export default function AdvancedRecommendationPage() {
}, [selectedSource, page]);
useEffect(() => {
if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
if (!loadMoreRef.current || !hasMore || isLoadingVideos || !!error) return;
const observer = new IntersectionObserver(
(entries) => {
@@ -132,7 +133,7 @@ export default function AdvancedRecommendationPage() {
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [hasMore, isLoadingVideos]);
}, [error, hasMore, isLoadingVideos]);
return (
<PageLayout activePath='/advanced-recommendation'>
+85
View File
@@ -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 }
);
}
}
+14
View File
@@ -39,6 +39,7 @@ export async function POST(request: NextRequest) {
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
DanmakuSourceType,
DanmakuApiBase,
DanmakuApiToken,
TMDBApiKey,
@@ -58,6 +59,8 @@ export async function POST(request: NextRequest) {
CustomAdFilterCode,
CustomAdFilterVersion,
EnableRegistration,
RequireRegistrationInviteCode,
RegistrationInviteCode,
RegistrationRequireTurnstile,
LoginRequireTurnstile,
TurnstileSiteKey,
@@ -84,6 +87,7 @@ export async function POST(request: NextRequest) {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
TMDBApiKey?: string;
@@ -103,6 +107,8 @@ export async function POST(request: NextRequest) {
CustomAdFilterCode?: string;
CustomAdFilterVersion?: number;
EnableRegistration?: boolean;
RequireRegistrationInviteCode?: boolean;
RegistrationInviteCode?: string;
RegistrationRequireTurnstile?: boolean;
LoginRequireTurnstile?: boolean;
TurnstileSiteKey?: string;
@@ -132,6 +138,9 @@ export async function POST(request: NextRequest) {
typeof DoubanImageProxy !== 'string' ||
typeof DisableYellowFilter !== 'boolean' ||
typeof FluidSearch !== 'boolean' ||
(DanmakuSourceType !== undefined &&
DanmakuSourceType !== 'builtin' &&
DanmakuSourceType !== 'custom') ||
typeof DanmakuApiBase !== 'string' ||
typeof DanmakuApiToken !== 'string' ||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
@@ -148,6 +157,8 @@ export async function POST(request: NextRequest) {
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
(RequireRegistrationInviteCode !== undefined && typeof RequireRegistrationInviteCode !== 'boolean') ||
(RegistrationInviteCode !== undefined && typeof RegistrationInviteCode !== 'string') ||
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
@@ -189,6 +200,7 @@ export async function POST(request: NextRequest) {
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
DanmakuSourceType,
DanmakuApiBase,
DanmakuApiToken,
TMDBApiKey,
@@ -208,6 +220,8 @@ export async function POST(request: NextRequest) {
CustomAdFilterCode,
CustomAdFilterVersion,
EnableRegistration,
RequireRegistrationInviteCode,
RegistrationInviteCode,
RegistrationRequireTurnstile,
LoginRequireTurnstile,
TurnstileSiteKey,
+2 -7
View File
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -50,13 +51,7 @@ export async function GET(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
// 构建 API URL
const baseUrl =
DanmakuApiToken === '87654321'
? DanmakuApiBase
: `${DanmakuApiBase}/${DanmakuApiToken}`;
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
let apiUrl: string;
+2 -7
View File
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -28,13 +29,7 @@ export async function GET(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
// 构建 API URL
const baseUrl =
DanmakuApiToken === '87654321'
? DanmakuApiBase
: `${DanmakuApiBase}/${DanmakuApiToken}`;
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
+2 -7
View File
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -25,13 +26,7 @@ export async function POST(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
// 构建 API URL
const baseUrl =
DanmakuApiToken === '87654321'
? DanmakuApiBase
: `${DanmakuApiBase}/${DanmakuApiToken}`;
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
const apiUrl = `${baseUrl}/api/v2/match`;
+2 -7
View File
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -24,13 +25,7 @@ export async function GET(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
// 构建 API URL
const baseUrl =
DanmakuApiToken === '87654321'
? DanmakuApiBase
: `${DanmakuApiBase}/${DanmakuApiToken}`;
const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
const apiUrl = `${baseUrl}/api/v2/search/anime?keyword=${encodeURIComponent(keyword)}`;
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { createQuarkInstantPlayFolder } from '@/lib/netdisk/quark.client';
import { base58Encode } from '@/lib/utils';
export const runtime = 'nodejs';
function joinPath(...parts: string[]) {
const joined = parts
.filter(Boolean)
.join('/')
.replace(/\/+/g, '/');
return joined.startsWith('/') ? joined : `/${joined}`;
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const { shareUrl, passcode, title } = await request.json();
if (!shareUrl) {
return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
}
const config = await getConfig();
const quarkConfig = config.NetDiskConfig?.Quark;
if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
return NextResponse.json({ error: '夸克网盘未配置或未启用' }, { status: 400 });
}
const result = await createQuarkInstantPlayFolder(quarkConfig.Cookie, {
shareUrl,
passcode,
playTempSavePath: quarkConfig.PlayTempSavePath,
title,
});
if (!result.folderName) {
throw new Error('未生成临时播放目录');
}
const openlistFolderPath = joinPath(
quarkConfig.OpenListTempPath,
result.folderName
);
if (
config.OpenListConfig?.Enabled &&
config.OpenListConfig.URL &&
config.OpenListConfig.Username &&
config.OpenListConfig.Password
) {
try {
const { OpenListClient } = await import('@/lib/openlist.client');
const openListClient = new OpenListClient(
config.OpenListConfig.URL,
config.OpenListConfig.Username,
config.OpenListConfig.Password
);
await openListClient.refreshDirectory(quarkConfig.OpenListTempPath || '/');
await openListClient.refreshDirectory(openlistFolderPath);
} catch (refreshError) {
console.warn('[quark instant-play] 刷新 OpenList 临时目录失败:', refreshError);
}
}
return NextResponse.json({
success: true,
source: 'quark-temp',
id: base58Encode(openlistFolderPath),
title: title || result.folderName,
openlistFolderPath,
...result,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '立即播放失败' },
{ status: 500 }
);
}
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { transferQuarkShare } from '@/lib/netdisk/quark.client';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const { shareUrl, passcode } = await request.json();
if (!shareUrl) {
return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
}
const config = await getConfig();
const quarkConfig = config.NetDiskConfig?.Quark;
if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
return NextResponse.json({ error: '夸克网盘未配置或未启用' }, { status: 400 });
}
const result = await transferQuarkShare(quarkConfig.Cookie, {
shareUrl,
passcode,
savePath: quarkConfig.SavePath,
});
return NextResponse.json({
success: true,
...result,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '转存失败' },
{ status: 500 }
);
}
}
+10 -3
View File
@@ -168,11 +168,18 @@ export async function GET(request: NextRequest) {
throw new Error('未找到已完成的播放链接');
}
// 如果指定了 format=json,返回 JSON 格式
// 如果指定了 format=json尝试解析到最终直链后再返回 JSON
if (format === 'json') {
const resolvedQualities = await Promise.all(
qualities.map(async (quality: any) => ({
...quality,
url: await getFinalUrl(quality.url),
}))
);
return NextResponse.json({
url: qualities[0].url,
qualities
url: resolvedQualities[0].url,
qualities: resolvedQualities,
});
}
+21 -1
View File
@@ -60,7 +60,7 @@ export async function POST(req: NextRequest) {
);
}
const { username, password, turnstileToken } = await req.json();
const { username, password, inviteCode, turnstileToken } = await req.json();
// 验证输入
if (!username || typeof username !== 'string') {
@@ -69,6 +69,9 @@ export async function POST(req: NextRequest) {
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
if (inviteCode !== undefined && typeof inviteCode !== 'string') {
return NextResponse.json({ error: '邀请码格式错误' }, { status: 400 });
}
// 验证用户名格式(只允许字母、数字、下划线,长度3-20)
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
@@ -94,6 +97,23 @@ export async function POST(req: NextRequest) {
);
}
if (siteConfig.RequireRegistrationInviteCode) {
const expectedInviteCode = (siteConfig.RegistrationInviteCode || '').trim();
if (!expectedInviteCode) {
return NextResponse.json(
{ error: '服务器未配置邀请码' },
{ status: 500 }
);
}
if (!inviteCode || inviteCode.trim() !== expectedInviteCode) {
return NextResponse.json(
{ error: '邀请码错误' },
{ status: 400 }
);
}
}
// 获取用户名锁,防止并发注册
let releaseLock: (() => void) | null = null;
try {
+1
View File
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest) {
WatchRoom: watchRoomConfig,
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
EnableRegistration: config.SiteConfig.EnableRegistration || false,
RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
+139 -5
View File
@@ -29,6 +29,7 @@ export async function GET(request: NextRequest) {
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
const title = searchParams.get('title');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
@@ -274,6 +275,141 @@ export async function GET(request: NextRequest) {
}
}
if (sourceCode === 'quark-temp') {
try {
const config = await getConfig();
const openListConfig = config.OpenListConfig;
if (
!openListConfig ||
!openListConfig.Enabled ||
!openListConfig.URL ||
!openListConfig.Username ||
!openListConfig.Password
) {
throw new Error('OpenList 未配置或未启用');
}
const { base58Decode } = await import('@/lib/utils');
const { OpenListClient } = await import('@/lib/openlist.client');
const { parseVideoFileName } = await import('@/lib/video-parser');
const folderPath = base58Decode(id);
if (!folderPath) {
throw new Error('无效的临时播放目录');
}
const client = new OpenListClient(
openListConfig.URL,
openListConfig.Username,
openListConfig.Password
);
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
const listTempDirectory = async (currentPath: string, page: number, pageSize: number) => {
const load = async (refresh = false) => client.listDirectory(currentPath, page, pageSize, refresh);
let response = await load(page === 1);
if (response.code === 200) {
return response;
}
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
await client.refreshDirectory(parentPath);
response = await load(true);
if (response.code !== 200) {
const message = response.message || '目录不存在或 OpenList 路径未映射';
throw new Error(`读取临时目录失败: ${message}(路径: ${currentPath}`);
}
return response;
};
const collectFiles = async (currentPath: string): Promise<Array<{ path: string; name: string }>> => {
const allFiles: Array<{ path: string; name: string }> = [];
let currentPage = 1;
const pageSize = 100;
let hasMore = true;
while (hasMore) {
const response = await listTempDirectory(currentPath, currentPage, pageSize);
for (const item of response.data.content) {
const itemPath = `${currentPath}${currentPath.endsWith('/') ? '' : '/'}${item.name}`;
if (item.is_dir) {
const nested = await collectFiles(itemPath);
allFiles.push(...nested);
} else if (
!item.name.startsWith('.') &&
videoExtensions.some((ext) => item.name.toLowerCase().endsWith(ext))
) {
allFiles.push({
path: itemPath,
name: item.name,
});
}
}
hasMore = !(
response.data.content.length < pageSize ||
currentPage * pageSize >= response.data.total
);
currentPage += 1;
}
return allFiles;
};
const files = await collectFiles(folderPath);
if (files.length === 0) {
throw new Error('临时播放目录中没有视频文件');
}
const episodes = files
.map((file, index) => {
const parsed = parseVideoFileName(file.name);
const fileDir = file.path.substring(0, file.path.lastIndexOf('/')) || '/';
return {
fileName: file.name,
fileDir,
episode: parsed.episode || index + 1,
title:
parsed.title ||
(parsed.episode ? `${parsed.episode}` : file.name),
isOVA: parsed.isOVA,
};
})
.sort((a, b) => {
if (a.isOVA && !b.isOVA) return 1;
if (!a.isOVA && b.isOVA) return -1;
return a.episode !== b.episode
? a.episode - b.episode
: a.fileName.localeCompare(b.fileName);
});
return NextResponse.json({
source: 'quark-temp',
source_name: '夸克临时播放',
id,
title: title || folderPath.split('/').filter(Boolean).pop() || '夸克临时播放',
poster: '',
year: '',
douban_id: 0,
desc: `临时播放目录:${folderPath}`,
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(ep.fileDir)}&fileName=${encodeURIComponent(ep.fileName)}`),
episodes_titles: episodes.map((ep) => ep.title),
proxyMode: false,
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// 特殊处理 openlist 源 - 直接调用 /api/detail
if (sourceCode === 'openlist') {
try {
@@ -340,8 +476,9 @@ export async function GET(request: NextRequest) {
let currentPage = 1;
const pageSize = 100;
let total = 0;
let hasMore = true;
while (true) {
while (hasMore) {
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
if (listResponse.code !== 200) {
@@ -351,10 +488,7 @@ export async function GET(request: NextRequest) {
total = listResponse.data.total;
allFiles.push(...listResponse.data.content);
if (allFiles.length >= total) {
break;
}
hasMore = allFiles.length < total;
currentPage++;
}
+105
View File
@@ -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 }
);
}
}
+3
View File
@@ -75,6 +75,7 @@ export default async function RootLayout({
let progressThumbPresetId = '';
let progressThumbCustomUrl = '';
let enableRegistration = false;
let requireRegistrationInviteCode = false;
let loginRequireTurnstile = false;
let registrationRequireTurnstile = false;
let turnstileSiteKey = '';
@@ -125,6 +126,7 @@ export default async function RootLayout({
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
enableRegistration = config.SiteConfig.EnableRegistration || false;
requireRegistrationInviteCode = config.SiteConfig.RequireRegistrationInviteCode || false;
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false;
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
@@ -195,6 +197,7 @@ export default async function RootLayout({
PROGRESS_THUMB_PRESET_ID: progressThumbPresetId,
PROGRESS_THUMB_CUSTOM_URL: progressThumbCustomUrl,
ENABLE_REGISTRATION: enableRegistration,
REQUIRE_REGISTRATION_INVITE_CODE: requireRegistrationInviteCode,
LOGIN_REQUIRE_TURNSTILE: loginRequireTurnstile,
REGISTRATION_REQUIRE_TURNSTILE: registrationRequireTurnstile,
TURNSTILE_SITE_KEY: turnstileSiteKey,
+14 -21
View File
@@ -2,7 +2,7 @@
'use client';
import { Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@@ -66,7 +66,6 @@ function HomeClient() {
const [aiEnabled, setAiEnabled] = useState(false);
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
const [musicEnabled, setMusicEnabled] = useState(false);
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
const [directPlayUrl, setDirectPlayUrl] = useState('');
@@ -149,14 +148,6 @@ function HomeClient() {
}
}, []);
// 检查音乐功能是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
const enabled = (window as any).RUNTIME_CONFIG?.TUNEHUB_ENABLED === true;
setMusicEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -613,17 +604,19 @@ function HomeClient() {
<LinkIcon size={18} />
</button>
{/* 音乐视听入口 */}
{musicEnabled && (
<Link href='/music'>
<button
className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
title='音乐视听'
>
<Music size={20} />
</button>
</Link>
)}
{/* 音乐视听入口(暂时隐藏,后续可能恢复) */}
{/**
* {musicEnabled && (
* <Link href='/music'>
* <button
* className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
* title='音乐视听'
* >
* <Music size={20} />
* </button>
* </Link>
* )}
*/}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
+149 -41
View File
@@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch';
import ProxyImage from '@/components/ProxyImage';
import { useSite } from '@/components/SiteProvider';
import SmartRecommendations from '@/components/SmartRecommendations';
import Toast, { ToastProps } from '@/components/Toast';
@@ -572,6 +573,13 @@ function PlayPageClient() {
// 纠错后的描述信息(用于显示,不触发 detail 更新)
const [correctedDesc, setCorrectedDesc] = useState<string>('');
const [quarkTempTMDBMeta, setQuarkTempTMDBMeta] = useState<{
desc?: string;
poster?: string;
year?: string;
tmdbId?: number;
} | null>(null);
const [pendingQuarkTempTMDBData, setPendingQuarkTempTMDBData] = useState<any | null>(null);
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby'
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
@@ -579,6 +587,11 @@ function PlayPageClient() {
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
const isDirectPlay = currentSource === 'directplay';
useEffect(() => {
setQuarkTempTMDBMeta(null);
setPendingQuarkTempTMDBData(null);
}, [currentSource, currentId]);
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
if (source.startsWith('emby_')) {
@@ -1187,14 +1200,18 @@ function PlayPageClient() {
const detCacheAge = Date.now() - detTimestamp;
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
console.log('使用缓存的TMDB详情数据');
setTmdbBackdrop(processImageUrl(data.backdrop));
if (detCacheAge < detCacheMaxAge && data) {
if (data.backdrop) {
setTmdbBackdrop(processImageUrl(data.backdrop));
} else {
setTmdbBackdrop(null);
}
// 如果没有豆瓣ID,使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(data);
}
populatePlayMetadataFromTMDB(data);
return;
}
} catch (e) {
@@ -1215,7 +1232,6 @@ function PlayPageClient() {
const response = await fetch(url);
if (!response.ok) {
console.log('获取TMDB详情失败');
setTmdbBackdrop(null);
return;
}
@@ -1224,45 +1240,94 @@ function PlayPageClient() {
if (result.backdrop) {
setTmdbBackdrop(processImageUrl(result.backdrop));
// 如果没有豆瓣ID,使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(result);
}
// 保存title到tmdbId的映射到localStorage1个月)
if (result.tmdbId) {
try {
localStorage.setItem(
mappingCacheKey,
JSON.stringify({
tmdbId: result.tmdbId,
timestamp: Date.now(),
})
);
// 保存TMDB详情数据到localStorage1天)
const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
localStorage.setItem(
detailsCacheKey,
JSON.stringify({
data: result,
timestamp: Date.now(),
})
);
} catch (e) {
console.error('保存缓存失败:', e);
}
}
} else {
setTmdbBackdrop(null);
}
// 如果没有豆瓣ID,使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(result);
}
populatePlayMetadataFromTMDB(result);
// 保存title到tmdbId的映射到localStorage1个月)
if (result.tmdbId) {
try {
localStorage.setItem(
mappingCacheKey,
JSON.stringify({
tmdbId: result.tmdbId,
timestamp: Date.now(),
})
);
// 保存TMDB详情数据到localStorage1天)
const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
localStorage.setItem(
detailsCacheKey,
JSON.stringify({
data: result,
timestamp: Date.now(),
})
);
} catch (e) {
console.error('保存缓存失败:', e);
}
}
} catch (error) {
console.error('获取TMDB背景图失败:', error);
setTmdbBackdrop(null);
}
};
const populatePlayMetadataFromTMDB = (tmdbData: any) => {
const currentDetail = detailRef.current;
if (!currentDetail || currentDetail.source !== 'quark-temp') {
setPendingQuarkTempTMDBData(tmdbData);
return;
}
const tmdbYear = tmdbData.releaseDate?.split('-')[0] || '';
const shouldReplaceDesc = !currentDetail.desc || currentDetail.desc.startsWith('临时播放目录:');
const resolvedTmdbId = typeof tmdbData.tmdbId === 'string'
? Number(String(tmdbData.tmdbId).split(':')[1] || 0)
: tmdbData.tmdbId;
setQuarkTempTMDBMeta({
desc: shouldReplaceDesc ? (tmdbData.overview || currentDetail.desc) : currentDetail.desc,
poster: currentDetail.poster || tmdbData.poster || '',
year: currentDetail.year || tmdbYear,
tmdbId: currentDetail.tmdb_id || resolvedTmdbId,
});
setDetail((prev) => {
if (!prev || prev.source !== 'quark-temp') {
return prev;
}
return {
...prev,
poster: prev.poster || tmdbData.poster || '',
year: prev.year || tmdbYear,
desc: shouldReplaceDesc ? (tmdbData.overview || prev.desc) : prev.desc,
tmdb_id: prev.tmdb_id || resolvedTmdbId,
};
});
if (tmdbData.overview && (!correctedDesc || currentDetail.desc?.startsWith('临时播放目录:'))) {
setCorrectedDesc(tmdbData.overview);
}
if (tmdbData.poster && !currentDetail.poster) {
setVideoCover(processImageUrl(tmdbData.poster));
}
if (tmdbYear && !currentDetail.year) {
setVideoYear(tmdbYear);
}
};
// 辅助函数:使用TMDb数据填充豆瓣字段
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
// 设置评分
@@ -1296,6 +1361,45 @@ function PlayPageClient() {
fetchTMDBBackdrop();
}, [videoTitle, videoDoubanId, isDirectPlay]);
useEffect(() => {
if (
pendingQuarkTempTMDBData &&
detail?.source === 'quark-temp'
) {
const pending = pendingQuarkTempTMDBData;
setPendingQuarkTempTMDBData(null);
const tmdbYear = pending.releaseDate?.split('-')[0] || '';
const shouldReplaceDesc = !detail.desc || detail.desc.startsWith('临时播放目录:');
const resolvedTmdbId = typeof pending.tmdbId === 'string'
? Number(String(pending.tmdbId).split(':')[1] || 0)
: pending.tmdbId;
setQuarkTempTMDBMeta({
desc: shouldReplaceDesc ? (pending.overview || detail.desc) : detail.desc,
poster: detail.poster || pending.poster || '',
year: detail.year || tmdbYear,
tmdbId: detail.tmdb_id || resolvedTmdbId,
});
setDetail((prev) => prev && prev.source === 'quark-temp' ? {
...prev,
poster: prev.poster || pending.poster || '',
year: prev.year || tmdbYear,
desc: shouldReplaceDesc ? (pending.overview || prev.desc) : prev.desc,
tmdb_id: prev.tmdb_id || resolvedTmdbId,
} : prev);
if (pending.poster && !detail.poster) {
setVideoCover(processImageUrl(pending.poster));
}
if (tmdbYear && !detail.year) {
setVideoYear(tmdbYear);
}
if (pending.overview) {
setCorrectedDesc(pending.overview);
}
}
}, [pendingQuarkTempTMDBData, detail]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
@@ -1413,6 +1517,7 @@ function PlayPageClient() {
!isM3u8LikeUrl(videoUrl) &&
(
detail.source === 'openlist' ||
detail.source === 'quark-temp' ||
detail.source === 'xiaoya' ||
detail.source.startsWith('emby')
)
@@ -8871,12 +8976,12 @@ function PlayPageClient() {
</span>
)}
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
{(doubanYear || detail?.year || videoYear) && (
<span>{doubanYear || detail?.year || videoYear}</span>
{(doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear) && (
<span>{doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear}</span>
)}
{detail?.source_name && (
<span
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'quark-temp' ? 'border-purple-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
}`}
onClick={fetchCurrentSourceVideoInfo}
>
@@ -8896,7 +9001,7 @@ function PlayPageClient() {
{detail?.type_name && <span>{detail.type_name}</span>}
</div>
{/* 剧情简介 */}
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
{(doubanCardSubtitle || quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc) && (
<div
className={`mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide ${tmdbBackdrop ? 'text-white' : ''}`}
style={{ whiteSpace: 'pre-line' }}
@@ -8907,7 +9012,7 @@ function PlayPageClient() {
{doubanCardSubtitle}
</div>
)}
{correctedDesc || detail?.desc}
{quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc}
</div>
)}
</div>
@@ -8919,8 +9024,8 @@ function PlayPageClient() {
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? (
<>
<img
src={processImageUrl(videoCover)}
<ProxyImage
originalSrc={videoCover}
alt={videoTitle}
className='w-full h-full object-cover'
/>
@@ -9161,6 +9266,7 @@ function PlayPageClient() {
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId
// 如果有豆瓣ID且不为0,传入doubanId
detail.source === 'openlist' ||
detail.source === 'quark-temp' ||
detail.source?.startsWith('emby') ||
detail.source === 'xiaoya'
? undefined
@@ -9171,6 +9277,7 @@ function PlayPageClient() {
tmdbId={
// 特殊源使用 tmdb
detail.source === 'openlist' ||
detail.source === 'quark-temp' ||
detail.source?.startsWith('emby') ||
detail.source === 'xiaoya'
? detail.tmdb_id
@@ -9182,6 +9289,7 @@ function PlayPageClient() {
// 非特殊源使用 cms 数据
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
detail.source !== 'openlist' &&
detail.source !== 'quark-temp' &&
!detail.source?.startsWith('emby') &&
detail.source !== 'xiaoya' &&
!(detail.douban_id && detail.douban_id !== 0)
+30
View File
@@ -73,6 +73,7 @@ function RegisterPageClient() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -108,6 +109,7 @@ function RegisterPageClient() {
// 设置站点配置
const config = {
EnableRegistration: runtimeConfig?.ENABLE_REGISTRATION || false,
RequireRegistrationInviteCode: runtimeConfig?.REQUIRE_REGISTRATION_INVITE_CODE || false,
RegistrationRequireTurnstile: runtimeConfig?.REGISTRATION_REQUIRE_TURNSTILE || false,
TurnstileSiteKey: runtimeConfig?.TURNSTILE_SITE_KEY || '',
};
@@ -167,6 +169,11 @@ function RegisterPageClient() {
return;
}
if (siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) {
setError('请输入邀请码');
return;
}
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
@@ -191,6 +198,7 @@ function RegisterPageClient() {
body: JSON.stringify({
username,
password,
inviteCode: siteConfig?.RequireRegistrationInviteCode ? inviteCode.trim() : undefined,
turnstileToken: siteConfig?.RegistrationRequireTurnstile ? turnstileToken : undefined,
}),
});
@@ -340,6 +348,27 @@ function RegisterPageClient() {
</div>
</div>
{siteConfig?.RequireRegistrationInviteCode && (
<div>
<label htmlFor='inviteCode' className='sr-only'>
</label>
<div className='relative'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
<User className='h-5 w-5 text-gray-400 dark:text-gray-500' />
</div>
<input
id='inviteCode'
type='text'
className='block w-full rounded-lg border-0 py-3 pl-10 pr-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
placeholder='输入邀请码'
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
/>
</div>
</div>
)}
{/* Cloudflare Turnstile */}
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
<div id='turnstile-container' className='flex justify-center'></div>
@@ -354,6 +383,7 @@ function RegisterPageClient() {
type='submit'
disabled={
!username || !password || !confirmPassword || loading ||
(siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) ||
(siteConfig?.RegistrationRequireTurnstile && !turnstileToken)
}
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
+3 -3
View File
@@ -38,6 +38,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
import ImageViewer from '@/components/ImageViewer';
import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch';
import ProxyImage from '@/components/ProxyImage';
import SearchResultFilter, {
SearchFilterCategory,
} from '@/components/SearchResultFilter';
@@ -829,9 +830,8 @@ function SearchPageClient() {
>
<div className='flex items-start gap-4'>
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={processImageUrl(item.poster)}
<ProxyImage
originalSrc={item.poster}
alt={item.title}
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
loading='lazy'
+164 -22
View File
@@ -8,12 +8,41 @@ import { useEffect,useState } from 'react';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import PageLayout from '@/components/PageLayout';
import Toast, { ToastProps } from '@/components/Toast';
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
import type { Room } from '@/types/watch-room';
import type { Room, RoomType } from '@/types/watch-room';
type TabType = 'create' | 'join' | 'list';
function getScreenShareHostSupportError() {
if (typeof window === 'undefined') return null;
if (!window.isSecureContext) {
return '当前环境不是安全上下文(HTTPS/localhost),不支持屏幕共享';
}
if (!navigator.mediaDevices?.getDisplayMedia) {
return '当前浏览器不支持屏幕共享';
}
if (typeof window.RTCPeerConnection === 'undefined') {
return '当前浏览器不支持实时屏幕传输';
}
return null;
}
function getScreenShareViewerSupportError() {
if (typeof window === 'undefined') return null;
if (typeof window.RTCPeerConnection === 'undefined') {
return '当前浏览器不支持实时屏幕传输';
}
return null;
}
export default function WatchRoomPage() {
const router = useRouter();
const watchRoom = useWatchRoomContext();
@@ -34,6 +63,7 @@ export default function WatchRoomPage() {
description: '',
password: '',
isPublic: true,
roomType: 'sync' as RoomType,
});
// 加入房间表单
@@ -47,28 +77,42 @@ export default function WatchRoomPage() {
const [loading, setLoading] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [joinLoading, setJoinLoading] = useState(false);
const [toast, setToast] = useState<ToastProps | null>(null);
const showToast = (message: string, type: ToastProps['type'] = 'info') => {
setToast({
message,
type,
duration: 3000,
onClose: () => setToast(null),
});
};
// 加载房间列表
const loadRooms = async () => {
const loadRooms = async (showLoading = false) => {
if (!isConnected) return;
setLoading(true);
if (showLoading) {
setLoading(true);
}
try {
const roomList = await getRoomList();
setRooms(roomList);
} catch (error) {
console.error('[WatchRoom] Failed to load rooms:', error);
} finally {
setLoading(false);
if (showLoading) {
setLoading(false);
}
}
};
// 切换到房间列表 tab 时加载房间
useEffect(() => {
if (activeTab === 'list') {
loadRooms();
loadRooms(true);
// 每5秒刷新一次
const interval = setInterval(loadRooms, 5000);
const interval = setInterval(() => loadRooms(false), 5000);
return () => clearInterval(interval);
}
}, [activeTab, isConnected]);
@@ -77,10 +121,18 @@ export default function WatchRoomPage() {
const handleCreateRoom = async (e: React.FormEvent) => {
e.preventDefault();
if (!createForm.roomName.trim()) {
alert('请输入房间名称');
showToast('请输入房间名称', 'error');
return;
}
if (createForm.roomType === 'screen') {
const supportError = getScreenShareHostSupportError();
if (supportError) {
showToast(`当前设备无法创建屏幕共享房间:${supportError}`, 'error');
return;
}
}
setCreateLoading(true);
try {
await createRoom({
@@ -88,6 +140,7 @@ export default function WatchRoomPage() {
description: createForm.description.trim(),
password: createForm.password.trim() || undefined,
isPublic: createForm.isPublic,
roomType: createForm.roomType,
userName: currentUsername,
});
@@ -97,9 +150,10 @@ export default function WatchRoomPage() {
description: '',
password: '',
isPublic: true,
roomType: 'sync',
});
} catch (error: any) {
alert(error.message || '创建房间失败');
showToast(error.message || '创建房间失败', 'error');
} finally {
setCreateLoading(false);
}
@@ -110,10 +164,19 @@ export default function WatchRoomPage() {
e.preventDefault();
const targetRoomId = roomId || joinForm.roomId.trim().toUpperCase();
if (!targetRoomId) {
alert('请输入房间ID');
showToast('请输入房间ID', 'error');
return;
}
const targetRoom = rooms.find((room) => room.id === targetRoomId);
if (targetRoom?.roomType === 'screen') {
const supportError = getScreenShareViewerSupportError();
if (supportError) {
showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error');
return;
}
}
setJoinLoading(true);
try {
const result = await joinRoom({
@@ -131,7 +194,7 @@ export default function WatchRoomPage() {
// 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新
// 跳转逻辑会在 useEffect 中处理
} catch (error: any) {
alert(error.message || '加入房间失败');
showToast(error.message || '加入房间失败', 'error');
} finally {
setJoinLoading(false);
}
@@ -141,6 +204,11 @@ export default function WatchRoomPage() {
useEffect(() => {
if (!currentRoom || isOwner) return;
if (currentRoom.roomType === 'screen') {
router.push('/watch-room/screen');
return;
}
// 房员加入房间后,不立即跳转
// 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用)
// 这样可以避免房主已经离开play页面但状态未清除的情况
@@ -153,6 +221,8 @@ export default function WatchRoomPage() {
useEffect(() => {
if (!currentRoom || isOwner) return;
if (currentRoom.roomType === 'screen') return;
const handlePlayChange = (state: any) => {
if (state.type === 'play') {
const params = new URLSearchParams({
@@ -196,8 +266,23 @@ export default function WatchRoomPage() {
}
}, [currentRoom, isOwner, router, socket]);
// 屏幕共享房间创建/加入后直接进入共享页
useEffect(() => {
if (currentRoom?.roomType === 'screen') {
router.push('/watch-room/screen');
}
}, [currentRoom?.id, currentRoom?.roomType, router]);
// 从房间列表加入房间
const handleJoinFromList = (room: Room) => {
if (room.roomType === 'screen') {
const supportError = getScreenShareViewerSupportError();
if (supportError) {
showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error');
return;
}
}
setJoinForm({
roomId: room.id,
password: '',
@@ -237,7 +322,9 @@ export default function WatchRoomPage() {
</div>
<div className="flex-1">
<h3 className="text-lg font-bold mb-1">
{currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
{currentRoom.roomType === 'screen'
? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享'
: currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
</h3>
<p className="text-sm text-white/80">
: {currentRoom.name} | : {currentRoom.ownerName}
@@ -246,12 +333,14 @@ export default function WatchRoomPage() {
<p className="text-xs text-white/90 mt-1">
{currentRoom.currentState.type === 'play'
? `${currentRoom.currentState.videoName || '未知视频'}`
: `${currentRoom.currentState.channelName || '未知频道'}`}
: currentRoom.currentState.type === 'live'
? `${currentRoom.currentState.channelName || '未知频道'}`
: '屏幕共享进行中'}
</p>
)}
{!currentRoom.currentState && (
<p className="text-xs text-white/70 mt-1">
{currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}
</p>
)}
</div>
@@ -280,6 +369,8 @@ export default function WatchRoomPage() {
// 普通 live 格式,导航到 live 页面
router.push(`/live?id=${state.channelId}`);
}
} else if (state.type === 'screen') {
router.push('/watch-room/screen');
}
}}
className="px-6 py-2 bg-white text-blue-600 font-medium rounded-lg hover:bg-white/90 transition-colors whitespace-nowrap"
@@ -303,7 +394,7 @@ export default function WatchRoomPage() {
)}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
</p>
</div>
@@ -360,7 +451,7 @@ export default function WatchRoomPage() {
)}
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
<p className="text-blue-100 text-xs mb-1"></p>
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
@@ -369,6 +460,10 @@ export default function WatchRoomPage() {
<p className="text-blue-100 text-xs mb-1"></p>
<p className="text-xl font-bold">{members.length} </p>
</div>
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
<p className="text-blue-100 text-xs mb-1"></p>
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
</div>
</div>
</div>
@@ -402,7 +497,9 @@ export default function WatchRoomPage() {
{/* 提示信息 */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡
💡 {currentRoom.roomType === 'screen'
? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
: '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
</p>
</div>
</div>
@@ -471,6 +568,38 @@ export default function WatchRoomPage() {
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCreateForm({ ...createForm, roomType: 'sync' })}
className={`rounded-lg border p-4 text-left transition-colors ${
createForm.roomType === 'sync'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100"></div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"></div>
</button>
<button
type="button"
onClick={() => setCreateForm({ ...createForm, roomType: 'screen' })}
className={`rounded-lg border p-4 text-left transition-colors ${
createForm.roomType === 'screen'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100"></div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"></div>
</button>
</div>
</div>
<button
type="submit"
disabled={createLoading || !createForm.roomName.trim()}
@@ -486,7 +615,7 @@ export default function WatchRoomPage() {
{!currentRoom && (
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong></strong>
<strong></strong>
</p>
</div>
)}
@@ -518,7 +647,7 @@ export default function WatchRoomPage() {
)}
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
<p className="text-green-100 text-xs mb-1"></p>
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
@@ -527,6 +656,10 @@ export default function WatchRoomPage() {
<p className="text-green-100 text-xs mb-1"></p>
<p className="text-xl font-bold">{members.length} </p>
</div>
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
<p className="text-green-100 text-xs mb-1"></p>
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
</div>
</div>
</div>
@@ -560,7 +693,9 @@ export default function WatchRoomPage() {
{/* 提示信息 */}
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<p className="text-sm text-green-800 dark:text-green-200">
💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
💡 {currentRoom.roomType === 'screen'
? '这是屏幕共享房间,进入后即可观看房主共享画面'
: isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
</p>
</div>
</div>
@@ -617,7 +752,7 @@ export default function WatchRoomPage() {
{!currentRoom && (
<div className="mt-6 bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<p className="text-sm text-green-800 dark:text-green-200">
<strong></strong>
<strong></strong>
</p>
</div>
)}
@@ -633,7 +768,7 @@ export default function WatchRoomPage() {
<span className="font-medium text-gray-900 dark:text-gray-100">{rooms.length}</span>
</p>
<button
onClick={loadRooms}
onClick={() => loadRooms(true)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors disabled:opacity-50"
>
@@ -704,6 +839,10 @@ export default function WatchRoomPage() {
<span></span>
<span className="font-medium">{room.ownerName}</span>
</div>
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
<span></span>
<span>{room.roomType === 'screen' ? '屏幕共享' : '进度同步'}</span>
</div>
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
<span></span>
<span>{formatTime(room.createdAt)}</span>
@@ -713,7 +852,9 @@ export default function WatchRoomPage() {
<p className="text-xs text-blue-700 dark:text-blue-300 truncate">
{room.currentState.type === 'play'
? `正在播放: ${room.currentState.videoName}`
: `正在观看: ${room.currentState.channelName}`}
: room.currentState.type === 'live'
? `正在观看: ${room.currentState.channelName}`
: '正在共享屏幕'}
</p>
</div>
)}
@@ -733,6 +874,7 @@ export default function WatchRoomPage() {
)}
</div>
</div>
{toast && <Toast {...toast} />}
</PageLayout>
);
}
+323
View File
@@ -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>
);
}
+11 -12
View File
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useRef,useState } from 'react';
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
import { getDoubanDetail } from '@/lib/douban.client';
import { processImageUrl } from '@/lib/utils';
import ProxyImage from '@/components/ProxyImage';
interface BannerCarouselProps {
autoPlayInterval?: number; // 自动播放间隔(毫秒)
@@ -67,15 +68,15 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
}
};
// 获取图片URL(处理TX完整URL和TMDB路径)
// 获取图片原始URL(处理TX完整URL和TMDB路径)
const getImageUrl = (path: string | null) => {
if (!path) return '';
// 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理
// 如果是完整URL(TX数据源或豆瓣),直接返回原始地址
if (path.startsWith('http://') || path.startsWith('https://')) {
return processImageUrl(path);
return path;
}
// 否则使用TMDB的URL拼接,并通过processImageUrl处理
return processImageUrl(getTMDBImageUrl(path, 'original'));
// 否则使用TMDB的URL拼接原始地址
return getTMDBImageUrl(path, 'original');
};
// 获取视频URL(处理豆瓣视频代理)
@@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
</div>
) : (
/* 显示图片 */
<Image
src={getImageUrl(item.backdrop_path || item.poster_path)}
<ProxyImage
originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
alt={item.title}
fill
className="object-cover"
priority={index === 0}
sizes="100vw"
className="absolute inset-0 w-full h-full object-cover"
loading={index === 0 ? 'eager' : 'lazy'}
/>
)}
{/* 渐变遮罩 */}
+68 -1
View File
@@ -35,9 +35,12 @@ export default function DanmakuPanel({
const [searchError, setSearchError] = useState<string | null>(null);
const initializedRef = useRef(false); // 标记是否已初始化过
const fileInputRef = useRef<HTMLInputElement>(null);
const episodeGroupContainerRef = useRef<HTMLDivElement>(null);
const episodeGroupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [episodeGroupIndex, setEpisodeGroupIndex] = useState(0);
const [episodeDescending, setEpisodeDescending] = useState(false);
const [episodeViewMode, setEpisodeViewMode] = useState<'list' | 'grid'>('list');
const [isEpisodeGroupHovered, setIsEpisodeGroupHovered] = useState(false);
const episodesPerGroup = 50;
// 搜索弹幕
@@ -230,6 +233,62 @@ export default function DanmakuPanel({
return String(episodeNumber);
}, []);
const preventPageScroll = useCallback((e: WheelEvent) => {
if (isEpisodeGroupHovered) {
e.preventDefault();
}
}, [isEpisodeGroupHovered]);
const handleEpisodeGroupWheel = useCallback((e: WheelEvent) => {
if (!isEpisodeGroupHovered || !episodeGroupContainerRef.current) {
return;
}
const container = episodeGroupContainerRef.current;
if (container.scrollWidth <= container.clientWidth) {
return;
}
e.preventDefault();
container.scrollBy({
left: e.deltaY * 2,
behavior: 'smooth',
});
}, [isEpisodeGroupHovered]);
useEffect(() => {
if (isEpisodeGroupHovered) {
document.addEventListener('wheel', preventPageScroll, { passive: false });
document.addEventListener('wheel', handleEpisodeGroupWheel, { passive: false });
} else {
document.removeEventListener('wheel', preventPageScroll);
document.removeEventListener('wheel', handleEpisodeGroupWheel);
}
return () => {
document.removeEventListener('wheel', preventPageScroll);
document.removeEventListener('wheel', handleEpisodeGroupWheel);
};
}, [handleEpisodeGroupWheel, isEpisodeGroupHovered, preventPageScroll]);
useEffect(() => {
const btn = episodeGroupButtonRefs.current[displayEpisodeGroupIndex];
const container = episodeGroupContainerRef.current;
if (!btn || !container) {
return;
}
const containerRect = container.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const btnLeft = btnRect.left - containerRect.left + container.scrollLeft;
const targetScrollLeft = btnLeft - (containerRect.width - btnRect.width) / 2;
container.scrollTo({
left: targetScrollLeft,
behavior: 'smooth',
});
}, [displayEpisodeGroupIndex]);
return (
<div className='flex h-full flex-col overflow-hidden'>
{/* 搜索区域 - 固定在顶部 */}
@@ -348,12 +407,20 @@ export default function DanmakuPanel({
{!isLoadingEpisodes && episodes.length > 0 && (
<div className='pb-4'>
<div className='mb-4 border-b border-gray-300 dark:border-gray-700'>
<div className='flex items-center gap-4 overflow-x-auto pb-3'>
<div
ref={episodeGroupContainerRef}
className='flex items-center gap-4 overflow-x-auto pb-3'
onMouseEnter={() => setIsEpisodeGroupHovered(true)}
onMouseLeave={() => setIsEpisodeGroupHovered(false)}
>
{episodeGroups.map((label, idx) => {
const isActive = idx === displayEpisodeGroupIndex;
return (
<button
key={label}
ref={(el) => {
episodeGroupButtonRefs.current[idx] = el;
}}
onClick={() =>
setEpisodeGroupIndex(
episodeDescending ? episodeGroupCount - 1 - idx : idx
+364 -84
View File
@@ -1,6 +1,6 @@
'use client';
import { Calendar, Clock, ExternalLink, Film,Globe, Star, Tag, Users, X } from 'lucide-react';
import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client';
import { processImageUrl } from '@/lib/utils';
import ImageViewer from '@/components/ImageViewer';
import ProxyImage from '@/components/ProxyImage';
interface DetailPanelProps {
isOpen: boolean;
@@ -69,6 +70,16 @@ interface Episode {
air_date: string;
}
interface GalleryImage {
file_path: string;
width: number;
height: number;
vote_average?: number;
vote_count?: number;
iso_639_1?: string | null;
imageType: 'backdrop' | 'poster';
}
const DetailPanel: React.FC<DetailPanelProps> = ({
isOpen,
onClose,
@@ -100,6 +111,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState<string>('');
const [showGallery, setShowGallery] = useState(false);
const [galleryLoading, setGalleryLoading] = useState(false);
const [galleryError, setGalleryError] = useState<string | null>(null);
const [galleryImages, setGalleryImages] = useState<GalleryImage[]>([]);
const [galleryTotal, setGalleryTotal] = useState(0);
const [galleryScrollTop, setGalleryScrollTop] = useState(0);
const [galleryViewportHeight, setGalleryViewportHeight] = useState(0);
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
// 数据源状态管理
@@ -151,11 +171,82 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
setShowImageViewer(true);
};
const galleryTmdbId = detailData?.tmdbId || tmdbId;
const galleryMediaType = detailData?.mediaType || type;
const canShowGalleryEntry = !!galleryTmdbId && !!galleryMediaType;
const fetchGalleryImages = async () => {
if (!galleryTmdbId || !galleryMediaType) return;
setGalleryLoading(true);
setGalleryError(null);
try {
const response = await fetch(
`/api/tmdb/images?id=${galleryTmdbId}&type=${galleryMediaType}`
);
if (!response.ok) {
throw new Error('获取照片墙失败');
}
const data = await response.json();
setGalleryImages(data.list || []);
setGalleryTotal(data.total || 0);
} catch (err) {
console.error('获取照片墙失败:', err);
setGalleryError(err instanceof Error ? err.message : '获取照片墙失败');
} finally {
setGalleryLoading(false);
}
};
const openGallery = () => {
setShowGallery(true);
};
// 确保组件在客户端挂载后才渲染 Portal
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!showGallery) {
setGalleryImages([]);
setGalleryError(null);
setGalleryLoading(false);
setGalleryTotal(0);
setGalleryScrollTop(0);
setGalleryViewportHeight(0);
setGalleryViewportWidth(0);
return;
}
fetchGalleryImages();
}, [showGallery, galleryTmdbId, galleryMediaType]);
useEffect(() => {
if (!showGallery || !galleryScrollRef.current) return;
const element = galleryScrollRef.current;
const updateMetrics = () => {
setGalleryViewportHeight(element.clientHeight);
setGalleryViewportWidth(element.clientWidth);
setGalleryScrollTop(element.scrollTop);
};
updateMetrics();
element.addEventListener('scroll', updateMetrics, { passive: true });
const resizeObserver = new ResizeObserver(updateMetrics);
resizeObserver.observe(element);
return () => {
element.removeEventListener('scroll', updateMetrics);
resizeObserver.disconnect();
};
}, [showGallery]);
// 控制动画状态
useEffect(() => {
let animationId: number;
@@ -185,6 +276,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setShowGallery(false);
}
}, [isOpen]);
// 阻止背景滚动(仅在非抽屉模式下)
useEffect(() => {
if (isVisible && !useDrawer) {
@@ -277,7 +374,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: title,
intro: cmsData.desc,
episodesCount: cmsData.episodes?.length,
poster: poster ? processImageUrl(poster) : poster,
poster: poster,
};
setDetailData(data);
setOriginalDetailData(data);
@@ -297,7 +394,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: data.title || title,
intro: data.desc || '',
episodesCount: data.episodes?.length || cmsData.episodes?.length,
poster: data.poster ? processImageUrl(data.poster) : poster,
poster: data.poster || poster,
year: data.year,
};
setDetailData(detailData);
@@ -327,7 +424,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: data.name_cn || data.name,
originalTitle: data.name,
year: data.date ? data.date.substring(0, 4) : undefined,
poster: data.images?.large ? processImageUrl(data.images.large) : poster,
poster: data.images?.large || poster,
rating: data.rating
? {
value: data.rating.score,
@@ -358,7 +455,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: data.title,
originalTitle: data.original_title,
year: data.year,
poster: (data.pic?.large || data.pic?.normal) ? processImageUrl(data.pic?.large || data.pic?.normal) : poster,
poster: data.pic?.large || data.pic?.normal || poster,
rating: data.rating
? {
value: data.rating.value,
@@ -777,7 +874,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
...prev,
title: episodesData.name || season?.name || prev.title,
intro: episodesData.overview || season?.overview || prev.overview,
poster: season?.poster_path ? processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')) : prev.poster,
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
@@ -887,6 +984,171 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
};
const galleryEntryButton = canShowGalleryEntry ? (
<button
onClick={openGallery}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors"
>
<Images size={16} />
</button>
) : null;
const virtualGalleryLayout = React.useMemo(() => {
if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
return {
visibleItems: [] as Array<GalleryImage & { top: number; left: number; renderWidth: number; renderHeight: number; index: number }>,
totalHeight: 0,
usedWidth: 0,
};
}
const gap = 4;
const overscan = 800;
const horizontalPadding = 32;
const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
const columnWidth = Math.floor((width - gap * (columnCount - 1)) / columnCount);
const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
const columnHeights = new Array(columnCount).fill(0);
const items = galleryImages.map((image, index) => {
let targetColumn = 0;
for (let i = 1; i < columnCount; i++) {
if (columnHeights[i] < columnHeights[targetColumn]) {
targetColumn = i;
}
}
const ratio = image.width && image.height ? image.height / image.width : (image.imageType === 'poster' ? 1.5 : 0.5625);
const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
const top = columnHeights[targetColumn];
const left = targetColumn * (columnWidth + gap);
columnHeights[targetColumn] += renderHeight + gap;
return {
...image,
index,
top,
left,
renderWidth: columnWidth,
renderHeight,
};
});
const totalHeight = Math.max(...columnHeights, 0);
const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan;
const visibleItems = items.filter(item => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom);
return { visibleItems, totalHeight, usedWidth };
}, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
const galleryBody = (
<div ref={galleryScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden p-4">
{galleryLoading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
</div>
)}
{!galleryLoading && galleryError && (
<div className="text-center py-12 text-red-500 dark:text-red-400">{galleryError}</div>
)}
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400"></div>
)}
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
<div
className="relative mx-auto"
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
>
{virtualGalleryLayout.visibleItems.map((image) => {
const imageUrl = getTMDBImageUrl(
image.file_path,
image.imageType === 'poster' ? 'w500' : 'original'
);
const thumbUrl = getTMDBImageUrl(
image.file_path,
image.imageType === 'poster' ? 'w342' : 'w780'
);
return (
<div
key={`${image.imageType}-${image.file_path}-${image.index}`}
className="group absolute"
style={{
top: image.top,
left: image.left,
width: image.renderWidth,
height: image.renderHeight,
}}
>
<div
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(imageUrl)}
>
<ProxyImage
originalSrc={thumbUrl}
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
{image.imageType === 'poster' ? '海报' : '剧照'}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
const galleryHeader = (
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h3>
{!galleryLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{galleryTotal}
</p>
)}
</div>
<button
onClick={() => setShowGallery(false)}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="关闭照片墙"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
);
const galleryModal = showGallery ? (useDrawer ? (
<div className="fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none">
<div className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}>
{galleryHeader}
{galleryBody}
</div>
</div>
) : (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setShowGallery(false)}
/>
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{galleryHeader}
{galleryBody}
</div>
</div>
)) : null;
if (!isVisible || !mounted) return null;
const content = useDrawer ? (
@@ -938,7 +1200,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 数据源显示和切换 - 错误时也显示 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
@@ -948,24 +1210,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{currentSource === 'tmdb' && 'TMDB'}
</span>
</div>
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
<div className="flex items-center gap-2 flex-wrap">
{galleryEntryButton}
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
</div>
</div>
</div>
</div>
@@ -976,11 +1241,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 海报和基本信息 */}
<div className="flex gap-6 mb-6">
{detailData.poster && (
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)}
>
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
<div className="flex flex-col items-start gap-3 flex-shrink-0">
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)}
>
<ProxyImage
originalSrc={detailData.poster}
alt={detailData.title}
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
{galleryEntryButton}
</div>
)}
<div className="flex-1 min-w-0">
@@ -1103,13 +1376,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{actor.profile_path ? (
<div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
>
<Image
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
<ProxyImage
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
alt={actor.name}
fill
className="object-cover"
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
@@ -1221,14 +1493,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
}}
>
<Image
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
<ProxyImage
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
alt={season.name}
fill
className="object-cover"
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
@@ -1283,13 +1554,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{episode.still_path && (
<div
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
>
<Image
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
<ProxyImage
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
alt={episode.name}
fill
className="object-cover"
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
@@ -1332,7 +1602,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 数据源显示和切换 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
@@ -1342,24 +1612,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{currentSource === 'tmdb' && 'TMDB'}
</span>
</div>
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
<div className="flex items-center gap-2 flex-wrap">
{galleryEntryButton}
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
</div>
</div>
</div>
</div>
@@ -1368,6 +1641,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
</div>
{/* 图片查看器 */}
{galleryModal}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
@@ -1480,11 +1754,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 海报和基本信息 */}
<div className="flex gap-6 mb-6">
{detailData.poster && (
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)}
>
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
<div className="flex flex-col items-start gap-3 flex-shrink-0">
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)}
>
<ProxyImage
originalSrc={detailData.poster}
alt={detailData.title}
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
{galleryEntryButton}
</div>
)}
<div className="flex-1 min-w-0">
@@ -1607,13 +1889,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{actor.profile_path ? (
<div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
>
<Image
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
<ProxyImage
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
alt={actor.name}
fill
className="object-cover"
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
@@ -1725,14 +2006,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
}}
>
<Image
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
<ProxyImage
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
alt={season.name}
fill
className="object-cover"
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
@@ -1787,13 +2067,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{episode.still_path && (
<div
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
>
<Image
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
<ProxyImage
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
alt={episode.name}
fill
className="object-cover"
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div>
@@ -1872,6 +2151,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
</div>
{/* 图片查看器 */}
{galleryModal}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
+6 -4
View File
@@ -12,10 +12,11 @@ import React, {
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import DanmakuPanel from '@/components/DanmakuPanel';
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
import ProxyImage from '@/components/ProxyImage';
// 定义视频信息类型
interface VideoInfo {
@@ -870,10 +871,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{source.source === 'directplay' ? (
<LinkIcon className='w-6 h-6 text-blue-500' />
) : source.poster ? (
<img
src={processImageUrl(source.poster)}
<ProxyImage
originalSrc={source.poster}
alt={source.title}
className='w-full h-full object-cover'
retryOnError={false}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
@@ -940,7 +942,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{/* 源名称和集数信息 - 垂直居中 */}
<div className='flex items-center justify-between'>
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
source.source === 'xiaoya' ? 'border-blue-500' : source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
source.source === 'xiaoya' ? 'border-blue-500' : source.source === 'quark-temp' ? 'border-purple-500' : source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
? 'border-yellow-500'
: 'border-gray-500/60'
}`}>
+5 -7
View File
@@ -1,10 +1,11 @@
'use client';
import { X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import ProxyImage from '@/components/ProxyImage';
interface ImageViewerProps {
isOpen: boolean;
onClose: () => void;
@@ -151,18 +152,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full h-full">
<Image
src={imageUrl}
<ProxyImage
originalSrc={imageUrl}
alt={alt}
width={1200}
height={1800}
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
style={{
maxWidth: '100vw',
maxHeight: '100vh',
}}
priority
quality={100}
loading="eager"
/>
</div>
</div>
+4
View File
@@ -28,6 +28,10 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
};
const currentActive = activePath ?? getCurrentFullPath();
if (pathname === '/watch-room/screen') {
return null;
}
const [navItems, setNavItems] = useState([
{ icon: Home, label: '首页', href: '/' },
{
+286 -181
View File
@@ -2,8 +2,10 @@
'use client';
import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import Toast, { ToastProps } from '@/components/Toast';
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
interface PansouSearchProps {
@@ -51,11 +53,15 @@ export default function PansouSearch({
triggerSearch,
onError,
}: PansouSearchProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<PansouSearchResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
const [transferingUrl, setTransferingUrl] = useState<string | null>(null);
const [playingUrl, setPlayingUrl] = useState<string | null>(null);
const [toast, setToast] = useState<ToastProps | null>(null);
// 提取搜索函数,以便在重试时调用
const searchPansou = useCallback(async () => {
@@ -118,205 +124,304 @@ export default function PansouSearch({
window.open(url, '_blank', 'noopener,noreferrer');
};
if (loading) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
...
</p>
const handleQuarkTransfer = async (link: PansouLink) => {
try {
setTransferingUrl(link.url);
const response = await fetch('/api/netdisk/quark/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
shareUrl: link.url,
passcode: link.password || '',
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '转存失败');
}
setToast({
message: `转存成功,已保存到:${data.targetPath}`,
type: 'success',
onClose: () => setToast(null),
});
} catch (err: any) {
setToast({
message: err?.message || '转存失败',
type: 'error',
onClose: () => setToast(null),
});
} finally {
setTransferingUrl(null);
}
};
const handleQuarkInstantPlay = async (link: PansouLink) => {
try {
setPlayingUrl(link.url);
const response = await fetch('/api/netdisk/quark/instant-play', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
shareUrl: link.url,
passcode: link.password || '',
title: link.note || keyword,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '立即播放失败');
}
router.push(
`/play?source=quark-temp&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
);
} catch (err: any) {
setToast({
message: err?.message || '立即播放失败',
type: 'error',
onClose: () => setToast(null),
});
} finally {
setPlayingUrl(null);
}
};
const renderBody = () => {
if (loading) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
...
</p>
</div>
</div>
</div>
);
}
);
}
if (error) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
<button
onClick={searchPansou}
className='mt-4 inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors'
>
<RefreshCw className='h-4 w-4' />
</button>
</div>
</div>
);
}
if (!results || results.total === 0 || !results.merged_by_type) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
</p>
</div>
</div>
);
}
const cloudTypes = Object.keys(results.merged_by_type || {});
// 过滤显示的网盘类型
const filteredCloudTypes = selectedType === 'all'
? cloudTypes
: cloudTypes.filter(type => type === selectedType);
// 计算每种网盘类型的数量
const typeStats = cloudTypes.map(type => ({
type,
count: results.merged_by_type?.[type]?.length || 0,
}));
if (error) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
<>
{/* 搜索结果统计 */}
<div className='text-sm text-gray-600 dark:text-gray-400'>
<span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span>
</div>
{/* 网盘类型过滤器 */}
<div className='flex flex-wrap gap-2'>
<button
onClick={searchPansou}
className='mt-4 inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors'
onClick={() => setSelectedType('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedType === 'all'
? 'bg-green-600 text-white dark:bg-green-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<RefreshCw className='h-4 w-4' />
({results.total})
</button>
{typeStats.map(({ type, count }) => {
const typeName = CLOUD_TYPE_NAMES[type] || type;
return (
<button
key={type}
onClick={() => setSelectedType(type)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedType === type
? 'bg-green-600 text-white dark:bg-green-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{typeName} ({count})
</button>
);
})}
</div>
</div>
);
}
if (!results || results.total === 0 || !results.merged_by_type) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
</p>
</div>
</div>
);
}
{/* 按网盘类型分类显示 */}
{filteredCloudTypes.map((cloudType) => {
const links = results.merged_by_type?.[cloudType];
if (!links || links.length === 0) return null;
const cloudTypes = Object.keys(results.merged_by_type || {});
// 过滤显示的网盘类型
const filteredCloudTypes = selectedType === 'all'
? cloudTypes
: cloudTypes.filter(type => type === selectedType);
// 计算每种网盘类型的数量
const typeStats = cloudTypes.map(type => ({
type,
count: results.merged_by_type?.[type]?.length || 0,
}));
return (
<div className='space-y-6'>
{/* 搜索结果统计 */}
<div className='text-sm text-gray-600 dark:text-gray-400'>
<span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span>
</div>
{/* 网盘类型过滤器 */}
<div className='flex flex-wrap gap-2'>
<button
onClick={() => setSelectedType('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedType === 'all'
? 'bg-green-600 text-white dark:bg-green-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
({results.total})
</button>
{typeStats.map(({ type, count }) => {
const typeName = CLOUD_TYPE_NAMES[type] || type;
const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others;
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
return (
<button
key={type}
onClick={() => setSelectedType(type)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedType === type
? 'bg-green-600 text-white dark:bg-green-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{typeName} ({count})
</button>
);
})}
</div>
<div key={cloudType} className='space-y-3'>
{/* 网盘类型标题 */}
<div className='flex items-center gap-2'>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
{typeName}
</span>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{links.length}
</span>
</div>
{/* 按网盘类型分类显示 */}
{filteredCloudTypes.map((cloudType) => {
const links = results.merged_by_type?.[cloudType];
if (!links || links.length === 0) return null;
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
return (
<div key={cloudType} className='space-y-3'>
{/* 网盘类型标题 */}
<div className='flex items-center gap-2'>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
{typeName}
</span>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{links.length}
</span>
</div>
{/* 链接列表 */}
<div className='space-y-2'>
{links.map((link: PansouLink, index: number) => (
<div
key={`${cloudType}-${index}`}
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
>
{/* 资源标题 */}
{link.note && (
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
{link.note}
</div>
)}
{/* 链接和密码 */}
<div className='flex items-center gap-2 mb-2'>
<div className='flex-1 min-w-0'>
<div className='text-xs text-gray-600 dark:text-gray-400 truncate'>
{link.url}
{/* 链接列表 */}
<div className='space-y-2'>
{links.map((link: PansouLink, index: number) => (
<div
key={`${cloudType}-${index}`}
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
>
{/* 资源标题 */}
{link.note && (
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
{link.note}
</div>
{link.password && (
<div className='text-xs text-gray-600 dark:text-gray-400 mt-1'>
: <span className='font-mono font-semibold'>{link.password}</span>
)}
{/* 链接和密码 */}
<div className='flex items-center gap-2 mb-2'>
<div className='flex-1 min-w-0'>
<div className='text-xs text-gray-600 dark:text-gray-400 truncate'>
{link.url}
</div>
{link.password && (
<div className='text-xs text-gray-600 dark:text-gray-400 mt-1'>
: <span className='font-mono font-semibold'>{link.password}</span>
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex items-center gap-1 flex-shrink-0'>
{cloudType === 'quark' && (
<>
<button
onClick={() => handleQuarkInstantPlay(link)}
disabled={playingUrl === link.url}
className='px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
title='立即播放'
>
{playingUrl === link.url ? '处理中...' : '立即播放'}
</button>
<button
onClick={() => handleQuarkTransfer(link)}
disabled={transferingUrl === link.url}
className='px-2 py-1 rounded-md bg-purple-600 hover:bg-purple-700 text-white text-xs transition-colors disabled:opacity-60'
title='转存到配置目录'
>
{transferingUrl === link.url ? '转存中...' : '转存'}
</button>
</>
)}
<button
onClick={() => handleCopy(
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
link.url
)}
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='复制链接'
>
{copiedUrl === link.url ? (
<span className='text-xs text-green-600 dark:text-green-400'></span>
) : (
<Copy className='h-4 w-4 text-gray-600 dark:text-gray-400' />
)}
</button>
<button
onClick={() => handleOpenLink(link.url)}
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='打开链接'
>
<ExternalLink className='h-4 w-4 text-gray-600 dark:text-gray-400' />
</button>
</div>
</div>
{/* 来源和时间 */}
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
{link.source && (
<span>: {link.source}</span>
)}
{link.datetime && (
<span>{new Date(link.datetime).toLocaleDateString()}</span>
)}
</div>
{/* 操作按钮 */}
<div className='flex items-center gap-1 flex-shrink-0'>
<button
onClick={() => handleCopy(
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
link.url
)}
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='复制链接'
>
{copiedUrl === link.url ? (
<span className='text-xs text-green-600 dark:text-green-400'></span>
) : (
<Copy className='h-4 w-4 text-gray-600 dark:text-gray-400' />
)}
</button>
<button
onClick={() => handleOpenLink(link.url)}
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
title='打开链接'
>
<ExternalLink className='h-4 w-4 text-gray-600 dark:text-gray-400' />
</button>
</div>
</div>
{/* 来源和时间 */}
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
{link.source && (
<span>: {link.source}</span>
)}
{link.datetime && (
<span>{new Date(link.datetime).toLocaleDateString()}</span>
{/* 图片预览 */}
{link.images && link.images.length > 0 && (
<div className='mt-3 flex gap-2 overflow-x-auto'>
{link.images.map((img, imgIndex) => (
<img
key={imgIndex}
src={img}
alt=''
className='h-20 w-auto rounded object-cover'
loading='lazy'
/>
))}
</div>
)}
</div>
{/* 图片预览 */}
{link.images && link.images.length > 0 && (
<div className='mt-3 flex gap-2 overflow-x-auto'>
{link.images.map((img, imgIndex) => (
<img
key={imgIndex}
src={img}
alt=''
className='h-20 w-auto rounded object-cover'
loading='lazy'
/>
))}
</div>
)}
</div>
))}
))}
</div>
</div>
</div>
);
})}
</div>
);
})}
</>
);
};
return (
<>
<div className='space-y-6'>
{renderBody()}
</div>
{toast && <Toast {...toast} />}
</>
);
}
+49
View File
@@ -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;
+4
View File
@@ -63,6 +63,10 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const watchRoomContext = useWatchRoomContextSafe();
if (pathname === '/watch-room/screen') {
return null;
}
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (
+279
View File
@@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => {
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [enableTrailers, setEnableTrailers] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
useState(false);
const [bufferStrategy, setBufferStrategy] = useState('medium');
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
@@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => {
setDoubanProxyUrl(defaultDoubanProxy);
}
const savedDoubanDataSourceBackup = localStorage.getItem(
'doubanDataSourceBackup'
);
setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
const savedDoubanProxyUrlBackup = localStorage.getItem(
'doubanProxyUrlBackup'
);
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
const savedDoubanImageProxyType = localStorage.getItem(
'doubanImageProxyType'
);
@@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => {
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
}
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
'doubanImageProxyTypeBackup'
);
setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
'doubanImageProxyUrlBackup'
);
setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
if (savedTmdbImageBaseUrl !== null) {
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
@@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => {
}
}, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanBackupDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource-backup"]')) {
setIsDoubanBackupDropdownOpen(false);
}
}
};
if (isDoubanBackupDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanBackupDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
@@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => {
}
}, [isDoubanImageProxyDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyBackupDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy-backup"]')) {
setIsDoubanImageProxyBackupDropdownOpen(false);
}
}
};
if (isDoubanImageProxyBackupDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyBackupDropdownOpen]);
const handleMenuClick = () => {
setIsOpen(!isOpen);
};
@@ -1049,6 +1110,13 @@ export const UserMenu: React.FC = () => {
}
};
const handleDoubanDataSourceBackupChange = (value: string) => {
setDoubanDataSourceBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanDataSourceBackup', value);
}
};
const handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
@@ -1056,6 +1124,20 @@ export const UserMenu: React.FC = () => {
}
};
const handleDoubanImageProxyTypeBackupChange = (value: string) => {
setDoubanImageProxyTypeBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyTypeBackup', value);
}
};
const handleDoubanProxyUrlBackupChange = (value: string) => {
setDoubanProxyUrlBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrlBackup', value);
}
};
const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') {
@@ -1063,6 +1145,13 @@ export const UserMenu: React.FC = () => {
}
};
const handleDoubanImageProxyUrlBackupChange = (value: string) => {
setDoubanImageProxyUrlBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrlBackup', value);
}
};
const handleTmdbImageBaseUrlChange = (value: string) => {
setTmdbImageBaseUrl(value);
if (typeof window !== 'undefined') {
@@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => {
setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
setDoubanDataSourceBackup('direct');
setDoubanProxyUrlBackup('');
setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
setDoubanImageProxyTypeBackup('server');
setDoubanImageProxyUrlBackup('');
setTmdbImageBaseUrl('https://image.tmdb.org');
setBufferStrategy('medium');
setNextEpisodePreCache(true);
@@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => {
localStorage.setItem('enableTrailers', 'false');
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
localStorage.setItem('doubanDataSourceBackup', 'direct');
localStorage.setItem('doubanProxyUrlBackup', '');
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
localStorage.setItem('doubanImageProxyUrlBackup', '');
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
localStorage.setItem('bufferStrategy', 'medium');
localStorage.setItem('nextEpisodePreCache', 'true');
@@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => {
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
{!doubanProxyUrl.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div>
)}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div
className='relative'
data-dropdown='douban-datasource-backup'
>
<button
type='button'
onClick={() =>
setIsDoubanBackupDropdownOpen(!isDoubanBackupDropdownOpen)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSourceBackup
)?.label
}
</button>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanBackupDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{isDoubanBackupDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceBackupChange(option.value);
setIsDoubanBackupDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSourceBackup === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSourceBackup === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
</div>
{doubanDataSourceBackup === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrlBackup}
onChange={(e) =>
handleDoubanProxyUrlBackupChange(e.target.value)
}
/>
{!doubanProxyUrlBackup.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div>
)}
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
{!doubanImageProxyUrl.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div>
)}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div
className='relative'
data-dropdown='douban-image-proxy-backup'
>
<button
type='button'
onClick={() =>
setIsDoubanImageProxyBackupDropdownOpen(
!isDoubanImageProxyBackupDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyTypeBackup
)?.label
}
</button>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyBackupDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{isDoubanImageProxyBackupDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeBackupChange(option.value);
setIsDoubanImageProxyBackupDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyTypeBackup === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyTypeBackup === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
</div>
{doubanImageProxyTypeBackup === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrlBackup}
onChange={(e) =>
handleDoubanImageProxyUrlBackupChange(e.target.value)
}
/>
{!doubanImageProxyUrlBackup.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div>
)}
+9 -5
View File
@@ -21,7 +21,7 @@ import {
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { processImageUrl, base58Decode } from '@/lib/utils';
import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils';
import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel';
@@ -750,8 +750,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
setShowImageViewer(true);
}}
onError={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (tryApplyDoubanImageFallback(img, actualPoster)) {
return;
}
// 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
@@ -1043,7 +1047,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
>
<span
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : origin === 'live' ? 'border-red-500' : 'border-white/60'
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'quark-temp' ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : origin === 'live' ? 'border-red-500' : 'border-white/60'
}`}
style={{
WebkitUserSelect: 'none',
@@ -1367,7 +1371,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{config.showSourceName && source_name && !cmsData && (
<span
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'quark-temp' ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
}`}
style={{
WebkitUserSelect: 'none',
@@ -1598,7 +1602,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<ImageViewer
isOpen={showImageViewer}
onClose={() => setShowImageViewer(false)}
imageUrl={processImageUrl(actualPoster)}
imageUrl={actualPoster}
alt={actualTitle}
/>
)}
+37 -3
View File
@@ -9,10 +9,12 @@ import Toast, { ToastProps } from '@/components/Toast';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import type { ChatMessage, Member, Room, WatchRoomConfig } from '@/types/watch-room';
import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room';
// Import type from watch-room-socket
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect';
const WATCH_ROOM_SCREEN_PATH = '/watch-room/screen';
interface WatchRoomContextType {
socket: WatchRoomSocket | null;
@@ -31,12 +33,14 @@ interface WatchRoomContextType {
description: string;
password?: string;
isPublic: boolean;
roomType: RoomType;
userName: string;
}) => Promise<Room>;
joinRoom: (data: {
roomId: string;
password?: string;
userName: string;
ownerToken?: string;
}) => Promise<{ room: Room; members: Member[] }>;
leaveRoom: () => void;
getRoomList: () => Promise<Room[]>;
@@ -51,6 +55,8 @@ interface WatchRoomContextType {
pause: () => void;
changeVideo: (state: any) => void;
changeLiveChannel: (state: any) => void;
startScreenShare: (state: ScreenState) => void;
stopScreenShare: () => void;
clearRoomState: () => void;
// 重连
@@ -82,6 +88,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
const [toast, setToast] = useState<ToastProps | null>(null);
const [reconnectFailed, setReconnectFailed] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState<boolean | null>(null);
// 处理房间删除的回调
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
@@ -119,6 +126,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
useEffect(() => {
if (typeof window === 'undefined') return;
setShouldDisableWatchRoomConnection(
window.location.pathname !== WATCH_ROOM_SCREEN_PATH
&& window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1'
);
}, []);
// 检查登录状态
useEffect(() => {
const checkLoginStatus = () => {
@@ -156,6 +172,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
roomId: info.roomId,
password: info.password,
userName: info.userName,
ownerToken: info.ownerToken,
});
} catch (error) {
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
@@ -169,6 +186,19 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
// 加载配置
useEffect(() => {
if (shouldDisableWatchRoomConnection === null) {
return;
}
if (shouldDisableWatchRoomConnection) {
setConfig({
enabled: false,
serverType: 'internal',
});
setIsEnabled(false);
return;
}
const loadConfig = async () => {
try {
// 使用公共 API 获取观影室配置(不需要管理员权限)
@@ -253,12 +283,14 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
};
loadConfig();
}, [isLoggedIn, shouldDisableWatchRoomConnection]); // 添加 isLoggedIn 作为依赖
// 清理
// 仅在 Provider 卸载时断开,避免路由切换时误断开房间连接
useEffect(() => {
return () => {
watchRoom.disconnect();
};
}, [isLoggedIn]); // 添加 isLoggedIn 作为依赖
}, []);
const contextValue: WatchRoomContextType = {
socket: watchRoom.socket,
@@ -281,6 +313,8 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
pause: watchRoom.pause,
changeVideo: watchRoom.changeVideo,
changeLiveChannel: watchRoom.changeLiveChannel,
startScreenShare: watchRoom.startScreenShare,
stopScreenShare: watchRoom.stopScreenShare,
clearRoomState: watchRoom.clearRoomState,
manualReconnect,
};
+362
View File
@@ -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,
};
}
+72 -9
View File
@@ -11,6 +11,8 @@ import type {
Member,
PlayState,
Room,
RoomType,
ScreenState,
StoredRoomInfo,
WatchRoomConfig,
} from '@/types/watch-room';
@@ -28,9 +30,15 @@ export function useWatchRoom(
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isOwner, setIsOwner] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const rejoinInFlightRef = useRef(false);
// 重新加入房间(自动重连)
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
if (rejoinInFlightRef.current) {
return;
}
rejoinInFlightRef.current = true;
console.log('[WatchRoom] Auto-rejoining room:', info);
try {
const sock = watchRoomSocketManager.getSocket();
@@ -62,9 +70,21 @@ export function useWatchRoom(
} catch (error) {
console.error('[WatchRoom] Failed to rejoin room:', error);
clearStoredRoomInfo();
} finally {
rejoinInFlightRef.current = false;
}
}, []);
const scheduleRejoin = useCallback((info: StoredRoomInfo, delay = 300) => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = setTimeout(() => {
rejoinRoom(info);
}, delay);
}, [rejoinRoom]);
// 连接到服务器
const connect = useCallback(async (config: WatchRoomConfig) => {
try {
@@ -76,15 +96,13 @@ export function useWatchRoom(
const storedInfo = getStoredRoomInfo();
if (storedInfo) {
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
reconnectTimeoutRef.current = setTimeout(() => {
rejoinRoom(storedInfo);
}, 1000);
scheduleRejoin(storedInfo);
}
} catch (error) {
console.error('[WatchRoom] Failed to connect:', error);
setIsConnected(false);
}
}, [rejoinRoom]);
}, [scheduleRejoin]);
// 断开连接
const disconnect = useCallback(() => {
@@ -97,11 +115,12 @@ export function useWatchRoom(
setCurrentRoom(null);
setMembers([]);
setChatMessages([]);
setIsOwner(false);
}, []);
// 创建房间
const createRoom = useCallback(
async (data: { name: string; description: string; password?: string; isPublic: boolean; userName: string }) => {
async (data: { name: string; description: string; password?: string; isPublic: boolean; roomType: RoomType; userName: string }) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !watchRoomSocketManager.isConnected()) {
throw new Error('Not connected');
@@ -140,7 +159,7 @@ export function useWatchRoom(
// 加入房间
const joinRoom = useCallback(
async (data: { roomId: string; password?: string; userName: string }) => {
async (data: { roomId: string; password?: string; userName: string; ownerToken?: string }) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !watchRoomSocketManager.isConnected()) {
throw new Error('Not connected');
@@ -160,7 +179,7 @@ export function useWatchRoom(
isOwner: isRoomOwner,
userName: data.userName,
password: data.password,
ownerToken: isRoomOwner ? response.room.ownerToken : undefined,
ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined,
timestamp: Date.now(),
});
resolve({ room: response.room, members: response.members });
@@ -295,6 +314,25 @@ export function useWatchRoom(
[isOwner]
);
// 开始屏幕共享
const startScreenShare = useCallback(
(state: ScreenState) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !isOwner) return;
sock.emit('screen:start', state);
},
[isOwner]
);
// 停止屏幕共享
const stopScreenShare = useCallback(() => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !isOwner) return;
sock.emit('screen:stop');
}, [isOwner]);
// 清除房间播放状态(房主离开播放/直播页面时调用)
const clearRoomState = useCallback(() => {
const sock = watchRoomSocketManager.getSocket();
@@ -322,7 +360,11 @@ export function useWatchRoom(
});
socket.on('room:member-joined', (member) => {
setMembers((prev) => [...prev, member]);
setMembers((prev) => {
const next = prev.filter((existing) => existing.id !== member.id);
next.push(member);
return next;
});
});
socket.on('room:member-left', (userId) => {
@@ -362,6 +404,19 @@ export function useWatchRoom(
}
});
// 屏幕共享事件
socket.on('screen:start', (state) => {
if (currentRoom) {
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
}
});
socket.on('screen:stop', () => {
if (currentRoom) {
setCurrentRoom((prev) => (prev ? { ...prev, currentState: null } : null));
}
});
// 聊天事件
socket.on('chat:message', (message) => {
setChatMessages((prev) => [...prev, message]);
@@ -381,6 +436,10 @@ export function useWatchRoom(
// 连接状态
socket.on('connect', () => {
setIsConnected(true);
const storedInfo = getStoredRoomInfo();
if (storedInfo) {
scheduleRejoin(storedInfo);
}
});
socket.on('disconnect', () => {
@@ -395,12 +454,14 @@ export function useWatchRoom(
socket.off('play:update');
socket.off('play:change');
socket.off('live:change');
socket.off('screen:start');
socket.off('screen:stop');
socket.off('chat:message');
socket.off('state:cleared');
socket.off('connect');
socket.off('disconnect');
};
}, [socket, currentRoom, onRoomDeleted, onStateCleared]);
}, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]);
// 清理
useEffect(() => {
@@ -431,6 +492,8 @@ export function useWatchRoom(
pause,
changeVideo,
changeLiveChannel,
startScreenShare,
stopScreenShare,
clearRoomState,
};
}
+12
View File
@@ -17,6 +17,7 @@ export interface AdminConfig {
DisableYellowFilter: boolean;
FluidSearch: boolean;
// 弹幕配置
DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
// TMDB配置
@@ -42,6 +43,8 @@ export interface AdminConfig {
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
// 注册相关配置
EnableRegistration?: boolean; // 开启注册
RequireRegistrationInviteCode?: boolean; // 注册时要求邀请码
RegistrationInviteCode?: string; // 通用注册邀请码
RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile
LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
@@ -140,6 +143,15 @@ export interface AdminConfig {
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
};
NetDiskConfig?: {
Quark?: {
Enabled: boolean;
Cookie: string;
SavePath: string;
PlayTempSavePath: string;
OpenListTempPath: string;
};
};
AIConfig?: {
Enabled: boolean; // 是否启用AI问片功能
Provider: 'openai' | 'claude' | 'custom'; // AI服务提供商
+20
View File
@@ -10,6 +10,26 @@ export interface ChangelogEntry {
}
export const changelog: ChangelogEntry[] = [
{
version: "217.0.0",
date: "2026-04-10",
added: [
"新增注册邀请码功能",
"增加弹幕内置源",
"增加netlify部署支持",
"观影室增加屏幕共享功能",
"详情面板新增照片墙功能",
"新增夸克网盘的转存与播放功能",
"豆瓣数据源增加备用源功能"
],
changed: [
"移除音乐功能"
],
fixed: [
"修复弹幕选集分组无法鼠标滑轮滚动",
"修复高级推荐报错无限刷新"
]
},
{
version: "216.0.0",
date: "2026-03-30",
+80 -3
View File
@@ -4,6 +4,8 @@ import { db } from '@/lib/db';
import { AdminConfig } from './admin.types';
const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
export interface ApiSite {
key: string;
api: string;
@@ -223,6 +225,9 @@ async function getInitConfig(configFile: string, subConfig: {
} catch (e) {
cfgFile = {} as ConfigFileStruct;
}
const hasCustomDanmakuEnv = Boolean(
process.env.DANMAKU_API_BASE || process.env.DANMAKU_API_TOKEN
);
const adminConfig: AdminConfig = {
ConfigFile: configSource,
ConfigSubscribtion: subConfig,
@@ -245,7 +250,10 @@ async function getInitConfig(configFile: string, subConfig: {
FluidSearch:
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
// 弹幕配置
DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin',
DanmakuApiBase:
process.env.DANMAKU_API_BASE ||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
// TMDB配置
TMDBApiKey: process.env.TMDB_API_KEY || '',
@@ -263,6 +271,14 @@ async function getInitConfig(configFile: string, subConfig: {
MagnetAcgripReverseProxy: '',
// 评论功能开关
EnableComments: false,
EnableRegistration: false,
RequireRegistrationInviteCode: false,
RegistrationInviteCode: '',
RegistrationRequireTurnstile: false,
LoginRequireTurnstile: false,
TurnstileSiteKey: '',
TurnstileSecretKey: '',
DefaultUserTags: [],
},
UserConfig: {
Users: [],
@@ -431,7 +447,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
DanmakuApiBase: 'http://localhost:9321',
DanmakuSourceType: 'builtin',
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
DanmakuApiToken: '87654321',
PansouApiUrl: '',
PansouUsername: '',
@@ -442,11 +459,25 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
MagnetDmhyReverseProxy: '',
MagnetAcgripReverseProxy: '',
EnableComments: false,
EnableRegistration: false,
RequireRegistrationInviteCode: false,
RegistrationInviteCode: '',
RegistrationRequireTurnstile: false,
LoginRequireTurnstile: false,
TurnstileSiteKey: '',
TurnstileSecretKey: '',
DefaultUserTags: [],
};
}
// 确保弹幕配置存在
if (adminConfig.SiteConfig.DanmakuSourceType === undefined) {
adminConfig.SiteConfig.DanmakuSourceType = 'custom';
}
if (!adminConfig.SiteConfig.DanmakuApiBase) {
adminConfig.SiteConfig.DanmakuApiBase = 'http://localhost:9321';
adminConfig.SiteConfig.DanmakuApiBase =
adminConfig.SiteConfig.DanmakuSourceType === 'builtin'
? BUILTIN_DANMAKU_API_BASE
: 'http://localhost:9321';
}
if (!adminConfig.SiteConfig.DanmakuApiToken) {
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
@@ -455,6 +486,30 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (adminConfig.SiteConfig.EnableComments === undefined) {
adminConfig.SiteConfig.EnableComments = false;
}
if (adminConfig.SiteConfig.EnableRegistration === undefined) {
adminConfig.SiteConfig.EnableRegistration = false;
}
if (adminConfig.SiteConfig.RequireRegistrationInviteCode === undefined) {
adminConfig.SiteConfig.RequireRegistrationInviteCode = false;
}
if (adminConfig.SiteConfig.RegistrationInviteCode === undefined) {
adminConfig.SiteConfig.RegistrationInviteCode = '';
}
if (adminConfig.SiteConfig.RegistrationRequireTurnstile === undefined) {
adminConfig.SiteConfig.RegistrationRequireTurnstile = false;
}
if (adminConfig.SiteConfig.LoginRequireTurnstile === undefined) {
adminConfig.SiteConfig.LoginRequireTurnstile = false;
}
if (adminConfig.SiteConfig.TurnstileSiteKey === undefined) {
adminConfig.SiteConfig.TurnstileSiteKey = '';
}
if (adminConfig.SiteConfig.TurnstileSecretKey === undefined) {
adminConfig.SiteConfig.TurnstileSecretKey = '';
}
if (adminConfig.SiteConfig.DefaultUserTags === undefined) {
adminConfig.SiteConfig.DefaultUserTags = [];
}
if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) {
adminConfig.SiteConfig.PansouKeywordBlocklist = '';
}
@@ -563,6 +618,28 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
}
}
if (!adminConfig.NetDiskConfig) {
adminConfig.NetDiskConfig = {
Quark: {
Enabled: false,
Cookie: '',
SavePath: '/',
PlayTempSavePath: '/',
OpenListTempPath: '/',
},
};
}
if (!adminConfig.NetDiskConfig.Quark) {
adminConfig.NetDiskConfig.Quark = {
Enabled: false,
Cookie: '',
SavePath: '/',
PlayTempSavePath: '/',
OpenListTempPath: '/',
};
}
// 确保音乐配置存在
if (!adminConfig.MusicConfig) {
adminConfig.MusicConfig = {
+19
View File
@@ -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
View File
@@ -88,6 +88,36 @@ interface DoubanDetailApiResponse {
[key: string]: any; // 允许其他字段
}
type DoubanProxyType =
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
function normalizeDoubanProxyConfig(
proxyType: DoubanProxyType,
proxyUrl: string
): {
proxyType: DoubanProxyType;
proxyUrl: string;
} {
const normalizedProxyUrl = proxyUrl.trim();
if (proxyType === 'custom' && !normalizedProxyUrl) {
return {
proxyType: 'direct',
proxyUrl: '',
};
}
return {
proxyType,
proxyUrl: normalizedProxyUrl,
};
}
/**
* fetch
*/
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
| 'cors-anywhere'
| 'custom';
proxyUrl: string;
backupProxyType:
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
backupProxyUrl: string;
} {
const doubanProxyType =
localStorage.getItem('doubanDataSource') ||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
localStorage.getItem('doubanProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
'';
const doubanProxyBackupType =
(localStorage.getItem('doubanDataSourceBackup') as DoubanProxyType | null) ||
'direct';
const doubanProxyBackupUrl =
localStorage.getItem('doubanProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanProxyConfig(doubanProxyType, doubanProxy);
const backupConfig = normalizeDoubanProxyConfig(
doubanProxyBackupType,
doubanProxyBackupUrl
);
return {
proxyType: doubanProxyType,
proxyUrl: doubanProxy,
proxyType: primaryConfig.proxyType,
proxyUrl: primaryConfig.proxyUrl,
backupProxyType: backupConfig.proxyType,
backupProxyUrl: backupConfig.proxyUrl,
};
}
function buildDoubanRequester(
proxyType: DoubanProxyType,
proxyUrl: string
): {
useDirectApi: boolean;
requestProxyUrl: string;
useTencentCDN: boolean;
useAliCDN: boolean;
} {
switch (proxyType) {
case 'cors-proxy-zwei':
return {
useDirectApi: false,
requestProxyUrl: 'https://ciao-cors.is-an.org/',
useTencentCDN: false,
useAliCDN: false,
};
case 'cmliussss-cdn-tencent':
return {
useDirectApi: false,
requestProxyUrl: '',
useTencentCDN: true,
useAliCDN: false,
};
case 'cmliussss-cdn-ali':
return {
useDirectApi: false,
requestProxyUrl: '',
useTencentCDN: false,
useAliCDN: true,
};
case 'cors-anywhere':
return {
useDirectApi: false,
requestProxyUrl: 'https://cors-anywhere.com/',
useTencentCDN: false,
useAliCDN: false,
};
case 'custom':
return {
useDirectApi: false,
requestProxyUrl: proxyUrl,
useTencentCDN: false,
useAliCDN: false,
};
case 'direct':
default:
return {
useDirectApi: true,
requestProxyUrl: '',
useTencentCDN: false,
useAliCDN: false,
};
}
}
async function requestDoubanWithFallback<T>(
primary: { proxyType: DoubanProxyType; proxyUrl: string },
backup: { proxyType: DoubanProxyType; proxyUrl: string },
runner: (requester: ReturnType<typeof buildDoubanRequester>) => Promise<T>
): Promise<T> {
const primaryRequester = buildDoubanRequester(primary.proxyType, primary.proxyUrl);
const backupRequester = buildDoubanRequester(backup.proxyType, backup.proxyUrl);
try {
return await runner(primaryRequester);
} catch (primaryError) {
const sameStrategy =
primary.proxyType === backup.proxyType && primary.proxyUrl === backup.proxyUrl;
if (sameStrategy) {
throw primaryError;
}
console.warn(
`[Douban] 主渠道失败,切换备用渠道: ${primary.proxyType} -> ${backup.proxyType}`,
primaryError
);
return runner(backupRequester);
}
}
function dispatchDoubanGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}
/**
*
*/
@@ -211,14 +352,6 @@ export async function fetchDoubanCategories(
list: list,
};
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣分类数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
@@ -230,25 +363,34 @@ export async function getDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanCategories(params, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanCategories(params, '', false, true);
case 'cors-anywhere':
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanCategories(params, proxyUrl);
case 'direct':
default:
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return response.json();
return fetchDoubanCategories(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣分类数据失败');
throw error;
}
}
@@ -263,25 +405,34 @@ export async function getDoubanList(
params: DoubanListParams
): Promise<DoubanResult> {
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanList(params, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanList(params, '', false, true);
case 'cors-anywhere':
return fetchDoubanList(params, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanList(params, proxyUrl);
case 'direct':
default:
const response = await fetch(
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return response.json();
return fetchDoubanList(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣列表数据失败');
throw error;
}
}
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
list: list,
};
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣列表数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
platform,
sort,
} = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanRecommends(params, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanRecommends(params, '', false, true);
case 'cors-anywhere':
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanRecommends(params, proxyUrl);
case 'direct':
default:
const response = await fetch(
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return response.json();
return fetchDoubanRecommends(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣推荐数据失败');
throw error;
}
}
@@ -544,14 +696,6 @@ export async function fetchDoubanDetail(
const doubanData: DoubanDetailApiResponse = await response.json();
return doubanData;
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣详情数据失败' },
})
);
}
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
}
}
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
export async function getDoubanDetail(
id: string
): Promise<DoubanDetailApiResponse> {
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanDetail(id, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanDetail(id, '', false, true);
case 'cors-anywhere':
return fetchDoubanDetail(id, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanDetail(id, proxyUrl);
case 'direct':
default:
const response = await fetch(`/api/douban/detail?id=${id}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanProxyConfig();
try {
return await requestDoubanWithFallback(
{ proxyType, proxyUrl },
{ proxyType: backupProxyType, proxyUrl: backupProxyUrl },
async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
if (useDirectApi) {
const response = await fetch(`/api/douban/detail?id=${id}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
}
return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
}
return response.json();
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣详情数据失败');
throw error;
}
}
+539
View File
@@ -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,
};
}
+44
View File
@@ -703,3 +703,47 @@ export async function getTMDBCredits(
return { code: 500, credits: null };
}
}
/**
* TMDB
* @param apiKey - TMDB API Key
* @param mediaId - ID
* @param mediaType - (movie tv)
* @param proxy -
* @param reverseProxyBaseUrl - Base URL
* @returns
*/
export async function getTMDBImages(
apiKey: string,
mediaId: number,
mediaType: 'movie' | 'tv',
proxy?: string,
reverseProxyBaseUrl?: string
): Promise<{ code: number; images: any }> {
try {
const actualKey = getNextApiKey(apiKey);
if (!actualKey) {
return { code: 400, images: null };
}
const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL;
const url = `${baseUrl}/3/${mediaType}/${mediaId}/images?api_key=${actualKey}`;
const response = await universalFetch(url, proxy);
if (!response.ok) {
console.error('TMDB Images API 请求失败:', response.status, response.statusText);
return { code: response.status, images: null };
}
const data: any = await response.json();
return {
code: 200,
images: data,
};
} catch (error) {
console.error('获取 TMDB 图片信息失败:', error);
return { code: 500, images: null };
}
}
+121 -28
View File
@@ -3,8 +3,7 @@ import bs58 from 'bs58';
import he from 'he';
import Hls from 'hls.js';
function getDoubanImageProxyConfig(): {
proxyType:
export type DoubanImageProxyType =
| 'direct'
| 'server'
| 'img3'
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
| 'cmliussss-cdn-ali'
| 'baidu'
| 'custom';
function normalizeDoubanImageProxyConfig(
proxyType: DoubanImageProxyType,
proxyUrl: string
): {
proxyType: DoubanImageProxyType;
proxyUrl: string;
} {
const normalizedProxyUrl = proxyUrl.trim();
if (proxyType === 'custom' && !normalizedProxyUrl) {
return {
proxyType: 'server',
proxyUrl: '',
};
}
return {
proxyType,
proxyUrl: normalizedProxyUrl,
};
}
function buildDoubanImageUrl(
originalUrl: string,
proxyType: DoubanImageProxyType,
proxyUrl: string
): string {
switch (proxyType) {
case 'server':
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
case 'img3':
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
case 'cmliussss-cdn-tencent':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.net'
);
case 'cmliussss-cdn-ali':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.com'
);
case 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
case 'custom':
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
case 'direct':
default:
return originalUrl;
}
}
function getDoubanImageProxyConfig(): {
proxyType: DoubanImageProxyType;
proxyUrl: string;
backupProxyType: DoubanImageProxyType;
backupProxyUrl: string;
} {
// 确保在浏览器环境中执行
if (typeof window === 'undefined') {
return {
proxyType: 'cmliussss-cdn-tencent',
proxyUrl: '',
backupProxyType: 'server',
backupProxyUrl: '',
};
}
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
localStorage.getItem('doubanImageProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
'';
const doubanImageProxyBackupType =
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
'server';
const doubanImageProxyBackupUrl =
localStorage.getItem('doubanImageProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanImageProxyConfig(
doubanImageProxyType,
doubanImageProxy
);
const backupConfig = normalizeDoubanImageProxyConfig(
doubanImageProxyBackupType,
doubanImageProxyBackupUrl
);
return {
proxyType: doubanImageProxyType,
proxyUrl: doubanImageProxy,
proxyType: primaryConfig.proxyType,
proxyUrl: primaryConfig.proxyUrl,
backupProxyType: backupConfig.proxyType,
backupProxyUrl: backupConfig.proxyUrl,
};
}
export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
return null;
}
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanImageProxyConfig();
const primaryUrl = buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
const backupUrl = buildDoubanImageUrl(
originalUrl,
backupProxyType,
backupProxyUrl
);
if (backupUrl === primaryUrl) {
return null;
}
return backupUrl;
}
export function tryApplyDoubanImageFallback(
target: HTMLImageElement,
originalUrl: string
): boolean {
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
return false;
}
if (target.dataset.doubanBackupTried === 'true') {
return false;
}
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
return false;
}
target.dataset.doubanBackupTried = 'true';
target.src = fallbackUrl;
return true;
}
/**
* URL使
*/
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
switch (proxyType) {
case 'server':
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
case 'img3':
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
case 'cmliussss-cdn-tencent':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.net'
);
case 'cmliussss-cdn-ali':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.com'
);
case 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
case 'custom':
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
case 'direct':
default:
return originalUrl;
}
return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
}
/**
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8');
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
const CURRENT_VERSION = '216.0.0';
const CURRENT_VERSION = '217.0.0';
// 导出当前版本号供其他地方使用
export { CURRENT_VERSION };
+147 -3
View File
@@ -16,6 +16,8 @@ export class WatchRoomServer {
private rooms: Map<string, Room> = new Map();
private members: Map<string, Map<string, Member>> = new Map(); // roomId -> userId -> Member
private socketToRoom: Map<string, RoomMemberInfo> = new Map(); // socketId -> RoomMemberInfo
private screenHelpers: Map<string, string> = new Map(); // roomId -> helperSocketId
private helperToRoom: Map<string, string> = new Map(); // helperSocketId -> roomId
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
@@ -40,6 +42,7 @@ export class WatchRoomServer {
description: data.description,
password: data.password,
isPublic: data.isPublic,
roomType: data.roomType || 'sync',
ownerId: userId,
ownerName: data.userName,
ownerToken: ownerToken, // 保存房主令牌
@@ -89,15 +92,33 @@ export class WatchRoomServer {
}
const userId = socket.id;
let isOwner = false;
if (data.ownerToken && data.ownerToken === room.ownerToken) {
isOwner = true;
room.ownerId = userId;
room.lastOwnerHeartbeat = Date.now();
this.rooms.set(data.roomId, room);
console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`);
}
const member: Member = {
id: userId,
name: data.userName,
isOwner: false,
isOwner,
lastHeartbeat: Date.now(),
};
const roomMembers = this.members.get(data.roomId);
if (roomMembers) {
if (isOwner) {
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
if (existingMember.isOwner && memberId !== userId) {
roomMembers.delete(memberId);
}
});
}
roomMembers.set(userId, member);
room.memberCount = roomMembers.size;
this.rooms.set(data.roomId, room);
@@ -107,7 +128,7 @@ export class WatchRoomServer {
roomId: data.roomId,
userId,
userName: data.userName,
isOwner: false,
isOwner,
});
socket.join(data.roomId);
@@ -115,7 +136,7 @@ export class WatchRoomServer {
// 通知房间内其他成员
socket.to(data.roomId).emit('room:member-joined', member);
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`);
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`);
const members = Array.from(roomMembers?.values() || []);
callback({ success: true, room, members });
@@ -199,6 +220,111 @@ export class WatchRoomServer {
}
});
socket.on('screen:helper-register', (data, callback) => {
try {
const room = this.rooms.get(data.roomId);
if (!room) {
callback({ success: false, error: '房间不存在' });
return;
}
if (room.ownerToken !== data.ownerToken) {
callback({ success: false, error: '房主身份验证失败' });
return;
}
const oldHelperSocketId = this.screenHelpers.get(data.roomId);
if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
this.helperToRoom.delete(oldHelperSocketId);
}
this.screenHelpers.set(data.roomId, socket.id);
this.helperToRoom.set(socket.id, data.roomId);
callback({ success: true });
} catch (error) {
console.error('[WatchRoom] Error registering screen helper:', error);
callback({ success: false, error: '注册共享控制窗口失败' });
}
});
socket.on('screen:start', (state) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
const roomId = roomInfo?.roomId || helperRoomId;
if (!roomId) return;
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
if (roomInfo && !roomInfo.isOwner) return;
const room = this.rooms.get(roomId);
if (room) {
room.currentState = state;
this.rooms.set(roomId, room);
this.io.to(roomId).emit('screen:start', state);
}
});
socket.on('screen:stop', () => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
const roomId = roomInfo?.roomId || helperRoomId;
if (!roomId) return;
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
if (roomInfo && !roomInfo.isOwner) return;
const room = this.rooms.get(roomId);
if (room) {
room.currentState = null;
this.rooms.set(roomId, room);
this.io.to(roomId).emit('screen:stop');
}
});
socket.on('screen:viewer-ready', () => {
const roomInfo = this.socketToRoom.get(socket.id);
if (!roomInfo) return;
const room = this.rooms.get(roomInfo.roomId);
if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
this.io.to(targetSocketId).emit('screen:viewer-ready', {
userId: socket.id,
});
});
socket.on('screen:offer', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
if (!roomInfo && !helperRoomId) return;
this.io.to(data.targetUserId).emit('screen:offer', {
userId: socket.id,
offer: data.offer,
});
});
socket.on('screen:answer', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
if (!roomInfo && !helperRoomId) return;
this.io.to(data.targetUserId).emit('screen:answer', {
userId: socket.id,
answer: data.answer,
});
});
socket.on('screen:ice', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
const helperRoomId = this.helperToRoom.get(socket.id);
if (!roomInfo && !helperRoomId) return;
this.io.to(data.targetUserId).emit('screen:ice', {
userId: socket.id,
candidate: data.candidate,
});
});
// 聊天消息
socket.on('chat:message', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
@@ -303,6 +429,19 @@ export class WatchRoomServer {
// 断开连接
socket.on('disconnect', () => {
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
const helperRoomId = this.helperToRoom.get(socket.id);
if (helperRoomId) {
this.helperToRoom.delete(socket.id);
if (this.screenHelpers.get(helperRoomId) === socket.id) {
this.screenHelpers.delete(helperRoomId);
const room = this.rooms.get(helperRoomId);
if (room && room.currentState?.type === 'screen') {
room.currentState = null;
this.rooms.set(helperRoomId, room);
this.io.to(helperRoomId).emit('screen:stop');
}
}
}
this.handleLeaveRoom(socket);
});
});
@@ -348,6 +487,11 @@ export class WatchRoomServer {
this.io.to(roomId).emit('room:deleted');
this.rooms.delete(roomId);
this.members.delete(roomId);
const helperSocketId = this.screenHelpers.get(roomId);
if (helperSocketId) {
this.helperToRoom.delete(helperSocketId);
this.screenHelpers.delete(roomId);
}
}
// 定时清理房间(房主断开5分钟后删除)
+40 -1
View File
@@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>
class WatchRoomSocketManager {
private socket: WatchRoomSocket | null = null;
private config: WatchRoomConfig | null = null;
private connectionPromise: Promise<WatchRoomSocket> | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
private lastHeartbeatResponse: number = Date.now();
@@ -25,6 +26,37 @@ class WatchRoomSocketManager {
return this.socket;
}
if (this.connectionPromise) {
return this.connectionPromise;
}
if (this.socket) {
this.connectionPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.connectionPromise = null;
reject(new Error('Socket connection timeout'));
}, 10000);
this.socket!.once('connect', () => {
clearTimeout(timeout);
this.connectionPromise = null;
resolve(this.socket!);
});
this.socket!.once('connect_error', (error) => {
clearTimeout(timeout);
this.connectionPromise = null;
reject(error);
});
if (!this.socket!.connected) {
this.socket!.connect();
}
});
return this.connectionPromise;
}
this.config = config;
const socketOptions = {
@@ -72,8 +104,9 @@ class WatchRoomSocketManager {
// 设置浏览器可见性监听
this.setupVisibilityListener();
return new Promise((resolve, reject) => {
this.connectionPromise = new Promise((resolve, reject) => {
if (!this.socket) {
this.connectionPromise = null;
reject(new Error('Socket not initialized'));
return;
}
@@ -82,6 +115,7 @@ class WatchRoomSocketManager {
this.socket.once('connect', () => {
// eslint-disable-next-line no-console
console.log('[WatchRoom] Connected to server');
this.connectionPromise = null;
if (this.socket) {
resolve(this.socket);
}
@@ -90,9 +124,12 @@ class WatchRoomSocketManager {
this.socket.once('connect_error', (error) => {
// eslint-disable-next-line no-console
console.error('[WatchRoom] Connection error:', error);
this.connectionPromise = null;
reject(error);
});
});
return this.connectionPromise;
}
disconnect() {
@@ -122,6 +159,8 @@ class WatchRoomSocketManager {
this.socket.disconnect();
this.socket = null;
}
this.connectionPromise = null;
}
getSocket(): WatchRoomSocket | null {
+29 -1
View File
@@ -6,15 +6,18 @@ export interface Room {
description: string;
password?: string;
isPublic: boolean;
roomType: RoomType;
ownerId: string;
ownerName: string;
ownerToken: string; // 房主令牌,用于重连时验证身份
memberCount: number;
currentState: PlayState | LiveState | null;
currentState: PlayState | LiveState | ScreenState | null;
createdAt: number;
lastOwnerHeartbeat: number;
}
export type RoomType = 'sync' | 'screen';
export interface Member {
id: string;
name: string;
@@ -42,6 +45,14 @@ export interface LiveState {
channelUrl: string;
}
export interface ScreenState {
type: 'screen';
status: 'idle' | 'sharing';
ownerName: string;
hasAudio?: boolean;
startedAt?: number;
}
export interface ChatMessage {
id: string;
userId: string;
@@ -73,6 +84,12 @@ export interface ServerToClientEvents {
'play:pause': () => void;
'play:change': (state: PlayState) => void;
'live:change': (state: LiveState) => void;
'screen:start': (state: ScreenState) => void;
'screen:stop': () => void;
'screen:viewer-ready': (data: { userId: string }) => void;
'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
'chat:message': (message: ChatMessage) => void;
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
@@ -90,6 +107,7 @@ export interface ClientToServerEvents {
description: string;
password?: string;
isPublic: boolean;
roomType: RoomType;
userName: string;
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
@@ -111,6 +129,16 @@ export interface ClientToServerEvents {
'play:change': (state: PlayState) => void;
'live:change': (state: LiveState) => void;
'screen:helper-register': (data: {
roomId: string;
ownerToken: string;
}, callback: (response: { success: boolean; error?: string }) => void) => void;
'screen:start': (state: ScreenState) => void;
'screen:stop': () => void;
'screen:viewer-ready': () => void;
'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;