diff --git a/README.md b/README.md index 2d40908..eb8d44a 100644 --- a/README.md +++ b/README.md @@ -478,11 +478,11 @@ docker run -d -p 3000:3000 \ ## 一起看 (VideoTogether) 配置 -播放器页面和 IPTV 页面默认内置 [VideoTogether](https://videotogether.github.io/zh-cn/guide/website.html) 官方网页集成脚本,进入播放页后即可直接使用一起看功能。 +播放器页面和 IPTV 页面支持集成 [VideoTogether](https://videotogether.github.io/zh-cn/guide/website.html) 官方网页脚本。应用内默认关闭,用户可在设置页手动开启;开启后仅在播放器和 IPTV 页面显示,并默认折叠为小图标。 | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `VIDEOTOGETHER_ENABLED` | 设为 `false` 时禁用一起看集成 | `true` | +| `VIDEOTOGETHER_ENABLED` | 设为 `false` 时彻底禁用一起看集成(即使用户在设置页开启也不会加载) | `true` | | `VIDEOTOGETHER_SCRIPT_URL` | 自定义 VideoTogether 脚本地址,适合自托管或替换 CDN | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` | | `VIDEOTOGETHER_SETTING_URL` | 自定义 VideoTogether 设置页地址,对应官方 `window.videoTogetherWebsiteSettingUrl` 接口 | - | @@ -639,7 +639,7 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest | `AD_KEYWORDS` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - | | `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - | | `DANMAKU_API_URL` / `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - | -| `VIDEOTOGETHER_ENABLED` | 是否启用 VideoTogether 一起看集成(`false` 时关闭) | `true` | +| `VIDEOTOGETHER_ENABLED` | 是否允许 VideoTogether 一起看集成(`false` 时关闭) | `true` | | `VIDEOTOGETHER_SCRIPT_URL` | VideoTogether 脚本地址 | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` | | `VIDEOTOGETHER_SETTING_URL` | VideoTogether 设置页地址 | - | | `UPSTASH_REDIS_REST_URL` | Upstash Redis REST URL(跨设备同步:配置、历史、收藏) | - | diff --git a/app/iptv/layout.tsx b/app/iptv/layout.tsx index d5b63c8..c53c2b3 100644 --- a/app/iptv/layout.tsx +++ b/app/iptv/layout.tsx @@ -1,14 +1,7 @@ -import { VideoTogetherScript } from '@/components/VideoTogetherScript'; - export default function IPTVLayout({ children, }: { children: React.ReactNode; }) { - return ( - <> - {children} - - - ); + return children; } diff --git a/app/layout.tsx b/app/layout.tsx index 416c880..37a0c2d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,10 +15,13 @@ import { BackToTop } from "@/components/ui/BackToTop"; import { ScrollPositionManager } from "@/components/ScrollPositionManager"; import { LocaleProvider } from "@/components/LocaleProvider"; import { RuntimeFeaturesProvider } from "@/components/RuntimeFeaturesProvider"; +import { VideoTogetherController } from '@/components/VideoTogetherController'; import { getRuntimeFeatures } from "@/lib/server/runtime-features"; import fs from 'fs'; import path from 'path'; +const DEFAULT_VIDEOTOGETHER_SCRIPT_URL = + 'https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js'; // Server Component specifically for reading env/file (async for best practices) async function AdKeywordsWrapper() { @@ -83,6 +86,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { const runtimeFeatures = getRuntimeFeatures(); + const videoTogetherScriptUrl = + process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL; + const videoTogetherSettingUrl = process.env.VIDEOTOGETHER_SETTING_URL?.trim(); + const videoTogetherEnvEnabled = process.env.VIDEOTOGETHER_ENABLED !== 'false'; return ( @@ -105,6 +112,11 @@ export default function RootLayout({ > + {/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */} diff --git a/app/player/layout.tsx b/app/player/layout.tsx index fc92ce8..2eee97e 100644 --- a/app/player/layout.tsx +++ b/app/player/layout.tsx @@ -1,5 +1,4 @@ import { Metadata } from 'next'; -import { VideoTogetherScript } from '@/components/VideoTogetherScript'; export const metadata: Metadata = { referrer: 'no-referrer', @@ -10,10 +9,5 @@ export default function PlayerLayout({ }: { children: React.ReactNode; }) { - return ( - <> - {children} - - - ); + return children; } diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 3634fc2..40a4b1e 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -36,6 +36,7 @@ export function useSettingsPage() { const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS); const [rememberScrollPosition, setRememberScrollPosition] = useState(true); const [locale, setLocale] = useState('zh-CN'); + const [videoTogetherEnabled, setVideoTogetherEnabled] = useState(false); // Danmaku settings const [danmakuApiUrl, setDanmakuApiUrl] = useState(''); @@ -58,6 +59,7 @@ export function useSettingsPage() { setSeekStepSeconds(settings.seekStepSeconds); setRememberScrollPosition(settings.rememberScrollPosition); setLocale(settings.locale); + setVideoTogetherEnabled(settings.videoTogetherEnabled); setDanmakuApiUrl(settings.danmakuApiUrl); setDanmakuOpacity(settings.danmakuOpacity); setDanmakuFontSize(settings.danmakuFontSize); @@ -283,6 +285,15 @@ export function useSettingsPage() { }); }; + const handleVideoTogetherEnabledChange = (enabled: boolean) => { + setVideoTogetherEnabled(enabled); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + videoTogetherEnabled: enabled, + }); + }; + const handleLocaleChange = (newLocale: LocaleOption) => { setLocale(newLocale); const currentSettings = settingsStore.getSettings(); @@ -392,6 +403,8 @@ export function useSettingsPage() { handleRememberScrollPositionChange, locale, handleLocaleChange, + videoTogetherEnabled, + handleVideoTogetherEnabledChange, danmakuApiUrl, handleDanmakuApiUrlChange, danmakuOpacity, diff --git a/app/settings/page.tsx b/app/settings/page.tsx index baa7dcb..7ba454b 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -57,7 +57,9 @@ export default function SettingsPage() { handleProxyModeChange, handleSeekStepSecondsChange, rememberScrollPosition, + videoTogetherEnabled, handleRememberScrollPositionChange, + handleVideoTogetherEnabledChange, locale, handleLocaleChange, danmakuApiUrl, @@ -90,6 +92,8 @@ export default function SettingsPage() { onProxyModeChange={handleProxyModeChange} seekStepSeconds={seekStepSeconds} onSeekStepSecondsChange={handleSeekStepSecondsChange} + videoTogetherEnabled={videoTogetherEnabled} + onVideoTogetherEnabledChange={handleVideoTogetherEnabledChange} danmakuApiUrl={danmakuApiUrl} onDanmakuApiUrlChange={handleDanmakuApiUrlChange} danmakuOpacity={danmakuOpacity} diff --git a/components/VideoTogetherController.tsx b/components/VideoTogetherController.tsx new file mode 100644 index 0000000..f661503 --- /dev/null +++ b/components/VideoTogetherController.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { usePathname } from 'next/navigation'; + +import { settingsStore } from '@/lib/store/settings-store'; + +const HIDE_STYLE_ID = 'kvideo-videotogether-visibility'; +const SCRIPT_ID = 'kvideo-videotogether-script'; + +declare global { + interface Window { + VideoTogetherLoading?: boolean; + VideoTogetherSettingEnabled?: boolean; + videoTogetherWebsiteSettingUrl?: string; + videoTogetherExtension?: unknown; + videoTogetherFlyPannel?: { + Minimize?: (isDefault?: boolean) => void; + } | null; + } +} + +interface VideoTogetherControllerProps { + envEnabled: boolean; + scriptUrl: string; + settingUrl?: string; +} + +function isSupportedRoute(pathname: string | null): boolean { + return pathname?.startsWith('/player') === true || pathname?.startsWith('/iptv') === true; +} + +function syncMinimizedDefaults(forceCurrentPageMinimized: boolean) { + if (typeof window === 'undefined') { + return; + } + + try { + localStorage.setItem('EnableMiniBar', JSON.stringify(true)); + localStorage.setItem('MinimiseDefault', JSON.stringify(true)); + + if (forceCurrentPageMinimized) { + localStorage.setItem('VideoTogetherMinimizedHere', '1'); + window.videoTogetherFlyPannel?.Minimize?.(true); + } + } catch { + // Ignore storage failures so player pages continue working. + } +} + +function updateVisibility(hidden: boolean) { + if (typeof document === 'undefined') { + return; + } + + let style = document.getElementById(HIDE_STYLE_ID) as HTMLStyleElement | null; + if (!style) { + style = document.createElement('style'); + style.id = HIDE_STYLE_ID; + document.head.appendChild(style); + } + + style.textContent = hidden + ? ` + #VideoTogetherWrapper, + #VideoTogetherfullscreenSWrapper, + #videoTogetherLoading, + #videoTogetherTxtMsgTouch { + display: none !important; + } + ` + : ` + #videoTogetherLoading { + display: none !important; + } + `; +} + +export function VideoTogetherController({ + envEnabled, + scriptUrl, + settingUrl, +}: VideoTogetherControllerProps) { + const pathname = usePathname(); + const [videoTogetherEnabled, setVideoTogetherEnabled] = useState(false); + + useEffect(() => { + const sync = () => { + setVideoTogetherEnabled(settingsStore.getSettings().videoTogetherEnabled); + }; + + sync(); + return settingsStore.subscribe(sync); + }, []); + + const supportedRoute = isSupportedRoute(pathname); + const shouldActivate = envEnabled && videoTogetherEnabled && supportedRoute; + + useEffect(() => { + if (!envEnabled || !videoTogetherEnabled) { + updateVisibility(true); + return; + } + + syncMinimizedDefaults(supportedRoute); + }, [envEnabled, videoTogetherEnabled, supportedRoute]); + + useEffect(() => { + updateVisibility(!shouldActivate); + }, [shouldActivate]); + + useEffect(() => { + if (!shouldActivate) { + return; + } + + if (settingUrl) { + window.videoTogetherWebsiteSettingUrl = settingUrl; + } + + if ( + document.getElementById(SCRIPT_ID) || + document.getElementById('videotogether-script') || + window.VideoTogetherLoading || + window.videoTogetherExtension || + window.videoTogetherFlyPannel + ) { + return; + } + + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = scriptUrl; + script.async = true; + + document.body.appendChild(script); + }, [scriptUrl, settingUrl, shouldActivate]); + + return null; +} diff --git a/components/VideoTogetherScript.tsx b/components/VideoTogetherScript.tsx deleted file mode 100644 index f027ac5..0000000 --- a/components/VideoTogetherScript.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Script from 'next/script'; - -const DEFAULT_VIDEOTOGETHER_SCRIPT_URL = - 'https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js'; - -export function VideoTogetherScript() { - if (process.env.VIDEOTOGETHER_ENABLED === 'false') { - return null; - } - - const scriptUrl = process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL; - const settingUrl = process.env.VIDEOTOGETHER_SETTING_URL?.trim(); - - return ( - <> - {settingUrl ? ( -