import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/errors.dart'; import '../models/album.dart'; import '../models/artist.dart'; import '../models/playlist.dart'; import '../models/system_playlists_status.dart'; import '../models/track.dart'; import '../playlists/playlists_provider.dart'; import '../playlists/widgets/playlist_card.dart'; import '../playlists/widgets/playlist_placeholder_card.dart'; import '../shared/delayed_loading.dart'; import '../shared/widgets/connection_error_banner.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/album_card.dart'; import 'widgets/artist_card.dart'; import 'widgets/compact_track_card.dart'; import 'widgets/horizontal_scroll_row.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final home = ref.watch(homeProvider); final allPlaylists = ref.watch(playlistsListProvider('all')); final status = ref.watch(systemPlaylistsStatusProvider); return Scaffold( backgroundColor: fs.obsidian, appBar: AppBar( backgroundColor: fs.obsidian, elevation: 0, title: Text('Minstrel', style: TextStyle(color: fs.parchment)), actions: const [MainAppBarActions(currentRoute: '/home')], ), body: SafeArea( child: home.when( error: (e, _) { final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; if (code == 'connection_refused') { return ConnectionErrorBanner( onRetry: () => ref.refresh(homeProvider), ); } return Center(child: Text('$e', style: TextStyle(color: fs.error))); }, loading: () => const DelayedLoading( isLoading: true, whenReady: SizedBox.shrink(), whileDelayed: Center(child: CircularProgressIndicator()), ), data: (h) => RefreshIndicator( onRefresh: () async => ref.refresh(homeProvider.future), child: ListView(children: [ _PlaylistsSection( playlists: allPlaylists.value?.owned ?? const [], status: status.value ?? SystemPlaylistsStatus.empty(), ), _RecentlyAddedSection(albums: h.recentlyAddedAlbums), _RediscoverSection( albums: h.rediscoverAlbums, artists: h.rediscoverArtists, ), _MostPlayedSection(tracks: h.mostPlayedTracks), _LastPlayedSection(artists: h.lastPlayedArtists), const SizedBox(height: 96), ]), ), ), ), ); } } class _PlaylistsSection extends StatelessWidget { const _PlaylistsSection({required this.playlists, required this.status}); final List playlists; final SystemPlaylistsStatus status; @override Widget build(BuildContext context) { final items = _buildPlaylistsRow(playlists, status); return HorizontalScrollRow( title: 'Playlists', height: 220, children: items.map((item) { if (item is _RealPlaylist) { return PlaylistCard(playlist: item.playlist); } final ph = item as _PlaceholderPlaylist; return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant); }).toList(), ); } } abstract class _PlaylistRowItem {} class _RealPlaylist extends _PlaylistRowItem { _RealPlaylist(this.playlist); final Playlist playlist; } class _PlaceholderPlaylist extends _PlaylistRowItem { _PlaceholderPlaylist(this.label, this.variant); final String label; final String variant; } List<_PlaylistRowItem> _buildPlaylistsRow( List ownedAll, SystemPlaylistsStatus status, ) { final out = <_PlaylistRowItem>[]; Playlist? findFirst(bool Function(Playlist) test) { for (final p in ownedAll) { if (test(p)) return p; } return null; } // Slot 1: For-You. final forYou = findFirst((p) => p.systemVariant == 'for_you'); out.add(forYou != null ? _RealPlaylist(forYou) : _PlaceholderPlaylist('For You', _variantFor('for-you', status))); // Slots 2-4: Songs-like (real first, padded to 3). final songsLike = ownedAll .where((p) => p.systemVariant == 'songs_like_artist') .take(3) .toList(); for (var i = 0; i < 3; i++) { out.add(i < songsLike.length ? _RealPlaylist(songsLike[i]) : _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status))); } // User-created trail (server returns most-recently-updated first). for (final p in ownedAll.where((p) => p.systemVariant == null)) { out.add(_RealPlaylist(p)); } return out; } String _variantFor(String slot, SystemPlaylistsStatus s) { if (s.inFlight) return 'building'; if (s.lastError != null) return 'failed'; if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed'; return 'pending'; } class _RecentlyAddedSection extends StatelessWidget { const _RecentlyAddedSection({required this.albums}); final List albums; @override Widget build(BuildContext context) { if (albums.isEmpty) { return const _EmptySection( title: 'Recently added', message: "Nothing added yet. Scan a folder via the server's config.", ); } final controller = ScrollController(); final rows = _chunk(albums, 25); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < rows.length; i++) HorizontalScrollRow( title: i == 0 ? 'Recently added' : '', controller: controller, children: rows[i] .map((a) => AlbumCard( album: a, onTap: () => _push(context, '/albums/${a.id}'), )) .toList(), ), ]); } } class _RediscoverSection extends StatelessWidget { const _RediscoverSection({required this.albums, required this.artists}); final List albums; final List artists; @override Widget build(BuildContext context) { if (albums.isEmpty && artists.isEmpty) { return const _EmptySection( title: 'Rediscover', message: 'No forgotten favourites yet. Like some albums or artists to fill this in.', ); } return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (albums.isNotEmpty) HorizontalScrollRow( title: 'Rediscover', children: albums .map((a) => AlbumCard( album: a, onTap: () => _push(context, '/albums/${a.id}'), )) .toList(), ), if (artists.isNotEmpty) HorizontalScrollRow( title: albums.isEmpty ? 'Rediscover' : '', height: 168, children: artists .map((ar) => ArtistCard( artist: ar, onTap: () => _push(context, '/artists/${ar.id}'), )) .toList(), ), ]); } } class _MostPlayedSection extends StatelessWidget { const _MostPlayedSection({required this.tracks}); final List tracks; @override Widget build(BuildContext context) { if (tracks.isEmpty) { return const _EmptySection( title: 'Most played', message: 'No plays to draw from. Listen to something.', ); } final controller = ScrollController(); final rows = _chunk(tracks, 25); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < rows.length; i++) HorizontalScrollRow( title: i == 0 ? 'Most played' : '', height: 64, controller: controller, children: [ for (var j = 0; j < rows[i].length; j++) CompactTrackCard( track: rows[i][j], sectionTracks: tracks, index: i * 25 + j, ), ], ), ]); } } class _LastPlayedSection extends StatelessWidget { const _LastPlayedSection({required this.artists}); final List artists; @override Widget build(BuildContext context) { if (artists.isEmpty) { return const _EmptySection( title: 'Last played', message: 'No recent plays.', ); } return HorizontalScrollRow( title: 'Last played', height: 168, children: artists .map((ar) => ArtistCard( artist: ar, onTap: () => _push(context, '/artists/${ar.id}'), )) .toList(), ); } } class _EmptySection extends StatelessWidget { const _EmptySection({required this.title, required this.message}); final String title; final String message; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( fontFamily: fs.display.fontFamily, fontSize: 18, color: fs.parchment, ), ), const SizedBox(height: 8), Text(message, style: TextStyle(color: fs.ash)), ]), ); } } void _push(BuildContext context, String path) { context.push(path); } List> _chunk(List items, int size) { final out = >[]; for (var i = 0; i < items.length; i += size) { out.add(items.sublist(i, i + size > items.length ? items.length : i + size)); } return out; }