feat: Implement user-defined video sources and danmaku API management with new settings pages and a dedicated store.

This commit is contained in:
kuekhaoyang
2026-02-19 11:56:14 +08:00
parent e1ea1a0d9a
commit 9ea9f85507
26 changed files with 1097 additions and 151 deletions
+1
View File
@@ -80,6 +80,7 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
profileId: data.profileId,
name: data.name,
role: data.role,
customPermissions: data.customPermissions,
}, data.persistSession ?? persistSession);
// Reload to re-initialize stores with profiled keys
+11 -7
View File
@@ -16,12 +16,12 @@ const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
lowLatencyMode: true,
liveDurationInfinity: true,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 2,
manifestLoadingMaxRetry: 3,
levelLoadingTimeOut: 10000,
fragLoadingTimeOut: 15000,
fragLoadingTimeOut: 20000,
};
const LOADING_TIMEOUT_MS = 20000;
const LOADING_TIMEOUT_MS = 30000;
interface IPTVPlayerProps {
channel: M3UChannel;
@@ -30,8 +30,12 @@ interface IPTVPlayerProps {
onChannelChange: (channel: M3UChannel) => void;
}
function getProxiedUrl(url: string): string {
return `/api/iptv/stream?url=${encodeURIComponent(url)}`;
function getProxiedUrl(url: string, ua?: string, referer?: string): string {
let proxyUrl = `/api/iptv/stream?`;
if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`;
if (referer) proxyUrl += `referer=${encodeURIComponent(referer)}&`;
proxyUrl += `url=${encodeURIComponent(url)}`;
return proxyUrl;
}
function formatTime(seconds: number): string {
@@ -161,7 +165,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
video.removeAttribute('src');
video.load();
const proxiedUrl = getProxiedUrl(url);
const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer);
// Global loading timeout
let loadingResolved = false;
@@ -309,7 +313,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
}, { once: true });
}, { once: true });
}
}, []);
}, [channel.httpUserAgent, channel.httpReferrer]);
// Load on channel/route change
useEffect(() => {
+177 -65
View File
@@ -20,6 +20,7 @@ export interface SourceInfo {
sourceName?: string;
latency?: number;
pic?: string;
typeName?: string;
}
interface EpisodeListProps {
@@ -47,6 +48,7 @@ export function EpisodeList({
const listRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [sourceExpanded, setSourceExpanded] = useState(false);
const [showAllSources, setShowAllSources] = useState(false);
// Source latency state
const [latencies, setLatencies] = useState<Record<string, number>>({});
@@ -233,74 +235,184 @@ export function EpisodeList({
</Button>
</div>
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
{sortedSources.map((source, index) => {
const isCurrent = source.source === currentSource;
const latency = latencies[source.source] ?? source.latency;
{(() => {
const MAX_VISIBLE = 5;
const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
const hasMoreSources = sortedSources.length > MAX_VISIBLE;
return (
<button
key={`${source.source}-${index}`}
onClick={() => {
if (!isCurrent) {
onSourceChange!(source);
setSourceExpanded(false);
}
}}
className={`
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
flex items-center gap-2.5
${isCurrent
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
}
`}
aria-current={isCurrent ? 'true' : undefined}
>
{source.pic && (
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
<Image
src={source.pic}
alt=""
width={40}
height={56}
className="w-full h-full object-cover"
unoptimized
referrerPolicy="no-referrer"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{source.sourceName || source.source}
</div>
{latency !== undefined && (
<div className="mt-0.5">
<LatencyBadge latency={latency} />
// Group sources by typeName
const groupedByType = new Map<string, typeof visibleSources>();
for (const source of visibleSources) {
const typeName = source.typeName || '';
if (!groupedByType.has(typeName)) groupedByType.set(typeName, []);
groupedByType.get(typeName)!.push(source);
}
const hasTypeGroups = groupedByType.size > 1 || (groupedByType.size === 1 && !groupedByType.has(''));
return (
<>
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
{hasTypeGroups ? (
Array.from(groupedByType.entries()).map(([typeName, typeSources]) => (
<div key={typeName || '__default'}>
{typeName && (
<div className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-2 pt-2 pb-1">
{typeName}
</div>
)}
{typeSources.map((source, index) => {
const isCurrent = source.source === currentSource;
const latency = latencies[source.source] ?? source.latency;
const globalIndex = sortedSources.indexOf(source);
return (
<button
key={`${source.source}-${index}`}
onClick={() => {
if (!isCurrent) {
onSourceChange!(source);
setSourceExpanded(false);
}
}}
className={`
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
flex items-center gap-2.5
${isCurrent
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
}
`}
aria-current={isCurrent ? 'true' : undefined}
>
{source.pic && (
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
<Image
src={source.pic}
alt=""
width={40}
height={56}
className="w-full h-full object-cover"
unoptimized
referrerPolicy="no-referrer"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{source.sourceName || source.source}
</div>
{latency !== undefined && (
<div className="mt-0.5">
<LatencyBadge latency={latency} />
</div>
)}
</div>
{isCurrent && (
<Icons.Play size={14} className="flex-shrink-0" />
)}
{!isCurrent && globalIndex < 3 && (
<Badge
variant="secondary"
className={`flex-shrink-0 ${globalIndex === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
globalIndex === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
'bg-orange-400/20 text-orange-600 border-orange-400'
}`}
>
#{globalIndex + 1}
</Badge>
)}
</button>
);
})}
</div>
))
) : (
visibleSources.map((source, index) => {
const isCurrent = source.source === currentSource;
const latency = latencies[source.source] ?? source.latency;
return (
<button
key={`${source.source}-${index}`}
onClick={() => {
if (!isCurrent) {
onSourceChange!(source);
setSourceExpanded(false);
}
}}
className={`
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
flex items-center gap-2.5
${isCurrent
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
}
`}
aria-current={isCurrent ? 'true' : undefined}
>
{source.pic && (
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
<Image
src={source.pic}
alt=""
width={40}
height={56}
className="w-full h-full object-cover"
unoptimized
referrerPolicy="no-referrer"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{source.sourceName || source.source}
</div>
{latency !== undefined && (
<div className="mt-0.5">
<LatencyBadge latency={latency} />
</div>
)}
</div>
{isCurrent && (
<Icons.Play size={14} className="flex-shrink-0" />
)}
{!isCurrent && index < 3 && (
<Badge
variant="secondary"
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
'bg-orange-400/20 text-orange-600 border-orange-400'
}`}
>
#{index + 1}
</Badge>
)}
</button>
);
})
)}
</div>
{hasMoreSources && (
<button
onClick={() => setShowAllSources(!showAllSources)}
className="w-full mt-1.5 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
{showAllSources ? (
<> <Icons.ChevronDown size={12} className="rotate-180" /></>
) : (
<> ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
)}
</div>
{isCurrent && (
<Icons.Play size={14} className="flex-shrink-0" />
)}
{!isCurrent && index < 3 && (
<Badge
variant="secondary"
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
'bg-orange-400/20 text-orange-600 border-orange-400'
}`}
>
#{index + 1}
</Badge>
)}
</button>
);
})}
</div>
</button>
)}
</>
);
})()}
</div>
)}
</div>
+15 -10
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { userSourcesStore } from '@/lib/store/user-sources-store';
import { parseDanmakuResponse, parseSearchResults, matchEpisode, fuzzyMatchTitle } from '@/lib/utils/danmaku-utils';
import type { DanmakuComment } from '@/lib/types/danmaku';
@@ -27,18 +28,22 @@ export function useDanmaku({ videoTitle, episodeName, episodeIndex }: UseDanmaku
const [error, setError] = useState<string | null>(null);
const fetchedKeyRef = useRef('');
// Sync with settings store
// Sync with settings store + user danmaku API override
useEffect(() => {
const s = settingsStore.getSettings();
setDanmakuEnabledState(s.danmakuEnabled);
setApiUrl(s.danmakuApiUrl);
const updateApi = () => {
const s = settingsStore.getSettings();
setDanmakuEnabledState(s.danmakuEnabled);
const unsub = settingsStore.subscribe(() => {
const ns = settingsStore.getSettings();
setDanmakuEnabledState(ns.danmakuEnabled);
setApiUrl(ns.danmakuApiUrl);
});
return unsub;
// User's active danmaku API takes priority over system setting
const userApi = userSourcesStore.getActiveDanmakuApi();
setApiUrl(userApi ? userApi.url : s.danmakuApiUrl);
};
updateApi();
const unsub1 = settingsStore.subscribe(updateApi);
const unsub2 = userSourcesStore.subscribe(updateApi);
return () => { unsub1(); unsub2(); };
}, []);
const setDanmakuEnabled = useCallback((v: boolean) => {
+56 -14
View File
@@ -6,7 +6,7 @@
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Icons } from '@/components/ui/Icon';
import { SourceBadgeItem } from './SourceBadgeItem';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
@@ -17,6 +17,7 @@ interface Source {
id: string;
name: string;
count: number;
typeName?: string;
}
interface SourceBadgeListProps {
@@ -37,6 +38,19 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
const badgeContainerRef = useRef<HTMLDivElement>(null);
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Group sources by typeName if type info is available
const typeGroups = useMemo(() => {
const groups = new Map<string, Source[]>();
for (const source of sources) {
const type = source.typeName || '';
if (!groups.has(type)) groups.set(type, []);
groups.get(type)!.push(source);
}
// Only use grouping if there are meaningful type names
const hasTypes = groups.size > 1 || (groups.size === 1 && !groups.has(''));
return hasTypes ? groups : null;
}, [sources]);
const toggleExpanded = useCallback(() => {
setIsExpanded(prev => {
const next = !prev;
@@ -97,19 +111,47 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
ref={badgeContainerRef}
className="flex items-center gap-2 flex-wrap p-1"
>
{sources.map((source, index) => (
<SourceBadgeItem
key={source.id}
id={source.id}
name={source.name}
count={source.count}
isSelected={selectedSources.has(source.id)}
onToggle={() => onToggleSource(source.id)}
isFocused={focusedIndex === index}
onFocus={() => setFocusedIndex(index)}
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
/>
))}
{typeGroups ? (
Array.from(typeGroups.entries()).map(([typeName, typeSources]) => (
<div key={typeName || '__default'} className="flex items-center gap-2 flex-wrap">
{typeName && (
<span className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-1 select-none">
{typeName}:
</span>
)}
{typeSources.map((source) => {
const globalIndex = sources.indexOf(source);
return (
<SourceBadgeItem
key={source.id}
id={source.id}
name={source.name}
count={source.count}
isSelected={selectedSources.has(source.id)}
onToggle={() => onToggleSource(source.id)}
isFocused={focusedIndex === globalIndex}
onFocus={() => setFocusedIndex(globalIndex)}
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[globalIndex] = el; }}
/>
);
})}
</div>
))
) : (
sources.map((source, index) => (
<SourceBadgeItem
key={source.id}
id={source.id}
name={source.name}
count={source.count}
isSelected={selectedSources.has(source.id)}
onToggle={() => onToggleSource(source.id)}
isFocused={focusedIndex === index}
onFocus={() => setFocusedIndex(index)}
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
/>
))
)}
</div>
</div>
+12 -5
View File
@@ -90,11 +90,18 @@ export const VideoCard = memo<VideoCardProps>(({
{/* Badge Container */}
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
{video.sourceName && (
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
{video.sourceName}
</Badge>
)}
<div className="flex items-center gap-1 min-w-0">
{video.sourceName && (
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
{video.sourceName}
</Badge>
)}
{video.type_name && (
<Badge variant="secondary" className="flex-shrink-0 max-w-[40%] truncate text-[10px]">
{video.type_name}
</Badge>
)}
</div>
{displayLatency !== undefined && (
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
+1
View File
@@ -66,6 +66,7 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
sourceName: v.sourceName,
latency: v.latency,
pic: v.vod_pic,
typeName: v.type_name,
}));
params.set('groupedSources', JSON.stringify(groupData));
}
+69 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { getSession, clearSession, hasPermission, type Role } from '@/lib/store/auth-store';
import { getSession, clearSession, hasPermission, type Role, type Permission } from '@/lib/store/auth-store';
import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { LogOut, Shield, Info } from 'lucide-react';
@@ -9,14 +9,32 @@ import { LogOut, Shield, Info } from 'lucide-react';
interface AccountInfo {
name: string;
role: Role;
customPermissions?: string[];
}
interface ConfigEntry {
password: string;
name: string;
role: Role;
customPermissions: Permission[];
}
const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
{ key: 'source_management', label: '视频源管理' },
{ key: 'account_management', label: '账户管理' },
{ key: 'danmaku_api', label: '弹幕 API' },
{ key: 'data_management', label: '数据管理' },
{ key: 'player_settings', label: '播放器设置' },
{ key: 'danmaku_appearance', label: '弹幕外观' },
{ key: 'iptv_access', label: 'IPTV 访问' },
];
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
viewer: ['view_settings'],
};
export function AccountSettings() {
const [session, setSessionState] = useState<ReturnType<typeof getSession>>(null);
const [hasAuth, setHasAuth] = useState(false);
@@ -54,7 +72,7 @@ export function AccountSettings() {
// Config generator helpers
const addConfigEntry = () => {
setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer' }]);
setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer', customPermissions: [] }]);
};
const updateConfigEntry = (index: number, field: keyof ConfigEntry, value: string) => {
@@ -63,6 +81,18 @@ export function AccountSettings() {
setConfigEntries(updated);
};
const toggleConfigPermission = (index: number, perm: Permission) => {
const updated = [...configEntries];
const entry = updated[index];
const perms = entry.customPermissions || [];
if (perms.includes(perm)) {
entry.customPermissions = perms.filter(p => p !== perm);
} else {
entry.customPermissions = [...perms, perm];
}
setConfigEntries(updated);
};
const removeConfigEntry = (index: number) => {
setConfigEntries(configEntries.filter((_, i) => i !== index));
};
@@ -70,7 +100,17 @@ export function AccountSettings() {
const generateAccountsString = () => {
return configEntries
.filter(e => e.password.trim() && e.name.trim())
.map(e => `${e.password}:${e.name}${e.role !== 'viewer' ? ':' + e.role : ''}`)
.map(e => {
let str = `${e.password}:${e.name}`;
const hasCustomPerms = e.customPermissions && e.customPermissions.length > 0;
if (e.role !== 'viewer' || hasCustomPerms) {
str += ':' + e.role;
}
if (hasCustomPerms) {
str += ':' + e.customPermissions.join('|');
}
return str;
})
.join(',');
};
@@ -92,6 +132,7 @@ export function AccountSettings() {
password: '',
name: a.name,
role: a.role,
customPermissions: (a.customPermissions || []) as Permission[],
}));
setConfigEntries(existingEntries);
setShowConfigGen(true);
@@ -279,6 +320,30 @@ export function AccountSettings() {
<option value="super_admin"></option>
</select>
</div>
{/* Custom permissions: show only those not in the selected role */}
{(() => {
const rolePerms = ROLE_PERMISSIONS[entry.role] || [];
const extraPerms = ALL_PERMISSIONS.filter(p => !rolePerms.includes(p.key));
if (extraPerms.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5 pl-1">
{extraPerms.map(p => {
const checked = entry.customPermissions?.includes(p.key) ?? false;
return (
<label key={p.key} className="flex items-center gap-1 text-[10px] text-[var(--text-color-secondary)] cursor-pointer select-none">
<input
type="checkbox"
checked={checked}
onChange={() => toggleConfigPermission(index, p.key)}
className="w-3 h-3 rounded accent-[var(--accent-color)]"
/>
{p.label}
</label>
);
})}
</div>
);
})()}
</div>
<button
onClick={() => removeConfigEntry(index)}
@@ -331,7 +396,7 @@ export function AccountSettings() {
</p>
<div className="text-xs text-[var(--text-color-secondary)] space-y-0.5">
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ADMIN_PASSWORD</code> </p>
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 密码:名称[:]</p>
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 密码:名称[:[:1|2]]</p>
</div>
</div>
</div>
+6 -1
View File
@@ -5,6 +5,7 @@ import { ImportModalTabs } from './import/ImportModalTabs';
import { FileImportTab } from './import/FileImportTab';
import { LinkImportTab } from './import/LinkImportTab';
import { SubscriptionImportTab } from './import/SubscriptionImportTab';
import { JsonImportTab } from './import/JsonImportTab';
import type { ImportResult } from '@/lib/utils/source-import-utils';
import type { SourceSubscription } from '@/lib/types';
import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
@@ -33,7 +34,7 @@ export function ImportModal({
onRemoveSubscription,
onRefreshSubscription
}: ImportModalProps) {
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription'>('file');
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription' | 'json'>('file');
// Reset tab on open
useEffect(() => {
@@ -90,6 +91,10 @@ export function ImportModal({
onRefresh={onRefreshSubscription}
/>
)}
{activeTab === 'json' && (
<JsonImportTab />
)}
</div>
</div>
</div>
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useState, useEffect } from 'react';
import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
import { settingsStore } from '@/lib/store/settings-store';
export function UserDanmakuSettings() {
const [apis, setApis] = useState<DanmakuApiEntry[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [systemApiUrl, setSystemApiUrl] = useState('');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState('');
useEffect(() => {
const state = userSourcesStore.getState();
setApis(state.danmakuApis);
setActiveId(state.activeDanmakuApiId);
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
const unsub = userSourcesStore.subscribe(() => {
const s = userSourcesStore.getState();
setApis(s.danmakuApis);
setActiveId(s.activeDanmakuApiId);
});
const unsub2 = settingsStore.subscribe(() => {
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
});
return () => { unsub(); unsub2(); };
}, []);
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !url.trim()) {
setError('名称和 URL 不能为空');
return;
}
try {
new URL(url);
} catch {
setError('请输入有效的 URL');
return;
}
const id = `danmaku-${Date.now().toString(36)}`;
userSourcesStore.addDanmakuApi({ id, name: name.trim(), url: url.trim() });
setName('');
setUrl('');
setError('');
};
return (
<SettingsSection title="弹幕 API" description="管理你的弹幕 API,选择当前使用的 API。">
<div className="space-y-4">
{/* Add form */}
<form onSubmit={handleAdd} className="space-y-3">
<div className="flex gap-2">
<input
type="text"
placeholder="API 名称"
value={name}
onChange={(e) => { setName(e.target.value); setError(''); }}
className="flex-1 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="API URL (https://...)"
value={url}
onChange={(e) => { setUrl(e.target.value); setError(''); }}
className="flex-[2] 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)]"
/>
<button
type="submit"
className="px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center gap-1"
>
<Icons.Plus size={14} />
</button>
</div>
{error && (
<p className="text-xs text-red-500">{error}</p>
)}
</form>
{/* API list with radio select */}
<div className="space-y-2">
{/* System default option */}
<button
onClick={() => userSourcesStore.setActiveDanmakuApi(null)}
className={`w-full flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-left transition-all cursor-pointer ${
activeId === null ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
}`}
>
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
activeId === null ? 'border-[var(--accent-color)]' : 'border-[var(--glass-border)]'
}`}>
{activeId === null && <span className="w-2 h-2 rounded-full bg-[var(--accent-color)]" />}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-color)]">使</p>
{systemApiUrl && (
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{systemApiUrl}</p>
)}
{!systemApiUrl && (
<p className="text-[10px] text-[var(--text-color-secondary)]"> API</p>
)}
</div>
</button>
{apis.map(api => (
<div key={api.id} className={`flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] ${
activeId === api.id ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
}`}>
<button
onClick={() => userSourcesStore.setActiveDanmakuApi(api.id)}
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
>
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
activeId === api.id ? 'border-[var(--accent-color)]' : 'border-[var(--glass-border)]'
}`}>
{activeId === api.id && <span className="w-2 h-2 rounded-full bg-[var(--accent-color)]" />}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{api.name}</p>
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{api.url}</p>
</div>
</button>
<button
onClick={() => userSourcesStore.removeDanmakuApi(api.id)}
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
>
<Icons.Trash size={14} />
</button>
</div>
))}
</div>
</div>
</SettingsSection>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useState, useEffect } from 'react';
import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { userSourcesStore } from '@/lib/store/user-sources-store';
import type { VideoSource } from '@/lib/types';
export function UserSourceSettings() {
const [sources, setSources] = useState<VideoSource[]>([]);
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [error, setError] = useState('');
useEffect(() => {
setSources(userSourcesStore.getSources());
const unsub = userSourcesStore.subscribe(() => {
setSources(userSourcesStore.getSources());
});
return unsub;
}, []);
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !baseUrl.trim()) {
setError('名称和接口地址不能为空');
return;
}
try {
new URL(baseUrl);
} catch {
setError('请输入有效的 URL');
return;
}
const id = `user-${name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now().toString(36)}`;
const source: VideoSource = {
id,
name: name.trim(),
baseUrl: baseUrl.trim(),
searchPath: '/provide/vod',
detailPath: '/provide/vod',
enabled: true,
};
userSourcesStore.addSource(source);
setName('');
setBaseUrl('');
setError('');
};
return (
<SettingsSection title="个人视频源" description="添加你自己的视频源,不影响其他用户。">
<div className="space-y-4">
{/* Add form */}
<form onSubmit={handleAdd} className="space-y-3">
<div className="flex gap-2">
<input
type="text"
placeholder="源名称"
value={name}
onChange={(e) => { setName(e.target.value); setError(''); }}
className="flex-1 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="接口地址 (https://...)"
value={baseUrl}
onChange={(e) => { setBaseUrl(e.target.value); setError(''); }}
className="flex-[2] 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)]"
/>
<button
type="submit"
className="px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center gap-1"
>
<Icons.Plus size={14} />
</button>
</div>
{error && (
<p className="text-xs text-red-500">{error}</p>
)}
</form>
{/* Source list */}
{sources.length > 0 && (
<div className="space-y-2">
{sources.map(source => (
<div
key={source.id}
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 min-w-0">
<button
onClick={() => userSourcesStore.toggleSource(source.id)}
className={`w-8 h-5 rounded-full transition-colors cursor-pointer relative flex-shrink-0 ${
source.enabled !== false ? 'bg-[var(--accent-color)]' : 'bg-[var(--glass-border)]'
}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${
source.enabled !== false ? 'left-3.5' : 'left-0.5'
}`} />
</button>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{source.baseUrl}</p>
</div>
</div>
<button
onClick={() => userSourcesStore.removeSource(source.id)}
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
>
<Icons.Trash size={14} />
</button>
</div>
))}
</div>
)}
{sources.length === 0 && (
<p className="text-xs text-[var(--text-color-secondary)] text-center py-4">
</p>
)}
</div>
</SettingsSection>
);
}
@@ -1,8 +1,8 @@
'use client';
interface ImportModalTabsProps {
activeTab: 'file' | 'link' | 'subscription';
onTabChange: (tab: 'file' | 'link' | 'subscription') => void;
activeTab: 'file' | 'link' | 'subscription' | 'json';
onTabChange: (tab: 'file' | 'link' | 'subscription' | 'json') => void;
}
export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps) {
@@ -10,6 +10,7 @@ export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps
{ id: 'file', label: '文件导入' },
{ id: 'link', label: '链接导入' },
{ id: 'subscription', label: '订阅管理' },
{ id: 'json', label: 'JSON' },
] as const;
return (
@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import { Icons } from '@/components/ui/Icon';
import { settingsStore } from '@/lib/store/settings-store';
import type { VideoSource } from '@/lib/types';
export function JsonImportTab() {
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState('');
const [preview, setPreview] = useState<VideoSource[] | null>(null);
const [imported, setImported] = useState(false);
const handleParse = () => {
setError('');
setPreview(null);
if (!jsonText.trim()) {
setError('请粘贴 JSON 内容');
return;
}
try {
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) {
setError('JSON 格式错误:应为数组');
return;
}
const sources: VideoSource[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
if (!item.name || !item.baseUrl) {
setError(`${i + 1} 项缺少 name 或 baseUrl`);
return;
}
sources.push({
id: item.id || `json-${item.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${i}`,
name: item.name,
baseUrl: item.baseUrl,
searchPath: item.searchPath || '/provide/vod',
detailPath: item.detailPath || '/provide/vod',
enabled: item.enabled !== false,
headers: item.headers,
priority: item.priority,
group: item.group,
});
}
if (sources.length === 0) {
setError('没有有效的视频源');
return;
}
setPreview(sources);
} catch (e) {
setError('JSON 解析失败:' + (e instanceof Error ? e.message : '格式错误'));
}
};
const handleImport = () => {
if (!preview) return;
const settings = settingsStore.getSettings();
const existingIds = new Set(settings.sources.map(s => s.id));
const newSources = preview.filter(s => !existingIds.has(s.id));
const updatedSources = [...settings.sources, ...newSources];
settingsStore.saveSettings({ ...settings, sources: updatedSources });
setImported(true);
setPreview(null);
setJsonText('');
};
return (
<div className="space-y-4">
<p className="text-xs text-[var(--text-color-secondary)]">
JSON
<code className="block mt-1 px-2 py-1 bg-black/10 rounded text-[10px]">
{'[{ "name": "...", "baseUrl": "..." }]'}
</code>
</p>
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setError(''); setPreview(null); setImported(false); }}
placeholder='[{ "name": "源名称", "baseUrl": "https://..." }]'
rows={6}
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)] font-mono resize-none"
/>
{error && (
<p className="text-xs text-red-500">{error}</p>
)}
{!preview && !imported && (
<button
onClick={handleParse}
className="w-full px-4 py-2.5 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center justify-center gap-2"
>
<Icons.Search size={14} />
</button>
)}
{preview && (
<div className="space-y-3">
<div className="p-3 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
<p className="text-sm text-[var(--text-color)]">
<span className="font-bold text-[var(--accent-color)]">{preview.length}</span>
</p>
<div className="mt-2 space-y-1 max-h-[150px] overflow-y-auto">
{preview.map((s, i) => (
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2">
<span className="font-medium text-[var(--text-color)]">{s.name}</span>
<span className="truncate">{s.baseUrl}</span>
</div>
))}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => { setPreview(null); }}
className="flex-1 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] rounded-[var(--radius-2xl)] text-sm hover:bg-[var(--glass-hover)] transition-all cursor-pointer"
>
</button>
<button
onClick={handleImport}
className="flex-1 px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center justify-center gap-1"
>
<Icons.Download size={14} />
</button>
</div>
</div>
)}
{imported && (
<p className="text-xs text-green-500 text-center py-2"></p>
)}
</div>
);
}