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
+899
View File
@@ -0,0 +1,899 @@
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<String>(
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<String>(
value: appearanceMode,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService.supportedAppearanceModes
.map(
(mode) => DropdownMenuItem<String>(
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<int>(
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<int>(
value: resolution,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService.supportedResolutions
.map(
(value) => DropdownMenuItem<int>(
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<String>(
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<String>(
value: language,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: PreferredLanguageService.options
.map(
(option) => DropdownMenuItem<String>(
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<int>(
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<int>(
value: level,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService
.supportedYoungAudienceFilterLevels
.map(
(value) => DropdownMenuItem<int>(
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<int>(
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<int>(
value: minutes,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: AppSettingsService
.supportedPauseScreenSleepMinutes
.map(
(value) => DropdownMenuItem<int>(
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<void> _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<int>(
value: _maxGb,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedMaxCacheGb
.map(
(value) => DropdownMenuItem<int>(
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<void> _changePath() async {
final initialValue = _cache.customCachePath.value.trim().isNotEmpty
? _cache.customCachePath.value
: _effectivePath;
final controller = TextEditingController(text: initialValue);
final value = await showDialog<String>(
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<void> _resetPath() async {
await _cache.resetCustomCachePath();
await _reload();
}
Future<void> _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<void> _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<bool> _confirmDelete(String title, String message) async {
final result = await showDialog<bool>(
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<void> _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<int>(
value: _bufferMb,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedAndroidBufferMb
.map(
(value) => DropdownMenuItem<int>(
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<int>(
value: _diskMode,
dropdownColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
items: MediaCacheSettingsService.supportedAndroidDiskCacheModes
.map(
(value) => DropdownMenuItem<int>(
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<void> _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,
],
);
}
}