('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 ? (
-
- ) : null}
-
- >
- );
-}
diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx
index 7bcf2f1..0e155d4 100644
--- a/components/settings/PlayerSettings.tsx
+++ b/components/settings/PlayerSettings.tsx
@@ -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({
+ {typeof videoTogetherEnabled === 'boolean' && onVideoTogetherEnabledChange ? (
+ <>
+
+
+
+
+
+ 一起看 (VideoTogether)
+
+
+ 关闭后不显示一起看悬浮入口;开启后仅在播放器和 IPTV 页面显示,且默认折叠为小图标,用户可自行展开。
+
+
+
+
+
+
+
+ >
+ ) : null}
+
{/* Danmaku Settings */}
diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts
index a0a9c5b..b89e2b5 100644
--- a/lib/store/settings-store.ts
+++ b/lib/store/settings-store.ts
@@ -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,