From 4256ef41790eadb252f0f3e7384a383cb8d481a6 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 20:42:26 +0800 Subject: [PATCH] feat: Add pagination to the IPTV channel grid and enable editing for IPTV sources. --- app/player/page.tsx | 19 +++- components/iptv/IPTVChannelGrid.tsx | 36 +++++++- components/iptv/IPTVSourceManager.tsx | 120 ++++++++++++++++++++++---- components/ui/icons/utility-icons.tsx | 7 ++ lib/store/iptv-store.ts | 21 ++++- package-lock.json | 12 +-- package.json | 4 +- 7 files changed, 186 insertions(+), 33 deletions(-) diff --git a/app/player/page.tsx b/app/player/page.tsx index 5bc0c9b..450a017 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -97,10 +97,20 @@ function PlayerContent() { return sources; }, [groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]); - // Background fetch alternative sources when none provided + // Background fetch alternative sources when none provided or when existing ones lack full info const fetchedSourcesRef = useRef(false); useEffect(() => { - if (groupedSourcesParam || fetchedSourcesRef.current || !title) return; + if (fetchedSourcesRef.current || !title) return; + + // Check if existing grouped sources already have full info (pic + latency) + let existingSources: SourceInfo[] = []; + if (groupedSourcesParam) { + try { existingSources = JSON.parse(groupedSourcesParam); } catch {} + } + const hasFullInfo = existingSources.length > 1 && + existingSources.every(s => s.pic || s.latency !== undefined); + if (hasFullInfo) return; + fetchedSourcesRef.current = true; const settings = settingsStore.getSettings(); @@ -334,6 +344,8 @@ function PlayerContent() { params.set('id', String(newSource.id)); params.set('source', newSource.source); params.set('title', title || ''); + // Preserve current episode index + params.set('episode', currentEpisode.toString()); // Pass all known sources so switching persists const allSources = groupedSources.length > 0 ? groupedSources : []; if (allSources.length > 1) { @@ -341,6 +353,9 @@ function PlayerContent() { } else if (groupedSourcesParam) { params.set('groupedSources', groupedSourcesParam); } + if (isPremium) { + params.set('premium', '1'); + } setCurrentSourceId(newSource.source); router.replace(`/player?${params.toString()}`, { scroll: false }); }} diff --git a/components/iptv/IPTVChannelGrid.tsx b/components/iptv/IPTVChannelGrid.tsx index 0642ac3..9da91df 100644 --- a/components/iptv/IPTVChannelGrid.tsx +++ b/components/iptv/IPTVChannelGrid.tsx @@ -2,12 +2,15 @@ /** * IPTVChannelGrid - Displays IPTV channels grouped by category with search + * Uses pagination to handle large playlists without freezing. */ import { useState, useMemo } from 'react'; import { Icons } from '@/components/ui/Icon'; import type { M3UChannel } from '@/lib/utils/m3u-parser'; +const PAGE_SIZE = 100; + interface IPTVChannelGridProps { channels: M3UChannel[]; groups: string[]; @@ -18,6 +21,7 @@ interface IPTVChannelGridProps { export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: IPTVChannelGridProps) { const [selectedGroup, setSelectedGroup] = useState(null); const [search, setSearch] = useState(''); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const filteredChannels = useMemo(() => { let result = channels; @@ -34,6 +38,17 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I return result; }, [channels, selectedGroup, search]); + // Reset visible count when filter changes + const filterKey = `${selectedGroup}-${search}`; + const [lastFilterKey, setLastFilterKey] = useState(filterKey); + if (filterKey !== lastFilterKey) { + setLastFilterKey(filterKey); + setVisibleCount(PAGE_SIZE); + } + + const visibleChannels = filteredChannels.slice(0, visibleCount); + const hasMore = visibleCount < filteredChannels.length; + if (channels.length === 0) { return (
@@ -60,6 +75,13 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
+ {/* Channel count */} +
+ {filteredChannels.length === channels.length + ? `共 ${channels.length} 个频道` + : `${filteredChannels.length} / ${channels.length} 个频道`} +
+ {/* Group Tabs */} {groups.length > 0 && (
@@ -94,7 +116,7 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I {/* Channel Grid */}
- {filteredChannels.map((channel, index) => ( + {visibleChannels.map((channel, index) => (
+ {/* Load More */} + {hasMore && ( +
+ +
+ )} + {filteredChannels.length === 0 && (
未找到匹配的频道 diff --git a/components/iptv/IPTVSourceManager.tsx b/components/iptv/IPTVSourceManager.tsx index 64d4e8c..a7e17f6 100644 --- a/components/iptv/IPTVSourceManager.tsx +++ b/components/iptv/IPTVSourceManager.tsx @@ -8,11 +8,25 @@ import { useState } from 'react'; import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store'; import { Icons } from '@/components/ui/Icon'; +const inputProps = { + spellCheck: false, + autoCorrect: 'off' as const, + autoCapitalize: 'off' as const, + autoComplete: 'off' as const, + 'data-form-type': 'other', + 'data-lpignore': 'true', + lang: 'en', + translate: 'no' as const, +}; + export function IPTVSourceManager() { - const { sources, addSource, removeSource, refreshSources, isLoading } = useIPTVStore(); + const { sources, addSource, removeSource, updateSource, refreshSources, isLoading } = useIPTVStore(); const [name, setName] = useState(''); const [url, setUrl] = useState(''); const [showAdd, setShowAdd] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editUrl, setEditUrl] = useState(''); const handleAdd = () => { if (!name.trim() || !url.trim()) return; @@ -26,6 +40,30 @@ export function IPTVSourceManager() { } }; + const startEdit = (source: IPTVSource) => { + setEditingId(source.id); + setEditName(source.name); + setEditUrl(source.url); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditName(''); + setEditUrl(''); + }; + + const saveEdit = () => { + if (!editingId || !editName.trim() || !editUrl.trim()) return; + updateSource(editingId, { name: editName.trim(), url: editUrl.trim() }); + setEditingId(null); + setEditName(''); + setEditUrl(''); + // Auto-refresh after editing + if (!isLoading) { + setTimeout(() => refreshSources(), 100); + } + }; + return (
@@ -59,19 +97,15 @@ export function IPTVSourceManager() { placeholder="源名称(如:我的IPTV)" value={name} onChange={(e) => setName(e.target.value)} - spellCheck={false} - autoCorrect="off" - autoCapitalize="off" + {...inputProps} className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" /> setUrl(e.target.value)} - spellCheck={false} - autoCorrect="off" - autoCapitalize="off" + {...inputProps} className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" />
@@ -102,18 +136,66 @@ export function IPTVSourceManager() { {sources.map((source) => (
-
-

{source.name}

-

{source.url}

-
- + {editingId === source.id ? ( + /* Edit Mode */ +
+ setEditName(e.target.value)} + {...inputProps} + className="w-full px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]" + /> + setEditUrl(e.target.value)} + {...inputProps} + className="w-full px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]" + /> +
+ + +
+
+ ) : ( + /* Display Mode */ +
+
+

{source.name}

+

{source.url}

+
+
+ + +
+
+ )}
))}
diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx index 17b081b..46d3f2f 100644 --- a/components/ui/icons/utility-icons.tsx +++ b/components/ui/icons/utility-icons.tsx @@ -212,6 +212,13 @@ export const UtilityIcons = { ), + Edit: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + Users: ({ className = "", size = 24 }: IconProps) => ( diff --git a/lib/store/iptv-store.ts b/lib/store/iptv-store.ts index 5f5b4b7..a50ec00 100644 --- a/lib/store/iptv-store.ts +++ b/lib/store/iptv-store.ts @@ -24,6 +24,7 @@ interface IPTVState { interface IPTVActions { addSource: (name: string, url: string) => void; removeSource: (id: string) => void; + updateSource: (id: string, updates: Partial>) => void; refreshSources: () => Promise; setLoading: (loading: boolean) => void; } @@ -31,6 +32,7 @@ interface IPTVActions { interface IPTVStore extends IPTVState, IPTVActions {} const MAX_CONCURRENT = 3; +const MAX_CHANNELS = 5000; // Safety limit to prevent UI freeze async function fetchWithConcurrencyLimit( tasks: (() => Promise)[], @@ -73,6 +75,14 @@ export const useIPTVStore = create()( })); }, + updateSource: (id, updates) => { + set((state) => ({ + sources: state.sources.map((s) => + s.id === id ? { ...s, ...updates } : s + ), + })); + }, + refreshSources: async () => { const { sources } = get(); if (sources.length === 0) { @@ -101,8 +111,13 @@ export const useIPTVStore = create()( await fetchWithConcurrencyLimit(tasks, MAX_CONCURRENT); + // Limit total channels for performance + const finalChannels = allChannels.length > MAX_CHANNELS + ? allChannels.slice(0, MAX_CHANNELS) + : allChannels; + set({ - cachedChannels: allChannels, + cachedChannels: finalChannels, cachedGroups: Array.from(allGroups).sort(), lastRefreshed: Date.now(), isLoading: false, @@ -118,9 +133,9 @@ export const useIPTVStore = create()( name: 'kvideo-iptv-store', partialize: (state) => ({ sources: state.sources, - cachedChannels: state.cachedChannels, - cachedGroups: state.cachedGroups, lastRefreshed: state.lastRefreshed, + // Don't persist cachedChannels/cachedGroups - they can be very large + // and will be re-fetched on page load }), } ) diff --git a/package-lock.json b/package-lock.json index 56252c8..e511f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "kvideo", - "version": "4.3.8", + "version": "4.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.3.8", + "version": "4.3.9", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "hls.js": "^1.6.15", - "lucide-react": "^0.571.0", + "lucide-react": "^0.574.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -7230,9 +7230,9 @@ } }, "node_modules/lucide-react": { - "version": "0.571.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.571.0.tgz", - "integrity": "sha512-WyKTOQ5MFyedq1C5kYEiWR1bpwa0lKQaxBgudVziReCiu5itJh6c0WTeg83sPv97IOyAcEA3mKOt4NCAq5cIOw==", + "version": "0.574.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz", + "integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index a9eba96..eccd693 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.3.8", + "version": "4.3.9", "private": true, "scripts": { "dev": "next dev", @@ -15,7 +15,7 @@ "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "hls.js": "^1.6.15", - "lucide-react": "^0.571.0", + "lucide-react": "^0.574.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4",