feat: implement IPTV channel search debouncing and pagination, add video source auto-fallback, and improve type normalization.

This commit is contained in:
kuekhaoyang
2026-02-19 14:27:17 +08:00
parent 9ea9f85507
commit c0fcfcc4ac
11 changed files with 120 additions and 29 deletions
+26 -4
View File
@@ -58,6 +58,8 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
const [error, setError] = useState<string | null>(null);
const [showSidebar, setShowSidebar] = useState(false);
const [sidebarSearch, setSidebarSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [sidebarVisibleCount, setSidebarVisibleCount] = useState(100);
const [isLoading, setIsLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [showControls, setShowControls] = useState(true);
@@ -437,10 +439,19 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2;
const filteredSidebarChannels = useMemo(() => {
if (!sidebarSearch.trim()) return channels;
const q = sidebarSearch.toLowerCase().trim();
if (!debouncedSearch.trim()) return channels;
const q = debouncedSearch.toLowerCase().trim();
return channels.filter(ch => ch.name.toLowerCase().includes(q));
}, [channels, sidebarSearch]);
}, [channels, debouncedSearch]);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(sidebarSearch);
setSidebarVisibleCount(100);
}, 200);
return () => clearTimeout(timer);
}, [sidebarSearch]);
return (
<div
@@ -659,7 +670,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
</div>
</div>
<div className="p-1">
{filteredSidebarChannels.map((ch, i) => {
{filteredSidebarChannels.slice(0, sidebarVisibleCount).map((ch, i) => {
const isActive = ch.name === channel.name && ch.url === channel.url;
return (
<button
@@ -696,6 +707,17 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
</button>
);
})}
{filteredSidebarChannels.length > sidebarVisibleCount && (
<button
onClick={(e) => {
e.stopPropagation();
setSidebarVisibleCount(prev => prev + 100);
}}
className="w-full py-2 text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer"
>
({filteredSidebarChannels.length - sidebarVisibleCount} )
</button>
)}
</div>
</div>
)}
+20 -4
View File
@@ -8,6 +8,7 @@ import { Icons } from '@/components/ui/Icon';
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';
interface Episode {
name?: string;
@@ -72,6 +73,17 @@ export function EpisodeList({
});
}, [sources, latencies]);
// Resolve source ID to its actual baseUrl for pinging
const getSourcePingUrl = useCallback((sourceId: string): string | null => {
const settings = settingsStore.getSettings();
const allConfigs = [
...settings.sources,
...settings.premiumSources,
];
const config = allConfigs.find(s => s.id === sourceId);
return config?.baseUrl || null;
}, []);
// Initialize latencies from sources
useEffect(() => {
if (!sources) return;
@@ -93,10 +105,12 @@ export function EpisodeList({
const results = await Promise.all(
missing.map(async (source) => {
try {
const pingUrl = getSourcePingUrl(source.source);
if (!pingUrl) return { source: source.source, latency: undefined };
const response = await fetch('/api/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: source.source }),
body: JSON.stringify({ url: pingUrl }),
});
if (response.ok) {
const data = await response.json();
@@ -116,7 +130,7 @@ export function EpisodeList({
};
autoRefresh();
}
}, [sources]);
}, [sources, getSourcePingUrl]);
// Refresh latencies
const refreshLatencies = useCallback(async () => {
@@ -126,10 +140,12 @@ export function EpisodeList({
const results = await Promise.all(
sources.map(async (source) => {
try {
const pingUrl = getSourcePingUrl(source.source);
if (!pingUrl) return { source: source.source, latency: undefined };
const response = await fetch('/api/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: source.source }),
body: JSON.stringify({ url: pingUrl }),
});
if (response.ok) {
const data = await response.json();
@@ -150,7 +166,7 @@ export function EpisodeList({
});
setLatencies(newLatencies);
setIsLoadingLatency(false);
}, [sources]);
}, [sources, getSourcePingUrl]);
// Memoized display episodes - reversed if toggle is on
const displayEpisodes = useMemo(() => {
+10 -6
View File
@@ -81,7 +81,8 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
}, [sources, onToggleSource]),
});
// Check if content has overflow on mount and when sources change
// Check if content has overflow on mount and when source count changes
const hasCheckedOverflow = useRef(false);
useEffect(() => {
const checkOverflow = () => {
if (badgeContainerRef.current) {
@@ -91,10 +92,13 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
};
checkOverflow();
// Recheck after a short delay to account for animations
const timeout = setTimeout(checkOverflow, 100);
return () => clearTimeout(timeout);
}, [sources]);
// Only do delayed recheck on first measurement
if (!hasCheckedOverflow.current) {
hasCheckedOverflow.current = true;
const timeout = setTimeout(checkOverflow, 100);
return () => clearTimeout(timeout);
}
}, [sources.length]);
return (
<>
@@ -105,7 +109,7 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
role="group"
aria-label="视频源筛选"
>
<div className={`relative transition-all duration-300 z-10 ${!isExpanded ? 'max-h-[50px] overflow-hidden' : 'overflow-visible'
<div className={`relative transition-[max-height] duration-300 z-10 ${!isExpanded ? 'max-h-[50px] overflow-hidden' : 'overflow-visible'
}`}>
<div
ref={badgeContainerRef}
+4 -1
View File
@@ -5,6 +5,7 @@ import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
import { settingsStore } from '@/lib/store/settings-store';
import { hasPermission } from '@/lib/store/auth-store';
export function UserDanmakuSettings() {
const [apis, setApis] = useState<DanmakuApiEntry[]>([]);
@@ -100,7 +101,9 @@ export function UserDanmakuSettings() {
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-color)]">使</p>
{systemApiUrl && (
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{systemApiUrl}</p>
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">
{hasPermission('danmaku_api') ? systemApiUrl : '内置 API'}
</p>
)}
{!systemApiUrl && (
<p className="text-[10px] text-[var(--text-color-secondary)]"> API</p>