mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 04:03:42 +08:00
feat: implement IPTV channel search debouncing and pagination, add video source auto-fallback, and improve type normalization.
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user