diff --git a/app/api/auth/accounts/route.ts b/app/api/auth/accounts/route.ts new file mode 100644 index 0000000..535509e --- /dev/null +++ b/app/api/auth/accounts/route.ts @@ -0,0 +1,58 @@ +/** + * Accounts API Route + * Returns account list (names + roles, no passwords) for admin visibility + */ + +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; + +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; +const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; +const ACCOUNTS = process.env.ACCOUNTS || ''; + +const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; + +interface AccountInfo { + name: string; + role: 'admin' | 'viewer'; +} + +function getAccountList(): AccountInfo[] { + const accounts: AccountInfo[] = []; + + // Add admin from ADMIN_PASSWORD + if (effectiveAdminPassword) { + accounts.push({ name: '管理员', role: 'admin' }); + } + + // Add accounts from ACCOUNTS env var + if (ACCOUNTS) { + ACCOUNTS.split(',') + .map(entry => entry.trim()) + .filter(entry => entry.length > 0) + .forEach(entry => { + const parts = entry.split(':'); + if (parts.length >= 2) { + const name = parts[1].trim(); + const role = parts[2]?.trim() === 'admin' ? 'admin' : 'viewer'; + if (name) { + accounts.push({ name, role }); + } + } + }); + } + + return accounts; +} + +export async function GET() { + const accounts = getAccountList(); + + return NextResponse.json({ + accounts, + hasAdminPassword: !!effectiveAdminPassword, + hasAccounts: !!ACCOUNTS, + totalCount: accounts.length, + }); +} diff --git a/app/api/iptv/route.ts b/app/api/iptv/route.ts new file mode 100644 index 0000000..7f2516a --- /dev/null +++ b/app/api/iptv/route.ts @@ -0,0 +1,47 @@ +/** + * IPTV Proxy API Route + * Fetches M3U playlist files to avoid CORS issues + */ + +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); + } + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Failed to fetch: ${response.status}` }, + { status: response.status } + ); + } + + const text = await response.text(); + + return new NextResponse(text, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes + }, + }); + } catch (e) { + return NextResponse.json( + { error: 'Failed to fetch M3U playlist' }, + { status: 500 } + ); + } +} diff --git a/app/iptv/page.tsx b/app/iptv/page.tsx new file mode 100644 index 0000000..8837402 --- /dev/null +++ b/app/iptv/page.tsx @@ -0,0 +1,107 @@ +'use client'; + +/** + * IPTV Page - Live TV channel viewer with M3U source management + */ + +import { useState, useEffect } from 'react'; +import { useIPTVStore } from '@/lib/store/iptv-store'; +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 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 [activeChannel, setActiveChannel] = useState(null); + const [showManager, setShowManager] = useState(false); + + // Auto-refresh on first load if we have sources but no cached channels + useEffect(() => { + if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) { + refreshSources(); + } + }, [sources.length, cachedChannels.length, isLoading, refreshSources]); + + return ( + +
+
+ {/* Header */} +
+
+
+ + + + + +
+

+ + 直播 +

+

+ {cachedChannels.length > 0 ? `${cachedChannels.length} 个频道` : 'IPTV 直播频道'} +

+
+
+ + +
+
+ + {/* Source Manager (collapsible) */} + {showManager && ( +
+ +
+ )} + + {/* Loading State */} + {isLoading && ( +
+
+

正在加载频道列表...

+
+ )} + + {/* Channel Grid */} + {!isLoading && ( +
+ +
+ )} +
+ + {/* Player Overlay */} + {activeChannel && ( + setActiveChannel(null)} + channels={cachedChannels} + onChannelChange={setActiveChannel} + /> + )} +
+ + ); +} diff --git a/app/premium/settings/hooks/usePremiumSettingsPage.ts b/app/premium/settings/hooks/usePremiumSettingsPage.ts index 3f51ec1..d859c67 100644 --- a/app/premium/settings/hooks/usePremiumSettingsPage.ts +++ b/app/premium/settings/hooks/usePremiumSettingsPage.ts @@ -20,6 +20,7 @@ export function usePremiumSettingsPage() { const [danmakuApiUrl, setDanmakuApiUrl] = useState(''); const [danmakuOpacity, setDanmakuOpacity] = useState(0.7); const [danmakuFontSize, setDanmakuFontSize] = useState(20); + const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5); useEffect(() => { // Sources come from main settings store @@ -36,6 +37,7 @@ export function usePremiumSettingsPage() { setDanmakuApiUrl(modeSettings.danmakuApiUrl); setDanmakuOpacity(modeSettings.danmakuOpacity); setDanmakuFontSize(modeSettings.danmakuFontSize); + setDanmakuDisplayArea(modeSettings.danmakuDisplayArea); }, []); // --- Source management (uses main settingsStore) --- @@ -121,6 +123,11 @@ export function usePremiumSettingsPage() { savePremiumModeSetting({ danmakuFontSize: value }); }; + const handleDanmakuDisplayAreaChange = (value: number) => { + setDanmakuDisplayArea(value); + savePremiumModeSetting({ danmakuDisplayArea: value }); + }; + return { premiumSources, isAddModalOpen, @@ -151,5 +158,7 @@ export function usePremiumSettingsPage() { handleDanmakuOpacityChange, danmakuFontSize, handleDanmakuFontSizeChange, + danmakuDisplayArea, + handleDanmakuDisplayAreaChange, }; } diff --git a/app/premium/settings/page.tsx b/app/premium/settings/page.tsx index f59005d..3ce663c 100644 --- a/app/premium/settings/page.tsx +++ b/app/premium/settings/page.tsx @@ -40,6 +40,8 @@ export default function PremiumSettingsPage() { handleDanmakuOpacityChange, danmakuFontSize, handleDanmakuFontSizeChange, + danmakuDisplayArea, + handleDanmakuDisplayAreaChange, } = usePremiumSettingsPage(); return ( @@ -79,6 +81,8 @@ export default function PremiumSettingsPage() { onDanmakuOpacityChange={handleDanmakuOpacityChange} danmakuFontSize={danmakuFontSize} onDanmakuFontSizeChange={handleDanmakuFontSizeChange} + danmakuDisplayArea={danmakuDisplayArea} + onDanmakuDisplayAreaChange={handleDanmakuDisplayAreaChange} /> {/* Display Settings */} diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 9e335d9..50714ce 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -30,6 +30,7 @@ export function useSettingsPage() { const [danmakuApiUrl, setDanmakuApiUrl] = useState(''); const [danmakuOpacity, setDanmakuOpacity] = useState(0.7); const [danmakuFontSize, setDanmakuFontSize] = useState(20); + const [danmakuDisplayArea, setDanmakuDisplayArea] = useState(0.5); useEffect(() => { const settings = settingsStore.getSettings(); @@ -44,6 +45,7 @@ export function useSettingsPage() { setDanmakuApiUrl(settings.danmakuApiUrl); setDanmakuOpacity(settings.danmakuOpacity); setDanmakuFontSize(settings.danmakuFontSize); + setDanmakuDisplayArea(settings.danmakuDisplayArea); }, []); const handleSourcesChange = (newSources: VideoSource[]) => { @@ -282,6 +284,15 @@ export function useSettingsPage() { }); }; + const handleDanmakuDisplayAreaChange = (value: number) => { + setDanmakuDisplayArea(value); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + danmakuDisplayArea: value, + }); + }; + const handleRestoreDefaults = () => { const defaults = getDefaultSources(); handleSourcesChange(defaults); @@ -338,5 +349,7 @@ export function useSettingsPage() { handleDanmakuOpacityChange, danmakuFontSize, handleDanmakuFontSizeChange, + danmakuDisplayArea, + handleDanmakuDisplayAreaChange, }; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 978df99..ccf7a54 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -59,6 +59,8 @@ export default function SettingsPage() { handleDanmakuOpacityChange, danmakuFontSize, handleDanmakuFontSizeChange, + danmakuDisplayArea, + handleDanmakuDisplayAreaChange, } = useSettingsPage(); return ( @@ -83,6 +85,8 @@ export default function SettingsPage() { onDanmakuOpacityChange={handleDanmakuOpacityChange} danmakuFontSize={danmakuFontSize} onDanmakuFontSizeChange={handleDanmakuFontSizeChange} + danmakuDisplayArea={danmakuDisplayArea} + onDanmakuDisplayAreaChange={handleDanmakuDisplayAreaChange} /> {/* Display Settings */} diff --git a/components/history/HistoryList.tsx b/components/history/HistoryList.tsx index 1c5f29e..ecefbf4 100644 --- a/components/history/HistoryList.tsx +++ b/components/history/HistoryList.tsx @@ -4,7 +4,7 @@ import type { VideoHistoryItem } from '@/lib/types'; interface HistoryListProps { history: VideoHistoryItem[]; - onRemove: (videoId: string | number, source: string) => void; + onRemove: (showIdentifier: string) => void; isPremium?: boolean; } @@ -20,9 +20,9 @@ export function HistoryList({ history, onRemove, isPremium = false }: HistoryLis
{history.map((item) => ( onRemove(item.videoId, item.source)} + onRemove={() => onRemove(item.showIdentifier)} isPremium={isPremium} /> ))} diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx index 330fda7..51262c2 100644 --- a/components/history/WatchHistorySidebar.tsx +++ b/components/history/WatchHistorySidebar.tsx @@ -18,8 +18,7 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean const [isOpen, setIsOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; - videoId?: string; - source?: string; + showIdentifier?: string; isClearAll?: boolean; }>({ isOpen: false }); const { viewingHistory, removeFromHistory, clearHistory } = useHistory(isPremium); @@ -58,8 +57,8 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean }, [isOpen]); // Handle delete confirmation - const handleDeleteItem = (videoId: string | number, source: string) => { - setDeleteConfirm({ isOpen: true, videoId: String(videoId), source }); + const handleDeleteItem = (showIdentifier: string) => { + setDeleteConfirm({ isOpen: true, showIdentifier }); }; const handleClearAll = () => { @@ -69,8 +68,8 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean const confirmDelete = () => { if (deleteConfirm.isClearAll) { clearHistory(); - } else if (deleteConfirm.videoId && deleteConfirm.source) { - removeFromHistory(deleteConfirm.videoId, deleteConfirm.source); + } else if (deleteConfirm.showIdentifier) { + removeFromHistory(deleteConfirm.showIdentifier); } setDeleteConfirm({ isOpen: false }); }; diff --git a/components/iptv/IPTVChannelGrid.tsx b/components/iptv/IPTVChannelGrid.tsx new file mode 100644 index 0000000..0642ac3 --- /dev/null +++ b/components/iptv/IPTVChannelGrid.tsx @@ -0,0 +1,150 @@ +'use client'; + +/** + * IPTVChannelGrid - Displays IPTV channels grouped by category with search + */ + +import { useState, useMemo } from 'react'; +import { Icons } from '@/components/ui/Icon'; +import type { M3UChannel } from '@/lib/utils/m3u-parser'; + +interface IPTVChannelGridProps { + channels: M3UChannel[]; + groups: string[]; + onSelect: (channel: M3UChannel) => void; + activeChannel?: M3UChannel | null; +} + +export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: IPTVChannelGridProps) { + const [selectedGroup, setSelectedGroup] = useState(null); + const [search, setSearch] = useState(''); + + const filteredChannels = useMemo(() => { + let result = channels; + + if (selectedGroup) { + result = result.filter((c) => c.group === selectedGroup); + } + + if (search.trim()) { + const q = search.toLowerCase().trim(); + result = result.filter((c) => c.name.toLowerCase().includes(q)); + } + + return result; + }, [channels, selectedGroup, search]); + + if (channels.length === 0) { + return ( +
+ +

暂无频道

+

请先添加 M3U 直播源

+
+ ); + } + + return ( +
+ {/* Search + Group Filter */} +
+
+ + 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)]" + /> +
+
+ + {/* Group Tabs */} + {groups.length > 0 && ( +
+ + {groups.map((group) => { + const count = channels.filter((c) => c.group === group).length; + return ( + + ); + })} +
+ )} + + {/* Channel Grid */} +
+ {filteredChannels.map((channel, index) => ( + + ))} +
+ + {filteredChannels.length === 0 && ( +
+ 未找到匹配的频道 +
+ )} +
+ ); +} diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx new file mode 100644 index 0000000..3bb23a7 --- /dev/null +++ b/components/iptv/IPTVPlayer.tsx @@ -0,0 +1,218 @@ +'use client'; + +/** + * IPTVPlayer - Lightweight player for IPTV live streams + * Uses HLS.js for playback with a channel switching sidebar + */ + +import { useRef, useEffect, useState, useCallback } from 'react'; +import Hls from 'hls.js'; +import { Icons } from '@/components/ui/Icon'; +import type { M3UChannel } from '@/lib/utils/m3u-parser'; + +interface IPTVPlayerProps { + channel: M3UChannel; + onClose: () => void; + channels: M3UChannel[]; + onChannelChange: (channel: M3UChannel) => void; +} + +export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) { + const videoRef = useRef(null); + const hlsRef = useRef(null); + const [error, setError] = useState(null); + const [showSidebar, setShowSidebar] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const loadChannel = useCallback((ch: M3UChannel) => { + const video = videoRef.current; + if (!video) return; + + setError(null); + setIsLoading(true); + + // Clean up previous HLS instance + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + + const url = ch.url; + + if (url.endsWith('.m3u8') || url.includes('.m3u8')) { + if (Hls.isSupported()) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: true, + liveDurationInfinity: true, + }); + hlsRef.current = hls; + + hls.loadSource(url); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setIsLoading(false); + video.play().catch(() => {}); + }); + + hls.on(Hls.Events.ERROR, (_, data) => { + if (data.fatal) { + setIsLoading(false); + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + setError('网络错误,无法加载频道'); + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError(); + } else { + setError('播放错误,请尝试其他频道'); + } + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + // Native HLS (Safari/iOS) + video.src = url; + video.addEventListener('loadedmetadata', () => { + setIsLoading(false); + video.play().catch(() => {}); + }, { once: true }); + video.addEventListener('error', () => { + setIsLoading(false); + setError('播放错误'); + }, { once: true }); + } else { + setError('您的浏览器不支持 HLS 播放'); + setIsLoading(false); + } + } else { + // Direct video URL (mp4, etc.) + video.src = url; + video.addEventListener('loadedmetadata', () => { + setIsLoading(false); + video.play().catch(() => {}); + }, { once: true }); + video.addEventListener('error', () => { + setIsLoading(false); + setError('播放错误'); + }, { once: true }); + } + }, []); + + useEffect(() => { + loadChannel(channel); + return () => { + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + }; + }, [channel, loadChannel]); + + return ( +
+ {/* Player Area */} +
+
+ ); +} diff --git a/components/iptv/IPTVSourceManager.tsx b/components/iptv/IPTVSourceManager.tsx new file mode 100644 index 0000000..339e397 --- /dev/null +++ b/components/iptv/IPTVSourceManager.tsx @@ -0,0 +1,115 @@ +'use client'; + +/** + * IPTVSourceManager - Admin UI to manage M3U playlist sources + */ + +import { useState } from 'react'; +import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store'; +import { Icons } from '@/components/ui/Icon'; + +export function IPTVSourceManager() { + const { sources, addSource, removeSource, refreshSources, isLoading } = useIPTVStore(); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [showAdd, setShowAdd] = useState(false); + + const handleAdd = () => { + if (!name.trim() || !url.trim()) return; + addSource(name.trim(), url.trim()); + setName(''); + setUrl(''); + setShowAdd(false); + // Auto-refresh after adding + setTimeout(() => refreshSources(), 100); + }; + + return ( +
+
+

+ 直播源管理 +

+
+ + +
+
+ + {/* Add Source Form */} + {showAdd && ( +
+ setName(e.target.value)} + className="w-full px-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)]" + /> + setUrl(e.target.value)} + className="w-full px-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)]" + /> +
+ + +
+
+ )} + + {/* Source List */} + {sources.length === 0 ? ( +
+ 暂无直播源,请添加 M3U 播放列表链接 +
+ ) : ( +
+ {sources.map((source) => ( +
+
+

{source.name}

+

{source.url}

+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 2c40133..8729fc9 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -59,6 +59,17 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
+ {/* IPTV Link */} + + + + {/* User Info */} {session && (
diff --git a/components/player/DanmakuCanvas.tsx b/components/player/DanmakuCanvas.tsx index 2dcd5d5..ac57e9a 100644 --- a/components/player/DanmakuCanvas.tsx +++ b/components/player/DanmakuCanvas.tsx @@ -37,15 +37,18 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da // Settings (read reactively) const [opacity, setOpacity] = React.useState(0.7); const [fontSize, setFontSize] = React.useState(20); + const [displayArea, setDisplayArea] = React.useState(0.5); useEffect(() => { const s = settingsStore.getSettings(); setOpacity(s.danmakuOpacity); setFontSize(s.danmakuFontSize); + setDisplayArea(s.danmakuDisplayArea); const unsub = settingsStore.subscribe(() => { const ns = settingsStore.getSettings(); setOpacity(ns.danmakuOpacity); setFontSize(ns.danmakuFontSize); + setDisplayArea(ns.danmakuDisplayArea); }); return unsub; }, []); @@ -86,6 +89,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da const rect = canvas.getBoundingClientRect(); const canvasWidth = rect.width; + const effectiveHeight = rect.height * displayArea; const laneHeight = fontSize * LANE_HEIGHT_FACTOR; // Find comments in the time window [lastSpawn, time] @@ -119,7 +123,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da let bestLane = -1; for (let lane = 0; lane < MAX_LANES; lane++) { const yPos = lane * laneHeight + fontSize; - if (yPos > rect.height - fontSize) break; + if (yPos > effectiveHeight - fontSize) break; if (laneSlotsRef.current[lane] <= time) { bestLane = lane; break; @@ -142,7 +146,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da }); } else { // Top or bottom: find center lane - const maxLanes = Math.floor(rect.height / laneHeight / 2); // only use top/bottom half + const maxLanes = Math.floor(effectiveHeight / laneHeight / 2); // only use top/bottom half let bestLane = -1; for (let lane = 0; lane < Math.min(maxLanes, MAX_LANES); lane++) { const laneKey = type === 'top' ? lane : MAX_LANES - 1 - lane; @@ -156,7 +160,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da const y = type === 'top' ? bestLane * laneHeight + fontSize - : rect.height - bestLane * laneHeight - fontSize * 0.4; + : effectiveHeight - bestLane * laneHeight - fontSize * 0.4; activeRef.current.push({ comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any, @@ -170,7 +174,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da } lastSpawnTimeRef.current = windowEnd; - }, [comments, fontSize]); + }, [comments, fontSize, displayArea]); // Animation loop useEffect(() => { diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx index 88e0ec2..515008d 100644 --- a/components/player/VideoPlayer.tsx +++ b/components/player/VideoPlayer.tsx @@ -94,15 +94,10 @@ export function VideoPlayer({ const getSavedProgress = () => { if (!videoId) return 0; - // Directly check HistoryStore for progress - // We prioritize a strict match (including source), but fall back to any match for this video/episode - // This fixes issues where the source parameter might be missing or different + // Match by normalized title + episode index (source-agnostic) + const normalizedTitle = title.toLowerCase().trim(); const historyItem = viewingHistory.find(item => - item.videoId.toString() === videoId?.toString() && - item.episodeIndex === currentEpisode && - (source ? item.source === source : true) - ) || viewingHistory.find(item => - item.videoId.toString() === videoId?.toString() && + item.title.toLowerCase().trim() === normalizedTitle && item.episodeIndex === currentEpisode ); diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index f21b83b..2ca8e87 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -50,6 +50,12 @@ export function DesktopMoreMenu({ danmakuEnabled, setDanmakuEnabled, danmakuApiUrl, + danmakuOpacity, + setDanmakuOpacity, + danmakuFontSize, + setDanmakuFontSize, + danmakuDisplayArea, + setDanmakuDisplayArea, } = usePlayerSettings(); const buttonRef = React.useRef(null); @@ -392,6 +398,71 @@ export function DesktopMoreMenu({
+ {/* Danmaku Sub-Settings (shown when enabled and configured) */} + {danmakuEnabled && danmakuApiUrl && ( +
+ {/* Opacity Slider */} +
+
+ 透明度 + {Math.round(danmakuOpacity * 100)}% +
+ setDanmakuOpacity(parseInt(e.target.value) / 100)} + className={`w-full accent-[var(--accent-color)] ${isRotated ? 'h-1' : 'h-1.5'}`} + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* Font Size Buttons */} +
+
字号
+
+ {[14, 18, 20, 24, 28].map((size) => ( + + ))} +
+
+ + {/* Display Area Buttons */} +
+
显示区域
+
+ {([ + { value: 0.25, label: '1/4屏' }, + { value: 0.5, label: '半屏' }, + { value: 0.75, label: '3/4屏' }, + { value: 1.0, label: '全屏' }, + ] as const).map(({ value, label }) => ( + + ))} +
+
+
+ )} + {/* Auto Next Episode Switch */}
diff --git a/components/player/hooks/desktop/usePlaybackControls.ts b/components/player/hooks/desktop/usePlaybackControls.ts index 5bd154e..d04dd27 100644 --- a/components/player/hooks/desktop/usePlaybackControls.ts +++ b/components/player/hooks/desktop/usePlaybackControls.ts @@ -18,6 +18,8 @@ interface UsePlaybackControlsProps { playbackRate: number; setPlaybackRate: (rate: number) => void; setShowSpeedMenu: (show: boolean) => void; + volume: number; + isMuted: boolean; } export function usePlaybackControls({ @@ -35,7 +37,9 @@ export function usePlaybackControls({ speedMenuTimeoutRef, playbackRate, setPlaybackRate, - setShowSpeedMenu + setShowSpeedMenu, + volume, + isMuted }: UsePlaybackControlsProps) { const togglePlay = useCallback(() => { if (!videoRef.current) return; @@ -87,10 +91,13 @@ export function usePlaybackControls({ videoRef.current.playbackRate = playbackRate; } + // Apply saved volume and mute state when new source loads + videoRef.current.volume = isMuted ? 0 : volume; + videoRef.current.play().catch((err: Error) => { console.warn('Autoplay was prevented:', err); }); - }, [videoRef, setDuration, setIsLoading, initialTime, playbackRate]); + }, [videoRef, setDuration, setIsLoading, initialTime, playbackRate, volume, isMuted]); // Handle late initialization of initialTime (e.g. from async storage hydration) useEffect(() => { diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index 5d8c455..ccbfae4 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -95,7 +95,8 @@ export function useDesktopPlayerLogic({ const playbackControls = usePlaybackControls({ videoRef, isPlaying, setIsPlaying, setIsLoading, initialTime, shouldAutoPlay, setDuration, setCurrentTime, onTimeUpdate, onError, - isDraggingProgressRef, speedMenuTimeoutRef, playbackRate, setPlaybackRate, setShowSpeedMenu + isDraggingProgressRef, speedMenuTimeoutRef, playbackRate, setPlaybackRate, setShowSpeedMenu, + volume, isMuted }); const volumeControls = useVolumeControls({ diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index bd3a1ac..890b133 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -26,6 +26,7 @@ export function usePlayerSettings() { danmakuApiUrl: stored.danmakuApiUrl, danmakuOpacity: stored.danmakuOpacity, danmakuFontSize: stored.danmakuFontSize, + danmakuDisplayArea: stored.danmakuDisplayArea, }; }); @@ -49,6 +50,7 @@ export function usePlayerSettings() { danmakuApiUrl: stored.danmakuApiUrl, danmakuOpacity: stored.danmakuOpacity, danmakuFontSize: stored.danmakuFontSize, + danmakuDisplayArea: stored.danmakuDisplayArea, }); }); return unsubscribe; @@ -125,6 +127,10 @@ export function usePlayerSettings() { updateSetting('danmakuFontSize', value); }, [updateSetting]); + const setDanmakuDisplayArea = useCallback((value: number) => { + updateSetting('danmakuDisplayArea', value); + }, [updateSetting]); + return { ...settings, setAutoNextEpisode, @@ -142,5 +148,6 @@ export function usePlayerSettings() { setDanmakuApiUrl, setDanmakuOpacity, setDanmakuFontSize, + setDanmakuDisplayArea, }; } diff --git a/components/settings/AccountSettings.tsx b/components/settings/AccountSettings.tsx index 748a652..ba8e105 100644 --- a/components/settings/AccountSettings.tsx +++ b/components/settings/AccountSettings.tsx @@ -3,11 +3,27 @@ import { useState, useEffect } from 'react'; import { getSession, clearSession } 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'; +} + +interface ConfigEntry { + password: string; + name: string; + role: 'admin' | 'viewer'; +} + export function AccountSettings() { const [session, setSessionState] = useState>(null); const [hasAuth, setHasAuth] = useState(false); + const [accounts, setAccounts] = useState([]); + const [showConfigGen, setShowConfigGen] = useState(false); + const [configEntries, setConfigEntries] = useState([]); + const [copied, setCopied] = useState(false); useEffect(() => { setSessionState(getSession()); @@ -16,6 +32,14 @@ export function AccountSettings() { .then(res => res.json()) .then(data => setHasAuth(data.hasAuth)) .catch(() => {}); + + // Fetch account list for admins + fetch('/api/auth/accounts') + .then(res => res.json()) + .then(data => { + if (data.accounts) setAccounts(data.accounts); + }) + .catch(() => {}); }, []); const handleLogout = () => { @@ -23,6 +47,38 @@ export function AccountSettings() { window.location.reload(); }; + const isAdmin = session?.role === 'admin'; + + // Config generator helpers + const addConfigEntry = () => { + setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer' }]); + }; + + const updateConfigEntry = (index: number, field: keyof ConfigEntry, value: string) => { + const updated = [...configEntries]; + updated[index] = { ...updated[index], [field]: value }; + setConfigEntries(updated); + }; + + const removeConfigEntry = (index: number) => { + setConfigEntries(configEntries.filter((_, i) => i !== index)); + }; + + const generateAccountsString = () => { + return configEntries + .filter(e => e.password.trim() && e.name.trim()) + .map(e => `${e.password}:${e.name}${e.role === 'admin' ? ':admin' : ''}`) + .join(','); + }; + + const handleCopy = () => { + const str = generateAccountsString(); + navigator.clipboard.writeText(str).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + if (!hasAuth && !session) return null; return ( @@ -58,6 +114,131 @@ export function AccountSettings() {
)} + {/* Account List (Admin only) */} + {isAdmin && accounts.length > 0 && ( +
+

+ + 已配置的账户 +

+
+ {accounts.map((account, index) => ( +
+
+
+ {account.name.charAt(0)} +
+ {account.name} +
+ + {account.role === 'admin' ? '管理员' : '观众'} + +
+ ))} +
+
+ )} + + {/* Config Generator (Admin only) */} + {isAdmin && ( +
+
+

+ + 配置生成器 +

+ +
+ + {showConfigGen && ( +
+

+ 添加账户条目后,将生成的 ACCOUNTS 环境变量值复制到部署配置中。 +

+ + {/* Entry List */} + {configEntries.map((entry, index) => ( +
+
+
+ updateConfigEntry(index, 'password', e.target.value)} + className="flex-1 px-3 py-1.5 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)]" + /> + updateConfigEntry(index, 'name', e.target.value)} + className="flex-1 px-3 py-1.5 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)]" + /> + +
+
+ +
+ ))} + + + + {/* Generated Output */} + {configEntries.length > 0 && configEntries.some(e => e.password && e.name) && ( +
+ +
+ + {generateAccountsString()} + + +
+
+ )} +
+ )} +
+ )} + {/* Config Notice */}
diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 4fd1390..8f590a4 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -19,9 +19,17 @@ interface PlayerSettingsProps { onDanmakuOpacityChange: (value: number) => void; danmakuFontSize: number; onDanmakuFontSizeChange: (value: number) => void; + danmakuDisplayArea: number; + onDanmakuDisplayAreaChange: (value: number) => void; } const DANMAKU_FONT_SIZES = [14, 18, 20, 24, 28]; +const DANMAKU_DISPLAY_AREAS = [ + { value: 0.25, label: '1/4屏' }, + { value: 0.5, label: '半屏' }, + { value: 0.75, label: '3/4屏' }, + { value: 1.0, label: '全屏' }, +]; export function PlayerSettings({ fullscreenType, @@ -34,6 +42,8 @@ export function PlayerSettings({ onDanmakuOpacityChange, danmakuFontSize, onDanmakuFontSizeChange, + danmakuDisplayArea, + onDanmakuDisplayAreaChange, }: PlayerSettingsProps) { return (
@@ -183,6 +193,27 @@ export function PlayerSettings({ ))}
+ + {/* Display Area */} +
+ +
+ {DANMAKU_DISPLAY_AREAS.map(({ value, label }) => ( + + ))} +
+
diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx index 0ce50bc..17b081b 100644 --- a/components/ui/icons/utility-icons.tsx +++ b/components/ui/icons/utility-icons.tsx @@ -197,4 +197,27 @@ export const UtilityIcons = { ), + + Plus: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + Copy: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + Users: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + ), }; diff --git a/lib/store/history-store.ts b/lib/store/history-store.ts index 11e023f..99ce17c 100644 --- a/lib/store/history-store.ts +++ b/lib/store/history-store.ts @@ -29,7 +29,7 @@ interface HistoryActions { metadata?: { vod_actor?: string; type_name?: string; vod_area?: string } ) => void; - removeFromHistory: (videoId: string | number, source: string) => void; + removeFromHistory: (showIdentifier: string) => void; clearHistory: () => void; importHistory: (history: VideoHistoryItem[]) => void; } @@ -37,14 +37,55 @@ interface HistoryActions { interface HistoryStore extends HistoryState, HistoryActions { } /** - * Generate unique identifier for deduplication + * Generate unique identifier for deduplication (source-agnostic) */ -function generateShowIdentifier( - title: string, - source: string, - videoId: string | number -): string { - return `${source}:${videoId}:${title.toLowerCase().trim()}`; +function generateShowIdentifier(title: string): string { + return `title:${title.toLowerCase().trim()}`; +} + +/** + * Migrate v1 history entries to v2 (merge entries with same title) + */ +function migrateHistory(history: VideoHistoryItem[]): VideoHistoryItem[] { + const merged = new Map(); + + for (const item of history) { + const newId = generateShowIdentifier(item.title); + + const existing = merged.get(newId); + if (existing) { + // Keep the more recent entry, merge sourceMap + const isNewer = item.timestamp > existing.timestamp; + const mergedSourceMap = { + ...(existing.sourceMap || { [existing.source]: existing.videoId }), + ...(item.sourceMap || { [item.source]: item.videoId }), + }; + + merged.set(newId, { + ...(isNewer ? item : existing), + showIdentifier: newId, + sourceMap: mergedSourceMap, + // Keep newer playback state + playbackPosition: isNewer ? item.playbackPosition : existing.playbackPosition, + duration: isNewer ? item.duration : existing.duration, + episodeIndex: isNewer ? item.episodeIndex : existing.episodeIndex, + url: isNewer ? item.url : existing.url, + source: isNewer ? item.source : existing.source, + videoId: isNewer ? item.videoId : existing.videoId, + timestamp: Math.max(item.timestamp, existing.timestamp), + episodes: (isNewer ? item.episodes : existing.episodes) || [], + poster: isNewer ? (item.poster || existing.poster) : (existing.poster || item.poster), + }); + } else { + merged.set(newId, { + ...item, + showIdentifier: newId, + sourceMap: item.sourceMap || { [item.source]: item.videoId }, + }); + } + } + + return Array.from(merged.values()).sort((a, b) => b.timestamp - a.timestamp); } const createHistoryStore = (name: string) => @@ -65,11 +106,11 @@ const createHistoryStore = (name: string) => episodes = [], metadata ) => { - const showIdentifier = generateShowIdentifier(title, source, videoId); + const showIdentifier = generateShowIdentifier(title); const timestamp = Date.now(); set((state) => { - // Check if item already exists + // Check if item already exists (by normalized title) const existingIndex = state.viewingHistory.findIndex( (item) => item.showIdentifier === showIdentifier ); @@ -77,18 +118,29 @@ const createHistoryStore = (name: string) => let newHistory: VideoHistoryItem[]; if (existingIndex !== -1) { + const existing = state.viewingHistory[existingIndex]; + // Merge sourceMap + const mergedSourceMap = { + ...(existing.sourceMap || { [existing.source]: existing.videoId }), + [source]: videoId, + }; + // Update existing item and move to top const updatedItem: VideoHistoryItem = { - ...state.viewingHistory[existingIndex], + ...existing, + videoId, + source, url, episodeIndex, playbackPosition, duration, timestamp, - episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes, - vod_actor: metadata?.vod_actor ?? state.viewingHistory[existingIndex].vod_actor, - type_name: metadata?.type_name ?? state.viewingHistory[existingIndex].type_name, - vod_area: metadata?.vod_area ?? state.viewingHistory[existingIndex].vod_area, + sourceMap: mergedSourceMap, + episodes: episodes.length > 0 ? episodes : existing.episodes, + poster: poster || existing.poster, + vod_actor: metadata?.vod_actor ?? existing.vod_actor, + type_name: metadata?.type_name ?? existing.type_name, + vod_area: metadata?.vod_area ?? existing.vod_area, }; newHistory = [ @@ -109,6 +161,7 @@ const createHistoryStore = (name: string) => poster, episodes, showIdentifier, + sourceMap: { [source]: videoId }, vod_actor: metadata?.vod_actor, type_name: metadata?.type_name, vod_area: metadata?.vod_area, @@ -126,10 +179,10 @@ const createHistoryStore = (name: string) => }); }, - removeFromHistory: (videoId, source) => { + removeFromHistory: (showIdentifier) => { const state = get(); const itemToRemove = state.viewingHistory.find( - (item) => item.videoId === videoId && item.source === source + (item) => item.showIdentifier === showIdentifier ); if (itemToRemove) { @@ -139,7 +192,7 @@ const createHistoryStore = (name: string) => set((state) => ({ viewingHistory: state.viewingHistory.filter( - (item) => !(item.videoId === videoId && item.source === source) + (item) => item.showIdentifier !== showIdentifier ), })); }, @@ -156,6 +209,18 @@ const createHistoryStore = (name: string) => }), { name, + version: 2, + migrate: (persistedState: any, version: number) => { + if (version < 2) { + // Migrate from v1: merge entries with same normalized title + const oldHistory = persistedState?.viewingHistory || []; + return { + ...persistedState, + viewingHistory: migrateHistory(oldHistory), + }; + } + return persistedState as HistoryStore; + }, } ) ); diff --git a/lib/store/iptv-store.ts b/lib/store/iptv-store.ts new file mode 100644 index 0000000..9501a9b --- /dev/null +++ b/lib/store/iptv-store.ts @@ -0,0 +1,106 @@ +/** + * IPTV Store - Manages IPTV/M3U playlist sources and cached channels + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { parseM3U, type M3UChannel } from '@/lib/utils/m3u-parser'; + +export interface IPTVSource { + id: string; + name: string; + url: string; + addedAt: number; +} + +interface IPTVState { + sources: IPTVSource[]; + cachedChannels: M3UChannel[]; + cachedGroups: string[]; + lastRefreshed: number; + isLoading: boolean; +} + +interface IPTVActions { + addSource: (name: string, url: string) => void; + removeSource: (id: string) => void; + refreshSources: () => Promise; + setLoading: (loading: boolean) => void; +} + +interface IPTVStore extends IPTVState, IPTVActions {} + +export const useIPTVStore = create()( + persist( + (set, get) => ({ + sources: [], + cachedChannels: [], + cachedGroups: [], + lastRefreshed: 0, + isLoading: false, + + addSource: (name, url) => { + const id = `iptv-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + set((state) => ({ + sources: [...state.sources, { id, name, url, addedAt: Date.now() }], + })); + }, + + removeSource: (id) => { + set((state) => ({ + sources: state.sources.filter((s) => s.id !== id), + })); + }, + + refreshSources: async () => { + const { sources } = get(); + if (sources.length === 0) { + set({ cachedChannels: [], cachedGroups: [], lastRefreshed: Date.now() }); + return; + } + + set({ isLoading: true }); + + try { + const allChannels: M3UChannel[] = []; + const allGroups = new Set(); + + await Promise.all( + sources.map(async (source) => { + try { + const res = await fetch('/api/iptv?' + new URLSearchParams({ url: source.url })); + if (!res.ok) return; + const text = await res.text(); + const playlist = parseM3U(text); + allChannels.push(...playlist.channels); + playlist.groups.forEach((g) => allGroups.add(g)); + } catch (e) { + console.error(`Failed to fetch IPTV source: ${source.name}`, e); + } + }) + ); + + set({ + cachedChannels: allChannels, + cachedGroups: Array.from(allGroups).sort(), + lastRefreshed: Date.now(), + isLoading: false, + }); + } catch { + set({ isLoading: false }); + } + }, + + setLoading: (loading) => set({ isLoading: loading }), + }), + { + name: 'kvideo-iptv-store', + partialize: (state) => ({ + sources: state.sources, + cachedChannels: state.cachedChannels, + cachedGroups: state.cachedGroups, + lastRefreshed: state.lastRefreshed, + }), + } + ) +); diff --git a/lib/store/premium-mode-settings.ts b/lib/store/premium-mode-settings.ts index 8b013ef..a64f09d 100644 --- a/lib/store/premium-mode-settings.ts +++ b/lib/store/premium-mode-settings.ts @@ -28,6 +28,7 @@ export interface ModeSettings { danmakuApiUrl: string; danmakuOpacity: number; danmakuFontSize: number; + danmakuDisplayArea: number; } function getDefaultModeSettings(): ModeSettings { @@ -51,6 +52,7 @@ function getDefaultModeSettings(): ModeSettings { danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '', danmakuOpacity: 0.7, danmakuFontSize: 20, + danmakuDisplayArea: 0.5, }; } @@ -87,6 +89,7 @@ export const premiumModeSettingsStore = { danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''), danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7, danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20, + danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5, }; } catch { return getDefaultModeSettings(); @@ -146,6 +149,7 @@ export function getModeSettings(isPremium: boolean): ModeSettings { danmakuApiUrl: s.danmakuApiUrl, danmakuOpacity: s.danmakuOpacity, danmakuFontSize: s.danmakuFontSize, + danmakuDisplayArea: s.danmakuDisplayArea, }; } diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index 9d54686..5da6533 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -51,6 +51,7 @@ export interface AppSettings { danmakuApiUrl: string; // Self-hosted danmaku API endpoint danmakuOpacity: number; // 0.1 - 1.0 danmakuFontSize: number; // px + danmakuDisplayArea: number; // 0.25 | 0.5 | 0.75 | 1.0 } import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers'; @@ -128,6 +129,7 @@ function getDefaultAppSettings(): AppSettings { danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '', danmakuOpacity: 0.7, danmakuFontSize: 20, + danmakuDisplayArea: 0.5, }; } @@ -209,6 +211,7 @@ export const settingsStore = { danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''), danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7, danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20, + danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5, }; } catch { // Even if localStorage fails, we should return defaults + ENV subscriptions diff --git a/lib/types/index.ts b/lib/types/index.ts index 3a69818..26dae12 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -101,6 +101,7 @@ export interface VideoHistoryItem { poster?: string; episodes: Episode[]; showIdentifier: string; // Unique identifier for deduplication + sourceMap?: Record; // Maps source name to videoId for that source vod_actor?: string; type_name?: string; vod_area?: string; diff --git a/lib/utils/m3u-parser.ts b/lib/utils/m3u-parser.ts new file mode 100644 index 0000000..fe8047b --- /dev/null +++ b/lib/utils/m3u-parser.ts @@ -0,0 +1,78 @@ +/** + * M3U Playlist Parser + * Parses M3U/M3U8 IPTV playlist format + */ + +export interface M3UChannel { + name: string; + url: string; + logo?: string; + group?: string; + tvgId?: string; + tvgName?: string; +} + +export interface M3UPlaylist { + channels: M3UChannel[]; + groups: string[]; +} + +/** + * Parse M3U playlist content into structured data + */ +export function parseM3U(content: string): M3UPlaylist { + const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0); + const channels: M3UChannel[] = []; + const groupSet = new Set(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('#EXTINF:')) { + // Parse EXTINF line + const channel: M3UChannel = { name: '', url: '' }; + + // Extract attributes from EXTINF + const tvgNameMatch = line.match(/tvg-name="([^"]*)"/i); + const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/i); + const groupTitleMatch = line.match(/group-title="([^"]*)"/i); + const tvgIdMatch = line.match(/tvg-id="([^"]*)"/i); + + if (tvgNameMatch) channel.tvgName = tvgNameMatch[1]; + if (tvgLogoMatch) channel.logo = tvgLogoMatch[1]; + if (groupTitleMatch) { + channel.group = groupTitleMatch[1]; + if (channel.group) groupSet.add(channel.group); + } + if (tvgIdMatch) channel.tvgId = tvgIdMatch[1]; + + // Extract channel name (after last comma) + const commaIndex = line.lastIndexOf(','); + if (commaIndex !== -1) { + channel.name = line.substring(commaIndex + 1).trim(); + } + + // Next non-comment line should be the URL + for (let j = i + 1; j < lines.length; j++) { + if (!lines[j].startsWith('#')) { + channel.url = lines[j]; + i = j; // Skip to after URL + break; + } + } + + if (channel.name && channel.url) { + // Use tvgName as fallback for name + if (!channel.name && channel.tvgName) { + channel.name = channel.tvgName; + } + channels.push(channel); + } + } + } + + return { + channels, + groups: Array.from(groupSet).sort(), + }; +} diff --git a/package-lock.json b/package-lock.json index bc35eb0..41ef3cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "kvideo", - "version": "4.3.5", + "version": "4.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.3.5", + "version": "4.3.6", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "hls.js": "^1.6.15", - "lucide-react": "^0.564.0", + "lucide-react": "^0.568.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -7230,9 +7230,9 @@ } }, "node_modules/lucide-react": { - "version": "0.564.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz", - "integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==", + "version": "0.568.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.568.0.tgz", + "integrity": "sha512-uiPQfBwb8uiNUFFbVolgtnX9yjZPs4I7fM/RboKHSVfiN7d/59sx3FfKvE9i+2yCJCBs5Sx1+pfVmKcTM5Og1Q==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 42ebc44..e0a164f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.3.5", + "version": "4.3.6", "private": true, "scripts": { "dev": "next dev", @@ -15,7 +15,7 @@ "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "hls.js": "^1.6.15", - "lucide-react": "^0.564.0", + "lucide-react": "^0.568.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4",