diff --git a/app/iptv/page.tsx b/app/iptv/page.tsx index 933a531..baceff5 100644 --- a/app/iptv/page.tsx +++ b/app/iptv/page.tsx @@ -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 ( +
+
+ +

无权访问 IPTV

+

请联系管理员开通权限

+ 返回首页 +
+
+ ); + } // Auto-refresh on first load if we have sources but no cached channels useEffect(() => { diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 1157a0e..052b056 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -352,11 +352,14 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV if (value > 0 && video.muted) video.muted = false; }; + const progressRef = useRef(null); + const handleSeek = (e: React.MouseEvent) => { 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 && (
{ e.stopPropagation(); handleSeek(e); }} >
diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 8729fc9..6a303d2 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -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) {
- {/* IPTV Link */} + {/* IPTV Link - only show if user has iptv_access or no auth configured */} + {hasPermission('iptv_access') && ( + )} {/* User Info */} {session && ( diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index 7cd8ef8..cdefba5 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -74,12 +74,46 @@ export function EpisodeList({ useEffect(() => { if (!sources) return; const initial: Record = {}; + 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 diff --git a/components/search/SourceBadgeList.tsx b/components/search/SourceBadgeList.tsx index e328e7c..5b507e4 100644 --- a/components/search/SourceBadgeList.tsx +++ b/components/search/SourceBadgeList.tsx @@ -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(null); const badgeContainerRef = useRef(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 && (