mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-18 02:03:43 +08:00
feat: Implement intelligent HLS preloading that respects player loading state and browser buffer health.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user