gate VideoTogether behind settings

This commit is contained in:
kuekhaoyang
2026-04-02 22:29:27 +08:00
parent 37b6e160d7
commit e73416d76b
10 changed files with 207 additions and 48 deletions
+3 -3
View File
@@ -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(跨设备同步:配置、历史、收藏) | - |
+1 -8
View File
@@ -1,14 +1,7 @@
import { VideoTogetherScript } from '@/components/VideoTogetherScript';
export default function IPTVLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<VideoTogetherScript />
</>
);
return children;
}
+12
View File
@@ -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 (
<html lang="zh-CN" suppressHydrationWarning>
@@ -105,6 +112,11 @@ export default function RootLayout({
>
<ThemeProvider>
<RuntimeFeaturesProvider initialFeatures={runtimeFeatures}>
<VideoTogetherController
envEnabled={videoTogetherEnvEnabled}
scriptUrl={videoTogetherScriptUrl}
settingUrl={videoTogetherSettingUrl}
/>
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
<AutoSync />
<LocaleProvider />
+1 -7
View File
@@ -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}
<VideoTogetherScript />
</>
);
return children;
}
+13
View File
@@ -36,6 +36,7 @@ export function useSettingsPage() {
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
const [locale, setLocale] = useState<LocaleOption>('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,
+4
View File
@@ -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}
+140
View File
@@ -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;
}
-30
View File
@@ -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 ? (
<script
dangerouslySetInnerHTML={{
__html: `window.videoTogetherWebsiteSettingUrl = ${JSON.stringify(settingUrl)};`,
}}
/>
) : null}
<Script
id="videotogether-script"
src={scriptUrl}
strategy="afterInteractive"
/>
</>
);
}
+30
View File
@@ -7,6 +7,7 @@
import { Icons } from '@/components/ui/Icon';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import { Switch } from '@/components/ui/Switch';
import {
type ProxyMode,
DEFAULT_SEEK_STEP_SECONDS,
@@ -21,6 +22,8 @@ interface PlayerSettingsProps {
onProxyModeChange: (mode: ProxyMode) => void;
seekStepSeconds: number;
onSeekStepSecondsChange: (value: number) => void;
videoTogetherEnabled?: boolean;
onVideoTogetherEnabledChange?: (enabled: boolean) => void;
danmakuApiUrl: string;
onDanmakuApiUrlChange: (url: string) => void;
danmakuOpacity: number;
@@ -48,6 +51,8 @@ export function PlayerSettings({
onProxyModeChange,
seekStepSeconds,
onSeekStepSecondsChange,
videoTogetherEnabled,
onVideoTogetherEnabledChange,
danmakuApiUrl,
onDanmakuApiUrlChange,
danmakuOpacity,
@@ -209,6 +214,31 @@ export function PlayerSettings({
<div className="border-t border-[var(--glass-border)]" />
{typeof videoTogetherEnabled === 'boolean' && onVideoTogetherEnabledChange ? (
<>
<div>
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Users size={18} className="text-[var(--accent-color)]" />
(VideoTogether)
</h3>
<p className="text-sm text-[var(--text-color-secondary)]">
IPTV
</p>
</div>
<Switch
checked={videoTogetherEnabled}
onChange={onVideoTogetherEnabledChange}
ariaLabel="一起看开关"
/>
</div>
</div>
<div className="border-t border-[var(--glass-border)]" />
</>
) : null}
{/* Danmaku Settings */}
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
+3
View File
@@ -61,6 +61,7 @@ export interface AppSettings {
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
personalizedRecommendations: boolean; // Show personalized recommendations based on watch history
videoTogetherEnabled: boolean; // Show VideoTogether entry on supported player pages
// Danmaku settings
danmakuEnabled: boolean; // Show danmaku overlay on video
danmakuApiUrl: string; // Self-hosted danmaku API endpoint
@@ -143,6 +144,7 @@ function getDefaultAppSettings(): AppSettings {
proxyMode: 'retry',
rememberScrollPosition: true,
personalizedRecommendations: true,
videoTogetherEnabled: false,
danmakuEnabled: false,
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
danmakuOpacity: 0.7,
@@ -246,6 +248,7 @@ export const settingsStore = {
proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
personalizedRecommendations: parsed.personalizedRecommendations !== undefined ? parsed.personalizedRecommendations : true,
videoTogetherEnabled: parsed.videoTogetherEnabled !== undefined ? parsed.videoTogetherEnabled : false,
danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false,
danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''),
danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,