import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/errors.dart'; import '../cache/offline_provider.dart'; import '../cache/shuffle_source.dart'; import '../cache/tile_providers.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/widgets/connection_error_banner.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../shared/widgets/skeletons.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'; /// Home screen. Per-item rendering: a tiny /api/home/index discovery /// fetch returns just section→IDs, then each tile hydrates itself /// against the per-entity drift tables (sync-populated) with REST /// fallback via the HydrationQueue. Cold-visit dead air shrinks to a /// single small round-trip; tiles materialize as their data lands. class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final index = ref.watch(homeIndexProvider); 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: index.when( error: (e, _) { final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; if (code == 'connection_refused') { return ConnectionErrorBanner( onRetry: () => ref.refresh(homeIndexProvider), ); } return Center(child: Text('$e', style: TextStyle(color: fs.error))); }, loading: () => _HomeSkeleton(fs: fs), data: (h) => RefreshIndicator( onRefresh: () async => ref.refresh(homeIndexProvider.future), child: ListView( physics: const ClampingScrollPhysics(), children: [ _PlaylistsSection( playlists: allPlaylists.value?.owned ?? const [], status: status.value ?? SystemPlaylistsStatus.empty(), offline: ref.watch(offlineProvider), ), _RecentlyAddedSection(ids: h.recentlyAddedAlbums), _RediscoverSection( albumIds: h.rediscoverAlbums, artistIds: h.rediscoverArtists, ), _MostPlayedSection(ids: h.mostPlayedTracks), _LastPlayedSection(ids: h.lastPlayedArtists), const SizedBox(height: 140), ], ), ), ), ), ); } } // ─── Per-tile widgets ──────────────────────────────────────────────── /// Duration of the skeleton→content cross-fade. 220ms reads as "tile /// settled into place" — longer drags, shorter feels like a hard cut. /// Each tile cross-fades independently when its data lands, so the /// natural cascade from the hydration queue's bounded concurrency /// produces a staged-reveal feel without any per-tile delay math. const Duration _tileRevealDuration = Duration(milliseconds: 220); /// Album tile: skeleton until albumTileProvider yields a populated row. class _AlbumTile extends ConsumerWidget { const _AlbumTile({required this.id}); final String id; @override Widget build(BuildContext context, WidgetRef ref) { final asyncAlbum = ref.watch(albumTileProvider(id)); final album = asyncAlbum.asData?.value; return AnimatedSwitcher( duration: _tileRevealDuration, switchInCurve: Curves.easeOut, child: album == null ? const SkeletonAlbumTile(key: ValueKey('skeleton')) : AlbumCard( key: ValueKey('album-${album.id}'), album: album, onTap: () => context.push('/albums/${album.id}', extra: album), ), ); } } /// Artist tile: skeleton until artistTileProvider yields a populated row. class _ArtistTile extends ConsumerWidget { const _ArtistTile({required this.id}); final String id; @override Widget build(BuildContext context, WidgetRef ref) { final asyncArtist = ref.watch(artistTileProvider(id)); final artist = asyncArtist.asData?.value; return AnimatedSwitcher( duration: _tileRevealDuration, switchInCurve: Curves.easeOut, child: artist == null ? const SkeletonArtistTile(key: ValueKey('skeleton')) : ArtistCard( key: ValueKey('artist-${artist.id}'), artist: artist, onTap: () => context.push('/artists/${artist.id}', extra: artist), ), ); } } /// Track tile (used by Most-Played). On tap, gathers the currently- /// hydrated TrackRefs for the whole section so playback flows like /// it did with the bulk-loaded list. Tracks that haven't hydrated yet /// are skipped from the play list — typically transient on a cold /// visit since hydration runs in parallel and finishes quickly. class _TrackTile extends ConsumerWidget { const _TrackTile({required this.id, required this.sectionIds}); final String id; final List sectionIds; @override Widget build(BuildContext context, WidgetRef ref) { final asyncTrack = ref.watch(trackTileProvider(id)); final track = asyncTrack.asData?.value; return AnimatedSwitcher( duration: _tileRevealDuration, switchInCurve: Curves.easeOut, child: track == null ? const _CompactTrackSkeleton(key: ValueKey('skeleton')) : CompactTrackCard( key: ValueKey('track-${track.id}'), track: track, sectionTracks: _resolveSectionTracks(ref, sectionIds), index: sectionIds.indexOf(id).clamp(0, sectionIds.length - 1), ), ); } /// Snapshots the section's current track states. Used at tile /// construction time — CompactTrackCard's onTap uses it as the play /// queue. Tracks still hydrating are dropped; once they land, a /// rebuild re-runs this lookup so the queue grows naturally. static List _resolveSectionTracks(WidgetRef ref, List ids) { final out = []; for (final i in ids) { final v = ref.read(trackTileProvider(i)).asData?.value; if (v != null) out.add(v); } return out; } } /// Compact-track placeholder. 56dp cover + two text lines, matched to /// CompactTrackCard so swapping in the real card doesn't shift the /// row's height. class _CompactTrackSkeleton extends StatelessWidget { const _CompactTrackSkeleton({super.key}); @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Container( width: 240, margin: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row(children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: fs.slate, borderRadius: BorderRadius.circular(6), ), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Container(width: 120, height: 12, color: fs.slate), const SizedBox(height: 6), Container(width: 80, height: 10, color: fs.slate), ], ), ), ]), ); } } // ─── Sections ──────────────────────────────────────────────────────── class _PlaylistsSection extends StatelessWidget { const _PlaylistsSection({ required this.playlists, required this.status, required this.offline, }); final List playlists; final SystemPlaylistsStatus status; final bool offline; @override Widget build(BuildContext context) { final items = _buildPlaylistsRow(playlists, status); final children = [ // Offline: surface the cache-backed pools where the (now // play-disabled) system playlists sit, so there's something // to play. #427 S4b. if (offline) ...const [ _OfflinePoolCard( label: 'Recently played', icon: LucideIcons.history, kind: _OfflinePoolKind.recentlyPlayed, ), _OfflinePoolCard( label: 'Liked', icon: LucideIcons.heart, kind: _OfflinePoolKind.liked, ), ], for (final item in items) if (item is _RealPlaylist) PlaylistCard(playlist: item.playlist) else PlaylistPlaceholderCard( label: (item as _PlaceholderPlaylist).label, variant: item.variant, ), ]; return HorizontalScrollRow( title: 'Playlists', height: 220, children: children, ); } } enum _OfflinePoolKind { recentlyPlayed, liked } /// Home tile for an offline cache-backed pool. Tapping shuffles + /// plays that pool from the local cache. Sized to match /// PlaylistCard so the row stays visually consistent. class _OfflinePoolCard extends ConsumerWidget { const _OfflinePoolCard({ required this.label, required this.icon, required this.kind, }); final String label; final IconData icon; final _OfflinePoolKind kind; @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; return SizedBox( width: 176, child: Material( color: Colors.transparent, child: InkWell( onTap: () async { final messenger = ScaffoldMessenger.of(context); final src = ref.read(shuffleSourceProvider); final refs = await switch (kind) { _OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(), _OfflinePoolKind.liked => src.liked(), }; if (refs.isEmpty) { messenger.showSnackBar( SnackBar(content: Text('No cached $label tracks yet')), ); return; } await ref .read(playerActionsProvider) .playTracks(refs, shuffle: true); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 144, height: 144, decoration: BoxDecoration( color: fs.slate, borderRadius: BorderRadius.circular(6), ), child: Icon(icon, color: fs.accent, size: 56), ), const SizedBox(height: 8), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.parchment, fontSize: 14), ), Padding( padding: const EdgeInsets.only(top: 2), child: Text( 'Offline', style: TextStyle(color: fs.ash, fontSize: 11), ), ), ], ), ), ), ), ); } } 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; } final forYou = findFirst((p) => p.systemVariant == 'for_you'); out.add(forYou != null ? _RealPlaylist(forYou) : _PlaceholderPlaylist('For You', _variantFor('for-you', status))); final discover = findFirst((p) => p.systemVariant == 'discover'); out.add(discover != null ? _RealPlaylist(discover) : _PlaceholderPlaylist('Discover', _variantFor('discover', status))); 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))); } 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.ids}); final List ids; @override Widget build(BuildContext context) { if (ids.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(ids, 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((id) => _AlbumTile(id: id)).toList(), ), ]); } } class _RediscoverSection extends StatelessWidget { const _RediscoverSection({required this.albumIds, required this.artistIds}); final List albumIds; final List artistIds; @override Widget build(BuildContext context) { if (albumIds.isEmpty && artistIds.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 (albumIds.isNotEmpty) HorizontalScrollRow( title: 'Rediscover', children: albumIds.map((id) => _AlbumTile(id: id)).toList(), ), if (artistIds.isNotEmpty) HorizontalScrollRow( title: albumIds.isEmpty ? 'Rediscover' : '', height: 168, children: artistIds.map((id) => _ArtistTile(id: id)).toList(), ), ]); } } class _MostPlayedSection extends StatelessWidget { const _MostPlayedSection({required this.ids}); final List ids; @override Widget build(BuildContext context) { if (ids.isEmpty) { return const _EmptySection( title: 'Most played', message: 'No plays to draw from. Listen to something.', ); } final controller = ScrollController(); final rows = _chunk(ids, 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 (final id in rows[i]) _TrackTile(id: id, sectionIds: ids), ], ), ]); } } class _LastPlayedSection extends StatelessWidget { const _LastPlayedSection({required this.ids}); final List ids; @override Widget build(BuildContext context) { if (ids.isEmpty) { return const _EmptySection( title: 'Last played', message: 'No recent plays.', ); } return HorizontalScrollRow( title: 'Last played', height: 168, children: ids.map((id) => _ArtistTile(id: 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)), ]), ); } } /// Cold-start skeleton — shown only between mount and the first /// homeIndexProvider emission (typically a single drift query + small /// REST round-trip). Each section then renders its own per-tile /// skeletons internally, so this widget's role is just the very first /// pre-discovery frame. class _HomeSkeleton extends StatelessWidget { const _HomeSkeleton({required this.fs}); final FabledSwordTheme fs; @override Widget build(BuildContext context) { return ListView( physics: const ClampingScrollPhysics(), children: [ _SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176), _SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140), _SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140), _SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140), _SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140), const SizedBox(height: 140), ], ); } } class _SkeletonSection extends StatelessWidget { const _SkeletonSection({ required this.fs, required this.title, required this.cardWidth, }); final FabledSwordTheme fs; final String title; final double cardWidth; @override Widget build(BuildContext context) { final coverSize = cardWidth - 16; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( title, style: TextStyle( fontFamily: fs.display.fontFamily, fontSize: 18, color: fs.parchment, ), ), ), SizedBox( height: coverSize + 40, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), physics: const NeverScrollableScrollPhysics(), itemCount: 6, itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth), ), ), ]); } } 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; }