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