import 'dart:io'; import 'package:flutter/material.dart'; import '../services/app_settings_service.dart'; import '../services/media_cache_settings_service.dart'; import '../services/preferred_language_service.dart'; import '../widgets/memory_transfer_settings.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context) { return SafeArea( child: ListView( padding: EdgeInsets.fromLTRB(20, 22, 20, 32), children: [ Text( 'Paramètres', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800), ), SizedBox(height: 6), Text( 'Personnalise le comportement de YouTube Personal.', style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 13, ), ), SizedBox(height: 24), _SettingsSection( title: 'Apparence', icon: Icons.brightness_6_outlined, child: ValueListenableBuilder( valueListenable: AppSettingsService.instance.appearanceMode, builder: (context, appearanceMode, child) { return _SettingsRow( title: 'Thème de l’application', subtitle: 'Système suit automatiquement le thème clair ou sombre configuré sur Windows ou Android.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: appearanceMode, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: AppSettingsService.supportedAppearanceModes .map( (mode) => DropdownMenuItem( value: mode, child: Text( AppSettingsService.appearanceModeLabel(mode), ), ), ) .toList(growable: false), onChanged: (value) { if (value == null) { return; } AppSettingsService.instance.setAppearanceMode(value); }, ), ), ); }, ), ), SizedBox(height: 14), _SettingsSection( title: 'Lecture', icon: Icons.play_circle_outline, child: ValueListenableBuilder( valueListenable: AppSettingsService.instance.defaultResolution, builder: (context, resolution, child) { return _SettingsRow( title: 'Résolution par défaut', subtitle: 'Utilisée à l’ouverture d’une nouvelle vidéo. La qualité reste modifiable dans le lecteur.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: resolution, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: AppSettingsService.supportedResolutions .map( (value) => DropdownMenuItem( value: value, child: Text( AppSettingsService.resolutionLabel(value), ), ), ) .toList(growable: false), onChanged: (value) { if (value == null) { return; } AppSettingsService.instance.setDefaultResolution(value); }, ), ), ); }, ), ), SizedBox(height: 14), _SettingsSection( title: 'Recommandations', icon: Icons.language, child: ValueListenableBuilder( valueListenable: AppSettingsService.instance.preferredLanguage, builder: (context, language, child) { return _SettingsRow( title: 'Langue préférée des vidéos', subtitle: 'Favorise cette langue dans le Radar et dans l’ordre des résultats de recherche, sans masquer les autres langues.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: language, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: PreferredLanguageService.options .map( (option) => DropdownMenuItem( value: option.code, child: Text(option.label), ), ) .toList(growable: false), onChanged: (value) { if (value == null) { return; } AppSettingsService.instance.setPreferredLanguage(value); }, ), ), ); }, ), ), SizedBox(height: 14), _SettingsSection( title: 'Filtre contenu très jeune', icon: Icons.child_care, child: ValueListenableBuilder( valueListenable: AppSettingsService.instance.youngAudienceFilterLevel, builder: (context, level, child) { return _SettingsRow( title: 'Chaînes / vidéos orientées très jeune', subtitle: 'Modéré masque surtout les contenus explicitement enfants, bébés, jouets ou comptines. Strict élargit le filtrage aux chaînes très fortement orientées jeune public. Le filtre s’applique aussi à Continuer à regarder, Continuer ce sujet et aux autres recommandations automatiques. Il se base sur les métadonnées textuelles, pas sur l’apparence des personnes.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: level, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: AppSettingsService .supportedYoungAudienceFilterLevels .map( (value) => DropdownMenuItem( value: value, child: Text( AppSettingsService.youngAudienceFilterLabel( value, ), ), ), ) .toList(growable: false), onChanged: (value) { if (value == null) { return; } AppSettingsService.instance.setYoungAudienceFilterLevel( value, ); }, ), ), ); }, ), ), if (Platform.isWindows) ...[ SizedBox(height: 14), _WindowsMediaCacheSettings(), ], if (Platform.isAndroid) ...[ SizedBox(height: 14), _AndroidMediaCacheSettings(), SizedBox(height: 14), _SettingsSection( title: 'Écran Android', icon: Icons.bedtime_outlined, child: ValueListenableBuilder( valueListenable: AppSettingsService.instance.pauseScreenSleepMinutes, builder: (context, minutes, child) { return _SettingsRow( title: 'Mise en veille après pause', subtitle: 'Pendant la lecture, l’écran reste allumé. À la fin de la vidéo, la mise en veille système redevient immédiatement possible. En pause, le maintien de l’écran est libéré après ce délai.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: minutes, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: AppSettingsService .supportedPauseScreenSleepMinutes .map( (value) => DropdownMenuItem( value: value, child: Text( AppSettingsService.pauseScreenSleepLabel( value, ), ), ), ) .toList(growable: false), onChanged: (value) { if (value == null) { return; } AppSettingsService.instance .setPauseScreenSleepMinutes(value); }, ), ), ); }, ), ), ], SizedBox(height: 14), const MemoryTransferSettings(), SizedBox(height: 14), _SettingsSection( title: 'Navigation', icon: Icons.dashboard_outlined, child: Text( 'La barre du bas mobile a été simplifiée à 5 onglets : Accueil, Recherche, Abos, Bibliothèque et Réglages. Historique, Favoris et Chaînes sont maintenant regroupés dans Bibliothèque pour un rendu plus lisible.', style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), height: 1.45, fontSize: 12.5, ), ), ), ], ), ); } } class _WindowsMediaCacheSettings extends StatefulWidget { const _WindowsMediaCacheSettings(); @override State<_WindowsMediaCacheSettings> createState() => _WindowsMediaCacheSettingsState(); } class _WindowsMediaCacheSettingsState extends State<_WindowsMediaCacheSettings> { final MediaCacheSettingsService _cache = MediaCacheSettingsService.instance; bool _loading = true; String _effectivePath = ''; int _currentBytes = 0; int _defaultBytes = 0; int _maxGb = MediaCacheSettingsService.defaultMaxCacheGb; @override void initState() { super.initState(); _reload(); } Future _reload() async { setState(() => _loading = true); await _cache.ensureInitialized(); final effectivePath = await _cache.effectiveCachePath(); final currentBytes = await _cache.currentCacheSizeBytes(); final defaultBytes = await _cache.defaultCacheSizeBytes(); if (!mounted) { return; } setState(() { _effectivePath = effectivePath; _currentBytes = currentBytes; _defaultBytes = defaultBytes; _maxGb = _cache.maxCacheGb.value; _loading = false; }); } @override Widget build(BuildContext context) { final usingCustomPath = _cache.customCachePath.value.trim().isNotEmpty; final defaultPath = _cache.defaultCachePath; return _SettingsSection( title: 'Cache média Windows', icon: Icons.storage_outlined, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Le cache accélère les relectures et les bascules HD. Il est maintenant limité automatiquement pour éviter de remplir le disque système.', style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 12.5, height: 1.4, ), ), SizedBox(height: 16), Text('Emplacement', style: TextStyle(fontWeight: FontWeight.w700)), SizedBox(height: 6), Container( width: double.infinity, padding: EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, ), ), child: SelectableText( _loading ? 'Calcul...' : _effectivePath, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12.5, ), ), ), SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ OutlinedButton.icon( onPressed: _loading ? null : _changePath, icon: Icon(Icons.folder_outlined), label: Text('Modifier le chemin'), ), if (usingCustomPath) TextButton.icon( onPressed: _loading ? null : _resetPath, icon: Icon(Icons.restart_alt), label: Text('Revenir au chemin par défaut'), ), ], ), SizedBox(height: 16), _SettingsRow( title: 'Taille maximale', subtitle: 'Quand la limite est dépassée, les vidéos les moins récemment utilisées sont supprimées. La vidéo active est protégée.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: _maxGb, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: MediaCacheSettingsService.supportedMaxCacheGb .map( (value) => DropdownMenuItem( value: value, child: Text( MediaCacheSettingsService.maxCacheLabel(value), ), ), ) .toList(growable: false), onChanged: _loading ? null : (value) async { if (value == null) { return; } await _cache.setMaxCacheGb(value); await _reload(); }, ), ), ), SizedBox(height: 16), Row( children: [ Expanded( child: Text( _loading ? 'Taille actuelle : calcul...' : 'Taille actuelle : ${MediaCacheSettingsService.formatBytesForUi(_currentBytes)}', style: TextStyle(fontWeight: FontWeight.w700), ), ), OutlinedButton.icon( onPressed: _loading ? null : _clearCurrentCache, icon: Icon(Icons.delete_sweep_outlined), label: Text('Vider le cache'), ), ], ), if (usingCustomPath && _defaultBytes > 0 && _effectivePath.toLowerCase() != defaultPath.toLowerCase()) ...[ SizedBox(height: 14), Container( width: double.infinity, padding: EdgeInsets.all(12), decoration: BoxDecoration( color: Color(0x1AFF9800), borderRadius: BorderRadius.circular(12), border: Border.all(color: Color(0x44FF9800)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Ancien cache sur C: ${MediaCacheSettingsService.formatBytesForUi(_defaultBytes)}', style: TextStyle(fontWeight: FontWeight.w700), ), SizedBox(height: 4), SelectableText( defaultPath, style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 12, ), ), SizedBox(height: 8), TextButton.icon( onPressed: _clearDefaultCache, icon: Icon(Icons.delete_outline), label: Text('Supprimer cet ancien cache'), ), ], ), ), ], ], ), ); } Future _changePath() async { final initialValue = _cache.customCachePath.value.trim().isNotEmpty ? _cache.customCachePath.value : _effectivePath; final controller = TextEditingController(text: initialValue); final value = await showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text('Emplacement du cache'), content: SizedBox( width: 560, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Entre un dossier disposant de suffisamment d’espace. Exemple : D:\\YouTubePersonalCache', ), SizedBox(height: 14), TextField( controller: controller, autofocus: true, decoration: InputDecoration( labelText: 'Chemin du cache', border: OutlineInputBorder(), ), ), SizedBox(height: 8), Text( 'Le changement ne copie pas les anciens fichiers. Ils pourront être supprimés séparément.', style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 12, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(controller.text), child: Text('Utiliser ce dossier'), ), ], ); }, ); controller.dispose(); if (value == null) { return; } try { await _cache.setCustomCachePath(value); await _reload(); } catch (error) { if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Impossible d’utiliser ce dossier : $error')), ); } } Future _resetPath() async { await _cache.resetCustomCachePath(); await _reload(); } Future _clearCurrentCache() async { final confirmed = await _confirmDelete( 'Vider le cache média ?', 'Ferme d’abord toute vidéo en cours. Les vidéos mises en cache devront être retéléchargées lors de la prochaine lecture.', ); if (!confirmed) { return; } await _cache.clearCurrentCache(); await _reload(); } Future _clearDefaultCache() async { final confirmed = await _confirmDelete( 'Supprimer l’ancien cache sur C: ?', 'Cette opération libérera l’espace utilisé par l’ancien cache média. Le nouveau cache restera intact.', ); if (!confirmed) { return; } await _cache.clearDefaultCache(); await _reload(); } Future _confirmDelete(String title, String message) async { final result = await showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text(title), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text('Supprimer'), ), ], ); }, ); return result == true; } } class _AndroidMediaCacheSettings extends StatefulWidget { const _AndroidMediaCacheSettings(); @override State<_AndroidMediaCacheSettings> createState() => _AndroidMediaCacheSettingsState(); } class _AndroidMediaCacheSettingsState extends State<_AndroidMediaCacheSettings> { final MediaCacheSettingsService _cache = MediaCacheSettingsService.instance; bool _loading = true; int _bufferMb = MediaCacheSettingsService.defaultAndroidBufferMb; int _diskMode = MediaCacheSettingsService.androidDiskCacheDisabled; String _path = ''; int _sizeBytes = 0; @override void initState() { super.initState(); _reload(); } Future _reload() async { if (mounted) { setState(() => _loading = true); } await _cache.ensureInitialized(); final mode = _cache.androidDiskCacheMode.value; final path = await _cache.androidCachePathForMode(mode); final bytes = await _cache.androidCacheSizeBytes(mode); if (!mounted) { return; } setState(() { _bufferMb = _cache.androidBufferMb.value; _diskMode = mode; _path = path; _sizeBytes = bytes; _loading = false; }); } @override Widget build(BuildContext context) { return _SettingsSection( title: 'Cache média Android', icon: Icons.memory_outlined, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Par défaut, Android garde le cache vidéo en RAM pour éviter de remplir le stockage. Le cache disque temporaire est optionnel et reste dans l’espace privé de l’application.', style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 12.5, height: 1.4, ), ), SizedBox(height: 16), _SettingsRow( title: 'Tampon mémoire vidéo', subtitle: 'Limite le cache démuxeur de media_kit. Le changement s’applique aux nouvelles pages vidéo.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: _bufferMb, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: MediaCacheSettingsService.supportedAndroidBufferMb .map( (value) => DropdownMenuItem( value: value, child: Text( MediaCacheSettingsService.androidBufferLabel(value), ), ), ) .toList(growable: false), onChanged: _loading ? null : (value) async { if (value == null) { return; } await _cache.setAndroidBufferMb(value); await _reload(); }, ), ), ), SizedBox(height: 14), _SettingsRow( title: 'Cache disque temporaire', subtitle: 'RAM uniquement est recommandé. Interne/Externe utilise le cache temporaire mpv et le supprime à la fermeture normale de la vidéo.', trailing: DropdownButtonHideUnderline( child: DropdownButton( value: _diskMode, dropdownColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), items: MediaCacheSettingsService.supportedAndroidDiskCacheModes .map( (value) => DropdownMenuItem( value: value, child: Text( MediaCacheSettingsService.androidDiskCacheModeLabel( value, ), ), ), ) .toList(growable: false), onChanged: _loading ? null : (value) async { if (value == null) { return; } await _cache.setAndroidDiskCacheMode(value); await _reload(); }, ), ), ), if (_diskMode != MediaCacheSettingsService.androidDiskCacheDisabled) ...[ SizedBox(height: 14), Text('Emplacement', style: TextStyle(fontWeight: FontWeight.w700)), SizedBox(height: 6), Container( width: double.infinity, padding: EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, ), ), child: SelectableText( _loading ? 'Calcul...' : (_path.isEmpty ? 'Indisponible' : _path), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12.5, ), ), ), SizedBox(height: 10), Row( children: [ Expanded( child: Text( 'Taille temporaire : ${MediaCacheSettingsService.formatBytesForUi(_sizeBytes)}', style: TextStyle(fontWeight: FontWeight.w700), ), ), OutlinedButton.icon( onPressed: _loading ? null : _clear, icon: Icon(Icons.delete_sweep_outlined), label: Text('Vider'), ), ], ), ], SizedBox(height: 10), Text( 'Android ne permet pas à l’application d’écrire librement dans n’importe quel dossier sans passer par le sélecteur de stockage. Le choix Externe utilise donc le dossier externe privé de l’application, sans permission supplémentaire.', style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 11.5, height: 1.35, ), ), ], ), ); } Future _clear() async { await _cache.clearAndroidMediaCache(_diskMode); await _reload(); } } class _SettingsSection extends StatelessWidget { const _SettingsSection({ required this.title, required this.icon, required this.child, }); final String title; final IconData icon; final Widget child; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 19, color: Color(0xFFFF6B63)), SizedBox(width: 8), Text( title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), ], ), SizedBox(height: 14), child, ], ), ); } } class _SettingsRow extends StatelessWidget { const _SettingsRow({ required this.title, required this.subtitle, required this.trailing, }); final String title; final String subtitle; final Widget trailing; @override Widget build(BuildContext context) { final narrow = MediaQuery.sizeOf(context).width < 560; final text = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle(fontWeight: FontWeight.w700)), SizedBox(height: 4), Text( subtitle, style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 12, height: 1.35, ), ), ], ); if (narrow) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [text, SizedBox(height: 12), trailing], ); } return Row( children: [ Expanded(child: text), SizedBox(width: 18), trailing, ], ); } }