mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Fix managed auth and source list regressions
This commit is contained in:
+256
-191
@@ -1,238 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSession, setSession } from '@/lib/store/auth-store';
|
||||
import { Lock, User } from 'lucide-react';
|
||||
import { clearSession, getSession, setSession, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
|
||||
import { useIPTVStore } from '@/lib/store/iptv-store';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Sync IPTV sources from environment variable.
|
||||
* Format: JSON array [{name, url}] or comma-separated URLs.
|
||||
*/
|
||||
type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
function syncIPTVSources(rawValue: string) {
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
|
||||
// Try JSON
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter((item: any) => item && typeof item.url === 'string');
|
||||
}
|
||||
} catch {
|
||||
// Try comma-separated URLs
|
||||
if (rawValue.includes('http')) {
|
||||
const urls = rawValue.split(',').map(u => u.trim()).filter(u => u.startsWith('http'));
|
||||
entries = urls.map((url, i) => ({
|
||||
name: urls.length > 1 ? `直播源 ${i + 1}` : '直播源',
|
||||
url,
|
||||
}));
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter((item: unknown): item is { name: string; url: string } => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const candidate = item as { name?: unknown; url?: unknown };
|
||||
return typeof candidate.url === 'string';
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (rawValue.includes('http')) {
|
||||
const urls = rawValue.split(',').map((value) => value.trim()).filter((value) => value.startsWith('http'));
|
||||
entries = urls.map((url, index) => ({
|
||||
name: urls.length > 1 ? `直播源 ${index + 1}` : '直播源',
|
||||
url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
iptvStore.syncBuiltinSources(entries);
|
||||
iptvStore.syncBuiltinSources(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync merge sources setting from environment variable.
|
||||
* Value: 'true' or '1' to enable grouped display mode.
|
||||
*/
|
||||
function syncMergeSources(rawValue: string) {
|
||||
const enabled = rawValue === 'true' || rawValue === '1';
|
||||
if (!enabled) return;
|
||||
const enabled = rawValue === 'true' || rawValue === '1';
|
||||
if (!enabled) return;
|
||||
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.searchDisplayMode !== 'grouped') {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
searchDisplayMode: 'grouped',
|
||||
});
|
||||
}
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.searchDisplayMode !== 'grouped') {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
searchDisplayMode: 'grouped',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function syncDanmakuApiUrl(rawValue: string) {
|
||||
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
|
||||
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
|
||||
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.danmakuApiUrl !== rawValue) {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
danmakuApiUrl: rawValue,
|
||||
});
|
||||
}
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.danmakuApiUrl !== rawValue) {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
danmakuApiUrl: rawValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyRuntimeConfig(data: {
|
||||
subscriptionSources?: string;
|
||||
iptvSources?: string;
|
||||
mergeSources?: string;
|
||||
danmakuApiUrl?: string;
|
||||
subscriptionSources?: string;
|
||||
iptvSources?: string;
|
||||
mergeSources?: string;
|
||||
danmakuApiUrl?: string;
|
||||
}) {
|
||||
if (data.subscriptionSources) {
|
||||
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
|
||||
}
|
||||
if (data.subscriptionSources) {
|
||||
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
|
||||
}
|
||||
|
||||
if (data.iptvSources) {
|
||||
syncIPTVSources(data.iptvSources);
|
||||
}
|
||||
if (data.iptvSources) {
|
||||
syncIPTVSources(data.iptvSources);
|
||||
}
|
||||
|
||||
if (data.mergeSources) {
|
||||
syncMergeSources(data.mergeSources);
|
||||
}
|
||||
if (data.mergeSources) {
|
||||
syncMergeSources(data.mergeSources);
|
||||
}
|
||||
|
||||
if (data.danmakuApiUrl) {
|
||||
syncDanmakuApiUrl(data.danmakuApiUrl);
|
||||
}
|
||||
if (data.danmakuApiUrl) {
|
||||
syncDanmakuApiUrl(data.danmakuApiUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) {
|
||||
// Enable background subscription syncing globally
|
||||
useSubscriptionSync();
|
||||
function toAuthSession(session: {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: AuthSession['role'];
|
||||
customPermissions?: AuthSession['customPermissions'];
|
||||
mode?: AuthSession['mode'];
|
||||
}): AuthSession {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions,
|
||||
mode: session.mode,
|
||||
};
|
||||
}
|
||||
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [hasAuth, setHasAuth] = useState(initialHasAuth);
|
||||
const [persistSession, setPersistSession] = useState(true);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
export function PasswordGate({
|
||||
children,
|
||||
hasAuth: initialHasAuth,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hasAuth: boolean;
|
||||
}) {
|
||||
useSubscriptionSync();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [persistSession, setPersistSession] = useState(true);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
||||
|
||||
const init = async () => {
|
||||
// Check if already has a valid session
|
||||
const session = getSession();
|
||||
const isAuthenticated = !!session;
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// Initial fast check
|
||||
const localLocked = initialHasAuth && !isAuthenticated;
|
||||
if (mounted) {
|
||||
setIsLocked(localLocked);
|
||||
setIsClient(true);
|
||||
}
|
||||
const init = async () => {
|
||||
const mirroredSession = getSession();
|
||||
|
||||
// Fetch remote config & sync
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
try {
|
||||
const [configRes, sessionRes] = await Promise.all([
|
||||
fetch('/api/auth'),
|
||||
fetch('/api/auth/session'),
|
||||
]);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (mounted) {
|
||||
setHasAuth(data.hasAuth);
|
||||
setPersistSession(data.persistSession);
|
||||
applyRuntimeConfig(data);
|
||||
|
||||
// Re-evaluate lock status with confirmed server state
|
||||
const confirmLocked = data.hasAuth && !isAuthenticated;
|
||||
setIsLocked(confirmLocked);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("PasswordGate init failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, [initialHasAuth]);
|
||||
|
||||
const handleUnlock = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.valid) {
|
||||
applyRuntimeConfig(data);
|
||||
setSession({
|
||||
profileId: data.profileId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
customPermissions: data.customPermissions,
|
||||
}, data.persistSession ?? persistSession);
|
||||
|
||||
// Reload to re-initialize stores with profiled keys
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// API error
|
||||
if (!configRes.ok) {
|
||||
throw new Error('Failed to fetch auth config');
|
||||
}
|
||||
|
||||
// Password didn't match
|
||||
setError(true);
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
setTimeout(() => form?.classList.remove('animate-shake'), 500);
|
||||
const config = await configRes.json();
|
||||
const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null };
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setPersistSession(config.persistSession);
|
||||
setLoginMode(config.loginMode || 'none');
|
||||
applyRuntimeConfig(config);
|
||||
|
||||
if (sessionStatus.authenticated && sessionStatus.session) {
|
||||
const session = toAuthSession(sessionStatus.session);
|
||||
const hasMatchingMirror = mirroredSession &&
|
||||
mirroredSession.accountId === session.accountId &&
|
||||
mirroredSession.profileId === session.profileId;
|
||||
|
||||
setSession(session, config.persistSession);
|
||||
|
||||
if (!hasMatchingMirror) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mirroredSession) {
|
||||
clearSession();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocked(!!config.hasAuth);
|
||||
setIsClient(true);
|
||||
} catch {
|
||||
if (!mounted) return;
|
||||
setIsLocked(initialHasAuth && !mirroredSession);
|
||||
setIsClient(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isClient) return null; // Prevent hydration mismatch
|
||||
init();
|
||||
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [initialHasAuth]);
|
||||
|
||||
const handleUnlock = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsValidating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: loginMode === 'managed' ? username : undefined,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid && data.session) {
|
||||
setSession(toAuthSession(data.session), data.persistSession ?? persistSession);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors and show the same message as invalid credentials.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
|
||||
<div className="w-full max-w-md p-4">
|
||||
<form
|
||||
id="password-form"
|
||||
onSubmit={handleUnlock}
|
||||
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
setTimeout(() => form?.classList.remove('animate-shake'), 500);
|
||||
};
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold">访问受限</h2>
|
||||
<p className="text-[var(--text-color-secondary)]">请输入访问密码以继续</p>
|
||||
</div>
|
||||
if (!isClient) return null;
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
placeholder="输入密码..."
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'
|
||||
} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center animate-pulse">
|
||||
密码错误
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isValidating}
|
||||
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? '验证中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
const showManagedFields = loginMode === 'managed';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
|
||||
<div className="w-full max-w-md p-4">
|
||||
<form
|
||||
id="password-form"
|
||||
onSubmit={handleUnlock}
|
||||
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold">访问受限</h2>
|
||||
<p className="text-[var(--text-color-secondary)]">
|
||||
{showManagedFields ? '请输入用户名和密码以继续' : '请输入访问密码以继续'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
{showManagedFields && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<User size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="输入用户名..."
|
||||
className="w-full pl-11 pr-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={showManagedFields ? '输入密码...' : '输入密码...'}
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
autoFocus={!showManagedFields}
|
||||
autoComplete={showManagedFields ? 'current-password' : 'off'}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center animate-pulse">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isValidating}
|
||||
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? '验证中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
@@ -242,6 +307,6 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
|
||||
animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked';
|
||||
|
||||
export function PremiumPasswordGate({ children }: { children: React.ReactNode }) {
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [hasPremiumAuth, setHasPremiumAuth] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
@@ -21,14 +20,19 @@ export function PremiumPasswordGate({ children }: { children: React.ReactNode })
|
||||
const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await res.json();
|
||||
const [configRes, sessionRes] = await Promise.all([
|
||||
fetch('/api/auth'),
|
||||
fetch('/api/auth/session'),
|
||||
]);
|
||||
if (!configRes.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await configRes.json();
|
||||
const sessionData = sessionRes.ok ? await sessionRes.json() : null;
|
||||
const isAdminSession = !!sessionData?.session &&
|
||||
(sessionData.session.role === 'admin' || sessionData.session.role === 'super_admin');
|
||||
|
||||
if (mounted) {
|
||||
setHasPremiumAuth(data.hasPremiumAuth);
|
||||
// If no premium password configured, allow access
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked);
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked && !isAdminSession);
|
||||
setIsClient(true);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -24,9 +24,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
const siteIconSrc = useSiteIcon();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
// Navigate to root to clear search query params
|
||||
window.location.href = '/';
|
||||
fetch('/api/auth/session', { method: 'DELETE' })
|
||||
.catch(() => {
|
||||
// Best effort only.
|
||||
})
|
||||
.finally(() => {
|
||||
clearSession();
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -83,9 +88,9 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
{session.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span>
|
||||
{session.role === 'admin' && (
|
||||
{(session.role === 'admin' || session.role === 'super_admin') && (
|
||||
<span className="px-1 py-0.5 bg-[var(--accent-color)]/10 text-[var(--accent-color)] rounded text-[10px] font-medium">
|
||||
管理
|
||||
{session.role === 'super_admin' ? '超管' : '管理'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,10 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { extractQualityLabel } from '@/lib/utils/video';
|
||||
import type { VideoResolutionInfo } from './hooks/useVideoResolution';
|
||||
import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe';
|
||||
import { getCachedResolution } from '@/lib/player/resolution-cache';
|
||||
import { getSourceResolutionBadge, shouldExpandForCurrentSource } from '@/lib/player/source-list-utils';
|
||||
|
||||
interface Episode {
|
||||
name?: string;
|
||||
@@ -66,6 +67,7 @@ export function EpisodeList({
|
||||
}: EpisodeListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const [sourceExpanded, setSourceExpanded] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
|
||||
@@ -77,18 +79,14 @@ export function EpisodeList({
|
||||
|
||||
// Helper: get best resolution badge for a source
|
||||
const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => {
|
||||
// For current source, prefer actual detected resolution from video element
|
||||
if (isCurrent && currentResolution) {
|
||||
return { label: currentResolution.label, color: currentResolution.color };
|
||||
}
|
||||
// Check probed resolution from m3u8 manifest
|
||||
const probeKey = `${source.source}:${source.id}`;
|
||||
const probed = sourceResolutions?.[probeKey];
|
||||
if (probed) {
|
||||
return { label: probed.label, color: probed.color };
|
||||
}
|
||||
// Fall back to quality label parsed from remarks
|
||||
return extractQualityLabel(source.remarks) || null;
|
||||
return getSourceResolutionBadge({
|
||||
isCurrent,
|
||||
currentResolution: currentResolution || undefined,
|
||||
probedResolution: sourceResolutions?.[probeKey] || undefined,
|
||||
cachedResolution: getCachedResolution(source.source, source.id) || undefined,
|
||||
remarks: source.remarks,
|
||||
});
|
||||
}, [currentResolution, sourceResolutions]);
|
||||
|
||||
// Current source info
|
||||
@@ -97,21 +95,47 @@ export function EpisodeList({
|
||||
return sources.find(s => s.source === currentSource) || null;
|
||||
}, [sources, currentSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceSectionCollapsed) {
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}, [sourceSectionCollapsed]);
|
||||
|
||||
// Sort sources by latency
|
||||
const initialLatencies = useMemo(() => {
|
||||
if (!sources) return {};
|
||||
return sources.reduce<Record<string, number>>((accumulator, source) => {
|
||||
if (source.latency !== undefined) {
|
||||
accumulator[source.source] = source.latency;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [sources]);
|
||||
|
||||
const mergedLatencies = useMemo(() => ({
|
||||
...initialLatencies,
|
||||
...latencies,
|
||||
}), [initialLatencies, latencies]);
|
||||
|
||||
const sortedSources = useMemo(() => {
|
||||
if (!sources) return [];
|
||||
return [...sources].sort((a, b) => {
|
||||
const latA = latencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = latencies[b.source] ?? b.latency ?? Infinity;
|
||||
const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
});
|
||||
}, [sources, latencies]);
|
||||
}, [mergedLatencies, sources]);
|
||||
|
||||
const isSourceListOpen = !sourceSectionCollapsed && sourceExpanded;
|
||||
const forceExpandedForCurrentSource = !!currentSource && shouldExpandForCurrentSource(sortedSources, currentSource);
|
||||
const showAllVisibleSources = showAllSources || forceExpandedForCurrentSource;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSourceListOpen || !currentSource) return;
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
sourceItemRefs.current[currentSource]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [currentSource, isSourceListOpen, showAllVisibleSources, sortedSources]);
|
||||
|
||||
// Resolve source ID to its actual baseUrl for pinging
|
||||
const getSourcePingUrl = useCallback((sourceId: string): string | null => {
|
||||
@@ -127,16 +151,7 @@ export function EpisodeList({
|
||||
// Initialize latencies from sources
|
||||
useEffect(() => {
|
||||
if (!sources) return;
|
||||
const initial: Record<string, number> = {};
|
||||
let hasMissing = false;
|
||||
sources.forEach(s => {
|
||||
if (s.latency !== undefined) {
|
||||
initial[s.source] = s.latency;
|
||||
} else {
|
||||
hasMissing = true;
|
||||
}
|
||||
});
|
||||
setLatencies(initial);
|
||||
const hasMissing = sources.some((source) => source.latency === undefined);
|
||||
|
||||
// Auto-refresh latencies for sources that don't have them
|
||||
if (hasMissing && sources.length > 1) {
|
||||
@@ -283,7 +298,7 @@ export function EpisodeList({
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!sourceSectionCollapsed) {
|
||||
setSourceExpanded(!sourceExpanded);
|
||||
setSourceExpanded((current) => !current);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 flex items-center justify-between gap-3 text-left ${sourceSectionCollapsed ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
@@ -301,7 +316,7 @@ export function EpisodeList({
|
||||
{!sourceSectionCollapsed && (
|
||||
<Icons.ChevronDown
|
||||
size={16}
|
||||
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${sourceExpanded ? 'rotate-180' : 'rotate-0'}`}
|
||||
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${isSourceListOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@@ -331,11 +346,11 @@ export function EpisodeList({
|
||||
</div>
|
||||
|
||||
{/* Expanded source list */}
|
||||
{!sourceSectionCollapsed && sourceExpanded && (
|
||||
{isSourceListOpen && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{(() => {
|
||||
const MAX_VISIBLE = 5;
|
||||
const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const visibleSources = showAllVisibleSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const hasMoreSources = sortedSources.length > MAX_VISIBLE;
|
||||
|
||||
// Group sources by typeName
|
||||
@@ -360,12 +375,14 @@ export function EpisodeList({
|
||||
)}
|
||||
{typeSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
const globalIndex = sortedSources.indexOf(source);
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
@@ -401,16 +418,13 @@ export function EpisodeList({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
||||
{source.sourceName || source.source}
|
||||
{(() => {
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
return badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{source.remarks && !extractQualityLabel(source.remarks) && (
|
||||
{source.remarks && !badge && (
|
||||
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
||||
)}
|
||||
{latency !== undefined && (
|
||||
@@ -441,11 +455,13 @@ export function EpisodeList({
|
||||
) : (
|
||||
visibleSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
@@ -481,16 +497,13 @@ export function EpisodeList({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
||||
{source.sourceName || source.source}
|
||||
{(() => {
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
return badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{source.remarks && !extractQualityLabel(source.remarks) && (
|
||||
{source.remarks && !badge && (
|
||||
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
||||
)}
|
||||
{latency !== undefined && (
|
||||
@@ -520,10 +533,10 @@ export function EpisodeList({
|
||||
</div>
|
||||
{hasMoreSources && (
|
||||
<button
|
||||
onClick={() => setShowAllSources(!showAllSources)}
|
||||
onClick={() => setShowAllSources((current) => !current)}
|
||||
className="w-full mt-1.5 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] flex items-center justify-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllSources ? (
|
||||
{showAllVisibleSources ? (
|
||||
<>收起 <Icons.ChevronDown size={12} className="rotate-180" /></>
|
||||
) : (
|
||||
<>展开更多 ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user