import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../history/play_history.dart'; import '../subsonic/models.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; import '../widgets/block_progress_bar.dart'; import 'browser_screen.dart'; /// Home tab — a "Resume" hero bound to live playback, then horizontal art /// shelves. Reworked from the terminal-style dense list into a touch-first /// mobile layout while keeping the app's angular, hairline aesthetic. class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(subsonicClientProvider); final recentAlbums = ref.watch(recentAlbumsProvider); final newest = ref.watch(newestAlbumsProvider); final random = ref.watch(randomAlbumsProvider); String? artFor(String? coverArt, {int size = 300}) => (client != null && coverArt != null) ? client.coverArtUri(coverArt, size: size).toString() : null; return ListView( padding: const EdgeInsets.fromLTRB( TimbreSpacing.lg, TimbreSpacing.xl, TimbreSpacing.lg, TimbreSpacing.lg, ), children: [ const _HeroCard(), const SizedBox(height: TimbreSpacing.xl), // Recently Played — from local history. if (recentAlbums.isNotEmpty) _Shelf( title: 'Recently Played', cards: [ for (final rec in recentAlbums) _ArtCard( artUri: artFor(rec.coverArt), title: rec.album ?? 'Unknown album', subtitle: rec.artist, onTap: rec.albumId == null ? null : () => _pushAlbum(context, rec.albumId!), ), ], ), // Recently Added — server discovery shelf. _AlbumShelf( title: 'Recently Added', albums: newest, artFor: artFor, ), // Random — a single spotlighted album, re-rolled via the shuffle action. _RandomAlbum( album: random, artFor: artFor, onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++, ), if (recentAlbums.isEmpty && client == null) Padding( padding: const EdgeInsets.only(top: TimbreSpacing.xl), child: Text( 'Connect to a server and start listening — your home fills in as you play.', style: TextStyle(color: TimbreColors.dimmed), ), ), ], ); } static void _pushAlbum(BuildContext context, String albumId) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)), ); } } /// Live "Resume" hero. Isolated so playback position ticks rebuild only this /// card, not the whole Home list. Tapping jumps to Now Playing (or plays the /// most recent track when nothing is loaded). class _HeroCard extends ConsumerWidget { const _HeroCard(); @override Widget build(BuildContext context, WidgetRef ref) { final accent = Theme.of(context).colorScheme.primary; final client = ref.watch(subsonicClientProvider); final current = ref.watch(activePlaybackProvider.select((s) => s.current)); // Fall back to the most recent track so the hero is useful before playback. final recent = ref.watch(recentSongsProvider); final PlayRecord? fallback = recent.isEmpty ? null : recent.first; final String? coverArt = current?.coverArt ?? fallback?.coverArt; final artUri = (client != null && coverArt != null) ? client.coverArtUri(coverArt, size: 240).toString() : null; final title = current?.title ?? fallback?.title; final subtitle = current?.artist ?? fallback?.artist; final hasCurrent = current != null; if (title == null) { return _HeroShell( accent: accent, onTap: () => ref.read(selectedTabProvider.notifier).state = 1, child: Row( children: [ Icon(Icons.library_music_outlined, color: TimbreColors.dimmed, size: 40), const SizedBox(width: TimbreSpacing.lg), Expanded( child: Text('Browse your library to start listening', style: TextStyle(color: TimbreColors.foreground)), ), ], ), ); } return _HeroShell( accent: accent, onTap: () { if (hasCurrent) { ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex; } else if (fallback != null) { ref.read(playbackCommandsProvider).playSongs([fallback.toSong()]); ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex; } }, child: Row( children: [ SizedBox( width: 64, height: 64, child: ColoredBox( color: TimbreColors.surface, child: artUri != null ? Image.network(artUri, key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => Icon( Icons.album_outlined, color: TimbreColors.dimmed)) : Icon(Icons.album_outlined, color: TimbreColors.dimmed), ), ), const SizedBox(width: TimbreSpacing.lg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Icon(hasCurrent ? Icons.play_arrow : Icons.history, size: 14, color: accent), const SizedBox(width: TimbreSpacing.xs), Text(hasCurrent ? 'NOW PLAYING' : 'RESUME', style: TextStyle( color: accent, fontSize: 11, letterSpacing: 1, fontWeight: FontWeight.w700)), ], ), const SizedBox(height: TimbreSpacing.xs), Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700)), if (subtitle != null) Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: TimbreColors.dimmed)), if (hasCurrent) ...[ const SizedBox(height: TimbreSpacing.md), const _HeroProgress(), ], ], ), ), ], ), ); } } /// The hero's block progress bar. Isolated so position ticks rebuild only this /// strip, not the whole card (which would flash the album art). Mirrors the /// mini-player's `_MiniProgress`. class _HeroProgress extends ConsumerWidget { const _HeroProgress(); @override Widget build(BuildContext context, WidgetRef ref) { final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); return BlockProgressBar(progress: progress, cells: 32, height: 6); } } class _HeroShell extends StatelessWidget { const _HeroShell( {required this.child, required this.accent, required this.onTap}); final Widget child; final Color accent; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( padding: const EdgeInsets.all(TimbreSpacing.lg), decoration: BoxDecoration( color: TimbreColors.surface, border: Border.all(color: TimbreColors.borderActive), ), child: child, ), ); } } /// A horizontal shelf: a header over a scrolling row of art cards. class _Shelf extends StatelessWidget { const _Shelf({required this.title, required this.cards}); final String title; final List cards; @override Widget build(BuildContext context) { if (cards.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: TimbreSpacing.xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ShelfHeader(title: title), const SizedBox(height: TimbreSpacing.md), SizedBox( height: 182, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: cards.length, separatorBuilder: (_, _) => const SizedBox(width: TimbreSpacing.md), itemBuilder: (_, i) => cards[i], ), ), ], ), ); } } class _ShelfHeader extends StatelessWidget { const _ShelfHeader({required this.title, this.onShuffle}); final String title; final VoidCallback? onShuffle; @override Widget build(BuildContext context) { return Row( children: [ Text(title, style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700, letterSpacing: 0.5)), const Spacer(), if (onShuffle != null) InkWell( onTap: onShuffle, customBorder: const CircleBorder(), child: SizedBox( width: TimbreSpacing.minTouchTarget, height: 28, child: Icon(Icons.shuffle, size: 18, color: TimbreColors.dimmed), ), ), ], ); } } /// An async album shelf backed by a FutureProvider. Renders a placeholder row /// while loading and disappears when empty / errored / offline. class _AlbumShelf extends StatelessWidget { const _AlbumShelf({ required this.title, required this.albums, required this.artFor, }); final String title; final AsyncValue> albums; final String? Function(String? coverArt, {int size}) artFor; @override Widget build(BuildContext context) { return albums.when( loading: () => _Shelf( title: title, cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()], ), error: (_, _) => const SizedBox.shrink(), data: (list) => _Shelf( title: title, cards: [ for (final a in list) _ArtCard( artUri: artFor(a.coverArt), title: a.name ?? 'Unknown album', subtitle: a.artist, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), ), ), ], ), ); } } /// Random spotlight — a single album shown as cover-left / details-right. /// Details are the album title, artist, release year and genre. Re-rolls via /// the header's shuffle affordance. Hidden while loading errors or is empty. class _RandomAlbum extends StatelessWidget { const _RandomAlbum({ required this.album, required this.artFor, this.onShuffle, }); final AsyncValue> album; final String? Function(String? coverArt, {int size}) artFor; final VoidCallback? onShuffle; static const double _size = 132; @override Widget build(BuildContext context) { final Album? a = album.maybeWhen( data: (list) => list.isEmpty ? null : list.first, orElse: () => null, ); final bool loading = album.isLoading; if (a == null && !loading) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: TimbreSpacing.xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ShelfHeader(title: 'Random', onShuffle: onShuffle), const SizedBox(height: TimbreSpacing.md), if (a == null) const _RandomSkeleton() else InkWell( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), ), child: _RandomBody(album: a, artUri: artFor(a.coverArt)), ), ], ), ); } } class _RandomBody extends StatelessWidget { const _RandomBody({required this.album, required this.artUri}); final Album album; final String? artUri; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(4), child: SizedBox( width: _RandomAlbum._size, height: _RandomAlbum._size, child: ColoredBox( color: TimbreColors.surface, child: artUri != null ? Image.network(artUri!, key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => Icon( Icons.album_outlined, color: TimbreColors.dimmed)) : Icon(Icons.album_outlined, color: TimbreColors.dimmed), ), ), ), const SizedBox(width: TimbreSpacing.lg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(album.name ?? 'Unknown album', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: TimbreColors.foreground, fontWeight: FontWeight.w700)), const SizedBox(height: TimbreSpacing.xs), if (album.artist != null) Text(album.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: TimbreColors.dimmed)), if (album.year != null) Text('${album.year}', style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), if (album.genre != null) Text(album.genre!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: TimbreColors.dimmed, fontSize: 12)), ], ), ), ], ); } } class _RandomSkeleton extends StatelessWidget { const _RandomSkeleton(); @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(4), child: SizedBox( width: _RandomAlbum._size, height: _RandomAlbum._size, child: ColoredBox(color: TimbreColors.surface), ), ); } } /// A fixed-width art tile with a caption — the shelf's building block. class _ArtCard extends StatelessWidget { const _ArtCard({ required this.title, required this.artUri, this.subtitle, this.onTap, }); final String title; final String? artUri; final String? subtitle; final VoidCallback? onTap; static const double _size = 132; @override Widget build(BuildContext context) { return SizedBox( width: _size, child: InkWell( onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(4), child: SizedBox( width: _size, height: _size, child: ColoredBox( color: TimbreColors.surface, child: artUri != null ? Image.network(artUri!, key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => Icon( Icons.album_outlined, color: TimbreColors.dimmed)) : Icon(Icons.album_outlined, color: TimbreColors.dimmed), ), ), ), const SizedBox(height: TimbreSpacing.sm), Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: TimbreColors.foreground)), if (subtitle != null) Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)), ], ), ), ); } } class _ArtCardSkeleton extends StatelessWidget { const _ArtCardSkeleton(); @override Widget build(BuildContext context) { return SizedBox( width: _ArtCard._size, child: ClipRRect( borderRadius: BorderRadius.circular(4), child: SizedBox( width: _ArtCard._size, height: _ArtCard._size, child: ColoredBox(color: TimbreColors.surface), ), ), ); } }