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 createState() => _HomePageState(); } class _HomePageState extends State { RadarSessionMode _mode = RadarSessionMode.normal; static const List _interestOptions = [ '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 _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 _changeInterest( String interest, ) async { if (interest == _selectedInterest) { return; } setState(() { _selectedInterest = interest; }); await _refreshRadar( force: true, ); } Future _changeMode( RadarSessionMode mode, ) async { if (mode == _mode) { return; } setState(() { _mode = mode; }); await _refreshRadar(); } Future _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 _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>( valueListenable: LibraryService.instance.history, builder: ( context, history, child, ) { return ValueListenableBuilder< List>( 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 _buildRediscover( List history, List favorites, ) { final result = []; final used = {}; 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( 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 profileTopics; final List interests; final String selectedInterest; final bool loading; final ValueChanged onInterestChanged; final ValueChanged 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 items; final Color accent; final ValueChanged onWhy; final ValueChanged onDismiss; final ValueChanged 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 = []; 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> categories; final String selectedCategory; final ValueChanged onCategorySelected; final ValueChanged onWhy; final ValueChanged onDismiss; final ValueChanged 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 _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.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 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( builder: (context) => VideoPlayerPage( video: video, ), ), ); }