feat: Add pagination to the IPTV channel grid and enable editing for IPTV sources.

This commit is contained in:
kuekhaoyang
2026-02-17 20:42:26 +08:00
parent 798fac455a
commit 4256ef4179
7 changed files with 186 additions and 33 deletions
+17 -2
View File
@@ -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 });
}}
+35 -1
View File
@@ -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<string | null>(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 (
<div className="text-center py-16 text-[var(--text-color-secondary)]">
@@ -60,6 +75,13 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
</div>
</div>
{/* Channel count */}
<div className="text-xs text-[var(--text-color-secondary)]">
{filteredChannels.length === channels.length
? `${channels.length} 个频道`
: `${filteredChannels.length} / ${channels.length} 个频道`}
</div>
{/* Group Tabs */}
{groups.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
@@ -94,7 +116,7 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
{/* Channel Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{filteredChannels.map((channel, index) => (
{visibleChannels.map((channel, index) => (
<button
key={`${channel.name}-${index}`}
onClick={() => onSelect(channel)}
@@ -140,6 +162,18 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
))}
</div>
{/* Load More */}
{hasMore && (
<div className="text-center py-4">
<button
onClick={() => setVisibleCount(prev => prev + PAGE_SIZE)}
className="px-6 py-2 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer"
>
({filteredChannels.length - visibleCount} )
</button>
</div>
)}
{filteredChannels.length === 0 && (
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
+101 -19
View File
@@ -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<string | null>(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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -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)]"
/>
<input
type="url"
type="text"
placeholder="M3U 链接地址"
value={url}
onChange={(e) => 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)]"
/>
<div className="flex justify-end gap-2">
@@ -102,18 +136,66 @@ export function IPTVSourceManager() {
{sources.map((source) => (
<div
key={source.id}
className="flex items-center justify-between p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
className="p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
</div>
<button
onClick={() => removeSource(source.id)}
className="ml-2 p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
>
<Icons.Trash size={14} />
</button>
{editingId === source.id ? (
/* Edit Mode */
<div className="space-y-2">
<input
type="text"
value={editName}
onChange={(e) => 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)]"
/>
<input
type="text"
value={editUrl}
onChange={(e) => 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)]"
/>
<div className="flex justify-end gap-2">
<button
onClick={cancelEdit}
className="px-3 py-1 text-xs text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors cursor-pointer"
>
</button>
<button
onClick={saveEdit}
disabled={!editName.trim() || !editUrl.trim()}
className="px-3 py-1 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:opacity-90 transition-all cursor-pointer disabled:opacity-40"
>
</button>
</div>
</div>
) : (
/* Display Mode */
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
</div>
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
<button
onClick={() => startEdit(source)}
className="p-1.5 text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors cursor-pointer"
title="编辑"
>
<Icons.Edit size={14} />
</button>
<button
onClick={() => removeSource(source.id)}
className="p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
title="删除"
>
<Icons.Trash size={14} />
</button>
</div>
</div>
)}
</div>
))}
</div>
+7
View File
@@ -212,6 +212,13 @@ export const UtilityIcons = {
</svg>
),
Edit: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
),
Users: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
+18 -3
View File
@@ -24,6 +24,7 @@ interface IPTVState {
interface IPTVActions {
addSource: (name: string, url: string) => void;
removeSource: (id: string) => void;
updateSource: (id: string, updates: Partial<Pick<IPTVSource, 'name' | 'url'>>) => void;
refreshSources: () => Promise<void>;
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<T>(
tasks: (() => Promise<T>)[],
@@ -73,6 +75,14 @@ export const useIPTVStore = create<IPTVStore>()(
}));
},
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<IPTVStore>()(
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<IPTVStore>()(
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
}),
}
)
+6 -6
View File
@@ -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"
+2 -2
View File
@@ -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",