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 axios from 'axios';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
import { Home, Users, Search, X, Plus } from 'lucide-react';
|
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 ViewState = 'login' | 'loading' | 'feed';
|
||||||
type TabType = 'foryou' | 'following' | 'search';
|
type TabType = 'foryou' | 'following' | 'search';
|
||||||
|
|
@ -196,6 +198,17 @@ export const Feed: React.FC = () => {
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [activeTab, viewState]);
|
}, [activeTab, viewState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefetch = async () => {
|
||||||
|
await videoPrefetcher.init();
|
||||||
|
if (activeTab === 'foryou') {
|
||||||
|
videoPrefetcher.prefetchNext(videos, currentIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
prefetch();
|
||||||
|
}, [currentIndex, videos, activeTab]);
|
||||||
|
|
||||||
const loadSuggestedProfiles = async () => {
|
const loadSuggestedProfiles = async () => {
|
||||||
setLoadingProfiles(true);
|
setLoadingProfiles(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -313,90 +326,24 @@ export const Feed: React.FC = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stage 1: Fast Load (0 scrolls, roughly 5-10 videos)
|
const videos = await feedLoader.loadFeedWithOptimization(
|
||||||
const fastRes = await axios.get(`${API_BASE_URL}/feed?fast=true`);
|
false,
|
||||||
|
(loaded: Video[]) => {
|
||||||
let initialVideos: Video[] = [];
|
if (loaded.length > 0) {
|
||||||
|
setVideos(loaded);
|
||||||
if (Array.isArray(fastRes.data) && fastRes.data.length > 0) {
|
setViewState('feed');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
}).catch(console.error); // Silent error for background fetch
|
);
|
||||||
|
|
||||||
if (initialVideos.length === 0) {
|
if (videos.length === 0) {
|
||||||
// If fast fetch failed to get videos, we wait for background...
|
setError('No videos found.');
|
||||||
// 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');
|
|
||||||
setViewState('login');
|
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);
|
setIsFetching(true);
|
||||||
|
|
||||||
try {
|
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) {
|
setVideos(prev => {
|
||||||
const newVideos = res.data.map((v: any, i: number) => ({
|
const existingIds = new Set(prev.map(v => v.id));
|
||||||
id: v.id || `video-new-${Date.now()}-${i}`,
|
const unique = newVideos.filter((v: Video) => !existingIds.has(v.id));
|
||||||
url: v.url,
|
if (unique.length === 0) setHasMore(false);
|
||||||
author: v.author || 'unknown',
|
return [...prev, ...unique];
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load more:', err);
|
console.error('Failed to load more:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { Download, UserPlus, Check, Volume2, VolumeX } from 'lucide-react';
|
import { Download, UserPlus, Check, Volume2, VolumeX } from 'lucide-react';
|
||||||
import type { Video } from '../types';
|
import type { Video } from '../types';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
|
import { videoCache } from '../utils/videoCache';
|
||||||
|
|
||||||
interface HeartParticle {
|
interface HeartParticle {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -37,21 +38,17 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [isSeeking, setIsSeeking] = useState(false);
|
const [isSeeking, setIsSeeking] = useState(false);
|
||||||
const [useFallback, setUseFallback] = useState(false); // Fallback to full proxy
|
const [useFallback, setUseFallback] = useState(false);
|
||||||
// Use external mute state if provided, otherwise use local state for backward compatibility
|
|
||||||
const [localMuted, setLocalMuted] = useState(true);
|
const [localMuted, setLocalMuted] = useState(true);
|
||||||
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
||||||
const [hearts, setHearts] = useState<HeartParticle[]>([]);
|
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);
|
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)}`;
|
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;
|
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
|
||||||
|
const proxyUrl = cachedUrl ? cachedUrl : (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl;
|
||||||
// Use thin proxy first, fallback to full if needed (or if no cdn_url)
|
|
||||||
const proxyUrl = (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl;
|
|
||||||
const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`;
|
const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -109,7 +106,18 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
// Reset fallback and loading state when video changes
|
// Reset fallback and loading state when video changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUseFallback(false);
|
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]);
|
}, [video.id]);
|
||||||
|
|
||||||
// Progress tracking
|
// Progress tracking
|
||||||
|
|
@ -142,7 +150,27 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
video.removeEventListener('error', handleError);
|
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 = () => {
|
const togglePlayPause = () => {
|
||||||
if (!videoRef.current) return;
|
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