Files
youtube_personal/lib/screens/settings_page.dart.before_memory_v1
T

889 lines
30 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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),
_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,
],
);
}
}