mobile-music/lib/screens/browser_screen.dart
2026-07-29 22:41:38 -04:00

810 lines
26 KiB
Dart

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 '../widgets/toast.dart';
import 'add_tag_sheet.dart';
import 'add_to_playlist_sheet.dart';
import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
import 'search_screen.dart';
import 'tags_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.label_outline,
label: 'Tags',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TagsScreen()),
),
),
_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]),
onAddToTag: () => showAddTagSheet(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 <Song>[];
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);
showToast(context, 'Downloading album…');
},
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]),
onAddToTag: () => showAddTagSheet(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.onAddToTag,
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? onAddToTag;
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 ||
onAddToTag != 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!();
showToast(context, 'Playing next', icon: Icons.check);
},
),
if (onAddToQueue != null)
_RowIcon(
icon: Icons.add,
onTap: () {
onAddToQueue!();
showToast(context, 'Added to queue', icon: Icons.check);
},
),
if (_hasMenu)
_RowMenu(
isDownloaded: isDone,
isDownloading: isActive,
onAddToPlaylist: onAddToPlaylist,
onAddToTag: onAddToTag,
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.onAddToTag,
this.onDownload,
this.onRemoveDownload,
});
final bool isDownloaded;
final bool isDownloading;
final VoidCallback? onAddToPlaylist;
final VoidCallback? onAddToTag;
final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
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 'tag':
onAddToTag?.call();
case 'download':
onDownload?.call();
case 'remove_download':
onRemoveDownload?.call();
}
},
itemBuilder: (_) => [
if (onAddToPlaylist != null)
const PopupMenuItem(
value: 'playlist', child: Text('Add to playlist')),
if (onAddToTag != null)
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
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<Widget>? 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')}';
}