diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a72e76d..bcb81a5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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 { diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index c938ecb..cafa9f8 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -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 = ({ 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([]); - const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready + const [isLoading, setIsLoading] = useState(true); + const [cachedUrl, setCachedUrl] = useState(null); const lastTapRef = useRef(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 = ({ // 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 = ({ 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; diff --git a/frontend/src/utils/feedLoader.ts b/frontend/src/utils/feedLoader.ts new file mode 100644 index 0000000..8339f88 --- /dev/null +++ b/frontend/src/utils/feedLoader.ts @@ -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 = new Map(); + private CACHE_TTL_MS = 60000; + + async loadFeedWithOptimization( + fast: boolean = false, + onProgress?: (videos: Video[]) => void + ): Promise { + 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 { + 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 { + 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(); diff --git a/frontend/src/utils/videoCache.ts b/frontend/src/utils/videoCache.ts new file mode 100644 index 0000000..f281fdc --- /dev/null +++ b/frontend/src/utils/videoCache.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/frontend/src/utils/videoPrefetch.ts b/frontend/src/utils/videoPrefetch.ts new file mode 100644 index 0000000..57994f9 --- /dev/null +++ b/frontend/src/utils/videoPrefetch.ts @@ -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 = new Set(); + private config: PrefetchConfig; + private isInitialized = false; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + async init(): Promise { + if (this.isInitialized) return; + await videoCache.init(); + this.isInitialized = true; + } + + async prefetchNext( + videos: Video[], + currentIndex: number + ): Promise { + 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 { + 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();