This commit is contained in:
Forrest 2026-07-29 15:28:11 -04:00
parent d205277cdd
commit d1cab09a4f
9 changed files with 850 additions and 140 deletions

View file

@ -279,6 +279,12 @@ class DownloadController extends StateNotifier<DownloadState> {
return;
}
// Honor a removal that happened mid-download: don't resurrect the entry.
if (!state.byId.containsKey(song.id)) {
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
final tmp = File(tmpPath);
await tmp.rename(finalPath);
final size = await File(finalPath).length();

View file

@ -11,7 +11,6 @@ import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
import 'search_screen.dart';
import 'settings_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;
@ -66,13 +65,6 @@ class BrowserScreen extends ConsumerWidget {
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
),
),
_Action(
icon: Icons.settings,
label: 'Settings',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
const SizedBox(height: RatuneSpacing.md),
@ -337,6 +329,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
: 'No tracks indexed yet.'),
);
} else {
final downloads = ref.watch(downloadManagerProvider);
body = ListView.builder(
padding: EdgeInsets.zero,
itemCount: index.songs.length,
@ -345,9 +338,16 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
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),
);
},
);
@ -451,8 +451,36 @@ class AlbumScreen extends ConsumerWidget {
@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);
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')),
@ -461,14 +489,20 @@ class AlbumScreen extends ConsumerWidget {
itemCount: a.songs.length,
itemBuilder: (context, i) {
final song = a.songs[i];
final playback = ref.read(playbackProvider.notifier);
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),
);
},
),

View file

@ -1,13 +1,16 @@
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/hairline_panel.dart';
import '../widgets/block_progress_bar.dart';
import 'browser_screen.dart';
/// Home tab — Recently Played (album-art strip), Recent Tracks, and Rediscover,
/// all derived from the local play history (mirrors Ratune's home tab).
/// 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});
@ -15,8 +18,14 @@ class HomeScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final recentAlbums = ref.watch(recentAlbumsProvider);
final recentSongs = ref.watch(recentSongsProvider);
final rediscover = ref.watch(rediscoverProvider);
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(
@ -26,123 +35,408 @@ class HomeScreen extends ConsumerWidget {
RatuneSpacing.lg,
),
children: [
HairlinePanel(
title: 'Recently Played',
child: SizedBox(
height: 120,
child: recentAlbums.isEmpty
? const _Empty('No listening history yet.')
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentAlbums.length,
separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.md),
itemBuilder: (_, i) {
final rec = recentAlbums[i];
final art = (client != null && rec.coverArt != null)
? client.coverArtUri(rec.coverArt!, size: 240).toString()
: null;
return GestureDetector(
onTap: rec.albumId == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
AlbumScreen(id: rec.albumId!),
),
),
child: SizedBox(
width: 120,
child: ColoredBox(
color: RatuneColors.surface,
child: art != null
? Image.network(art,
fit: BoxFit.cover, gaplessPlayback: true)
: const Icon(Icons.album_outlined,
color: RatuneColors.dimmed),
),
),
);
},
),
),
),
const _HeroCard(),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Recent Tracks',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: recentSongs.isEmpty
? const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: _Empty('Nothing played recently.'),
)
: Column(
children: [
for (final rec in recentSongs.take(8))
BrowseRow(
title: rec.title,
trailing: rec.artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs([rec.toSong()]),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(rec.toSong()),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(rec.toSong()),
),
],
// 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!),
),
),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Rediscover',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (rediscover.isEmpty)
const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: _Empty('Listen to more music to unlock suggestions.'),
)
else
for (final a in rediscover)
BrowseRow(
title: a.name,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.artistId),
),
),
),
InkWell(
onTap: () =>
ref.read(rediscoverSeedProvider.notifier).state++,
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.md,
),
child: Text('↻ re-roll',
style: TextStyle(color: RatuneColors.dimmed)),
),
),
],
),
// Recently Added — server discovery shelf.
_AlbumShelf(
title: 'Recently Added',
albums: newest,
artFor: artFor,
),
// Made For You — rediscover artists you're neglecting.
if (rediscover.isNotEmpty)
_Shelf(
title: 'Made For You',
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
cards: [
for (final a in rediscover)
_ArtCard(
artUri: null,
title: a.name,
subtitle: 'Artist',
fallbackIcon: Icons.person_outline,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.artistId),
),
),
),
],
),
// Random — server discovery shelf, shares the shuffle affordance.
_AlbumShelf(
title: 'Random',
albums: random,
artFor: artFor,
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
),
if (recentAlbums.isEmpty && client == null)
const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.xl),
child: Text(
'Connect to a server and start listening — your home fills in as you play.',
style: TextStyle(color: RatuneColors.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(playbackProvider.select((s) => s.current));
final progress = ref.watch(playbackProvider.select((s) => s.progress));
// 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: const Row(
children: [
Icon(Icons.library_music_outlined,
color: RatuneColors.dimmed, size: 40),
SizedBox(width: RatuneSpacing.lg),
Expanded(
child: Text('Browse your library to start listening',
style: TextStyle(color: RatuneColors.foreground)),
),
],
),
);
}
return _HeroShell(
accent: accent,
onTap: () {
if (hasCurrent) {
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
} else if (fallback != null) {
ref.read(playbackProvider.notifier).playSongs([fallback.toSong()]);
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
}
},
child: Row(
children: [
SizedBox(
width: 64,
height: 64,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon(
Icons.album_outlined, color: RatuneColors.dimmed))
: const Icon(Icons.album_outlined,
color: RatuneColors.dimmed),
),
),
const SizedBox(width: RatuneSpacing.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: RatuneSpacing.xs),
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle(
color: accent,
fontSize: 11,
letterSpacing: 1,
fontWeight: FontWeight.w700)),
],
),
const SizedBox(height: RatuneSpacing.xs),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.foreground,
fontWeight: FontWeight.w700)),
if (subtitle != null)
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.dimmed)),
if (hasCurrent) ...[
const SizedBox(height: RatuneSpacing.md),
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(RatuneSpacing.lg),
decoration: BoxDecoration(
color: RatuneColors.surface,
border: Border.all(color: RatuneColors.borderActive),
),
child: child,
),
);
}
}
/// A horizontal shelf: a header (with an optional shuffle action) over a
/// scrolling row of art cards.
class _Shelf extends StatelessWidget {
const _Shelf({required this.title, required this.cards, this.onShuffle});
final String title;
final List<Widget> cards;
final VoidCallback? onShuffle;
@override
Widget build(BuildContext context) {
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShelfHeader(title: title, onShuffle: onShuffle),
const SizedBox(height: RatuneSpacing.md),
SizedBox(
height: 182,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: cards.length,
separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.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: const TextStyle(
color: RatuneColors.foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5)),
const Spacer(),
if (onShuffle != null)
InkWell(
onTap: onShuffle,
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: 28,
child: Icon(Icons.shuffle, size: 18, color: RatuneColors.dimmed),
),
),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty(this.message);
final String message;
/// 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,
this.onShuffle,
});
final String title;
final AsyncValue<List<Album>> albums;
final String? Function(String? coverArt, {int size}) artFor;
final VoidCallback? onShuffle;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)),
);
Widget build(BuildContext context) {
return albums.when(
loading: () => _Shelf(
title: title,
onShuffle: onShuffle,
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
),
error: (_, _) => const SizedBox.shrink(),
data: (list) => _Shelf(
title: title,
onShuffle: onShuffle,
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)),
),
),
],
),
);
}
}
/// 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,
this.fallbackIcon = Icons.album_outlined,
});
final String title;
final String? artUri;
final String? subtitle;
final VoidCallback? onTap;
final IconData fallbackIcon;
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: RatuneColors.surface,
child: artUri != null
? Image.network(artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
Icon(fallbackIcon, color: RatuneColors.dimmed))
: Icon(fallbackIcon, color: RatuneColors.dimmed),
),
),
),
const SizedBox(height: RatuneSpacing.sm),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: RatuneColors.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: const SizedBox(
width: _ArtCard._size,
height: _ArtCard._size,
child: ColoredBox(color: RatuneColors.surface),
),
),
);
}
}

View file

@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode;
import '../downloads/download_manager.dart';
import '../playback/playback_engine.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/block_progress_bar.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
/// Now Playing tab — album art + info strip + transport, bound to the live
/// playback engine. The top region shows the full-size album art by default and
@ -249,6 +251,12 @@ class _FavRating extends ConsumerWidget {
final rating =
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
final downloadStatus =
ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
final isDownloaded = downloadStatus == DownloadStatus.done;
final isDownloading = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return Row(
children: [
InkWell(
@ -278,6 +286,41 @@ class _FavRating extends ConsumerWidget {
),
),
),
const Spacer(),
InkWell(
onTap: () => showAddToPlaylistSheet(context, songs: [song]),
customBorder: const CircleBorder(),
child: const Padding(
padding: EdgeInsets.all(RatuneSpacing.sm),
child: Icon(Icons.playlist_add,
size: 22, color: RatuneColors.dimmed),
),
),
InkWell(
onTap: isDownloading
? null
: () {
final dm = ref.read(downloadManagerProvider.notifier);
isDownloaded ? dm.remove(song.id) : dm.download(song);
},
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm),
child: isDownloading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
isDownloaded
? Icons.download_done
: Icons.download_outlined,
size: 22,
color: isDownloaded ? accent : RatuneColors.dimmed,
),
),
),
],
);
}

View file

@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../playlists/playlists.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';

View file

@ -6,6 +6,7 @@ import '../screens/browser_screen.dart';
import '../screens/connect_sheet.dart';
import '../screens/home_screen.dart';
import '../screens/now_playing_screen.dart';
import '../screens/settings_screen.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/mini_player.dart';
@ -52,6 +53,12 @@ class _AppShellState extends ConsumerState<AppShell> {
// Keep favorites alive from launch so its connect/disconnect listener runs
// and hydrates stars/ratings as soon as a server connects.
ref.watch(favoritesProvider);
// Instantiate the download + playlist stores at launch too, so their
// per-server manifests load from disk before the user can trigger
// playback — otherwise the first play right after a cold (offline) boot
// races the async manifest load and misses a downloaded file.
ref.watch(downloadManagerProvider);
ref.watch(playlistsProvider);
final index = ref.watch(selectedTabProvider);
return PopScope(
@ -189,27 +196,53 @@ class _StatusBar extends ConsumerWidget {
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
};
return InkWell(
onTap: () => showConnectSheet(context),
child: Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
color: RatuneColors.surface,
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color),
return Container(
height: 22,
color: RatuneColors.surface,
child: Row(
children: [
// Left: connection status — tap to open the connect sheet.
Expanded(
child: InkWell(
onTap: () => showConnectSheet(context),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color),
),
),
],
),
),
),
const SizedBox(width: RatuneSpacing.md),
const Text('i — help', style: TextStyle(color: RatuneColors.dimmed)),
],
),
),
// Right: settings — pushes over the whole shell (root navigator).
InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings, size: 12, color: RatuneColors.dimmed),
SizedBox(width: RatuneSpacing.xs),
Text('settings',
style: TextStyle(color: RatuneColors.dimmed)),
],
),
),
),
],
),
);
}

View file

@ -195,6 +195,23 @@ final libraryIndexProvider =
return controller;
});
/// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on
/// the Home tab. Small page; the Home shelf shows the first handful.
final newestAlbumsProvider = FutureProvider<List<Album>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
return client.getAlbumList2(type: 'newest', size: 20);
});
/// A random album shelf (`getAlbumList2` type `random`). Re-rolls when
/// [rediscoverSeedProvider] bumps so it shares the Home "shuffle" affordance.
final randomAlbumsProvider = FutureProvider<List<Album>>((ref) async {
ref.watch(rediscoverSeedProvider);
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
return client.getAlbumList2(type: 'random', size: 20);
});
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) throw StateError('Not connected');