updates
This commit is contained in:
parent
d558aba246
commit
3bd713d667
17 changed files with 1566 additions and 132 deletions
|
|
@ -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…');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue