Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 573aa4226d | |||
| 30fc7603d4 | |||
| 86d67f6fc6 | |||
| 8cb9a8b797 | |||
| 7339815ea9 | |||
| 2a18e91c39 | |||
| 507c532f6d | |||
| bf0ef5e0c3 | |||
| 6efb3159d5 | |||
| 8f1bc60757 | |||
| c29d25d1cb | |||
| 2ebe6229b7 | |||
| 6a08d94255 | |||
| 3e7b2582a2 |
+12
-1
@@ -17,10 +17,21 @@ import '../models/track.dart';
|
||||
import 'db.dart';
|
||||
|
||||
extension CachedArtistAdapter on CachedArtist {
|
||||
ArtistRef toRef() => ArtistRef(
|
||||
/// `coverAlbumId` lets the caller pass a representative album id so
|
||||
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
|
||||
/// URL. cached_artists doesn't store this directly — the server
|
||||
/// derives it from the most-recent album at query time — so drift
|
||||
/// readers that want the cover URL join cached_albums and pass the
|
||||
/// first album's id through. Empty string yields empty coverUrl,
|
||||
/// matching the server's behavior for endpoints that don't carry
|
||||
/// the lookup (artist detail, search, raw cached_artists row reads).
|
||||
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
|
||||
id: id,
|
||||
name: name,
|
||||
sortName: sortName,
|
||||
coverUrl: coverAlbumId.isNotEmpty
|
||||
? '/api/albums/$coverAlbumId/cover'
|
||||
: '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+24
-1
@@ -142,6 +142,21 @@ class CachedHistorySnapshot extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Single-row cache of the /api/me/system-playlists-status response.
|
||||
/// The home Playlists row reads this every render to pick between
|
||||
/// real cards and placeholder cards for For-You / Discover / Songs-
|
||||
/// like slots, so a fresh-mount cold fetch produces a visible
|
||||
/// "building / pending / failed" flicker. Storing the last result
|
||||
/// as a JSON blob means the home renders with the prior status
|
||||
/// instantly, then SWR refreshes underneath. Schema 7+.
|
||||
class CachedSystemPlaylistsStatus extends Table {
|
||||
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||
TextColumn get json => text()();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Section→position→entity-id index for the home screen, populated
|
||||
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
||||
/// pins one tile slot to an entity; the actual entity data lives in
|
||||
@@ -201,12 +216,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
CachedHistorySnapshot,
|
||||
CachedQuarantineMine,
|
||||
CachedHomeIndex,
|
||||
CachedSystemPlaylistsStatus,
|
||||
])
|
||||
class AppDb extends _$AppDb {
|
||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 6;
|
||||
int get schemaVersion => 7;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -247,6 +263,13 @@ class AppDb extends _$AppDb {
|
||||
// builds still read from it as a fallback path.
|
||||
await m.createTable(cachedHomeIndex);
|
||||
}
|
||||
if (from < 7) {
|
||||
// Schema 7: cached_system_playlists_status. Single-row
|
||||
// snapshot of /api/me/system-playlists-status used by the
|
||||
// home Playlists row. Empty on upgrade; first fetch
|
||||
// populates.
|
||||
await m.createTable(cachedSystemPlaylistsStatus);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+19
-2
@@ -18,6 +18,7 @@
|
||||
// subscriptions; drift handles this fine in practice but worth
|
||||
// measuring if a future surface scales to hundreds.
|
||||
|
||||
import 'package:drift/drift.dart' as drift show OrderingTerm;
|
||||
import 'package:drift/drift.dart' show leftOuterJoin;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -63,6 +64,12 @@ final albumTileProvider =
|
||||
|
||||
/// Watches the cached_artists row for [id]. Triggers background
|
||||
/// hydration on miss; yields the row once it lands.
|
||||
///
|
||||
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
|
||||
/// artist carries a representative album id; toRef() reconstructs
|
||||
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
|
||||
/// has an empty coverUrl and tiles fall back to the music-notes
|
||||
/// placeholder even though the server has the cover available.
|
||||
final artistTileProvider =
|
||||
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
||||
if (id.isEmpty) {
|
||||
@@ -70,7 +77,12 @@ final artistTileProvider =
|
||||
return;
|
||||
}
|
||||
final db = ref.watch(appDbProvider);
|
||||
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
|
||||
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||
.join([
|
||||
leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||
])
|
||||
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||
var enqueued = false;
|
||||
await for (final rows in query.watch()) {
|
||||
if (rows.isEmpty) {
|
||||
@@ -83,7 +95,12 @@ final artistTileProvider =
|
||||
yield null;
|
||||
continue;
|
||||
}
|
||||
yield rows.first.toRef();
|
||||
// First row has the alphabetically-first album (or null if artist
|
||||
// has no albums in drift yet — yields a coverless ArtistRef which
|
||||
// the UI falls back to the placeholder icon for).
|
||||
final artist = rows.first.readTable(db.cachedArtists);
|
||||
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -233,17 +233,28 @@ Map<String, dynamic> _trackToJson(TrackRef t) => {
|
||||
final artistProvider =
|
||||
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedArtist, ArtistRef>(
|
||||
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||
.watch(),
|
||||
// LEFT JOIN cached_albums for cover-URL reconstruction (see
|
||||
// CachedArtistAdapter.toRef). First row carries the alphabetically-
|
||||
// first album.
|
||||
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||
])
|
||||
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||
return cacheFirst<drift.TypedResult, ArtistRef>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtist(id);
|
||||
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||
},
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? const ArtistRef(id: '', name: '')
|
||||
: rows.first.toRef(),
|
||||
toResult: (rows) {
|
||||
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
|
||||
final artist = rows.first.readTable(db.cachedArtists);
|
||||
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||
},
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../api/endpoints/library_lists.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../auth/auth_provider.dart' show authControllerProvider;
|
||||
import '../cache/adapters.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
@@ -19,9 +20,6 @@ import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/history_event.dart';
|
||||
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
||||
// route descriptor) that conflicts with our wire-format Page<T>.
|
||||
import '../models/page.dart' as wire;
|
||||
import '../models/quarantine_mine.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
@@ -51,89 +49,106 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||
return MeApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
||||
/// pages append to the existing items rather than replacing them.
|
||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<ArtistRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listArtists(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
/// Drift-first all-artists list. Reads cached_artists ordered by
|
||||
/// sortName; GridView.builder takes care of lazy widget construction
|
||||
/// so loading the full set up front is fine for typical library sizes.
|
||||
/// SWR refresh on every subscription hits /api/artists with a generous
|
||||
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
|
||||
///
|
||||
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
|
||||
/// previous "fetch one page at a time as you scroll" felt like the
|
||||
/// rest of the app was buffering when this tab was the slow surface,
|
||||
/// and SyncController already populates the full set of artist rows
|
||||
/// via /api/library/sync.
|
||||
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
// LEFT JOIN cached_albums so each artist row carries a representative
|
||||
// album id for cover-URL reconstruction. Sorted by artist sortName
|
||||
// then album sortTitle: the first row per artist gets the
|
||||
// alphabetically-first album. Dedup happens in toResult.
|
||||
final query = db.select(db.cachedArtists).join([
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||
])
|
||||
..orderBy([
|
||||
drift.OrderingTerm.asc(db.cachedArtists.sortName),
|
||||
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
|
||||
]);
|
||||
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
// Cold-cache fallback: sync should have populated drift already,
|
||||
// but a fresh install + first Library visit before sync completes
|
||||
// will be empty. Fetch a generous chunk via /api/artists and
|
||||
// persist; subsequent SWR refreshes keep things current.
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listArtists(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — the next near-bottom event will retry. The current
|
||||
// partial list stays visible.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
final fresh = await api.listArtists(limit: 1000, offset: 0);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedArtists,
|
||||
fresh.items.map((a) => a.toDrift()).toList(),
|
||||
);
|
||||
});
|
||||
},
|
||||
toResult: (rows) {
|
||||
// The join multiplies rows by album count; dedup by artist id
|
||||
// keeping the first occurrence so we get one ArtistRef per
|
||||
// artist with the alphabetically-first album's id for the
|
||||
// cover-URL projection. Artists with no albums in drift yet
|
||||
// come through as a single row with null cachedAlbums and an
|
||||
// empty coverUrl — UI falls back to the placeholder icon.
|
||||
final seen = <String>{};
|
||||
final out = <ArtistRef>[];
|
||||
for (final r in rows) {
|
||||
final a = r.readTable(db.cachedArtists);
|
||||
if (!seen.add(a.id)) continue;
|
||||
final album = r.readTableOrNull(db.cachedAlbums);
|
||||
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'libraryArtists',
|
||||
);
|
||||
});
|
||||
|
||||
final _libraryArtistsProvider = AsyncNotifierProvider<
|
||||
_LibraryArtistsNotifier,
|
||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
||||
|
||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<AlbumRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
/// Drift-first all-albums list. Joins cached_albums with cached_artists
|
||||
/// for the artistName field. Same cold-cache fallback + SWR refresh
|
||||
/// pattern as libraryArtistsProvider.
|
||||
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final query = db.select(db.cachedAlbums).join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||
])
|
||||
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listAlbums(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — next scroll event will retry.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
||||
_LibraryAlbumsNotifier,
|
||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
||||
final fresh = await api.listAlbums(limit: 1000, offset: 0);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedAlbums,
|
||||
fresh.items.map((a) => a.toDrift()).toList(),
|
||||
);
|
||||
});
|
||||
},
|
||||
toResult: (rows) => rows.map((r) {
|
||||
final album = r.readTable(db.cachedAlbums);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
return album.toRef(artistName: artist?.name ?? '');
|
||||
}).toList(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'libraryAlbums',
|
||||
);
|
||||
});
|
||||
|
||||
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
||||
// last /api/me/history page as JSON in a single-row drift table, yield
|
||||
@@ -337,6 +352,19 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// Pre-warm every tab's provider on Library mount so swiping
|
||||
// between tabs feels instant rather than each tab paying its
|
||||
// own cold-cache cost on first visit. ref.listen subscribes
|
||||
// without rebuilding LibraryScreen on emit; subscriptions stay
|
||||
// alive for the lifetime of this widget. cacheFirst handles
|
||||
// dedupe of concurrent fetchAndPopulate triggers.
|
||||
ref.listen(_libraryArtistsProvider, (_, __) {});
|
||||
ref.listen(_libraryAlbumsProvider, (_, __) {});
|
||||
ref.listen(_historyProvider, (_, __) {});
|
||||
ref.listen(_likedTrackIdsProvider, (_, __) {});
|
||||
ref.listen(_likedAlbumIdsProvider, (_, __) {});
|
||||
ref.listen(_likedArtistIdsProvider, (_, __) {});
|
||||
ref.listen(myQuarantineProvider, (_, __) {});
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
@@ -383,51 +411,56 @@ class _ArtistsTab extends ConsumerWidget {
|
||||
return ref.watch(_libraryArtistsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) {
|
||||
data: (artists) {
|
||||
// Warm details for the first screenful so taps are instant.
|
||||
ref
|
||||
.read(metadataPrefetcherProvider)
|
||||
.warmArtists(page.items.map((a) => a.id));
|
||||
return page.items.isEmpty
|
||||
.warmArtists(artists.map((a) => a.id));
|
||||
return artists.isEmpty
|
||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryArtistsProvider);
|
||||
await ref.read(_libraryArtistsProvider.future);
|
||||
},
|
||||
// Listen for scroll-near-bottom and ask the notifier
|
||||
// to fetch the next page. 800px lookahead so the next
|
||||
// page lands before the user reaches the visible end.
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryArtistsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
||||
// the AlbumsTab pattern so the circular avatar stays
|
||||
// a true circle on narrow grid cells.
|
||||
//
|
||||
// Drift-first: GridView holds the full sorted list;
|
||||
// .builder lazily realizes only visible cells, so
|
||||
// even on large libraries the up-front cost is just
|
||||
// a sort over cached_artists, not N network round
|
||||
// trips.
|
||||
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;
|
||||
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
|
||||
// + slack matched to AlbumsTab's overflow guard.
|
||||
final cellH = (cellW - 16) + 8 + 18 + 8;
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(sidePad),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemCount: artists.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = page.items[i];
|
||||
final artist = artists[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
width: cellW,
|
||||
onTap: () => ctx.push('/artists/${artist.id}',
|
||||
extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -443,17 +476,16 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
return ref.watch(_libraryAlbumsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) {
|
||||
return page.items.isEmpty
|
||||
data: (albums) {
|
||||
return albums.isEmpty
|
||||
? 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.invalidate(_libraryAlbumsProvider);
|
||||
await ref.read(_libraryAlbumsProvider.future);
|
||||
},
|
||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||
// Same responsive 3-up grid as the artist detail
|
||||
// album list — LayoutBuilder + AlbumCard sized to the
|
||||
// cell, mainAxisExtent matched to actual card height.
|
||||
// Drift-first; full sorted list, lazy realization via
|
||||
// GridView.builder.
|
||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
@@ -468,37 +500,26 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
// would otherwise overflow the cell by a pixel
|
||||
// (logged as a noisy RenderFlex warning).
|
||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryAlbumsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: 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),
|
||||
);
|
||||
},
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(sidePad),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = albums[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
onTap: () => ctx.push('/albums/${album.id}',
|
||||
extra: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12,15 +12,27 @@ import '../library_providers.dart';
|
||||
import 'play_circle_button.dart';
|
||||
|
||||
class ArtistCard extends ConsumerWidget {
|
||||
const ArtistCard({required this.artist, required this.onTap, super.key});
|
||||
const ArtistCard({
|
||||
required this.artist,
|
||||
required this.onTap,
|
||||
this.width = 140,
|
||||
super.key,
|
||||
});
|
||||
final ArtistRef artist;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Outer width of the card. Avatar is a circle of width-16 (8dp
|
||||
/// horizontal padding either side). Default suits horizontal lists;
|
||||
/// grids should pass the cell width so the avatar shrinks
|
||||
/// proportionally and stays a true circle.
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final coverSize = width - 16;
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
width: width,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
@@ -29,15 +41,20 @@ class ArtistCard extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Stack: circular avatar + overlaid play button at bottom-right.
|
||||
// The avatar is a circle (ClipOval) — bottom-right of its
|
||||
// bounding box still places the button inside the visible area
|
||||
// because the button is small relative to the radius.
|
||||
// Avatar size derives from the [width] parameter so the
|
||||
// Library Artists 3-column grid can pass its cell width
|
||||
// (~109dp on typical phones) and the avatar stays a
|
||||
// perfect circle. Previous hardcoded 124×124 got squeezed
|
||||
// horizontally in the grid (cell narrower than 124+16
|
||||
// padding) but kept its 124dp height — ClipOval produced
|
||||
// a visible vertical ellipse. Mirrors AlbumCard's width-
|
||||
// parameter convention.
|
||||
Stack(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||
|
||||
@@ -68,6 +68,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
/// new queue, leaving the player "locked" to a corrupted state.
|
||||
int _queueGeneration = 0;
|
||||
|
||||
/// Tracks the most recent setQueueFromTracks() input so
|
||||
/// skipToQueueItem can reconstruct the source list. just_audio
|
||||
/// requires every source to be built before it can be a skip
|
||||
/// target, but setQueueFromTracks only builds the initial source
|
||||
/// and fills the rest in the background — so a skip to an index
|
||||
/// past the fill front needs to rebuild from the stored tracks.
|
||||
List<TrackRef> _lastTracks = const [];
|
||||
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> get volumeStream => _player.volumeStream;
|
||||
@@ -105,6 +113,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
// Reset suppress flag in case a prior backward-fill bailed on
|
||||
// gen check before reaching its `finally`.
|
||||
_suppressIndexUpdates = false;
|
||||
_lastTracks = tracks;
|
||||
|
||||
// Pause the old source immediately so the previous track stops
|
||||
// audibly the moment the user taps, instead of bleeding through
|
||||
// until the new source finishes building. setAudioSources below
|
||||
// will swap the source list cleanly; pause is the simplest way
|
||||
// to silence the player during the (possibly multi-100ms) build.
|
||||
if (_player.playing) {
|
||||
await _player.pause();
|
||||
}
|
||||
|
||||
// Populate the visible queue + current mediaItem immediately so
|
||||
// the player UI reflects the user's tap before any source has
|
||||
@@ -130,6 +148,23 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
||||
}
|
||||
|
||||
/// Switches playback to the [index]th queue item. The full
|
||||
/// just_audio source list isn't necessarily built yet
|
||||
/// (_fillRemainingSources runs in the background), so we
|
||||
/// reconstruct from the stored TrackRef list rather than calling
|
||||
/// _player.seek(index: ...) on a possibly-missing source. Calling
|
||||
/// setQueueFromTracks again is the safe path: it bumps the
|
||||
/// generation, cancels any in-flight fill, rebuilds source[0] as
|
||||
/// the target, and re-fills around. play() restarts playback so
|
||||
/// queue taps feel like "jump to this song" rather than "set the
|
||||
/// pointer and wait for me to press play."
|
||||
@override
|
||||
Future<void> skipToQueueItem(int index) async {
|
||||
if (index < 0 || index >= _lastTracks.length) return;
|
||||
await setQueueFromTracks(_lastTracks, initialIndex: index);
|
||||
await play();
|
||||
}
|
||||
|
||||
/// Background fill of the rest of the just_audio source list after
|
||||
/// the initial source is playing. Forward direction first (most
|
||||
/// common skipNext target). Backward inserts shift the player's
|
||||
|
||||
@@ -45,6 +45,73 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
// dismiss. Reset on each drag start.
|
||||
double _dragOffset = 0;
|
||||
|
||||
/// The MediaItem currently displayed on the screen. Held separately
|
||||
/// from the live mediaItemProvider so we can preload the new track's
|
||||
/// cover bytes + dominant color BEFORE flipping the visible state.
|
||||
/// Without this gate the full-player UI swapped to the new track's
|
||||
/// title/cover slot immediately while the image was still decoding,
|
||||
/// producing a visible "pop in" once the bytes arrived.
|
||||
MediaItem? _displayedMedia;
|
||||
|
||||
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
|
||||
/// backdrop AnimatedContainer when it changes.
|
||||
Color? _displayedDominant;
|
||||
|
||||
/// The id of the most-recent track we kicked a preload for. Used to
|
||||
/// drop stale preload completions when the user skips rapidly past
|
||||
/// a track whose cover hadn't finished decoding yet.
|
||||
String? _pendingPreloadId;
|
||||
|
||||
/// Preload the new track's cover bytes + dominant color, then
|
||||
/// atomically flip _displayedMedia / _displayedDominant. Until both
|
||||
/// resolve, the screen continues to render the previous track —
|
||||
/// the new title/cover/gradient appear together in one frame.
|
||||
///
|
||||
/// Concurrency: if a second track change arrives while the first
|
||||
/// preload is still in flight, the older preload's
|
||||
/// _pendingPreloadId check fails and its completion is dropped.
|
||||
Future<void> _scheduleSwap(MediaItem newMedia) async {
|
||||
_pendingPreloadId = newMedia.id;
|
||||
|
||||
// 1. Precache the cover image bytes so when _AlbumArt mounts with
|
||||
// the new media, FileImage paints synchronously. Non-file
|
||||
// artUris fall through to ServerImage which handles its own
|
||||
// network load + 120ms fade — they won't snap.
|
||||
final artUri = newMedia.artUri;
|
||||
if (artUri != null && artUri.isScheme('file') && context.mounted) {
|
||||
try {
|
||||
await precacheImage(FileImage(File.fromUri(artUri)), context);
|
||||
} catch (_) {
|
||||
// Decode failed (missing file, corrupt bytes) — proceed
|
||||
// anyway; _AlbumArt's errorBuilder shows the slate fallback.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wait for the dominant-color extraction so the gradient is
|
||||
// populated when we swap. PaletteGenerator runs against the
|
||||
// same FileImage, typically resolving within ~50ms once the
|
||||
// decode completes.
|
||||
Color? newDominant;
|
||||
final albumId = newMedia.extras?['album_id'] as String?;
|
||||
if (albumId != null && albumId.isNotEmpty) {
|
||||
try {
|
||||
newDominant = await ref.read(albumColorProvider(albumId).future);
|
||||
} catch (_) {
|
||||
// Color extraction failed — keep the previous dominant so the
|
||||
// backdrop doesn't drop to obsidian on the swap.
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Atomic flip. Bail if a newer swap superseded us, or the
|
||||
// screen was disposed mid-preload.
|
||||
if (!mounted) return;
|
||||
if (_pendingPreloadId != newMedia.id) return;
|
||||
setState(() {
|
||||
_displayedMedia = newMedia;
|
||||
if (newDominant != null) _displayedDominant = newDominant;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_dragOffset = 0;
|
||||
}
|
||||
@@ -67,10 +134,61 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
|
||||
if (media == null) {
|
||||
// Listen for mediaItem changes and schedule a preload-then-swap.
|
||||
// Build renders _displayedMedia / _displayedDominant; the live
|
||||
// mediaItemProvider only drives the swap pipeline.
|
||||
//
|
||||
// Decision process:
|
||||
// - On the first non-null mediaItem after mount, seed
|
||||
// _displayedMedia immediately so the screen has something to
|
||||
// paint.
|
||||
// - On a track id change, kick off a preload. The new track's
|
||||
// cover bytes + dominant color must both resolve before we
|
||||
// flip the displayed state. The user sees the previous track
|
||||
// in full until the new one is fully ready, then a clean
|
||||
// atomic swap (no fade, no placeholder flash).
|
||||
// - On the same track id but the artUri-bearing rebroadcast
|
||||
// (audio_handler sends MediaItem twice on track change),
|
||||
// refresh through the same preload pipeline so the displayed
|
||||
// cover reflects the freshly-written AlbumCoverCache file.
|
||||
ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
|
||||
final newMedia = next.asData?.value;
|
||||
if (newMedia == null) {
|
||||
if (_displayedMedia != null) {
|
||||
setState(() {
|
||||
_displayedMedia = null;
|
||||
_displayedDominant = null;
|
||||
_pendingPreloadId = null;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_displayedMedia == null) {
|
||||
// First mount with a track playing — seed immediately, then
|
||||
// kick the preload pipeline to update the dominant color and
|
||||
// ensure the cover is decoded.
|
||||
setState(() => _displayedMedia = newMedia);
|
||||
_scheduleSwap(newMedia);
|
||||
return;
|
||||
}
|
||||
final isNewTrack = newMedia.id != _displayedMedia!.id;
|
||||
final coverGotPopulated =
|
||||
newMedia.id == _displayedMedia!.id &&
|
||||
newMedia.artUri != null &&
|
||||
_displayedMedia!.artUri == null;
|
||||
if (isNewTrack || coverGotPopulated) {
|
||||
_scheduleSwap(newMedia);
|
||||
}
|
||||
});
|
||||
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
final displayedMedia = _displayedMedia;
|
||||
|
||||
if (displayedMedia == null) {
|
||||
// Either nothing playing, or the very first mount before a
|
||||
// mediaItem has been emitted. The ref.listen above will seed
|
||||
// _displayedMedia as soon as a non-null MediaItem arrives.
|
||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||
}
|
||||
|
||||
@@ -79,23 +197,20 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
// only fires on state transitions and would leave the bar frozen
|
||||
// between them.
|
||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
final dur = displayedMedia.duration ?? Duration.zero;
|
||||
final isPlaying = playback?.playing == true;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
||||
final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';
|
||||
|
||||
// Dominant-color gradient backdrop seeded from the current track's
|
||||
// album cover. While the color resolves (or when no cover exists),
|
||||
// the gradient collapses to a flat fs.obsidian — same look as
|
||||
// before the polish landed. Tweens to the new color on track change
|
||||
// via AnimatedContainer.
|
||||
final dominantAsync = ref.watch(albumColorProvider(albumId));
|
||||
final dominant = dominantAsync.asData?.value ?? fs.obsidian;
|
||||
// 0.55 alpha keeps the color present without overwhelming contrast
|
||||
// on the title / artist text below. The lower half of the screen
|
||||
// stays obsidian for control legibility.
|
||||
// Backdrop color: render the dominant we've already committed to
|
||||
// _displayedDominant. AnimatedContainer tweens between successive
|
||||
// values, so the only color changes the user sees are the
|
||||
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
|
||||
// the gradient present without overwhelming the title/artist
|
||||
// text below.
|
||||
final dominant = _displayedDominant ?? fs.obsidian;
|
||||
final gradientTop = dominant.withValues(alpha: 0.55);
|
||||
|
||||
return Scaffold(
|
||||
@@ -128,46 +243,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
AnimatedSwitcher(
|
||||
duration: _trackChangeDuration,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey('cover-${media.id}'),
|
||||
child: _AlbumArt(
|
||||
media: media, albumId: albumId, fs: fs),
|
||||
),
|
||||
),
|
||||
// No AnimatedSwitcher: _displayedMedia only
|
||||
// advances after the preload pipeline has the
|
||||
// new cover + color ready, so a track change
|
||||
// is an atomic swap. Fading would just smear a
|
||||
// transition the user explicitly asked us not
|
||||
// to add.
|
||||
_AlbumArt(
|
||||
media: displayedMedia, albumId: albumId, fs: fs),
|
||||
const SizedBox(height: 28),
|
||||
AnimatedSwitcher(
|
||||
duration: _trackChangeDuration,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey('title-${media.id}'),
|
||||
child: _TitleRow(media: media, fs: fs),
|
||||
),
|
||||
),
|
||||
_TitleRow(media: displayedMedia, fs: fs),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedSwitcher(
|
||||
duration: _trackChangeDuration,
|
||||
child: Column(
|
||||
key: ValueKey('artist-album-${media.id}'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayedMedia.artist ?? '',
|
||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((displayedMedia.album ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
media.artist ?? '',
|
||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||
displayedMedia.album!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((media.album ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
media.album!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SecondaryControls(
|
||||
@@ -175,7 +280,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
actions: actions,
|
||||
shuffleOn: shuffleOn,
|
||||
repeatMode: repeatMode,
|
||||
media: media,
|
||||
media: displayedMedia,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
|
||||
@@ -28,16 +28,37 @@ import 'player_provider.dart';
|
||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||
/// the drag-down dismissal on the full screen.
|
||||
class PlayerBar extends ConsumerWidget {
|
||||
class PlayerBar extends ConsumerStatefulWidget {
|
||||
const PlayerBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<PlayerBar> createState() => _PlayerBarState();
|
||||
}
|
||||
|
||||
class _PlayerBarState extends ConsumerState<PlayerBar> {
|
||||
/// Last non-null artUri we've seen from the mediaItem stream. Held
|
||||
/// across the audio_handler's two-broadcast track-change pattern
|
||||
/// (bare MediaItem first, then with artUri once AlbumCoverCache
|
||||
/// resolves) so the mini bar keeps showing the previous track's
|
||||
/// cover until the new one is known. Without this hold the bar
|
||||
/// flickered to the slate placeholder for ~100ms on every track
|
||||
/// change.
|
||||
Uri? _lastArtUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
if (media == null) return const SizedBox.shrink();
|
||||
|
||||
// Capture the latest non-null artUri so _TrackInfo's cover stays
|
||||
// continuous across track changes.
|
||||
if (media.artUri != null) _lastArtUri = media.artUri;
|
||||
final displayMedia = media.artUri == null && _lastArtUri != null
|
||||
? media.copyWith(artUri: _lastArtUri)
|
||||
: media;
|
||||
|
||||
// positionProvider is just_audio's positionStream (~200ms cadence)
|
||||
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
||||
// only updates on event transitions and would leave it frozen.
|
||||
@@ -65,7 +86,7 @@ class PlayerBar extends ConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: _TrackInfo(media: media)),
|
||||
Expanded(child: _TrackInfo(media: displayMedia)),
|
||||
const SizedBox(width: 8),
|
||||
_PlayControls(playback: playback, ref: ref),
|
||||
],
|
||||
@@ -95,17 +116,28 @@ class _TrackInfo extends StatelessWidget {
|
||||
// artwork from this 48dp footprint to the full-screen size rather
|
||||
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
|
||||
// so the transition works regardless of what's playing.
|
||||
Widget cover;
|
||||
if (media.artUri != null) {
|
||||
//
|
||||
// Why no transition / placeholder handling: the audio_handler
|
||||
// broadcasts MediaItem twice on track change — once with artUri
|
||||
// null (the new track's bare metadata), then with artUri set once
|
||||
// AlbumCoverCache resolves. The widget tree above hands us the
|
||||
// most-recent non-null artUri (`displayArtUri`), so the previous
|
||||
// track's cover stays visible across that null gap and the new
|
||||
// cover snaps in the moment its artUri arrives. Rapid change is
|
||||
// fine here per operator preference; AnimatedSwitcher previously
|
||||
// smeared the swap and made the slate placeholder visible.
|
||||
final displayArtUri = media.artUri;
|
||||
final Widget cover;
|
||||
if (displayArtUri != null) {
|
||||
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
|
||||
// hits the same disk cache the rest of the UI uses (ServerImage,
|
||||
// discover thumbnails). FileImage stays for the file:// branch
|
||||
// populated by AlbumCoverCache — bytes are already on disk and a
|
||||
// second cache layer would only burn duplicate space.
|
||||
cover = Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: CachedNetworkImageProvider(media.artUri.toString()),
|
||||
image: displayArtUri.isScheme('file')
|
||||
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
|
||||
: CachedNetworkImageProvider(displayArtUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
// Without a fit, Image paints the source at its intrinsic
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
@@ -362,8 +363,41 @@ class _TrackRefRow {
|
||||
final int durationSec;
|
||||
}
|
||||
|
||||
/// Drift-first per #357 pattern. Reads from cached_system_playlists_
|
||||
/// status (single-row JSON blob populated by /api/me/system-playlists-
|
||||
/// status). Home Playlists row reads this every render to choose
|
||||
/// between real cards and "building / pending / failed" placeholders
|
||||
/// — drift-first means the row paints with the prior status instantly
|
||||
/// rather than flickering through SystemPlaylistsStatus.empty() while
|
||||
/// the REST round-trip resolves. SWR refresh on every visit keeps it
|
||||
/// current.
|
||||
final systemPlaylistsStatusProvider =
|
||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
return MeApi(dio).systemPlaylistsStatus();
|
||||
StreamProvider<SystemPlaylistsStatus>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
|
||||
driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final dio = await ref.read(dioProvider.future);
|
||||
final fresh = await MeApi(dio).systemPlaylistsStatus();
|
||||
await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate(
|
||||
CachedSystemPlaylistsStatusCompanion.insert(
|
||||
json: jsonEncode({
|
||||
'in_flight': fresh.inFlight,
|
||||
'last_run_at': fresh.lastRunAt,
|
||||
'last_error': fresh.lastError,
|
||||
}),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
},
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? SystemPlaylistsStatus.empty()
|
||||
: SystemPlaylistsStatus.fromJson(
|
||||
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'systemPlaylistsStatus',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
|
||||
show HttpExceptionWithStatus;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
@@ -71,6 +73,19 @@ class ServerImage extends ConsumerWidget {
|
||||
// Keep failures local — a single 401/timeout shouldn't dump
|
||||
// a stack trace or replace the parent Container's background.
|
||||
errorWidget: (_, __, ___) => empty,
|
||||
// Filter expected 404s out of dev console noise: playlist
|
||||
// collages aren't built until the playlist has tracks and
|
||||
// the build job runs (system playlists like For-You /
|
||||
// Discover hit this on first-render). The errorWidget
|
||||
// already renders the fallback for the user; this just
|
||||
// keeps the dev console clean. Non-404 errors still
|
||||
// surface so auth/connectivity issues remain visible.
|
||||
errorListener: (err) {
|
||||
if (err is HttpExceptionWithStatus && err.statusCode == 404) {
|
||||
return;
|
||||
}
|
||||
debugPrint('ServerImage: $err');
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: minstrel
|
||||
description: Minstrel mobile client
|
||||
publish_to: 'none'
|
||||
version: 2026.5.13+1
|
||||
version: 2026.5.13+3
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.0 <4.0.0'
|
||||
|
||||
@@ -28,7 +28,7 @@ void main() {
|
||||
(ref) => Stream.value(PlaylistsList.empty()),
|
||||
),
|
||||
systemPlaylistsStatusProvider.overrideWith(
|
||||
(ref) async => SystemPlaylistsStatus.empty(),
|
||||
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
(ref) => Stream.value(PlaylistsList.empty()),
|
||||
),
|
||||
systemPlaylistsStatusProvider.overrideWith(
|
||||
(ref) async => SystemPlaylistsStatus.empty(),
|
||||
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||
@@ -91,7 +91,7 @@ void main() {
|
||||
const PlaylistsList(owned: [forYou], public: [])),
|
||||
),
|
||||
systemPlaylistsStatusProvider.overrideWith(
|
||||
(ref) async => SystemPlaylistsStatus.empty(),
|
||||
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||
|
||||
@@ -254,18 +254,41 @@ func redistributeSlots(buckets []bucketRequest) []int {
|
||||
|
||||
// interleaveBuckets round-robins items from the buckets in order. The
|
||||
// first item of each bucket appears before the second of any bucket.
|
||||
// interleaveBuckets walks each bucket round-robin, emitting one track
|
||||
// per bucket per pass. Tracks already seen in an earlier bucket (or an
|
||||
// earlier pass) are skipped — single-user servers hit this often
|
||||
// because the empty crossUser bucket redistributes slots to dormant +
|
||||
// random, and a dormant-artist track is also a valid random-unheard
|
||||
// pick. Without dedup the same track lands at two interleaved
|
||||
// positions, which on a 2-bucket round-robin (dormant+random)
|
||||
// produces ADJACENT duplicates in the playlist — exactly the
|
||||
// "every song has a duplicate right after it" report from v2026.05.13.0.
|
||||
//
|
||||
// Dedup priority is bucket order (caller-supplied), so a track in both
|
||||
// dormant and random is taken from dormant.
|
||||
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
|
||||
total := 0
|
||||
for _, b := range buckets {
|
||||
total += len(b)
|
||||
}
|
||||
out := make([]discoverTrack, 0, total)
|
||||
seen := make(map[pgtype.UUID]struct{}, total)
|
||||
indices := make([]int, len(buckets))
|
||||
for {
|
||||
anyAppended := false
|
||||
for bi, b := range buckets {
|
||||
// Advance past tracks already taken from an earlier bucket
|
||||
// or an earlier pass.
|
||||
for indices[bi] < len(b) {
|
||||
if _, dup := seen[b[indices[bi]].ID]; !dup {
|
||||
break
|
||||
}
|
||||
indices[bi]++
|
||||
}
|
||||
if indices[bi] < len(b) {
|
||||
out = append(out, b[indices[bi]])
|
||||
t := b[indices[bi]]
|
||||
out = append(out, t)
|
||||
seen[t.ID] = struct{}{}
|
||||
indices[bi]++
|
||||
anyAppended = true
|
||||
}
|
||||
|
||||
@@ -137,3 +137,27 @@ func TestInterleaveBuckets_RoundRobin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
|
||||
// Single-user server scenario: crossUser bucket empty, dormant +
|
||||
// random share tracks. Without dedup, shared tracks land at
|
||||
// adjacent interleaved positions — the duplication users reported
|
||||
// on the Discover playlist in v2026.05.13.0.
|
||||
dormant := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(3)}}
|
||||
crossUser := []discoverTrack{} // empty bucket, single-user server
|
||||
random := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(4)}}
|
||||
got := interleaveBuckets(dormant, crossUser, random)
|
||||
|
||||
// Tracks 1 and 2 appear in both dormant and random — must be
|
||||
// emitted once each (from dormant, the earlier bucket). Track 3
|
||||
// is dormant-only, track 4 is random-only.
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("len = %d, want 4 (3 unique from dormant + 1 unique from random)", len(got))
|
||||
}
|
||||
wantOrder := []byte{1, 2, 3, 4}
|
||||
for i, w := range wantOrder {
|
||||
if got[i].ID.Bytes[15] != w {
|
||||
t.Errorf("got[%d].ID = %d, want %d", i, got[i].ID.Bytes[15], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user