Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user