diff --git a/README.md b/README.md index f504449..d5203f5 100644 --- a/README.md +++ b/README.md @@ -378,15 +378,6 @@ docker run -d -p 3000:3000 \ - 变量名:`NEXT_PUBLIC_SITE_NAME` - 变量值:`我的视频平台` -**Docker 部署:** -```bash -docker run -d -p 3000:3000 \ - -e NEXT_PUBLIC_SITE_NAME="我的视频平台" \ - -e NEXT_PUBLIC_SITE_TITLE="我的视频 - 聚合播放平台" \ - -e NEXT_PUBLIC_SITE_DESCRIPTION="专属视频聚合播放平台" \ - --name kvideo kuekhaoyang/kvideo:latest -``` - **本地开发:** 在项目根目录创建 `.env.local` 文件: ```env @@ -395,6 +386,44 @@ NEXT_PUBLIC_SITE_TITLE=我的视频 - 聚合播放平台 NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台 ``` +> [!NOTE] +> `NEXT_PUBLIC_SITE_*` 属于构建时变量。直接运行 Docker Hub 的预构建镜像时,`docker run -e NEXT_PUBLIC_SITE_* ...` 不会覆盖已经打包进前端的文案。 + +## Docker 图标自定义 + +Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会同时作用于顶部 Logo、浏览器 favicon 和 PWA 图标。 + +### 可用环境变量: + +| 变量名 | 说明 | +|--------|------| +| `SITE_ICON_FILE` | 从容器内文件路径读取图标,适合 Docker 挂载,优先级高于 `SITE_ICON_URL` | +| `SITE_ICON_URL` | 直接使用外部 URL 或站内路径作为图标 | + +### 配置示例: + +**Docker 挂载文件(推荐):** +```bash +docker run -d -p 3000:3000 \ + -v /path/to/icon.png:/app/custom/icon.png:ro \ + -e SITE_ICON_FILE=/app/custom/icon.png \ + --name kvideo kuekhaoyang/kvideo:latest +``` + +**Docker 使用 URL:** +```bash +docker run -d -p 3000:3000 \ + -e SITE_ICON_URL="https://example.com/icon.png" \ + --name kvideo kuekhaoyang/kvideo:latest +``` + +**Docker 使用站内路径:** +```bash +docker run -d -p 3000:3000 \ + -e SITE_ICON_URL="/placeholder-poster.svg" \ + --name kvideo kuekhaoyang/kvideo:latest +``` + ## 自动订阅源配置 可以通过环境变量自动配置订阅源,应用启动时会自动加载并设置为自动更新。 @@ -632,6 +661,8 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest | `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` | | `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` | | `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` | +| `SITE_ICON_FILE` | Docker 运行时图标文件路径(优先于 `SITE_ICON_URL`) | - | +| `SITE_ICON_URL` | Docker 运行时图标 URL 或站内路径 | - | | `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - | | `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - | | `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - | diff --git a/app/api/site-icon/route.ts b/app/api/site-icon/route.ts new file mode 100644 index 0000000..79506fb --- /dev/null +++ b/app/api/site-icon/route.ts @@ -0,0 +1,118 @@ +import fs from 'fs'; +import path from 'path'; +import { NextResponse } from 'next/server'; + +const DEFAULT_ICON_PATH = '/icon.png'; +const SITE_ICON_ROUTE = '/api/site-icon'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function getMimeType(filePath: string): string { + switch (path.extname(filePath).toLowerCase()) { + case '.avif': + return 'image/avif'; + case '.gif': + return 'image/gif'; + case '.ico': + return 'image/x-icon'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.webp': + return 'image/webp'; + default: + return 'application/octet-stream'; + } +} + +function getIconFileCandidates(filePath: string): string[] { + if (path.isAbsolute(filePath)) { + return [filePath]; + } + + const currentWorkingDirectory = process.cwd(); + const candidates = [path.join(currentWorkingDirectory, filePath)]; + const standaloneSuffix = `${path.sep}.next${path.sep}standalone`; + + if (currentWorkingDirectory.endsWith(standaloneSuffix)) { + candidates.push(path.resolve(currentWorkingDirectory, '..', '..', filePath)); + } + + return [...new Set(candidates)]; +} + +function buildIconRedirect(request: Request, iconUrl: string): NextResponse | null { + try { + const origin = new URL(request.url).origin; + const resolvedUrl = new URL(iconUrl, `${origin}/`); + + if (resolvedUrl.pathname === SITE_ICON_ROUTE) { + console.warn('[SiteIcon] Ignoring SITE_ICON_URL because it points back to /api/site-icon.'); + return null; + } + + return NextResponse.redirect(resolvedUrl, 307); + } catch (error) { + console.warn('[SiteIcon] Invalid SITE_ICON_URL:', iconUrl, error); + return null; + } +} + +async function serveIconFile(filePath: string): Promise { + const [fileBuffer, fileStat] = await Promise.all([ + fs.promises.readFile(filePath), + fs.promises.stat(filePath), + ]); + const etag = `W/"${fileStat.size}-${Math.trunc(fileStat.mtimeMs)}"`; + + return new Response(fileBuffer, { + headers: { + 'Cache-Control': 'public, max-age=0, must-revalidate', + 'Content-Length': String(fileStat.size), + 'Content-Type': getMimeType(filePath), + ETag: etag, + 'Last-Modified': fileStat.mtime.toUTCString(), + }, + }); +} + +export async function GET(request: Request) { + const iconFile = process.env.SITE_ICON_FILE?.trim(); + + if (iconFile) { + const resolvedFilePaths = getIconFileCandidates(iconFile); + let lastError: unknown = null; + + for (const resolvedFilePath of resolvedFilePaths) { + try { + return await serveIconFile(resolvedFilePath); + } catch (error) { + lastError = error; + } + } + + console.warn( + `[SiteIcon] Failed to read SITE_ICON_FILE from any supported path: ${resolvedFilePaths.join(', ')}`, + lastError, + ); + } + + const iconUrl = process.env.SITE_ICON_URL?.trim() || process.env.NEXT_PUBLIC_SITE_ICON_URL?.trim(); + + if (iconUrl) { + const redirectResponse = buildIconRedirect(request, iconUrl); + + if (redirectResponse) { + return redirectResponse; + } + } + + return NextResponse.redirect(new URL(DEFAULT_ICON_PATH, request.url), 307); +} + +export const HEAD = GET; diff --git a/app/layout.tsx b/app/layout.tsx index c1e818d..390ca0f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,7 @@ import { TVNavigationInitializer } from "@/components/TVNavigationInitializer"; import { Analytics } from "@vercel/analytics/react"; import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; import { PasswordGate } from "@/components/PasswordGate"; -import { siteConfig } from "@/lib/config/site-config"; +import { siteConfig, SITE_ICON_PATH } from "@/lib/config/site-config"; import { AdKeywordsInjector } from "@/components/AdKeywordsInjector"; import { BackToTop } from "@/components/ui/BackToTop"; import { ScrollPositionManager } from "@/components/ScrollPositionManager"; @@ -65,7 +65,7 @@ export const metadata: Metadata = { title: siteConfig.title, description: siteConfig.description, icons: { - icon: '/icon.png', + icon: SITE_ICON_PATH, }, }; @@ -89,7 +89,7 @@ export default function RootLayout({ - + {/* Theme Color (for browser address bar) */} {/* Mobile viewport */} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 7c119a8..716e236 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { Icons } from '@/components/ui/Icon'; -import { siteConfig } from '@/lib/config/site-config'; +import { siteConfig, SITE_ICON_PATH } from '@/lib/config/site-config'; import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store'; import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider'; import { LogOut } from 'lucide-react'; @@ -45,10 +45,11 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { >
{siteConfig.name}
diff --git a/components/player/PlayerNavbar.tsx b/components/player/PlayerNavbar.tsx index 8e6dd02..8c080c6 100644 --- a/components/player/PlayerNavbar.tsx +++ b/components/player/PlayerNavbar.tsx @@ -4,7 +4,7 @@ import Image from 'next/image'; import { Button } from '@/components/ui/Button'; import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { Icons } from '@/components/ui/Icon'; -import { siteConfig } from '@/lib/config/site-config'; +import { siteConfig, SITE_ICON_PATH } from '@/lib/config/site-config'; export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) { const router = useRouter(); @@ -20,10 +20,11 @@ export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) { title={isPremium ? "返回高级主页" : "返回首页"} > {siteConfig.name} diff --git a/lib/config/site-config.ts b/lib/config/site-config.ts index 000f8ed..a855f57 100644 --- a/lib/config/site-config.ts +++ b/lib/config/site-config.ts @@ -9,6 +9,8 @@ export interface SiteConfig { name: string; } +export const SITE_ICON_PATH = "/api/site-icon"; + /** * Site configuration object * Uses environment variables with fallback to default values @@ -18,4 +20,4 @@ export const siteConfig: SiteConfig = { title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台", description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台", name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo", -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index 1b507dc..9fa92ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.8.2", + "version": "4.8.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.8.2", + "version": "4.8.3", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 7b6d4db..13f8027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.8.2", + "version": "4.8.3", "private": true, "scripts": { "dev": "next dev --port ${PORT:-3000}", diff --git a/public/manifest.json b/public/manifest.json index 0611b77..6bac464 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -9,9 +9,7 @@ "orientation": "any", "icons": [ { - "src": "/icon.png", - "sizes": "512x512", - "type": "image/png", + "src": "/api/site-icon", "purpose": "any maskable" } ], @@ -23,4 +21,4 @@ "dir": "ltr", "scope": "/", "prefer_related_applications": false -} \ No newline at end of file +}