Files
youtube_personal/lib/screens/channel_videos_page.dart
T

2037 lines
57 KiB
Dart

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<ChannelVideosPage> createState() =>
_ChannelVideosPageState();
}
class _ChannelVideosPageState extends State<ChannelVideosPage> {
final YoutubeAccountService _accountService = YoutubeAccountService();
final YoutubeService _publicService = YoutubeService();
final List<VideoItem> _videos = <VideoItem>[];
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<void> _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<void> _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<int>(
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>(
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<int>(
0,
(sum, video) => sum + (video.likeCount ?? 0),
);
}
int get _loadedCommentCount {
return _videos.fold<int>(
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<VideoItem> get _sortedVideos {
final list = List<VideoItem>.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<void> _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<void> _openVideo(VideoItem video) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VideoPlayerPage(video: video),
),
);
}
Future<void> _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<String>(
tooltip: 'Options de la chaîne',
color: Theme.of(context).colorScheme.surfaceContainerHighest,
onSelected: (value) {
if (value == 'hide') {
_hideChannel();
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
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<VideoItem> 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,
),
),
],
],
),
);
}
}