mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
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:
+16
-1
@@ -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(() => {
|
||||
|
||||
@@ -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,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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.1",
|
||||
"version": "4.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user