Merge pull request 'release v2026.05.11.1: Flutter caching, navigation, and player polish' (#39) from dev into main

This commit was merged in pull request #39.
This commit is contained in:
2026-05-11 17:47:41 +00:00
14 changed files with 571 additions and 116 deletions
@@ -12,9 +12,16 @@ class LibraryListsApi {
final Dio _dio;
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
// Server mounts the artists list at /api/artists (handleListArtists),
// not /api/library/artists. Albums use /api/library/albums for
// historical reasons; the paths aren't symmetric.
final r = await _dio.get<Map<String, dynamic>>(
'/api/library/artists',
queryParameters: {'limit': limit, 'offset': offset},
'/api/artists',
queryParameters: {
'limit': limit,
'offset': offset,
'sort': 'alpha',
},
);
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
}
@@ -0,0 +1,27 @@
import 'package:dio/dio.dart';
import '../../models/track.dart';
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
/// scored by the server's recommendation engine. The shape matches
/// what `playerActions.playTracks` expects, so a radio start is just
/// one fetch + one playTracks call.
class RadioApi {
RadioApi(this._dio);
final Dio _dio;
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/radio',
queryParameters: {
'seed_track': trackId,
if (limit != null) 'limit': limit,
},
);
final raw = (r.data?['tracks'] as List?) ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../models/album.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../likes/like_button.dart';
@@ -12,18 +13,78 @@ import 'library_providers.dart';
import 'widgets/track_row.dart';
class AlbumDetailScreen extends ConsumerWidget {
const AlbumDetailScreen({required this.id, super.key});
const AlbumDetailScreen({required this.id, this.seed, super.key});
final String id;
/// Optional album reference passed by the caller (typically the
/// tile they tapped) so the header can render immediately while
/// the full provider loads tracks. Hydration only — provider data
/// still wins once it arrives.
final AlbumRef? seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final live = ref.watch(albumProvider(id));
// Pick the best header info available: live data > seed > nothing.
final headerTitle = live.value?.album.title.isNotEmpty == true
? live.value!.album.title
: seed?.title ?? '';
final headerArtist = live.value?.album.artistName.isNotEmpty == true
? live.value!.album.artistName
: seed?.artistName ?? '';
Widget header() => Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 96,
height: 96,
child: ServerImage(
url: '/api/albums/$id/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
headerTitle,
style: TextStyle(
color: fs.parchment,
fontFamily: fs.display.fontFamily,
fontSize: 22,
),
),
if (headerArtist.isNotEmpty)
Text(headerArtist, style: TextStyle(color: fs.ash)),
],
),
),
]),
);
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: ref.watch(albumProvider(id)).when(
body: live.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => seed != null
? ListView(children: [
header(),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
])
: const Center(child: CircularProgressIndicator()),
data: (r) => ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../api/endpoints/likes.dart';
import '../likes/like_button.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
@@ -12,22 +13,49 @@ import 'library_providers.dart';
import 'widgets/album_card.dart';
class ArtistDetailScreen extends ConsumerWidget {
const ArtistDetailScreen({required this.id, super.key});
const ArtistDetailScreen({required this.id, this.seed, super.key});
final String id;
/// Optional artist reference from the caller. Lets the screen render
/// the name + avatar immediately while albums load.
final ArtistRef? seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final artist = ref.watch(artistProvider(id));
final albums = ref.watch(artistAlbumsProvider(id));
// Resolve which artist info to render in the header. Live wins
// when present and populated; seed fills the gap during the
// first frame after navigation.
final liveArtist = artist.value;
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: artist.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Center(child: CircularProgressIndicator()),
data: (a) => ListView(children: [
// While loading: render header from seed if available so the
// page isn't blank.
loading: () => seed == null
? const Center(child: CircularProgressIndicator())
: _artistBody(context, ref, seed!, albums, fs),
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
),
);
}
Widget _artistBody(
BuildContext context,
WidgetRef ref,
ArtistRef a,
AsyncValue<List<AlbumRef>> albums,
FabledSwordTheme fs,
) =>
ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
@@ -111,16 +139,14 @@ class ArtistDetailScreen extends ConsumerWidget {
width: cellW,
titleMaxLines: 2,
showArtist: false,
onTap: () => context.push('/albums/${album.id}'),
onTap: () =>
context.push('/albums/${album.id}', extra: album),
);
},
);
}),
),
]),
),
);
}
]);
}
/// Renders the artist's avatar. Server-emitted coverUrl wins when
+87 -13
View File
@@ -12,7 +12,6 @@ import '../models/track.dart';
import '../playlists/playlists_provider.dart';
import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.dart';
import '../shared/delayed_loading.dart';
import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
@@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget {
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const DelayedLoading(
isLoading: true,
whenReady: SizedBox.shrink(),
whileDelayed:
Center(child: CircularProgressIndicator()),
),
loading: () => _HomeSkeleton(fs: fs),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(
@@ -190,7 +184,7 @@ class _RecentlyAddedSection extends StatelessWidget {
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
context.push('/albums/${a.id}', extra: a),
))
.toList(),
),
@@ -220,7 +214,7 @@ class _RediscoverSection extends StatelessWidget {
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
context.push('/albums/${a.id}', extra: a),
))
.toList(),
),
@@ -232,7 +226,7 @@ class _RediscoverSection extends StatelessWidget {
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
_push(context, '/artists/${ar.id}'),
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
),
@@ -291,7 +285,8 @@ class _LastPlayedSection extends StatelessWidget {
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () => _push(context, '/artists/${ar.id}'),
onTap: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
);
@@ -324,8 +319,87 @@ class _EmptySection extends StatelessWidget {
}
}
void _push(BuildContext context, String path) {
context.push(path);
/// Cold-start skeleton. Renders the same shape as the real home (a few
/// section titles + card-sized placeholders) so the page feels alive
/// while /api/home is in flight, instead of a 30-second blank spinner.
/// Each section drops in independently as data arrives via the
/// individual providers.
class _HomeSkeleton extends StatelessWidget {
const _HomeSkeleton({required this.fs});
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
children: [
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
const SizedBox(height: 140),
],
);
}
}
class _SkeletonSection extends StatelessWidget {
const _SkeletonSection({
required this.fs,
required this.title,
required this.cardWidth,
});
final FabledSwordTheme fs;
final String title;
final double cardWidth;
@override
Widget build(BuildContext context) {
final coverSize = cardWidth - 16;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
),
SizedBox(
height: coverSize + 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
],
),
),
),
),
]);
}
}
List<List<T>> _chunk<T>(List<T> items, int size) {
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
@@ -64,6 +66,9 @@ final artistProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: yield cache instantly on hit, refresh in the background so
// the row catches up to server state without making the user wait.
alwaysRefresh: true,
tag: 'artist($id)',
);
});
@@ -95,6 +100,7 @@ final artistAlbumsProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'artistAlbums($artistId)',
);
});
@@ -128,8 +134,11 @@ final artistTracksProvider =
albumTitle: album?.title ?? '',
);
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'artistTracks($artistId)',
);
});
@@ -160,6 +169,10 @@ final albumProvider = StreamProvider.family<
// Once-per-subscription guard so we don't re-fetch in a loop if the
// server genuinely returns zero tracks (or if the fetch fails).
var fetchAttempted = false;
// SWR revalidation guard — fire one background refresh per
// subscription on the first complete cache hit, so the displayed
// data catches up to server state without making the user wait.
var revalidated = false;
Future<bool> isOnline() async {
try {
@@ -290,6 +303,16 @@ final albumProvider = StreamProvider.family<
);
}).toList();
// SWR: first complete cache hit triggers one background refresh
// so we don't show stale data indefinitely. Drift watch re-emits
// on success and the next iteration yields the fresh data.
if (!revalidated && trackRows.isNotEmpty) {
revalidated = true;
if (await isOnline()) {
unawaited(fetchAndPopulate());
}
}
yield (album: album, tracks: tracks);
}
});
+59 -26
View File
@@ -144,10 +144,14 @@ class _ArtistsTab extends ConsumerWidget {
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) => ArtistCard(
artist: page.items[i],
onTap: () => ctx.push('/artists/${page.items[i].id}'),
),
itemBuilder: (ctx, i) {
final artist = page.items[i];
return ArtistCard(
artist: artist,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
},
),
),
);
@@ -167,20 +171,41 @@ class _AlbumsTab extends ConsumerWidget {
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) => AlbumCard(
album: page.items[i],
onTap: () => ctx.push('/albums/${page.items[i].id}'),
),
),
// Same responsive 3-up grid as the artist detail
// album list — LayoutBuilder + AlbumCard sized to the
// cell, mainAxisExtent matched to actual card height.
child: LayoutBuilder(builder: (ctx, constraints) {
const cols = 3;
const sidePad = 8.0;
const gap = 8.0;
final cellW = (constraints.maxWidth -
sidePad * 2 -
gap * (cols - 1)) /
cols;
// cover (cellW - 16) + gap (8) + 2-line title (~36)
// + artist line (~16) + small fudge.
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
return GridView.builder(
padding: const EdgeInsets.all(sidePad),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final album = page.items[i];
return AlbumCard(
album: album,
width: cellW,
titleMaxLines: 2,
onTap: () => ctx.push('/albums/${album.id}',
extra: album),
);
},
);
}),
),
);
}
@@ -260,10 +285,14 @@ class _LikedTab extends ConsumerWidget {
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: ar.items.length,
itemBuilder: (ctx, i) => ArtistCard(
artist: ar.items[i],
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
),
itemBuilder: (ctx, i) {
final artist = ar.items[i];
return ArtistCard(
artist: artist,
onTap: () =>
ctx.push('/artists/${artist.id}', extra: artist),
);
},
),
),
],
@@ -275,10 +304,14 @@ class _LikedTab extends ConsumerWidget {
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: al.items.length,
itemBuilder: (ctx, i) => AlbumCard(
album: al.items[i],
onTap: () => ctx.push('/albums/${al.items[i].id}'),
),
itemBuilder: (ctx, i) {
final album = al.items[i];
return AlbumCard(
album: album,
onTap: () =>
ctx.push('/albums/${album.id}', extra: album),
);
},
),
),
],
+27 -10
View File
@@ -165,19 +165,36 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
queue.add([...queue.value, item]);
}
MediaItem _toMediaItem(TrackRef t) => MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
// Stash album_id in extras so _loadArtForCurrentItem can pull it
// back without re-walking the track list.
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
);
MediaItem _toMediaItem(TrackRef t) {
// Stash album_id + artist_id in extras so widgets reconstructing
// a TrackRef from the MediaItem (player kebab → "Go to artist",
// "Go to album") have the IDs they need to navigate. Earlier code
// only carried album_id which left "Go to artist" pushing
// /artists/ (empty id, route 404).
final extras = <String, dynamic>{};
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
return MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
extras: extras.isEmpty ? null : extras,
);
}
void _onCurrentIndexChanged(int? idx) {
if (idx == null) return;
// Push the new track's MediaItem onto the mediaItem stream so
// the player UI rebuilds with the new title/artist/album/cover.
// Without this, the bar and full player stayed pinned to whichever
// track was passed via setQueueFromTracks(initialIndex:) regardless
// of skip/auto-advance.
final items = queue.value;
if (idx >= 0 && idx < items.length) {
mediaItem.add(items[idx]);
}
unawaited(_loadArtForCurrentItem());
}
@@ -219,30 +219,49 @@ class _TitleRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(children: [
Expanded(
child: Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Stack: title centered absolutely in the row; like + kebab pinned
// to the right edge. Padding on the title equals the actions' width
// so it stays optically centered without colliding with them.
return SizedBox(
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 64),
child: Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
Positioned(
right: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: (media.extras?['artist_id'] as String?) ?? '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
),
],
),
),
],
),
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
),
]);
);
}
}
+3 -1
View File
@@ -286,7 +286,9 @@ TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '',
// artist_id is stashed in extras by audio_handler — without it
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
artistId: (media.extras?['artist_id'] as String?) ?? '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
@@ -1,6 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/radio.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../library/library_providers.dart' show dioProvider;
@@ -102,6 +103,15 @@ class PlayerActions {
Future<void> setVolume(double v) async {
await _ref.read(audioHandlerProvider).setVolume(v);
}
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
/// returned track list (seed at index 0 + recommended picks).
Future<void> startRadio(String trackId) async {
final dio = await _ref.read(dioProvider.future);
final tracks = await RadioApi(dio).seedTrack(trackId);
if (tracks.isEmpty) return;
await playTracks(tracks);
}
}
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -123,41 +125,144 @@ final playlistDetailProvider =
tracks: const [],
);
Future<bool> isOnline() async {
try {
return await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
} catch (_) {
return true;
}
}
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(playlistsApiProvider.future);
debugPrint('playlistDetailProvider($id): calling get');
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
debugPrint(
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
// Collect the artist + album rows referenced by these tracks so
// the JOINs that build artistName/albumTitle in the row view
// have something to bind to. Without this, system-playlist tracks
// surface with empty artist/album columns.
final artists = <String, ArtistRefRow>{};
final albums = <String, AlbumRefRow>{};
for (final t in fresh.tracks) {
final aId = t.artistId;
final aName = t.artistName;
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
}
final lbId = t.albumId;
final lbTitle = t.albumTitle;
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
}
}
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
// Wipe + re-insert this playlist's track positions so deletions
// propagate. Without the wipe, removed tracks would linger in
// drift after the playlist mutated server-side.
b.deleteWhere(
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists,
artists.values
.map((a) => CachedArtistsCompanion.insert(
id: a.id,
name: a.name,
sortName: a.name,
))
.toList(),
);
}
if (albums.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedAlbums,
albums.values
.map((a) => CachedAlbumsCompanion.insert(
id: a.id,
artistId: a.artistId,
title: a.title,
sortTitle: a.title,
))
.toList(),
);
}
});
debugPrint(
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
return true;
} catch (e, st) {
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
return false;
}
}
// Once-per-subscription guard so an empty server response doesn't
// cause repeated re-fetches.
var fetchAttempted = false;
var revalidated = false;
await for (final playlistRows in playlistQuery.watch()) {
if (playlistRows.isEmpty) {
if (await ref.read(connectivityProvider.future)) {
try {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.get(id);
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
});
// watch() re-emits next iteration with populated row.
} catch (_) {
yield emptyDetail();
}
} else {
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
if (fetchAttempted) {
yield emptyDetail();
continue;
}
fetchAttempted = true;
if (!await isOnline()) {
yield emptyDetail();
continue;
}
final ok = await fetchAndPopulate();
if (!ok) yield emptyDetail();
continue;
}
debugPrint(
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
final playlist = playlistRows.first.toRef();
final trackRows = await tracksQuery.get();
// Same pattern as albumProvider: playlist row exists but no tracks
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
// tracks for system playlists). Trigger the same fetch, drift
// watch re-emits with populated tracks.
if (trackRows.isEmpty && !fetchAttempted) {
debugPrint(
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
if (ok) continue; // wait for watch re-emit
}
}
final tracks = trackRows.asMap().entries.map((e) {
final r = e.value;
final track = r.readTableOrNull(db.cachedTracks);
@@ -177,9 +282,33 @@ final playlistDetailProvider =
}).toList();
yield PlaylistDetail(playlist: playlist, tracks: tracks);
// SWR: first complete cache hit kicks one background refresh.
if (!revalidated && trackRows.isNotEmpty) {
revalidated = true;
if (await isOnline()) {
unawaited(fetchAndPopulate());
}
}
}
});
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
/// and album rows referenced by playlist tracks before we batch-write
/// them to drift.
class ArtistRefRow {
ArtistRefRow(this.id, this.name);
final String id;
final String name;
}
class AlbumRefRow {
AlbumRefRow(this.id, this.title, this.artistId);
final String id;
final String title;
final String artistId;
}
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);
+12 -2
View File
@@ -7,6 +7,8 @@ import '../auth/login_screen.dart';
import '../auth/server_url_screen.dart';
import '../library/album_detail_screen.dart';
import '../library/artist_detail_screen.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../discover/discover_screen.dart';
import '../library/home_screen.dart';
import '../library/library_screen.dart';
@@ -89,11 +91,19 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/artists/:id',
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
// `extra` carries an optional ArtistRef so the detail
// header renders immediately while tracks/albums load.
builder: (_, s) => ArtistDetailScreen(
id: s.pathParameters['id']!,
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
),
),
GoRoute(
path: '/albums/:id',
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
builder: (_, s) => AlbumDetailScreen(
id: s.pathParameters['id']!,
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
),
),
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
@@ -117,6 +117,23 @@ class TrackActionsSheet extends ConsumerWidget {
}
},
),
_MenuItem(
key: const Key('track_actions_start_radio'),
icon: Icons.radio,
label: 'Start radio',
onTap: () async {
Navigator.pop(context);
try {
await ref.read(playerActionsProvider).startRadio(track.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't start radio: $e")),
);
}
}
},
),
const _Divider(),
_MenuItem(
key: const Key('track_actions_go_to_album'),