Files
doyin/lib/app/modules/selfMedia/video/services/videoPreloader.dart
2025-07-07 18:45:44 +08:00

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';
}
}