import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import '../models/channel_details.dart'; import '../models/subscription_channel.dart'; import '../models/video_item.dart'; import '../services/google_auth_service.dart'; import '../services/hidden_channels_service.dart'; import '../services/youtube_account_service.dart'; import '../services/youtube_service.dart'; import '../services/windows_channel_video_catalog_service.dart'; import 'video_player_page.dart'; enum _ChannelSort { newest, mostViewed, mostLiked, oldest, } class ChannelVideosPage extends StatefulWidget { const ChannelVideosPage({ super.key, required this.channel, }); final SubscriptionChannel channel; @override State createState() => _ChannelVideosPageState(); } class _ChannelVideosPageState extends State { final YoutubeAccountService _accountService = YoutubeAccountService(); final YoutubeService _publicService = YoutubeService(); final List _videos = []; ChannelDetails? _channelDetails; bool _detailsLoading = true; bool _loading = true; bool _loadingMore = false; bool _publicHasMore = false; bool _complete = false; String? _error; String _sourceLabel = 'YouTube'; int _loadGeneration = 0; bool _usingPublicSource = false; _ChannelSort _sort = _ChannelSort.newest; bool _channelInfoExpanded = true; @override void initState() { super.initState(); // Sur Android, l'espace vertical est précieux : les informations // détaillées de la chaîne sont repliées par défaut afin d'afficher // immédiatement le catalogue vidéo. Sur desktop, on conserve la vue // détaillée historique, tout en permettant de la replier manuellement. _channelInfoExpanded = !Platform.isAndroid; _loadAllVideos(); } @override void dispose() { _loadGeneration++; _publicService.close(); super.dispose(); } Future _loadPublicChannelDetails( int generation, ) async { try { final details = await _publicService.getPublicChannelDetails( widget.channel.channelId, ); if (!mounted || generation != _loadGeneration) { return; } setState(() { // Official Data API details, when available, may replace this a bit // later with an exact creation timestamp & public video count. _channelDetails = details; _detailsLoading = false; }); } catch (error) { if (!mounted || generation != _loadGeneration) { return; } debugPrint( '[CHANNEL] infos publiques indisponibles: $error', ); setState(() { _detailsLoading = false; }); } } Future _loadAllVideos() async { final generation = ++_loadGeneration; if (!mounted) { return; } setState(() { _videos.clear(); _channelDetails = null; _detailsLoading = true; _loading = true; _loadingMore = false; _publicHasMore = false; _complete = false; _error = null; _sourceLabel = 'Connexion à YouTube…'; _usingPublicSource = false; }); try { unawaited( _loadPublicChannelDetails(generation), ); var usedOfficialApi = false; try { final accessToken = await GoogleAuthService.instance.getAccessToken(); usedOfficialApi = true; _usingPublicSource = false; _sourceLabel = 'YouTube Data API'; try { final details = await _accountService.fetchChannelDetails( accessToken, widget.channel.channelId, ); if (mounted && generation == _loadGeneration) { setState(() { _channelDetails = details; _detailsLoading = false; }); } } catch (detailsError) { debugPrint( '[CHANNEL] statistiques chaîne indisponibles: ' '$detailsError', ); if (mounted && generation == _loadGeneration) { setState(() { _detailsLoading = false; }); } } await for (final video in _accountService.streamAllVideosForChannel( accessToken, widget.channel.channelId, )) { if (!mounted || generation != _loadGeneration) { return; } _videos.add(video); // Official API yields one enriched playlist page at a time. if (_videos.length <= 10 || _videos.length % 10 == 0) { setState(() {}); } } } catch (officialError) { if (!mounted || generation != _loadGeneration) { return; } debugPrint( '[CHANNEL] Data API indisponible -> fallback public: ' '$officialError', ); // If the official source failed before producing data, use the // library's explicit full-upload traversal fallback. if (_videos.isEmpty) { usedOfficialApi = false; _usingPublicSource = true; _sourceLabel = 'Accès public • liste rapide'; final publicSort = switch (_sort) { _ChannelSort.newest => ChannelUploadSort.newest, _ChannelSort.mostViewed => ChannelUploadSort.popularity, _ChannelSort.mostLiked => ChannelUploadSort.newest, _ChannelSort.oldest => ChannelUploadSort.oldest, }; final firstBatch = await _publicService.getChannelUploadsPage( widget.channel.channelId, sort: publicSort, reset: true, ); if (!mounted || generation != _loadGeneration) { return; } _videos.addAll(firstBatch.videos); _publicHasMore = firstBatch.hasMore; if (_videos.isNotEmpty) { setState(() {}); } // Windows: enrich the WHOLE visible catalogue through one yt-dlp // flat-playlist process. No videos.get() per entry. if (Platform.isWindows && mounted && generation == _loadGeneration && _videos.isNotEmpty) { setState(() { _sourceLabel = 'Accès public • métadonnées groupées…'; }); final metadata = await WindowsChannelVideoCatalogService .instance .getMetadata( widget.channel.channelId, ); if (!mounted || generation != _loadGeneration) { return; } if (metadata.isNotEmpty) { for (var index = 0; index < _videos.length; index++) { final base = _videos[index]; final extra = metadata[base.id]; if (extra != null) { _videos[index] = _mergeVideoMetadata( base, extra, ); } } setState(() { _sourceLabel = 'Accès public • métadonnées groupées'; }); } } } else { rethrow; } } if (!mounted || generation != _loadGeneration) { return; } setState(() { _loading = false; _complete = usedOfficialApi || !_publicHasMore; _sourceLabel = usedOfficialApi ? 'YouTube Data API' : 'Accès public'; }); } catch (error) { if (!mounted || generation != _loadGeneration) { return; } setState(() { _loading = false; _error = error.toString(); }); } } DateTime? get _newestLoadedVideoDate { DateTime? newest; for (final video in _videos) { final date = video.uploadDate; if (date == null) { continue; } if (newest == null || date.isAfter(newest)) { newest = date; } } return newest; } int get _loadedViewCount { return _videos.fold( 0, (sum, video) => sum + (video.viewCount ?? 0), ); } int get _viewsMeasuredVideos { return _videos .where( (video) => video.viewCount != null, ) .length; } int get _averageViewCount { final measured = _viewsMeasuredVideos; if (measured == 0) { return 0; } return _loadedViewCount ~/ measured; } Duration get _loadedDuration { return _videos.fold( Duration.zero, (sum, video) => sum + (video.duration ?? Duration.zero), ); } DateTime? get _oldestLoadedVideoDate { DateTime? oldest; for (final video in _videos) { final date = video.uploadDate; if (date == null) { continue; } if (oldest == null || date.isBefore(oldest)) { oldest = date; } } return oldest; } int get _loadedLikeCount { return _videos.fold( 0, (sum, video) => sum + (video.likeCount ?? 0), ); } int get _loadedCommentCount { return _videos.fold( 0, (sum, video) => sum + (video.commentCount ?? 0), ); } int get _likesMeasuredVideos { return _videos .where((video) => video.likeCount != null) .length; } int get _commentsMeasuredVideos { return _videos .where((video) => video.commentCount != null) .length; } VideoItem _mergeVideoMetadata( VideoItem base, VideoItem extra, ) { Duration? duration = extra.duration; if (duration == null || duration <= Duration.zero) { duration = base.duration; } final extraDescription = extra.description.trim(); return VideoItem( id: base.id, title: extra.title.trim().isNotEmpty ? extra.title : base.title, author: extra.author.trim().isNotEmpty ? extra.author : base.author, thumbnailUrl: extra.thumbnailUrl.trim().isNotEmpty ? extra.thumbnailUrl : base.thumbnailUrl, duration: duration, channelId: extra.channelId?.trim().isNotEmpty == true ? extra.channelId : base.channelId, viewCount: extra.viewCount ?? base.viewCount, likeCount: extra.likeCount ?? base.likeCount, commentCount: extra.commentCount ?? base.commentCount, description: extraDescription.isNotEmpty ? extra.description : base.description, uploadDate: extra.uploadDate ?? base.uploadDate, ); } List get _sortedVideos { final list = List.of(_videos); final epoch = DateTime.fromMillisecondsSinceEpoch(0); int newestFirst( VideoItem a, VideoItem b, ) { final date = (b.uploadDate ?? epoch) .compareTo(a.uploadDate ?? epoch); if (date != 0) { return date; } return (b.viewCount ?? 0) .compareTo(a.viewCount ?? 0); } switch (_sort) { case _ChannelSort.newest: list.sort(newestFirst); break; case _ChannelSort.mostViewed: list.sort( (a, b) { final views = (b.viewCount ?? 0) .compareTo( a.viewCount ?? 0, ); return views != 0 ? views : newestFirst(a, b); }, ); break; case _ChannelSort.mostLiked: list.sort( (a, b) { final likes = (b.likeCount ?? 0) .compareTo( a.likeCount ?? 0, ); return likes != 0 ? likes : newestFirst(a, b); }, ); break; case _ChannelSort.oldest: list.sort( (a, b) { final date = (a.uploadDate ?? epoch) .compareTo( b.uploadDate ?? epoch, ); if (date != 0) { return date; } return (a.viewCount ?? 0) .compareTo( b.viewCount ?? 0, ); }, ); break; } return list; } ChannelUploadSort get _publicSort { return switch (_sort) { _ChannelSort.newest => ChannelUploadSort.newest, _ChannelSort.mostViewed => ChannelUploadSort.popularity, _ChannelSort.mostLiked => ChannelUploadSort.newest, _ChannelSort.oldest => ChannelUploadSort.oldest, }; } Future _loadMorePublicVideos() async { if (_loading || _loadingMore || !_usingPublicSource || !_publicHasMore) { return; } final generation = _loadGeneration; setState(() { _loadingMore = true; _sourceLabel = 'Accès public • page suivante…'; }); try { final batch = await _publicService.getChannelUploadsPage( widget.channel.channelId, sort: _publicSort, ); if (!mounted || generation != _loadGeneration) { return; } final existingIds = _videos .map((video) => video.id) .toSet(); final added = batch.videos .where((video) => existingIds.add(video.id)) .toList(growable: false); final firstAddedIndex = _videos.length; _videos.addAll(added); _publicHasMore = batch.hasMore; // Windows already has one grouped yt-dlp catalogue in cache. Reuse it // to enrich only the newly appended page, without per-video requests. if (Platform.isWindows && added.isNotEmpty) { final metadata = await WindowsChannelVideoCatalogService.instance.getMetadata( widget.channel.channelId, ); if (!mounted || generation != _loadGeneration) { return; } if (metadata.isNotEmpty) { for (var index = firstAddedIndex; index < _videos.length; index++) { final base = _videos[index]; final extra = metadata[base.id]; if (extra != null) { _videos[index] = _mergeVideoMetadata(base, extra); } } } } setState(() { _loadingMore = false; _complete = !_publicHasMore; _sourceLabel = 'Accès public'; }); } catch (error) { if (!mounted || generation != _loadGeneration) { return; } setState(() { _loadingMore = false; _sourceLabel = 'Accès public'; }); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( duration: Duration(seconds: 4), content: Text( 'Impossible de charger la page suivante : $error', ), ), ); } } Future _openVideo(VideoItem video) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => VideoPlayerPage(video: video), ), ); } Future _hideChannel() async { await HiddenChannelsService.instance.hide(widget.channel); if (!mounted) { return; } Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final videos = _sortedVideos; return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, titleSpacing: 0, title: Row( children: [ CircleAvatar( radius: 18, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, backgroundImage: widget.channel.thumbnailUrl.isNotEmpty ? NetworkImage(widget.channel.thumbnailUrl) : null, child: widget.channel.thumbnailUrl.isEmpty ? Icon(Icons.account_circle) : null, ), SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.channel.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( _complete ? '${_videos.length} vidéo${_videos.length > 1 ? 's' : ''} • $_sourceLabel' : '${_videos.length} chargée${_videos.length > 1 ? 's' : ''} • $_sourceLabel', style: TextStyle( fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), ), ), ], ), ), ], ), actions: [ IconButton( tooltip: 'Actualiser', onPressed: _loading ? null : _loadAllVideos, icon: Icon(Icons.refresh), ), PopupMenuButton( tooltip: 'Options de la chaîne', color: Theme.of(context).colorScheme.surfaceContainerHighest, onSelected: (value) { if (value == 'hide') { _hideChannel(); } }, itemBuilder: (_) => [ PopupMenuItem( value: 'hide', child: Row( children: [ Icon(Icons.visibility_off_outlined), SizedBox(width: 10), Text('Ne plus me proposer cette chaîne'), ], ), ), ], ), ], ), body: SafeArea( child: Column( children: [ _ChannelInfoToggle( expanded: _channelInfoExpanded, onPressed: () { setState(() { _channelInfoExpanded = !_channelInfoExpanded; }); }, ), AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeInOut, alignment: Alignment.topCenter, child: _channelInfoExpanded ? Column( mainAxisSize: MainAxisSize.min, children: [ _ChannelInfoCard( channel: widget.channel, details: _channelDetails, detailsLoading: _detailsLoading, loadedLikeCount: _loadedLikeCount, likesMeasuredVideos: _likesMeasuredVideos, loadedCommentCount: _loadedCommentCount, commentsMeasuredVideos: _commentsMeasuredVideos, catalogueComplete: _complete, oldestLoadedVideoDate: _oldestLoadedVideoDate, ), _ChannelCatalogueSummary( loadedVideos: _videos.length, loadedViews: _loadedViewCount, viewsMeasuredVideos: _viewsMeasuredVideos, averageViews: _averageViewCount, loadedLikes: _loadedLikeCount, loadedDuration: _loadedDuration, newestDate: _newestLoadedVideoDate, oldestDate: _oldestLoadedVideoDate, loading: _loading, ), ], ) : const SizedBox.shrink(), ), _ChannelToolbar( sort: _sort, loading: _loading, onSortChanged: (value) { if (value == _sort) { return; } setState(() { _sort = value; }); // The public YouTube channel endpoint itself knows how to // order newest/popular/oldest. Reload when using it so a sort // still changes the list even if some metadata fields are // absent. if (_usingPublicSource) { unawaited( _loadAllVideos(), ); } }, ), Expanded(child: _buildBody(videos)), ], ), ), ); } Widget _buildBody(List videos) { if (_loading && videos.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 14), Text( 'Chargement et enrichissement des vidéos…', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), ), ), SizedBox(height: 5), Text( 'Durée • vues • likes • date • description', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), fontSize: 11, ), ), ], ), ); } if (_error != null && videos.isEmpty) { 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: 14), Text( _error!, textAlign: TextAlign.center, style: TextStyle( color: Colors.redAccent, ), ), SizedBox(height: 18), FilledButton.icon( onPressed: _loadAllVideos, icon: Icon( Icons.refresh, ), label: Text( 'Réessayer', ), ), ], ), ), ); } if (videos.isEmpty) { return Center( child: Text( 'Aucune vidéo publique trouvée.', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), ), ), ); } return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final columns = width >= 1500 ? 4 : width >= 1080 ? 3 : width >= 720 ? 2 : 1; final cardHeight = columns == 1 ? 350.0 : 360.0; return Column( children: [ Expanded( child: RefreshIndicator( onRefresh: _loadAllVideos, child: GridView.builder( key: ValueKey<_ChannelSort>( _sort, ), physics: AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( 18, 10, 18, 20, ), itemCount: videos.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, crossAxisSpacing: 14, mainAxisSpacing: 14, mainAxisExtent: cardHeight, ), itemBuilder: (context, index) { final video = videos[index]; return _ChannelVideoCard( video: video, rank: index + 1, onTap: () => _openVideo(video), ); }, ), ), ), if (_usingPublicSource && (_publicHasMore || _loadingMore)) SafeArea( top: false, child: Padding( padding: EdgeInsets.fromLTRB( 18, 10, 18, 14, ), child: SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _loadingMore ? null : _loadMorePublicVideos, icon: _loadingMore ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Icon( Icons.expand_more, ), label: Text( _loadingMore ? 'Chargement…' : 'Charger 30 vidéos de plus', ), ), ), ), ), ], ); }, ); } String _formatCount(int value) { if (value >= 1000000000) { return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; } if (value >= 1000000) { return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; } if (value >= 1000) { return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; } return '$value'; } String _formatDate(DateTime value) { return '${value.day.toString().padLeft(2, '0')}/' '${value.month.toString().padLeft(2, '0')}/' '${value.year}'; } } class _ChannelInfoToggle extends StatelessWidget { const _ChannelInfoToggle({ required this.expanded, required this.onPressed, }); final bool expanded; final VoidCallback onPressed; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), child: Material( color: colors.surfaceContainerLow, borderRadius: BorderRadius.circular(14), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), child: Row( children: [ Icon( expanded ? Icons.expand_less_rounded : Icons.info_outline_rounded, size: 21, color: colors.primary, ), const SizedBox(width: 10), Expanded( child: Text( expanded ? 'Masquer les infos' : 'Infos de la chaîne', style: TextStyle( fontWeight: FontWeight.w600, color: colors.onSurface, ), ), ), Icon( expanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, color: colors.onSurfaceVariant, ), ], ), ), ), ), ); } } class _ChannelCatalogueSummary extends StatelessWidget { const _ChannelCatalogueSummary({ required this.loadedVideos, required this.loadedViews, required this.viewsMeasuredVideos, required this.averageViews, required this.loadedLikes, required this.loadedDuration, required this.newestDate, required this.oldestDate, required this.loading, }); final int loadedVideos; final int loadedViews; final int viewsMeasuredVideos; final int averageViews; final int loadedLikes; final Duration loadedDuration; final DateTime? newestDate; final DateTime? oldestDate; final bool loading; @override Widget build(BuildContext context) { if (loadedVideos == 0 && loading) { return SizedBox.shrink(); } return Padding( padding: EdgeInsets.fromLTRB( 14, 7, 14, 1, ), child: Wrap( spacing: 8, runSpacing: 8, children: [ _CatalogueMetric( icon: Icons.video_library_outlined, label: 'Chargées', value: '$loadedVideos', ), if (viewsMeasuredVideos > 0) _CatalogueMetric( icon: Icons.visibility_outlined, label: 'Vues cumulées', value: _compact(loadedViews), ), if (averageViews > 0) _CatalogueMetric( icon: Icons.analytics_outlined, label: 'Moyenne / vidéo', value: _compact(averageViews), ), if (loadedLikes > 0) _CatalogueMetric( icon: Icons.thumb_up_alt_outlined, label: 'Likes cumulés', value: _compact(loadedLikes), ), if (loadedDuration > Duration.zero) _CatalogueMetric( icon: Icons.schedule_outlined, label: 'Durée chargée', value: _duration( loadedDuration, ), ), if (newestDate != null && oldestDate != null) _CatalogueMetric( icon: Icons.date_range_outlined, label: 'Période', value: '${_year(oldestDate!)} → ${_year(newestDate!)}', ), ], ), ); } static String _compact(int value) { if (value >= 1000000000) { return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; } if (value >= 1000000) { return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; } if (value >= 1000) { return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; } return '$value'; } static String _duration( Duration value, ) { if (value.inHours >= 24) { final days = value.inHours / 24; return '${days.toStringAsFixed(days >= 10 ? 0 : 1)} j'; } if (value.inHours > 0) { return '${value.inHours} h'; } return '${value.inMinutes} min'; } static String _year(DateTime value) { return '${value.year}'; } } class _CatalogueMetric extends StatelessWidget { const _CatalogueMetric({ required this.icon, required this.label, required this.value, }); final IconData icon; final String label; final String value; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric( horizontal: 11, vertical: 8, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(11), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.55), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 16, color: Color(0xFFFF8E87), ), SizedBox(width: 7), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 9.5, ), ), Text( value, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 12, fontWeight: FontWeight.w700, ), ), ], ), ], ), ); } } class _ChannelVideoCard extends StatelessWidget { const _ChannelVideoCard({ required this.video, required this.rank, required this.onTap, }); final VideoItem video; final int rank; final VoidCallback onTap; @override Widget build(BuildContext context) { final description = video.description .replaceAll( RegExp(r'\s+'), ' ', ) .trim(); return Material( color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(15), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ Image.network( video.thumbnailUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => ColoredBox( color: Theme.of(context).colorScheme.surfaceContainerHigh, child: Center( child: Icon( Icons .broken_image_outlined, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), ), ), ), ), Positioned( left: 8, top: 8, child: DecoratedBox( decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius .circular(7), ), child: Padding( padding: const EdgeInsets .symmetric( horizontal: 7, vertical: 4, ), child: Text( '#$rank', style: TextStyle( fontSize: 10, fontWeight: FontWeight .w800, ), ), ), ), ), if (video.duration != null && video.duration! > Duration.zero) Positioned( right: 7, bottom: 7, child: DecoratedBox( decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius .circular(5), ), child: Padding( padding: const EdgeInsets .symmetric( horizontal: 6, vertical: 3, ), child: Text( video.durationLabel, style: TextStyle( fontSize: 10.5, fontWeight: FontWeight .w700, ), ), ), ), ), ], ), ), Expanded( child: Padding( padding: EdgeInsets.fromLTRB( 12, 10, 12, 10, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( video.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.5, height: 1.25, fontWeight: FontWeight.w700, ), ), SizedBox(height: 7), Wrap( spacing: 10, runSpacing: 5, children: [ if (video.viewCount != null && video.viewCount! > 0) _MiniVideoStat( icon: Icons.visibility_outlined, text: '${_compact(video.viewCount!)} vues', ), if (video.likeCount != null && video.likeCount! > 0) _MiniVideoStat( icon: Icons.thumb_up_alt_outlined, text: '${_compact(video.likeCount!)} likes', ), if (video.commentCount != null) _MiniVideoStat( icon: Icons.chat_bubble_outline, text: '${_compact(video.commentCount!)} commentaires', ), ], ), if (video.uploadDate != null) ...[ SizedBox(height: 7), Text( 'Publiée le ${_date(video.uploadDate!)}', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 10.5, ), ), ], if (description.isNotEmpty) ...[ SizedBox(height: 7), Expanded( child: Text( description, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle( color: Color(0x73FFFFFF), fontSize: 11, height: 1.32, ), ), ), ] else Spacer(), ], ), ), ), ], ), ), ); } static String _compact(int value) { if (value >= 1000000000) { return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; } if (value >= 1000000) { return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; } if (value >= 1000) { return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; } return '$value'; } static String _date(DateTime value) { return '${value.day.toString().padLeft(2, '0')}/' '${value.month.toString().padLeft(2, '0')}/' '${value.year}'; } } class _MiniVideoStat extends StatelessWidget { const _MiniVideoStat({ required this.icon, required this.text, }); final IconData icon; final String text; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 13, color: Color(0xFFFF938D), ), SizedBox(width: 4), Text( text, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), fontSize: 10.5, fontWeight: FontWeight.w500, ), ), ], ); } } class _ChannelInfoCard extends StatelessWidget { const _ChannelInfoCard({ required this.channel, required this.details, required this.detailsLoading, required this.loadedLikeCount, required this.likesMeasuredVideos, required this.loadedCommentCount, required this.commentsMeasuredVideos, required this.catalogueComplete, required this.oldestLoadedVideoDate, }); final SubscriptionChannel channel; final ChannelDetails? details; final bool detailsLoading; final int loadedLikeCount; final int likesMeasuredVideos; final int loadedCommentCount; final int commentsMeasuredVideos; final bool catalogueComplete; final DateTime? oldestLoadedVideoDate; @override Widget build(BuildContext context) { final value = details; final description = value?.description.trim().isNotEmpty == true ? value!.description.trim() : channel.description.trim(); return Container( width: double.infinity, margin: EdgeInsets.fromLTRB(14, 12, 14, 6), 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ CircleAvatar( radius: 34, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, backgroundImage: (value?.thumbnailUrl.isNotEmpty == true) ? NetworkImage(value!.thumbnailUrl) : channel.thumbnailUrl.isNotEmpty ? NetworkImage(channel.thumbnailUrl) : null, child: (value?.thumbnailUrl.isNotEmpty == true || channel.thumbnailUrl.isNotEmpty) ? null : Icon( Icons.account_circle, size: 42, ), ), SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( value?.title.isNotEmpty == true ? value!.title : channel.title, style: TextStyle( fontSize: 20, fontWeight: FontWeight.w800, ), ), if (value?.country != null && value!.country!.trim().isNotEmpty) ...[ SizedBox(height: 4), Text( 'Pays : ${value.country}', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 12, ), ), ], if (description.isNotEmpty) ...[ SizedBox(height: 8), Text( description, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), fontSize: 12, height: 1.35, ), ), ], ], ), ), if (detailsLoading) Padding( padding: EdgeInsets.only(left: 8, top: 4), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ), ), ], ), SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: [ if (value?.publishedAt != null) _ChannelStatChip( icon: Icons.calendar_month_outlined, label: 'Créée le', value: _formatDate(value!.publishedAt!), ) else if (value?.joinDateLabel != null && value!.joinDateLabel!.isNotEmpty) _ChannelStatChip( icon: Icons.calendar_month_outlined, label: 'Chaîne créée', value: value.joinDateLabel!, ) else if (catalogueComplete && oldestLoadedVideoDate != null) _ChannelStatChip( icon: Icons.history_rounded, label: 'Plus ancienne vidéo', value: _formatDate(oldestLoadedVideoDate!), ), if (value?.subscriberCount != null) _ChannelStatChip( icon: Icons.people_alt_outlined, label: 'Abonnés', value: _formatCount(value!.subscriberCount!), ) else if (value?.hiddenSubscriberCount == true) _ChannelStatChip( icon: Icons.people_alt_outlined, label: 'Abonnés', value: 'masqués', ), if (value?.viewCount != null) _ChannelStatChip( icon: Icons.visibility_outlined, label: 'Vues chaîne', value: _formatCount(value!.viewCount!), ), if (value?.videoCount != null) _ChannelStatChip( icon: Icons.video_library_outlined, label: 'Vidéos publiques', value: _formatCount(value!.videoCount!), ), if (likesMeasuredVideos > 0) _ChannelStatChip( icon: Icons.thumb_up_alt_outlined, label: catalogueComplete ? 'Likes cumulés' : 'Likes chargés', value: _formatCount(loadedLikeCount), subtitle: '$likesMeasuredVideos vidéo${likesMeasuredVideos > 1 ? 's' : ''} mesurée${likesMeasuredVideos > 1 ? 's' : ''}', showInfo: true, ), if (commentsMeasuredVideos > 0) _ChannelStatChip( icon: Icons.mode_comment_outlined, label: catalogueComplete ? 'Commentaires cumulés' : 'Commentaires chargés', value: _formatCount(loadedCommentCount), subtitle: '$commentsMeasuredVideos vidéo${commentsMeasuredVideos > 1 ? 's' : ''} mesurée${commentsMeasuredVideos > 1 ? 's' : ''}', ), ], ), if (likesMeasuredVideos > 0) ...[ SizedBox(height: 10), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.info_outline, size: 15, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), ), SizedBox(width: 6), Expanded( child: Text( 'YouTube ne fournit pas un compteur de likes global au niveau de la chaîne. ' 'YouTube Personal additionne donc les likes publics des vidéos chargées. ' 'Ce calcul fonctionne aussi sans connexion Google.', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 10.5, height: 1.3, ), ), ), ], ), ], ], ), ); } static String _formatCount(int value) { if (value >= 1000000000) { return '${(value / 1000000000).toStringAsFixed(value >= 10000000000 ? 0 : 1)} Md'; } if (value >= 1000000) { return '${(value / 1000000).toStringAsFixed(value >= 10000000 ? 0 : 1)} M'; } if (value >= 1000) { return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)} k'; } return '$value'; } static String _formatDate(DateTime value) { return '${value.day.toString().padLeft(2, '0')}/' '${value.month.toString().padLeft(2, '0')}/' '${value.year}'; } } class _ChannelStatChip extends StatelessWidget { const _ChannelStatChip({ required this.icon, required this.label, required this.value, this.subtitle, this.showInfo = false, }); final IconData icon; final String label; final String value; final String? subtitle; final bool showInfo; @override Widget build(BuildContext context) { return Container( constraints: BoxConstraints( minWidth: 132, ), padding: EdgeInsets.symmetric( horizontal: 11, vertical: 9, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 18, color: Color(0xFFFF766F), ), SizedBox(width: 8), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( label, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.78), fontSize: 10.5, ), ), ), if (showInfo) ...[ SizedBox(width: 4), Icon( Icons.info_outline, size: 12, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.52), ), ], ], ), SizedBox(height: 1), Text( value, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 14, fontWeight: FontWeight.w700, ), ), if (subtitle != null) ...[ SizedBox(height: 1), Text( subtitle!, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 9.5, ), ), ], ], ), ), ], ), ); } } class _ChannelToolbar extends StatelessWidget { const _ChannelToolbar({ required this.sort, required this.loading, required this.onSortChanged, }); final _ChannelSort sort; final bool loading; final ValueChanged<_ChannelSort> onSortChanged; String get _description { switch (sort) { case _ChannelSort.newest: return 'Date de publication • plus récente en premier'; case _ChannelSort.mostViewed: return 'Vues publiques YouTube • plus vue en premier'; case _ChannelSort.mostLiked: return 'Likes publics • plus aimée en premier'; case _ChannelSort.oldest: return 'Date de publication • plus ancienne en premier'; } } @override Widget build(BuildContext context) { return Container( margin: EdgeInsets.fromLTRB( 14, 8, 14, 4, ), padding: EdgeInsets.symmetric( horizontal: 14, vertical: 9, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(13), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.55), ), ), child: Row( children: [ Icon( Icons.sort_rounded, size: 19, color: Color(0xFFFF8E87), ), SizedBox(width: 8), Text( 'Trier', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.86), fontSize: 12, ), ), SizedBox(width: 10), DropdownButton<_ChannelSort>( value: sort, underline: SizedBox.shrink(), onChanged: loading ? null : (value) { if (value != null) { onSortChanged(value); } }, items: [ DropdownMenuItem( value: _ChannelSort.newest, child: Text('Plus récentes'), ), DropdownMenuItem( value: _ChannelSort.mostViewed, child: Text('Plus vues'), ), DropdownMenuItem( value: _ChannelSort.mostLiked, child: Text('Plus aimées'), ), DropdownMenuItem( value: _ChannelSort.oldest, child: Text('Plus anciennes'), ), ], ), SizedBox(width: 14), Expanded( child: Text( _description, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.62), fontSize: 10.5, ), ), ), if (loading) ...[ SizedBox(width: 10), SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, ), ), ], ], ), ); } }