mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
gate VideoTogether behind settings
This commit is contained in:
@@ -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
@@ -1,14 +1,7 @@
|
||||
import { VideoTogetherScript } from '@/components/VideoTogetherScript';
|
||||
|
||||
export default function IPTVLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<VideoTogetherScript />
|
||||
</>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user