mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement IPTV stream proxy to resolve CORS issues and refine desktop player menu positioning for fullscreen.
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+6
-6
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user