From bf17abacf37dc59e7f39a0c4258ee86804a1f11a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 31 Mar 2026 12:53:19 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E7=A0=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 527 +++++++++++++++++------------ src/app/api/admin/site/route.ts | 8 + src/app/api/register/route.ts | 22 +- src/app/api/server-config/route.ts | 1 + src/app/layout.tsx | 3 + src/app/register/page.tsx | 30 ++ src/lib/admin.types.ts | 2 + src/lib/config.ts | 40 +++ 8 files changed, 409 insertions(+), 224 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 52c4af0..4721044 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -358,6 +358,8 @@ interface SiteConfig { MagnetAcgripReverseProxy?: string; EnableComments: boolean; EnableRegistration?: boolean; + RequireRegistrationInviteCode?: boolean; + RegistrationInviteCode?: string; RegistrationRequireTurnstile?: boolean; LoginRequireTurnstile?: boolean; TurnstileSiteKey?: string; @@ -9054,6 +9056,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 +9075,8 @@ const RegistrationConfigComponent = ({ OIDCMinTrustLevel: number; }>({ EnableRegistration: false, + RequireRegistrationInviteCode: false, + RegistrationInviteCode: '', RegistrationRequireTurnstile: false, LoginRequireTurnstile: false, TurnstileSiteKey: '', @@ -9092,6 +9098,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 +9148,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 +9198,269 @@ const RegistrationConfigComponent = ({ 注册配置 - {/* 开启注册 */} -
-
- - +
+ + 基础注册设置 + +
+
+
+ + +
+

+ 开启后登录页面将显示注册按钮,允许用户自行注册账号。 +

+
+ +
+ + +

+ 新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制 +

+
-

- 开启后登录页面将显示注册按钮,允许用户自行注册账号。 -

-
+ - {/* 注册启用Cloudflare Turnstile */} -
-
- - +
+

+ 开启后,普通注册必须填写管理员设置的统一邀请码。 +

+
+ +
+ + + setRegistrationSettings((prev) => ({ + ...prev, + RegistrationInviteCode: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' /> - -
-

- 开启后注册时需要通过Cloudflare Turnstile人机验证。 - {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && ( - 需要先配置Site Key和Secret Key才能启用。 - )} -

-
+

+ 仅普通注册生效;开启邀请码注册时不能为空。 +

+ - {/* 登录启用Cloudflare Turnstile */} -
-
- - +
+

+ 开启后注册时需要通过Cloudflare Turnstile人机验证。 + {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && ( + 需要先配置Site Key和Secret Key才能启用。 + )} +

+
+ +
+
+ + +
+

+ 开启后登录时需要通过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 +9594,10 @@ const RegistrationConfigComponent = ({

OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置

-
+
- {/* Authorization Endpoint */} -
+ {/* Authorization Endpoint */} +
@@ -9536,10 +9616,10 @@ const RegistrationConfigComponent = ({

用户授权的端点URL

-
+
- {/* Token Endpoint */} -
+ {/* Token Endpoint */} +
@@ -9558,10 +9638,10 @@ const RegistrationConfigComponent = ({

交换授权码获取token的端点URL

-
+
- {/* UserInfo Endpoint */} -
+ {/* UserInfo Endpoint */} +
@@ -9580,10 +9660,10 @@ const RegistrationConfigComponent = ({

获取用户信息的端点URL

-
+
- {/* OIDC Client ID */} -
+ {/* OIDC Client ID */} +
@@ -9602,10 +9682,10 @@ const RegistrationConfigComponent = ({

在OIDC提供商处注册应用后获得的Client ID

-
+
- {/* OIDC Client Secret */} -
+ {/* OIDC Client Secret */} +
@@ -9624,10 +9704,10 @@ const RegistrationConfigComponent = ({

在OIDC提供商处注册应用后获得的Client Secret

-
+
- {/* OIDC Redirect URI - 只读显示 */} -
+ {/* OIDC Redirect URI - 只读显示 */} +
@@ -9657,10 +9737,10 @@ const RegistrationConfigComponent = ({

这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI

-
+
- {/* OIDC登录按钮文字 */} -
+ {/* OIDC登录按钮文字 */} +
@@ -9679,10 +9759,10 @@ const RegistrationConfigComponent = ({

自定义OIDC登录按钮显示的文字,如"使用企业账号登录"、"使用SSO登录"等。留空则显示默认文字"使用OIDC登录"

-
+
- {/* OIDC最低信任等级 */} -
+ {/* OIDC最低信任等级 */} +
@@ -9700,11 +9780,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表示最低信任等级要求 +

+
-
+ {/* 操作按钮 */}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 6619bff..75ce279 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -58,6 +58,8 @@ export async function POST(request: NextRequest) { CustomAdFilterCode, CustomAdFilterVersion, EnableRegistration, + RequireRegistrationInviteCode, + RegistrationInviteCode, RegistrationRequireTurnstile, LoginRequireTurnstile, TurnstileSiteKey, @@ -103,6 +105,8 @@ export async function POST(request: NextRequest) { CustomAdFilterCode?: string; CustomAdFilterVersion?: number; EnableRegistration?: boolean; + RequireRegistrationInviteCode?: boolean; + RegistrationInviteCode?: string; RegistrationRequireTurnstile?: boolean; LoginRequireTurnstile?: boolean; TurnstileSiteKey?: string; @@ -148,6 +152,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') || @@ -208,6 +214,8 @@ export async function POST(request: NextRequest) { CustomAdFilterCode, CustomAdFilterVersion, EnableRegistration, + RequireRegistrationInviteCode, + RegistrationInviteCode, RegistrationRequireTurnstile, LoginRequireTurnstile, TurnstileSiteKey, 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/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/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/lib/admin.types.ts b/src/lib/admin.types.ts index eb74fc8..117f797 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -42,6 +42,8 @@ export interface AdminConfig { CustomAdFilterVersion?: number; // 代码版本号(时间戳) // 注册相关配置 EnableRegistration?: boolean; // 开启注册 + RequireRegistrationInviteCode?: boolean; // 注册时要求邀请码 + RegistrationInviteCode?: string; // 通用注册邀请码 RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key diff --git a/src/lib/config.ts b/src/lib/config.ts index 6216130..25090aa 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -263,6 +263,14 @@ async function getInitConfig(configFile: string, subConfig: { MagnetAcgripReverseProxy: '', // 评论功能开关 EnableComments: false, + EnableRegistration: false, + RequireRegistrationInviteCode: false, + RegistrationInviteCode: '', + RegistrationRequireTurnstile: false, + LoginRequireTurnstile: false, + TurnstileSiteKey: '', + TurnstileSecretKey: '', + DefaultUserTags: [], }, UserConfig: { Users: [], @@ -442,6 +450,14 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { MagnetDmhyReverseProxy: '', MagnetAcgripReverseProxy: '', EnableComments: false, + EnableRegistration: false, + RequireRegistrationInviteCode: false, + RegistrationInviteCode: '', + RegistrationRequireTurnstile: false, + LoginRequireTurnstile: false, + TurnstileSiteKey: '', + TurnstileSecretKey: '', + DefaultUserTags: [], }; } // 确保弹幕配置存在 @@ -455,6 +471,30 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { if (adminConfig.SiteConfig.EnableComments === undefined) { adminConfig.SiteConfig.EnableComments = false; } + if (adminConfig.SiteConfig.EnableRegistration === undefined) { + adminConfig.SiteConfig.EnableRegistration = false; + } + if (adminConfig.SiteConfig.RequireRegistrationInviteCode === undefined) { + adminConfig.SiteConfig.RequireRegistrationInviteCode = false; + } + if (adminConfig.SiteConfig.RegistrationInviteCode === undefined) { + adminConfig.SiteConfig.RegistrationInviteCode = ''; + } + if (adminConfig.SiteConfig.RegistrationRequireTurnstile === undefined) { + adminConfig.SiteConfig.RegistrationRequireTurnstile = false; + } + if (adminConfig.SiteConfig.LoginRequireTurnstile === undefined) { + adminConfig.SiteConfig.LoginRequireTurnstile = false; + } + if (adminConfig.SiteConfig.TurnstileSiteKey === undefined) { + adminConfig.SiteConfig.TurnstileSiteKey = ''; + } + if (adminConfig.SiteConfig.TurnstileSecretKey === undefined) { + adminConfig.SiteConfig.TurnstileSecretKey = ''; + } + if (adminConfig.SiteConfig.DefaultUserTags === undefined) { + adminConfig.SiteConfig.DefaultUserTags = []; + } if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) { adminConfig.SiteConfig.PansouKeywordBlocklist = ''; } From f9480a13e77868b70ffc9eeec1b4070fa3da1652 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 1 Apr 2026 17:26:59 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=B9=E5=B9=95?= =?UTF-8?q?=E9=80=89=E9=9B=86=E5=88=86=E7=BB=84=E6=97=A0=E6=B3=95=E9=BC=A0?= =?UTF-8?q?=E6=A0=87=E6=BB=91=E8=BD=AE=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ src/components/DanmakuPanel.tsx | 69 ++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) 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/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 ( +
- {/* 弹幕 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 +

+
+ + )}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 75ce279..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, @@ -86,6 +87,7 @@ export async function POST(request: NextRequest) { DoubanImageProxy: string; DisableYellowFilter: boolean; FluidSearch: boolean; + DanmakuSourceType?: 'builtin' | 'custom'; DanmakuApiBase: string; DanmakuApiToken: string; TMDBApiKey?: string; @@ -136,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') || @@ -195,6 +200,7 @@ export async function POST(request: NextRequest) { DoubanImageProxy, DisableYellowFilter, FluidSearch, + DanmakuSourceType, DanmakuApiBase, DanmakuApiToken, TMDBApiKey, 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/lib/admin.types.ts b/src/lib/admin.types.ts index 117f797..33b8332 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -17,6 +17,7 @@ export interface AdminConfig { DisableYellowFilter: boolean; FluidSearch: boolean; // 弹幕配置 + DanmakuSourceType?: 'builtin' | 'custom'; DanmakuApiBase: string; DanmakuApiToken: string; // TMDB配置 diff --git a/src/lib/config.ts b/src/lib/config.ts index 25090aa..c35c9c2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -4,6 +4,8 @@ import { db } from '@/lib/db'; import { AdminConfig } from './admin.types'; +const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321'; + export interface ApiSite { key: string; api: string; @@ -223,6 +225,9 @@ async function getInitConfig(configFile: string, subConfig: { } catch (e) { cfgFile = {} as ConfigFileStruct; } + const hasCustomDanmakuEnv = Boolean( + process.env.DANMAKU_API_BASE || process.env.DANMAKU_API_TOKEN + ); const adminConfig: AdminConfig = { ConfigFile: configSource, ConfigSubscribtion: subConfig, @@ -245,7 +250,10 @@ async function getInitConfig(configFile: string, subConfig: { FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', // 弹幕配置 - DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321', + DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin', + DanmakuApiBase: + process.env.DANMAKU_API_BASE || + (hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE), DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321', // TMDB配置 TMDBApiKey: process.env.TMDB_API_KEY || '', @@ -439,7 +447,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { DoubanImageProxy: '', DisableYellowFilter: false, FluidSearch: true, - DanmakuApiBase: 'http://localhost:9321', + DanmakuSourceType: 'builtin', + DanmakuApiBase: BUILTIN_DANMAKU_API_BASE, DanmakuApiToken: '87654321', PansouApiUrl: '', PansouUsername: '', @@ -461,8 +470,14 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { }; } // 确保弹幕配置存在 + if (adminConfig.SiteConfig.DanmakuSourceType === undefined) { + adminConfig.SiteConfig.DanmakuSourceType = 'custom'; + } if (!adminConfig.SiteConfig.DanmakuApiBase) { - adminConfig.SiteConfig.DanmakuApiBase = 'http://localhost:9321'; + adminConfig.SiteConfig.DanmakuApiBase = + adminConfig.SiteConfig.DanmakuSourceType === 'builtin' + ? BUILTIN_DANMAKU_API_BASE + : 'http://localhost:9321'; } if (!adminConfig.SiteConfig.DanmakuApiToken) { adminConfig.SiteConfig.DanmakuApiToken = '87654321'; diff --git a/src/lib/danmaku/config.ts b/src/lib/danmaku/config.ts new file mode 100644 index 0000000..0e9dec4 --- /dev/null +++ b/src/lib/danmaku/config.ts @@ -0,0 +1,19 @@ +import type { AdminConfig } from '@/lib/admin.types'; + +export const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321'; +export const BUILTIN_DANMAKU_API_TOKEN = '87654321'; + +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ''); +} + +export function getDanmakuApiBaseUrl(siteConfig: AdminConfig['SiteConfig']) { + if (siteConfig.DanmakuSourceType === 'builtin') { + return BUILTIN_DANMAKU_API_BASE; + } + + const base = trimTrailingSlash(siteConfig.DanmakuApiBase || 'http://localhost:9321'); + const token = (siteConfig.DanmakuApiToken || BUILTIN_DANMAKU_API_TOKEN).trim(); + + return token === BUILTIN_DANMAKU_API_TOKEN ? base : `${base}/${token}`; +} From 0285162e2f64121fe1411777c755a0b4ea6f7340 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 4 Apr 2026 01:47:22 +0800 Subject: [PATCH 05/18] =?UTF-8?q?netlify=E9=83=A8=E7=BD=B2=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 平台** 部署。 [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus) +[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus) + **一键部署到 Zeabur** [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/SCHCAY/deploy) From 2ab48b678fe5b0e1821c10d0fa5fd89c8fce8481 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 5 Apr 2026 00:15:53 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E8=A7=82=E5=BD=B1=E5=AE=A4=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=B1=8F=E5=B9=95=E5=85=B1=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 129 ++++++++++++ src/app/watch-room/page.tsx | 113 ++++++++-- src/app/watch-room/screen/page.tsx | 201 ++++++++++++++++++ src/components/MobileBottomNav.tsx | 4 + src/components/Sidebar.tsx | 4 + src/components/WatchRoomProvider.tsx | 25 ++- src/hooks/useScreenShare.ts | 297 +++++++++++++++++++++++++++ src/hooks/useWatchRoom.ts | 40 +++- src/lib/watch-room-server.ts | 126 ++++++++++++ src/types/watch-room.ts | 30 ++- 10 files changed, 946 insertions(+), 23 deletions(-) create mode 100644 src/app/watch-room/screen/page.tsx create mode 100644 src/hooks/useScreenShare.ts diff --git a/server.js b/server.js index ebf7e94..1ce336e 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, // 保存房主令牌 @@ -260,6 +263,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 +458,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 +549,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/watch-room/page.tsx b/src/app/watch-room/page.tsx index a824fb6..0c2eaf8 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -10,7 +10,7 @@ import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import PageLayout from '@/components/PageLayout'; import { useWatchRoomContext } from '@/components/WatchRoomProvider'; -import type { Room } from '@/types/watch-room'; +import type { Room, RoomType } from '@/types/watch-room'; type TabType = 'create' | 'join' | 'list'; @@ -34,6 +34,7 @@ export default function WatchRoomPage() { description: '', password: '', isPublic: true, + roomType: 'sync' as RoomType, }); // 加入房间表单 @@ -49,26 +50,30 @@ export default function WatchRoomPage() { const [joinLoading, setJoinLoading] = useState(false); // 加载房间列表 - 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]); @@ -88,6 +93,7 @@ export default function WatchRoomPage() { description: createForm.description.trim(), password: createForm.password.trim() || undefined, isPublic: createForm.isPublic, + roomType: createForm.roomType, userName: currentUsername, }); @@ -97,6 +103,7 @@ export default function WatchRoomPage() { description: '', password: '', isPublic: true, + roomType: 'sync', }); } catch (error: any) { alert(error.message || '创建房间失败'); @@ -141,6 +148,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 +165,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,6 +210,13 @@ 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) => { setJoinForm({ @@ -237,7 +258,9 @@ export default function WatchRoomPage() {

- {currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'} + {currentRoom.roomType === 'screen' + ? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享' + : currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}

房间: {currentRoom.name} | 房主: {currentRoom.ownerName} @@ -246,12 +269,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 +305,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 +330,7 @@ export default function WatchRoomPage() { )}

- 与好友一起看视频,实时同步播放 + 与好友一起看视频,支持进度同步或屏幕共享

@@ -360,7 +387,7 @@ export default function WatchRoomPage() { )}
-
+

房间号

{currentRoom.id}

@@ -369,6 +396,10 @@ export default function WatchRoomPage() {

成员数

{members.length} 人

+
+

房间类型

+

{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}

+
@@ -402,7 +433,9 @@ export default function WatchRoomPage() { {/* 提示信息 */}

- 💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作 + 💡 {currentRoom.roomType === 'screen' + ? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享' + : '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}

@@ -471,6 +504,38 @@ export default function WatchRoomPage() {
+
+ +
+ + +
+
+ + + + +
+
+ {isOwner ? ( +
+ +
+
+

共享状态

+
+

类型:屏幕共享

+

状态:{isSharing ? '共享中' : '未开始'}

+

成员:{members.length} 人

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {isOwner ? ( + <> + + + + ) : ( +
+ 房员无需操作,房主开始共享后会自动显示画面。 +
+ )} +
+
+ +
+

+ + 房间成员 +

+
+ {members.map((member) => ( +
+ {member.name} + {member.isOwner && ( + + 房主 + + )} +
+ ))} +
+
+ +
+ 本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。 +
+
+
+ + + ); +} 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/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/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index 930ec6a..cbd9af1 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -2,6 +2,7 @@ 'use client'; import React, { createContext, useCallback,useContext, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useWatchRoom } from '@/hooks/useWatchRoom'; @@ -9,7 +10,7 @@ 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; @@ -31,6 +32,7 @@ interface WatchRoomContextType { description: string; password?: string; isPublic: boolean; + roomType: RoomType; userName: string; }) => Promise; joinRoom: (data: { @@ -51,6 +53,8 @@ interface WatchRoomContextType { pause: () => void; changeVideo: (state: any) => void; changeLiveChannel: (state: any) => void; + startScreenShare: (state: ScreenState) => void; + stopScreenShare: () => void; clearRoomState: () => void; // 重连 @@ -77,6 +81,7 @@ interface WatchRoomProviderProps { } export function WatchRoomProvider({ children }: WatchRoomProviderProps) { + const searchParams = useSearchParams(); const [config, setConfig] = useState(null); const [isEnabled, setIsEnabled] = useState(false); const [toast, setToast] = useState(null); @@ -118,6 +123,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { }, []); const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared); + const shouldDisableWatchRoomConnection = searchParams.get('watchRoomNoConnect') === '1'; // 检查登录状态 useEffect(() => { @@ -169,6 +175,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { // 加载配置 useEffect(() => { + if (shouldDisableWatchRoomConnection) { + setConfig({ + enabled: false, + serverType: 'internal', + }); + setIsEnabled(false); + return; + } + const loadConfig = async () => { try { // 使用公共 API 获取观影室配置(不需要管理员权限) @@ -253,12 +268,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 +298,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..3fdc57c --- /dev/null +++ b/src/hooks/useScreenShare.ts @@ -0,0 +1,297 @@ +'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 function useScreenShare() { + const watchRoom = useWatchRoomContextSafe(); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const displayStreamRef = useRef(null); + const remoteStreamRef = useRef(null); + const peerConnectionsRef = useRef>(new Map()); + const stoppingRef = useRef(false); + + const [error, setError] = useState(null); + const [isStarting, setIsStarting] = useState(false); + + const currentRoom = watchRoom?.currentRoom || null; + const socket = watchRoom?.socket || null; + const isOwner = watchRoom?.isOwner || false; + const members = watchRoom?.members || []; + const currentState = currentRoom?.currentState; + const isSharing = currentState?.type === 'screen' && currentState.status === 'sharing'; + + const closePeerConnection = useCallback((userId: string) => { + const pc = peerConnectionsRef.current.get(userId); + if (!pc) return; + + pc.onicecandidate = null; + pc.ontrack = null; + pc.close(); + peerConnectionsRef.current.delete(userId); + }, []); + + const clearRemoteVideo = useCallback(() => { + remoteStreamRef.current = null; + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = null; + } + }, []); + + const cleanupSharingResources = useCallback(() => { + if (stoppingRef.current) return; + stoppingRef.current = true; + + peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId)); + peerConnectionsRef.current.clear(); + + if (displayStreamRef.current) { + displayStreamRef.current.getTracks().forEach((track) => { + track.onended = null; + track.stop(); + }); + displayStreamRef.current = null; + } + + if (localVideoRef.current) { + localVideoRef.current.srcObject = null; + } + + clearRemoteVideo(); + stoppingRef.current = false; + }, [clearRemoteVideo, closePeerConnection]); + + const stopSharing = useCallback((notifyServer = true) => { + cleanupSharingResources(); + + if (notifyServer && isOwner) { + watchRoom?.stopScreenShare(); + } + }, [cleanupSharingResources, isOwner, watchRoom]); + + const createPeerConnection = useCallback((userId: string, ownerMode: boolean) => { + const existing = peerConnectionsRef.current.get(userId); + if (existing) return existing; + + const pc = new RTCPeerConnection({ iceServers }); + + pc.onicecandidate = (event) => { + if (event.candidate && socket) { + socket.emit('screen:ice', { + targetUserId: userId, + candidate: event.candidate.toJSON(), + }); + } + }; + + if (ownerMode && displayStreamRef.current) { + displayStreamRef.current.getTracks().forEach((track) => { + pc.addTrack(track, displayStreamRef.current!); + }); + } else { + pc.ontrack = (event) => { + const stream = event.streams[0]; + remoteStreamRef.current = stream; + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = stream; + } + }; + } + + peerConnectionsRef.current.set(userId, pc); + return pc; + }, [socket]); + + const sendOfferToMember = useCallback(async (memberId: string) => { + if (!socket || !displayStreamRef.current) return; + + try { + const pc = createPeerConnection(memberId, true); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('screen:offer', { + targetUserId: memberId, + offer, + }); + } catch (err) { + console.error('[ScreenShare] Failed to send offer:', err); + setError('无法建立屏幕共享连接'); + } + }, [createPeerConnection, socket]); + + const startSharing = useCallback(async () => { + if (!watchRoom || !currentRoom || !isOwner) return; + + setIsStarting(true); + setError(null); + + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { + frameRate: 15, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: true, + }); + + displayStreamRef.current = stream; + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } + + const videoTrack = stream.getVideoTracks()[0]; + if (videoTrack) { + videoTrack.onended = () => { + stopSharing(true); + }; + } + + const state: ScreenState = { + type: 'screen', + status: 'sharing', + ownerName: currentRoom.ownerName, + hasAudio: stream.getAudioTracks().length > 0, + startedAt: Date.now(), + }; + + watchRoom.startScreenShare(state); + + await Promise.all( + members.filter((member) => !member.isOwner).map((member) => sendOfferToMember(member.id)) + ); + } catch (err: any) { + console.error('[ScreenShare] Failed to start sharing:', err); + setError(err?.message || '开启屏幕共享失败'); + } finally { + setIsStarting(false); + } + }, [currentRoom, isOwner, members, sendOfferToMember, stopSharing, watchRoom]); + + useEffect(() => { + if (!socket || !currentRoom) return; + + const handleOffer = async (data: { userId: string; offer: RTCSessionDescriptionInit }) => { + if (isOwner) return; + + try { + const pc = createPeerConnection(data.userId, false); + await pc.setRemoteDescription(new RTCSessionDescription(data.offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('screen:answer', { + targetUserId: data.userId, + answer, + }); + } catch (err) { + console.error('[ScreenShare] Failed to handle offer:', err); + setError('接收共享画面失败'); + } + }; + + const handleAnswer = async (data: { userId: string; answer: RTCSessionDescriptionInit }) => { + if (!isOwner) return; + + const pc = peerConnectionsRef.current.get(data.userId); + if (!pc) return; + + try { + await pc.setRemoteDescription(new RTCSessionDescription(data.answer)); + } catch (err) { + console.error('[ScreenShare] Failed to handle answer:', err); + } + }; + + const handleIce = async (data: { userId: string; candidate: RTCIceCandidateInit }) => { + const pc = peerConnectionsRef.current.get(data.userId); + if (!pc) return; + + try { + await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); + } catch (err) { + console.error('[ScreenShare] Failed to handle ICE:', err); + } + }; + + const handleScreenStop = () => { + if (!isOwner) { + peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId)); + peerConnectionsRef.current.clear(); + clearRemoteVideo(); + } + }; + + const handleViewerReady = (data: { userId: string }) => { + if (!isOwner || !displayStreamRef.current) return; + sendOfferToMember(data.userId); + }; + + socket.on('screen:offer', handleOffer); + socket.on('screen:answer', handleAnswer); + socket.on('screen:ice', handleIce); + socket.on('screen:stop', handleScreenStop); + socket.on('screen:viewer-ready', handleViewerReady); + + return () => { + socket.off('screen:offer', handleOffer); + socket.off('screen:answer', handleAnswer); + socket.off('screen:ice', handleIce); + socket.off('screen:stop', handleScreenStop); + socket.off('screen:viewer-ready', handleViewerReady); + }; + }, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]); + + useEffect(() => { + if (!isOwner || !isSharing || !displayStreamRef.current) return; + + members + .filter((member) => !member.isOwner) + .forEach((member) => { + if (!peerConnectionsRef.current.has(member.id)) { + sendOfferToMember(member.id); + } + }); + + Array.from(peerConnectionsRef.current.keys()).forEach((userId) => { + const stillInRoom = members.some((member) => member.id === userId && !member.isOwner); + if (!stillInRoom) { + closePeerConnection(userId); + } + }); + }, [closePeerConnection, isOwner, isSharing, members, sendOfferToMember]); + + useEffect(() => { + return () => { + cleanupSharingResources(); + }; + }, [cleanupSharingResources]); + + useEffect(() => { + if (!socket || !currentRoom || isOwner) return; + if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return; + + socket.emit('screen:viewer-ready'); + }, [currentRoom, currentState, isOwner, socket]); + + return { + currentRoom, + isOwner, + isSharing, + isStarting, + error, + localVideoRef, + remoteVideoRef, + startSharing, + stopSharing, + }; +} diff --git a/src/hooks/useWatchRoom.ts b/src/hooks/useWatchRoom.ts index 0f19c75..5700550 100644 --- a/src/hooks/useWatchRoom.ts +++ b/src/hooks/useWatchRoom.ts @@ -11,6 +11,8 @@ import type { Member, PlayState, Room, + RoomType, + ScreenState, StoredRoomInfo, WatchRoomConfig, } from '@/types/watch-room'; @@ -101,7 +103,7 @@ export function useWatchRoom( // 创建房间 const createRoom = useCallback( - async (data: { name: string; description: string; password?: string; isPublic: boolean; userName: string }) => { + async (data: { name: string; description: string; password?: string; isPublic: boolean; roomType: RoomType; userName: string }) => { const sock = watchRoomSocketManager.getSocket(); if (!sock || !watchRoomSocketManager.isConnected()) { throw new Error('Not connected'); @@ -295,6 +297,25 @@ export function useWatchRoom( [isOwner] ); + // 开始屏幕共享 + const startScreenShare = useCallback( + (state: ScreenState) => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('screen:start', state); + }, + [isOwner] + ); + + // 停止屏幕共享 + const stopScreenShare = useCallback(() => { + const sock = watchRoomSocketManager.getSocket(); + if (!sock || !isOwner) return; + + sock.emit('screen:stop'); + }, [isOwner]); + // 清除房间播放状态(房主离开播放/直播页面时调用) const clearRoomState = useCallback(() => { const sock = watchRoomSocketManager.getSocket(); @@ -362,6 +383,19 @@ export function useWatchRoom( } }); + // 屏幕共享事件 + socket.on('screen:start', (state) => { + if (currentRoom) { + setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null)); + } + }); + + socket.on('screen:stop', () => { + if (currentRoom) { + setCurrentRoom((prev) => (prev ? { ...prev, currentState: null } : null)); + } + }); + // 聊天事件 socket.on('chat:message', (message) => { setChatMessages((prev) => [...prev, message]); @@ -395,6 +429,8 @@ export function useWatchRoom( socket.off('play:update'); socket.off('play:change'); socket.off('live:change'); + socket.off('screen:start'); + socket.off('screen:stop'); socket.off('chat:message'); socket.off('state:cleared'); socket.off('connect'); @@ -431,6 +467,8 @@ export function useWatchRoom( pause, changeVideo, changeLiveChannel, + startScreenShare, + stopScreenShare, clearRoomState, }; } diff --git a/src/lib/watch-room-server.ts b/src/lib/watch-room-server.ts index 2d4afad..deb1862 100644 --- a/src/lib/watch-room-server.ts +++ b/src/lib/watch-room-server.ts @@ -16,6 +16,8 @@ export class WatchRoomServer { private rooms: Map = new Map(); private members: Map> = new Map(); // roomId -> userId -> Member private socketToRoom: Map = new Map(); // socketId -> RoomMemberInfo + private screenHelpers: Map = new Map(); // roomId -> helperSocketId + private helperToRoom: Map = new Map(); // helperSocketId -> roomId private cleanupInterval: NodeJS.Timeout | null = null; constructor(private io: SocketIOServer) { @@ -40,6 +42,7 @@ export class WatchRoomServer { description: data.description, password: data.password, isPublic: data.isPublic, + roomType: data.roomType || 'sync', ownerId: userId, ownerName: data.userName, ownerToken: ownerToken, // 保存房主令牌 @@ -199,6 +202,111 @@ export class WatchRoomServer { } }); + socket.on('screen:helper-register', (data, callback) => { + try { + const room = this.rooms.get(data.roomId); + if (!room) { + callback({ success: false, error: '房间不存在' }); + return; + } + + if (room.ownerToken !== data.ownerToken) { + callback({ success: false, error: '房主身份验证失败' }); + return; + } + + const oldHelperSocketId = this.screenHelpers.get(data.roomId); + if (oldHelperSocketId && oldHelperSocketId !== socket.id) { + this.helperToRoom.delete(oldHelperSocketId); + } + + this.screenHelpers.set(data.roomId, socket.id); + this.helperToRoom.set(socket.id, data.roomId); + callback({ success: true }); + } catch (error) { + console.error('[WatchRoom] Error registering screen helper:', error); + callback({ success: false, error: '注册共享控制窗口失败' }); + } + }); + + socket.on('screen:start', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + const roomId = roomInfo?.roomId || helperRoomId; + if (!roomId) return; + if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return; + if (roomInfo && !roomInfo.isOwner) return; + + const room = this.rooms.get(roomId); + if (room) { + room.currentState = state; + this.rooms.set(roomId, room); + this.io.to(roomId).emit('screen:start', state); + } + }); + + socket.on('screen:stop', () => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + const roomId = roomInfo?.roomId || helperRoomId; + if (!roomId) return; + if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return; + if (roomInfo && !roomInfo.isOwner) return; + + const room = this.rooms.get(roomId); + if (room) { + room.currentState = null; + this.rooms.set(roomId, room); + this.io.to(roomId).emit('screen:stop'); + } + }); + + socket.on('screen:viewer-ready', () => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo) return; + + const room = this.rooms.get(roomInfo.roomId); + if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return; + + const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId; + this.io.to(targetSocketId).emit('screen:viewer-ready', { + userId: socket.id, + }); + }); + + socket.on('screen:offer', (data) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + if (!roomInfo && !helperRoomId) return; + + this.io.to(data.targetUserId).emit('screen:offer', { + userId: socket.id, + offer: data.offer, + }); + }); + + socket.on('screen:answer', (data) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + if (!roomInfo && !helperRoomId) return; + + this.io.to(data.targetUserId).emit('screen:answer', { + userId: socket.id, + answer: data.answer, + }); + }); + + socket.on('screen:ice', (data) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + if (!roomInfo && !helperRoomId) return; + + this.io.to(data.targetUserId).emit('screen:ice', { + userId: socket.id, + candidate: data.candidate, + }); + }); + // 聊天消息 socket.on('chat:message', (data) => { const roomInfo = this.socketToRoom.get(socket.id); @@ -303,6 +411,19 @@ export class WatchRoomServer { // 断开连接 socket.on('disconnect', () => { console.log(`[WatchRoom] Client disconnected: ${socket.id}`); + const helperRoomId = this.helperToRoom.get(socket.id); + if (helperRoomId) { + this.helperToRoom.delete(socket.id); + if (this.screenHelpers.get(helperRoomId) === socket.id) { + this.screenHelpers.delete(helperRoomId); + const room = this.rooms.get(helperRoomId); + if (room && room.currentState?.type === 'screen') { + room.currentState = null; + this.rooms.set(helperRoomId, room); + this.io.to(helperRoomId).emit('screen:stop'); + } + } + } this.handleLeaveRoom(socket); }); }); @@ -348,6 +469,11 @@ export class WatchRoomServer { this.io.to(roomId).emit('room:deleted'); this.rooms.delete(roomId); this.members.delete(roomId); + const helperSocketId = this.screenHelpers.get(roomId); + if (helperSocketId) { + this.helperToRoom.delete(helperSocketId); + this.screenHelpers.delete(roomId); + } } // 定时清理房间(房主断开5分钟后删除) diff --git a/src/types/watch-room.ts b/src/types/watch-room.ts index 6bbf1ca..a77b914 100644 --- a/src/types/watch-room.ts +++ b/src/types/watch-room.ts @@ -6,15 +6,18 @@ export interface Room { description: string; password?: string; isPublic: boolean; + roomType: RoomType; ownerId: string; ownerName: string; ownerToken: string; // 房主令牌,用于重连时验证身份 memberCount: number; - currentState: PlayState | LiveState | null; + currentState: PlayState | LiveState | ScreenState | null; createdAt: number; lastOwnerHeartbeat: number; } +export type RoomType = 'sync' | 'screen'; + export interface Member { id: string; name: string; @@ -42,6 +45,14 @@ export interface LiveState { channelUrl: string; } +export interface ScreenState { + type: 'screen'; + status: 'idle' | 'sharing'; + ownerName: string; + hasAudio?: boolean; + startedAt?: number; +} + export interface ChatMessage { id: string; userId: string; @@ -73,6 +84,12 @@ export interface ServerToClientEvents { 'play:pause': () => void; 'play:change': (state: PlayState) => void; 'live:change': (state: LiveState) => void; + 'screen:start': (state: ScreenState) => void; + 'screen:stop': () => void; + 'screen:viewer-ready': (data: { userId: string }) => void; + 'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void; + 'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void; + 'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void; 'chat:message': (message: ChatMessage) => void; 'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void; 'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void; @@ -90,6 +107,7 @@ export interface ClientToServerEvents { description: string; password?: string; isPublic: boolean; + roomType: RoomType; userName: string; }, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void; @@ -111,6 +129,16 @@ export interface ClientToServerEvents { 'play:change': (state: PlayState) => void; 'live:change': (state: LiveState) => void; + 'screen:helper-register': (data: { + roomId: string; + ownerToken: string; + }, callback: (response: { success: boolean; error?: string }) => void) => void; + 'screen:start': (state: ScreenState) => void; + 'screen:stop': () => void; + 'screen:viewer-ready': () => void; + 'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void; + 'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void; + 'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void; 'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void; From 14d4c483f366e7ae7e2561a063a06602f5f78fe6 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 5 Apr 2026 10:38:42 +0800 Subject: [PATCH 07/18] =?UTF-8?q?=E5=BB=BA=E6=88=BF=E9=A2=84=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/watch-room/page.tsx | 73 ++++++++++++++++++++++++++++-- src/app/watch-room/screen/page.tsx | 55 +++++++++++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx index 0c2eaf8..15f9fa5 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -8,12 +8,41 @@ import { useEffect,useState } from 'react'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import PageLayout from '@/components/PageLayout'; +import Toast, { ToastProps } from '@/components/Toast'; import { useWatchRoomContext } from '@/components/WatchRoomProvider'; import type { Room, RoomType } from '@/types/watch-room'; type TabType = 'create' | 'join' | 'list'; +function getScreenShareHostSupportError() { + if (typeof window === 'undefined') return null; + + if (!window.isSecureContext) { + return '当前环境不是安全上下文(HTTPS/localhost),不支持屏幕共享'; + } + + if (!navigator.mediaDevices?.getDisplayMedia) { + return '当前浏览器不支持屏幕共享'; + } + + if (typeof window.RTCPeerConnection === 'undefined') { + return '当前浏览器不支持实时屏幕传输'; + } + + return null; +} + +function getScreenShareViewerSupportError() { + if (typeof window === 'undefined') return null; + + if (typeof window.RTCPeerConnection === 'undefined') { + return '当前浏览器不支持实时屏幕传输'; + } + + return null; +} + export default function WatchRoomPage() { const router = useRouter(); const watchRoom = useWatchRoomContext(); @@ -48,6 +77,16 @@ export default function WatchRoomPage() { const [loading, setLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false); const [joinLoading, setJoinLoading] = useState(false); + const [toast, setToast] = useState(null); + + const showToast = (message: string, type: ToastProps['type'] = 'info') => { + setToast({ + message, + type, + duration: 3000, + onClose: () => setToast(null), + }); + }; // 加载房间列表 const loadRooms = async (showLoading = false) => { @@ -82,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({ @@ -106,7 +153,7 @@ export default function WatchRoomPage() { roomType: 'sync', }); } catch (error: any) { - alert(error.message || '创建房间失败'); + showToast(error.message || '创建房间失败', 'error'); } finally { setCreateLoading(false); } @@ -117,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({ @@ -138,7 +194,7 @@ export default function WatchRoomPage() { // 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新 // 跳转逻辑会在 useEffect 中处理 } catch (error: any) { - alert(error.message || '加入房间失败'); + showToast(error.message || '加入房间失败', 'error'); } finally { setJoinLoading(false); } @@ -219,6 +275,14 @@ export default function WatchRoomPage() { // 从房间列表加入房间 const handleJoinFromList = (room: Room) => { + if (room.roomType === 'screen') { + const supportError = getScreenShareViewerSupportError(); + if (supportError) { + showToast(`当前设备无法加入屏幕共享房间:${supportError}`, 'error'); + return; + } + } + setJoinForm({ roomId: room.id, password: '', @@ -810,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 index 138740f..b8fb56b 100644 --- a/src/app/watch-room/screen/page.tsx +++ b/src/app/watch-room/screen/page.tsx @@ -3,17 +3,47 @@ import { Monitor, MonitorPlay, Users } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import Toast, { ToastProps } from '@/components/Toast'; import { useWatchRoomContext } from '@/components/WatchRoomProvider'; import { useScreenShare } from '@/hooks/useScreenShare'; const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_'; +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 { currentRoom: screenRoom, isOwner, @@ -26,6 +56,15 @@ export default function WatchRoomScreenPage() { stopSharing, } = useScreenShare(); + const showToast = (message: string, type: ToastProps['type'] = 'info') => { + setToast({ + message, + type, + duration: 3000, + onClose: () => setToast(null), + }); + }; + useEffect(() => { if (!currentRoom) { router.replace('/watch-room'); @@ -37,6 +76,19 @@ export default function WatchRoomScreenPage() { } }, [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; @@ -196,6 +248,7 @@ export default function WatchRoomScreenPage() { + {toast && } ); } From 23b8115f36ede0ae9b4a7a3ab24aaa388788ec99 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 6 Apr 2026 01:30:24 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E5=B1=8F=E5=B9=95=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 8 +++ src/app/watch-room/page.tsx | 4 +- src/app/watch-room/screen/page.tsx | 87 +++++++++++++++++++++++++--- src/components/WatchRoomProvider.tsx | 21 ++++++- src/hooks/useScreenShare.ts | 79 ++++++++++++++++++++++--- src/hooks/useWatchRoom.ts | 41 ++++++++++--- src/lib/watch-room-server.ts | 24 +++++++- src/lib/watch-room-socket.ts | 41 ++++++++++++- 8 files changed, 272 insertions(+), 33 deletions(-) diff --git a/server.js b/server.js index 1ce336e..2cbf79a 100644 --- a/server.js +++ b/server.js @@ -134,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); diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx index 15f9fa5..6daf4c2 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -583,7 +583,7 @@ export default function WatchRoomPage() { }`} >
进度同步
-
统一播放进度
+
统一播放进度(适合双方网络稳定的情况)
diff --git a/src/app/watch-room/screen/page.tsx b/src/app/watch-room/screen/page.tsx index b8fb56b..0bd73c1 100644 --- a/src/app/watch-room/screen/page.tsx +++ b/src/app/watch-room/screen/page.tsx @@ -3,13 +3,15 @@ import { Monitor, MonitorPlay, Users } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Toast, { ToastProps } from '@/components/Toast'; import { useWatchRoomContext } from '@/components/WatchRoomProvider'; -import { useScreenShare } from '@/hooks/useScreenShare'; +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; @@ -44,17 +46,19 @@ export default function WatchRoomScreenPage() { 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(); + } = useScreenShare(qualityPreset); const showToast = (message: string, type: ToastProps['type'] = 'info') => { setToast({ @@ -65,6 +69,24 @@ export default function WatchRoomScreenPage() { }); }; + 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'); @@ -92,12 +114,17 @@ export default function WatchRoomScreenPage() { 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)) return; + if (!sessionStorage.getItem(key)) { + sessionStorage.setItem(key, '1'); + openDetachedPage(); + } - sessionStorage.setItem(key, '1'); - window.open('/?watchRoomNoConnect=1', '_blank', 'noopener,noreferrer'); - }, [isOwner, screenRoom?.id]); + return () => { + localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY); + }; + }, [isOwner, openDetachedPage, screenRoom?.id]); if (!screenRoom || screenRoom.roomType !== 'screen') { return null; @@ -111,6 +138,15 @@ export default function WatchRoomScreenPage() { 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 (
@@ -127,9 +163,13 @@ export default function WatchRoomScreenPage() {
{isOwner && ( { + event.preventDefault(); + openDetachedPage(); + }} className='rounded-lg bg-blue-500 px-4 py-2 text-white' > 新开主页 @@ -188,12 +228,41 @@ export default function WatchRoomScreenPage() {

成员:{members.length} 人

+ {isOwner && ( +
+ 实际采集:{captureSettingsText} +
+ )} + {error && (
{error}
)} + {isOwner && ( +
+ + +

+ 画质越高越清晰,但更依赖网络和设备性能。共享开始后不可切换。 +

+
+ )} +
{isOwner ? ( <> @@ -243,7 +312,7 @@ export default function WatchRoomScreenPage() {
- 本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。 + 建议使用桌面版 Chrome / Edge,并优先共享标签页。
diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index cbd9af1..cb464a6 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -2,7 +2,6 @@ 'use client'; import React, { createContext, useCallback,useContext, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; import { useWatchRoom } from '@/hooks/useWatchRoom'; @@ -14,6 +13,8 @@ import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig // 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; @@ -39,6 +40,7 @@ interface WatchRoomContextType { roomId: string; password?: string; userName: string; + ownerToken?: string; }) => Promise<{ room: Room; members: Member[] }>; leaveRoom: () => void; getRoomList: () => Promise; @@ -81,12 +83,12 @@ interface WatchRoomProviderProps { } export function WatchRoomProvider({ children }: WatchRoomProviderProps) { - const searchParams = useSearchParams(); const [config, setConfig] = useState(null); const [isEnabled, setIsEnabled] = useState(false); 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 }) => { @@ -123,7 +125,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { }, []); const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared); - const shouldDisableWatchRoomConnection = searchParams.get('watchRoomNoConnect') === '1'; + + useEffect(() => { + if (typeof window === 'undefined') return; + + setShouldDisableWatchRoomConnection( + window.location.pathname !== WATCH_ROOM_SCREEN_PATH + && window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1' + ); + }, []); // 检查登录状态 useEffect(() => { @@ -162,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); @@ -175,6 +186,10 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { // 加载配置 useEffect(() => { + if (shouldDisableWatchRoomConnection === null) { + return; + } + if (shouldDisableWatchRoomConnection) { setConfig({ enabled: false, diff --git a/src/hooks/useScreenShare.ts b/src/hooks/useScreenShare.ts index 3fdc57c..da88699 100644 --- a/src/hooks/useScreenShare.ts +++ b/src/hooks/useScreenShare.ts @@ -12,7 +12,51 @@ const iceServers = [ { urls: 'stun:stun1.l.google.com:19302' }, ]; -export function useScreenShare() { +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); @@ -23,9 +67,11 @@ export function useScreenShare() { const [error, setError] = useState(null); const [isStarting, setIsStarting] = useState(false); + const [captureSettings, setCaptureSettings] = useState(null); const currentRoom = watchRoom?.currentRoom || null; const socket = watchRoom?.socket || null; + const isConnected = watchRoom?.isConnected || false; const isOwner = watchRoom?.isOwner || false; const members = watchRoom?.members || []; const currentState = currentRoom?.currentState; @@ -67,6 +113,7 @@ export function useScreenShare() { localVideoRef.current.srcObject = null; } + setCaptureSettings(null); clearRemoteVideo(); stoppingRef.current = false; }, [clearRemoteVideo, closePeerConnection]); @@ -136,11 +183,12 @@ export function useScreenShare() { setError(null); try { + const constraints = SCREEN_SHARE_CONSTRAINTS[qualityPreset]; const stream = await navigator.mediaDevices.getDisplayMedia({ video: { - frameRate: 15, - width: { ideal: 1280 }, - height: { ideal: 720 }, + frameRate: constraints.frameRate, + width: { ideal: constraints.width }, + height: { ideal: constraints.height }, }, audio: true, }); @@ -152,6 +200,12 @@ export function useScreenShare() { const videoTrack = stream.getVideoTracks()[0]; if (videoTrack) { + const settings = videoTrack.getSettings(); + setCaptureSettings({ + width: typeof settings.width === 'number' ? settings.width : null, + height: typeof settings.height === 'number' ? settings.height : null, + frameRate: typeof settings.frameRate === 'number' ? settings.frameRate : null, + }); videoTrack.onended = () => { stopSharing(true); }; @@ -176,7 +230,7 @@ export function useScreenShare() { } finally { setIsStarting(false); } - }, [currentRoom, isOwner, members, sendOfferToMember, stopSharing, watchRoom]); + }, [currentRoom, isOwner, members, qualityPreset, sendOfferToMember, stopSharing, watchRoom]); useEffect(() => { if (!socket || !currentRoom) return; @@ -231,6 +285,14 @@ export function useScreenShare() { } }; + const handleSocketDisconnect = () => { + if (!isOwner) { + peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId)); + peerConnectionsRef.current.clear(); + clearRemoteVideo(); + } + }; + const handleViewerReady = (data: { userId: string }) => { if (!isOwner || !displayStreamRef.current) return; sendOfferToMember(data.userId); @@ -241,6 +303,7 @@ export function useScreenShare() { socket.on('screen:ice', handleIce); socket.on('screen:stop', handleScreenStop); socket.on('screen:viewer-ready', handleViewerReady); + socket.on('disconnect', handleSocketDisconnect); return () => { socket.off('screen:offer', handleOffer); @@ -248,6 +311,7 @@ export function useScreenShare() { socket.off('screen:ice', handleIce); socket.off('screen:stop', handleScreenStop); socket.off('screen:viewer-ready', handleViewerReady); + socket.off('disconnect', handleSocketDisconnect); }; }, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]); @@ -277,11 +341,11 @@ export function useScreenShare() { }, [cleanupSharingResources]); useEffect(() => { - if (!socket || !currentRoom || isOwner) return; + if (!socket || !currentRoom || isOwner || !isConnected) return; if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return; socket.emit('screen:viewer-ready'); - }, [currentRoom, currentState, isOwner, socket]); + }, [currentRoom, currentState, isConnected, isOwner, socket]); return { currentRoom, @@ -289,6 +353,7 @@ export function useScreenShare() { isSharing, isStarting, error, + captureSettings, localVideoRef, remoteVideoRef, startSharing, diff --git a/src/hooks/useWatchRoom.ts b/src/hooks/useWatchRoom.ts index 5700550..6cf245f 100644 --- a/src/hooks/useWatchRoom.ts +++ b/src/hooks/useWatchRoom.ts @@ -30,9 +30,15 @@ export function useWatchRoom( const [chatMessages, setChatMessages] = useState([]); const [isOwner, setIsOwner] = useState(false); const reconnectTimeoutRef = useRef(null); + const rejoinInFlightRef = useRef(false); // 重新加入房间(自动重连) const rejoinRoom = useCallback(async (info: StoredRoomInfo) => { + if (rejoinInFlightRef.current) { + return; + } + + rejoinInFlightRef.current = true; console.log('[WatchRoom] Auto-rejoining room:', info); try { const sock = watchRoomSocketManager.getSocket(); @@ -64,9 +70,21 @@ export function useWatchRoom( } catch (error) { console.error('[WatchRoom] Failed to rejoin room:', error); clearStoredRoomInfo(); + } finally { + rejoinInFlightRef.current = false; } }, []); + const scheduleRejoin = useCallback((info: StoredRoomInfo, delay = 300) => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = setTimeout(() => { + rejoinRoom(info); + }, delay); + }, [rejoinRoom]); + // 连接到服务器 const connect = useCallback(async (config: WatchRoomConfig) => { try { @@ -78,15 +96,13 @@ export function useWatchRoom( const storedInfo = getStoredRoomInfo(); if (storedInfo) { console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId); - reconnectTimeoutRef.current = setTimeout(() => { - rejoinRoom(storedInfo); - }, 1000); + scheduleRejoin(storedInfo); } } catch (error) { console.error('[WatchRoom] Failed to connect:', error); setIsConnected(false); } - }, [rejoinRoom]); + }, [scheduleRejoin]); // 断开连接 const disconnect = useCallback(() => { @@ -99,6 +115,7 @@ export function useWatchRoom( setCurrentRoom(null); setMembers([]); setChatMessages([]); + setIsOwner(false); }, []); // 创建房间 @@ -142,7 +159,7 @@ export function useWatchRoom( // 加入房间 const joinRoom = useCallback( - async (data: { roomId: string; password?: string; userName: string }) => { + async (data: { roomId: string; password?: string; userName: string; ownerToken?: string }) => { const sock = watchRoomSocketManager.getSocket(); if (!sock || !watchRoomSocketManager.isConnected()) { throw new Error('Not connected'); @@ -162,7 +179,7 @@ export function useWatchRoom( isOwner: isRoomOwner, userName: data.userName, password: data.password, - ownerToken: isRoomOwner ? response.room.ownerToken : undefined, + ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined, timestamp: Date.now(), }); resolve({ room: response.room, members: response.members }); @@ -343,7 +360,11 @@ export function useWatchRoom( }); socket.on('room:member-joined', (member) => { - setMembers((prev) => [...prev, member]); + setMembers((prev) => { + const next = prev.filter((existing) => existing.id !== member.id); + next.push(member); + return next; + }); }); socket.on('room:member-left', (userId) => { @@ -415,6 +436,10 @@ export function useWatchRoom( // 连接状态 socket.on('connect', () => { setIsConnected(true); + const storedInfo = getStoredRoomInfo(); + if (storedInfo) { + scheduleRejoin(storedInfo); + } }); socket.on('disconnect', () => { @@ -436,7 +461,7 @@ export function useWatchRoom( socket.off('connect'); socket.off('disconnect'); }; - }, [socket, currentRoom, onRoomDeleted, onStateCleared]); + }, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]); // 清理 useEffect(() => { diff --git a/src/lib/watch-room-server.ts b/src/lib/watch-room-server.ts index deb1862..a4bcd30 100644 --- a/src/lib/watch-room-server.ts +++ b/src/lib/watch-room-server.ts @@ -92,15 +92,33 @@ export class WatchRoomServer { } const userId = socket.id; + let isOwner = false; + + if (data.ownerToken && data.ownerToken === room.ownerToken) { + isOwner = true; + room.ownerId = userId; + room.lastOwnerHeartbeat = Date.now(); + this.rooms.set(data.roomId, room); + console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`); + } + const member: Member = { id: userId, name: data.userName, - isOwner: false, + isOwner, lastHeartbeat: Date.now(), }; const roomMembers = this.members.get(data.roomId); if (roomMembers) { + if (isOwner) { + Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => { + if (existingMember.isOwner && memberId !== userId) { + roomMembers.delete(memberId); + } + }); + } + roomMembers.set(userId, member); room.memberCount = roomMembers.size; this.rooms.set(data.roomId, room); @@ -110,7 +128,7 @@ export class WatchRoomServer { roomId: data.roomId, userId, userName: data.userName, - isOwner: false, + isOwner, }); socket.join(data.roomId); @@ -118,7 +136,7 @@ export class WatchRoomServer { // 通知房间内其他成员 socket.to(data.roomId).emit('room:member-joined', member); - console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`); + console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`); const members = Array.from(roomMembers?.values() || []); callback({ success: true, room, members }); diff --git a/src/lib/watch-room-socket.ts b/src/lib/watch-room-socket.ts index 1949d98..02e5ee8 100644 --- a/src/lib/watch-room-socket.ts +++ b/src/lib/watch-room-socket.ts @@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket class WatchRoomSocketManager { private socket: WatchRoomSocket | null = null; private config: WatchRoomConfig | null = null; + private connectionPromise: Promise | null = null; private heartbeatInterval: NodeJS.Timeout | null = null; private heartbeatTimeoutCheck: NodeJS.Timeout | null = null; private lastHeartbeatResponse: number = Date.now(); @@ -25,6 +26,37 @@ class WatchRoomSocketManager { return this.socket; } + if (this.connectionPromise) { + return this.connectionPromise; + } + + if (this.socket) { + this.connectionPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.connectionPromise = null; + reject(new Error('Socket connection timeout')); + }, 10000); + + this.socket!.once('connect', () => { + clearTimeout(timeout); + this.connectionPromise = null; + resolve(this.socket!); + }); + + this.socket!.once('connect_error', (error) => { + clearTimeout(timeout); + this.connectionPromise = null; + reject(error); + }); + + if (!this.socket!.connected) { + this.socket!.connect(); + } + }); + + return this.connectionPromise; + } + this.config = config; const socketOptions = { @@ -72,8 +104,9 @@ class WatchRoomSocketManager { // 设置浏览器可见性监听 this.setupVisibilityListener(); - return new Promise((resolve, reject) => { + this.connectionPromise = new Promise((resolve, reject) => { if (!this.socket) { + this.connectionPromise = null; reject(new Error('Socket not initialized')); return; } @@ -82,6 +115,7 @@ class WatchRoomSocketManager { this.socket.once('connect', () => { // eslint-disable-next-line no-console console.log('[WatchRoom] Connected to server'); + this.connectionPromise = null; if (this.socket) { resolve(this.socket); } @@ -90,9 +124,12 @@ class WatchRoomSocketManager { this.socket.once('connect_error', (error) => { // eslint-disable-next-line no-console console.error('[WatchRoom] Connection error:', error); + this.connectionPromise = null; reject(error); }); }); + + return this.connectionPromise; } disconnect() { @@ -122,6 +159,8 @@ class WatchRoomSocketManager { this.socket.disconnect(); this.socket = null; } + + this.connectionPromise = null; } getSocket(): WatchRoomSocket | null { From 4bed01e7a3479d6667b3cd698d4b81b87398de8d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 6 Apr 2026 11:02:57 +0800 Subject: [PATCH 09/18] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=85=A7=E7=89=87?= =?UTF-8?q?=E5=A2=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/tmdb/images/route.ts | 105 +++++++++ src/components/DetailPanel.tsx | 359 ++++++++++++++++++++++++++----- src/lib/tmdb.client.ts | 44 ++++ 3 files changed, 459 insertions(+), 49 deletions(-) create mode 100644 src/app/api/tmdb/images/route.ts 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/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index 40cffb4..9c8cbe8 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'; @@ -69,6 +69,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 +110,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 +170,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 +275,12 @@ const DetailPanel: React.FC = ({ }; }, [isOpen]); + useEffect(() => { + if (!isOpen) { + setShowGallery(false); + } + }, [isOpen]); + // 阻止背景滚动(仅在非抽屉模式下) useEffect(() => { if (isVisible && !useDrawer) { @@ -887,6 +983,157 @@ 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 galleryModal = showGallery ? ( +
+
setShowGallery(false)} + /> +
+
+
+

照片墙

+ {!galleryLoading && ( +

+ 共 {galleryTotal} 张 +

+ )} +
+ +
+ +
+ {galleryLoading && ( +
+
+
+ )} + + {!galleryLoading && galleryError && ( +
{galleryError}
+ )} + + {!galleryLoading && !galleryError && galleryImages.length === 0 && ( +
暂无图片
+ )} + + {!galleryLoading && !galleryError && galleryImages.length > 0 && ( +
+ {virtualGalleryLayout.visibleItems.map((image) => { + const imageUrl = processImageUrl( + getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original') + ); + const thumbUrl = processImageUrl( + getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780') + ); + + return ( +
+
handleImageClick(imageUrl)} + > + {`${detailData?.title +
+ {image.imageType === 'poster' ? '海报' : '剧照'} +
+
+
+ ); + })} +
+ )} +
+
+
+ ) : null; + if (!isVisible || !mounted) return null; const content = useDrawer ? ( @@ -938,7 +1185,7 @@ const DetailPanel: React.FC = ({ {/* 数据源显示和切换 - 错误时也显示 */}
-
+
数据来源: @@ -948,24 +1195,27 @@ const DetailPanel: React.FC = ({ {currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && ( - - )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} +
+ {galleryEntryButton} + {currentSource !== 'tmdb' && ( + + )} + {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( + + )} +
@@ -976,11 +1226,14 @@ const DetailPanel: React.FC = ({ {/* 海报和基本信息 */}
{detailData.poster && ( -
handleImageClick(detailData.poster!)} - > - {detailData.title} +
+
handleImageClick(detailData.poster!)} + > + {detailData.title} +
+ {galleryEntryButton}
)}
@@ -1332,7 +1585,7 @@ const DetailPanel: React.FC = ({ {/* 数据源显示和切换 */}
-
+
数据来源: @@ -1342,24 +1595,27 @@ const DetailPanel: React.FC = ({ {currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && ( - - )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} +
+ {galleryEntryButton} + {currentSource !== 'tmdb' && ( + + )} + {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( + + )} +
@@ -1368,6 +1624,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */} + {galleryModal} {showImageViewer && ( = ({ {/* 海报和基本信息 */}
{detailData.poster && ( -
handleImageClick(detailData.poster!)} - > - {detailData.title} +
+
handleImageClick(detailData.poster!)} + > + {detailData.title} +
+ {galleryEntryButton}
)}
@@ -1872,6 +2132,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */} + {galleryModal} {showImageViewer && ( { + try { + const actualKey = getNextApiKey(apiKey); + if (!actualKey) { + return { code: 400, images: null }; + } + + const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL; + const url = `${baseUrl}/3/${mediaType}/${mediaId}/images?api_key=${actualKey}`; + + const response = await universalFetch(url, proxy); + + if (!response.ok) { + console.error('TMDB Images API 请求失败:', response.status, response.statusText); + return { code: response.status, images: null }; + } + + const data: any = await response.json(); + + return { + code: 200, + images: data, + }; + } catch (error) { + console.error('获取 TMDB 图片信息失败:', error); + return { code: 500, images: null }; + } +} From 9b707c1a17bc42cb11cb5b84c72d4f32715e37cf Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 7 Apr 2026 16:58:22 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E5=A4=B8=E5=85=8B=E7=BD=91=E7=9B=98?= =?UTF-8?q?=E8=BD=AC=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 226 ++++++++++++ src/app/api/admin/netdisk/route.ts | 85 +++++ src/app/api/quark/instant-play/route.ts | 67 ++++ src/app/api/quark/transfer/route.ts | 44 +++ src/app/api/source-detail/route.ts | 127 ++++++- src/app/play/page.tsx | 4 + src/components/PansouSearch.tsx | 84 ++++- src/lib/admin.types.ts | 9 + src/lib/config.ts | 22 ++ src/lib/quark.client.ts | 471 ++++++++++++++++++++++++ 10 files changed, 1132 insertions(+), 7 deletions(-) create mode 100644 src/app/api/admin/netdisk/route.ts create mode 100644 src/app/api/quark/instant-play/route.ts create mode 100644 src/app/api/quark/transfer/route.ts create mode 100644 src/lib/quark.client.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b9700ce..1f5a406 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -30,6 +30,7 @@ import { CheckCircle, ChevronDown, ChevronUp, + Cloud, Database, ExternalLink, FileText, @@ -3499,6 +3500,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 临时目录必须映射到夸克临时播放目录,否则立即播放无法找到文件。

+
+
+ +
+
+

+ 启用夸克网盘 +

+

+ 开启后,网盘搜索中的夸克资源会显示“立即播放”和“转存”按钮 +

+
+ +
+ +
+ +