feat: Implement intelligent HLS preloading that respects player loading state and browser buffer health.

This commit is contained in:
kuekhaoyang
2025-11-24 22:15:57 +08:00
parent 3ee8bd24ec
commit 3843ecdd84
8 changed files with 64 additions and 18 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ export function DesktopVideoPlayer({
const { currentTime } = state;
// Preload HLS segments
useHLSPreloader({ src, currentTime });
useHLSPreloader({ src, currentTime, videoRef: refs.videoRef, isLoading: state.isLoading });
const {
videoRef,
+3 -2
View File
@@ -31,7 +31,7 @@ export function MobileVideoPlayer({
const { currentTime } = state;
// Preload HLS segments
useHLSPreloader({ src, currentTime });
useHLSPreloader({ src, currentTime, videoRef: refs.videoRef, isLoading: state.isLoading });
const {
videoRef,
@@ -97,7 +97,7 @@ export function MobileVideoPlayer({
{/* Video Element */}
<video
ref={videoRef}
className="w-full h-full object-contain"
className="w-full h-full object-contain touch-none"
src={src}
poster={poster}
onPlay={handlePlay}
@@ -108,6 +108,7 @@ export function MobileVideoPlayer({
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onTouchEnd={handleTap}
onClick={(e) => e.preventDefault()}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
interface UseProgressControlsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -15,6 +15,8 @@ export function useProgressControls({
setCurrentTime,
isDraggingProgressRef
}: UseProgressControlsProps) {
const lastDragTimeRef = useRef<number>(0);
const handleProgressClick = useCallback((e: any) => {
if (!videoRef.current || !progressBarRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
@@ -37,13 +39,16 @@ export function useProgressControls({
const rect = progressBarRef.current.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newTime = pos * duration;
videoRef.current.currentTime = newTime;
lastDragTimeRef.current = newTime;
setCurrentTime(newTime);
};
const handleMouseUp = () => {
if (isDraggingProgressRef.current) {
isDraggingProgressRef.current = false;
if (videoRef.current) {
videoRef.current.currentTime = lastDragTimeRef.current;
}
}
};
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { formatTime } from '@/lib/utils/format-utils';
import { usePlaybackPolling } from '../usePlaybackPolling';
import { useMobileTogglePlay } from './useMobileTogglePlay';
@@ -81,14 +81,20 @@ export function useMobilePlaybackControls({
});
}, [videoRef, setDuration, setIsLoading, initialTime]);
const hasInitialSeekHappened = useRef(false);
// Handle late initialization of initialTime (e.g. from async storage hydration)
useEffect(() => {
if (initialTime > 0 && videoRef.current) {
if (initialTime > 0 && videoRef.current && !hasInitialSeekHappened.current) {
// Only seek if we haven't progressed far (e.g. still near start)
// AND if the target time is significantly different from current time (> 0.5s)
// This prevents jumping if the user has already started watching and initialTime updates
if (videoRef.current.currentTime < 2 && Math.abs(videoRef.current.currentTime - initialTime) > 0.5) {
videoRef.current.currentTime = initialTime;
hasInitialSeekHappened.current = true;
} else if (videoRef.current.currentTime >= 2) {
// If user already watched past 2s, assume they don't want to be reset
hasInitialSeekHappened.current = true;
}
}
}, [initialTime, videoRef]);
@@ -47,11 +47,8 @@ export function useMobileProgressControls({
const newTime = updateProgressFromEvent(e);
if (newTime !== undefined) {
setCurrentTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
}
}
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime, videoRef]);
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime]);
const handleProgressTouchEnd = useCallback((e: any) => {
if (!isDraggingProgressRef.current) return;
+37 -4
View File
@@ -5,15 +5,18 @@ import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
interface UseHLSPreloaderProps {
src: string;
currentTime: number;
videoRef: React.RefObject<HTMLVideoElement | null>;
isLoading: boolean;
}
export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) {
export function useHLSPreloader({ src, currentTime, videoRef, isLoading }: UseHLSPreloaderProps) {
const abortControllerRef = useRef<AbortController | null>(null);
const segmentsRef = useRef<Segment[]>([]);
const [isManifestLoaded, setIsManifestLoaded] = useState(false);
const lastStartIndexRef = useRef<number>(-1);
const isInitializedRef = useRef(false);
const downloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastCurrentTimeRef = useRef<number>(0);
// Fetch and parse manifest when src changes
useEffect(() => {
@@ -51,6 +54,15 @@ export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) {
useEffect(() => {
if (!isManifestLoaded || segmentsRef.current.length === 0) return;
// Stop if player is struggling (loading)
if (isLoading) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
return;
}
// Clear any pending download timeout
if (downloadTimeoutRef.current) {
clearTimeout(downloadTimeoutRef.current);
@@ -67,6 +79,25 @@ export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) {
}
}
// 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);
@@ -104,9 +135,11 @@ export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) {
signal: abortControllerRef.current.signal,
videoUrl: src // Pass the m3u8 URL for metadata tracking
});
}, isInitializedRef.current ? 0 : 100); // 100ms debounce on initial load only
}, !isInitializedRef.current ? 100 : (Math.abs(currentTime - lastCurrentTimeRef.current) > 2 ? 2000 : 500));
}, [isManifestLoaded, currentTime]);
lastCurrentTimeRef.current = currentTime;
}, [isManifestLoaded, currentTime, isLoading, videoRef]);
// Cleanup on unmount
useEffect(() => {
@@ -116,4 +149,4 @@ export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) {
}
};
}, []);
}
}
+5
View File
@@ -6,6 +6,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { useHistoryStore } from '@/lib/store/history-store';
import { parseHLSManifest } from '@/lib/utils/hlsManifestParser';
import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
@@ -13,8 +14,12 @@ import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
export function useHistoryDownloader() {
const viewingHistory = useHistoryStore((state) => state.viewingHistory);
const processedUrlsRef = useRef<Set<string>>(new Set());
const pathname = usePathname();
useEffect(() => {
// Don't download history while watching a video
if (pathname?.startsWith('/player')) return;
if (viewingHistory.length === 0) return;
const downloadHistoryVideos = async () => {
+2 -3
View File
@@ -14,7 +14,7 @@ interface DownloadQueueOptions {
videoUrl?: string; // The m3u8 URL for metadata tracking
}
const CONCURRENCY = 5;
const CONCURRENCY = 2;
const TIMEOUT_MS = 15000;
const CACHE_NAME = 'video-cache-v1';
@@ -45,8 +45,7 @@ export async function downloadSegmentQueue(options: DownloadQueueOptions): Promi
const isValid = match ? await cacheManager.isCacheValid(url) : false;
if (match && isValid) {
onProgress?.(currentIndex, segments.length);
console.log(`[Preloader] 已缓存且有效: ${currentIndex}/${segments.length}`);
// Silently skip cached segments
} else {
// If cache exists but expired, delete it
if (match && !isValid) {