Fix issue #72 follow-ups and IPTV source handling

This commit is contained in:
kuekhaoyang
2026-03-26 14:16:58 +08:00
parent 35f7f4d83a
commit 91a89e8bf2
23 changed files with 453 additions and 154 deletions
+1 -7
View File
@@ -13,7 +13,6 @@ import { Lock } from 'lucide-react';
*/
function syncIPTVSources(rawValue: string) {
const iptvStore = useIPTVStore.getState();
const existingUrls = new Set(iptvStore.sources.map(s => s.url));
let entries: { name: string; url: string }[] = [];
@@ -34,12 +33,7 @@ function syncIPTVSources(rawValue: string) {
}
}
// Add new sources that don't already exist
for (const entry of entries) {
if (!existingUrls.has(entry.url)) {
iptvStore.addSource(entry.name || '直播源', entry.url);
}
}
iptvStore.syncBuiltinSources(entries);
}
/**
+4 -1
View File
@@ -18,6 +18,7 @@ interface FavoriteButtonProps {
type?: string;
year?: string;
remarks?: string;
sourceMap?: Record<string, string | number>;
className?: string;
size?: number;
showTooltip?: boolean;
@@ -33,6 +34,7 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
type,
year,
remarks,
sourceMap,
className = '',
size = 20,
showTooltip = true,
@@ -61,11 +63,12 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
type,
year,
remarks,
sourceMap,
});
setIsFav(newState);
setTimeout(() => setIsAnimating(false), 300);
}, [videoId, source, title, poster, sourceName, type, year, remarks, toggleFavorite]);
}, [videoId, source, title, poster, sourceName, type, year, remarks, sourceMap, toggleFavorite]);
return (
<button
+14
View File
@@ -5,6 +5,8 @@
import { Icons } from '@/components/ui/Icon';
import { formatDate } from '@/lib/utils/format-utils';
import { getSourceName } from '@/lib/utils/source-names';
import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache';
import type { FavoriteItem } from '@/lib/types';
interface FavoritesItemProps {
@@ -20,6 +22,18 @@ export function FavoritesItem({ item, onRemove, isPremium = false }: FavoritesIt
source: item.source,
title: item.title,
});
if (item.sourceMap && Object.keys(item.sourceMap).length > 1) {
const groupData = Object.entries(item.sourceMap).map(([sourceName, videoId]) => ({
id: videoId,
source: sourceName,
sourceName: getSourceName(sourceName),
pic: item.poster,
}));
const cacheKey = storeGroupedSources(groupData);
if (cacheKey) {
params.set('gs', cacheKey);
}
}
if (isPremium) {
params.set('premium', '1');
}
+2
View File
@@ -32,6 +32,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
id: videoId,
source: sourceName,
sourceName: getSourceName(sourceName),
pic: item.poster,
}));
const cacheKey = storeGroupedSources(groupData);
if (cacheKey) {
@@ -101,6 +102,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
title={item.title}
poster={item.poster}
remarks={episodeText}
sourceMap={item.sourceMap}
size={14}
className="!p-1.5 !bg-transparent !border-0 !shadow-none hover:!bg-[var(--glass-bg)]"
showTooltip={false}
+90 -83
View File
@@ -71,6 +71,15 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
const visibleChannels = filteredChannels.slice(0, visibleCount);
const hasMore = visibleCount < filteredChannels.length;
const orderedSources = useMemo(() => {
if (!sources || !selectedSourceId) return sources || [];
const selected = sources.find((source) => source.id === selectedSourceId);
return selected ? [selected, ...sources.filter((source) => source.id !== selectedSourceId)] : sources;
}, [sources, selectedSourceId]);
const orderedGroups = useMemo(() => {
if (!selectedGroup) return effectiveGroups;
return [selectedGroup, ...effectiveGroups.filter((group) => group !== selectedGroup)];
}, [effectiveGroups, selectedGroup]);
if (channels.length === 0) {
return (
@@ -84,92 +93,90 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
return (
<div className="space-y-4">
{/* Search + Group Filter */}
<div className="flex gap-3">
<div className="relative flex-1">
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
<input
type="text"
placeholder="搜索频道..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-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="sticky top-4 z-10 -mx-2 px-2 py-2 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_88%,transparent)] backdrop-blur-md">
<div className="flex gap-3">
<div className="relative flex-1">
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
<input
type="text"
placeholder="搜索频道..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-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>
</div>
<div className="mt-3 text-xs text-[var(--text-color-secondary)]">
{filteredChannels.length === effectiveChannels.length
? `${effectiveChannels.length} 个频道`
: `${filteredChannels.length} / ${effectiveChannels.length} 个频道`}
</div>
{hasMultipleSources && sources && (
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedSourceId(null)}
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedSourceId === null
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
</button>
{orderedSources.map((source) => {
const sourceData = channelsBySource![source.id];
if (!sourceData) return null;
return (
<button
key={source.id}
onClick={() => setSelectedSourceId(source.id === selectedSourceId ? null : source.id)}
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedSourceId === source.id
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
{source.name} ({sourceData.channels.length})
</button>
);
})}
</div>
)}
{effectiveGroups.length > 0 && (
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedGroup(null)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === null
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
({effectiveChannels.length})
</button>
{orderedGroups.map((group) => {
const count = effectiveChannels.filter((channel) => channel.group === group).length;
return (
<button
key={group}
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === group
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
{group} ({count})
</button>
);
})}
</div>
)}
</div>
{/* Channel count */}
<div className="text-xs text-[var(--text-color-secondary)]">
{filteredChannels.length === effectiveChannels.length
? `${effectiveChannels.length} 个频道`
: `${filteredChannels.length} / ${effectiveChannels.length} 个频道`}
</div>
{/* Source Tabs (only when multiple sources) */}
{hasMultipleSources && sources && (
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedSourceId(null)}
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedSourceId === null
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
</button>
{sources.map((source) => {
const sourceData = channelsBySource![source.id];
if (!sourceData) return null;
return (
<button
key={source.id}
onClick={() => setSelectedSourceId(source.id === selectedSourceId ? null : source.id)}
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedSourceId === source.id
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
{source.name} ({sourceData.channels.length})
</button>
);
})}
</div>
)}
{/* Group Tabs */}
{effectiveGroups.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedGroup(null)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === null
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
({effectiveChannels.length})
</button>
{effectiveGroups.map((group) => {
const count = effectiveChannels.filter((c) => c.group === group).length;
return (
<button
key={group}
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === group
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
{group} ({count})
</button>
);
})}
</div>
)}
{/* Channel Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{visibleChannels.map((channel, index) => (
+31 -5
View File
@@ -54,6 +54,22 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function getSeekRange(video: HTMLVideoElement): { start: number; end: number; duration: number } | null {
if (video.seekable.length > 0) {
const start = video.seekable.start(0);
const end = video.seekable.end(video.seekable.length - 1);
if (isFinite(start) && isFinite(end) && end > start) {
return { start, end, duration: end - start };
}
}
if (isFinite(video.duration) && video.duration > 0) {
return { start: 0, end: video.duration, duration: video.duration };
}
return null;
}
export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channelsBySource, sources }: IPTVPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
@@ -82,8 +98,6 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
const [showAllRoutes, setShowAllRoutes] = useState(false);
// Multi-level sidebar state
const [activeSourceId, setActiveSourceId] = useState<string | null>(null);
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
@@ -434,12 +448,24 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
if (isLive) return;
const video = videoRef.current;
const bar = progressRef.current;
if (!video || !duration || !bar) return;
if (!video || !bar) return;
const seekRange = getSeekRange(video);
if (!seekRange) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
video.currentTime = seekRange.start + ratio * seekRange.duration;
};
const progressPercent = useMemo(() => {
const video = videoRef.current;
const seekRange = video ? getSeekRange(video) : null;
if (seekRange) {
return Math.max(0, Math.min(100, ((currentTime - seekRange.start) / seekRange.duration) * 100));
}
if (!duration) return 0;
return Math.max(0, Math.min(100, (currentTime / duration) * 100));
}, [currentTime, duration]);
const toggleFullscreen = async () => {
if (!containerRef.current) return;
if (document.fullscreenElement) {
@@ -789,7 +815,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
>
<div
className="h-full bg-[var(--accent-color)] rounded-full relative pointer-events-none"
style={{ width: `${(currentTime / duration) * 100}%` }}
style={{ width: `${progressPercent}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
+36 -18
View File
@@ -7,6 +7,7 @@
import { useState } from 'react';
import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store';
import { Icons } from '@/components/ui/Icon';
import { hasPermission } from '@/lib/store/auth-store';
const inputProps = {
spellCheck: false,
@@ -21,12 +22,14 @@ const inputProps = {
export function IPTVSourceManager() {
const { sources, addSource, removeSource, updateSource, refreshSources, isLoading } = useIPTVStore();
const canUseBuiltinSources = hasPermission('iptv_builtin_sources');
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 visibleSources = sources.filter((source) => canUseBuiltinSources || source.kind !== 'builtin');
const handleAdd = () => {
if (!name.trim() || !url.trim()) return;
@@ -73,7 +76,7 @@ export function IPTVSourceManager() {
<div className="flex gap-2 flex-wrap">
<button
onClick={() => refreshSources()}
disabled={isLoading || sources.length === 0}
disabled={isLoading || visibleSources.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 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 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Icons.RefreshCw size={12} className={isLoading ? 'animate-spin' : ''} />
@@ -127,13 +130,13 @@ export function IPTVSourceManager() {
)}
{/* Source List */}
{sources.length === 0 ? (
{visibleSources.length === 0 ? (
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
M3U JSON
</div>
) : (
<div className="space-y-2">
{sources.map((source) => (
{visibleSources.map((source) => (
<div
key={source.id}
className="p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
@@ -175,24 +178,39 @@ export function IPTVSourceManager() {
/* 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>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
{source.kind === 'builtin' && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[var(--accent-color)]/10 text-[var(--accent-color)]">
</span>
)}
</div>
<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>
{source.kind !== 'builtin' ? (
<>
<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>
</>
) : (
<span className="text-[10px] text-[var(--text-color-secondary)] px-2">
</span>
)}
</div>
</div>
)}
+1
View File
@@ -31,6 +31,7 @@ export function SourceBadgeItem({
return (
<button
type="button"
ref={innerRef}
onClick={handleClick}
onFocus={onFocus}
+1
View File
@@ -161,6 +161,7 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
{hasOverflow && (
<button
type="button"
onClick={toggleExpanded}
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors self-start cursor-pointer"
+1
View File
@@ -29,6 +29,7 @@ export function TypeBadgeItem({
return (
<button
type="button"
ref={innerRef}
onClick={handleClick}
onFocus={onFocus}
+1
View File
@@ -116,6 +116,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
</div>
{hasOverflow && (
<button
type="button"
onClick={toggleExpanded}
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors self-start cursor-pointer"
+1
View File
@@ -168,6 +168,7 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
type={representative.type_name}
year={representative.vod_year}
remarks={representative.vod_remarks}
sourceMap={Object.fromEntries(videos.map((video) => [video.source, video.vod_id]))}
size={16}
className="shadow-md"
isPremium={isPremium}
+4 -2
View File
@@ -27,11 +27,13 @@ const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
{ key: 'player_settings', label: '播放器设置' },
{ key: 'danmaku_appearance', label: '弹幕外观' },
{ key: 'iptv_access', label: 'IPTV 访问' },
{ key: 'iptv_source_management', label: 'IPTV 自定义源管理' },
{ key: 'iptv_builtin_sources', label: 'IPTV 内置源' },
];
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
viewer: ['view_settings'],
};
+14
View File
@@ -5,6 +5,17 @@ import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
import { ModalHeader } from '@/components/ui/ModalHeader';
import type { VideoSource } from '@/lib/types';
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,
};
interface AddSourceModalProps {
isOpen: boolean;
onClose: () => void;
@@ -49,6 +60,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:新视频源"
{...inputProps}
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
/>
</div>
@@ -64,6 +76,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
onChange={(e) => setCustomId(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="自动生成,可手动修改"
disabled={isEditing}
{...inputProps}
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] disabled:opacity-50"
/>
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">
@@ -81,6 +94,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/api.php/provide/vod"
{...inputProps}
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
/>
</div>