Files
youtube_personal/lib/screens/home_page.dart
T

2590 lines
69 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../models/radar_recommendation.dart';
import '../models/video_item.dart';
import '../services/hidden_channels_service.dart';
import '../services/channel_preview_metadata_service.dart';
import '../services/library_service.dart';
import '../services/radar_service.dart';
import 'video_player_page.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() =>
_HomePageState();
}
class _HomePageState
extends State<HomePage> {
RadarSessionMode _mode =
RadarSessionMode.normal;
static const List<String>
_interestOptions = <String>[
'Tous',
'Politique',
'Finance',
'Technologie & IA',
'Jeux vidéo',
'Culture & histoire',
'Science & espace',
'OVNI & mystères',
'Auto & transport',
'Musique',
'Société & actualité',
'Tutoriels & apprentissage',
];
String _selectedInterest = 'Tous';
String? _selectedRadarCategory;
RadarFeed? _feed;
bool _loadingRadar = false;
String? _radarError;
Timer? _refreshDebounce;
@override
void initState() {
super.initState();
LibraryService.instance.history
.addListener(_onLibraryChanged);
LibraryService.instance.favorites
.addListener(_onLibraryChanged);
HiddenChannelsService.instance.revision
.addListener(_onLibraryChanged);
WidgetsBinding.instance
.addPostFrameCallback(
(_) => _refreshRadar(),
);
}
@override
void dispose() {
_refreshDebounce?.cancel();
LibraryService.instance.history
.removeListener(_onLibraryChanged);
LibraryService.instance.favorites
.removeListener(_onLibraryChanged);
HiddenChannelsService.instance.revision
.removeListener(_onLibraryChanged);
super.dispose();
}
void _onLibraryChanged() {
_refreshDebounce?.cancel();
_refreshDebounce = Timer(
const Duration(milliseconds: 650),
() {
if (mounted) {
unawaited(
_refreshRadar(force: true),
);
}
},
);
}
Future<void> _refreshRadar({
bool force = false,
}) async {
if (_loadingRadar) {
return;
}
final history =
LibraryService.instance.history.value;
final favorites =
LibraryService.instance.favorites.value;
if (history.isEmpty &&
favorites.isEmpty) {
if (mounted) {
setState(() {
_feed = null;
_radarError = null;
_loadingRadar = false;
});
}
return;
}
setState(() {
_loadingRadar = true;
_radarError = null;
});
try {
final feed =
await RadarService.instance.generate(
mode: _mode,
force: force,
interest: _selectedInterest,
);
if (!mounted) {
return;
}
setState(() {
_feed = feed;
if (feed.categories.isEmpty) {
_selectedRadarCategory = null;
} else if (_selectedRadarCategory == null ||
!feed.categories.containsKey(
_selectedRadarCategory,
)) {
_selectedRadarCategory =
feed.categories.keys.first;
}
_loadingRadar = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_loadingRadar = false;
_radarError =
'Le Radar n’a pas pu reconstruire '
'les propositions.';
});
debugPrint(
'[RADAR] génération échouée: $error',
);
}
}
Future<void> _changeInterest(
String interest,
) async {
if (interest == _selectedInterest) {
return;
}
setState(() {
_selectedInterest = interest;
});
await _refreshRadar(
force: true,
);
}
Future<void> _changeMode(
RadarSessionMode mode,
) async {
if (mode == _mode) {
return;
}
setState(() {
_mode = mode;
});
await _refreshRadar();
}
Future<void> _dismiss(
RadarRecommendation item,
) async {
await RadarService.instance
.dismissVideo(item.video.id);
if (!mounted) {
return;
}
_removeFromFeed(item.video.id);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text(
'Pas maintenant. Cette vidéo disparaît '
'du Radar pendant un moment.',
),
duration: Duration(seconds: 2),
),
);
}
Future<void> _reduceAuthor(
RadarRecommendation item,
) async {
await RadarService.instance
.reduceAuthor(item.video.author);
if (!mounted) {
return;
}
_removeAuthorFromFeed(
item.video.author,
);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Le Radar montrera moins de vidéos de '
'${item.video.author}.',
),
duration: const Duration(
seconds: 2,
),
),
);
}
void _removeFromFeed(
String videoId,
) {
final current = _feed;
if (current == null) {
return;
}
setState(() {
_feed = RadarFeed(
mode: current.mode,
generatedAt: current.generatedAt,
profileTopics:
current.profileTopics,
primary: current.primary
.where(
(item) =>
item.video.id != videoId,
)
.toList(growable: false),
continueTopic:
current.continueTopic
.where(
(item) =>
item.video.id !=
videoId,
)
.toList(growable: false),
discovery: current.discovery
.where(
(item) =>
item.video.id != videoId,
)
.toList(growable: false),
categories: {
for (final entry
in current.categories.entries)
entry.key: entry.value
.where(
(item) =>
item.video.id != videoId,
)
.toList(
growable: false,
),
},
);
});
}
void _removeAuthorFromFeed(
String author,
) {
final current = _feed;
if (current == null) {
return;
}
final normalized =
author.trim().toLowerCase();
bool keep(
RadarRecommendation item,
) {
return item.video.author
.trim()
.toLowerCase() !=
normalized;
}
setState(() {
_feed = RadarFeed(
mode: current.mode,
generatedAt: current.generatedAt,
profileTopics:
current.profileTopics,
primary:
current.primary
.where(keep)
.toList(
growable: false,
),
continueTopic:
current.continueTopic
.where(keep)
.toList(
growable: false,
),
discovery:
current.discovery
.where(keep)
.toList(
growable: false,
),
categories: {
for (final entry
in current.categories.entries)
entry.key: entry.value
.where(keep)
.toList(
growable: false,
),
},
);
});
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: ValueListenableBuilder<
List<VideoItem>>(
valueListenable:
LibraryService.instance.history,
builder: (
context,
history,
child,
) {
return ValueListenableBuilder<
List<VideoItem>>(
valueListenable:
LibraryService
.instance.favorites,
builder: (
context,
favorites,
child,
) {
final continueWatching =
history
.where(
(video) =>
LibraryService
.instance
.getPlaybackPosition(
video.id,
) !=
null,
)
.take(10)
.toList(
growable: false,
);
final rediscover =
_buildRediscover(
history,
favorites,
);
final feed = _feed;
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _RadarHeader(
historyCount:
history.length,
favoritesCount:
favorites.length,
mode: _mode,
profileTopics:
feed?.profileTopics ??
const [],
interests:
_interestOptions,
selectedInterest:
_selectedInterest,
loading:
_loadingRadar,
onInterestChanged:
_changeInterest,
onModeChanged:
_changeMode,
onShuffle: () =>
_refreshRadar(
force: true,
),
),
),
if (feed != null &&
feed.primary.isNotEmpty)
SliverToBoxAdapter(
child: _RadarSection(
title:
'Pour toi maintenant',
subtitle:
_selectedInterest == 'Tous'
? 'Le meilleur mélange calculé localement pour cette session.'
: 'Sélection actuelle : $_selectedInterest.',
icon:
Icons.radar_rounded,
items:
feed.primary,
accent:
const Color(
0xFFFF4E45,
),
onWhy: _showWhy,
onDismiss:
_dismiss,
onReduceAuthor:
_reduceAuthor,
),
),
if (_loadingRadar &&
(feed == null ||
feed.isEmpty))
const SliverToBoxAdapter(
child:
_RadarLoadingCard(),
),
if (_radarError != null)
SliverToBoxAdapter(
child:
_RadarErrorCard(
text: _radarError!,
onRetry:
_refreshRadar,
),
),
if (continueWatching
.isNotEmpty)
SliverToBoxAdapter(
child: _VideoSection(
title:
'Continuer à regarder',
subtitle:
'Reprends exactement là où tu t’étais arrêté.',
videos:
continueWatching,
showProgress: true,
),
),
if (feed != null &&
feed.continueTopic
.isNotEmpty)
SliverToBoxAdapter(
child: _RadarSection(
title:
'Continuer ce sujet',
subtitle:
'Même fil conducteur, sans répéter exactement ce que tu as déjà vu.',
icon:
Icons.route_rounded,
items: feed
.continueTopic,
accent:
const Color(
0xFF7CA8FF,
),
onWhy: _showWhy,
onDismiss:
_dismiss,
onReduceAuthor:
_reduceAuthor,
),
),
if (feed != null &&
feed.discovery
.isNotEmpty)
SliverToBoxAdapter(
child: _RadarSection(
title:
'Quelque chose de différent',
subtitle:
'Des chaînes nouvelles, mais reliées à tes vrais centres d’intérêt.',
icon: Icons
.explore_outlined,
items:
feed.discovery,
accent:
const Color(
0xFF7FE0B8,
),
onWhy: _showWhy,
onDismiss:
_dismiss,
onReduceAuthor:
_reduceAuthor,
),
),
if (rediscover.isNotEmpty)
SliverToBoxAdapter(
child: _VideoSection(
title:
'À redécouvrir',
subtitle:
'Des vidéos de ton propre historique qui ont disparu de ton écran depuis un moment.',
videos:
rediscover,
),
),
if (history.isNotEmpty)
SliverToBoxAdapter(
child: _VideoSection(
title:
'Vues récemment',
subtitle:
'Tes dernières vidéos regardées.',
videos: history
.take(10)
.toList(
growable:
false,
),
),
),
if (favorites.isNotEmpty)
SliverToBoxAdapter(
child: _VideoSection(
title:
'Favoris récents',
subtitle:
'Tes vidéos enregistrées.',
videos: favorites
.take(10)
.toList(
growable:
false,
),
),
),
if (history.isEmpty &&
favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: _EmptyHome(),
),
const SliverToBoxAdapter(
child:
SizedBox(height: 36),
),
],
);
},
);
},
),
);
}
List<VideoItem> _buildRediscover(
List<VideoItem> history,
List<VideoItem> favorites,
) {
final result =
<VideoItem>[];
final used =
<String>{};
for (final video
in favorites.skip(6)) {
if (used.add(video.id)) {
result.add(video);
}
if (result.length >= 8) {
return result;
}
}
for (final video
in history.skip(15)) {
if (used.add(video.id)) {
result.add(video);
}
if (result.length >= 8) {
break;
}
}
return result;
}
void _showWhy(
RadarRecommendation item,
) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
backgroundColor:
const Color(0xFF181818),
builder: (context) {
return SafeArea(
child: Padding(
padding:
const EdgeInsets.fromLTRB(
20,
4,
20,
24,
),
child: Column(
mainAxisSize:
MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
_ScoreBadge(
score: item.score,
compact: false,
),
const SizedBox(
width: 12,
),
const Expanded(
child: Text(
'Pourquoi cette vidéo ?',
style: TextStyle(
fontSize: 20,
fontWeight:
FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 18),
Text(
item.video.title,
maxLines: 2,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
item.video.author,
style: const TextStyle(
color:
Colors.white60,
),
),
const SizedBox(height: 18),
...item.reasons.map(
(reason) => Padding(
padding:
const EdgeInsets.only(
bottom: 9,
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
const Icon(
Icons
.check_circle_outline,
size: 18,
color: Color(
0xFF7FE0B8,
),
),
const SizedBox(
width: 9,
),
Expanded(
child: Text(
reason,
),
),
],
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: item
.breakdown.entries
.map(
(entry) =>
_ScorePartChip(
label:
entry.key,
points:
entry.value,
),
)
.toList(
growable:
false,
),
),
],
),
),
);
},
);
}
}
class _RadarHeader
extends StatelessWidget {
const _RadarHeader({
required this.historyCount,
required this.favoritesCount,
required this.mode,
required this.profileTopics,
required this.interests,
required this.selectedInterest,
required this.loading,
required this.onInterestChanged,
required this.onModeChanged,
required this.onShuffle,
});
final int historyCount;
final int favoritesCount;
final RadarSessionMode mode;
final List<String> profileTopics;
final List<String> interests;
final String selectedInterest;
final bool loading;
final ValueChanged<String>
onInterestChanged;
final ValueChanged<RadarSessionMode>
onModeChanged;
final VoidCallback onShuffle;
@override
Widget build(BuildContext context) {
final wide =
MediaQuery.sizeOf(context).width >=
800;
return Padding(
padding: EdgeInsets.fromLTRB(
wide ? 30 : 18,
22,
wide ? 30 : 18,
8,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.play_circle_fill,
color:
Color(0xFFFF4E45),
size: 36,
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'YouTube Personal',
style: TextStyle(
fontSize: 25,
fontWeight:
FontWeight.w700,
),
),
),
FilledButton.tonalIcon(
onPressed:
loading ? null : onShuffle,
icon: loading
? const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.shuffle_rounded,
),
label: Text(
wide
? 'Mélanger mon Radar'
: 'Mélanger',
),
),
],
),
const SizedBox(height: 22),
Row(
children: [
const Icon(
Icons.radar_rounded,
color:
Color(0xFFFF4E45),
size: 28,
),
const SizedBox(width: 9),
Text(
'Radar personnel',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight:
FontWeight.w700,
),
),
],
),
const SizedBox(height: 7),
Text(
historyCount == 0
? 'Regarde quelques vidéos pour donner du signal au Radar.'
: '$historyCount vidéo${historyCount > 1 ? 's' : ''} '
'dans l’historique • '
'$favoritesCount favori${favoritesCount > 1 ? 's' : ''} '
'• recommandations classées localement',
style: const TextStyle(
color: Colors.white60,
fontSize: 13,
),
),
const SizedBox(height: 15),
const Text(
'Centres d’intérêt',
style: TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 9),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: interests
.map(
(interest) => Padding(
padding:
const EdgeInsets.only(
right: 8,
),
child: ChoiceChip(
selected:
interest ==
selectedInterest,
label: Text(interest),
onSelected: loading
? null
: (_) =>
onInterestChanged(
interest,
),
),
),
)
.toList(
growable: false,
),
),
),
const SizedBox(height: 18),
const Text(
'Humeur de cette session',
style: TextStyle(
fontSize: 13,
fontWeight:
FontWeight.w600,
color: Colors.white70,
),
),
const SizedBox(height: 9),
SingleChildScrollView(
scrollDirection:
Axis.horizontal,
child: Row(
children:
RadarSessionMode.values
.map(
(item) => Padding(
padding:
const EdgeInsets
.only(
right: 8,
),
child: ChoiceChip(
selected:
item == mode,
label: Text(
item.label,
),
onSelected:
(_) =>
onModeChanged(
item,
),
),
),
)
.toList(
growable: false,
),
),
),
],
),
);
}
}
class _RadarSection
extends StatelessWidget {
const _RadarSection({
required this.title,
required this.subtitle,
required this.icon,
required this.items,
required this.accent,
required this.onWhy,
required this.onDismiss,
required this.onReduceAuthor,
});
final String title;
final String subtitle;
final IconData icon;
final List<RadarRecommendation> items;
final Color accent;
final ValueChanged<RadarRecommendation>
onWhy;
final ValueChanged<RadarRecommendation>
onDismiss;
final ValueChanged<RadarRecommendation>
onReduceAuthor;
@override
Widget build(BuildContext context) {
final wide =
MediaQuery.sizeOf(context).width >=
800;
final cardWidth =
wide ? 320.0 : 250.0;
return Padding(
padding: EdgeInsets.only(
top: 20,
bottom: 5,
left: wide ? 30 : 18,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(
right: wide ? 30 : 18,
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration:
BoxDecoration(
color: accent
.withValues(
alpha: 0.14,
),
borderRadius:
BorderRadius
.circular(
10,
),
),
child: Icon(
icon,
size: 20,
color: accent,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Text(
title,
style:
const TextStyle(
fontSize: 20,
fontWeight:
FontWeight
.w700,
),
),
const SizedBox(
height: 3,
),
Text(
subtitle,
style:
const TextStyle(
color:
Colors.white54,
fontSize: 13,
),
),
],
),
),
],
),
),
const SizedBox(height: 13),
_HorizontalVideoRail(
height: wide ? 326 : 292,
itemCount: items.length,
itemWidth: cardWidth,
endPadding: wide ? 30 : 18,
itemBuilder: (context, index) {
final item = items[index];
return _RadarVideoCard(
item: item,
accent: accent,
onTap: () => _openVideo(
context,
item.video,
),
onWhy: () => onWhy(item),
onDismiss: () =>
onDismiss(item),
onReduceAuthor: () =>
onReduceAuthor(item),
);
},
),
],
),
);
}
}
enum _RadarCardAction {
why,
dismiss,
reduceAuthor,
}
class _RadarVideoCard
extends StatelessWidget {
const _RadarVideoCard({
required this.item,
required this.accent,
required this.onTap,
required this.onWhy,
required this.onDismiss,
required this.onReduceAuthor,
});
final RadarRecommendation item;
final Color accent;
final VoidCallback onTap;
final VoidCallback onWhy;
final VoidCallback onDismiss;
final VoidCallback onReduceAuthor;
@override
Widget build(BuildContext context) {
final video = item.video;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius:
BorderRadius.circular(14),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius:
BorderRadius.circular(
12,
),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: const Color(
0xFF202020,
),
child: Image.network(
video.thumbnailUrl,
fit: BoxFit.cover,
errorBuilder: (
context,
error,
stackTrace,
) {
return const Center(
child: Icon(
Icons
.broken_image_outlined,
color:
Colors.white38,
size: 38,
),
);
},
),
),
const Positioned.fill(
child:
DecoratedBox(
decoration:
BoxDecoration(
gradient:
LinearGradient(
begin: Alignment
.topCenter,
end: Alignment
.bottomCenter,
colors: [
Color(
0x22000000,
),
Color(
0x77000000,
),
],
),
),
),
),
Positioned(
left: 8,
top: 8,
child: _ScoreBadge(
score:
item.score,
),
),
Positioned(
right: 4,
top: 3,
child: PopupMenuButton<
_RadarCardAction>(
tooltip:
'Options Radar',
color: const Color(
0xFF252525,
),
icon: Container(
decoration:
BoxDecoration(
color: Colors
.black54,
shape: BoxShape
.circle,
),
child:
const Padding(
padding:
EdgeInsets
.all(
5,
),
child: Icon(
Icons
.more_vert,
size: 18,
),
),
),
onSelected:
(action) {
switch (
action) {
case _RadarCardAction
.why:
onWhy();
break;
case _RadarCardAction
.dismiss:
onDismiss();
break;
case _RadarCardAction
.reduceAuthor:
onReduceAuthor();
break;
}
},
itemBuilder:
(context) =>
const [
PopupMenuItem(
value:
_RadarCardAction
.why,
child: Text(
'Pourquoi cette vidéo ?',
),
),
PopupMenuItem(
value:
_RadarCardAction
.dismiss,
child: Text(
'Pas maintenant',
),
),
PopupMenuItem(
value:
_RadarCardAction
.reduceAuthor,
child: Text(
'Moins de cette chaîne',
),
),
],
),
),
if (video
.durationLabel
.isNotEmpty)
Positioned(
right: 8,
bottom: 8,
child:
_DurationBadge(
text: video
.durationLabel,
),
),
Positioned(
left: 8,
bottom: 8,
child: Material(
color: accent
.withValues(
alpha: 0.9,
),
borderRadius:
BorderRadius
.circular(
999,
),
child: InkWell(
onTap: onWhy,
borderRadius:
BorderRadius
.circular(
999,
),
child:
const Padding(
padding:
EdgeInsets
.symmetric(
horizontal:
9,
vertical: 5,
),
child: Row(
mainAxisSize:
MainAxisSize
.min,
children: [
Icon(
Icons
.psychology_alt_outlined,
size: 14,
color: Colors
.black87,
),
SizedBox(
width: 4,
),
Text(
'Pourquoi ?',
style:
TextStyle(
color: Colors
.black87,
fontSize:
11,
fontWeight:
FontWeight
.w700,
),
),
],
),
),
),
),
),
],
),
),
),
const SizedBox(height: 8),
Text(
video.title,
maxLines: 2,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.25,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
video.author,
maxLines: 1,
overflow:
TextOverflow
.ellipsis,
style:
const TextStyle(
color:
Colors.white54,
fontSize: 12,
),
),
),
if (item.isNewAuthor)
const Padding(
padding:
EdgeInsets.only(
left: 6,
),
child: Text(
'NOUVEAU',
style: TextStyle(
color: Color(
0xFF7FE0B8,
),
fontSize: 9,
fontWeight:
FontWeight
.w800,
letterSpacing:
0.5,
),
),
),
],
),
const SizedBox(height: 5),
_RadarPreviewMetadata(
video: video,
),
if (video.description.trim().isNotEmpty) ...[
const SizedBox(height: 5),
Text(
video.description
.replaceAll(
RegExp(r'\s+'),
' ',
)
.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white38,
fontSize: 10.5,
height: 1.2,
),
),
],
if (item.reasons.isNotEmpty) ...[
const SizedBox(height: 5),
Text(
item.reasons.first,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: TextStyle(
color: accent
.withValues(
alpha: 0.86,
),
fontSize: 11,
),
),
],
],
),
),
);
}
}
class _RadarPreviewMetadata extends StatelessWidget {
const _RadarPreviewMetadata({
required this.video,
});
final VideoItem video;
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: ChannelPreviewMetadataService.instance
.getChannel(video.channelId),
builder: (context, snapshot) {
final channel = snapshot.data;
final parts = <String>[];
if (channel?.subscriberCount != null) {
parts.add(
'${_compact(channel!.subscriberCount!)} abonnés',
);
}
if (video.viewCount != null) {
parts.add(
'${_compact(video.viewCount!)} vues',
);
}
if (video.likeCount != null) {
parts.add(
'${_compact(video.likeCount!)} likes',
);
}
if (parts.isEmpty) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const SizedBox(
height: 13,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 11,
height: 11,
child: CircularProgressIndicator(
strokeWidth: 1.3,
),
),
),
);
}
return const SizedBox.shrink();
}
return Text(
parts.join(' • '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54,
fontSize: 10.5,
fontWeight: FontWeight.w500,
),
);
},
);
}
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';
}
}
class _ScoreBadge
extends StatelessWidget {
const _ScoreBadge({
required this.score,
this.compact = true,
});
final int score;
final bool compact;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: const Color(
0xEE111111,
),
borderRadius:
BorderRadius.circular(999),
border: Border.all(
color: const Color(
0x55FFFFFF,
),
),
),
child: Padding(
padding:
EdgeInsets.symmetric(
horizontal:
compact ? 8 : 11,
vertical:
compact ? 5 : 7,
),
child: Text(
'$score%',
style: TextStyle(
color: Colors.white,
fontSize:
compact ? 11 : 15,
fontWeight:
FontWeight.w800,
),
),
),
);
}
}
class _ScorePartChip
extends StatelessWidget {
const _ScorePartChip({
required this.label,
required this.points,
});
final String label;
final int points;
@override
Widget build(BuildContext context) {
final positive = points >= 0;
return DecoratedBox(
decoration: BoxDecoration(
color: positive
? const Color(
0x187FE0B8,
)
: const Color(
0x18FF7D7D,
),
borderRadius:
BorderRadius.circular(999),
border: Border.all(
color: positive
? const Color(
0x557FE0B8,
)
: const Color(
0x55FF7D7D,
),
),
),
child: Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
'$label ${points >= 0 ? '+' : ''}$points',
style: TextStyle(
fontSize: 12,
color: positive
? const Color(
0xFF9CE8C7,
)
: const Color(
0xFFFF9A9A,
),
),
),
),
);
}
}
class _RadarLoadingCard
extends StatelessWidget {
const _RadarLoadingCard();
@override
Widget build(BuildContext context) {
return const Padding(
padding:
EdgeInsets.fromLTRB(
30,
24,
30,
10,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Color(0xFF181818),
borderRadius:
BorderRadius.all(
Radius.circular(16),
),
),
child: Padding(
padding:
EdgeInsets.all(18),
child: Row(
children: [
SizedBox(
width: 22,
height: 22,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
),
SizedBox(width: 13),
Expanded(
child: Text(
'Le Radar mélange ton historique, tes favoris et de nouvelles pistes…',
style: TextStyle(
color:
Colors.white70,
),
),
),
],
),
),
),
);
}
}
class _RadarErrorCard
extends StatelessWidget {
const _RadarErrorCard({
required this.text,
required this.onRetry,
});
final String text;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Padding(
padding:
const EdgeInsets.fromLTRB(
30,
20,
30,
4,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(
0xFF211818,
),
borderRadius:
BorderRadius.circular(
14,
),
),
child: Padding(
padding:
const EdgeInsets.all(
15,
),
child: Row(
children: [
const Icon(
Icons
.radar_outlined,
color:
Colors.white54,
),
const SizedBox(
width: 11,
),
Expanded(
child: Text(text),
),
TextButton(
onPressed: onRetry,
child: const Text(
'Réessayer',
),
),
],
),
),
),
);
}
}
class _RadarCategoriesBlock
extends StatelessWidget {
const _RadarCategoriesBlock({
required this.categories,
required this.selectedCategory,
required this.onCategorySelected,
required this.onWhy,
required this.onDismiss,
required this.onReduceAuthor,
});
final Map<String, List<RadarRecommendation>>
categories;
final String selectedCategory;
final ValueChanged<String>
onCategorySelected;
final ValueChanged<RadarRecommendation>
onWhy;
final ValueChanged<RadarRecommendation>
onDismiss;
final ValueChanged<RadarRecommendation>
onReduceAuthor;
@override
Widget build(BuildContext context) {
final selected =
categories[selectedCategory] ??
categories.values.first;
final wide =
MediaQuery.sizeOf(context).width >=
800;
return Padding(
padding: EdgeInsets.only(
top: 24,
bottom: 4,
left: wide ? 30 : 18,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(
right: wide ? 30 : 18,
),
child: const Row(
children: [
Icon(
Icons
.category_outlined,
color:
Color(0xFFFFC76A),
size: 26,
),
SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Text(
'Explorer par catégorie',
style:
TextStyle(
fontSize: 20,
fontWeight:
FontWeight
.w700,
),
),
SizedBox(height: 3),
Text(
'Le classement est local : Jeux vidéo, Culture, OVNI & mystères, Technologie, Science…',
style:
TextStyle(
color:
Colors.white54,
fontSize: 13,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection:
Axis.horizontal,
child: Row(
children: categories.entries
.map(
(entry) => Padding(
padding:
const EdgeInsets
.only(
right: 8,
),
child: ChoiceChip(
selected:
entry.key ==
selectedCategory,
label: Text(
'${entry.key} (${entry.value.length})',
),
onSelected: (_) =>
onCategorySelected(
entry.key,
),
),
),
)
.toList(
growable: false,
),
),
),
const SizedBox(height: 10),
_HorizontalVideoRail(
height: wide ? 326 : 292,
itemCount: selected.length,
itemWidth: wide ? 320 : 250,
endPadding: wide ? 30 : 18,
itemBuilder: (context, index) {
final item = selected[index];
return _RadarVideoCard(
item: item,
accent: const Color(
0xFFFFC76A,
),
onTap: () => _openVideo(
context,
item.video,
),
onWhy: () => onWhy(item),
onDismiss: () =>
onDismiss(item),
onReduceAuthor: () =>
onReduceAuthor(item),
);
},
),
],
),
);
}
}
class _HorizontalVideoRail extends StatefulWidget {
const _HorizontalVideoRail({
required this.height,
required this.itemCount,
required this.itemWidth,
required this.itemBuilder,
this.endPadding = 18,
});
final double height;
final int itemCount;
final double itemWidth;
final double endPadding;
final IndexedWidgetBuilder itemBuilder;
@override
State<_HorizontalVideoRail> createState() =>
_HorizontalVideoRailState();
}
class _HorizontalVideoRailState
extends State<_HorizontalVideoRail> {
final ScrollController _controller =
ScrollController();
bool _canGoBack = false;
bool _canGoForward = false;
@override
void initState() {
super.initState();
_controller.addListener(_syncButtons);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncButtons(),
);
}
@override
void didUpdateWidget(
covariant _HorizontalVideoRail oldWidget,
) {
super.didUpdateWidget(oldWidget);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncButtons(),
);
}
@override
void dispose() {
_controller
..removeListener(_syncButtons)
..dispose();
super.dispose();
}
void _syncButtons() {
if (!mounted || !_controller.hasClients) {
return;
}
final position = _controller.position;
final canGoBack =
position.pixels > position.minScrollExtent + 2;
final canGoForward =
position.pixels < position.maxScrollExtent - 2;
if (canGoBack == _canGoBack &&
canGoForward == _canGoForward) {
return;
}
setState(() {
_canGoBack = canGoBack;
_canGoForward = canGoForward;
});
}
Future<void> _move(int direction) async {
if (!_controller.hasClients) {
return;
}
final position = _controller.position;
// Move almost one viewport at a time, but always at least one card.
final distance = (
position.viewportDimension * 0.82
).clamp(
widget.itemWidth + 12,
double.infinity,
);
final target = (
_controller.offset +
distance * direction
).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
await _controller.animateTo(
target.toDouble(),
duration:
const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final desktop =
MediaQuery.sizeOf(context).width >= 800;
final behavior =
ScrollConfiguration.of(context).copyWith(
dragDevices: const <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
},
scrollbars: false,
);
return SizedBox(
height: widget.height,
child: Stack(
children: [
Positioned.fill(
child: Scrollbar(
controller: _controller,
thumbVisibility: desktop,
trackVisibility: false,
thickness: desktop ? 4 : 2,
radius: const Radius.circular(99),
child: ScrollConfiguration(
behavior: behavior,
child: ListView.separated(
controller: _controller,
scrollDirection:
Axis.horizontal,
physics:
const ClampingScrollPhysics(),
padding: EdgeInsets.only(
right: widget.endPadding,
bottom: desktop ? 9 : 0,
),
itemCount: widget.itemCount,
separatorBuilder:
(_, __) =>
const SizedBox(
width: 12,
),
itemBuilder:
(context, index) =>
SizedBox(
width: widget.itemWidth,
child: widget.itemBuilder(
context,
index,
),
),
),
),
),
),
if (desktop && _canGoBack)
Positioned(
left: 8,
top: 0,
bottom: 12,
child: Center(
child: _RailArrowButton(
icon:
Icons.chevron_left_rounded,
tooltip:
'Vidéos précédentes',
onPressed: () =>
_move(-1),
),
),
),
if (desktop && _canGoForward)
Positioned(
right: 12,
top: 0,
bottom: 12,
child: Center(
child: _RailArrowButton(
icon:
Icons.chevron_right_rounded,
tooltip:
'Voir la suite',
onPressed: () =>
_move(1),
),
),
),
],
),
);
}
}
class _RailArrowButton extends StatelessWidget {
const _RailArrowButton({
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Material(
color: const Color(0xE6222222),
shape: const CircleBorder(),
elevation: 8,
shadowColor: Colors.black87,
child: InkWell(
onTap: onPressed,
customBorder:
const CircleBorder(),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color:
const Color(0x44FFFFFF),
),
),
child: Icon(
icon,
size: 30,
color: Colors.white,
),
),
),
),
);
}
}
class _VideoSection
extends StatelessWidget {
const _VideoSection({
required this.title,
required this.subtitle,
required this.videos,
this.showProgress = false,
});
final String title;
final String subtitle;
final List<VideoItem> videos;
final bool showProgress;
@override
Widget build(BuildContext context) {
final wide =
MediaQuery.sizeOf(context).width >=
800;
final cardWidth =
wide ? 290.0 : 235.0;
return Padding(
padding: EdgeInsets.only(
top: 18,
bottom: 6,
left: wide ? 30 : 18,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(
right: wide ? 30 : 18,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
title,
style:
const TextStyle(
fontSize: 20,
fontWeight:
FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style:
const TextStyle(
color:
Colors.white54,
fontSize: 13,
),
),
],
),
),
const SizedBox(height: 12),
_HorizontalVideoRail(
height: wide ? 272 : 242,
itemCount: videos.length,
itemWidth: cardWidth,
endPadding: wide ? 30 : 18,
itemBuilder: (context, index) {
final video = videos[index];
return _HomeVideoCard(
video: video,
showProgress: showProgress,
onTap: () => _openVideo(
context,
video,
),
);
},
),
],
),
);
}
}
class _HomeVideoCard
extends StatelessWidget {
const _HomeVideoCard({
required this.video,
required this.showProgress,
required this.onTap,
});
final VideoItem video;
final bool showProgress;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final position =
LibraryService.instance
.getPlaybackPosition(
video.id,
);
final progress =
_progressValue(
position,
video.duration,
);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius:
BorderRadius.circular(14),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius:
BorderRadius.circular(
12,
),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: const Color(
0xFF202020,
),
child: Image.network(
video.thumbnailUrl,
fit: BoxFit.cover,
errorBuilder: (
context,
error,
stackTrace,
) {
return const Center(
child: Icon(
Icons
.broken_image_outlined,
color:
Colors.white38,
size: 38,
),
);
},
),
),
const Positioned.fill(
child:
DecoratedBox(
decoration:
BoxDecoration(
gradient:
LinearGradient(
begin: Alignment
.topCenter,
end: Alignment
.bottomCenter,
colors: [
Colors
.transparent,
Color(
0x55000000,
),
],
),
),
),
),
if (video
.durationLabel
.isNotEmpty)
Positioned(
right: 7,
bottom:
showProgress
? 10
: 7,
child:
_DurationBadge(
text: video
.durationLabel,
),
),
if (showProgress &&
position != null &&
position >
Duration.zero)
Positioned(
left: 0,
right: 0,
bottom: 0,
child:
LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor:
Colors
.white24,
),
),
],
),
),
),
const SizedBox(height: 9),
Text(
video.title,
maxLines: 2,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.25,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(height: 5),
Text(
video.author,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
),
),
if (showProgress &&
position != null) ...[
const SizedBox(height: 4),
Text(
'Reprendre à '
'${_formatDuration(position)}',
style:
const TextStyle(
color:
Colors.white70,
fontSize: 12,
fontWeight:
FontWeight.w500,
),
),
],
],
),
),
);
}
double? _progressValue(
Duration? position,
Duration? duration,
) {
if (position == null ||
duration == null ||
duration <= Duration.zero) {
return null;
}
return (position.inMilliseconds /
duration.inMilliseconds)
.clamp(0.0, 1.0);
}
String _formatDuration(
Duration value,
) {
final hours =
value.inHours;
final minutes =
value.inMinutes.remainder(60);
final seconds =
value.inSeconds.remainder(60);
if (hours > 0) {
return '$hours:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
return '$minutes:'
'${seconds.toString().padLeft(2, '0')}';
}
}
class _DurationBadge
extends StatelessWidget {
const _DurationBadge({
required this.text,
});
final String text;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black87,
borderRadius:
BorderRadius.circular(5),
),
child: Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight:
FontWeight.w600,
),
),
),
);
}
}
class _EmptyHome
extends StatelessWidget {
const _EmptyHome();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding:
const EdgeInsets.all(30),
child: Column(
mainAxisSize:
MainAxisSize.min,
children: [
const Icon(
Icons.radar_rounded,
size: 72,
color: Colors.white24,
),
const SizedBox(height: 18),
const Text(
'Ton Radar est encore vide',
textAlign:
TextAlign.center,
style: TextStyle(
fontSize: 21,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(height: 8),
const Text(
'Recherche et regarde quelques vidéos. '
'Le Radar apprendra uniquement à partir de '
'ton historique et de tes favoris.',
textAlign:
TextAlign.center,
style: TextStyle(
color:
Colors.white60,
fontSize: 14,
height: 1.4,
),
),
],
),
),
);
}
}
void _openVideo(
BuildContext context,
VideoItem video,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) =>
VideoPlayerPage(
video: video,
),
),
);
}