diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index 87c00645..c714414e 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -6,16 +6,21 @@ 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 '../player/player_provider.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'; -import 'widgets/track_row.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @@ -23,6 +28,9 @@ class HomeScreen extends ConsumerWidget { @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( @@ -32,64 +40,288 @@ class HomeScreen extends ConsumerWidget { actions: const [MainAppBarActions(currentRoute: '/home')], ), body: SafeArea( - child: ref.watch(homeProvider).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 Center(child: CircularProgressIndicator()), - data: (h) => RefreshIndicator( - onRefresh: () async => ref.refresh(homeProvider.future), - child: ListView(children: [ - _albumsRow(context, 'Recently added', h.recentlyAddedAlbums), - _albumsRow(context, 'Rediscover', h.rediscoverAlbums), - _artistsRow(context, 'Rediscover artists', h.rediscoverArtists), - _tracksRow(context, ref, 'Most played', h.mostPlayedTracks), - _artistsRow(context, 'Last played artists', h.lastPlayedArtists), - const SizedBox(height: 96), - ]), + 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), + ]), + ), + ), ), ); } - - Widget _albumsRow(BuildContext ctx, String title, List albums) => - HorizontalScrollRow( - title: title, - children: [ - for (final a in albums) - AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')), - ], - ); - - Widget _artistsRow(BuildContext ctx, String title, List artists) => - HorizontalScrollRow( - title: title, - children: [ - for (final a in artists) - ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')), - ], - ); - - Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List tracks) => - HorizontalScrollRow( - title: title, - height: 64, - children: [ - for (final t in tracks) - SizedBox( - width: 280, - child: TrackRow( - track: t, - onTap: () => ref.read(playerActionsProvider).playTracks([t]), - ), - ), - ], - ); +} + +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; }