import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MediaCacheSettingsService { MediaCacheSettingsService._(); static final MediaCacheSettingsService instance = MediaCacheSettingsService._(); static const String _customPathKey = 'youtube_personal.cache.custom_path.v1'; static const String _maxCacheGbKey = 'youtube_personal.cache.max_gb.v1'; static const String _androidBufferMbKey = 'youtube_personal.cache.android_buffer_mb.v1'; static const String _androidDiskCacheModeKey = 'youtube_personal.cache.android_disk_mode.v1'; static const int defaultMaxCacheGb = 5; static const List supportedMaxCacheGb = [ 2, 5, 10, 20, 50, 0, ]; // Android : le flux vidéo direct reste par défaut en cache mémoire. // 64 Mo offre une marge confortable pour 1080p/1440p/4K sans créer // de cache disque persistant. static const int defaultAndroidBufferMb = 64; static const List supportedAndroidBufferMb = [ 32, 64, 128, ]; // 0 = RAM uniquement, 1 = cache temporaire stockage interne app, // 2 = cache temporaire stockage externe app. static const int androidDiskCacheDisabled = 0; static const int androidDiskCacheInternal = 1; static const int androidDiskCacheExternal = 2; static const List supportedAndroidDiskCacheModes = [ androidDiskCacheDisabled, androidDiskCacheInternal, androidDiskCacheExternal, ]; static const MethodChannel _androidCacheChannel = MethodChannel( 'com.studioweb.youtube_personal/cache', ); final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); final ValueNotifier customCachePath = ValueNotifier(''); final ValueNotifier maxCacheGb = ValueNotifier(defaultMaxCacheGb); final ValueNotifier androidBufferMb = ValueNotifier(defaultAndroidBufferMb); final ValueNotifier androidDiskCacheMode = ValueNotifier(androidDiskCacheDisabled); Future? _initialization; bool _initialized = false; Future ensureInitialized() async { if (_initialized) { return; } _initialization ??= _load(); await _initialization; } Future _load() async { final storedPath = await _preferences.getString(_customPathKey); final storedMaxGb = await _preferences.getInt(_maxCacheGbKey); final storedAndroidBufferMb = await _preferences.getInt(_androidBufferMbKey); final storedAndroidDiskCacheMode = await _preferences.getInt(_androidDiskCacheModeKey); if (storedPath != null && storedPath.trim().isNotEmpty) { customCachePath.value = storedPath.trim(); } if (storedMaxGb != null && supportedMaxCacheGb.contains(storedMaxGb)) { maxCacheGb.value = storedMaxGb; } if (storedAndroidBufferMb != null && supportedAndroidBufferMb.contains(storedAndroidBufferMb)) { androidBufferMb.value = storedAndroidBufferMb; } if (storedAndroidDiskCacheMode != null && supportedAndroidDiskCacheModes.contains(storedAndroidDiskCacheMode)) { androidDiskCacheMode.value = storedAndroidDiskCacheMode; } _initialized = true; // Nettoyage immédiat au premier lancement après mise à jour : l'ancien // cache Windows de plusieurs dizaines de Go est ramené à la limite // configurée avant même d'ouvrir une nouvelle vidéo. if (Platform.isWindows) { await enforceLimit(); } } String get defaultCachePath { final localAppData = Platform.environment['LOCALAPPDATA']; final rootPath = localAppData != null && localAppData.trim().isNotEmpty ? localAppData.trim() : Directory.systemTemp.path; return '$rootPath' '${Platform.pathSeparator}' 'YouTubePersonal' '${Platform.pathSeparator}' 'media_cache'; } Future effectiveCachePath() async { await ensureInitialized(); final custom = customCachePath.value.trim(); if (custom.isNotEmpty) { return custom; } return defaultCachePath; } Future resolveCacheRoot() async { final path = await effectiveCachePath(); final directory = Directory(path); try { await directory.create(recursive: true); return directory; } catch (error) { // Si un disque externe / secondaire a disparu, ne pas casser le // lecteur Windows : retour temporaire au chemin par défaut. if (customCachePath.value.trim().isNotEmpty) { debugPrint( '[MEDIA-CACHE][18.7.15] chemin personnalisé indisponible ' '$path -> fallback: $error', ); final fallback = Directory(defaultCachePath); await fallback.create(recursive: true); return fallback; } rethrow; } } Future setCustomCachePath(String value) async { await ensureInitialized(); final trimmed = value.trim(); if (trimmed.isEmpty || _samePath(trimmed, defaultCachePath)) { customCachePath.value = ''; await _preferences.remove(_customPathKey); return; } final directory = Directory(trimmed); await directory.create(recursive: true); final testFile = File( '${directory.path}${Platform.pathSeparator}.youtube_personal_write_test', ); try { await testFile.writeAsString('ok', flush: true); } finally { try { if (await testFile.exists()) { await testFile.delete(); } } catch (_) { // Non bloquant. } } customCachePath.value = directory.path; await _preferences.setString(_customPathKey, directory.path); } Future resetCustomCachePath() async { await setCustomCachePath(''); } Future setMaxCacheGb(int value) async { await ensureInitialized(); if (!supportedMaxCacheGb.contains(value)) { return; } maxCacheGb.value = value; await _preferences.setInt(_maxCacheGbKey, value); if (Platform.isWindows) { await enforceLimit(); } } static String maxCacheLabel(int value) { if (value == 0) { return 'Illimité'; } return '$value Go'; } Future setAndroidBufferMb(int value) async { await ensureInitialized(); if (!supportedAndroidBufferMb.contains(value)) { return; } androidBufferMb.value = value; await _preferences.setInt(_androidBufferMbKey, value); } static String androidBufferLabel(int value) => '$value Mo'; Future setAndroidDiskCacheMode(int value) async { await ensureInitialized(); if (!supportedAndroidDiskCacheModes.contains(value)) { return; } androidDiskCacheMode.value = value; await _preferences.setInt(_androidDiskCacheModeKey, value); if (!Platform.isAndroid) { return; } // Quand le cache disque est désactivé, supprimer les éventuels restes // d'anciennes sessions sur les deux emplacements. if (value == androidDiskCacheDisabled) { await clearAndroidMediaCache(androidDiskCacheInternal); await clearAndroidMediaCache(androidDiskCacheExternal); } else { // Le dossier est temporaire. On retire les restes d'un éventuel crash // précédent avant la prochaine lecture. await clearAndroidMediaCache(value); } } static String androidDiskCacheModeLabel(int value) { return switch (value) { androidDiskCacheInternal => 'Interne (app)', androidDiskCacheExternal => 'Externe (app)', _ => 'Désactivé (RAM uniquement)', }; } Future> androidCachePaths() async { if (!Platform.isAndroid) { return const {}; } try { final raw = await _androidCacheChannel.invokeMapMethod( 'getCachePaths', ); if (raw == null) { return const {}; } final result = {}; for (final entry in raw.entries) { final value = entry.value; if (value is String && value.trim().isNotEmpty) { result[entry.key] = value; } } return result; } catch (error) { debugPrint('[MEDIA-CACHE][18.7.15] Android paths: $error'); return const {}; } } Future androidCachePathForMode(int mode) async { if (!Platform.isAndroid || mode == androidDiskCacheDisabled) { return ''; } final paths = await androidCachePaths(); if (mode == androidDiskCacheExternal) { return paths['external'] ?? paths['internal'] ?? ''; } return paths['internal'] ?? ''; } Future androidCacheSizeBytes(int mode) async { if (!Platform.isAndroid || mode == androidDiskCacheDisabled) { return 0; } try { final value = await _androidCacheChannel.invokeMethod( 'getCacheSize', {'mode': mode}, ); return value?.toInt() ?? 0; } catch (error) { debugPrint('[MEDIA-CACHE][18.7.15] Android size: $error'); return 0; } } Future clearAndroidMediaCache(int mode) async { if (!Platform.isAndroid || mode == androidDiskCacheDisabled) { return; } try { await _androidCacheChannel.invokeMethod( 'clearCache', {'mode': mode}, ); } catch (error) { debugPrint('[MEDIA-CACHE][18.7.15] Android clear: $error'); } } Future currentCacheSizeBytes() async { return directorySizeBytes(await resolveCacheRoot()); } Future defaultCacheSizeBytes() async { return directorySizeBytes(Directory(defaultCachePath)); } Future directorySizeBytes(Directory directory) async { if (!await directory.exists()) { return 0; } var total = 0; try { await for (final entity in directory.list( recursive: true, followLinks: false, )) { if (entity is File) { try { total += await entity.length(); } catch (_) { // Un fichier verrouillé ne doit pas bloquer l'affichage. } } } } catch (_) { // Un dossier partiellement inaccessible ne doit pas bloquer l'app. } return total; } Future clearCurrentCache() async { final root = await resolveCacheRoot(); await _deleteChildren(root); } Future clearDefaultCache() async { final root = Directory(defaultCachePath); if (!await root.exists()) { return; } await _deleteChildren(root); } Future _deleteChildren(Directory root) async { try { final entities = await root.list(followLinks: false).toList(); for (final entity in entities) { try { await entity.delete(recursive: true); } catch (_) { // Un fichier encore utilisé sera conservé et pourra être supprimé // lors du prochain nettoyage. } } } catch (_) { // Le nettoyage ne doit jamais empêcher l'application de fonctionner. } } Future touchVideoDirectory(Directory videoDirectory) async { final marker = File( '${videoDirectory.path}${Platform.pathSeparator}.last_access', ); try { await marker.writeAsString( DateTime.now().toIso8601String(), flush: true, ); } catch (_) { // Non bloquant. } } Future enforceLimit({String? keepVideoId}) async { await ensureInitialized(); if (!Platform.isWindows) { return; } final limitGb = maxCacheGb.value; if (limitGb <= 0) { return; } final root = await resolveCacheRoot(); final maxBytes = limitGb * 1024 * 1024 * 1024; final entries = <_CacheEntry>[]; var totalBytes = 0; try { final children = await root.list(followLinks: false).toList(); for (final entity in children) { final name = entity.uri.pathSegments .where((part) => part.isNotEmpty) .last; int bytes = 0; DateTime lastAccess = DateTime.fromMillisecondsSinceEpoch(0); if (entity is Directory) { bytes = await directorySizeBytes(entity); lastAccess = await _lastAccess(entity); } else if (entity is File) { try { bytes = await entity.length(); lastAccess = (await entity.stat()).modified; } catch (_) { continue; } } entries.add( _CacheEntry( entity: entity, name: name, bytes: bytes, lastAccess: lastAccess, ), ); totalBytes += bytes; } } catch (_) { return; } if (totalBytes <= maxBytes) { return; } // On descend à 85 % de la limite pour éviter un nettoyage à chaque // petite écriture suivante. final targetBytes = (maxBytes * 0.85).round(); entries.sort( (a, b) => a.lastAccess.compareTo(b.lastAccess), ); for (final entry in entries) { if (totalBytes <= targetBytes) { break; } if (keepVideoId != null && entry.name == keepVideoId) { continue; } try { await entry.entity.delete(recursive: true); totalBytes -= entry.bytes; debugPrint( '[MEDIA-CACHE][18.7.15] LRU supprimé: ' '${entry.name} (${_formatBytes(entry.bytes)})', ); } catch (_) { // Probablement encore utilisé par le lecteur. } } debugPrint( '[MEDIA-CACHE][18.7.15] taille après nettoyage: ' '${_formatBytes(totalBytes)} / ${limitGb}Go', ); } Future _lastAccess(Directory directory) async { final marker = File( '${directory.path}${Platform.pathSeparator}.last_access', ); try { if (await marker.exists()) { return (await marker.stat()).modified; } return (await directory.stat()).modified; } catch (_) { return DateTime.fromMillisecondsSinceEpoch(0); } } bool _samePath(String a, String b) { String normalize(String value) { var normalized = value .replaceAll('/', Platform.pathSeparator) .replaceAll('\\', Platform.pathSeparator) .trim(); while (normalized.endsWith(Platform.pathSeparator)) { normalized = normalized.substring(0, normalized.length - 1); } return Platform.isWindows ? normalized.toLowerCase() : normalized; } return normalize(a) == normalize(b); } static String formatBytesForUi(int bytes) => _formatBytes(bytes); static String _formatBytes(int bytes) { if (bytes >= 1024 * 1024 * 1024) { return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} Go'; } if (bytes >= 1024 * 1024) { return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} Mo'; } return '${(bytes / 1024).toStringAsFixed(0)} Ko'; } } class _CacheEntry { const _CacheEntry({ required this.entity, required this.name, required this.bytes, required this.lastAccess, }); final FileSystemEntity entity; final String name; final int bytes; final DateTime lastAccess; }