Updated VideoPlayer.tsx

This commit is contained in:
vndangkhoa 2025-12-20 11:11:27 +07:00 committed by GitHub
parent dcf30ae3a5
commit 13841f479e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 510 additions and 109 deletions

View file

@ -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);
const videos = await feedLoader.loadFeedWithOptimization(
false,
(loaded: Video[]) => {
if (loaded.length > 0) {
setVideos(loaded);
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 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 {
if (videos.length === 0) {
setError('No videos found.');
setViewState('login');
}
}).catch(e => {
setError(e.response?.data?.detail || 'Failed to load feed');
} 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`);
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 || ''
}));
const newVideos = await feedLoader.loadFeedWithOptimization(false);
setVideos(prev => {
const existingIds = new Set(prev.map(v => v.id));
const unique = newVideos.filter((v: any) => !existingIds.has(v.id));
const unique = newVideos.filter((v: Video) => !existingIds.has(v.id));
if (unique.length === 0) setHasMore(false);
return [...prev, ...unique];
});
} else {
setHasMore(false);
}
} catch (err) {
console.error('Failed to load more:', err);
} finally {

View file

@ -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;

View 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();

View 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();

View 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();