This commit is contained in:
Forrest 2026-08-04 16:08:51 -04:00
parent d558aba246
commit 3bd713d667
17 changed files with 1566 additions and 132 deletions

View file

@ -9,6 +9,7 @@ import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
import 'add_tag_sheet.dart';
import 'add_to_playlist_sheet.dart';
import 'browse_controls.dart';
import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
@ -188,7 +189,8 @@ class _AlbumsPanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumsProvider);
final albums = ref.watch(visibleAlbumsProvider);
final filter = ref.watch(albumFilterProvider);
final client = ref.watch(subsonicClientProvider);
return HairlinePanel(
title: 'Albums',
@ -200,34 +202,56 @@ class _AlbumsPanel extends ConsumerWidget {
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,
),
);
},
data: (list) {
// Hide the control bar only on a genuinely empty server (nothing to
// filter); keep it visible if a filter is what emptied the list.
final showControls = list.isNotEmpty || filter.isActive;
return Column(
children: [
if (showControls) ...[
const AlbumControlBar(),
const SizedBox(height: TimbreSpacing.md),
],
Expanded(
child: list.isEmpty
? _Centered(
child: _ErrorText(filter.isActive
? 'No albums match these filters.'
: '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,
),
);
},
),
),
],
);
},
),
);
}
@ -314,6 +338,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
@override
Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider);
final visible = ref.watch(visibleTracksProvider);
final playback = ref.read(playbackProvider.notifier);
final client = ref.watch(subsonicClientProvider);
@ -341,61 +366,132 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
);
} else {
final downloads = ref.watch(downloadManagerProvider);
body = ListView.builder(
padding: EdgeInsets.zero,
itemCount: index.songs.length,
itemBuilder: (context, i) {
final song = index.songs[i];
final artUri = (client != null && song.coverArt != null)
? client.coverArtUri(song.coverArt!, size: 128).toString()
: null;
return BrowseRow(
title: song.title ?? 'Untitled',
subtitle: song.artist,
artUri: artUri,
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),
);
},
body = Column(
children: [
const TrackControlBar(),
const SizedBox(height: TimbreSpacing.md),
Expanded(
child: visible.isEmpty
? const _Centered(
child: _ErrorText('No tracks match these filters.'))
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: visible.length,
itemBuilder: (context, i) {
final song = visible[i];
final artUri = (client != null && song.coverArt != null)
? client
.coverArtUri(song.coverArt!, size: 128)
.toString()
: null;
return BrowseRow(
title: song.title ?? 'Untitled',
subtitle: song.artist,
artUri: artUri,
downloadStatus: downloads.byId[song.id]?.status,
onTap: () =>
playback.playSongs(visible, 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,
trailing: index.songs.isNotEmpty ? '(${visible.length})' : null,
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Column(
action: Row(
mainAxisSize: MainAxisSize.min,
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)),
),
InkWell(
onTap: index.building
? null
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: Text('↻ refresh',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
),
),
Expanded(child: body),
if (visible.isNotEmpty)
PopupMenuButton<String>(
tooltip: 'More',
color: TimbreColors.surface,
padding: EdgeInsets.zero,
onSelected: (value) {
if (value == 'download-all') {
_confirmDownloadAll(context, visible);
}
},
itemBuilder: (_) => const [
PopupMenuItem<String>(
value: 'download-all',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download,
size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Download all',
style: TextStyle(color: TimbreColors.foreground)),
],
),
),
],
child: const Padding(
padding: EdgeInsets.all(TimbreSpacing.xs),
child: Icon(Icons.more_vert,
size: 18, color: TimbreColors.dimmed),
),
),
],
),
child: body,
);
}
/// Confirm before enqueueing a large batch of tracks — this can be the whole
/// library, so it's gated behind a dialog unlike per-album download-all.
/// [songs] is the currently-visible (filtered/sorted) set.
Future<void> _confirmDownloadAll(
BuildContext context, List<Song> songs) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: TimbreColors.surface,
title: const Text('Download these tracks?'),
content: Text(
'This queues all ${songs.length} listed tracks for offline '
'download. It may use significant storage and data.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Download all')),
],
),
);
if (ok != true || !context.mounted) return;
ref.read(downloadManagerProvider.notifier).downloadAll(songs);
showToast(context, 'Downloading tracks…');
}
}