import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; import 'dart:convert'; class VideoPreloader { static final VideoPreloader _instance = VideoPreloader._internal(); factory VideoPreloader() => _instance; VideoPreloader._internal(); final Map cachedVideoPaths = {}; final Map> _downloadingVideos = {}; final Set _preloadQueue = {}; // Configuration int maxPreloadCount = 3; // Maximum number of videos to preload ahead int maxCacheSize = 500 * 1024 * 1024; // 500MB max cache size /// Get cache directory for videos Future get _cacheDirectory async { final Directory tempDir = await getTemporaryDirectory(); final Directory videoCache = Directory('${tempDir.path}/video_cache'); if (!await videoCache.exists()) { await videoCache.create(recursive: true); } return videoCache; } /// Generate a unique filename for the video URL String _generateFileName(String url) { final bytes = utf8.encode(url); final digest = sha256.convert(bytes); final extension = url.split('.').last.split('?').first; return '${digest.toString()}.${extension.isNotEmpty ? extension : 'mp4'}'; } /// Check if video is already cached Future isVideoCached(String url) async { String normalizedUrl = url; if (normalizedUrl.startsWith('http://')) { normalizedUrl = normalizedUrl.replaceFirst('http://', 'https://'); } if (cachedVideoPaths.containsKey(normalizedUrl)) { final file = File(cachedVideoPaths[normalizedUrl]!); if (await file.exists()) { return true; } else { cachedVideoPaths.remove(normalizedUrl); } } final cacheDir = await _cacheDirectory; final fileName = _generateFileName(normalizedUrl); final file = File('${cacheDir.path}/$fileName'); if (await file.exists()) { cachedVideoPaths[normalizedUrl] = file.path; debugPrint('Found cached video: ${file.path}'); return true; } return false; } /// Get cached video path if available Future getCachedVideoPath(String url) async { String normalizedUrl = url; if (normalizedUrl.startsWith('http://')) { normalizedUrl = normalizedUrl.replaceFirst('http://', 'https://'); } if (await isVideoCached(normalizedUrl)) { final path = cachedVideoPaths[normalizedUrl]; debugPrint('Retrieved cached video path: $path'); return path; } return null; } /// Download video to cache Future downloadVideo(String url, {Function(double)? onProgress}) async { // ✅ Skip RTMP streams if (url.startsWith('rtmp://')) { debugPrint('⏭️ Skipping download for RTMP stream: $url'); return null; } try { String normalizedUrl = url; if (normalizedUrl.startsWith('http://')) { normalizedUrl = normalizedUrl.replaceFirst('http://', 'https://'); } if (_downloadingVideos.containsKey(normalizedUrl)) { debugPrint('Video already downloading: $normalizedUrl'); return await _downloadingVideos[normalizedUrl]; } if (await isVideoCached(normalizedUrl)) { debugPrint('Video already cached: $normalizedUrl'); return cachedVideoPaths[normalizedUrl]; } debugPrint('⬇️ Starting download: $normalizedUrl'); final downloadFuture = _performDownload(normalizedUrl, onProgress: onProgress); _downloadingVideos[normalizedUrl] = downloadFuture; final result = await downloadFuture; _downloadingVideos.remove(normalizedUrl); return result; } catch (e) { _downloadingVideos.remove(url); debugPrint('Error downloading video: $e'); return null; } } Future _performDownload(String url, {Function(double)? onProgress}) async { try { debugPrint('Downloading video from: $url'); final response = await http.get(Uri.parse(url)); if (response.statusCode != 200) { throw Exception('Failed to download video: ${response.statusCode}'); } final cacheDir = await _cacheDirectory; final fileName = _generateFileName(url); final file = File('${cacheDir.path}/$fileName'); await _manageCacheSize(); await file.writeAsBytes(response.bodyBytes); cachedVideoPaths[url] = file.path; debugPrint('✅ Video cached at: ${file.path} (${response.bodyBytes.length} bytes)'); return file.path; } catch (e) { debugPrint('Error in _performDownload: $e'); return null; } } /// Preload videos based on current index Future preloadVideos(List videoUrls, int currentIndex) async { _preloadQueue.clear(); final List videosToPreload = []; for (int i = 1; i <= maxPreloadCount; i++) { final nextIndex = currentIndex + i; if (nextIndex < videoUrls.length) { videosToPreload.add(videoUrls[nextIndex]); } if (i == 1) { final prevIndex = currentIndex - 1; if (prevIndex >= 0) { videosToPreload.add(videoUrls[prevIndex]); } } } for (final url in videosToPreload) { // ✅ Skip RTMP streams if (url.startsWith('rtmp://')) { debugPrint('⏭️ Skipping preload for RTMP stream: $url'); continue; } if (!await isVideoCached(url) && !_preloadQueue.contains(url)) { _preloadQueue.add(url); _preloadInBackground(url); } } } void _preloadInBackground(String url) { downloadVideo(url).then((path) { _preloadQueue.remove(url); if (path != null) { debugPrint('✅ Preloaded video: $url'); } }).catchError((error) { _preloadQueue.remove(url); debugPrint('❌ Failed to preload video: $url, Error: $error'); }); } /// Manage cache size by removing old files Future _manageCacheSize() async { try { final cacheDir = await _cacheDirectory; final files = await cacheDir.list().toList(); int totalSize = 0; final List> fileStats = []; for (final entity in files) { if (entity is File) { final stat = await entity.stat(); totalSize += stat.size; fileStats.add(MapEntry(entity, stat.modified.millisecondsSinceEpoch)); } } if (totalSize > maxCacheSize) { fileStats.sort((a, b) => a.value.compareTo(b.value)); for (final entry in fileStats) { if (totalSize <= maxCacheSize * 0.8) break; final file = entry.key; final stat = await file.stat(); await file.delete(); totalSize -= stat.size; cachedVideoPaths.removeWhere((key, value) => value == file.path); debugPrint('🗑 Removed cached video: ${file.path}'); } } } catch (e) { debugPrint('Error managing cache size: $e'); } } /// Clear all cached videos Future clearCache() async { try { final cacheDir = await _cacheDirectory; if (await cacheDir.exists()) { await cacheDir.delete(recursive: true); } cachedVideoPaths.clear(); _preloadQueue.clear(); debugPrint('🧹 Video cache cleared'); } catch (e) { debugPrint('Error clearing cache: $e'); } } /// Load existing cached videos on app startup Future loadExistingCache() async { try { final cacheDir = await _cacheDirectory; if (!await cacheDir.exists()) return; final files = await cacheDir.list().toList(); debugPrint('🔍 Loading existing cache entries...'); for (final entity in files) { if (entity is File) { final fileName = entity.path.split('/').last; debugPrint('Found cached file: $fileName'); } } debugPrint('✅ Cache load complete. Found ${files.length} files.'); } catch (e) { debugPrint('Error loading existing cache: $e'); } } /// Get cache size Future getCacheSize() async { try { final cacheDir = await _cacheDirectory; if (!await cacheDir.exists()) return 0; final files = await cacheDir.list().toList(); int totalSize = 0; for (final entity in files) { if (entity is File) { final stat = await entity.stat(); totalSize += stat.size; } } return totalSize; } catch (e) { debugPrint('Error getting cache size: $e'); return 0; } } /// Get formatted cache size Future getFormattedCacheSize() async { final size = await getCacheSize(); if (size < 1024) return '${size}B'; if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)}KB'; return '${(size / (1024 * 1024)).toStringAsFixed(1)}MB'; } }