Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
@@ -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;
}