feat: Implement IPTV functionality with channel management and player, and add admin account configuration generation.

This commit is contained in:
kuekhaoyang
2026-02-17 16:37:53 +08:00
parent 91280f250f
commit 5d139114b5
30 changed files with 1362 additions and 50 deletions
+3 -3
View File
@@ -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}
/>
))}
+5 -6
View File
@@ -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 });
};
+150
View File
@@ -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>
);
}
+218
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+11
View File
@@ -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">
+8 -4
View File
@@ -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(() => {
+3 -8
View File
@@ -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,
};
}
+181
View File
@@ -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} />
+31
View File
@@ -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>
+23
View File
@@ -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>
),
};