feat(settings): add version and update status

This commit is contained in:
kuekhaoyang
2026-04-12 11:57:38 +08:00
parent 727fc2b0ed
commit 4fb718f8d8
10 changed files with 660 additions and 3 deletions
+14
View File
@@ -0,0 +1,14 @@
# Changelog
## 4.9.0 - 2026-04-12
- 设置页新增“版本与更新”卡片,直接显示当前版本、最近更新内容和检查结果。
- 新增手动“检查更新”按钮,会对比 GitHub `main` 分支上的最新版本元数据。
- 仓库新增更新日志文件,方便在项目内查看当前版本和更新说明。
## 4.8.3 - 2026-04-12
- 站点图标改为支持运行时配置,Docker 镜像无需重新构建即可替换。
- 扩展播放器默认视口,减少桌面端播放器黑边。
- 修复 Android WebView 中 Cast SDK 判定以及全屏和画中画相关兼容性问题。
+30
View File
@@ -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 判定以及全屏和画中画相关兼容性问题。"
]
}
]
}
+221
View File
@@ -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<AppReleaseEntry>;
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<AppReleaseManifest>;
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<T>(url: string): Promise<T | null> {
const response = await fetch(url, {
cache: 'no-store',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
return null;
}
return response.json() as Promise<T>;
}
function buildResponse(
repository: string,
branch: string,
overrides: Partial<AppUpdateResponse> = {},
): 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<unknown>(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 },
);
}
}
+3
View File
@@ -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() {
</div>
</div>
<AppVersionSettings />
{/* Player Settings */}
<PlayerSettings
fullscreenType={fullscreenType}
+3
View File
@@ -11,6 +11,7 @@ import { AccountSettings } from '@/components/settings/AccountSettings';
import { DisplaySettings } from '@/components/settings/DisplaySettings';
import { PlayerSettings } from '@/components/settings/PlayerSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader';
import { AppVersionSettings } from '@/components/settings/AppVersionSettings';
import { UserSourceSettings } from '@/components/settings/UserSourceSettings';
import { UserDanmakuSettings } from '@/components/settings/UserDanmakuSettings';
import { PermissionGate } from '@/components/PermissionGate';
@@ -80,6 +81,8 @@ export default function SettingsPage() {
{/* Header */}
<SettingsHeader />
<AppVersionSettings />
{/* Account Settings */}
<AccountSettings />
+263
View File
@@ -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 (
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_55%,transparent)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--text-color)]">{title}</h3>
{release ? (
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">
{release.version} · {release.title} · {release.publishedAt}
</p>
) : (
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">{emptyText}</p>
)}
</div>
</div>
{release ? (
<ul className="mt-4 space-y-2">
{release.notes.map((note) => (
<li key={note} className="flex items-start gap-2 text-sm text-[var(--text-color-secondary)]">
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-color)]" />
<span>{note}</span>
</li>
))}
</ul>
) : null}
</div>
);
}
export function AppVersionSettings() {
const [data, setData] = useState<AppUpdateResponse | null>(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 (
<SettingsSection
title="版本与更新"
description="查看当前版本、最近更新内容,并手动检查 GitHub 上是否已有新版本。"
headerAction={
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-[var(--glass-border)] px-4 py-2 text-sm font-medium text-[var(--text-color)] transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--accent-color)_8%,transparent)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
</button>
}
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--accent-color)_7%,transparent)] p-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--text-color-secondary)]"></p>
<p className="mt-2 text-3xl font-bold text-[var(--text-color)]">{data?.currentVersion || '加载中...'}</p>
<p className="mt-2 text-sm text-[var(--text-color-secondary)]">
{currentRelease ? `${currentRelease.title} · ${currentRelease.publishedAt}` : '正在读取本地版本说明'}
</p>
</div>
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_55%,transparent)] p-4">
<div className={`inline-flex rounded-[var(--radius-full)] border px-3 py-1 text-xs font-semibold ${statusMeta.tone}`}>
{statusMeta.label}
</div>
<p className="mt-3 text-sm text-[var(--text-color-secondary)]">{statusMeta.description}</p>
<p className="mt-2 text-xs text-[var(--text-color-secondary)]">
{hasLoaded ? formatDateLabel(data?.checkedAt) : '正在检查'}
{isPending ? '(正在整理结果)' : ''}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-color-secondary)]">
<span>
{data?.source.repository || 'KuekHaoYang/KVideo'} · {data?.source.branch || 'main'}
</span>
<Link
href={data?.source.changelogUrl || 'https://github.com/KuekHaoYang/KVideo/blob/main/CHANGELOG.md'}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--accent-color)] hover:underline"
>
<ExternalLink size={12} />
</Link>
<Link
href={data?.source.repositoryUrl || 'https://github.com/KuekHaoYang/KVideo'}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--accent-color)] hover:underline"
>
<ExternalLink size={12} />
</Link>
</div>
<ReleaseNotesBlock
title="当前版本更新内容"
release={currentRelease}
emptyText="当前版本尚未记录更新内容。"
/>
{shouldShowLatestRelease ? (
<ReleaseNotesBlock
title={`最新可用版本 ${latestRelease?.version}`}
release={latestRelease}
emptyText="最新版本尚未提供更新说明。"
/>
) : null}
</div>
</SettingsSection>
);
}
+78
View File
@@ -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<number | string> {
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';
}
+45
View File
@@ -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;
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.8.3",
"version": "4.9.0",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",