291 lines
8.8 KiB
Dart
291 lines
8.8 KiB
Dart
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<String, String> cachedVideoPaths = {};
|
|
final Map<String, Future<String?>> _downloadingVideos = {};
|
|
final Set<String> _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<Directory> 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<bool> 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<String?> 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<String?> 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<String?> _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<void> preloadVideos(List<String> videoUrls, int currentIndex) async {
|
|
_preloadQueue.clear();
|
|
final List<String> 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<void> _manageCacheSize() async {
|
|
try {
|
|
final cacheDir = await _cacheDirectory;
|
|
final files = await cacheDir.list().toList();
|
|
int totalSize = 0;
|
|
final List<MapEntry<File, int>> 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<void> 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<void> 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<int> 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<String> 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';
|
|
}
|
|
}
|
|
|
|
|