diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c1758cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 4.9.0 - 2026-04-12 + +- 设置页新增“版本与更新”卡片,直接显示当前版本、最近更新内容和检查结果。 +- 新增手动“检查更新”按钮,会对比 GitHub `main` 分支上的最新版本元数据。 +- 仓库新增更新日志文件,方便在项目内查看当前版本和更新说明。 + +## 4.8.3 - 2026-04-12 + +- 站点图标改为支持运行时配置,Docker 镜像无需重新构建即可替换。 +- 扩展播放器默认视口,减少桌面端播放器黑边。 +- 修复 Android WebView 中 Cast SDK 判定以及全屏和画中画相关兼容性问题。 + diff --git a/app-release.json b/app-release.json new file mode 100644 index 0000000..bd14c4a --- /dev/null +++ b/app-release.json @@ -0,0 +1,30 @@ +{ + "repository": { + "owner": "KuekHaoYang", + "name": "KVideo", + "branch": "main" + }, + "currentVersion": "4.9.0", + "releases": [ + { + "version": "4.9.0", + "publishedAt": "2026-04-12", + "title": "设置页新增版本信息与更新检查", + "notes": [ + "设置页新增“版本与更新”卡片,直接显示当前版本、最近更新内容和检查结果。", + "新增手动“检查更新”按钮,会对比 GitHub main 分支上的最新版本元数据。", + "仓库新增更新日志文件,方便在项目内查看当前版本和更新说明。" + ] + }, + { + "version": "4.8.3", + "publishedAt": "2026-04-12", + "title": "站点图标与播放器兼容性修复", + "notes": [ + "站点图标改为支持运行时配置,Docker 镜像无需重新构建即可替换。", + "扩展播放器默认视口,减少桌面端播放器黑边。", + "修复 Android WebView 中 Cast SDK 判定以及全屏和画中画相关兼容性问题。" + ] + } + ] +} diff --git a/app/api/app-update/route.ts b/app/api/app-update/route.ts new file mode 100644 index 0000000..9f52f8f --- /dev/null +++ b/app/api/app-update/route.ts @@ -0,0 +1,221 @@ +import { + APP_VERSION, + compareVersions, + getDefaultRepositoryBranch, + getDefaultRepositorySlug, + getReleaseByVersion, + LOCAL_RELEASE_MANIFEST, +} from '@/lib/app-release'; +import type { + AppReleaseEntry, + AppReleaseManifest, + AppUpdateResponse, +} from '@/lib/types/app-update'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +const MANIFEST_PATH = 'app-release.json'; +const CHANGELOG_PATH = 'CHANGELOG.md'; + +function parseRepositoryTarget(repository: string) { + const [owner, name] = repository.split('/', 2); + + if (!owner || !name) { + return null; + } + + return { owner, name }; +} + +function buildSourceInfo(repository: string, branch: string) { + const target = parseRepositoryTarget(repository); + + if (!target) { + const emptyUrl = 'https://github.com'; + return { + repository, + branch, + manifestUrl: emptyUrl, + changelogUrl: emptyUrl, + repositoryUrl: emptyUrl, + }; + } + + const { owner, name } = target; + + return { + repository, + branch, + manifestUrl: `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${MANIFEST_PATH}`, + changelogUrl: `https://github.com/${owner}/${name}/blob/${branch}/${CHANGELOG_PATH}`, + repositoryUrl: `https://github.com/${owner}/${name}`, + }; +} + +function isValidReleaseEntry(value: unknown): value is AppReleaseEntry { + if (!value || typeof value !== 'object') { + return false; + } + + const entry = value as Partial; + + return ( + typeof entry.version === 'string' && + typeof entry.publishedAt === 'string' && + typeof entry.title === 'string' && + Array.isArray(entry.notes) && + entry.notes.every((note) => typeof note === 'string') + ); +} + +function normalizeManifest( + value: unknown, + repository: string, + branch: string, +): AppReleaseManifest | null { + if (!value || typeof value !== 'object') { + return null; + } + + const manifest = value as Partial; + const target = parseRepositoryTarget(repository); + + if (!target || typeof manifest.currentVersion !== 'string' || !Array.isArray(manifest.releases)) { + return null; + } + + const releases = manifest.releases.filter(isValidReleaseEntry); + + if (releases.length === 0) { + return null; + } + + return { + repository: { + owner: manifest.repository?.owner || target.owner, + name: manifest.repository?.name || target.name, + branch: manifest.repository?.branch || branch, + }, + currentVersion: manifest.currentVersion, + releases, + }; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + cache: 'no-store', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + return null; + } + + return response.json() as Promise; +} + +function buildResponse( + repository: string, + branch: string, + overrides: Partial = {}, +): AppUpdateResponse { + const source = buildSourceInfo(repository, branch); + const currentRelease = getReleaseByVersion(APP_VERSION); + + return { + currentVersion: APP_VERSION, + currentRelease, + latestVersion: APP_VERSION, + latestRelease: currentRelease, + status: 'up-to-date', + updateAvailable: false, + checkedAt: new Date().toISOString(), + checkedRemotely: false, + usedRemoteManifest: false, + source, + ...overrides, + }; +} + +export async function GET() { + const repository = + process.env.UPDATE_REPOSITORY?.trim() || + process.env.NEXT_PUBLIC_UPDATE_REPOSITORY?.trim() || + getDefaultRepositorySlug(); + const branch = + process.env.UPDATE_BRANCH?.trim() || + process.env.NEXT_PUBLIC_UPDATE_BRANCH?.trim() || + getDefaultRepositoryBranch(); + + const source = buildSourceInfo(repository, branch); + + try { + const remoteManifestJson = await fetchJson(source.manifestUrl); + const remoteManifest = normalizeManifest(remoteManifestJson, repository, branch); + + if (remoteManifest) { + const latestVersion = remoteManifest.currentVersion; + const latestRelease = + getReleaseByVersion(latestVersion, remoteManifest) ?? remoteManifest.releases[0] ?? null; + const comparison = compareVersions(latestVersion, APP_VERSION); + + return Response.json( + buildResponse(repository, branch, { + latestVersion, + latestRelease, + status: + comparison > 0 + ? 'update-available' + : comparison < 0 + ? 'ahead-of-remote' + : 'up-to-date', + updateAvailable: comparison > 0, + checkedRemotely: true, + usedRemoteManifest: true, + }), + ); + } + + const remotePackage = await fetchJson<{ version?: string }>(`${source.manifestUrl.replace(MANIFEST_PATH, 'package.json')}`); + const latestVersion = remotePackage?.version?.trim(); + + if (latestVersion) { + const comparison = compareVersions(latestVersion, APP_VERSION); + + return Response.json( + buildResponse(repository, branch, { + latestVersion, + latestRelease: getReleaseByVersion(latestVersion, LOCAL_RELEASE_MANIFEST), + status: + comparison > 0 + ? 'update-available' + : comparison < 0 + ? 'ahead-of-remote' + : 'up-to-date', + updateAvailable: comparison > 0, + checkedRemotely: true, + usedRemoteManifest: false, + }), + ); + } + + return Response.json( + buildResponse(repository, branch, { + status: 'check-failed', + error: '无法获取远程版本信息。', + }), + { status: 200 }, + ); + } catch (error) { + return Response.json( + buildResponse(repository, branch, { + status: 'check-failed', + error: error instanceof Error ? error.message : '未知错误', + }), + { status: 200 }, + ); + } +} diff --git a/app/premium/settings/page.tsx b/app/premium/settings/page.tsx index 00e455b..0c7fab9 100644 --- a/app/premium/settings/page.tsx +++ b/app/premium/settings/page.tsx @@ -5,6 +5,7 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings'; import { DisplaySettings } from '@/components/settings/DisplaySettings'; import { PlayerSettings } from '@/components/settings/PlayerSettings'; +import { AppVersionSettings } from '@/components/settings/AppVersionSettings'; import { AdminGate } from '@/components/AdminGate'; import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage'; import Link from 'next/link'; @@ -75,6 +76,8 @@ export default function PremiumSettingsPage() { + + {/* Player Settings */} + + {/* Account Settings */} diff --git a/components/settings/AppVersionSettings.tsx b/components/settings/AppVersionSettings.tsx new file mode 100644 index 0000000..0fbee15 --- /dev/null +++ b/components/settings/AppVersionSettings.tsx @@ -0,0 +1,263 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useEffectEvent, useState, useTransition } from 'react'; +import { ExternalLink, RefreshCw } from 'lucide-react'; +import { SettingsSection } from './SettingsSection'; +import type { AppReleaseEntry, AppUpdateResponse } from '@/lib/types/app-update'; + +const DEFAULT_SOURCE = { + repository: 'KuekHaoYang/KVideo', + branch: 'main', + manifestUrl: 'https://raw.githubusercontent.com/KuekHaoYang/KVideo/main/app-release.json', + changelogUrl: 'https://github.com/KuekHaoYang/KVideo/blob/main/CHANGELOG.md', + repositoryUrl: 'https://github.com/KuekHaoYang/KVideo', +}; + +function formatDateLabel(value?: string) { + if (!value) { + return '未记录'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(parsed); +} + +function getStatusMeta(data: AppUpdateResponse | null) { + switch (data?.status) { + case 'update-available': + return { + label: '发现新版本', + tone: 'text-amber-500 border-amber-500/30 bg-amber-500/10', + description: `GitHub 最新版本为 ${data.latestVersion},当前实例仍是 ${data.currentVersion}。`, + }; + case 'ahead-of-remote': + return { + label: '本地版本较新', + tone: 'text-sky-500 border-sky-500/30 bg-sky-500/10', + description: `当前实例版本 ${data.currentVersion} 新于 GitHub 检查结果 ${data.latestVersion}。`, + }; + case 'check-failed': + return { + label: '检查失败', + tone: 'text-red-500 border-red-500/30 bg-red-500/10', + description: data.error || '远程版本检查失败,当前仅显示本地版本信息。', + }; + case 'up-to-date': + default: + return { + label: '已是最新版本', + tone: 'text-emerald-500 border-emerald-500/30 bg-emerald-500/10', + description: data + ? `当前实例版本 ${data.currentVersion} 与 GitHub 最新版本一致。` + : '正在获取最新版本信息。', + }; + } +} + +function ReleaseNotesBlock({ + title, + release, + emptyText, +}: { + title: string; + release: AppReleaseEntry | null; + emptyText: string; +}) { + return ( +
+
+
+

{title}

+ {release ? ( +

+ {release.version} · {release.title} · {release.publishedAt} +

+ ) : ( +

{emptyText}

+ )} +
+
+ + {release ? ( +
    + {release.notes.map((note) => ( +
  • + + {note} +
  • + ))} +
+ ) : null} +
+ ); +} + +export function AppVersionSettings() { + const [data, setData] = useState(null); + const [hasLoaded, setHasLoaded] = useState(false); + const [isPending, startTransition] = useTransition(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const fetchUpdateInfo = useEffectEvent(async () => { + setIsRefreshing(true); + + try { + const response = await fetch('/api/app-update', { + cache: 'no-store', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = (await response.json()) as AppUpdateResponse; + + startTransition(() => { + setData(payload); + setHasLoaded(true); + }); + } finally { + setIsRefreshing(false); + } + }); + + useEffect(() => { + void fetchUpdateInfo().catch((error) => { + startTransition(() => { + setHasLoaded(true); + setData((previous) => ({ + currentVersion: previous?.currentVersion || '未知', + currentRelease: previous?.currentRelease || null, + latestVersion: previous?.latestVersion || previous?.currentVersion || '未知', + latestRelease: previous?.latestRelease || previous?.currentRelease || null, + status: 'check-failed', + updateAvailable: false, + checkedAt: new Date().toISOString(), + checkedRemotely: false, + usedRemoteManifest: false, + source: previous?.source || DEFAULT_SOURCE, + error: error instanceof Error ? error.message : '未知错误', + })); + }); + }); + }, [fetchUpdateInfo, startTransition]); + + const handleRefresh = () => { + void fetchUpdateInfo().catch((error) => { + startTransition(() => { + setData((previous) => + previous + ? { + ...previous, + status: 'check-failed', + error: error instanceof Error ? error.message : '未知错误', + checkedAt: new Date().toISOString(), + } + : previous, + ); + setHasLoaded(true); + }); + }); + }; + + const statusMeta = getStatusMeta(data); + const currentRelease = data?.currentRelease ?? null; + const latestRelease = data?.latestRelease ?? null; + const shouldShowLatestRelease = Boolean( + latestRelease && (!currentRelease || latestRelease.version !== currentRelease.version), + ); + + return ( + + + 检查更新 + + } + > +
+
+
+

当前版本

+

{data?.currentVersion || '加载中...'}

+

+ {currentRelease ? `${currentRelease.title} · ${currentRelease.publishedAt}` : '正在读取本地版本说明'} +

+
+ +
+
+ {statusMeta.label} +
+

{statusMeta.description}

+

+ 上次检查:{hasLoaded ? formatDateLabel(data?.checkedAt) : '正在检查'} + {isPending ? '(正在整理结果)' : ''} +

+
+
+ +
+ + 检查来源:{data?.source.repository || 'KuekHaoYang/KVideo'} · {data?.source.branch || 'main'} + + + 查看更新日志 + + + + 查看仓库 + + +
+ + + + {shouldShowLatestRelease ? ( + + ) : null} +
+
+ ); +} diff --git a/lib/app-release.ts b/lib/app-release.ts new file mode 100644 index 0000000..67e8cf5 --- /dev/null +++ b/lib/app-release.ts @@ -0,0 +1,78 @@ +import appReleaseJson from '@/app-release.json'; +import packageJson from '@/package.json'; +import type { AppReleaseEntry, AppReleaseManifest } from '@/lib/types/app-update'; + +const rawManifest = appReleaseJson as AppReleaseManifest; + +export const APP_VERSION = packageJson.version; + +export const LOCAL_RELEASE_MANIFEST: AppReleaseManifest = { + ...rawManifest, + currentVersion: APP_VERSION, +}; + +function normalizeVersionParts(version: string): Array { + return version + .trim() + .replace(/^v/i, '') + .split(/[.-]/) + .filter(Boolean) + .map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isNaN(parsed) ? part : parsed; + }); +} + +export function compareVersions(left: string, right: string): number { + const leftParts = normalizeVersionParts(left); + const rightParts = normalizeVersionParts(right); + const maxLength = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const leftPart = leftParts[index] ?? 0; + const rightPart = rightParts[index] ?? 0; + + if (typeof leftPart === 'number' && typeof rightPart === 'number') { + if (leftPart !== rightPart) { + return leftPart > rightPart ? 1 : -1; + } + continue; + } + + const comparison = String(leftPart).localeCompare(String(rightPart), undefined, { + numeric: true, + sensitivity: 'base', + }); + + if (comparison !== 0) { + return comparison > 0 ? 1 : -1; + } + } + + return 0; +} + +export function getReleaseByVersion( + version: string, + manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST, +): AppReleaseEntry | null { + return manifest.releases.find((release) => release.version === version) ?? null; +} + +export function getLatestKnownRelease( + manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST, +): AppReleaseEntry | null { + return manifest.releases[0] ?? null; +} + +export function getDefaultRepositorySlug( + manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST, +): string { + return `${manifest.repository.owner}/${manifest.repository.name}`; +} + +export function getDefaultRepositoryBranch( + manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST, +): string { + return manifest.repository.branch || 'main'; +} diff --git a/lib/types/app-update.ts b/lib/types/app-update.ts new file mode 100644 index 0000000..b0412a2 --- /dev/null +++ b/lib/types/app-update.ts @@ -0,0 +1,45 @@ +export interface AppReleaseEntry { + version: string; + publishedAt: string; + title: string; + notes: string[]; +} + +export interface AppReleaseManifest { + repository: { + owner: string; + name: string; + branch: string; + }; + currentVersion: string; + releases: AppReleaseEntry[]; +} + +export type AppUpdateStatus = + | 'up-to-date' + | 'update-available' + | 'ahead-of-remote' + | 'check-failed'; + +export interface AppUpdateSource { + repository: string; + branch: string; + manifestUrl: string; + changelogUrl: string; + repositoryUrl: string; +} + +export interface AppUpdateResponse { + currentVersion: string; + currentRelease: AppReleaseEntry | null; + latestVersion: string; + latestRelease: AppReleaseEntry | null; + status: AppUpdateStatus; + updateAvailable: boolean; + checkedAt: string; + checkedRemotely: boolean; + usedRemoteManifest: boolean; + source: AppUpdateSource; + error?: string; +} + diff --git a/package-lock.json b/package-lock.json index 9fa92ed..b96fa8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.8.3", + "version": "4.9.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.8.3", + "version": "4.9.0", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 13f8027..45e8b4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.8.3", + "version": "4.9.0", "private": true, "scripts": { "dev": "next dev --port ${PORT:-3000}",