578 lines
15 KiB
Dart
578 lines
15 KiB
Dart
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<int> supportedMaxCacheGb = <int>[
|
|
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<int> supportedAndroidBufferMb = <int>[
|
|
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<int> supportedAndroidDiskCacheModes = <int>[
|
|
androidDiskCacheDisabled,
|
|
androidDiskCacheInternal,
|
|
androidDiskCacheExternal,
|
|
];
|
|
|
|
static const MethodChannel _androidCacheChannel = MethodChannel(
|
|
'com.studioweb.youtube_personal/cache',
|
|
);
|
|
|
|
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
|
|
|
|
final ValueNotifier<String> customCachePath =
|
|
ValueNotifier<String>('');
|
|
|
|
final ValueNotifier<int> maxCacheGb =
|
|
ValueNotifier<int>(defaultMaxCacheGb);
|
|
|
|
final ValueNotifier<int> androidBufferMb =
|
|
ValueNotifier<int>(defaultAndroidBufferMb);
|
|
|
|
final ValueNotifier<int> androidDiskCacheMode =
|
|
ValueNotifier<int>(androidDiskCacheDisabled);
|
|
|
|
Future<void>? _initialization;
|
|
bool _initialized = false;
|
|
|
|
Future<void> ensureInitialized() async {
|
|
if (_initialized) {
|
|
return;
|
|
}
|
|
|
|
_initialization ??= _load();
|
|
await _initialization;
|
|
}
|
|
|
|
Future<void> _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<String> effectiveCachePath() async {
|
|
await ensureInitialized();
|
|
|
|
final custom = customCachePath.value.trim();
|
|
if (custom.isNotEmpty) {
|
|
return custom;
|
|
}
|
|
|
|
return defaultCachePath;
|
|
}
|
|
|
|
Future<Directory> 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<void> 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<void> resetCustomCachePath() async {
|
|
await setCustomCachePath('');
|
|
}
|
|
|
|
Future<void> 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<void> 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<void> 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<Map<String, String>> androidCachePaths() async {
|
|
if (!Platform.isAndroid) {
|
|
return const <String, String>{};
|
|
}
|
|
|
|
try {
|
|
final raw = await _androidCacheChannel.invokeMapMethod<String, dynamic>(
|
|
'getCachePaths',
|
|
);
|
|
|
|
if (raw == null) {
|
|
return const <String, String>{};
|
|
}
|
|
|
|
final result = <String, String>{};
|
|
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 <String, String>{};
|
|
}
|
|
}
|
|
|
|
Future<String> 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<int> androidCacheSizeBytes(int mode) async {
|
|
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
final value = await _androidCacheChannel.invokeMethod<num>(
|
|
'getCacheSize',
|
|
<String, dynamic>{'mode': mode},
|
|
);
|
|
return value?.toInt() ?? 0;
|
|
} catch (error) {
|
|
debugPrint('[MEDIA-CACHE][18.7.15] Android size: $error');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
Future<void> clearAndroidMediaCache(int mode) async {
|
|
if (!Platform.isAndroid || mode == androidDiskCacheDisabled) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await _androidCacheChannel.invokeMethod<bool>(
|
|
'clearCache',
|
|
<String, dynamic>{'mode': mode},
|
|
);
|
|
} catch (error) {
|
|
debugPrint('[MEDIA-CACHE][18.7.15] Android clear: $error');
|
|
}
|
|
}
|
|
|
|
Future<int> currentCacheSizeBytes() async {
|
|
return directorySizeBytes(await resolveCacheRoot());
|
|
}
|
|
|
|
Future<int> defaultCacheSizeBytes() async {
|
|
return directorySizeBytes(Directory(defaultCachePath));
|
|
}
|
|
|
|
Future<int> 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<void> clearCurrentCache() async {
|
|
final root = await resolveCacheRoot();
|
|
await _deleteChildren(root);
|
|
}
|
|
|
|
Future<void> clearDefaultCache() async {
|
|
final root = Directory(defaultCachePath);
|
|
if (!await root.exists()) {
|
|
return;
|
|
}
|
|
await _deleteChildren(root);
|
|
}
|
|
|
|
Future<void> _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<void> 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<void> 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<DateTime> _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;
|
|
}
|