feat: Implement IPTV access control, enhance IPTV player with keyboard shortcuts, auto-refresh source latencies, normalize video type names, and persist source badge expansion state.

This commit is contained in:
kuekhaoyang
2026-02-18 11:53:42 +08:00
parent 54263f926b
commit e1ea1a0d9a
9 changed files with 175 additions and 21 deletions
+16 -1
View File
@@ -10,7 +10,7 @@ import { IPTVSourceManager } from '@/components/iptv/IPTVSourceManager';
import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
import { IPTVPlayer } from '@/components/iptv/IPTVPlayer';
import { Icons } from '@/components/ui/Icon';
import { hasPermission } from '@/lib/store/auth-store';
import { hasPermission, getSession } from '@/lib/store/auth-store';
import Link from 'next/link';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
@@ -20,6 +20,21 @@ export default function IPTVPage() {
const [showManager, setShowManager] = useState(false);
const canManageSources = hasPermission('source_management');
const canAccessIPTV = hasPermission('iptv_access');
// If auth is configured and user doesn't have iptv_access, show access denied
if (!canAccessIPTV && getSession()) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)]">
<div className="text-center p-8">
<Icons.TV size={48} className="mx-auto mb-4 text-[var(--text-color-secondary)] opacity-40" />
<p className="text-[var(--text-color)] font-medium mb-2">访 IPTV</p>
<p className="text-sm text-[var(--text-color-secondary)] mb-4"></p>
<Link href="/" className="text-sm text-[var(--accent-color)] hover:underline"></Link>
</div>
</div>
);
}
// Auto-refresh on first load if we have sources but no cached channels
useEffect(() => {
+64 -3
View File
@@ -352,11 +352,14 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
if (value > 0 && video.muted) video.muted = false;
};
const progressRef = useRef<HTMLDivElement>(null);
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (isLive) return;
const video = videoRef.current;
if (!video || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const bar = progressRef.current;
if (!video || !duration || !bar) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
};
@@ -370,6 +373,63 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
}
};
// Keyboard shortcuts (matching main video player)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
resetControlsTimeout();
const video = videoRef.current;
if (!video) return;
switch (e.key.toLowerCase()) {
case ' ':
case 'k':
e.preventDefault();
togglePlay();
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
toggleMute();
break;
case 'escape':
e.preventDefault();
onClose();
break;
case 'arrowright':
case 'l':
e.preventDefault();
if (!isLive && isFinite(video.duration)) {
video.currentTime = Math.min(video.duration, video.currentTime + 10);
}
break;
case 'arrowleft':
case 'j':
e.preventDefault();
if (!isLive && isFinite(video.duration)) {
video.currentTime = Math.max(0, video.currentTime - 10);
}
break;
case 'arrowup':
e.preventDefault();
video.volume = Math.min(1, video.volume + 0.1);
if (video.muted) video.muted = false;
break;
case 'arrowdown':
e.preventDefault();
video.volume = Math.max(0, video.volume - 0.1);
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
});
const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2;
const filteredSidebarChannels = useMemo(() => {
@@ -469,11 +529,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
{!isLive && duration > 0 && (
<div className="px-4 pt-2">
<div
ref={progressRef}
className="group h-1 hover:h-2 bg-white/20 rounded-full cursor-pointer transition-all relative"
onClick={(e) => { e.stopPropagation(); handleSeek(e); }}
>
<div
className="h-full bg-[var(--accent-color)] rounded-full relative"
className="h-full bg-[var(--accent-color)] rounded-full relative pointer-events-none"
style={{ width: `${(currentTime / duration) * 100}%` }}
>
<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" />
+6 -3
View File
@@ -6,7 +6,7 @@ import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config';
import { getSession, clearSession, type AuthSession } from '@/lib/store/auth-store';
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
import { LogOut } from 'lucide-react';
interface NavbarProps {
@@ -24,7 +24,8 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const handleLogout = () => {
clearSession();
window.location.reload();
// Navigate to root to clear search query params
window.location.href = '/';
};
return (
@@ -59,7 +60,8 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
</Link>
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* IPTV Link */}
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
{hasPermission('iptv_access') && (
<Link
href="/iptv"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
@@ -69,6 +71,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
>
<Icons.TV size={16} className="sm:w-5 sm:h-5" />
</Link>
)}
{/* User Info */}
{session && (
+34
View File
@@ -74,12 +74,46 @@ export function EpisodeList({
useEffect(() => {
if (!sources) return;
const initial: Record<string, number> = {};
let hasMissing = false;
sources.forEach(s => {
if (s.latency !== undefined) {
initial[s.source] = s.latency;
} else {
hasMissing = true;
}
});
setLatencies(initial);
// Auto-refresh latencies for sources that don't have them
if (hasMissing && sources.length > 1) {
const autoRefresh = async () => {
const missing = sources.filter(s => s.latency === undefined);
const results = await Promise.all(
missing.map(async (source) => {
try {
const response = await fetch('/api/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: source.source }),
});
if (response.ok) {
const data = await response.json();
return { source: source.source, latency: data.latency as number | undefined };
}
} catch { /* ignore */ }
return { source: source.source, latency: undefined };
})
);
setLatencies(prev => {
const updated = { ...prev };
results.forEach(({ source, latency }) => {
if (latency !== undefined) updated[source] = latency;
});
return updated;
});
};
autoRefresh();
}
}, [sources]);
// Refresh latencies
+16 -2
View File
@@ -11,6 +11,8 @@ import { Icons } from '@/components/ui/Icon';
import { SourceBadgeItem } from './SourceBadgeItem';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
const EXPAND_KEY = 'kvideo_source_badges_expanded';
interface Source {
id: string;
name: string;
@@ -24,13 +26,25 @@ interface SourceBadgeListProps {
}
export function SourceBadgeList({ sources, selectedSources, onToggleSource }: SourceBadgeListProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(() => {
if (typeof window === 'undefined') return true;
const saved = localStorage.getItem(EXPAND_KEY);
return saved !== 'false'; // default to expanded
});
const [focusedIndex, setFocusedIndex] = useState(-1);
const [hasOverflow, setHasOverflow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const badgeContainerRef = useRef<HTMLDivElement>(null);
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
const toggleExpanded = useCallback(() => {
setIsExpanded(prev => {
const next = !prev;
localStorage.setItem(EXPAND_KEY, String(next));
return next;
});
}, []);
// Keyboard navigation
useKeyboardNavigation({
enabled: true,
@@ -101,7 +115,7 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
{hasOverflow && (
<button
onClick={() => setIsExpanded(!isExpanded)}
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"
>
+30 -6
View File
@@ -5,31 +5,50 @@ import type { TypeBadge } from '@/lib/types';
/**
* Custom hook to automatically collect and track type badges from video results
*
*
* Features:
* - Auto-collects unique type_name values
* - Normalizes similar type names (e.g., "动作片" and "动作" merge)
* - Tracks count per type
* - Updates dynamically as videos are added/removed
* - Removes badges when count reaches 0
* - Supports filtering by selected types
*/
// Normalize type names to merge near-duplicates
function normalizeTypeName(type: string): string {
let t = type.trim();
// Remove trailing 片/剧 suffix for grouping (e.g., "动作片" → "动作", "喜剧片" → "喜剧")
// But keep standalone names like "电影", "电视剧" etc.
if (t.length > 2 && t.endsWith('片')) {
t = t.slice(0, -1);
}
return t;
}
export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
// Collect and count type badges from videos
const typeBadges = useMemo<TypeBadge[]>(() => {
const typeMap = new Map<string, number>();
const typeMap = new Map<string, { display: string; count: number }>();
videos.forEach(video => {
if (video.type_name && video.type_name.trim()) {
const type = video.type_name.trim();
typeMap.set(type, (typeMap.get(type) || 0) + 1);
const raw = video.type_name.trim();
const normalized = normalizeTypeName(raw);
const existing = typeMap.get(normalized);
if (existing) {
existing.count++;
} else {
typeMap.set(normalized, { display: raw, count: 1 });
}
}
});
// Convert to array and sort by count (descending)
return Array.from(typeMap.entries())
.map(([type, count]) => ({ type, count }))
.map(([, val]) => ({ type: val.display, count: val.count }))
.sort((a, b) => b.count - a.count);
}, [videos]);
@@ -39,8 +58,13 @@ export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
return videos;
}
// Build a set of normalized selected types
const normalizedSelected = new Set(
Array.from(selectedTypes).map(normalizeTypeName)
);
return videos.filter(video =>
video.type_name && selectedTypes.has(video.type_name.trim())
video.type_name && normalizedSelected.has(normalizeTypeName(video.type_name.trim()))
);
}, [videos, selectedTypes]);
+6 -3
View File
@@ -12,11 +12,12 @@ export type Permission =
| 'data_management'
| 'player_settings'
| 'danmaku_appearance'
| 'view_settings';
| 'view_settings'
| 'iptv_access';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings'],
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'],
viewer: ['view_settings'],
};
@@ -59,6 +60,8 @@ export function clearSession(): void {
if (typeof window === 'undefined') return;
sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(SESSION_KEY);
// Clear search cache so new session gets fresh results
localStorage.removeItem('kvideo_search_cache');
// Also clear old unlock keys for backward compat cleanup
sessionStorage.removeItem('kvideo-unlocked');
localStorage.removeItem('kvideo-unlocked');
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.4.1",
"version": "4.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.4.1",
"version": "4.4.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.4.1",
"version": "4.4.2",
"private": true,
"scripts": {
"dev": "next dev",