2590 lines
69 KiB
Dart
2590 lines
69 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|