Fix managed auth and source list regressions

This commit is contained in:
kuekhaoyang
2026-04-15 23:04:41 +08:00
parent 4f0f9308bb
commit 19e9707041
30 changed files with 4056 additions and 1087 deletions
+256 -191
View File
@@ -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>
);
}
+10 -6
View File
@@ -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 {
+10 -5
View File
@@ -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>
+70 -57
View File
@@ -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