mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement IPTV functionality with channel management and player, and add admin account configuration generation.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<M3UChannel | null>(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 (
|
||||
<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 */}
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="w-10 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"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.TV size={24} className="text-[var(--accent-color)]" />
|
||||
直播
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">
|
||||
{cachedChannels.length > 0 ? `${cachedChannels.length} 个频道` : 'IPTV 直播频道'}
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Manager (collapsible) */}
|
||||
{showManager && (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
<IPTVSourceManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-10 h-10 border-2 border-[var(--accent-color)]/30 border-t-[var(--accent-color)] rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">正在加载频道列表...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel Grid */}
|
||||
{!isLoading && (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
|
||||
<IPTVChannelGrid
|
||||
channels={cachedChannels}
|
||||
groups={cachedGroups}
|
||||
onSelect={setActiveChannel}
|
||||
activeChannel={activeChannel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Player Overlay */}
|
||||
{activeChannel && (
|
||||
<IPTVPlayer
|
||||
channel={activeChannel}
|
||||
onClose={() => setActiveChannel(null)}
|
||||
channels={cachedChannels}
|
||||
onChannelChange={setActiveChannel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
<div className="space-y-3">
|
||||
{history.map((item) => (
|
||||
<HistoryItem
|
||||
key={`${item.videoId}-${item.source}-${item.timestamp}`}
|
||||
key={item.showIdentifier}
|
||||
item={item}
|
||||
onRemove={() => onRemove(item.videoId, item.source)}
|
||||
onRemove={() => onRemove(item.showIdentifier)}
|
||||
isPremium={isPremium}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="text-center py-16 text-[var(--text-color-secondary)]">
|
||||
<Icons.TV size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm">暂无频道</p>
|
||||
<p className="text-xs mt-1">请先添加 M3U 直播源</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search + Group Filter */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索频道..."
|
||||
value={search}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Tabs */}
|
||||
{groups.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedGroup(null)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === 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'
|
||||
}`}
|
||||
>
|
||||
全部 ({channels.length})
|
||||
</button>
|
||||
{groups.map((group) => {
|
||||
const count = channels.filter((c) => c.group === group).length;
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === group
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{group} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{filteredChannels.map((channel, index) => (
|
||||
<button
|
||||
key={`${channel.name}-${index}`}
|
||||
onClick={() => onSelect(channel)}
|
||||
className={`group p-3 rounded-[var(--radius-2xl)] border text-left transition-all duration-200 cursor-pointer ${
|
||||
activeChannel?.url === channel.url
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{channel.logo ? (
|
||||
<img
|
||||
src={channel.logo}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-contain bg-black/10 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-8 h-8 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
activeChannel?.url === channel.url ? 'bg-white/20' : 'bg-[var(--glass-bg)]'
|
||||
}`}>
|
||||
<Icons.TV size={14} className={activeChannel?.url === channel.url ? 'text-white/70' : 'text-[var(--text-color-secondary)]'} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-xs font-medium truncate ${
|
||||
activeChannel?.url === channel.url ? 'text-white' : 'text-[var(--text-color)]'
|
||||
}`}>
|
||||
{channel.name}
|
||||
</p>
|
||||
{channel.group && (
|
||||
<p className={`text-[10px] truncate ${
|
||||
activeChannel?.url === channel.url ? 'text-white/70' : 'text-[var(--text-color-secondary)]'
|
||||
}`}>
|
||||
{channel.group}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
未找到匹配的频道
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-[9999] bg-black flex">
|
||||
{/* Player Area */}
|
||||
<div className="flex-1 relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain bg-black"
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<p className="text-white/70 text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Overlay */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 text-sm mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => loadChannel(channel)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors cursor-pointer"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LIVE Badge */}
|
||||
<div className="absolute top-4 left-4 flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
<span className="text-white text-sm font-medium drop-shadow-lg">{channel.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Icons.List size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Icons.X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-72 bg-[#111] border-l border-white/10 overflow-y-auto">
|
||||
<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={() => setShowSidebar(false)}
|
||||
className="text-white/50 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{channels.map((ch, i) => (
|
||||
<button
|
||||
key={`${ch.name}-${i}`}
|
||||
onClick={() => {
|
||||
onChannelChange(ch);
|
||||
setShowSidebar(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
ch.url === channel.url
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{ch.url === channel.url && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{ch.name}</span>
|
||||
</div>
|
||||
{ch.group && (
|
||||
<span className={`text-[10px] ${
|
||||
ch.url === channel.url ? 'text-white/60' : 'text-white/30'
|
||||
}`}>
|
||||
{ch.group}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)]">
|
||||
直播源管理
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refreshSources()}
|
||||
disabled={isLoading || sources.length === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.RefreshCw size={12} className={isLoading ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:opacity-90 transition-all cursor-pointer"
|
||||
>
|
||||
<Icons.Plus size={12} />
|
||||
添加源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Source Form */}
|
||||
{showAdd && (
|
||||
<div className="p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="源名称(如:我的IPTV)"
|
||||
value={name}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="M3U 链接地址"
|
||||
value={url}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdd(false)}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!name.trim() || !url.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source List */}
|
||||
{sources.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
暂无直播源,请添加 M3U 播放列表链接
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSource(source.id)}
|
||||
className="ml-2 p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,17 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* IPTV Link */}
|
||||
<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"
|
||||
aria-label="直播"
|
||||
title="直播"
|
||||
data-focusable
|
||||
>
|
||||
<Icons.TV size={16} className="sm:w-5 sm:h-5" />
|
||||
</Link>
|
||||
|
||||
{/* User Info */}
|
||||
{session && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ export function DesktopMoreMenu({
|
||||
danmakuEnabled,
|
||||
setDanmakuEnabled,
|
||||
danmakuApiUrl,
|
||||
danmakuOpacity,
|
||||
setDanmakuOpacity,
|
||||
danmakuFontSize,
|
||||
setDanmakuFontSize,
|
||||
danmakuDisplayArea,
|
||||
setDanmakuDisplayArea,
|
||||
} = usePlayerSettings();
|
||||
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
@@ -392,6 +398,71 @@ export function DesktopMoreMenu({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Danmaku Sub-Settings (shown when enabled and configured) */}
|
||||
{danmakuEnabled && danmakuApiUrl && (
|
||||
<div className={`${isRotated ? 'px-2 pb-1.5' : 'px-3 pb-2 sm:px-4 sm:pb-2.5'} space-y-2.5`}>
|
||||
{/* Opacity Slider */}
|
||||
<div className={`${isRotated ? 'ml-4' : 'ml-6 sm:ml-7'}`}>
|
||||
<div className={`flex items-center justify-between mb-1 ${isRotated ? 'text-[9px]' : 'text-[10px] sm:text-xs'} text-[var(--text-color-secondary)]`}>
|
||||
<span>透明度</span>
|
||||
<span>{Math.round(danmakuOpacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={Math.round(danmakuOpacity * 100)}
|
||||
onChange={(e) => setDanmakuOpacity(parseInt(e.target.value) / 100)}
|
||||
className={`w-full accent-[var(--accent-color)] ${isRotated ? 'h-1' : 'h-1.5'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font Size Buttons */}
|
||||
<div className={`${isRotated ? 'ml-4' : 'ml-6 sm:ml-7'}`}>
|
||||
<div className={`mb-1 ${isRotated ? 'text-[9px]' : 'text-[10px] sm:text-xs'} text-[var(--text-color-secondary)]`}>字号</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{[14, 18, 20, 24, 28].map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setDanmakuFontSize(size)}
|
||||
className={`rounded-[var(--radius-2xl)] border font-medium transition-all duration-200 cursor-pointer ${isRotated ? 'px-1.5 py-0.5 text-[9px]' : 'px-2 py-0.5 text-[10px] sm:text-xs'} ${danmakuFontSize === size
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Area Buttons */}
|
||||
<div className={`${isRotated ? 'ml-4' : 'ml-6 sm:ml-7'}`}>
|
||||
<div className={`mb-1 ${isRotated ? 'text-[9px]' : 'text-[10px] sm:text-xs'} text-[var(--text-color-secondary)]`}>显示区域</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{([
|
||||
{ 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 }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setDanmakuDisplayArea(value)}
|
||||
className={`rounded-[var(--radius-2xl)] border font-medium transition-all duration-200 cursor-pointer ${isRotated ? 'px-1.5 py-0.5 text-[9px]' : 'px-2 py-0.5 text-[10px] sm:text-xs'} ${danmakuDisplayArea === value
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Next Episode Switch */}
|
||||
<div className={`${isRotated ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-4 sm:py-2.5'} flex items-center justify-between gap-4`}>
|
||||
<div className={`flex items-center gap-2 text-[var(--text-color)] ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'}`}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof getSession>>(null);
|
||||
const [hasAuth, setHasAuth] = useState(false);
|
||||
const [accounts, setAccounts] = useState<AccountInfo[]>([]);
|
||||
const [showConfigGen, setShowConfigGen] = useState(false);
|
||||
const [configEntries, setConfigEntries] = useState<ConfigEntry[]>([]);
|
||||
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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account List (Admin only) */}
|
||||
{isAdmin && accounts.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)]" />
|
||||
已配置的账户
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-sm border border-[var(--glass-border)]">
|
||||
{account.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-color)]">{account.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-[var(--radius-full)] ${
|
||||
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' ? '管理员' : '观众'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Generator (Admin only) */}
|
||||
{isAdmin && (
|
||||
<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">
|
||||
<Icons.Settings size={16} className="text-[var(--accent-color)]" />
|
||||
配置生成器
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowConfigGen(!showConfigGen)}
|
||||
className="text-xs text-[var(--accent-color)] hover:underline cursor-pointer"
|
||||
>
|
||||
{showConfigGen ? '收起' : '展开'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfigGen && (
|
||||
<div className="space-y-4 p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
添加账户条目后,将生成的 <code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 环境变量值复制到部署配置中。
|
||||
</p>
|
||||
|
||||
{/* Entry List */}
|
||||
{configEntries.map((entry, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="密码"
|
||||
value={entry.password}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="名称"
|
||||
value={entry.name}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<select
|
||||
value={entry.role}
|
||||
onChange={(e) => updateConfigEntry(index, 'role', e.target.value)}
|
||||
className="px-2 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-xs text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
>
|
||||
<option value="viewer">观众</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeConfigEntry(index)}
|
||||
className="p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer mt-1"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={addConfigEntry}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] border-dashed rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all w-full justify-center cursor-pointer"
|
||||
>
|
||||
<Icons.Plus size={12} />
|
||||
添加账户
|
||||
</button>
|
||||
|
||||
{/* Generated Output */}
|
||||
{configEntries.length > 0 && configEntries.some(e => e.password && e.name) && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-[var(--text-color)]">
|
||||
生成的 ACCOUNTS 值:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-black/20 border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-xs text-[var(--text-color)] break-all select-all">
|
||||
{generateAccountsString()}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-xs hover:opacity-90 transition-all cursor-pointer flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<Icons.Copy size={12} />
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Notice */}
|
||||
<div className="flex items-start gap-3 p-4 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<Info className="text-[var(--text-color-secondary)] shrink-0 mt-0.5" size={16} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
@@ -183,6 +193,27 @@ export function PlayerSettings({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Area */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
|
||||
弹幕显示区域
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{DANMAKU_DISPLAY_AREAS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onDanmakuDisplayAreaChange(value)}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-2xl)] border text-sm font-medium transition-all duration-200 cursor-pointer ${danmakuDisplayArea === value
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,4 +197,27 @@ export const UtilityIcons = {
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Plus: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Copy: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Users: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
+83
-18
@@ -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<string, VideoHistoryItem>();
|
||||
|
||||
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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
interface IPTVStore extends IPTVState, IPTVActions {}
|
||||
|
||||
export const useIPTVStore = create<IPTVStore>()(
|
||||
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<string>();
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface VideoHistoryItem {
|
||||
poster?: string;
|
||||
episodes: Episode[];
|
||||
showIdentifier: string; // Unique identifier for deduplication
|
||||
sourceMap?: Record<string, string | number>; // Maps source name to videoId for that source
|
||||
vod_actor?: string;
|
||||
type_name?: string;
|
||||
vod_area?: string;
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Generated
+6
-6
@@ -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"
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user