feat: Implement IPTV stream proxy to resolve CORS issues and refine desktop player menu positioning for fullscreen.

This commit is contained in:
kuekhaoyang
2026-02-17 17:57:56 +08:00
parent 5d139114b5
commit 6a2e28c615
8 changed files with 425 additions and 77 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge'; export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url'); const url = request.nextUrl.searchParams.get('url');
+125
View File
@@ -0,0 +1,125 @@
/**
* IPTV Stream Proxy API Route
* Proxies HLS manifests and media segments to avoid CORS issues.
* For .m3u8 manifests, rewrites URLs to also route through this proxy.
*/
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
function resolveUrl(base: string, relative: string): string {
if (relative.startsWith('http://') || relative.startsWith('https://')) {
return relative;
}
try {
return new URL(relative, base).href;
} catch {
// Fallback: manual resolution
const baseUrl = base.substring(0, base.lastIndexOf('/') + 1);
return baseUrl + relative;
}
}
function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): string {
return content.split('\n').map(line => {
const trimmed = line.trim();
// Skip empty lines and comments (but process URI= in EXT tags)
if (!trimmed) return line;
// Rewrite URI="..." in EXT-X-KEY, EXT-X-MAP, etc.
if (trimmed.startsWith('#') && trimmed.includes('URI="')) {
return line.replace(/URI="([^"]+)"/g, (_match, uri) => {
const absoluteUri = resolveUrl(baseUrl, uri);
return `URI="${proxyBase}${encodeURIComponent(absoluteUri)}"`;
});
}
// Skip other comment lines
if (trimmed.startsWith('#')) return line;
// This is a segment/playlist URL line - rewrite it
const absoluteUrl = resolveUrl(baseUrl, trimmed);
return `${proxyBase}${encodeURIComponent(absoluteUrl)}`;
}).join('\n');
}
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)',
'Accept': '*/*',
},
});
if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch: ${response.status}` },
{ status: response.status }
);
}
const contentType = response.headers.get('content-type') || '';
const isM3u8 = url.includes('.m3u8') ||
contentType.includes('mpegurl') ||
contentType.includes('x-mpegURL');
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
if (isM3u8) {
// Parse and rewrite manifest
const text = await response.text();
const proxyBase = `/api/iptv/stream?url=`;
const rewritten = rewriteM3u8(text, url, proxyBase);
return new NextResponse(rewritten, {
status: 200,
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
...corsHeaders,
},
});
} else {
// Pipe through media segments directly
const body = response.body;
const forwardContentType = contentType || 'video/mp2t';
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': forwardContentType,
'Cache-Control': 'public, max-age=60',
...corsHeaders,
},
});
}
} catch (e) {
return NextResponse.json(
{ error: 'Failed to proxy stream' },
{ status: 500 }
);
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
},
});
}
+92 -32
View File
@@ -2,7 +2,8 @@
/** /**
* IPTVPlayer - Lightweight player for IPTV live streams * IPTVPlayer - Lightweight player for IPTV live streams
* Uses HLS.js for playback with a channel switching sidebar * Uses HLS.js for playback with a channel switching sidebar.
* Routes streams through /api/iptv/stream proxy to avoid CORS issues.
*/ */
import { useRef, useEffect, useState, useCallback } from 'react'; import { useRef, useEffect, useState, useCallback } from 'react';
@@ -17,6 +18,10 @@ interface IPTVPlayerProps {
onChannelChange: (channel: M3UChannel) => void; onChannelChange: (channel: M3UChannel) => void;
} }
function getProxiedUrl(url: string): string {
return `/api/iptv/stream?url=${encodeURIComponent(url)}`;
}
export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) { export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
@@ -37,62 +42,117 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
hlsRef.current = null; hlsRef.current = null;
} }
const url = ch.url; const originalUrl = ch.url;
const proxiedUrl = getProxiedUrl(originalUrl);
if (url.endsWith('.m3u8') || url.includes('.m3u8')) { // Try HLS.js first for all URLs (many IPTV streams are HLS even without .m3u8 extension)
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls({ const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveDurationInfinity: true,
});
hlsRef.current = hls;
let triedProxy = false;
const tryWithProxy = () => {
if (triedProxy) return;
triedProxy = true;
// Retry with proxied URL
hls.destroy();
const hlsProxy = new Hls({
enableWorker: true, enableWorker: true,
lowLatencyMode: true, lowLatencyMode: true,
liveDurationInfinity: true, liveDurationInfinity: true,
}); });
hlsRef.current = hls; hlsRef.current = hlsProxy;
hls.loadSource(url); hlsProxy.loadSource(proxiedUrl);
hls.attachMedia(video); hlsProxy.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => { hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => {
setIsLoading(false); setIsLoading(false);
video.play().catch(() => {}); video.play().catch(() => {});
}); });
hls.on(Hls.Events.ERROR, (_, data) => { hlsProxy.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) { if (data.fatal) {
setIsLoading(false); setIsLoading(false);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
setError('网络错误,无法加载频道'); hlsProxy.recoverMediaError();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else { } else {
setError('播放错误,请尝试其他频道'); // Last resort: try direct video element
hlsProxy.destroy();
hlsRef.current = null;
tryDirectVideo(proxiedUrl);
} }
} }
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { };
// Native HLS (Safari/iOS)
video.src = url; // First try direct URL
video.addEventListener('loadedmetadata', () => { hls.loadSource(originalUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setIsLoading(false);
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
// Likely CORS - try proxy
tryWithProxy();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
tryWithProxy();
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari/iOS) - try direct first, fall back to proxy
tryNativeHls(video, originalUrl, proxiedUrl);
} else {
tryDirectVideo(originalUrl);
}
function tryNativeHls(vid: HTMLVideoElement, url: string, fallbackUrl: string) {
vid.src = url;
const onLoad = () => {
setIsLoading(false);
vid.play().catch(() => {});
};
const onError = () => {
vid.removeEventListener('loadedmetadata', onLoad);
// Try proxied URL
vid.src = fallbackUrl;
vid.addEventListener('loadedmetadata', () => {
setIsLoading(false); setIsLoading(false);
video.play().catch(() => {}); vid.play().catch(() => {});
}, { once: true }); }, { once: true });
video.addEventListener('error', () => { vid.addEventListener('error', () => {
setIsLoading(false); setIsLoading(false);
setError('播放错误'); setError('播放错误');
}, { once: true }); }, { once: true });
} else { };
setError('您的浏览器不支持 HLS 播放'); vid.addEventListener('loadedmetadata', onLoad, { once: true });
vid.addEventListener('error', onError, { once: true });
}
function tryDirectVideo(url: string) {
const vid = videoRef.current;
if (!vid) return;
vid.src = url;
vid.addEventListener('loadedmetadata', () => {
setIsLoading(false); setIsLoading(false);
} vid.play().catch(() => {});
} else {
// Direct video URL (mp4, etc.)
video.src = url;
video.addEventListener('loadedmetadata', () => {
setIsLoading(false);
video.play().catch(() => {});
}, { once: true }); }, { once: true });
video.addEventListener('error', () => { vid.addEventListener('error', () => {
setIsLoading(false); setIsLoading(false);
setError('播放错误'); setError('播放错误,请尝试其他频道');
}, { once: true }); }, { once: true });
} }
}, []); }, []);
+46 -5
View File
@@ -93,9 +93,9 @@ export function DesktopMoreMenu({
const calculateMenuPosition = React.useCallback(() => { const calculateMenuPosition = React.useCallback(() => {
if (!buttonRef.current || !containerRef.current) return; if (!buttonRef.current || !containerRef.current) return;
if (!isRotated) { if (!isRotated && !isFullscreen) {
// Normal Mode: Non-rotated (Portrait on Mobile) // Normal Mode: Non-rotated, non-fullscreen
// Use Viewport Coordinates but position relative to button (User Request: "Below button") // Use Viewport Coordinates but position relative to button
// And use Body Portal to escape container clipping // And use Body Portal to escape container clipping
const buttonRect = buttonRef.current.getBoundingClientRect(); const buttonRect = buttonRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -136,6 +136,47 @@ export function DesktopMoreMenu({
openUpward: openUpward, openUpward: openUpward,
align: align align: align
}); });
} else if (isFullscreen && !isRotated) {
// Fullscreen Mode (not rotated): Use container-relative coordinates
// Portal goes to containerRef to stay visible within fullscreen element
let top = 0;
let left = 0;
let el: HTMLElement | null = buttonRef.current;
while (el && el !== containerRef.current) {
top += el.offsetTop;
left += el.offsetLeft;
el = el.offsetParent as HTMLElement;
}
const buttonHeight = buttonRef.current.offsetHeight;
const buttonWidth = buttonRef.current.offsetWidth;
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
const spaceBelow = containerHeight - (top + buttonHeight) - 10;
const spaceAbove = top - 10;
const estimatedMenuHeight = 450;
const actualMenuHeight = menuRef.current?.offsetHeight || estimatedMenuHeight;
const openUpward = spaceBelow < Math.min(actualMenuHeight, 300) && spaceAbove > spaceBelow;
const maxHeight = openUpward
? Math.min(spaceAbove, actualMenuHeight)
: Math.min(spaceBelow, containerHeight * 0.7);
const isLeftHalf = left < containerWidth / 2;
const align = isLeftHalf ? 'left' : 'right';
setMenuPosition({
top: openUpward
? top - 10
: top + buttonHeight + 10,
left: isLeftHalf ? left : left + buttonWidth,
maxHeight: `${maxHeight}px`,
openUpward: openUpward,
align: align
});
} else { } else {
// Rotated Mode: Use Container Coordinates (offset loop) and Portal to Container // Rotated Mode: Use Container Coordinates (offset loop) and Portal to Container
let top = 0; let top = 0;
@@ -185,7 +226,7 @@ export function DesktopMoreMenu({
align: align align: align
}); });
} }
}, [containerRef, isRotated]); }, [containerRef, isRotated, isFullscreen]);
@@ -583,7 +624,7 @@ export function DesktopMoreMenu({
{/* More Menu Dropdown (Portal) */} {/* More Menu Dropdown (Portal) */}
{/* More Menu Dropdown (Portal) */} {/* More Menu Dropdown (Portal) */}
{showMoreMenu && typeof document !== 'undefined' && createPortal(MenuContent, (isRotated && containerRef.current) ? containerRef.current : document.body)} {showMoreMenu && typeof document !== 'undefined' && createPortal(MenuContent, ((isRotated || isFullscreen) && containerRef.current) ? containerRef.current : document.body)}
</div> </div>
); );
} }
+45 -5
View File
@@ -51,9 +51,9 @@ export function DesktopSpeedMenu({
const calculateMenuPosition = React.useCallback(() => { const calculateMenuPosition = React.useCallback(() => {
if (!buttonRef.current || !containerRef.current) return; if (!buttonRef.current || !containerRef.current) return;
if (!isRotated) { if (!isRotated && !isFullscreen) {
// Normal Mode: Non-rotated // Normal Mode: Non-rotated, non-fullscreen
// Use Viewport Coordinates but position relative to button (User Request: "Below button") // Use Viewport Coordinates but position relative to button
// And use Body Portal to escape container clipping // And use Body Portal to escape container clipping
const buttonRect = buttonRef.current.getBoundingClientRect(); const buttonRect = buttonRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -94,6 +94,46 @@ export function DesktopSpeedMenu({
openUpward: openUpward, openUpward: openUpward,
align: align align: align
}); });
} else if (isFullscreen && !isRotated) {
// Fullscreen Mode (not rotated): Use container-relative coordinates
let top = 0;
let left = 0;
let el: HTMLElement | null = buttonRef.current;
while (el && el !== containerRef.current) {
top += el.offsetTop;
left += el.offsetLeft;
el = el.offsetParent as HTMLElement;
}
const buttonHeight = buttonRef.current.offsetHeight;
const buttonWidth = buttonRef.current.offsetWidth;
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
const spaceBelow = containerHeight - (top + buttonHeight) - 10;
const spaceAbove = top - 10;
const estimatedMenuHeight = 250;
const actualMenuHeight = menuRef.current?.offsetHeight || estimatedMenuHeight;
const openUpward = spaceBelow < Math.min(actualMenuHeight, 200) && spaceAbove > spaceBelow;
const maxHeight = openUpward
? Math.min(spaceAbove, actualMenuHeight)
: Math.min(spaceBelow, containerHeight * 0.7);
const isLeftHalf = left < containerWidth / 2;
const align = isLeftHalf ? 'left' : 'right';
setMenuPosition({
top: openUpward
? top - 10
: top + buttonHeight + 10,
left: isLeftHalf ? left : left + buttonWidth,
maxHeight: `${maxHeight}px`,
openUpward: openUpward,
align: align
});
} else { } else {
// Rotated Mode: Fullscreen/Landscape forced // Rotated Mode: Fullscreen/Landscape forced
// Use Container Coordinates (offset loop) and Portal to Container // Use Container Coordinates (offset loop) and Portal to Container
@@ -146,7 +186,7 @@ export function DesktopSpeedMenu({
align: align align: align
}); });
} }
}, [containerRef, isRotated]); }, [containerRef, isRotated, isFullscreen]);
@@ -255,7 +295,7 @@ export function DesktopSpeedMenu({
So portaling to containerRef is SAFE and CORRECT. So portaling to containerRef is SAFE and CORRECT.
*/} */}
{/* Speed Menu (Portal) */} {/* Speed Menu (Portal) */}
{showSpeedMenu && typeof document !== 'undefined' && createPortal(MenuContent, (isRotated && containerRef.current) ? containerRef.current : document.body)} {showSpeedMenu && typeof document !== 'undefined' && createPortal(MenuContent, ((isRotated || isFullscreen) && containerRef.current) ? containerRef.current : document.body)}
</div> </div>
); );
} }
+108 -26
View File
@@ -24,6 +24,8 @@ export function AccountSettings() {
const [showConfigGen, setShowConfigGen] = useState(false); const [showConfigGen, setShowConfigGen] = useState(false);
const [configEntries, setConfigEntries] = useState<ConfigEntry[]>([]); const [configEntries, setConfigEntries] = useState<ConfigEntry[]>([]);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [removedAccounts, setRemovedAccounts] = useState<Set<number>>(new Set());
const [hasAdminPassword, setHasAdminPassword] = useState(false);
useEffect(() => { useEffect(() => {
setSessionState(getSession()); setSessionState(getSession());
@@ -38,6 +40,7 @@ export function AccountSettings() {
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.accounts) setAccounts(data.accounts); if (data.accounts) setAccounts(data.accounts);
if (data.hasAdminPassword) setHasAdminPassword(data.hasAdminPassword);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -79,6 +82,33 @@ export function AccountSettings() {
}); });
}; };
// Load existing accounts into config generator (without passwords)
const loadExistingAccounts = () => {
// Filter out removed accounts and the standalone admin password account
const existingEntries: ConfigEntry[] = accounts
.filter((_, i) => !removedAccounts.has(i))
.filter(a => !(a.name === '管理员' && hasAdminPassword))
.map(a => ({
password: '',
name: a.name,
role: a.role,
}));
setConfigEntries(existingEntries);
setShowConfigGen(true);
};
// Remove account from visible list and track removal
const handleRemoveAccount = (index: number) => {
setRemovedAccounts(prev => {
const next = new Set(prev);
next.add(index);
return next;
});
};
// Get visible accounts (excluding removed ones)
const visibleAccounts = accounts.filter((_, i) => !removedAccounts.has(i));
if (!hasAuth && !session) return null; if (!hasAuth && !session) return null;
return ( return (
@@ -115,34 +145,69 @@ export function AccountSettings() {
)} )}
{/* Account List (Admin only) */} {/* Account List (Admin only) */}
{isAdmin && accounts.length > 0 && ( {isAdmin && visibleAccounts.length > 0 && (
<div> <div>
<h3 className="text-sm font-medium text-[var(--text-color)] mb-3 flex items-center gap-2"> <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)]" /> <Icons.Users size={16} className="text-[var(--accent-color)]" />
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{accounts.map((account, index) => ( {accounts.map((account, index) => {
<div if (removedAccounts.has(index)) return null;
key={index} return (
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
> key={index}
<div className="flex items-center gap-3"> 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="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 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>
<div className="flex items-center gap-2">
<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>
<button
onClick={() => handleRemoveAccount(index)}
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
title="移除账户"
>
<Icons.Trash size={14} />
</button>
</div> </div>
<span className="text-sm text-[var(--text-color)]">{account.name}</span>
</div> </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>
{/* Notice when accounts have been removed */}
{removedAccounts.size > 0 && (
<div className="mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-[var(--radius-2xl)]">
<p className="text-xs text-amber-400">
{removedAccounts.size} 使 <code className="px-1 py-0.5 bg-black/20 rounded text-[10px]">ACCOUNTS</code>
</p>
<div className="flex gap-2 mt-2">
<button
onClick={loadExistingAccounts}
className="text-xs px-3 py-1 bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 rounded-[var(--radius-2xl)] transition-colors cursor-pointer"
>
</button>
<button
onClick={() => setRemovedAccounts(new Set())}
className="text-xs px-3 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-[var(--text-color)] rounded-[var(--radius-2xl)] transition-colors cursor-pointer"
>
</button>
</div>
</div>
)}
</div> </div>
)} )}
@@ -154,18 +219,33 @@ export function AccountSettings() {
<Icons.Settings size={16} className="text-[var(--accent-color)]" /> <Icons.Settings size={16} className="text-[var(--accent-color)]" />
</h3> </h3>
<button <div className="flex items-center gap-2">
onClick={() => setShowConfigGen(!showConfigGen)} {!showConfigGen && accounts.length > 0 && (
className="text-xs text-[var(--accent-color)] hover:underline cursor-pointer" <button
> onClick={loadExistingAccounts}
{showConfigGen ? '收起' : '展开'} className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors cursor-pointer"
</button> >
</button>
)}
<button
onClick={() => setShowConfigGen(!showConfigGen)}
className="text-xs text-[var(--accent-color)] hover:underline cursor-pointer"
>
{showConfigGen ? '收起' : '展开'}
</button>
</div>
</div> </div>
{showConfigGen && ( {showConfigGen && (
<div className="space-y-4 p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"> <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)]"> <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> <code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code>
{configEntries.some(e => !e.password && e.name) && (
<span className="text-amber-400 block mt-1">
</span>
)}
</p> </p>
{/* Entry List */} {/* Entry List */}
@@ -178,7 +258,9 @@ export function AccountSettings() {
placeholder="密码" placeholder="密码"
value={entry.password} value={entry.password}
onChange={(e) => updateConfigEntry(index, 'password', e.target.value)} 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)]" className={`flex-1 px-3 py-1.5 bg-[var(--glass-bg)] 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)] ${
!entry.password && entry.name ? 'border-amber-500/50' : 'border-[var(--glass-border)]'
}`}
/> />
<input <input
type="text" type="text"
+6 -6
View File
@@ -1,19 +1,19 @@
{ {
"name": "kvideo", "name": "kvideo",
"version": "4.3.6", "version": "4.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "kvideo", "name": "kvideo",
"version": "4.3.6", "version": "4.3.7",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"lucide-react": "^0.568.0", "lucide-react": "^0.570.0",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -7230,9 +7230,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.568.0", "version": "0.570.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.568.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.570.0.tgz",
"integrity": "sha512-uiPQfBwb8uiNUFFbVolgtnX9yjZPs4I7fM/RboKHSVfiN7d/59sx3FfKvE9i+2yCJCBs5Sx1+pfVmKcTM5Og1Q==", "integrity": "sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "kvideo", "name": "kvideo",
"version": "4.3.6", "version": "4.3.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -15,7 +15,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"lucide-react": "^0.568.0", "lucide-react": "^0.570.0",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",