import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../downloads/download_manager.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/hairline_panel.dart'; import 'add_to_playlist_sheet.dart'; import 'downloads_screen.dart'; import 'favorites_screen.dart'; import 'playlists_screen.dart'; import 'search_screen.dart'; /// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic /// server. Artists and Albums drill down by pushing onto the (nested) navigator; /// Tracks is a flat list backed by the cached library index. class BrowserScreen extends ConsumerWidget { const BrowserScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(subsonicClientProvider); final mode = ref.watch(browseModeProvider); return Padding( padding: const EdgeInsets.fromLTRB( TimbreSpacing.lg, TimbreSpacing.xl, TimbreSpacing.lg, TimbreSpacing.lg, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Wrap( spacing: TimbreSpacing.xl, runSpacing: TimbreSpacing.xs, children: [ _Action( icon: Icons.search, label: 'Search', onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SearchScreen()), ), ), _Action( icon: Icons.favorite_border, label: 'Favorites', onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const FavoritesScreen()), ), ), _Action( icon: Icons.queue_music, label: 'Playlists', onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const PlaylistsScreen()), ), ), _Action( icon: Icons.download, label: 'Downloads', onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadsScreen()), ), ), ], ), const SizedBox(height: TimbreSpacing.md), _ModeSelector(mode: mode), const SizedBox(height: TimbreSpacing.lg), Expanded( child: client == null ? const HairlinePanel( title: 'Browse', active: true, child: _NotConnected(), ) : switch (mode) { BrowseMode.artists => const _ArtistsPanel(), BrowseMode.albums => const _AlbumsPanel(), BrowseMode.tracks => const _TracksPanel(), }, ), ], ), ); } } /// Three mono labels; the active one is underlined in accent — same language as /// the bottom `_TabBar`. class _ModeSelector extends ConsumerWidget { const _ModeSelector({required this.mode}); final BrowseMode mode; @override Widget build(BuildContext context, WidgetRef ref) { final accent = Theme.of(context).colorScheme.primary; Widget label(String text, BrowseMode m) { final active = m == mode; return InkWell( onTap: () => ref.read(browseModeProvider.notifier).state = m, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), alignment: Alignment.center, child: Text( text, style: TextStyle( color: active ? TimbreColors.foreground : TimbreColors.dimmed, fontWeight: active ? FontWeight.w700 : FontWeight.w400, decoration: active ? TextDecoration.underline : TextDecoration.none, decorationColor: accent, decorationThickness: 2, ), ), ), ); } return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ label('Artists', BrowseMode.artists), const Text('|', style: TextStyle(color: TimbreColors.border)), label('Albums', BrowseMode.albums), const Text('|', style: TextStyle(color: TimbreColors.border)), label('Tracks', BrowseMode.tracks), ], ); } } class _ArtistsPanel extends ConsumerWidget { const _ArtistsPanel(); @override Widget build(BuildContext context, WidgetRef ref) { final artists = ref.watch(artistsProvider); return HairlinePanel( title: 'Artists', active: true, trailing: artists.hasValue && artists.value!.isNotEmpty ? '(${artists.value!.length})' : null, padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: artists.when( loading: () => const _Centered(child: _Loading()), error: (e, _) => _Centered(child: _ErrorText('$e')), data: (list) => list.isEmpty ? const _Centered(child: _ErrorText('No artists on this server.')) : ListView.builder( padding: EdgeInsets.zero, itemCount: list.length, itemBuilder: (context, i) { final a = list[i]; return BrowseRow( title: a.name ?? 'Unknown artist', trailing: a.albumCount != null ? '${a.albumCount}' : null, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => ArtistScreen(id: a.id)), ), ); }, ), ), ); } } class _AlbumsPanel extends ConsumerWidget { const _AlbumsPanel(); @override Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(albumsProvider); final client = ref.watch(subsonicClientProvider); return HairlinePanel( title: 'Albums', active: true, trailing: albums.hasValue && albums.value!.isNotEmpty ? '(${albums.value!.length})' : null, padding: const EdgeInsets.all(TimbreSpacing.md), child: albums.when( loading: () => const _Centered(child: _Loading()), error: (e, _) => _Centered(child: _ErrorText('$e')), data: (list) => list.isEmpty ? const _Centered(child: _ErrorText('No albums on this server.')) : LayoutBuilder( builder: (context, constraints) { final cols = (constraints.maxWidth / 180).floor().clamp(2, 6); return GridView.builder( padding: EdgeInsets.zero, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: cols, mainAxisSpacing: TimbreSpacing.md, crossAxisSpacing: TimbreSpacing.md, // Square art + two caption lines; extra vertical slack so // the tile never sub-pixel-overflows. childAspectRatio: 0.68, ), itemCount: list.length, itemBuilder: (context, i) => _AlbumTile( album: list[i], artUri: (client != null && list[i].coverArt != null) ? client .coverArtUri(list[i].coverArt!, size: 300) .toString() : null, ), ); }, ), ), ); } } class _AlbumTile extends StatelessWidget { const _AlbumTile({required this.album, required this.artUri}); final Album album; final String? artUri; @override Widget build(BuildContext context) { return InkWell( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AspectRatio( aspectRatio: 1, child: ColoredBox( color: TimbreColors.surface, child: artUri != null ? Image.network( artUri!, key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => const _AlbumArtFallback(), ) : const _AlbumArtFallback(), ), ), const SizedBox(height: TimbreSpacing.xs), Text( album.name ?? 'Unknown album', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: TimbreColors.foreground), ), if (album.artist != null) Text( album.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: TimbreColors.dimmed, fontSize: 12), ), ], ), ); } } class _AlbumArtFallback extends StatelessWidget { const _AlbumArtFallback(); @override Widget build(BuildContext context) => const Center( child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 32), ); } /// Flat alphabetical list of every song, backed by the crawled+cached library /// index. Kicks off the build on first display and shows progress. class _TracksPanel extends ConsumerStatefulWidget { const _TracksPanel(); @override ConsumerState<_TracksPanel> createState() => _TracksPanelState(); } class _TracksPanelState extends ConsumerState<_TracksPanel> { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(libraryIndexProvider.notifier).ensureBuilt(); }); } @override Widget build(BuildContext context) { final index = ref.watch(libraryIndexProvider); final playback = ref.read(playbackProvider.notifier); final Widget body; if (index.building) { final total = index.total; final label = total > 0 ? 'Indexing ${index.done}/$total albums…' : 'Indexing library…'; body = _Centered( child: Column( mainAxisSize: MainAxisSize.min, children: [ const _Loading(), const SizedBox(height: TimbreSpacing.md), Text(label, style: const TextStyle(color: TimbreColors.dimmed)), ], ), ); } else if (index.songs.isEmpty) { body = _Centered( child: _ErrorText(index.error != null ? 'Could not build the track index.' : 'No tracks indexed yet.'), ); } else { final downloads = ref.watch(downloadManagerProvider); body = ListView.builder( padding: EdgeInsets.zero, itemCount: index.songs.length, itemBuilder: (context, i) { final song = index.songs[i]; return BrowseRow( title: song.title ?? 'Untitled', trailing: song.artist, downloadStatus: downloads.byId[song.id]?.status, onTap: () => playback.playSongs(index.songs, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), onAddToPlaylist: () => showAddToPlaylistSheet(context, songs: [song]), onDownload: () => ref.read(downloadManagerProvider.notifier).download(song), onRemoveDownload: () => ref.read(downloadManagerProvider.notifier).remove(song.id), ); }, ); } return HairlinePanel( title: 'Tracks', active: true, trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null, padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: Column( children: [ Align( alignment: Alignment.centerRight, child: InkWell( onTap: index.building ? null : () => ref.read(libraryIndexProvider.notifier).refresh(), child: const Padding( padding: EdgeInsets.symmetric( horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.sm, ), child: Text('↻ refresh', style: TextStyle(color: TimbreColors.dimmed)), ), ), ), Expanded(child: body), ], ), ); } } class _Action extends StatelessWidget { const _Action({required this.icon, required this.label, required this.onTap}); final IconData icon; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.sm), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.sm), Text(label, style: const TextStyle(color: TimbreColors.foreground)), ], ), ), ); } } class ArtistScreen extends ConsumerWidget { const ArtistScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context, WidgetRef ref) { final artist = ref.watch(artistProvider(id)); return _DetailScaffold( title: artist.valueOrNull?.name ?? 'Artist', child: artist.when( loading: () => const _Centered(child: _Loading()), error: (e, _) => _Centered(child: _ErrorText('$e')), data: (a) => ListView.builder( padding: EdgeInsets.zero, itemCount: a.albums.length, itemBuilder: (context, i) { final album = a.albums[i]; return BrowseRow( title: album.name ?? 'Unknown album', trailing: album.year?.toString(), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumScreen(id: album.id), ), ), ); }, ), ), ); } } class AlbumScreen extends ConsumerWidget { const AlbumScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(albumProvider(id)); final downloads = ref.watch(downloadManagerProvider); final playback = ref.read(playbackProvider.notifier); final songs = album.valueOrNull?.songs ?? const []; return _DetailScaffold( title: album.valueOrNull?.name ?? 'Album', actions: songs.isEmpty ? null : [ IconButton( tooltip: 'Add to playlist', onPressed: () => showAddToPlaylistSheet(context, songs: songs), icon: const Icon(Icons.playlist_add), ), IconButton( tooltip: 'Download album', onPressed: () { ref .read(downloadManagerProvider.notifier) .downloadAll(songs); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Downloading album…'), duration: Duration(seconds: 2)), ); }, icon: const Icon(Icons.download), ), ], child: album.when( loading: () => const _Centered(child: _Loading()), error: (e, _) => _Centered(child: _ErrorText('$e')), data: (a) => ListView.builder( padding: EdgeInsets.zero, itemCount: a.songs.length, itemBuilder: (context, i) { final song = a.songs[i]; return BrowseRow( leading: song.track?.toString(), title: song.title ?? 'Untitled', trailing: _fmtDuration(song.duration), downloadStatus: downloads.byId[song.id]?.status, onTap: () => playback.playSongs(a.songs, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), onAddToPlaylist: () => showAddToPlaylistSheet(context, songs: [song]), onDownload: () => ref.read(downloadManagerProvider.notifier).download(song), onRemoveDownload: () => ref.read(downloadManagerProvider.notifier).remove(song.id), ); }, ), ), ); } } // ---- Shared bits -------------------------------------------------------- class BrowseRow extends StatelessWidget { const BrowseRow({ super.key, required this.title, this.leading, this.trailing, this.onTap, this.onPlayNext, this.onAddToQueue, this.onAddToPlaylist, this.onDownload, this.onRemoveDownload, this.downloadStatus, }); final String title; final String? leading; final String? trailing; final VoidCallback? onTap; /// When set, renders inline "play next" / "add to queue" icons (song rows). final VoidCallback? onPlayNext; final VoidCallback? onAddToQueue; /// Secondary song actions, folded into a trailing overflow menu so the row /// stays uncluttered. final VoidCallback? onAddToPlaylist; final VoidCallback? onDownload; final VoidCallback? onRemoveDownload; /// Current offline-download state for this row's track (drives the menu label /// and the at-a-glance downloaded indicator). final DownloadStatus? downloadStatus; bool get _hasMenu => onAddToPlaylist != null || onDownload != null || onRemoveDownload != null; @override Widget build(BuildContext context) { final accent = Theme.of(context).colorScheme.primary; final isDone = downloadStatus == DownloadStatus.done; final isActive = downloadStatus == DownloadStatus.queued || downloadStatus == DownloadStatus.downloading; return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ if (leading != null) SizedBox( width: 28, child: Text(leading!, style: const TextStyle(color: TimbreColors.dimmed)), ), Expanded( child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: TimbreColors.foreground), ), ), if (isDone) Padding( padding: const EdgeInsets.only(left: TimbreSpacing.sm), child: Icon(Icons.download_done, size: 14, color: accent), ) else if (isActive) const Padding( padding: EdgeInsets.only(left: TimbreSpacing.sm), child: SizedBox( width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5), ), ), if (trailing != null) ...[ const SizedBox(width: TimbreSpacing.md), Text(trailing!, style: const TextStyle(color: TimbreColors.dimmed)), ], if (onPlayNext != null) _RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!), if (onAddToQueue != null) _RowIcon(icon: Icons.add, onTap: onAddToQueue!), if (_hasMenu) _RowMenu( isDownloaded: isDone, isDownloading: isActive, onAddToPlaylist: onAddToPlaylist, onDownload: onDownload, onRemoveDownload: onRemoveDownload, ), if (onPlayNext == null && onAddToQueue == null && !_hasMenu) const SizedBox(width: TimbreSpacing.lg), ], ), ), ); } } /// Trailing overflow menu for a song row's secondary actions. class _RowMenu extends StatelessWidget { const _RowMenu({ required this.isDownloaded, required this.isDownloading, this.onAddToPlaylist, this.onDownload, this.onRemoveDownload, }); final bool isDownloaded; final bool isDownloading; final VoidCallback? onAddToPlaylist; final VoidCallback? onDownload; final VoidCallback? onRemoveDownload; @override Widget build(BuildContext context) { return PopupMenuButton( icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget), onSelected: (v) { switch (v) { case 'playlist': onAddToPlaylist?.call(); case 'download': onDownload?.call(); case 'remove_download': onRemoveDownload?.call(); } }, itemBuilder: (_) => [ if (onAddToPlaylist != null) const PopupMenuItem( value: 'playlist', child: Text('Add to playlist')), if (isDownloaded && onRemoveDownload != null) const PopupMenuItem( value: 'remove_download', child: Text('Remove download')) else if (onDownload != null) PopupMenuItem( value: 'download', enabled: !isDownloading, child: Text(isDownloading ? 'Downloading…' : 'Download')), ], ); } } /// Compact trailing action icon with a ≥44pt hit target. class _RowIcon extends StatelessWidget { const _RowIcon({required this.icon, required this.onTap}); final IconData icon; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, customBorder: const CircleBorder(), child: SizedBox( width: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget, child: Icon(icon, size: 20, color: TimbreColors.dimmed), ), ); } } class _DetailScaffold extends StatelessWidget { const _DetailScaffold({ required this.title, required this.child, this.actions, }); final String title; final Widget child; final List? actions; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w700)), actions: actions, ), body: SafeArea(child: child), ); } } class _NotConnected extends StatelessWidget { const _NotConnected(); @override Widget build(BuildContext context) { return const _Centered( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Not connected.', style: TextStyle(color: TimbreColors.foreground)), SizedBox(height: TimbreSpacing.sm), Text('Tap the status bar to add a Subsonic server.', textAlign: TextAlign.center, style: TextStyle(color: TimbreColors.dimmed)), ], ), ); } } class _Centered extends StatelessWidget { const _Centered({required this.child}); final Widget child; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(TimbreSpacing.xl), child: Center(child: child), ); } class _Loading extends StatelessWidget { const _Loading(); @override Widget build(BuildContext context) => const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ); } class _ErrorText extends StatelessWidget { const _ErrorText(this.message); final String message; @override Widget build(BuildContext context) => Text( message, textAlign: TextAlign.center, style: const TextStyle(color: TimbreColors.dimmed), ); } String? _fmtDuration(int? seconds) { if (seconds == null) return null; final m = seconds ~/ 60; final s = seconds % 60; return '$m:${s.toString().padLeft(2, '0')}'; }