Files
MoonTVPlus/src/app/layout.tsx
T
2026-07-18 12:20:15 +08:00

428 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { cookies } from 'next/headers';
import './globals.css';
import { parseAuthInfo } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getUserFeatureAccess } from '@/lib/permissions';
import { listEnabledSourceScripts } from '@/lib/source-script';
import { StartupCacheCleanup } from '../components/DanmakuCacheCleanup';
import { DownloadBubble } from '../components/DownloadBubble';
import { DownloadPanel } from '../components/DownloadPanel';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import RouteScrollReset from '../components/RouteScrollReset';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
import { TokenRefreshManager } from '../components/TokenRefreshManager';
import TopProgressBar from '../components/TopProgressBar';
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
import { WatchRoomProvider } from '../components/WatchRoomProvider';
import { DownloadProvider } from '../contexts/DownloadContext';
const inter = Inter({ subsets: ['latin'] });
export const dynamic = 'force-dynamic';
// 动态生成 metadata,支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const config = await getConfig();
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTVPlus';
if (storageType !== 'localstorage') {
siteName = config.SiteConfig.SiteName;
}
return {
title: siteName,
description: '影视聚合',
manifest: '/manifest.json',
// iOS 添加到主屏幕:沉浸式状态栏(需配合 viewport-fit=cover + 顶部 safe-area
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: siteName,
},
};
}
export const viewport: Viewport = {
viewportFit: 'cover',
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTVPlus';
let announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
// 公告显示模式:从环境变量读取,数据库模式下由管理面板配置覆盖
let announcementDisplayMode: 'once' | 'every' =
process.env.ANNOUNCEMENT_DISPLAY_MODE === 'every' ? 'every' : 'once';
let doubanProxyType =
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
let doubanImageProxyType =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
let enableComments = false;
let danmakuAutoLoadDefault = true;
let recommendationDataSource = 'Mixed';
let tmdbApiKey = '';
let bangumiDataSource =
(process.env.NEXT_PUBLIC_BANGUMI_DATA_SOURCE as any) || 'direct';
let bangumiApiBaseUrl =
process.env.NEXT_PUBLIC_BANGUMI_API_BASE_URL ||
process.env.BANGUMI_API_BASE_URL ||
'https://api.bgm.tv';
let bangumiImageBaseUrl =
process.env.NEXT_PUBLIC_BANGUMI_IMAGE_BASE_URL ||
process.env.BANGUMI_IMAGE_BASE_URL ||
'';
let openListEnabled = false;
let embyEnabled = false;
let xiaoyaEnabled = false;
let loginBackgroundImage = '';
let registerBackgroundImage = '';
let homeBackgroundImage = '';
let progressThumbType = 'default';
let progressThumbPresetId = '';
let progressThumbCustomUrl = '';
let enableRegistration = false;
let requireRegistrationInviteCode = false;
let loginRequireTurnstile = false;
let registrationRequireTurnstile = false;
let turnstileSiteKey = '';
let enableOIDCLogin = false;
let enableOIDCRegistration = false;
let oidcButtonText = '';
let telegramLoginEnabled = false;
let telegramBotUsername = '';
let aiEnabled = false;
let aiEnableHomepageEntry = false;
let aiEnableVideoCardEntry = false;
let aiEnablePlayPageEntry = false;
let aiEnableComments = false;
let aiDefaultMessageNoVideo = '';
let aiDefaultMessageWithVideo = '';
let enableMovieRequest = true;
let liveEnabled = true;
let webLiveEnabled = false;
let customAdFilterVersion = 0;
let analyticsEnabled = false;
let analyticsProvider: 'umami' | 'google' | 'clarity' | 'custom' = 'umami';
let analyticsScriptUrl = '';
let analyticsWebsiteId = '';
let analyticsCustomScript = '';
let musicFeatureEnabled = false;
let suwayomiEnabled = false;
let booksEnabled =
process.env.OPDS_ENABLED === 'true' ||
process.env.LEGADO_ENABLED === 'true';
let musicProxyEnabled = true;
let advancedRecommendationEnabled = false;
let userFeatureAccess =
storageType === 'localstorage'
? await getUserFeatureAccess(process.env.USERNAME || 'localstorage-owner')
: await getUserFeatureAccess(null);
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
query: string;
}[];
if (storageType !== 'localstorage') {
const cookieStore = await cookies();
const authInfo = parseAuthInfo(cookieStore.get('auth')?.value);
userFeatureAccess = await getUserFeatureAccess(authInfo?.username);
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement;
announcementDisplayMode =
config.SiteConfig.AnnouncementDisplayMode === 'every' ? 'every' : 'once';
doubanProxyType = config.SiteConfig.DoubanProxyType;
doubanProxy = config.SiteConfig.DoubanProxy;
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
customCategories = config.CustomCategories.filter(
(category) => !category.disabled
).map((category) => ({
name: category.name || '',
type: category.type,
query: category.query,
}));
fluidSearch = config.SiteConfig.FluidSearch;
enableComments = config.SiteConfig.EnableComments;
danmakuAutoLoadDefault = config.SiteConfig.DanmakuAutoLoadDefault !== false;
recommendationDataSource =
config.SiteConfig.RecommendationDataSource || 'Mixed';
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
bangumiDataSource = config.SiteConfig.BangumiDataSource || 'direct';
bangumiApiBaseUrl =
config.SiteConfig.BangumiApiBaseUrl || 'https://api.bgm.tv';
bangumiImageBaseUrl = config.SiteConfig.BangumiImageBaseUrl || '';
loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || '';
registerBackgroundImage = config.ThemeConfig?.registerBackgroundImage || '';
homeBackgroundImage = config.ThemeConfig?.homeBackgroundImage || '';
progressThumbType = config.ThemeConfig?.progressThumbType || 'default';
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 || '';
enableOIDCLogin = config.SiteConfig.EnableOIDCLogin || false;
enableOIDCRegistration = config.SiteConfig.EnableOIDCRegistration || false;
oidcButtonText = config.SiteConfig.OIDCButtonText || '';
telegramLoginEnabled = Boolean(
config.TelegramConfig?.enabled &&
config.TelegramConfig?.loginEnabled &&
(config.TelegramConfig?.botToken || process.env.TELEGRAM_BOT_TOKEN) &&
(config.TelegramConfig?.botUsername || process.env.TELEGRAM_BOT_USERNAME)
);
telegramBotUsername = config.TelegramConfig?.botUsername || process.env.TELEGRAM_BOT_USERNAME || '';
// AI配置
aiEnabled = config.AIConfig?.Enabled || false;
aiEnableHomepageEntry = config.AIConfig?.EnableHomepageEntry || false;
aiEnableVideoCardEntry = config.AIConfig?.EnableVideoCardEntry || false;
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
aiEnableComments = config.AIConfig?.EnableAIComments || false;
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
// 求片功能配置
enableMovieRequest = config.SiteConfig.EnableMovieRequest ?? true;
// 网络直播功能配置
liveEnabled = (config.LiveConfig || []).some((source) => !source.disabled);
webLiveEnabled = config.WebLiveEnabled ?? false;
// 自定义去广告代码版本号
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
// 流量统计配置
analyticsEnabled = config.SiteConfig?.AnalyticsEnabled || false;
analyticsProvider = config.SiteConfig?.AnalyticsProvider || 'umami';
analyticsScriptUrl = config.SiteConfig?.AnalyticsScriptUrl || '';
analyticsWebsiteId = config.SiteConfig?.AnalyticsWebsiteId || '';
analyticsCustomScript = config.SiteConfig?.AnalyticsCustomScript || '';
// 音乐功能配置
musicFeatureEnabled = config.MusicConfig?.Enabled || false;
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
// 漫画功能配置
suwayomiEnabled = !!(
config.SuwayomiConfig?.Enabled && config.SuwayomiConfig?.ServerURL
);
// 电子书功能配置
const opdsConfig = config.OPDSConfig;
// 电子书馆同时支持 OPDS 与 Legado。Legado 源通过订阅单独配置,
// 不一定会出现在 OPDS Sources 中;入口应由“启用电子书馆”开关控制。
booksEnabled = !!opdsConfig?.Enabled;
// 高级推荐功能配置:存在已启用视频源脚本时显示
advancedRecommendationEnabled =
(await listEnabledSourceScripts()).length > 0;
// 检查是否启用了 OpenList 功能
openListEnabled = !!(
config.OpenListConfig?.Enabled &&
config.OpenListConfig?.URL &&
config.OpenListConfig?.Username &&
config.OpenListConfig?.Password
);
// 检查是否启用了 Emby 功能(支持多源)
embyEnabled = !!(
config.EmbyConfig?.Sources &&
config.EmbyConfig.Sources.length > 0 &&
config.EmbyConfig.Sources.some((s) => s.enabled && s.ServerURL)
);
// 检查是否启用了小雅功能
xiaoyaEnabled = !!(
config.XiaoyaConfig?.Enabled && config.XiaoyaConfig?.ServerURL
);
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeStorageType =
process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const isCloudflare =
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
const displayStorageType =
runtimeStorageType === 'd1' && !isCloudflare
? 'sqlite'
: runtimeStorageType;
const runtimeConfig = {
STORAGE_TYPE: runtimeStorageType,
DISPLAY_STORAGE_TYPE: displayStorageType,
DOUBAN_PROXY_TYPE: doubanProxyType,
DOUBAN_PROXY: doubanProxy,
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy,
DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
FLUID_SEARCH: fluidSearch,
EnableComments: enableComments,
DANMAKU_AUTO_LOAD_DEFAULT: danmakuAutoLoadDefault,
RecommendationDataSource: recommendationDataSource,
BANGUMI_DATA_SOURCE: bangumiDataSource,
BANGUMI_API_BASE_URL: bangumiApiBaseUrl,
BANGUMI_IMAGE_BASE_URL: bangumiImageBaseUrl,
ENABLE_TV_MODE: process.env.ENABLE_TV_MODE !== 'false',
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
ENABLE_OFFLINE_DOWNLOAD:
process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
VOICE_CHAT_STRATEGY:
process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
OPENLIST_ENABLED: openListEnabled && userFeatureAccess.private_library,
EMBY_ENABLED: embyEnabled && userFeatureAccess.emby,
XIAOYA_ENABLED: xiaoyaEnabled && userFeatureAccess.xiaoya,
PRIVATE_LIBRARY_ENABLED:
(openListEnabled && userFeatureAccess.private_library) ||
(embyEnabled && userFeatureAccess.emby) ||
(xiaoyaEnabled && userFeatureAccess.xiaoya),
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
HOME_BACKGROUND_IMAGE: homeBackgroundImage,
PROGRESS_THUMB_TYPE: progressThumbType,
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,
ENABLE_OIDC_LOGIN: enableOIDCLogin,
ENABLE_OIDC_REGISTRATION: enableOIDCRegistration,
OIDC_BUTTON_TEXT: oidcButtonText,
ENABLE_TELEGRAM_LOGIN: telegramLoginEnabled,
TELEGRAM_BOT_USERNAME: telegramBotUsername,
AI_ENABLED: aiEnabled && userFeatureAccess.ai_ask,
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
AIConfig: {
EnableAIComments: aiEnableComments,
},
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
ENABLE_MOVIE_REQUEST: enableMovieRequest,
LIVE_ENABLED: liveEnabled && userFeatureAccess.live,
WEB_LIVE_ENABLED: webLiveEnabled && userFeatureAccess.web_live,
ADVANCED_RECOMMENDATION_ENABLED: advancedRecommendationEnabled,
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
MUSIC_ENABLED: musicFeatureEnabled && userFeatureAccess.music,
MUSIC_PROXY_ENABLED: musicProxyEnabled,
SUWAYOMI_ENABLED: suwayomiEnabled && userFeatureAccess.manga,
BOOKS_ENABLED: booksEnabled && userFeatureAccess.books,
NETDISK_SEARCH_ENABLED: userFeatureAccess.netdisk_search,
MAGNET_SEARCH_ENABLED: userFeatureAccess.magnet_search,
MAGNET_SAVE_PRIVATE_LIBRARY_ENABLED:
userFeatureAccess.magnet_save_private_library,
NETDISK_TRANSFER_ENABLED: userFeatureAccess.netdisk_transfer,
NETDISK_TEMP_PLAY_ENABLED: userFeatureAccess.netdisk_temp_play,
FESTIVE_EFFECT_ENABLED: process.env.FESTIVE_EFFECT_ENABLED === 'true',
};
return (
<html lang='zh-CN' suppressHydrationWarning>
<head>
<meta
name='viewport'
content='width=device-width, initial-scale=1.0, viewport-fit=cover'
/>
<link rel='apple-touch-icon' href='/icons/icon-192x192.png' />
{/* 主题CSS */}
<link rel='stylesheet' href='/api/theme/css' />
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
dangerouslySetInnerHTML={{
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}}
/>
{/* 流量统计脚本 */}
{analyticsEnabled && analyticsProvider === 'umami' && analyticsScriptUrl && (
<>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
async
defer
data-website-id={analyticsWebsiteId}
src={analyticsScriptUrl}
/>
</>
)}
{analyticsEnabled && analyticsProvider === 'google' && analyticsWebsiteId && (
<>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${analyticsWebsiteId}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '${analyticsWebsiteId}');`,
}}
/>
</>
)}
{analyticsEnabled && analyticsProvider === 'clarity' && analyticsWebsiteId && (
<script
dangerouslySetInnerHTML={{
__html: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,"clarity","script","${analyticsWebsiteId}");`,
}}
/>
)}
{analyticsEnabled && analyticsProvider === 'custom' && analyticsCustomScript && (
<script
dangerouslySetInnerHTML={{ __html: analyticsCustomScript }}
/>
)}
</head>
<body
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<TopProgressBar />
<RouteScrollReset />
<TokenRefreshManager />
<SiteProvider
siteName={siteName}
announcement={announcement}
announcementDisplayMode={announcementDisplayMode}
tmdbApiKey={tmdbApiKey}
>
<WatchRoomProvider>
<DownloadProvider>
<StartupCacheCleanup />
{children}
<GlobalErrorIndicator />
<ChatFloatingWindow />
<DownloadBubble />
<DownloadPanel />
</DownloadProvider>
</WatchRoomProvider>
</SiteProvider>
</ThemeProvider>
</body>
</html>
);
}