feat: Implement granular role-based access control, enhance IPTV stream proxy with header forwarding, and improve IPTV store with per-source channel caching.

This commit is contained in:
kuekhaoyang
2026-02-18 10:24:03 +08:00
parent aa8f8e9ff9
commit 54263f926b
17 changed files with 369 additions and 138 deletions
+4 -3
View File
@@ -15,7 +15,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
interface AccountInfo {
name: string;
role: 'admin' | 'viewer';
role: 'super_admin' | 'admin' | 'viewer';
}
function getAccountList(): AccountInfo[] {
@@ -23,7 +23,7 @@ function getAccountList(): AccountInfo[] {
// Add admin from ADMIN_PASSWORD
if (effectiveAdminPassword) {
accounts.push({ name: '管理员', role: 'admin' });
accounts.push({ name: '超级管理员', role: 'super_admin' });
}
// Add accounts from ACCOUNTS env var
@@ -35,7 +35,8 @@ function getAccountList(): AccountInfo[] {
const parts = entry.split(':');
if (parts.length >= 2) {
const name = parts[1].trim();
const role = parts[2]?.trim() === 'admin' ? 'admin' : 'viewer';
const parsedRole = parts[2]?.trim();
const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer';
if (name) {
accounts.push({ name, role });
}
+4 -3
View File
@@ -19,7 +19,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
interface AccountEntry {
password: string;
name: string;
role: 'admin' | 'viewer';
role: 'super_admin' | 'admin' | 'viewer';
}
function parseAccounts(): AccountEntry[] {
@@ -32,10 +32,11 @@ function parseAccounts(): AccountEntry[] {
const parts = entry.split(':');
if (parts.length < 2) return null;
const [password, name, role] = parts;
const parsedRole = role?.trim();
return {
password: password.trim(),
name: name.trim(),
role: (role?.trim() === 'admin' ? 'admin' : 'viewer') as 'admin' | 'viewer',
role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer',
};
})
.filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0);
@@ -78,7 +79,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
valid: true,
name: '管理员',
role: 'admin',
role: 'super_admin',
profileId,
persistSession: PERSIST_SESSION,
});
+31 -12
View File
@@ -52,12 +52,21 @@ export async function GET(request: NextRequest) {
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
'Accept': '*/*',
},
});
const parsedUrl = new URL(url);
const fetchHeaders: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
'Accept': '*/*',
'Referer': `${parsedUrl.protocol}//${parsedUrl.host}/`,
'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
};
// Forward Range header for partial content requests
const rangeHeader = request.headers.get('range');
if (rangeHeader) {
fetchHeaders['Range'] = rangeHeader;
}
const response = await fetch(url, { headers: fetchHeaders });
if (!response.ok) {
return NextResponse.json(
@@ -96,13 +105,23 @@ export async function GET(request: NextRequest) {
const body = response.body;
const forwardContentType = contentType || 'video/mp2t';
const responseHeaders: Record<string, string> = {
'Content-Type': forwardContentType,
'Cache-Control': 'public, max-age=60',
...corsHeaders,
};
// Forward range-related headers
const contentRange = response.headers.get('content-range');
if (contentRange) responseHeaders['Content-Range'] = contentRange;
const acceptRanges = response.headers.get('accept-ranges');
if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges;
const contentLength = response.headers.get('content-length');
if (contentLength) responseHeaders['Content-Length'] = contentLength;
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': forwardContentType,
'Cache-Control': 'public, max-age=60',
...corsHeaders,
},
status: response.status,
headers: responseHeaders,
});
}
} catch (e) {
+15 -11
View File
@@ -10,15 +10,17 @@ 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 { AdminGate } from '@/components/AdminGate';
import { hasPermission } from '@/lib/store/auth-store';
import Link from 'next/link';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
export default function IPTVPage() {
const { sources, cachedChannels, cachedGroups, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
const { sources, cachedChannels, cachedGroups, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
const [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
const [showManager, setShowManager] = useState(false);
const canManageSources = hasPermission('source_management');
// Auto-refresh on first load if we have sources but no cached channels
useEffect(() => {
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
@@ -27,7 +29,6 @@ export default function IPTVPage() {
}, [sources.length, cachedChannels.length, isLoading, refreshSources]);
return (
<AdminGate>
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
@@ -54,13 +55,15 @@ export default function IPTVPage() {
</div>
</div>
<button
onClick={() => setShowManager(!showManager)}
className="flex items-center gap-1.5 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer"
>
<Icons.Settings size={16} />
</button>
{canManageSources && (
<button
onClick={() => setShowManager(!showManager)}
className="flex items-center gap-1.5 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer"
>
<Icons.Settings size={16} />
</button>
)}
</div>
</div>
@@ -87,6 +90,8 @@ export default function IPTVPage() {
groups={cachedGroups}
onSelect={setActiveChannel}
activeChannel={activeChannel}
channelsBySource={cachedChannelsBySource}
sources={sources}
/>
</div>
)}
@@ -102,6 +107,5 @@ export default function IPTVPage() {
/>
)}
</div>
</AdminGate>
);
}
+38 -32
View File
@@ -11,7 +11,8 @@ import { AccountSettings } from '@/components/settings/AccountSettings';
import { DisplaySettings } from '@/components/settings/DisplaySettings';
import { PlayerSettings } from '@/components/settings/PlayerSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader';
import { AdminGate } from '@/components/AdminGate';
import { PermissionGate } from '@/components/PermissionGate';
import { hasPermission } from '@/lib/store/auth-store';
import { useSettingsPage } from './hooks/useSettingsPage';
export default function SettingsPage() {
@@ -64,7 +65,6 @@ export default function SettingsPage() {
} = useSettingsPage();
return (
<AdminGate>
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
{/* Header */}
@@ -74,20 +74,23 @@ export default function SettingsPage() {
<AccountSettings />
{/* Player Settings */}
<PlayerSettings
fullscreenType={fullscreenType}
onFullscreenTypeChange={handleFullscreenTypeChange}
proxyMode={proxyMode}
onProxyModeChange={handleProxyModeChange}
danmakuApiUrl={danmakuApiUrl}
onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
danmakuOpacity={danmakuOpacity}
onDanmakuOpacityChange={handleDanmakuOpacityChange}
danmakuFontSize={danmakuFontSize}
onDanmakuFontSizeChange={handleDanmakuFontSizeChange}
danmakuDisplayArea={danmakuDisplayArea}
onDanmakuDisplayAreaChange={handleDanmakuDisplayAreaChange}
/>
<PermissionGate permission="player_settings">
<PlayerSettings
fullscreenType={fullscreenType}
onFullscreenTypeChange={handleFullscreenTypeChange}
proxyMode={proxyMode}
onProxyModeChange={handleProxyModeChange}
danmakuApiUrl={danmakuApiUrl}
onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
danmakuOpacity={danmakuOpacity}
onDanmakuOpacityChange={handleDanmakuOpacityChange}
danmakuFontSize={danmakuFontSize}
onDanmakuFontSizeChange={handleDanmakuFontSizeChange}
danmakuDisplayArea={danmakuDisplayArea}
onDanmakuDisplayAreaChange={handleDanmakuDisplayAreaChange}
showDanmakuApi={hasPermission('danmaku_api')}
/>
</PermissionGate>
{/* Display Settings */}
<DisplaySettings
@@ -100,16 +103,18 @@ export default function SettingsPage() {
/>
{/* Source Management */}
<SourceSettings
sources={sources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => {
setEditingSource(null);
setIsAddModalOpen(true);
}}
onEditSource={handleEditSource}
/>
<PermissionGate permission="source_management">
<SourceSettings
sources={sources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => {
setEditingSource(null);
setIsAddModalOpen(true);
}}
onEditSource={handleEditSource}
/>
</PermissionGate>
{/* Sort Options */}
<SortSettings
@@ -118,11 +123,13 @@ export default function SettingsPage() {
/>
{/* Data Management */}
<DataSettings
onExport={() => setIsExportModalOpen(true)}
onImport={() => setIsImportModalOpen(true)}
onReset={() => setIsResetDialogOpen(true)}
/>
<PermissionGate permission="data_management">
<DataSettings
onExport={() => setIsExportModalOpen(true)}
onImport={() => setIsImportModalOpen(true)}
onReset={() => setIsResetDialogOpen(true)}
/>
</PermissionGate>
</div>
{/* Modals */}
@@ -175,6 +182,5 @@ export default function SettingsPage() {
dangerous
/>
</div>
</AdminGate>
);
}
+14
View File
@@ -0,0 +1,14 @@
'use client';
import { hasPermission, type Permission } from '@/lib/store/auth-store';
interface PermissionGateProps {
permission: Permission;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function PermissionGate({ permission, children, fallback = null }: PermissionGateProps) {
if (!hasPermission(permission)) return <>{fallback}</>;
return <>{children}</>;
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function SortableTag({
>
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
<button
onClick={() => !showTagManager && onTagSelect(tag.id)}
onClick={() => onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
${selectedTag === tag.id
+10 -1
View File
@@ -7,7 +7,11 @@ const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' };
const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_';
export function useTagManager() {
const [contentType, setContentType] = useState<'movie' | 'tv'>('movie');
const [contentType, setContentType] = useState<'movie' | 'tv'>(() => {
if (typeof window === 'undefined') return 'movie';
const saved = localStorage.getItem('kvideo_default_content_type');
return saved === 'tv' ? 'tv' : 'movie';
});
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value);
const [tags, setTags] = useState<any[]>([]);
const [isLoadingTags, setIsLoadingTags] = useState(false);
@@ -15,6 +19,11 @@ export function useTagManager() {
const [showTagManager, setShowTagManager] = useState(false);
const [justAddedTag, setJustAddedTag] = useState(false);
// Persist content type preference
useEffect(() => {
localStorage.setItem('kvideo_default_content_type', contentType);
}, [contentType]);
const storageKey = `${STORAGE_KEY_PREFIX}${contentType}`;
// Load custom tags or fetch from Douban
+67 -11
View File
@@ -8,6 +8,7 @@
import { useState, useMemo } from 'react';
import { Icons } from '@/components/ui/Icon';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
import type { IPTVSource } from '@/lib/store/iptv-store';
const PAGE_SIZE = 100;
@@ -16,15 +17,30 @@ interface IPTVChannelGridProps {
groups: string[];
onSelect: (channel: M3UChannel) => void;
activeChannel?: M3UChannel | null;
channelsBySource?: Record<string, { channels: M3UChannel[]; groups: string[] }>;
sources?: IPTVSource[];
}
export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: IPTVChannelGridProps) {
export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, channelsBySource, sources }: IPTVChannelGridProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const hasMultipleSources = sources && sources.length > 1 && channelsBySource;
// Get effective channels and groups based on selected source
const { effectiveChannels, effectiveGroups } = useMemo(() => {
if (!hasMultipleSources || !selectedSourceId) {
return { effectiveChannels: channels, effectiveGroups: groups };
}
const sourceData = channelsBySource[selectedSourceId];
if (!sourceData) return { effectiveChannels: channels, effectiveGroups: groups };
return { effectiveChannels: sourceData.channels, effectiveGroups: sourceData.groups };
}, [channels, groups, hasMultipleSources, selectedSourceId, channelsBySource]);
const filteredChannels = useMemo(() => {
let result = channels;
let result = effectiveChannels;
if (selectedGroup) {
result = result.filter((c) => c.group === selectedGroup);
@@ -36,16 +52,23 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
}
return result;
}, [channels, selectedGroup, search]);
}, [effectiveChannels, selectedGroup, search]);
// Reset visible count when filter changes
const filterKey = `${selectedGroup}-${search}`;
const filterKey = `${selectedSourceId}-${selectedGroup}-${search}`;
const [lastFilterKey, setLastFilterKey] = useState(filterKey);
if (filterKey !== lastFilterKey) {
setLastFilterKey(filterKey);
setVisibleCount(PAGE_SIZE);
}
// Reset group when source changes
const [lastSourceId, setLastSourceId] = useState(selectedSourceId);
if (selectedSourceId !== lastSourceId) {
setLastSourceId(selectedSourceId);
setSelectedGroup(null);
}
const visibleChannels = filteredChannels.slice(0, visibleCount);
const hasMore = visibleCount < filteredChannels.length;
@@ -77,13 +100,46 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
{/* Channel count */}
<div className="text-xs text-[var(--text-color-secondary)]">
{filteredChannels.length === channels.length
? `${channels.length} 个频道`
: `${filteredChannels.length} / ${channels.length} 个频道`}
{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 */}
{groups.length > 0 && (
{effectiveGroups.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedGroup(null)}
@@ -93,10 +149,10 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
}`}
>
({channels.length})
({effectiveChannels.length})
</button>
{groups.map((group) => {
const count = channels.filter((c) => c.group === group).length;
{effectiveGroups.map((group) => {
const count = effectiveChannels.filter((c) => c.group === group).length;
return (
<button
key={group}
+100 -42
View File
@@ -6,11 +6,23 @@
* Routes streams through proxy to avoid CORS when direct access fails.
*/
import { useRef, useEffect, useState, useCallback } from 'react';
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import Hls from 'hls.js';
import { Icons } from '@/components/ui/Icon';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
enableWorker: true,
lowLatencyMode: true,
liveDurationInfinity: true,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 2,
levelLoadingTimeOut: 10000,
fragLoadingTimeOut: 15000,
};
const LOADING_TIMEOUT_MS = 20000;
interface IPTVPlayerProps {
channel: M3UChannel;
onClose: () => void;
@@ -37,9 +49,11 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
const containerRef = useRef<HTMLDivElement>(null);
const activeChannelRef = useRef<HTMLButtonElement>(null);
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const loadingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [error, setError] = useState<string | null>(null);
const [showSidebar, setShowSidebar] = useState(false);
const [sidebarSearch, setSidebarSearch] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [showControls, setShowControls] = useState(true);
@@ -140,16 +154,43 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
hlsRef.current.destroy();
hlsRef.current = null;
}
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
loadingTimeoutRef.current = undefined;
}
video.removeAttribute('src');
video.load();
const proxiedUrl = getProxiedUrl(url);
// Global loading timeout
let loadingResolved = false;
const markLoaded = () => {
if (loadingResolved) return;
loadingResolved = true;
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
loadingTimeoutRef.current = undefined;
}
setIsLoading(false);
};
const markError = (msg: string) => {
if (loadingResolved) return;
loadingResolved = true;
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
loadingTimeoutRef.current = undefined;
}
setIsLoading(false);
setError(msg);
};
loadingTimeoutRef.current = setTimeout(() => {
markError('加载超时,请尝试其他线路或频道');
}, LOADING_TIMEOUT_MS);
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
});
const hls = new Hls(HLS_LIVE_CONFIG);
hlsRef.current = hls;
let triedProxy = false;
@@ -157,16 +198,15 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
const tryDirectVideo = (directUrl: string) => {
if (triedDirect) {
setIsLoading(false);
setError('播放错误,请尝试其他线路或频道');
markError('播放错误,请尝试其他线路或频道');
return;
}
triedDirect = true;
const vid = videoRef.current;
if (!vid) return;
vid.src = directUrl;
vid.addEventListener('loadedmetadata', () => {
setIsLoading(false);
vid.addEventListener('canplay', () => {
markLoaded();
vid.play().catch(() => {});
}, { once: true });
vid.addEventListener('error', () => {
@@ -175,17 +215,15 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
const vid2 = videoRef.current;
if (!vid2) return;
vid2.src = proxiedUrl;
vid2.addEventListener('loadedmetadata', () => {
setIsLoading(false);
vid2.addEventListener('canplay', () => {
markLoaded();
vid2.play().catch(() => {});
}, { once: true });
vid2.addEventListener('error', () => {
setIsLoading(false);
setError('播放错误,请尝试其他线路或频道');
markError('播放错误,请尝试其他线路或频道');
}, { once: true });
} else {
setIsLoading(false);
setError('播放错误,请尝试其他线路或频道');
markError('播放错误,请尝试其他线路或频道');
}
}, { once: true });
};
@@ -197,15 +235,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
}
triedProxy = true;
hls.destroy();
const hlsProxy = new Hls({
enableWorker: true,
lowLatencyMode: true,
});
const hlsProxy = new Hls(HLS_LIVE_CONFIG);
hlsRef.current = hlsProxy;
hlsProxy.loadSource(proxiedUrl);
hlsProxy.attachMedia(video);
hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => {
setIsLoading(false);
markLoaded();
video.play().catch(() => {});
});
hlsProxy.on(Hls.Events.ERROR, (_, data) => {
@@ -225,7 +260,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setIsLoading(false);
markLoaded();
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_, data) => {
@@ -242,37 +277,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari/iOS)
video.src = url;
video.addEventListener('loadedmetadata', () => {
setIsLoading(false);
video.addEventListener('canplay', () => {
markLoaded();
video.play().catch(() => {});
}, { once: true });
video.addEventListener('error', () => {
video.src = proxiedUrl;
video.addEventListener('loadedmetadata', () => {
setIsLoading(false);
video.addEventListener('canplay', () => {
markLoaded();
video.play().catch(() => {});
}, { once: true });
video.addEventListener('error', () => {
setIsLoading(false);
setError('播放错误');
markError('播放错误');
}, { once: true });
}, { once: true });
} else {
// Direct video fallback
video.src = url;
video.addEventListener('loadedmetadata', () => {
setIsLoading(false);
video.addEventListener('canplay', () => {
markLoaded();
video.play().catch(() => {});
}, { once: true });
video.addEventListener('error', () => {
video.src = proxiedUrl;
video.addEventListener('loadedmetadata', () => {
setIsLoading(false);
video.addEventListener('canplay', () => {
markLoaded();
video.play().catch(() => {});
}, { once: true });
video.addEventListener('error', () => {
setIsLoading(false);
setError('播放错误,请尝试其他频道');
markError('播放错误,请尝试其他频道');
}, { once: true });
}, { once: true });
}
@@ -286,6 +319,10 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
hlsRef.current.destroy();
hlsRef.current = null;
}
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
loadingTimeoutRef.current = undefined;
}
};
}, [currentUrl, loadChannel]);
@@ -335,6 +372,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2;
const filteredSidebarChannels = useMemo(() => {
if (!sidebarSearch.trim()) return channels;
const q = sidebarSearch.toLowerCase().trim();
return channels.filter(ch => ch.name.toLowerCase().includes(q));
}, [channels, sidebarSearch]);
return (
<div
ref={containerRef}
@@ -526,17 +569,32 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
{/* Sidebar */}
{showSidebar && (
<div data-sidebar className="w-72 bg-[#111] border-l border-white/10 overflow-y-auto flex-shrink-0">
<div className="p-3 border-b border-white/10 flex items-center justify-between sticky top-0 bg-[#111] z-10">
<h3 className="text-white text-sm font-medium"></h3>
<button
onClick={(e) => { e.stopPropagation(); setShowSidebar(false); }}
className="text-white/50 hover:text-white cursor-pointer"
>
<Icons.X size={16} />
</button>
<div className="sticky top-0 bg-[#111] z-10">
<div className="p-3 border-b border-white/10 flex items-center justify-between">
<h3 className="text-white text-sm font-medium"></h3>
<button
onClick={(e) => { e.stopPropagation(); setShowSidebar(false); }}
className="text-white/50 hover:text-white cursor-pointer"
>
<Icons.X size={16} />
</button>
</div>
<div className="px-3 py-2 border-b border-white/10">
<div className="relative">
<Icons.Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/30" />
<input
type="text"
placeholder="搜索频道..."
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full pl-7 pr-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
/>
</div>
</div>
</div>
<div className="p-1">
{channels.map((ch, i) => {
{filteredSidebarChannels.map((ch, i) => {
const isActive = ch.name === channel.name && ch.url === channel.url;
return (
<button
+15 -14
View File
@@ -1,20 +1,20 @@
'use client';
import { useState, useEffect } from 'react';
import { getSession, clearSession } from '@/lib/store/auth-store';
import { getSession, clearSession, hasPermission, type Role } from '@/lib/store/auth-store';
import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { LogOut, Shield, Info } from 'lucide-react';
interface AccountInfo {
name: string;
role: 'admin' | 'viewer';
role: Role;
}
interface ConfigEntry {
password: string;
name: string;
role: 'admin' | 'viewer';
role: Role;
}
export function AccountSettings() {
@@ -50,7 +50,7 @@ export function AccountSettings() {
window.location.reload();
};
const isAdmin = session?.role === 'admin';
const canManageAccounts = hasPermission('account_management');
// Config generator helpers
const addConfigEntry = () => {
@@ -70,7 +70,7 @@ export function AccountSettings() {
const generateAccountsString = () => {
return configEntries
.filter(e => e.password.trim() && e.name.trim())
.map(e => `${e.password}:${e.name}${e.role === 'admin' ? ':admin' : ''}`)
.map(e => `${e.password}:${e.name}${e.role !== 'viewer' ? ':' + e.role : ''}`)
.join(',');
};
@@ -87,7 +87,7 @@ export function AccountSettings() {
// Filter out removed accounts and the standalone admin password account
const existingEntries: ConfigEntry[] = accounts
.filter((_, i) => !removedAccounts.has(i))
.filter(a => !(a.name === '管理员' && hasAdminPassword))
.filter(a => !(a.name === '超级管理员' && hasAdminPassword))
.map(a => ({
password: '',
name: a.name,
@@ -124,9 +124,9 @@ export function AccountSettings() {
<div>
<p className="text-sm font-medium text-[var(--text-color)]">{session.name}</p>
<div className="flex items-center gap-1.5">
<Shield size={12} className={session.role === 'admin' ? 'text-[var(--accent-color)]' : 'text-[var(--text-color-secondary)]'} />
<Shield size={12} className={session.role === 'super_admin' || session.role === 'admin' ? 'text-[var(--accent-color)]' : 'text-[var(--text-color-secondary)]'} />
<span className="text-xs text-[var(--text-color-secondary)]">
{session.role === 'admin' ? '管理员' : '观众'}
{session.role === 'super_admin' ? '超级管理员' : session.role === 'admin' ? '管理员' : '观众'}
</span>
</div>
</div>
@@ -144,8 +144,8 @@ export function AccountSettings() {
</div>
)}
{/* Account List (Admin only) */}
{isAdmin && visibleAccounts.length > 0 && (
{/* Account List (Account managers only) */}
{canManageAccounts && visibleAccounts.length > 0 && (
<div>
<h3 className="text-sm font-medium text-[var(--text-color)] mb-3 flex items-center gap-2">
<Icons.Users size={16} className="text-[var(--accent-color)]" />
@@ -167,11 +167,11 @@ export function AccountSettings() {
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-[var(--radius-full)] ${
account.role === 'admin'
account.role === 'super_admin' || account.role === 'admin'
? 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] border border-[var(--glass-border)]'
}`}>
{account.role === 'admin' ? '管理员' : '观众'}
{account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'}
</span>
<button
onClick={() => handleRemoveAccount(index)}
@@ -211,8 +211,8 @@ export function AccountSettings() {
</div>
)}
{/* Config Generator (Admin only) */}
{isAdmin && (
{/* Config Generator (Account managers only) */}
{canManageAccounts && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-[var(--text-color)] flex items-center gap-2">
@@ -276,6 +276,7 @@ export function AccountSettings() {
>
<option value="viewer"></option>
<option value="admin"></option>
<option value="super_admin"></option>
</select>
</div>
</div>
+4
View File
@@ -21,6 +21,7 @@ interface PlayerSettingsProps {
onDanmakuFontSizeChange: (value: number) => void;
danmakuDisplayArea: number;
onDanmakuDisplayAreaChange: (value: number) => void;
showDanmakuApi?: boolean;
}
const DANMAKU_FONT_SIZES = [14, 18, 20, 24, 28];
@@ -44,6 +45,7 @@ export function PlayerSettings({
onDanmakuFontSizeChange,
danmakuDisplayArea,
onDanmakuDisplayAreaChange,
showDanmakuApi = true,
}: PlayerSettingsProps) {
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
@@ -142,6 +144,7 @@ export function PlayerSettings({
{/* API URL */}
<div className="space-y-4">
{showDanmakuApi && (
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
API
@@ -157,6 +160,7 @@ export function PlayerSettings({
<a href="https://github.com/huangxd-/danmu_api" target="_blank" rel="noopener noreferrer" className="text-[var(--accent-color)] hover:underline">danmu_api</a>
</p>
</div>
)}
{/* Opacity */}
<div>
+32 -2
View File
@@ -3,10 +3,27 @@
* NOT Zustand — needs to be synchronous at import time for store key generation
*/
export type Role = 'super_admin' | 'admin' | 'viewer';
export type Permission =
| 'source_management'
| 'account_management'
| 'danmaku_api'
| 'data_management'
| 'player_settings'
| 'danmaku_appearance'
| 'view_settings';
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'],
viewer: ['view_settings'],
};
export interface AuthSession {
profileId: string;
name: string;
role: 'admin' | 'viewer';
role: Role;
}
const SESSION_KEY = 'kvideo-session';
@@ -50,7 +67,20 @@ export function clearSession(): void {
export function isAdmin(): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
return session.role === 'admin';
return session.role === 'admin' || session.role === 'super_admin';
}
export function hasPermission(permission: Permission): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
return ROLE_PERMISSIONS[session.role]?.includes(permission) ?? false;
}
export function hasRole(minimumRole: Role): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
const hierarchy: Role[] = ['viewer', 'admin', 'super_admin'];
return hierarchy.indexOf(session.role) >= hierarchy.indexOf(minimumRole);
}
export function getProfileId(): string {
+28 -2
View File
@@ -17,6 +17,7 @@ interface IPTVState {
sources: IPTVSource[];
cachedChannels: M3UChannel[];
cachedGroups: string[];
cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }>;
lastRefreshed: number;
isLoading: boolean;
}
@@ -58,6 +59,7 @@ export const useIPTVStore = create<IPTVStore>()(
sources: [],
cachedChannels: [],
cachedGroups: [],
cachedChannelsBySource: {},
lastRefreshed: 0,
isLoading: false,
@@ -85,7 +87,7 @@ export const useIPTVStore = create<IPTVStore>()(
refreshSources: async () => {
const { sources } = get();
if (sources.length === 0) {
set({ cachedChannels: [], cachedGroups: [], lastRefreshed: Date.now() });
set({ cachedChannels: [], cachedGroups: [], cachedChannelsBySource: {}, lastRefreshed: Date.now() });
return;
}
@@ -94,6 +96,8 @@ export const useIPTVStore = create<IPTVStore>()(
try {
const allChannels: M3UChannel[] = [];
const allGroups = new Set<string>();
const channelsBySourceRaw: Record<string, M3UChannel[]> = {};
const groupsBySource: Record<string, Set<string>> = {};
const tasks = sources.map((source) => async () => {
try {
@@ -101,8 +105,17 @@ export const useIPTVStore = create<IPTVStore>()(
if (!res.ok) return;
const text = await res.text();
const playlist = parseM3U(text);
allChannels.push(...playlist.channels);
// Tag channels with source info
const tagged = playlist.channels.map(ch => ({
...ch,
sourceId: source.id,
sourceName: source.name,
}));
allChannels.push(...tagged);
playlist.groups.forEach((g) => allGroups.add(g));
// Track per-source
channelsBySourceRaw[source.id] = tagged;
groupsBySource[source.id] = new Set(playlist.groups);
} catch (e) {
console.error(`Failed to fetch IPTV source: ${source.name}`, e);
}
@@ -113,9 +126,22 @@ export const useIPTVStore = create<IPTVStore>()(
// Group channels with the same name into multi-route entries
const grouped = groupChannelsByName(allChannels);
// Build per-source grouped data
const cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }> = {};
for (const source of sources) {
const raw = channelsBySourceRaw[source.id];
if (raw) {
cachedChannelsBySource[source.id] = {
channels: groupChannelsByName(raw),
groups: Array.from(groupsBySource[source.id] || []).sort(),
};
}
}
set({
cachedChannels: grouped,
cachedGroups: Array.from(allGroups).sort(),
cachedChannelsBySource,
lastRefreshed: Date.now(),
isLoading: false,
});
+3 -1
View File
@@ -11,6 +11,8 @@ export interface M3UChannel {
tvgId?: string;
tvgName?: string;
routes?: string[];
sourceId?: string;
sourceName?: string;
}
export interface M3UPlaylist {
@@ -86,7 +88,7 @@ export function groupChannelsByName(channels: M3UChannel[]): M3UChannel[] {
const groups = new Map<string, M3UChannel>();
for (const ch of channels) {
const key = ch.name.toLowerCase().trim();
const key = `${ch.sourceId || ''}::${ch.name.toLowerCase().trim()}`;
const existing = groups.get(key);
if (existing) {
if (!existing.routes) {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.4.0",
"version": "4.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.4.0",
"version": "4.4.1",
"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.0",
"version": "4.4.1",
"private": true,
"scripts": {
"dev": "next dev",