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