Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+536
View File
@@ -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)),
),
],
),
),
);
}
}