mirror of
https://github.com/vndangkhoa/purestream.git
synced 2026-04-05 01:17:58 +07:00
Updated VideoPlayer.tsx
This commit is contained in:
parent
dcf30ae3a5
commit
13841f479e
5 changed files with 510 additions and 109 deletions
|
|
@ -4,6 +4,8 @@ import type { Video, UserProfile } from '../types';
|
|||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config';
|
||||
import { Home, Users, Search, X, Plus } from 'lucide-react';
|
||||
import { videoPrefetcher } from '../utils/videoPrefetch';
|
||||
import { feedLoader } from '../utils/feedLoader';
|
||||
|
||||
type ViewState = 'login' | 'loading' | 'feed';
|
||||
type TabType = 'foryou' | 'following' | 'search';
|
||||
|
|
@ -196,6 +198,17 @@ export const Feed: React.FC = () => {
|
|||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeTab, viewState]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefetch = async () => {
|
||||
await videoPrefetcher.init();
|
||||
if (activeTab === 'foryou') {
|
||||
videoPrefetcher.prefetchNext(videos, currentIndex);
|
||||
}
|
||||
};
|
||||
|
||||
prefetch();
|
||||
}, [currentIndex, videos, activeTab]);
|
||||
|
||||
const loadSuggestedProfiles = async () => {
|
||||
setLoadingProfiles(true);
|
||||
try {
|
||||
|
|
@ -313,90 +326,24 @@ export const Feed: React.FC = () => {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// Stage 1: Fast Load (0 scrolls, roughly 5-10 videos)
|
||||
const fastRes = await axios.get(`${API_BASE_URL}/feed?fast=true`);
|
||||
|
||||
let initialVideos: Video[] = [];
|
||||
|
||||
if (Array.isArray(fastRes.data) && fastRes.data.length > 0) {
|
||||
initialVideos = fastRes.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || '',
|
||||
thumbnail: v.thumbnail,
|
||||
cdn_url: v.cdn_url,
|
||||
views: v.views,
|
||||
likes: v.likes
|
||||
}));
|
||||
setVideos(initialVideos);
|
||||
setViewState('feed');
|
||||
}
|
||||
|
||||
// Stage 2: Background Load (Full batch)
|
||||
// Silent fetch to get more videos without blocking UI
|
||||
// We only do this if we got some videos initially, OR if initial failed
|
||||
axios.get(`${API_BASE_URL}/feed`).then(res => {
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
const moreVideos = res.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-full-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || '',
|
||||
thumbnail: v.thumbnail,
|
||||
cdn_url: v.cdn_url,
|
||||
views: v.views,
|
||||
likes: v.likes
|
||||
}));
|
||||
|
||||
// Deduplicate and append
|
||||
setVideos(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id));
|
||||
const distinctNew = moreVideos.filter((v: Video) => !existingIds.has(v.id));
|
||||
return [...prev, ...distinctNew];
|
||||
});
|
||||
|
||||
// If we were in login/error state, switch to feed now
|
||||
setViewState(prev => prev === 'feed' ? 'feed' : 'feed');
|
||||
const videos = await feedLoader.loadFeedWithOptimization(
|
||||
false,
|
||||
(loaded: Video[]) => {
|
||||
if (loaded.length > 0) {
|
||||
setVideos(loaded);
|
||||
setViewState('feed');
|
||||
}
|
||||
}
|
||||
}).catch(console.error); // Silent error for background fetch
|
||||
);
|
||||
|
||||
if (initialVideos.length === 0) {
|
||||
// If fast fetch failed to get videos, we wait for background...
|
||||
// But simplified: show 'No videos' only if fast returned empty
|
||||
// The background fetch will update UI if it finds something
|
||||
if (!initialVideos.length) {
|
||||
// Keep loading state until background finishes?
|
||||
// Or show error? For now, let's just let the user wait or see empty
|
||||
// Ideally we'd have a 'fetching more' indicator
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Fast feed failed', err);
|
||||
// Fallback to full fetch if fast fails
|
||||
axios.get(`${API_BASE_URL}/feed`).then(res => {
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
const mapped = res.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-fallback-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || '',
|
||||
thumbnail: v.thumbnail,
|
||||
cdn_url: v.cdn_url,
|
||||
views: v.views,
|
||||
likes: v.likes
|
||||
}));
|
||||
setVideos(mapped);
|
||||
setViewState('feed');
|
||||
} else {
|
||||
setError('No videos found.');
|
||||
setViewState('login');
|
||||
}
|
||||
}).catch(e => {
|
||||
setError(e.response?.data?.detail || 'Failed to load feed');
|
||||
if (videos.length === 0) {
|
||||
setError('No videos found.');
|
||||
setViewState('login');
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Feed load failed:', err);
|
||||
setError(err.response?.data?.detail || 'Failed to load feed');
|
||||
setViewState('login');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -424,25 +371,14 @@ export const Feed: React.FC = () => {
|
|||
setIsFetching(true);
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE_URL}/feed`);
|
||||
const newVideos = await feedLoader.loadFeedWithOptimization(false);
|
||||
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
const newVideos = res.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-new-${Date.now()}-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || ''
|
||||
}));
|
||||
|
||||
setVideos(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id));
|
||||
const unique = newVideos.filter((v: any) => !existingIds.has(v.id));
|
||||
if (unique.length === 0) setHasMore(false);
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
setVideos(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id));
|
||||
const unique = newVideos.filter((v: Video) => !existingIds.has(v.id));
|
||||
if (unique.length === 0) setHasMore(false);
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load more:', err);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react';
|
|||
import { Download, UserPlus, Check, Volume2, VolumeX } from 'lucide-react';
|
||||
import type { Video } from '../types';
|
||||
import { API_BASE_URL } from '../config';
|
||||
import { videoCache } from '../utils/videoCache';
|
||||
|
||||
interface HeartParticle {
|
||||
id: number;
|
||||
|
|
@ -37,21 +38,17 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const [useFallback, setUseFallback] = useState(false); // Fallback to full proxy
|
||||
// Use external mute state if provided, otherwise use local state for backward compatibility
|
||||
const [useFallback, setUseFallback] = useState(false);
|
||||
const [localMuted, setLocalMuted] = useState(true);
|
||||
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
||||
const [hearts, setHearts] = useState<HeartParticle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
// Full proxy URL (yt-dlp, always works but heavier)
|
||||
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
|
||||
// Thin proxy URL (direct CDN stream, lighter)
|
||||
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
|
||||
|
||||
// Use thin proxy first, fallback to full if needed (or if no cdn_url)
|
||||
const proxyUrl = (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl;
|
||||
const proxyUrl = cachedUrl ? cachedUrl : (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl;
|
||||
const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -109,7 +106,18 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
// Reset fallback and loading state when video changes
|
||||
useEffect(() => {
|
||||
setUseFallback(false);
|
||||
setIsLoading(true); // Show loading for new video
|
||||
setIsLoading(true);
|
||||
setCachedUrl(null);
|
||||
|
||||
const checkCache = async () => {
|
||||
const cached = await videoCache.get(video.url);
|
||||
if (cached) {
|
||||
const blob_url = URL.createObjectURL(cached);
|
||||
setCachedUrl(blob_url);
|
||||
}
|
||||
};
|
||||
|
||||
checkCache();
|
||||
}, [video.id]);
|
||||
|
||||
// Progress tracking
|
||||
|
|
@ -142,7 +150,27 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
}, [thinProxyUrl, useFallback]);
|
||||
}, [thinProxyUrl, useFallback, cachedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const cacheVideo = async () => {
|
||||
if (!cachedUrl || !proxyUrl || proxyUrl === cachedUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(proxyUrl);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
await videoCache.set(video.url, blob);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to cache video:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isActive && !isLoading) {
|
||||
cacheVideo();
|
||||
}
|
||||
}, [isActive, isLoading, proxyUrl, cachedUrl, video.url]);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
|
|
|||
135
frontend/src/utils/feedLoader.ts
Normal file
135
frontend/src/utils/feedLoader.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import axios from 'axios';
|
||||
import type { Video } from '../types';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
interface FeedStats {
|
||||
totalLoaded: number;
|
||||
loadTime: number;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
class FeedLoader {
|
||||
private stats: FeedStats = {
|
||||
totalLoaded: 0,
|
||||
loadTime: 0,
|
||||
batchSize: 12
|
||||
};
|
||||
|
||||
private requestCache: Map<string, { data: Video[]; timestamp: number }> = new Map();
|
||||
private CACHE_TTL_MS = 60000;
|
||||
|
||||
async loadFeedWithOptimization(
|
||||
fast: boolean = false,
|
||||
onProgress?: (videos: Video[]) => void
|
||||
): Promise<Video[]> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
if (fast) {
|
||||
const videos = await this.loadWithCache('feed-fast');
|
||||
onProgress?.(videos);
|
||||
return videos;
|
||||
}
|
||||
|
||||
const cacheKey = 'feed-full';
|
||||
const cached = this.getCached(cacheKey);
|
||||
if (cached) {
|
||||
onProgress?.(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const videos = await this.fetchFeed();
|
||||
this.setCached(cacheKey, videos);
|
||||
|
||||
onProgress?.(videos);
|
||||
|
||||
this.stats.loadTime = performance.now() - startTime;
|
||||
this.stats.totalLoaded = videos.length;
|
||||
|
||||
return videos;
|
||||
} catch (error) {
|
||||
console.error('Feed load failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFeed(): Promise<Video[]> {
|
||||
const response = await axios.get(`${API_BASE_URL}/feed`);
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || '',
|
||||
thumbnail: v.thumbnail,
|
||||
cdn_url: v.cdn_url,
|
||||
views: v.views,
|
||||
likes: v.likes
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadWithCache(key: string): Promise<Video[]> {
|
||||
const cached = this.getCached(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const videos = await this.fetchFeed();
|
||||
this.setCached(key, videos);
|
||||
return videos;
|
||||
}
|
||||
|
||||
private getCached(key: string): Video[] | null {
|
||||
const cached = this.requestCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setCached(key: string, data: Video[]): void {
|
||||
this.requestCache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): FeedStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.requestCache.clear();
|
||||
}
|
||||
|
||||
getOptimalBatchSize(): number {
|
||||
const connection = (navigator as any).connection;
|
||||
|
||||
if (!connection) {
|
||||
return 15;
|
||||
}
|
||||
|
||||
const effectiveType = connection.effectiveType;
|
||||
|
||||
switch (effectiveType) {
|
||||
case '4g':
|
||||
return 20;
|
||||
case '3g':
|
||||
return 12;
|
||||
case '2g':
|
||||
return 6;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
|
||||
shouldPrefetchThumbnails(): boolean {
|
||||
const connection = (navigator as any).connection;
|
||||
if (!connection) return true;
|
||||
return connection.saveData !== true;
|
||||
}
|
||||
}
|
||||
|
||||
export const feedLoader = new FeedLoader();
|
||||
207
frontend/src/utils/videoCache.ts
Normal file
207
frontend/src/utils/videoCache.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
interface CachedVideo {
|
||||
id: string;
|
||||
url: string;
|
||||
data: Blob;
|
||||
timestamp: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const DB_NAME = 'PureStreamCache';
|
||||
const STORE_NAME = 'videos';
|
||||
const MAX_CACHE_SIZE_MB = 200;
|
||||
const CACHE_TTL_HOURS = 24;
|
||||
|
||||
class VideoCache {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initialized = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.initialized = true;
|
||||
this.cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async get(url: string): Promise<Blob | null> {
|
||||
if (!this.db) await this.init();
|
||||
if (!this.db) return null;
|
||||
|
||||
const videoId = this.getVideoId(url);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(videoId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedVideo | undefined;
|
||||
if (cached) {
|
||||
const ageHours = (Date.now() - cached.timestamp) / (1000 * 60 * 60);
|
||||
if (ageHours < CACHE_TTL_HOURS) {
|
||||
resolve(cached.data);
|
||||
return;
|
||||
}
|
||||
this.delete(videoId);
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
request.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async set(url: string, blob: Blob): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
if (!this.db) return;
|
||||
|
||||
const videoId = this.getVideoId(url);
|
||||
const cached: CachedVideo = {
|
||||
id: videoId,
|
||||
url,
|
||||
data: blob,
|
||||
timestamp: Date.now(),
|
||||
size: blob.size
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(cached);
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(videoId: string): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(videoId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ size_mb: number; count: number }> {
|
||||
if (!this.db) return { size_mb: 0, count: 0 };
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedVideo[];
|
||||
const totalSize = cached.reduce((sum, v) => sum + v.size, 0);
|
||||
resolve({
|
||||
size_mb: Math.round(totalSize / 1024 / 1024 * 100) / 100,
|
||||
count: cached.length
|
||||
});
|
||||
};
|
||||
|
||||
request.onerror = () => resolve({ size_mb: 0, count: 0 });
|
||||
} catch {
|
||||
resolve({ size_mb: 0, count: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
const stats = await this.getStats();
|
||||
if (stats.size_mb < MAX_CACHE_SIZE_MB) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = (request.result as CachedVideo[]).sort(
|
||||
(a, b) => a.timestamp - b.timestamp
|
||||
);
|
||||
|
||||
let totalSize = cached.reduce((sum, v) => sum + v.size, 0);
|
||||
const targetSize = MAX_CACHE_SIZE_MB * 1024 * 1024 * 0.8;
|
||||
|
||||
for (const video of cached) {
|
||||
if (totalSize <= targetSize) break;
|
||||
const deleteReq = store.delete(video.id);
|
||||
deleteReq.onsuccess = () => {
|
||||
totalSize -= video.size;
|
||||
};
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getVideoId(url: string): string {
|
||||
const match = url.match(/video\/(\d+)|id=([^&]+)/);
|
||||
return match ? match[1] || match[2] : url.substring(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
export const videoCache = new VideoCache();
|
||||
95
frontend/src/utils/videoPrefetch.ts
Normal file
95
frontend/src/utils/videoPrefetch.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { videoCache } from './videoCache';
|
||||
import type { Video } from '../types';
|
||||
|
||||
interface PrefetchConfig {
|
||||
lookahead: number;
|
||||
concurrency: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PrefetchConfig = {
|
||||
lookahead: 2,
|
||||
concurrency: 1,
|
||||
timeoutMs: 30000
|
||||
};
|
||||
|
||||
class VideoPrefetcher {
|
||||
private prefetchQueue: Set<string> = new Set();
|
||||
private config: PrefetchConfig;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: Partial<PrefetchConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
await videoCache.init();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async prefetchNext(
|
||||
videos: Video[],
|
||||
currentIndex: number
|
||||
): Promise<void> {
|
||||
if (!this.isInitialized) await this.init();
|
||||
|
||||
const endIndex = Math.min(
|
||||
currentIndex + this.config.lookahead,
|
||||
videos.length
|
||||
);
|
||||
|
||||
const toPrefetch = videos
|
||||
.slice(currentIndex + 1, endIndex)
|
||||
.filter((v) => v.url && !this.prefetchQueue.has(v.id));
|
||||
|
||||
for (const video of toPrefetch) {
|
||||
this.prefetchQueue.add(video.id);
|
||||
this.prefetchVideo(video).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
private async prefetchVideo(video: Video): Promise<void> {
|
||||
if (!video.url) return;
|
||||
|
||||
const cached = await videoCache.get(video.url);
|
||||
if (cached) {
|
||||
this.prefetchQueue.delete(video.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.config.timeoutMs
|
||||
);
|
||||
|
||||
const response = await fetch(video.url, {
|
||||
signal: controller.signal,
|
||||
headers: { Range: 'bytes=0-1048576' }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
await videoCache.set(video.url, blob);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Prefetch failed for ${video.id}:`, error);
|
||||
} finally {
|
||||
this.prefetchQueue.delete(video.id);
|
||||
}
|
||||
}
|
||||
|
||||
clearQueue(): void {
|
||||
this.prefetchQueue.clear();
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.prefetchQueue.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const videoPrefetcher = new VideoPrefetcher();
|
||||
Loading…
Reference in a new issue