Light theme default + yt-dlp search fallback
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/channel_rank_item.dart';
|
||||
import '../services/channel_catalog_service.dart';
|
||||
import '../services/hidden_channels_service.dart';
|
||||
import '../services/library_service.dart';
|
||||
import 'channel_videos_page.dart';
|
||||
|
||||
class ChannelsPage extends StatefulWidget {
|
||||
const ChannelsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ChannelsPage> createState() => _ChannelsPageState();
|
||||
}
|
||||
|
||||
class _ChannelsPageState extends State<ChannelsPage> {
|
||||
List<ChannelRankItem> _channels = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
String _filter = '';
|
||||
Timer? _refreshDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
LibraryService.instance.watchRevision.addListener(_onWatchChanged);
|
||||
HiddenChannelsService.instance.revision.addListener(_onHiddenChanged);
|
||||
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshDebounce?.cancel();
|
||||
LibraryService.instance.watchRevision.removeListener(_onWatchChanged);
|
||||
HiddenChannelsService.instance.revision.removeListener(_onHiddenChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onWatchChanged() {
|
||||
_scheduleRefresh();
|
||||
}
|
||||
|
||||
void _onHiddenChanged() {
|
||||
_scheduleRefresh();
|
||||
}
|
||||
|
||||
void _scheduleRefresh() {
|
||||
_refreshDebounce?.cancel();
|
||||
_refreshDebounce = Timer(
|
||||
Duration(milliseconds: 300),
|
||||
() {
|
||||
if (mounted) {
|
||||
unawaited(_load(force: true, quiet: true));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load({
|
||||
bool force = false,
|
||||
bool quiet = false,
|
||||
}) async {
|
||||
if (!quiet) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final channels =
|
||||
await ChannelCatalogService.instance.load(force: force);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_channels = channels;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<ChannelRankItem> get _filteredChannels {
|
||||
final query = _filter.trim().toLowerCase();
|
||||
|
||||
if (query.isEmpty) {
|
||||
return _channels;
|
||||
}
|
||||
|
||||
return _channels
|
||||
.where(
|
||||
(item) =>
|
||||
item.channel.title.toLowerCase().contains(query) ||
|
||||
(item.topVideoTitle ?? '').toLowerCase().contains(query),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _open(ChannelRankItem item) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelVideosPage(channel: item.channel),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
await _load(force: true, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _hide(ChannelRankItem item) async {
|
||||
await HiddenChannelsService.instance.hide(item.channel);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_channels = _channels
|
||||
.where(
|
||||
(candidate) =>
|
||||
candidate.channel.channelId != item.channel.channelId,
|
||||
)
|
||||
.toList(growable: false);
|
||||
});
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.hideCurrentSnackBar();
|
||||
|
||||
final controller = messenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: Duration(seconds: 3),
|
||||
content: Text(
|
||||
'${item.channel.title} ne sera plus proposée.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'ANNULER',
|
||||
onPressed: () {
|
||||
unawaited(_restore(item));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Sur certaines configurations desktop/accessibilité, un SnackBar avec
|
||||
// action peut rester visible au-delà de sa durée. On impose donc aussi
|
||||
// sa fermeture côté application.
|
||||
unawaited(
|
||||
Future<void>.delayed(Duration(seconds: 3)).then((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _restore(ChannelRankItem item) async {
|
||||
await HiddenChannelsService.instance.restore(item.channel);
|
||||
if (mounted) {
|
||||
await _load(force: true, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channels = _filteredChannels;
|
||||
final canGoBack = Navigator.of(context).canPop();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _load(force: true),
|
||||
child: CustomScrollView(
|
||||
physics: AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _ChannelsHeader(
|
||||
count: _channels.length,
|
||||
loading: _loading,
|
||||
canGoBack: canGoBack,
|
||||
onRefresh: () => _load(force: true),
|
||||
onFilterChanged: (value) {
|
||||
setState(() {
|
||||
_filter = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_loading && _channels.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_error != null && _channels.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _ChannelsError(
|
||||
message: _error!,
|
||||
onRetry: _load,
|
||||
),
|
||||
)
|
||||
else if (channels.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _EmptyChannels(),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(18, 4, 18, 28),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: channels.length,
|
||||
separatorBuilder: (_, __) => SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = channels[index];
|
||||
|
||||
return _ChannelRankCard(
|
||||
rank: index + 1,
|
||||
item: item,
|
||||
onTap: () => _open(item),
|
||||
onHide: () => _hide(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelsHeader extends StatelessWidget {
|
||||
const _ChannelsHeader({
|
||||
required this.count,
|
||||
required this.loading,
|
||||
required this.canGoBack,
|
||||
required this.onRefresh,
|
||||
required this.onFilterChanged,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final bool loading;
|
||||
final bool canGoBack;
|
||||
final VoidCallback onRefresh;
|
||||
final ValueChanged<String> onFilterChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final wide = MediaQuery.sizeOf(context).width >= 800;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
wide ? 30 : 18,
|
||||
24,
|
||||
wide ? 30 : 18,
|
||||
14,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (canGoBack) ...[
|
||||
IconButton(
|
||||
tooltip: 'Retour',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 2),
|
||||
],
|
||||
Icon(
|
||||
Icons.video_library_outlined,
|
||||
size: 31,
|
||||
color: Color(0xFFFF4E45),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Chaînes',
|
||||
style: TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Actualiser',
|
||||
onPressed: loading ? null : onRefresh,
|
||||
icon: Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 7),
|
||||
Text(
|
||||
count == 0
|
||||
? 'Les chaînes apparaissent à partir de ce que tu regardes réellement.'
|
||||
: '$count chaîne${count > 1 ? 's' : ''} • triées selon le nombre de tes propres vues',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextField(
|
||||
onChanged: onFilterChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher une chaîne',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelRankCard extends StatelessWidget {
|
||||
const _ChannelRankCard({
|
||||
required this.rank,
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
required this.onHide,
|
||||
});
|
||||
|
||||
final int rank;
|
||||
final ChannelRankItem item;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onHide;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channel = item.channel;
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(13),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text(
|
||||
'#$rank',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFFF766F),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
CircleAvatar(
|
||||
radius: 27,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
backgroundImage: channel.thumbnailUrl.isNotEmpty
|
||||
? NetworkImage(channel.thumbnailUrl)
|
||||
: null,
|
||||
child: channel.thumbnailUrl.isEmpty
|
||||
? Icon(Icons.account_circle, size: 31)
|
||||
: null,
|
||||
),
|
||||
SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
channel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'${item.personalViewCount} de mes vue${item.personalViewCount > 1 ? 's' : ''}'
|
||||
' • ${item.distinctVideoCount} vidéo${item.distinctVideoCount > 1 ? 's' : ''} différente${item.distinctVideoCount > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (item.topVideoTitle != null) ...[
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'La plus regardée par moi (${item.topVideoPersonalViews}×) : ${item.topVideoTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Options',
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
onSelected: (value) {
|
||||
if (value == 'hide') {
|
||||
onHide();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'hide',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.visibility_off_outlined),
|
||||
SizedBox(width: 10),
|
||||
Text('Cette chaîne ne m’intéresse pas'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelsError extends StatelessWidget {
|
||||
const _ChannelsError({
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 52,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: Icon(Icons.refresh),
|
||||
label: Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyChannels extends StatelessWidget {
|
||||
const _EmptyChannels();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.video_library_outlined,
|
||||
size: 68,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Pas encore assez de chaînes',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 7),
|
||||
Text(
|
||||
'Regarde quelques vidéos. Le classement sera construit à partir de tes propres vues.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,490 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
import '../services/library_service.dart';
|
||||
import '../widgets/video_result_card.dart';
|
||||
import 'channels_page.dart';
|
||||
import 'video_player_page.dart';
|
||||
|
||||
class LibraryHubPage extends StatelessWidget {
|
||||
const LibraryHubPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ValueListenableBuilder<List<VideoItem>>(
|
||||
valueListenable: LibraryService.instance.history,
|
||||
builder: (context, history, _) {
|
||||
return ValueListenableBuilder<List<VideoItem>>(
|
||||
valueListenable: LibraryService.instance.favorites,
|
||||
builder: (context, favorites, __) {
|
||||
final recentHistory = history.take(3).toList(growable: false);
|
||||
final recentFavorites =
|
||||
favorites.take(3).toList(growable: false);
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(18, 20, 18, 28),
|
||||
children: [
|
||||
Text(
|
||||
'Bibliothèque',
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Retrouve rapidement ton historique, tes favoris et tes chaînes.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final crossAxisCount = width >= 1100
|
||||
? 3
|
||||
: width >= 720
|
||||
? 2
|
||||
: 1;
|
||||
final mainAxisExtent = width >= 1100
|
||||
? 220.0
|
||||
: width >= 720
|
||||
? 210.0
|
||||
: 196.0;
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: crossAxisCount,
|
||||
shrinkWrap: true,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_LibraryHubCard(
|
||||
icon: Icons.history,
|
||||
title: 'Historique',
|
||||
value: '${history.length}',
|
||||
subtitle: history.isEmpty
|
||||
? 'Aucune vidéo lue pour le moment.'
|
||||
: 'Tes vidéos récemment ouvertes.',
|
||||
onTap: () => _openPage(
|
||||
context,
|
||||
HistoryPage(),
|
||||
),
|
||||
),
|
||||
_LibraryHubCard(
|
||||
icon: Icons.favorite,
|
||||
title: 'Favoris',
|
||||
value: '${favorites.length}',
|
||||
subtitle: favorites.isEmpty
|
||||
? 'Aucune vidéo en favori.'
|
||||
: 'Tes vidéos enregistrées pour plus tard.',
|
||||
onTap: () => _openPage(
|
||||
context,
|
||||
FavoritesPage(),
|
||||
),
|
||||
),
|
||||
_LibraryHubCard(
|
||||
icon: Icons.video_library,
|
||||
title: 'Chaînes',
|
||||
value: 'Explorer',
|
||||
subtitle:
|
||||
'Accède à tes chaînes personnelles et au catalogue.',
|
||||
onTap: () => _openPage(
|
||||
context,
|
||||
ChannelsPage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
if (recentHistory.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: 'Reprendre vite',
|
||||
actionLabel: 'Voir tout',
|
||||
onTap: () => _openPage(context, HistoryPage()),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
...recentHistory.map(
|
||||
(video) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 6),
|
||||
child: VideoResultCard(
|
||||
video: video,
|
||||
onTap: () => _openVideo(context, video),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
if (recentFavorites.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: 'Favoris récents',
|
||||
actionLabel: 'Voir tout',
|
||||
onTap: () => _openPage(context, FavoritesPage()),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
...recentFavorites.map(
|
||||
(video) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 6),
|
||||
child: VideoResultCard(
|
||||
video: video,
|
||||
onTap: () => _openVideo(context, video),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPage(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => page),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideo(BuildContext context, VideoItem video) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => VideoPlayerPage(video: video),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryPage extends StatelessWidget {
|
||||
const HistoryPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _LibraryPage(
|
||||
title: 'Historique',
|
||||
emptyIcon: Icons.history,
|
||||
emptyTitle: 'Aucun historique',
|
||||
emptySubtitle:
|
||||
'Les vidéos que tu regardes apparaîtront ici automatiquement.',
|
||||
listenable: LibraryService.instance.history,
|
||||
onClear: LibraryService.instance.clearHistory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FavoritesPage extends StatelessWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _LibraryPage(
|
||||
title: 'Favoris',
|
||||
emptyIcon: Icons.favorite_border,
|
||||
emptyTitle: 'Aucun favori',
|
||||
emptySubtitle: 'Ajoute des vidéos aux favoris depuis le lecteur.',
|
||||
listenable: LibraryService.instance.favorites,
|
||||
onClear: LibraryService.instance.clearFavorites,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryHubCard extends StatelessWidget {
|
||||
const _LibraryHubCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String value;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: onTap,
|
||||
child: Ink(
|
||||
padding: EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0x22FF6B63),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: Color(0xFFFF7A73)),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
subtitle,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78),
|
||||
fontSize: 12.5,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.actionLabel,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String actionLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(onPressed: onTap, child: Text(actionLabel)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryPage extends StatelessWidget {
|
||||
const _LibraryPage({
|
||||
required this.title,
|
||||
required this.emptyIcon,
|
||||
required this.emptyTitle,
|
||||
required this.emptySubtitle,
|
||||
required this.listenable,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData emptyIcon;
|
||||
final String emptyTitle;
|
||||
final String emptySubtitle;
|
||||
final ValueNotifier<List<VideoItem>> listenable;
|
||||
final Future<void> Function() onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ValueListenableBuilder<List<VideoItem>>(
|
||||
valueListenable: listenable,
|
||||
builder: (context, videos, child) {
|
||||
final canGoBack = Navigator.of(context).canPop();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 18, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
if (canGoBack) ...[
|
||||
IconButton(
|
||||
tooltip: 'Retour',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
if (videos.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Tout effacer',
|
||||
onPressed: () => _confirmClear(context),
|
||||
icon: Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: videos.isEmpty
|
||||
? _EmptyLibrary(
|
||||
icon: emptyIcon,
|
||||
title: emptyTitle,
|
||||
subtitle: emptySubtitle,
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(12, 4, 12, 24),
|
||||
itemCount: videos.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final video = videos[index];
|
||||
|
||||
return VideoResultCard(
|
||||
video: video,
|
||||
onTap: () => _openVideo(context, video),
|
||||
trailing: listenable == LibraryService.instance.history
|
||||
? IconButton(
|
||||
tooltip: "Supprimer de l'historique",
|
||||
icon: Icon(Icons.remove_circle_outline),
|
||||
onPressed: () => LibraryService.instance.removeFromHistory(video.id),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideo(
|
||||
BuildContext context,
|
||||
VideoItem video,
|
||||
) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => VideoPlayerPage(video: video),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmClear(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text('Effacer $title ?'),
|
||||
content: Text(
|
||||
'Cette action supprimera cette liste uniquement sur cet appareil.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text('Effacer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await onClear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyLibrary extends StatelessWidget {
|
||||
const _EmptyLibrary({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/preferred_language_service.dart';
|
||||
import '../services/youtube_service.dart';
|
||||
import '../widgets/video_result_card.dart';
|
||||
import 'video_player_page.dart';
|
||||
|
||||
class SearchPage extends StatefulWidget {
|
||||
const SearchPage({super.key});
|
||||
|
||||
@override
|
||||
State<SearchPage> createState() => _SearchPageState();
|
||||
}
|
||||
|
||||
class _SearchPageState extends State<SearchPage> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final YoutubeService _youtubeService = YoutubeService();
|
||||
|
||||
List<VideoItem> _results = [];
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
String _lastQuery = '';
|
||||
|
||||
Future<void> _search() async {
|
||||
final query = _controller.text.trim();
|
||||
|
||||
if (query.isEmpty || _loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_lastQuery = query;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await _youtubeService.searchVideos(query);
|
||||
final preferredLanguage =
|
||||
AppSettingsService.instance.preferredLanguage.value;
|
||||
final rankedResults = PreferredLanguageService.rankVideos(
|
||||
results,
|
||||
preferredLanguage,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_results = rankedResults;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_results = [];
|
||||
_error =
|
||||
'Impossible de récupérer les résultats YouTube.\n'
|
||||
'Vérifie ta connexion puis réessaie.';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_controller.clear();
|
||||
|
||||
setState(() {
|
||||
_results = [];
|
||||
_error = null;
|
||||
_lastQuery = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _openVideo(VideoItem video) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => VideoPlayerPage(video: video),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_youtubeService.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_SearchHeader(
|
||||
controller: _controller,
|
||||
loading: _loading,
|
||||
onSearch: _search,
|
||||
onClear: _clear,
|
||||
),
|
||||
Expanded(
|
||||
child: _buildBody(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading && _results.isEmpty) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return _ErrorState(
|
||||
message: _error!,
|
||||
onRetry: _search,
|
||||
);
|
||||
}
|
||||
|
||||
if (_results.isEmpty) {
|
||||
if (_lastQuery.isNotEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Aucune vidéo trouvée pour « $_lastQuery ».',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _EmptySearchState();
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 4, 20, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Résultats pour « $_lastQuery »',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(12, 4, 12, 24),
|
||||
itemCount: _results.length,
|
||||
separatorBuilder: (context, index) => SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final video = _results[index];
|
||||
|
||||
return VideoResultCard(
|
||||
video: video,
|
||||
onTap: () => _openVideo(video),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchHeader extends StatelessWidget {
|
||||
const _SearchHeader({
|
||||
required this.controller,
|
||||
required this.loading,
|
||||
required this.onSearch,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool loading;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final wide = MediaQuery.sizeOf(context).width >= 700;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
wide ? 28 : 16,
|
||||
18,
|
||||
wide ? 28 : 16,
|
||||
14,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 900),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => onSearch(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher sur YouTube...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, child) {
|
||||
if (value.text.isEmpty) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
tooltip: 'Effacer',
|
||||
onPressed: onClear,
|
||||
icon: Icon(Icons.close),
|
||||
);
|
||||
},
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xFFFF4E45),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
IconButton.filled(
|
||||
tooltip: 'Rechercher',
|
||||
onPressed: loading ? null : onSearch,
|
||||
icon: Icon(Icons.search),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptySearchState extends StatelessWidget {
|
||||
const _EmptySearchState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.youtube_searched_for,
|
||||
size: 70,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.44),
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
Text(
|
||||
'Recherche YouTube',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Saisis un titre, une chaîne ou un sujet.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86),
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off_outlined,
|
||||
size: 62,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: Icon(Icons.refresh),
|
||||
label: Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user