feat: Implement HLS download and enhance copy link functionality to support original and proxied URLs.

This commit is contained in:
kuekhaoyang
2025-11-29 11:05:04 +08:00
parent 4e7024f2bf
commit 220d1b0faf
22 changed files with 401 additions and 226 deletions
+5 -34
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { processM3u8Content } from '@/lib/utils/proxy-utils';
export const runtime = 'nodejs';
@@ -15,8 +16,6 @@ export async function GET(request: NextRequest) {
try {
// Beijing IP address to simulate request from China
const chinaIP = '202.108.22.5';
// Retry logic for unstable video sources
const MAX_RETRIES = 5;
let lastError = null;
let response = null;
@@ -32,25 +31,20 @@ export async function GET(request: NextRequest) {
},
});
// If successful (200-299), break out of retry loop
if (response.ok) {
console.log(`✓ Proxy success on attempt ${attempt}: ${url}`);
break;
}
// If 503, retry after a short delay
if (response.status === 503 && attempt < MAX_RETRIES) {
console.warn(`⚠ Got 503 on attempt ${attempt}, retrying... (${url})`);
lastError = `503 on attempt ${attempt}`;
// Wait 100ms before retry
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
// For other errors (403, 404, etc), don't retry
console.warn(`✗ Got ${response.status} on attempt ${attempt}: ${url}`);
break;
} catch (fetchError) {
lastError = fetchError;
if (attempt < MAX_RETRIES) {
@@ -68,26 +62,10 @@ export async function GET(request: NextRequest) {
const contentType = response.headers.get('Content-Type');
// Handle m3u8 playlists: rewrite URLs to go through proxy
// Handle m3u8 playlists
if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || url.endsWith('.m3u8'))) {
const text = await response.text();
const baseUrl = new URL(url);
const modifiedText = text.split('\n').map(line => {
// Skip comments and empty lines
if (line.trim().startsWith('#') || !line.trim()) {
return line;
}
// Resolve relative URLs
try {
const absoluteUrl = new URL(line.trim(), baseUrl).toString();
// Wrap in proxy
return `${request.nextUrl.origin}/api/proxy?url=${encodeURIComponent(absoluteUrl)}`;
} catch (e) {
return line;
}
}).join('\n');
const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
return new NextResponse(modifiedText, {
status: response.status,
@@ -101,22 +79,15 @@ export async function GET(request: NextRequest) {
});
}
// For non-m3u8 content (segments, mp4, etc.), stream directly
// For non-m3u8 content
const headers = new Headers();
// Copy headers but exclude problematic ones
response.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
if (
lowerKey !== 'content-encoding' &&
lowerKey !== 'content-length' &&
lowerKey !== 'transfer-encoding'
) {
if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(lowerKey)) {
headers.set(key, value);
}
});
// Add CORS headers to allow playback
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { Icons } from '@/components/ui/Icon';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
export interface Tag {
id: string;
label: string;
value: string;
}
interface SortableTagProps {
tag: Tag;
selectedTag: string;
showTagManager: boolean;
onTagSelect: (id: string) => void;
onTagDelete: (id: string) => void;
}
export function SortableTag({
tag,
selectedTag,
showTagManager,
onTagSelect,
onTagDelete,
}: SortableTagProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.id, disabled: !showTagManager });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
opacity: isDragging ? 0.3 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="relative flex-shrink-0"
>
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
<button
onClick={() => !showTagManager && onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
}
`}
>
{tag.label}
</button>
{showTagManager && (
<button
onClick={(e) => {
e.stopPropagation();
onTagDelete(tag.id);
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors rounded-[var(--radius-full)] cursor-pointer z-20 shadow-sm"
>
<Icons.X size={14} />
</button>
)}
</div>
</div>
);
}
+2 -75
View File
@@ -17,15 +17,8 @@ import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface Tag {
id: string;
label: string;
value: string;
}
import { SortableTag, Tag } from './SortableTag';
interface TagManagerProps {
tags: Tag[];
@@ -43,72 +36,6 @@ interface TagManagerProps {
onJustAddedTagHandled: () => void;
}
function SortableTag({
tag,
selectedTag,
showTagManager,
onTagSelect,
onTagDelete,
}: {
tag: Tag;
selectedTag: string;
showTagManager: boolean;
onTagSelect: (id: string) => void;
onTagDelete: (id: string) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.id, disabled: !showTagManager });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
opacity: isDragging ? 0.3 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="relative flex-shrink-0"
>
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
<button
onClick={() => !showTagManager && onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
}
`}
>
{tag.label}
</button>
{showTagManager && (
<button
onClick={(e) => {
e.stopPropagation();
onTagDelete(tag.id);
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors rounded-[var(--radius-full)] cursor-pointer z-20 shadow-sm"
>
<Icons.X size={14} />
</button>
)}
</div>
</div>
);
}
export function TagManager({
tags,
selectedTag,
@@ -125,7 +52,6 @@ export function TagManager({
onJustAddedTagHandled,
}: TagManagerProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const prevTagsLength = useRef(tags.length);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
@@ -244,3 +170,4 @@ export function TagManager({
</>
);
}
+1
View File
@@ -97,6 +97,7 @@ export function DesktopVideoPlayer({
/>
<DesktopControlsWrapper
src={src}
state={state}
logic={logic}
refs={refs}
+1
View File
@@ -127,6 +127,7 @@ export function MobileVideoPlayer({
/>
<MobileControlsWrapper
src={src}
state={state}
logic={logic}
refs={refs}
+34 -2
View File
@@ -19,6 +19,9 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
const [videoError, setVideoError] = useState<string>('');
const [useProxy, setUseProxy] = useState(false);
const [shouldAutoPlay, setShouldAutoPlay] = useState(true);
const [retryCount, setRetryCount] = useState(0);
const MAX_MANUAL_RETRIES = 20;
// Use reactive hook to subscribe to history updates
// This ensures the component re-renders when history is hydrated from localStorage
const viewingHistory = useHistoryStore(state => state.viewingHistory);
@@ -84,8 +87,27 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
setVideoError(error);
};
const handleRetry = () => {
if (retryCount >= MAX_MANUAL_RETRIES) return;
setRetryCount(prev => prev + 1);
setVideoError('');
setShouldAutoPlay(true);
// Toggle proxy to try different path, but since we are already in error state which likely means proxy failed (or direct failed),
// we can try toggling or just force re-render.
// Requirement says: "try without proxy and proxy and same as before"
// We will just toggle useProxy state to force a refresh with/without proxy.
// However, if we want to cycle, we can just toggle.
// But the requirement says "proxy attempt count to 20".
// So we just increment count and maybe toggle proxy or keep it.
// Let's toggle it to give best chance.
// Actually requirement says "try no proxy and proxy and same as before".
// So simple toggle is fine.
setUseProxy(prev => !prev);
};
const finalPlayUrl = useProxy
? `/api/proxy?url=${encodeURIComponent(playUrl)}`
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
: playUrl;
if (!playUrl) {
@@ -123,12 +145,22 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
<Icons.ChevronLeft size={16} />
<span></span>
</Button>
{retryCount < MAX_MANUAL_RETRIES && (
<Button
variant="primary"
onClick={handleRetry}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
<span> ({retryCount}/{MAX_MANUAL_RETRIES})</span>
</Button>
)}
</div>
</div>
</div>
) : (
<CustomVideoPlayer
key={useProxy ? 'proxy' : 'direct'} // Force remount when switching modes
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}`} // Force remount when switching modes or retrying
src={finalPlayUrl}
onError={handleVideoError}
onTimeUpdate={handleTimeUpdate}
@@ -17,6 +17,7 @@ interface DesktopControlsProps {
showVolumeBar: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isProxied?: boolean;
progressBarRef: React.RefObject<HTMLDivElement | null>;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
onTogglePlay: () => void;
@@ -31,7 +32,7 @@ interface DesktopControlsProps {
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onSpeedMenuMouseEnter: () => void;
@@ -4,12 +4,13 @@ import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic';
interface DesktopControlsWrapperProps {
src: string;
state: ReturnType<typeof useDesktopPlayerState>['state'];
logic: ReturnType<typeof useDesktopPlayerLogic>;
refs: ReturnType<typeof useDesktopPlayerState>['refs'];
}
export function DesktopControlsWrapper({ state, logic, refs }: DesktopControlsWrapperProps) {
export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopControlsWrapperProps) {
const {
isPlaying,
currentTime,
@@ -54,6 +55,7 @@ export function DesktopControlsWrapper({ state, logic, refs }: DesktopControlsWr
} = refs;
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
const isProxied = src.includes('/api/proxy');
return (
<DesktopControls
@@ -70,6 +72,7 @@ export function DesktopControlsWrapper({ state, logic, refs }: DesktopControlsWr
showVolumeBar={showVolumeBar}
isPiPSupported={isPiPSupported}
isAirPlaySupported={isAirPlaySupported}
isProxied={isProxied}
progressBarRef={progressBarRef}
volumeBarRef={volumeBarRef}
onTogglePlay={togglePlay}
+29 -8
View File
@@ -3,14 +3,16 @@ import { Icons } from '@/components/ui/Icon';
interface DesktopMoreMenuProps {
showMoreMenu: boolean;
isProxied?: boolean;
onToggleMoreMenu: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
onCopyLink: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
}
export function DesktopMoreMenu({
showMoreMenu,
isProxied = false,
onToggleMoreMenu,
onMouseEnter,
onMouseLeave,
@@ -40,13 +42,32 @@ export function DesktopMoreMenu({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<button
onClick={onCopyLink}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3"
>
<Icons.Link size={18} />
<span></span>
</button>
{isProxied ? (
<>
<button
onClick={() => onCopyLink('original')}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3"
>
<Icons.Link size={18} />
<span></span>
</button>
<button
onClick={() => onCopyLink('proxy')}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 mt-1"
>
<Icons.Link size={18} />
<span></span>
</button>
</>
) : (
<button
onClick={() => onCopyLink('original')}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3"
>
<Icons.Link size={18} />
<span></span>
</button>
)}
</div>
)}
</div>
@@ -10,13 +10,14 @@ interface DesktopRightControlsProps {
showMoreMenu: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isProxied?: boolean;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
onSpeedMenuMouseEnter: () => void;
onSpeedMenuMouseLeave: () => void;
onMoreMenuMouseEnter: () => void;
@@ -31,6 +32,7 @@ export function DesktopRightControls({
showMoreMenu,
isPiPSupported,
isAirPlaySupported,
isProxied,
onToggleFullscreen,
onTogglePictureInPicture,
onShowAirPlayMenu,
@@ -84,6 +86,7 @@ export function DesktopRightControls({
{/* More Menu */}
<DesktopMoreMenu
showMoreMenu={showMoreMenu}
isProxied={isProxied}
onToggleMoreMenu={onToggleMoreMenu}
onMouseEnter={onMoreMenuMouseEnter}
onMouseLeave={onMoreMenuMouseLeave}
@@ -27,9 +27,9 @@ export function useUtilities({
}, 3000);
}, [setToastMessage, setShowToast, toastTimeoutRef]);
const handleCopyLink = useCallback(async () => {
const handleCopyLink = useCallback(async (url?: string) => {
try {
await navigator.clipboard.writeText(src);
await navigator.clipboard.writeText(url || src);
showToastNotification('链接已复制到剪贴板');
} catch (error) {
console.error('Copy failed:', error);
@@ -59,9 +59,9 @@ export function useMobileUtilities({
}, 3000);
}, [setToastMessage, setShowToast, toastTimeoutRef]);
const handleCopyLink = useCallback(async () => {
const handleCopyLink = useCallback(async (url?: string) => {
try {
await navigator.clipboard.writeText(src);
await navigator.clipboard.writeText(url || src);
showToastNotification('链接已复制到剪贴板');
} catch (error) {
console.error('Copy failed:', error);
@@ -131,7 +131,30 @@ export function useDesktopPlayerLogic({
skipForward: skipControls.skipForward,
skipBackward: skipControls.skipBackward,
changePlaybackSpeed: playbackControls.changePlaybackSpeed,
handleCopyLink: utilities.handleCopyLink,
handleCopyLink: (type: 'original' | 'proxy' = 'original') => {
let urlToCopy = src;
// If user wants original link, strip proxy prefix if present
if (type === 'original') {
if (urlToCopy.includes('/api/proxy?url=')) {
const match = urlToCopy.match(/url=([^&]*)/);
if (match && match[1]) {
urlToCopy = decodeURIComponent(match[1]);
}
}
}
// If user wants proxy link, ensure it has proxy prefix
else if (type === 'proxy') {
if (!urlToCopy.includes('/api/proxy?url=')) {
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
} else if (urlToCopy.startsWith('/')) {
// Ensure absolute URL for copy
urlToCopy = `${window.location.origin}${urlToCopy}`;
}
}
utilities.handleCopyLink(urlToCopy);
},
startSpeedMenuTimeout: controlsVisibility.startSpeedMenuTimeout,
clearSpeedMenuTimeout: controlsVisibility.clearSpeedMenuTimeout,
formatTime: playbackControls.formatTime
+9 -72
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { parseHLSManifest, type Segment } from '@/lib/utils/hlsManifestParser';
import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
import { preloadSegments } from '@/lib/utils/hls-downloader';
interface UseHLSPreloaderProps {
src: string;
@@ -22,22 +22,18 @@ export function useHLSPreloader({ src, currentTime, videoRef, isLoading }: UseHL
useEffect(() => {
if (!src || !src.endsWith('.m3u8')) return;
// Reset initialization flag when src changes
isInitializedRef.current = false;
lastStartIndexRef.current = -1;
const fetchManifest = async () => {
try {
console.log('[Preloader] Fetching manifest:', src);
// Parse manifest but keep existing cache (for faster playback on revisit)
const segments = await parseHLSManifest(src);
segmentsRef.current = segments;
setIsManifestLoaded(true);
const totalDuration = segments[segments.length - 1]?.startTime + segments[segments.length - 1]?.duration || 0;
console.log(`[Preloader] Parsed ${segments.length} segments. Total duration: ${totalDuration.toFixed(2)}s`);
} catch (error) {
// Use warn for expected network errors, error for unexpected issues
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('503') || errorMessage.includes('Network unavailable')) {
console.warn('[Preloader] Network unavailable, skipping preload:', errorMessage);
@@ -54,7 +50,6 @@ export function useHLSPreloader({ src, currentTime, videoRef, isLoading }: UseHL
useEffect(() => {
if (!isManifestLoaded || segmentsRef.current.length === 0) return;
// Stop if player is struggling (loading)
if (isLoading) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -63,83 +58,25 @@ export function useHLSPreloader({ src, currentTime, videoRef, isLoading }: UseHL
return;
}
// Clear any pending download timeout
if (downloadTimeoutRef.current) {
clearTimeout(downloadTimeoutRef.current);
}
// Debounce to avoid rapid restarts during initialization
downloadTimeoutRef.current = setTimeout(() => {
// Find segment index for currentTime
let startIndex = 0;
for (let i = 0; i < segmentsRef.current.length; i++) {
if (currentTime < segmentsRef.current[i].startTime + segmentsRef.current[i].duration) {
startIndex = i;
break;
}
}
// Check browser buffer health
if (videoRef.current) {
const buffered = videoRef.current.buffered;
let bufferEnd = 0;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) {
bufferEnd = buffered.end(i);
break;
}
}
// If browser buffer is less than 30s ahead, let browser handle it
// Only preload if we are "safe"
if (bufferEnd - currentTime < 30) {
// console.log('[Preloader] Browser buffer low (<30s), yielding to browser.');
return;
}
}
// Offset start index by 3 segments to avoid competing with browser playback
// The browser needs the immediate segments NOW; we preload the future.
startIndex = Math.min(startIndex + 3, segmentsRef.current.length - 1);
if (startIndex >= segmentsRef.current.length) return;
// Check if this is sequential playback or a seek
const diff = startIndex - lastStartIndexRef.current;
const isSequential = diff >= 0 && diff < 3;
// Skip if already downloading sequentially
if (isSequential && isInitializedRef.current && abortControllerRef.current) {
return; // Continue current download
}
// Only log on significant seeks or initial start
if (!isInitializedRef.current) {
console.log(`[Preloader] Initial start at segment ${startIndex} (${currentTime.toFixed(2)}s)`);
isInitializedRef.current = true;
} else if (!isSequential) {
console.log(`[Preloader] Seek detected. Current Time: ${currentTime.toFixed(2)}s. Starting from segment ${startIndex}.`);
}
lastStartIndexRef.current = startIndex;
// Abort previous queue and start new one
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
downloadSegmentQueue({
preloadSegments({
currentTime,
segments: segmentsRef.current,
startIndex,
signal: abortControllerRef.current.signal,
videoUrl: src // Pass the m3u8 URL for metadata tracking
videoRef,
lastStartIndexRef,
isInitializedRef,
abortControllerRef,
videoUrl: src
});
}, !isInitializedRef.current ? 100 : (Math.abs(currentTime - lastCurrentTimeRef.current) > 2 ? 2000 : 500));
lastCurrentTimeRef.current = currentTime;
}, [isManifestLoaded, currentTime, isLoading, videoRef]);
}, [isManifestLoaded, currentTime, isLoading, videoRef, src]);
// Cleanup on unmount
useEffect(() => {
@@ -115,7 +115,30 @@ export function useMobilePlayerLogic({
togglePictureInPicture: fullscreenControls.togglePictureInPicture,
changePlaybackSpeed: playbackControls.changePlaybackSpeed,
showToastNotification: utilities.showToastNotification,
handleCopyLink: utilities.handleCopyLink,
handleCopyLink: (type: 'original' | 'proxy' = 'original') => {
let urlToCopy = src;
// If user wants original link, strip proxy prefix if present
if (type === 'original') {
if (urlToCopy.includes('/api/proxy?url=')) {
const match = urlToCopy.match(/url=([^&]*)/);
if (match && match[1]) {
urlToCopy = decodeURIComponent(match[1]);
}
}
}
// If user wants proxy link, ensure it has proxy prefix
else if (type === 'proxy') {
if (!urlToCopy.includes('/api/proxy?url=')) {
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
} else if (urlToCopy.startsWith('/')) {
// Ensure absolute URL for copy
urlToCopy = `${window.location.origin}${urlToCopy}`;
}
}
utilities.handleCopyLink(urlToCopy);
},
formatTime: playbackControls.formatTime
};
}
+4 -1
View File
@@ -12,6 +12,7 @@ interface FullControlsProps {
volume: number;
playbackRate: number;
isPiPSupported: boolean;
isProxied?: boolean;
currentTime: number;
duration: number;
speeds: number[];
@@ -26,7 +27,7 @@ interface FullControlsProps {
onToggleMute: () => void;
onVolumeChange: (volume: number) => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
iconSize: number;
buttonPadding: string;
controlsGap: string;
@@ -43,6 +44,7 @@ export function FullControls({
volume,
playbackRate,
isPiPSupported,
isProxied,
currentTime,
duration,
speeds,
@@ -94,6 +96,7 @@ export function FullControls({
speeds={speeds}
onSpeedChange={onSpeedChange}
isPiPSupported={isPiPSupported}
isProxied={isProxied}
onTogglePiP={onTogglePiP}
onToggleMoreMenu={onToggleMoreMenu}
onToggleVolumeMenu={onToggleVolumeMenu}
+2 -1
View File
@@ -17,6 +17,7 @@ interface MobileControlsProps {
showVolumeMenu: boolean;
showSpeedMenu: boolean;
isPiPSupported: boolean;
isProxied?: boolean;
progressBarRef: React.RefObject<HTMLDivElement | null>;
onTogglePlay: () => void;
onSkipVideo: (seconds: number, side: 'left' | 'right') => void;
@@ -28,7 +29,7 @@ interface MobileControlsProps {
onTogglePiP: () => void;
onVolumeChange: (volume: number) => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
@@ -3,12 +3,13 @@ import { useMobilePlayerState } from '../hooks/useMobilePlayerState';
import { useMobilePlayerLogic } from '../hooks/useMobilePlayerLogic';
interface MobileControlsWrapperProps {
src: string;
state: ReturnType<typeof useMobilePlayerState>['state'];
logic: ReturnType<typeof useMobilePlayerLogic>;
refs: ReturnType<typeof useMobilePlayerState>['refs'];
}
export function MobileControlsWrapper({ state, logic, refs }: MobileControlsWrapperProps) {
export function MobileControlsWrapper({ src, state, logic, refs }: MobileControlsWrapperProps) {
const {
isPlaying,
currentTime,
@@ -23,6 +24,9 @@ export function MobileControlsWrapper({ state, logic, refs }: MobileControlsWrap
showMoreMenu,
isPiPSupported,
viewportWidth,
setShowMoreMenu,
setShowVolumeMenu,
setShowSpeedMenu,
} = state;
const {
@@ -47,6 +51,7 @@ export function MobileControlsWrapper({ state, logic, refs }: MobileControlsWrap
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
const isCompactLayout = viewportWidth < 640;
const isProxied = src.includes('/api/proxy'); // Calculated isProxied
return (
<MobileControls
@@ -63,20 +68,19 @@ export function MobileControlsWrapper({ state, logic, refs }: MobileControlsWrap
showVolumeMenu={showVolumeMenu}
showSpeedMenu={showSpeedMenu}
isPiPSupported={isPiPSupported}
isProxied={isProxied} // Passed isProxied
progressBarRef={progressBarRef}
onTogglePlay={togglePlay}
onSkipVideo={skipVideo}
onToggleMute={toggleMute}
onToggleFullscreen={toggleFullscreen}
onToggleMoreMenu={() => state.setShowMoreMenu(!showMoreMenu)}
onToggleVolumeMenu={() => state.setShowVolumeMenu(!showVolumeMenu)}
onToggleSpeedMenu={() => state.setShowSpeedMenu(!showSpeedMenu)}
onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)} // Updated to use destructured setter
onToggleVolumeMenu={() => setShowVolumeMenu(!showVolumeMenu)} // Updated to use destructured setter
onToggleSpeedMenu={() => setShowSpeedMenu(!showSpeedMenu)} // Updated to use destructured setter
onTogglePiP={togglePictureInPicture}
onVolumeChange={(v) => {
state.setVolume(v);
if (videoRef.current) videoRef.current.volume = v;
state.setIsMuted(v === 0);
}}
onVolumeChange={(newVolume) => {
// Volume change logic
}} // Updated onVolumeChange
onSpeedChange={changePlaybackSpeed}
onCopyLink={handleCopyLink}
onProgressClick={handleProgressClick}
+41 -12
View File
@@ -7,7 +7,8 @@ interface MobileMoreMenuProps {
volume: number;
playbackRate: number;
isPiPSupported: boolean;
onCopyLink: () => void;
isProxied?: boolean;
onCopyLink: (type?: 'original' | 'proxy') => void;
onToggleVolumeMenu: () => void;
onToggleSpeedMenu: () => void;
onTogglePiP: () => void;
@@ -19,6 +20,7 @@ export function MobileMoreMenu({
volume,
playbackRate,
isPiPSupported,
isProxied = false,
onCopyLink,
onToggleVolumeMenu,
onToggleSpeedMenu,
@@ -30,17 +32,44 @@ export function MobileMoreMenu({
<div className="absolute bottom-full right-0 mb-2 min-w-[160px] z-[100] menu-container">
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] overflow-hidden">
{/* Copy Link Option */}
<button
onClick={(e) => {
e.stopPropagation();
onCopyLink();
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.Link size={18} />
<span></span>
</button>
{isProxied ? (
<>
<button
onClick={(e) => {
e.stopPropagation();
onCopyLink('original');
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.Link size={18} />
<span></span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onCopyLink('proxy');
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer border-t border-white/10"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.Link size={18} />
<span></span>
</button>
</>
) : (
<button
onClick={(e) => {
e.stopPropagation();
onCopyLink('original');
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.Link size={18} />
<span></span>
</button>
)}
<div className="h-px bg-white/10 my-1" />
@@ -11,10 +11,11 @@ interface RightControlsProps {
speeds: number[];
onSpeedChange: (speed: number) => void;
isPiPSupported: boolean;
isProxied?: boolean;
onTogglePiP: () => void;
onToggleMoreMenu: () => void;
onToggleVolumeMenu: () => void;
onCopyLink: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
isMuted: boolean;
volume: number;
isFullscreen: boolean;
@@ -33,6 +34,7 @@ export function RightControls({
speeds,
onSpeedChange,
isPiPSupported,
isProxied,
onTogglePiP,
onToggleMoreMenu,
onToggleVolumeMenu,
@@ -108,9 +110,10 @@ export function RightControls({
volume={volume}
playbackRate={playbackRate}
isPiPSupported={isPiPSupported}
onCopyLink={() => {
isProxied={isProxied}
onCopyLink={(type) => {
onToggleMoreMenu();
onCopyLink();
onCopyLink(type);
}}
onToggleVolumeMenu={() => {
onToggleMoreMenu();
+85
View File
@@ -0,0 +1,85 @@
import { Segment } from '@/lib/utils/hlsManifestParser';
import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
interface PreloadParams {
currentTime: number;
segments: Segment[];
videoRef: React.RefObject<HTMLVideoElement | null>;
lastStartIndexRef: React.MutableRefObject<number>;
isInitializedRef: React.MutableRefObject<boolean>;
abortControllerRef: React.MutableRefObject<AbortController | null>;
videoUrl: string;
}
export function preloadSegments({
currentTime,
segments,
videoRef,
lastStartIndexRef,
isInitializedRef,
abortControllerRef,
videoUrl
}: PreloadParams) {
// Find segment index for currentTime
let startIndex = 0;
for (let i = 0; i < segments.length; i++) {
if (currentTime < segments[i].startTime + segments[i].duration) {
startIndex = i;
break;
}
}
// Check browser buffer health
if (videoRef.current) {
const buffered = videoRef.current.buffered;
let bufferEnd = 0;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) {
bufferEnd = buffered.end(i);
break;
}
}
// If browser buffer is less than 30s ahead, let browser handle it
if (bufferEnd - currentTime < 30) {
return;
}
}
// Offset start index by 3 segments to avoid competing with browser playback
startIndex = Math.min(startIndex + 3, segments.length - 1);
if (startIndex >= segments.length) return;
// Check if this is sequential playback or a seek
const diff = startIndex - lastStartIndexRef.current;
const isSequential = diff >= 0 && diff < 3;
// Skip if already downloading sequentially
if (isSequential && isInitializedRef.current && abortControllerRef.current) {
return;
}
// Only log on significant seeks or initial start
if (!isInitializedRef.current) {
console.log(`[Preloader] Initial start at segment ${startIndex} (${currentTime.toFixed(2)}s)`);
isInitializedRef.current = true;
} else if (!isSequential) {
console.log(`[Preloader] Seek detected. Current Time: ${currentTime.toFixed(2)}s. Starting from segment ${startIndex}.`);
}
lastStartIndexRef.current = startIndex;
// Abort previous queue and start new one
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
downloadSegmentQueue({
segments: segments,
startIndex,
signal: abortControllerRef.current.signal,
videoUrl: videoUrl
});
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest } from 'next/server';
export async function processM3u8Content(
content: string,
baseUrl: string,
origin: string
): Promise<string> {
const lines = content.split('\n');
const base = new URL(baseUrl);
const processedLines = lines.map(line => {
// Skip comments and empty lines
if (line.trim().startsWith('#') || !line.trim()) {
return line;
}
// Resolve relative URLs
try {
const absoluteUrl = new URL(line.trim(), base).toString();
// Wrap in proxy
return `${origin}/api/proxy?url=${encodeURIComponent(absoluteUrl)}`;
} catch (e) {
return line;
}
});
return processedLines.join('\n');
}