2037 lines
57 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|