diff --git a/.gitignore b/.gitignore
index 1437f13..f45089d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ public/workbox-*.js.map
*.db
*.db-shm
*.db-wal
+
+# local scripts
+scripts/tvbox/
diff --git a/CHANGELOG b/CHANGELOG
index 36fdd2a..86fe0a3 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,20 @@
+## [217.0.0] - 2026-04-10
+### Added
+- 新增注册邀请码功能
+- 增加弹幕内置源
+- 增加netlify部署支持
+- 观影室增加屏幕共享功能
+- 详情面板新增照片墙功能
+- 新增夸克网盘的转存与播放功能
+- 豆瓣数据源增加备用源功能
+
+### Changed
+- 移除音乐功能
+
+### Fixed
+- 修复弹幕选集分组无法鼠标滑轮滚动
+- 修复高级推荐报错无限刷新
+
## [216.0.0] - 2026-03-30
### Added
- 新增视频源脚本
diff --git a/README.md b/README.md
index 7f2d057..d51b088 100644
--- a/README.md
+++ b/README.md
@@ -88,10 +88,12 @@
## 部署
-本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
+本项目**支持 Docker、Vercel、Netlify 和 Cloudflare Workers 平台** 部署。
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
+[](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus)
+
**一键部署到 Zeabur**
[](https://zeabur.com/templates/SCHCAY/deploy)
diff --git a/VERSION.txt b/VERSION.txt
index b7ea4d1..2cc7045 100644
--- a/VERSION.txt
+++ b/VERSION.txt
@@ -1,2 +1 @@
-216.0.0
-
+217.0.0
diff --git a/server.js b/server.js
index ebf7e94..2cbf79a 100644
--- a/server.js
+++ b/server.js
@@ -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() {
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 52c4af0..263c6ff 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -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;
+}) => {
+ 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 (
+
+
+
+ 夸克网盘
+
+
+
+
+
+
+ 夸克网盘说明
+
+
+
+
• 转存:把整个分享保存到夸克正式目录。
+
• 立即播放:将分享内所有视频文件转存到临时播放目录,再通过 OpenList 临时目录直接播放。
+
• OpenList 临时目录必须映射到夸克临时播放目录,否则立即播放无法找到文件。
+
+
+
+
+
+
+ 启用夸克网盘
+
+
+ 开启后,网盘搜索中的夸克资源会显示“立即播放”和“转存”按钮
+
+
+
+
+
+
+
+
+
+
+
+ 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'
+ />
+
+
+
+
+ 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'
+ />
+
+
+
+
+
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'
+ />
+
+ OpenList 中能访问到临时播放目录的路径。
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
// Emby 媒体库配置组件 - 多源管理版本
const EmbyConfigComponent = ({
config,
@@ -7555,349 +7771,8 @@ const ThemeConfigComponent = ({
);
};
-// 音乐配置组件
-const MusicConfigComponent = ({
- config,
- refreshConfig,
-}: {
- config: AdminConfig | null;
- refreshConfig: () => Promise;
-}) => {
- 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 (
-
- {/* TuneHub 音乐配置 */}
-
-
- TuneHub 音乐配置
-
-
- {/* 开启音乐功能 */}
-
-
-
-
-
-
- 开启后将在首页显示音乐视听入口,支持网易云、QQ音乐、酷我音乐
-
-
-
- {/* TuneHub Base URL */}
-
-
-
- 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'
- />
-
- TuneHub API 的基础地址,默认为 https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置
-
-
-
- {/* TuneHub API Key */}
-
-
-
- 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'
- />
-
- 用于解析歌曲播放链接的 API Key(消耗积分)。搜索、榜单、歌单等功能不需要 Key。也可以通过环境变量 TUNEHUB_API_KEY 配置
-
-
-
-
- {/* OpenList 缓存配置 */}
-
-
- OpenList 缓存配置
-
-
- {/* 开启 OpenList 缓存 */}
-
-
-
-
-
-
- 开启后将音乐解析结果(播放链接、歌词、元信息)和音频文件缓存到 OpenList,减少 API 调用次数并支持离线播放
-
-
-
- {/* OpenList URL */}
-
-
-
- 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'
- />
-
- OpenList 服务器的完整地址(例如:https://your-openlist-server.com)
-
-
-
- {/* OpenList 用户名 */}
-
-
-
- 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'
- />
-
-
- {/* OpenList 密码 */}
-
-
-
- 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'
- />
-
- 用于登录 OpenList 并获取访问权限
-
-
-
- {/* OpenList 缓存目录 */}
-
-
-
- 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'
- />
-
- 音乐缓存在 OpenList 中的存储目录(例如:/music-cache)
-
-
-
- {/* 缓存代理返回开关 */}
-
-
-
-
-
-
- 开启后,如果 OpenList 有缓存,将通过代理方式返回给前端,并设置永久缓存头,提升加载速度
-
-
-
-
- {/* 操作按钮 */}
-
-
-
-
- {/* 弹窗 */}
-
-
- );
-};
+// 音乐配置组件(已停用)
+// 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 = ({
弹幕配置
- {/* 弹幕 API 地址 */}
-
-
-
+
+
+
- {/* 弹幕 API Token */}
-
-
-
- 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'
- />
-
- 弹幕服务器的访问令牌,默认为 87654321
+ {siteSettings.DanmakuSourceType !== 'custom' && (
+
+ ⚠️ 内置弹幕源为多人共享服务,稳定性可能受使用高峰影响,建议自行部署后使用自定义源。
-
+ )}
+
+ {siteSettings.DanmakuSourceType === 'custom' && (
+ <>
+ {/* 弹幕 API 地址 */}
+
+
+
+ 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'
+ />
+
+ 自定义弹幕服务器的 API 地址。API部署参考
+
+ danmu_api
+
+
+
+
+ {/* 弹幕 API Token */}
+
+
+
+ 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'
+ />
+
+ 自定义弹幕服务器的访问令牌,默认为 87654321
+
+
+ >
+ )}
@@ -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 = ({
注册配置
- {/* 开启注册 */}
-
-
-
-
+
+
+ 基础注册设置
+
+
+
+
+
+
+
+
+ 开启后登录页面将显示注册按钮,允许用户自行注册账号。
+
+
+
+
+
+
+
+ 新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制
+
+
-
- 开启后登录页面将显示注册按钮,允许用户自行注册账号。
-
-
+
- {/* 注册启用Cloudflare Turnstile */}
-
-
-
-
- {/* 登录启用Cloudflare Turnstile */}
-
-
-
-
- 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
- }`}
- >
-
+
+
+
+ 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
+ }`}
+ >
+
+
+
+
+ 开启后注册时需要通过Cloudflare Turnstile人机验证。
+ {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
+ 需要先配置Site Key和Secret Key才能启用。
+ )}
+
+
+
+
+
+
+
+ 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
+ }`}
+ >
+
+
+
+
+ 开启后登录时需要通过Cloudflare Turnstile人机验证。
+ {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
+ 需要先配置Site Key和Secret Key才能启用。
+ )}
+
+
+
+
+
+
+ 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'
/>
-
+
+ 在Cloudflare Dashboard中获取的Site Key(公钥)
+
+
+
+
+
+
+ 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'
+ />
+
+ 在Cloudflare Dashboard中获取的Secret Key(私钥),用于服务端验证
+
+
-
- 开启后登录时需要通过Cloudflare Turnstile人机验证。
- {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
- 需要先配置Site Key和Secret Key才能启用。
- )}
-
-
-
- {/* Cloudflare Turnstile Site Key */}
-
-
-
- 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'
- />
-
- 在Cloudflare Dashboard中获取的Site Key(公钥)
-
-
-
- {/* Cloudflare Turnstile Secret Key */}
-
-
-
- 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'
- />
-
- 在Cloudflare Dashboard中获取的Secret Key(私钥),用于服务端验证
-
-
-
- {/* 默认用户组 */}
-
-
-
-
- 新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制
-
-
+
{/* OIDC配置 */}
-
-
+
+
OIDC配置
-
-
- {/* 启用OIDC登录 */}
-
+
+
+ {/* 启用OIDC登录 */}
+
+
- {/* 启用OIDC注册 */}
-
+ {/* 启用OIDC注册 */}
+
+
- {/* OIDC Issuer */}
-
+ {/* OIDC Issuer */}
+
@@ -9514,10 +9514,10 @@ const RegistrationConfigComponent = ({
OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置
-
+
- {/* Authorization Endpoint */}
-
+ {/* Authorization Endpoint */}
+
@@ -9536,10 +9536,10 @@ const RegistrationConfigComponent = ({
用户授权的端点URL
-
+
- {/* Token Endpoint */}
-
+ {/* Token Endpoint */}
+
@@ -9558,10 +9558,10 @@ const RegistrationConfigComponent = ({
交换授权码获取token的端点URL
-
+
- {/* UserInfo Endpoint */}
-
+ {/* UserInfo Endpoint */}
+
@@ -9580,10 +9580,10 @@ const RegistrationConfigComponent = ({
获取用户信息的端点URL
-
+
- {/* OIDC Client ID */}
-
+ {/* OIDC Client ID */}
+
@@ -9602,10 +9602,10 @@ const RegistrationConfigComponent = ({
在OIDC提供商处注册应用后获得的Client ID
-
+
- {/* OIDC Client Secret */}
-
+ {/* OIDC Client Secret */}
+
@@ -9624,10 +9624,10 @@ const RegistrationConfigComponent = ({
在OIDC提供商处注册应用后获得的Client Secret
-
+
- {/* OIDC Redirect URI - 只读显示 */}
-
+ {/* OIDC Redirect URI - 只读显示 */}
+
@@ -9657,10 +9657,10 @@ const RegistrationConfigComponent = ({
这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI
-
+
- {/* OIDC登录按钮文字 */}
-
+ {/* OIDC登录按钮文字 */}
+
@@ -9679,10 +9679,10 @@ const RegistrationConfigComponent = ({
自定义OIDC登录按钮显示的文字,如"使用企业账号登录"、"使用SSO登录"等。留空则显示默认文字"使用OIDC登录"
-
+
- {/* OIDC最低信任等级 */}
-
+ {/* OIDC最低信任等级 */}
+
@@ -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'
/>
-
- 仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
-
+
+ 仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
+
+
-
+
{/* 操作按钮 */}
@@ -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() {
/>
- {/* 音乐配置标签 */}
-
- }
- isExpanded={expandedTabs.musicConfig}
- onToggle={() => toggleTab('musicConfig')}
- >
-
-
-
{/* 视频源配置标签 */}
+
+
+ }
+ isExpanded={expandedTabs.netDiskConfig}
+ onToggle={() => toggleTab('netDiskConfig')}
+ >
+
+
diff --git a/src/app/advanced-recommendation/page.tsx b/src/app/advanced-recommendation/page.tsx
index 08bba6c..5793a28 100644
--- a/src/app/advanced-recommendation/page.tsx
+++ b/src/app/advanced-recommendation/page.tsx
@@ -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 (
diff --git a/src/app/api/admin/netdisk/route.ts b/src/app/api/admin/netdisk/route.ts
new file mode 100644
index 0000000..0536e66
--- /dev/null
+++ b/src/app/api/admin/netdisk/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts
index 6619bff..b51a4f5 100644
--- a/src/app/api/admin/site/route.ts
+++ b/src/app/api/admin/site/route.ts
@@ -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,
diff --git a/src/app/api/danmaku/comment/route.ts b/src/app/api/danmaku/comment/route.ts
index c56059a..4c754f3 100644
--- a/src/app/api/danmaku/comment/route.ts
+++ b/src/app/api/danmaku/comment/route.ts
@@ -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;
diff --git a/src/app/api/danmaku/episodes/route.ts b/src/app/api/danmaku/episodes/route.ts
index b28e064..181e93b 100644
--- a/src/app/api/danmaku/episodes/route.ts
+++ b/src/app/api/danmaku/episodes/route.ts
@@ -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}`;
diff --git a/src/app/api/danmaku/match/route.ts b/src/app/api/danmaku/match/route.ts
index 972bf00..03b16b5 100644
--- a/src/app/api/danmaku/match/route.ts
+++ b/src/app/api/danmaku/match/route.ts
@@ -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`;
diff --git a/src/app/api/danmaku/search/route.ts b/src/app/api/danmaku/search/route.ts
index ecda7ef..5c025d5 100644
--- a/src/app/api/danmaku/search/route.ts
+++ b/src/app/api/danmaku/search/route.ts
@@ -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)}`;
diff --git a/src/app/api/netdisk/quark/instant-play/route.ts b/src/app/api/netdisk/quark/instant-play/route.ts
new file mode 100644
index 0000000..1e47de2
--- /dev/null
+++ b/src/app/api/netdisk/quark/instant-play/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/netdisk/quark/transfer/route.ts b/src/app/api/netdisk/quark/transfer/route.ts
new file mode 100644
index 0000000..fe0201b
--- /dev/null
+++ b/src/app/api/netdisk/quark/transfer/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts
index 9952c16..a7b756f 100644
--- a/src/app/api/openlist/play/route.ts
+++ b/src/app/api/openlist/play/route.ts
@@ -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,
});
}
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index 543df9f..ea0ad05 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -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 {
diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts
index def5431..f9b9ff0 100644
--- a/src/app/api/server-config/route.ts
+++ b/src/app/api/server-config/route.ts
@@ -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 || '',
diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts
index 381cf62..e0688c5 100644
--- a/src/app/api/source-detail/route.ts
+++ b/src/app/api/source-detail/route.ts
@@ -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> => {
+ 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++;
}
diff --git a/src/app/api/tmdb/images/route.ts b/src/app/api/tmdb/images/route.ts
new file mode 100644
index 0000000..e83120a
--- /dev/null
+++ b/src/app/api/tmdb/images/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index cf54887..22eb3a6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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,
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 1dfe253..6b67dea 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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() {
- {/* 音乐视听入口 */}
- {musicEnabled && (
-
-
-
-
-
- )}
+ {/* 音乐视听入口(暂时隐藏,后续可能恢复) */}
+ {/**
+ * {musicEnabled && (
+ *
+ *
+ *
+ *
+ *
+ * )}
+ */}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 1ac0d4c..6ceff05 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -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('');
+ const [quarkTempTMDBMeta, setQuarkTempTMDBMeta] = useState<{
+ desc?: string;
+ poster?: string;
+ year?: string;
+ tmdbId?: number;
+ } | null>(null);
+ const [pendingQuarkTempTMDBData, setPendingQuarkTempTMDBData] = useState(null);
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
@@ -579,6 +587,11 @@ function PlayPageClient() {
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
const isDirectPlay = currentSource === 'directplay';
+ useEffect(() => {
+ setQuarkTempTMDBMeta(null);
+ setPendingQuarkTempTMDBData(null);
+ }, [currentSource, currentId]);
+
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
if (source.startsWith('emby_')) {
@@ -1187,14 +1200,18 @@ function PlayPageClient() {
const detCacheAge = Date.now() - detTimestamp;
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
- if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
- console.log('使用缓存的TMDB详情数据');
- setTmdbBackdrop(processImageUrl(data.backdrop));
+ if (detCacheAge < detCacheMaxAge && data) {
+ if (data.backdrop) {
+ setTmdbBackdrop(processImageUrl(data.backdrop));
+ } else {
+ setTmdbBackdrop(null);
+ }
// 如果没有豆瓣ID,使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(data);
}
+ populatePlayMetadataFromTMDB(data);
return;
}
} catch (e) {
@@ -1215,7 +1232,6 @@ function PlayPageClient() {
const response = await fetch(url);
if (!response.ok) {
- console.log('获取TMDB详情失败');
setTmdbBackdrop(null);
return;
}
@@ -1224,45 +1240,94 @@ function PlayPageClient() {
if (result.backdrop) {
setTmdbBackdrop(processImageUrl(result.backdrop));
-
- // 如果没有豆瓣ID,使用TMDb数据补充
- if (!videoDoubanId || videoDoubanId === 0) {
- populateDoubanFieldsFromTMDB(result);
- }
-
- // 保存title到tmdbId的映射到localStorage(1个月)
- if (result.tmdbId) {
- try {
- localStorage.setItem(
- mappingCacheKey,
- JSON.stringify({
- tmdbId: result.tmdbId,
- timestamp: Date.now(),
- })
- );
-
- // 保存TMDB详情数据到localStorage(1天)
- const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
- localStorage.setItem(
- detailsCacheKey,
- JSON.stringify({
- data: result,
- timestamp: Date.now(),
- })
- );
- } catch (e) {
- console.error('保存缓存失败:', e);
- }
- }
} else {
setTmdbBackdrop(null);
}
+
+ // 如果没有豆瓣ID,使用TMDb数据补充
+ if (!videoDoubanId || videoDoubanId === 0) {
+ populateDoubanFieldsFromTMDB(result);
+ }
+ populatePlayMetadataFromTMDB(result);
+
+ // 保存title到tmdbId的映射到localStorage(1个月)
+ if (result.tmdbId) {
+ try {
+ localStorage.setItem(
+ mappingCacheKey,
+ JSON.stringify({
+ tmdbId: result.tmdbId,
+ timestamp: Date.now(),
+ })
+ );
+
+ // 保存TMDB详情数据到localStorage(1天)
+ const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
+ localStorage.setItem(
+ detailsCacheKey,
+ JSON.stringify({
+ data: result,
+ timestamp: Date.now(),
+ })
+ );
+ } catch (e) {
+ console.error('保存缓存失败:', e);
+ }
+ }
} catch (error) {
console.error('获取TMDB背景图失败:', error);
setTmdbBackdrop(null);
}
};
+ const populatePlayMetadataFromTMDB = (tmdbData: any) => {
+ const currentDetail = detailRef.current;
+ if (!currentDetail || currentDetail.source !== 'quark-temp') {
+ setPendingQuarkTempTMDBData(tmdbData);
+ return;
+ }
+
+ const tmdbYear = tmdbData.releaseDate?.split('-')[0] || '';
+ const shouldReplaceDesc = !currentDetail.desc || currentDetail.desc.startsWith('临时播放目录:');
+
+ const resolvedTmdbId = typeof tmdbData.tmdbId === 'string'
+ ? Number(String(tmdbData.tmdbId).split(':')[1] || 0)
+ : tmdbData.tmdbId;
+
+ setQuarkTempTMDBMeta({
+ desc: shouldReplaceDesc ? (tmdbData.overview || currentDetail.desc) : currentDetail.desc,
+ poster: currentDetail.poster || tmdbData.poster || '',
+ year: currentDetail.year || tmdbYear,
+ tmdbId: currentDetail.tmdb_id || resolvedTmdbId,
+ });
+
+ setDetail((prev) => {
+ if (!prev || prev.source !== 'quark-temp') {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ poster: prev.poster || tmdbData.poster || '',
+ year: prev.year || tmdbYear,
+ desc: shouldReplaceDesc ? (tmdbData.overview || prev.desc) : prev.desc,
+ tmdb_id: prev.tmdb_id || resolvedTmdbId,
+ };
+ });
+
+ if (tmdbData.overview && (!correctedDesc || currentDetail.desc?.startsWith('临时播放目录:'))) {
+ setCorrectedDesc(tmdbData.overview);
+ }
+
+ if (tmdbData.poster && !currentDetail.poster) {
+ setVideoCover(processImageUrl(tmdbData.poster));
+ }
+
+ if (tmdbYear && !currentDetail.year) {
+ setVideoYear(tmdbYear);
+ }
+ };
+
// 辅助函数:使用TMDb数据填充豆瓣字段
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
// 设置评分
@@ -1296,6 +1361,45 @@ function PlayPageClient() {
fetchTMDBBackdrop();
}, [videoTitle, videoDoubanId, isDirectPlay]);
+ useEffect(() => {
+ if (
+ pendingQuarkTempTMDBData &&
+ detail?.source === 'quark-temp'
+ ) {
+ const pending = pendingQuarkTempTMDBData;
+ setPendingQuarkTempTMDBData(null);
+ const tmdbYear = pending.releaseDate?.split('-')[0] || '';
+ const shouldReplaceDesc = !detail.desc || detail.desc.startsWith('临时播放目录:');
+ const resolvedTmdbId = typeof pending.tmdbId === 'string'
+ ? Number(String(pending.tmdbId).split(':')[1] || 0)
+ : pending.tmdbId;
+
+ setQuarkTempTMDBMeta({
+ desc: shouldReplaceDesc ? (pending.overview || detail.desc) : detail.desc,
+ poster: detail.poster || pending.poster || '',
+ year: detail.year || tmdbYear,
+ tmdbId: detail.tmdb_id || resolvedTmdbId,
+ });
+
+ setDetail((prev) => prev && prev.source === 'quark-temp' ? {
+ ...prev,
+ poster: prev.poster || pending.poster || '',
+ year: prev.year || tmdbYear,
+ desc: shouldReplaceDesc ? (pending.overview || prev.desc) : prev.desc,
+ tmdb_id: prev.tmdb_id || resolvedTmdbId,
+ } : prev);
+
+ if (pending.poster && !detail.poster) {
+ setVideoCover(processImageUrl(pending.poster));
+ }
+ if (tmdbYear && !detail.year) {
+ setVideoYear(tmdbYear);
+ }
+ if (pending.overview) {
+ setCorrectedDesc(pending.overview);
+ }
+ }
+ }, [pendingQuarkTempTMDBData, detail]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
@@ -1413,6 +1517,7 @@ function PlayPageClient() {
!isM3u8LikeUrl(videoUrl) &&
(
detail.source === 'openlist' ||
+ detail.source === 'quark-temp' ||
detail.source === 'xiaoya' ||
detail.source.startsWith('emby')
)
@@ -8871,12 +8976,12 @@ function PlayPageClient() {
)}
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
- {(doubanYear || detail?.year || videoYear) && (
- {doubanYear || detail?.year || videoYear}
+ {(doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear) && (
+ {doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear}
)}
{detail?.source_name && (
@@ -8896,7 +9001,7 @@ function PlayPageClient() {
{detail?.type_name && {detail.type_name}}
{/* 剧情简介 */}
- {(doubanCardSubtitle || correctedDesc || detail?.desc) && (
+ {(doubanCardSubtitle || quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc) && (
)}
- {correctedDesc || detail?.desc}
+ {quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc}
)}
@@ -8919,8 +9024,8 @@ function PlayPageClient() {
{videoCover ? (
<>
-
})
@@ -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)
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 0e19786..030a323 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -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
(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() {
+ {siteConfig?.RequireRegistrationInviteCode && (
+
+
+
+
+
+
+
setInviteCode(e.target.value)}
+ />
+
+
+ )}
+
{/* Cloudflare Turnstile */}
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
@@ -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'
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
index 39bc6ca..2118fbb 100644
--- a/src/app/search/page.tsx
+++ b/src/app/search/page.tsx
@@ -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() {
>
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
})
(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() {
- {currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
+ {currentRoom.roomType === 'screen'
+ ? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享'
+ : currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
@@ -246,12 +333,14 @@ export default function WatchRoomPage() {
{currentRoom.currentState.type === 'play'
? `${currentRoom.currentState.videoName || '未知视频'}`
- : `${currentRoom.currentState.channelName || '未知频道'}`}
+ : currentRoom.currentState.type === 'live'
+ ? `${currentRoom.currentState.channelName || '未知频道'}`
+ : '屏幕共享进行中'}
)}
{!currentRoom.currentState && (
- 当房主开始播放时,您将自动跟随
+ {currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}
)}
@@ -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() {
)}
- 与好友一起看视频,实时同步播放
+ 与好友一起看视频,支持进度同步或屏幕共享
@@ -360,7 +451,7 @@ export default function WatchRoomPage() {
)}
-
+
房间号
{currentRoom.id}
@@ -369,6 +460,10 @@ export default function WatchRoomPage() {
成员数
{members.length} 人
+
+
房间类型
+
{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}
+
@@ -402,7 +497,9 @@ export default function WatchRoomPage() {
{/* 提示信息 */}
- 💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作
+ 💡 {currentRoom.roomType === 'screen'
+ ? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
+ : '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
@@ -471,6 +568,38 @@ export default function WatchRoomPage() {
+
+
+
+
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'
+ }`}
+ >
+ 进度同步
+ 统一播放进度(适合双方网络稳定的情况)
+
+
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'
+ }`}
+ >
+ 屏幕共享
+ 房员直接观看房主共享的浏览器画面(适合完全实时同步的情况)
+
+
+
+
- 提示:创建房间后,您将成为房主。所有成员的播放进度将自动跟随您的操作。
+ 提示:创建房间后,您将成为房主。进度同步房会跟随播放状态,屏幕共享房会进入独立共享页。
)}
@@ -518,7 +647,7 @@ export default function WatchRoomPage() {
)}
-
+
房间号
{currentRoom.id}
@@ -527,6 +656,10 @@ export default function WatchRoomPage() {
成员数
{members.length} 人
+
+
房间类型
+
{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}
+
@@ -560,7 +693,9 @@ export default function WatchRoomPage() {
{/* 提示信息 */}
- 💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
+ 💡 {currentRoom.roomType === 'screen'
+ ? '这是屏幕共享房间,进入后即可观看房主共享画面'
+ : isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
@@ -617,7 +752,7 @@ export default function WatchRoomPage() {
{!currentRoom && (
- 提示:加入房间后,您的播放进度将自动跟随房主的操作。
+ 提示:加入进度同步房后将跟随播放,加入屏幕共享房后会进入共享页面。
)}
@@ -633,7 +768,7 @@ export default function WatchRoomPage() {
找到 {rooms.length} 个公开房间
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() {
房主
{room.ownerName}
+
+ 类型
+ {room.roomType === 'screen' ? '屏幕共享' : '进度同步'}
+
创建时间
{formatTime(room.createdAt)}
@@ -713,7 +852,9 @@ export default function WatchRoomPage() {
{room.currentState.type === 'play'
? `正在播放: ${room.currentState.videoName}`
- : `正在观看: ${room.currentState.channelName}`}
+ : room.currentState.type === 'live'
+ ? `正在观看: ${room.currentState.channelName}`
+ : '正在共享屏幕'}
)}
@@ -733,6 +874,7 @@ export default function WatchRoomPage() {
)}
+ {toast && }
);
}
diff --git a/src/app/watch-room/screen/page.tsx b/src/app/watch-room/screen/page.tsx
new file mode 100644
index 0000000..0bd73c1
--- /dev/null
+++ b/src/app/watch-room/screen/page.tsx
@@ -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(null);
+ const [qualityPreset, setQualityPreset] = useState('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 (
+
+
+
+
+
+
+ 屏幕共享观影室
+
+
+ 房间:{screenRoom.name} · 房主:{screenRoom.ownerName}
+
+
+
+ {isOwner && (
+ {
+ event.preventDefault();
+ openDetachedPage();
+ }}
+ className='rounded-lg bg-blue-500 px-4 py-2 text-white'
+ >
+ 新开主页
+
+ )}
+
+ 离开房间
+
+
+
+
+
+
+ {isOwner ? (
+
+ ) : (
+
+ )}
+
+ {!isSharing && (
+
+
+
+ {isOwner ? '点击开始共享,向房员推送浏览器画面' : '等待房主开始共享屏幕'}
+
+ {isOwner && (
+
+ 本页不要关闭;已尝试为你新开一个主页标签页方便继续浏览。
+
+ )}
+
+ )}
+
+
+
+
+
共享状态
+
+
类型:屏幕共享
+
状态:{isSharing ? '共享中' : '未开始'}
+
成员:{members.length} 人
+
+
+ {isOwner && (
+
+ 实际采集:{captureSettingsText}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {isOwner && (
+
+
+
+
+ 画质越高越清晰,但更依赖网络和设备性能。共享开始后不可切换。
+
+
+ )}
+
+
+ {isOwner ? (
+ <>
+
startSharing()}
+ disabled={isStarting || isSharing}
+ className='flex-1 rounded-lg bg-blue-500 px-4 py-2 text-white disabled:bg-gray-400'
+ >
+ {isStarting ? '启动中...' : isSharing ? '共享中' : '开始共享'}
+
+
stopSharing(true)}
+ disabled={!isSharing}
+ className='rounded-lg bg-red-500 px-4 py-2 text-white disabled:bg-gray-400'
+ >
+ 停止
+
+ >
+ ) : (
+
+ 房员无需操作,房主开始共享后会自动显示画面。
+
+ )}
+
+
+
+
+
+
+ 房间成员
+
+
+ {members.map((member) => (
+
+ {member.name}
+ {member.isOwner && (
+
+ 房主
+
+ )}
+
+ ))}
+
+
+
+
+ 建议使用桌面版 Chrome / Edge,并优先共享标签页。
+
+
+
+
+ {toast &&
}
+
+ );
+}
diff --git a/src/components/BannerCarousel.tsx b/src/components/BannerCarousel.tsx
index 5389268..cc2dae6 100644
--- a/src/components/BannerCarousel.tsx
+++ b/src/components/BannerCarousel.tsx
@@ -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
) : (
/* 显示图片 */
-
)}
{/* 渐变遮罩 */}
diff --git a/src/components/DanmakuPanel.tsx b/src/components/DanmakuPanel.tsx
index e4eeed4..1984521 100644
--- a/src/components/DanmakuPanel.tsx
+++ b/src/components/DanmakuPanel.tsx
@@ -35,9 +35,12 @@ export default function DanmakuPanel({
const [searchError, setSearchError] = useState(null);
const initializedRef = useRef(false); // 标记是否已初始化过
const fileInputRef = useRef(null);
+ const episodeGroupContainerRef = useRef(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 (
{/* 搜索区域 - 固定在顶部 */}
@@ -348,12 +407,20 @@ export default function DanmakuPanel({
{!isLoadingEpisodes && episodes.length > 0 && (
-
+
setIsEpisodeGroupHovered(true)}
+ onMouseLeave={() => setIsEpisodeGroupHovered(false)}
+ >
{episodeGroups.map((label, idx) => {
const isActive = idx === displayEpisodeGroupIndex;
return (
{
+ episodeGroupButtonRefs.current[idx] = el;
+ }}
onClick={() =>
setEpisodeGroupIndex(
episodeDescending ? episodeGroupCount - 1 - idx : idx
diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx
index 40cffb4..97335c1 100644
--- a/src/components/DetailPanel.tsx
+++ b/src/components/DetailPanel.tsx
@@ -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 = ({
isOpen,
onClose,
@@ -100,6 +111,15 @@ const DetailPanel: React.FC = ({
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState('');
+ const [showGallery, setShowGallery] = useState(false);
+ const [galleryLoading, setGalleryLoading] = useState(false);
+ const [galleryError, setGalleryError] = useState(null);
+ const [galleryImages, setGalleryImages] = useState([]);
+ const [galleryTotal, setGalleryTotal] = useState(0);
+ const [galleryScrollTop, setGalleryScrollTop] = useState(0);
+ const [galleryViewportHeight, setGalleryViewportHeight] = useState(0);
+ const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
+ const galleryScrollRef = React.useRef(null);
// 数据源状态管理
@@ -151,11 +171,82 @@ const DetailPanel: React.FC = ({
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 = ({
};
}, [isOpen]);
+ useEffect(() => {
+ if (!isOpen) {
+ setShowGallery(false);
+ }
+ }, [isOpen]);
+
// 阻止背景滚动(仅在非抽屉模式下)
useEffect(() => {
if (isVisible && !useDrawer) {
@@ -277,7 +374,7 @@ const DetailPanel: React.FC = ({
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 = ({
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 = ({
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 = ({
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 = ({
...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 = ({
}
};
+ const galleryEntryButton = canShowGalleryEntry ? (
+
+
+ 照片墙
+
+ ) : null;
+
+ const virtualGalleryLayout = React.useMemo(() => {
+ if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
+ return {
+ visibleItems: [] as Array,
+ 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 = (
+
+ {galleryLoading && (
+
+ )}
+
+ {!galleryLoading && galleryError && (
+
{galleryError}
+ )}
+
+ {!galleryLoading && !galleryError && galleryImages.length === 0 && (
+
暂无图片
+ )}
+
+ {!galleryLoading && !galleryError && galleryImages.length > 0 && (
+
+ {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 (
+
+
handleImageClick(imageUrl)}
+ >
+
+
+ {image.imageType === 'poster' ? '海报' : '剧照'}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+
+ const galleryHeader = (
+
+
+
照片墙
+ {!galleryLoading && (
+
+ 共 {galleryTotal} 张
+
+ )}
+
+
setShowGallery(false)}
+ className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
+ aria-label="关闭照片墙"
+ >
+
+
+
+ );
+
+ const galleryModal = showGallery ? (useDrawer ? (
+
+
+ {galleryHeader}
+ {galleryBody}
+
+
+ ) : (
+
+
setShowGallery(false)}
+ />
+
+ {galleryHeader}
+ {galleryBody}
+
+
+ )) : null;
+
if (!isVisible || !mounted) return null;
const content = useDrawer ? (
@@ -938,7 +1200,7 @@ const DetailPanel: React.FC
= ({
{/* 数据源显示和切换 - 错误时也显示 */}
-
+
数据来源:
@@ -948,24 +1210,27 @@ const DetailPanel: React.FC = ({
{currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && (
-
- 切换到 TMDB
-
- )}
- {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
-
- 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
-
- )}
+
+ {galleryEntryButton}
+ {currentSource !== 'tmdb' && (
+
+ 切换到 TMDB
+
+ )}
+ {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
+
+ 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
+
+ )}
+
@@ -976,11 +1241,19 @@ const DetailPanel: React.FC = ({
{/* 海报和基本信息 */}
{detailData.poster && (
-
handleImageClick(detailData.poster!)}
- >
-
+
+
handleImageClick(detailData.poster!)}
+ >
+
+
+ {galleryEntryButton}
)}
@@ -1103,13 +1376,12 @@ const DetailPanel: React.FC
= ({
{actor.profile_path ? (
handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
>
-
@@ -1221,14 +1493,13 @@ const DetailPanel: React.FC = ({
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'));
}}
>
-
@@ -1283,13 +1554,12 @@ const DetailPanel: React.FC
= ({
{episode.still_path && (
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
>
-
@@ -1332,7 +1602,7 @@ const DetailPanel: React.FC = ({
{/* 数据源显示和切换 */}
-
+
数据来源:
@@ -1342,24 +1612,27 @@ const DetailPanel: React.FC = ({
{currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && (
-
- 切换到 TMDB
-
- )}
- {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
-
- 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
-
- )}
+
+ {galleryEntryButton}
+ {currentSource !== 'tmdb' && (
+
+ 切换到 TMDB
+
+ )}
+ {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
+
+ 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
+
+ )}
+
@@ -1368,6 +1641,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */}
+ {galleryModal}
{showImageViewer && (
= ({
{/* 海报和基本信息 */}
{detailData.poster && (
-
handleImageClick(detailData.poster!)}
- >
-
+
+
handleImageClick(detailData.poster!)}
+ >
+
+
+ {galleryEntryButton}
)}
@@ -1607,13 +1889,12 @@ const DetailPanel: React.FC
= ({
{actor.profile_path ? (
handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
>
-
@@ -1725,14 +2006,13 @@ const DetailPanel: React.FC = ({
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'));
}}
>
-
@@ -1787,13 +2067,12 @@ const DetailPanel: React.FC
= ({
{episode.still_path && (
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
>
-
@@ -1872,6 +2151,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */}
+ {galleryModal}
{showImageViewer && (
= ({
{source.source === 'directplay' ? (
) : source.poster ? (
-
{
const target = e.target as HTMLImageElement;
target.style.display = 'none';
@@ -940,7 +942,7 @@ const EpisodeSelector: React.FC = ({
{/* 源名称和集数信息 - 垂直居中 */}
diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx
index 4eda739..c4c6fe2 100644
--- a/src/components/ImageViewer.tsx
+++ b/src/components/ImageViewer.tsx
@@ -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 = ({
onClick={(e) => e.stopPropagation()}
>
-
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx
index 1154e95..0371727 100644
--- a/src/components/MobileBottomNav.tsx
+++ b/src/components/MobileBottomNav.tsx
@@ -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: '/' },
{
diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx
index 0d78a75..915b6db 100644
--- a/src/components/PansouSearch.tsx
+++ b/src/components/PansouSearch.tsx
@@ -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(null);
const [error, setError] = useState(null);
const [copiedUrl, setCopiedUrl] = useState(null);
const [selectedType, setSelectedType] = useState('all'); // 'all' 表示显示全部
+ const [transferingUrl, setTransferingUrl] = useState(null);
+ const [playingUrl, setPlayingUrl] = useState(null);
+ const [toast, setToast] = useState(null);
// 提取搜索函数,以便在重试时调用
const searchPansou = useCallback(async () => {
@@ -118,205 +124,304 @@ export default function PansouSearch({
window.open(url, '_blank', 'noopener,noreferrer');
};
- if (loading) {
- return (
-
-
-
-
- 正在搜索网盘资源...
-
+ 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 (
+
-
- );
- }
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!results || results.total === 0 || !results.merged_by_type) {
+ return (
+
+ );
+ }
+
+ 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 (
-
-
-
-
{error}
+ <>
+ {/* 搜索结果统计 */}
+
+ 找到 {results.total} 个资源
+
+
+ {/* 网盘类型过滤器 */}
+
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})
+ {typeStats.map(({ type, count }) => {
+ const typeName = CLOUD_TYPE_NAMES[type] || type;
+
+ return (
+ 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})
+
+ );
+ })}
-
- );
- }
- if (!results || results.total === 0 || !results.merged_by_type) {
- return (
-
- );
- }
+ {/* 按网盘类型分类显示 */}
+ {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 (
-
- {/* 搜索结果统计 */}
-
- 找到 {results.total} 个资源
-
-
- {/* 网盘类型过滤器 */}
-
- 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})
-
- {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 (
- 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})
-
- );
- })}
-
+
+ {/* 网盘类型标题 */}
+
+
+ {typeName}
+
+
+ {links.length} 个链接
+
+
- {/* 按网盘类型分类显示 */}
- {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 (
-
- {/* 网盘类型标题 */}
-
-
- {typeName}
-
-
- {links.length} 个链接
-
-
-
- {/* 链接列表 */}
-
- {links.map((link: PansouLink, index: number) => (
-
- {/* 资源标题 */}
- {link.note && (
-
- {link.note}
-
- )}
-
- {/* 链接和密码 */}
-
-
-
- {link.url}
+ {/* 链接列表 */}
+
+ {links.map((link: PansouLink, index: number) => (
+
+ {/* 资源标题 */}
+ {link.note && (
+
+ {link.note}
- {link.password && (
-
- 提取码:
{link.password}
+ )}
+
+ {/* 链接和密码 */}
+
+
+
+ {link.url}
+ {link.password && (
+
+ 提取码: {link.password}
+
+ )}
+
+
+ {/* 操作按钮 */}
+
+ {cloudType === 'quark' && (
+ <>
+ 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 ? '处理中...' : '立即播放'}
+
+ 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 ? '转存中...' : '转存'}
+
+ >
+ )}
+ 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 ? (
+ 已复制
+ ) : (
+
+ )}
+
+ handleOpenLink(link.url)}
+ className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
+ title='打开链接'
+ >
+
+
+
+
+
+ {/* 来源和时间 */}
+
+ {link.source && (
+ 来源: {link.source}
+ )}
+ {link.datetime && (
+ {new Date(link.datetime).toLocaleDateString()}
)}
- {/* 操作按钮 */}
-
- 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 ? (
- 已复制
- ) : (
-
- )}
-
- handleOpenLink(link.url)}
- className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
- title='打开链接'
- >
-
-
-
-
-
- {/* 来源和时间 */}
-
- {link.source && (
-
来源: {link.source}
- )}
- {link.datetime && (
-
{new Date(link.datetime).toLocaleDateString()}
+ {/* 图片预览 */}
+ {link.images && link.images.length > 0 && (
+
+ {link.images.map((img, imgIndex) => (
+

+ ))}
+
)}
-
- {/* 图片预览 */}
- {link.images && link.images.length > 0 && (
-
- {link.images.map((img, imgIndex) => (
-

- ))}
-
- )}
-
- ))}
+ ))}
+
-
- );
- })}
-
+ );
+ })}
+ >
+ );
+ };
+
+ return (
+ <>
+
+ {renderBody()}
+
+ {toast &&
}
+ >
);
}
diff --git a/src/components/ProxyImage.tsx b/src/components/ProxyImage.tsx
new file mode 100644
index 0000000..5684344
--- /dev/null
+++ b/src/components/ProxyImage.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import React from 'react';
+
+import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
+
+interface ProxyImageProps extends React.ImgHTMLAttributes
{
+ originalSrc: string;
+ displaySrc?: string;
+ retryDelay?: number;
+ retryOnError?: boolean;
+}
+
+const ProxyImage: React.FC = ({
+ originalSrc,
+ displaySrc,
+ retryDelay = 2000,
+ retryOnError = true,
+ onError,
+ src: _src,
+ ...props
+}) => {
+ const handleError = (e: React.SyntheticEvent) => {
+ 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 (
+
+ );
+};
+
+export default ProxyImage;
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index ae0f270..2f16a65 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -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(() => {
if (
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index ad182bf..104694a 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -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() && (
+
+ 未填写地址时将自动按直连处理
+
+ )}
+
+ )}
+
+
+
+
+ 豆瓣数据备用渠道
+
+
+ 主渠道失败后自动切换,默认直连
+
+
+
+
+ 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
+ }
+
+
+
+
+ {isDoubanBackupDropdownOpen && (
+
+ {doubanDataSourceOptions.map((option) => (
+ {
+ 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'
+ }`}
+ >
+ {option.label}
+ {doubanDataSourceBackup === option.value && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {doubanDataSourceBackup === 'custom' && (
+
+
+
+ 豆瓣备用代理地址
+
+
+ 备用渠道为自定义代理时生效
+
+
+
+ handleDoubanProxyUrlBackupChange(e.target.value)
+ }
+ />
+ {!doubanProxyUrlBackup.trim() && (
+
+ 未填写地址时备用渠道将自动按直连处理
+
+ )}
)}
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
+ {!doubanImageProxyUrl.trim() && (
+
+ 未填写地址时将自动按服务器代理处理
+
+ )}
+
+ )}
+
+
+
+
+ 豆瓣图片备用渠道
+
+
+ 主图片渠道失败后自动切换,默认服务器代理
+
+
+
+
+ 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
+ }
+
+
+
+
+ {isDoubanImageProxyBackupDropdownOpen && (
+
+ {doubanImageProxyTypeOptions.map((option) => (
+ {
+ 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'
+ }`}
+ >
+ {option.label}
+ {doubanImageProxyTypeBackup === option.value && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {doubanImageProxyTypeBackup === 'custom' && (
+
+
+
+ 豆瓣图片备用代理地址
+
+
+ 备用图片渠道为自定义代理时生效
+
+
+
+ handleDoubanImageProxyUrlBackupChange(e.target.value)
+ }
+ />
+ {!doubanImageProxyUrlBackup.trim() && (
+
+ 未填写地址时备用图片渠道将自动按服务器代理处理
+
+ )}
)}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx
index 95ddcf7..13c6f2f 100644
--- a/src/components/VideoCard.tsx
+++ b/src/components/VideoCard.tsx
@@ -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
(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(function VideoCard
>
(function VideoCard
{config.showSourceName && source_name && !cmsData && (
(function VideoCard
setShowImageViewer(false)}
- imageUrl={processImageUrl(actualPoster)}
+ imageUrl={actualPoster}
alt={actualTitle}
/>
)}
diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx
index 930ec6a..cb464a6 100644
--- a/src/components/WatchRoomProvider.tsx
+++ b/src/components/WatchRoomProvider.tsx
@@ -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;
joinRoom: (data: {
roomId: string;
password?: string;
userName: string;
+ ownerToken?: string;
}) => Promise<{ room: Room; members: Member[] }>;
leaveRoom: () => void;
getRoomList: () => Promise;
@@ -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(null);
const [reconnectFailed, setReconnectFailed] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState(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,
};
diff --git a/src/hooks/useScreenShare.ts b/src/hooks/useScreenShare.ts
new file mode 100644
index 0000000..da88699
--- /dev/null
+++ b/src/hooks/useScreenShare.ts
@@ -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(null);
+ const remoteVideoRef = useRef(null);
+ const displayStreamRef = useRef(null);
+ const remoteStreamRef = useRef(null);
+ const peerConnectionsRef = useRef