Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d009b34e2 | |||
| 5fc04f14b7 | |||
| a09b636e1a | |||
| 356f8f5d6c | |||
| 96aa2407d9 | |||
| e856172d60 | |||
| ab62a3d118 | |||
| a3c0aed63e | |||
| 1ddde12959 | |||
| f1b4652c77 | |||
| 261b44522d | |||
| 6f20a75f9b | |||
| 2d5f0691c2 | |||
| e8a515dac4 | |||
| acc7149537 | |||
| 4ede37d9ad | |||
| 4bd069430b | |||
| 22152b1ba3 | |||
| 572325e23f | |||
| 8d466ebdd5 | |||
| af5744f8ab | |||
| c7549bbe48 | |||
| 9cac664679 | |||
| c08f4ace80 | |||
| c1df2af992 | |||
| a7f35a5d6d |
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'cache/metadata_prefetcher.dart';
|
||||||
import 'cache/prefetcher.dart';
|
import 'cache/prefetcher.dart';
|
||||||
import 'cache/sync_controller.dart';
|
import 'cache/sync_controller.dart';
|
||||||
import 'shared/routing.dart';
|
import 'shared/routing.dart';
|
||||||
@@ -27,6 +28,11 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
|||||||
// ignore: unawaited_futures
|
// ignore: unawaited_futures
|
||||||
ref.read(syncControllerProvider.notifier).sync();
|
ref.read(syncControllerProvider.notifier).sync();
|
||||||
ref.read(prefetcherProvider);
|
ref.read(prefetcherProvider);
|
||||||
|
// Metadata prefetcher: when /api/home returns, fire background
|
||||||
|
// albumProvider/artistProvider reads for the top-N items in
|
||||||
|
// each section so subsequent taps are drift hits, not network
|
||||||
|
// round trips.
|
||||||
|
ref.read(metadataPrefetcherProvider);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -88,6 +88,12 @@ extension TrackRefDriftWrite on TrackRef {
|
|||||||
extension CachedPlaylistAdapter on CachedPlaylist {
|
extension CachedPlaylistAdapter on CachedPlaylist {
|
||||||
/// `ownerUsername` is server-derived; cache stores the userId only.
|
/// `ownerUsername` is server-derived; cache stores the userId only.
|
||||||
/// Pass empty unless a join supplies it.
|
/// Pass empty unless a join supplies it.
|
||||||
|
/// `coverUrl` is reconstructed deterministically from the playlist
|
||||||
|
/// id — the server serves the cached collage at this path
|
||||||
|
/// (handleGetPlaylistCover). Mirrors the album-cover trick. When
|
||||||
|
/// the server hasn't built a collage yet (system playlists with no
|
||||||
|
/// tracks at build time), the endpoint 404s and PlaylistCard's
|
||||||
|
/// ServerImage falls back to its slate placeholder.
|
||||||
Playlist toRef({String ownerUsername = ''}) => Playlist(
|
Playlist toRef({String ownerUsername = ''}) => Playlist(
|
||||||
id: id,
|
id: id,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -96,7 +102,7 @@ extension CachedPlaylistAdapter on CachedPlaylist {
|
|||||||
isPublic: isPublic,
|
isPublic: isPublic,
|
||||||
systemVariant: systemVariant,
|
systemVariant: systemVariant,
|
||||||
trackCount: trackCount,
|
trackCount: trackCount,
|
||||||
coverUrl: '', // server-derived; cache doesn't persist
|
coverUrl: '/api/playlists/$id/cover',
|
||||||
ownerUsername: ownerUsername,
|
ownerUsername: ownerUsername,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
|
|||||||
+39
-6
@@ -83,6 +83,27 @@ class AudioCacheManager {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers a file already on disk in the cache index. Intended for
|
||||||
|
/// the streaming path (LockCachingAudioSource) which writes the file
|
||||||
|
/// itself; we need an index row so eviction can find and delete it
|
||||||
|
/// when usage exceeds the cap. `source` defaults to incidental so
|
||||||
|
/// stream-cached tracks are first to be evicted.
|
||||||
|
Future<void> registerStreamCache(
|
||||||
|
String trackId,
|
||||||
|
String path,
|
||||||
|
int sizeBytes, {
|
||||||
|
CacheSource source = CacheSource.incidental,
|
||||||
|
}) async {
|
||||||
|
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
|
||||||
|
AudioCacheIndexCompanion.insert(
|
||||||
|
trackId: trackId,
|
||||||
|
path: path,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
source: source,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes a track from the index AND deletes the file.
|
/// Removes a track from the index AND deletes the file.
|
||||||
Future<void> unpin(String trackId) async {
|
Future<void> unpin(String trackId) async {
|
||||||
final row = await (_db.select(_db.audioCacheIndex)
|
final row = await (_db.select(_db.audioCacheIndex)
|
||||||
@@ -96,13 +117,25 @@ class AudioCacheManager {
|
|||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total bytes used by the cache.
|
/// Total bytes used by the cache. Walks the cache directory directly
|
||||||
|
/// instead of summing the index, because the streaming-as-you-play
|
||||||
|
/// path (audio_handler's LockCachingAudioSource) writes files
|
||||||
|
/// without registering an index row. The index sum would always
|
||||||
|
/// understate (often to zero) for users who only stream.
|
||||||
Future<int> usageBytes() async {
|
Future<int> usageBytes() async {
|
||||||
final result = await _db.customSelect(
|
final dir = Directory(await _tracksDir());
|
||||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index',
|
if (!await dir.exists()) return 0;
|
||||||
readsFrom: {_db.audioCacheIndex},
|
var total = 0;
|
||||||
).getSingle();
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
return result.read<int>('total');
|
if (entity is File) {
|
||||||
|
try {
|
||||||
|
total += await entity.length();
|
||||||
|
} catch (_) {
|
||||||
|
// Race against concurrent writes / deletes — just skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
||||||
|
|||||||
+7
-9
@@ -19,6 +19,10 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
|
||||||
|
// `tag` parameter is preserved for future ad-hoc instrumentation.
|
||||||
|
// Normal operation only logs failure paths so the per-screen log
|
||||||
|
// noise stays low.
|
||||||
|
|
||||||
/// Wraps the watch + cold-cache fallback pattern. Generic over:
|
/// Wraps the watch + cold-cache fallback pattern. Generic over:
|
||||||
/// D — the drift row type (or TypedResult for joins)
|
/// D — the drift row type (or TypedResult for joins)
|
||||||
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
|
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
|
||||||
@@ -43,13 +47,9 @@ Stream<T> cacheFirst<D, T>({
|
|||||||
// refresh for this stream subscription, so we don't fire one on every
|
// refresh for this stream subscription, so we don't fire one on every
|
||||||
// drift re-emission (otherwise the populate cycles forever).
|
// drift re-emission (otherwise the populate cycles forever).
|
||||||
var revalidated = false;
|
var revalidated = false;
|
||||||
void log(String msg) {
|
|
||||||
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
|
|
||||||
}
|
|
||||||
|
|
||||||
await for (final rows in driftStream) {
|
await for (final rows in driftStream) {
|
||||||
if (rows.isNotEmpty) {
|
if (rows.isNotEmpty) {
|
||||||
log('drift hit (${rows.length} rows)');
|
|
||||||
yield toResult(rows);
|
yield toResult(rows);
|
||||||
// Stale-while-revalidate: yield cache immediately, then kick off
|
// Stale-while-revalidate: yield cache immediately, then kick off
|
||||||
// a REST refresh in the background. Drift watch() picks up the
|
// a REST refresh in the background. Drift watch() picks up the
|
||||||
@@ -62,20 +62,18 @@ Stream<T> cacheFirst<D, T>({
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
log('drift miss; checking connectivity');
|
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
log('online; calling fetchAndPopulate');
|
|
||||||
try {
|
try {
|
||||||
await fetchAndPopulate();
|
await fetchAndPopulate();
|
||||||
log('fetchAndPopulate done; awaiting drift re-emit');
|
|
||||||
// The drift watch() stream re-emits with the populated rows on
|
// The drift watch() stream re-emits with the populated rows on
|
||||||
// the next loop iteration. Don't yield here.
|
// the next loop iteration. Don't yield here.
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log('fetchAndPopulate failed: $e\n$st');
|
if (tag != null) {
|
||||||
|
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
||||||
|
}
|
||||||
yield toResult(rows); // empty result; caller surfaces error
|
yield toResult(rows); // empty result; caller surfaces error
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log('offline; yielding empty');
|
|
||||||
yield toResult(rows); // empty result; offline
|
yield toResult(rows); // empty result; offline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../library/library_providers.dart';
|
||||||
|
import '../models/home_data.dart';
|
||||||
|
|
||||||
|
/// Pre-warms the drift cache for likely-tap targets. Conservative on
|
||||||
|
/// purpose: only warms artistProvider rows (single row, single round
|
||||||
|
/// trip per id) and only ever fires once per id per session. Album
|
||||||
|
/// detail is NOT prewarmed — the albumProvider auto-fetches its track
|
||||||
|
/// list when missing, and pre-warming N albums fans out N parallel
|
||||||
|
/// "fetch tracks" round trips that compete with the user's actual
|
||||||
|
/// playback request for bandwidth.
|
||||||
|
class MetadataPrefetcher {
|
||||||
|
MetadataPrefetcher(this._ref) {
|
||||||
|
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
|
||||||
|
next.whenData(_warmHome);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Per-session dedupe so re-rendering a screen (every UI rebuild
|
||||||
|
/// fires the data: callback) doesn't trigger N more fetches for
|
||||||
|
/// ids we've already warmed.
|
||||||
|
final Set<String> _warmedArtists = {};
|
||||||
|
|
||||||
|
/// Cap per section. Covers what fits on screen without scrolling.
|
||||||
|
static const _topN = 8;
|
||||||
|
|
||||||
|
/// Pre-warm artists. Albums are intentionally not pre-warmed —
|
||||||
|
/// see class comment.
|
||||||
|
void warmArtists(Iterable<String> ids) {
|
||||||
|
var n = 0;
|
||||||
|
for (final id in ids) {
|
||||||
|
if (id.isEmpty) continue;
|
||||||
|
if (!_warmedArtists.add(id)) continue; // already warmed
|
||||||
|
if (n++ >= _topN) break;
|
||||||
|
_swallow(_ref.read(artistProvider(id).future));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _warmHome(HomeData h) {
|
||||||
|
final artistIds = <String>{};
|
||||||
|
for (final a in h.recentlyAddedAlbums.take(_topN)) {
|
||||||
|
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||||
|
}
|
||||||
|
for (final a in h.rediscoverAlbums.take(_topN)) {
|
||||||
|
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||||
|
}
|
||||||
|
for (final ar in h.rediscoverArtists.take(_topN)) {
|
||||||
|
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||||
|
}
|
||||||
|
for (final ar in h.lastPlayedArtists.take(_topN)) {
|
||||||
|
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||||
|
}
|
||||||
|
for (final t in h.mostPlayedTracks.take(_topN)) {
|
||||||
|
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
|
||||||
|
}
|
||||||
|
warmArtists(artistIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discards the return value and any error from a fire-and-forget
|
||||||
|
/// provider read. We don't care about the value here — we only want
|
||||||
|
/// the side effect of writing drift.
|
||||||
|
void _swallow(Future<Object?> f) {
|
||||||
|
f.then<void>((_) {}).onError((_, __) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start to activate the prefetcher (e.g. wire it
|
||||||
|
/// from a top-level Consumer or main.dart container override).
|
||||||
|
final metadataPrefetcherProvider = Provider<MetadataPrefetcher>((ref) {
|
||||||
|
return MetadataPrefetcher(ref);
|
||||||
|
});
|
||||||
@@ -92,10 +92,31 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final tracks = await ref.read(artistTracksProvider(id).future);
|
try {
|
||||||
if (tracks.isEmpty) return;
|
final tracks =
|
||||||
final shuffled = [...tracks]..shuffle();
|
await ref.read(artistTracksProvider(id).future);
|
||||||
ref.read(playerActionsProvider).playTracks(shuffled);
|
if (tracks.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No tracks found for this artist yet.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final shuffled = [...tracks]..shuffle();
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(shuffled);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('artist_detail: play failed: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't start playback: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -109,7 +130,8 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
albums.when(
|
albums.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||||
data: (list) => LayoutBuilder(builder: (ctx, constraints) {
|
data: (list) {
|
||||||
|
return LayoutBuilder(builder: (ctx, constraints) {
|
||||||
const cols = 3;
|
const cols = 3;
|
||||||
const sidePad = 8.0;
|
const sidePad = 8.0;
|
||||||
const gap = 8.0;
|
const gap = 8.0;
|
||||||
@@ -117,10 +139,11 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||||
cols;
|
cols;
|
||||||
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||||
// ≈ 36) + small fudge. Artist line is suppressed in this
|
// ≈ 36) + slack. Artist line is suppressed in this
|
||||||
// grid (showArtist: false) since the page header already
|
// grid (showArtist: false) since the page header already
|
||||||
// names the artist.
|
// names the artist. Slack is generous on purpose — line-
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 4;
|
// height variations would otherwise overflow by 1px.
|
||||||
|
final cellH = (cellW - 16) + 8 + 36 + 8;
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
@@ -144,7 +167,8 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
|
|||||||
? _RealPlaylist(forYou)
|
? _RealPlaylist(forYou)
|
||||||
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
||||||
|
|
||||||
// Slots 2-4: Songs-like (real first, padded to 3).
|
// Slot 2: Discover. Server emits this with system_variant='discover'
|
||||||
|
// when the recommendation engine has eligible tracks; before then,
|
||||||
|
// show a placeholder in the same "building/pending" state machine
|
||||||
|
// as For-You.
|
||||||
|
final discover = findFirst((p) => p.systemVariant == 'discover');
|
||||||
|
out.add(discover != null
|
||||||
|
? _RealPlaylist(discover)
|
||||||
|
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
|
||||||
|
|
||||||
|
// Slots 3-5: Songs-like (real first, padded to 3).
|
||||||
final songsLike = ownedAll
|
final songsLike = ownedAll
|
||||||
.where((p) => p.systemVariant == 'songs_like_artist')
|
.where((p) => p.systemVariant == 'songs_like_artist')
|
||||||
.take(3)
|
.take(3)
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ final artistProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
// SWR: yield cache instantly on hit, refresh in the background so
|
// No alwaysRefresh: artist rows don't change frequently, and the
|
||||||
// the row catches up to server state without making the user wait.
|
// metadata prefetcher creates many subscriptions in parallel —
|
||||||
alwaysRefresh: true,
|
// each silently re-fetching once would saturate the request
|
||||||
|
// pipeline behind the user's actual playback request.
|
||||||
tag: 'artist($id)',
|
tag: 'artist($id)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -100,6 +101,13 @@ final artistAlbumsProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// SWR: re-fetch the full album list on every visit. Without this,
|
||||||
|
// a previously-incomplete drift cache (e.g. user had only opened
|
||||||
|
// one album by this artist, so cachedAlbums had just that row)
|
||||||
|
// would render forever as a partial list. The prefetcher only
|
||||||
|
// warms artistProvider (single row), so this provider isn't
|
||||||
|
// mass-instantiated and the storm risk that motivated dropping
|
||||||
|
// alwaysRefresh elsewhere doesn't apply here.
|
||||||
alwaysRefresh: true,
|
alwaysRefresh: true,
|
||||||
tag: 'artistAlbums($artistId)',
|
tag: 'artistAlbums($artistId)',
|
||||||
);
|
);
|
||||||
@@ -137,7 +145,6 @@ final artistTracksProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
alwaysRefresh: true,
|
|
||||||
tag: 'artistTracks($artistId)',
|
tag: 'artistTracks($artistId)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -169,10 +176,7 @@ final albumProvider = StreamProvider.family<
|
|||||||
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
// 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).
|
// server genuinely returns zero tracks (or if the fetch fails).
|
||||||
var fetchAttempted = false;
|
var fetchAttempted = false;
|
||||||
// SWR revalidation guard — fire one background refresh per
|
// (revalidated flag removed; see SWR note below the yield.)
|
||||||
// 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 {
|
Future<bool> isOnline() async {
|
||||||
try {
|
try {
|
||||||
@@ -191,11 +195,9 @@ final albumProvider = StreamProvider.family<
|
|||||||
Future<bool> fetchAndPopulate() async {
|
Future<bool> fetchAndPopulate() async {
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(libraryApiProvider.future);
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
debugPrint('albumProvider($albumId): calling getAlbum');
|
|
||||||
final fresh = await api
|
final fresh = await api
|
||||||
.getAlbum(albumId)
|
.getAlbum(albumId)
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
|
|
||||||
// Collect every artist mentioned by the album + its tracks so
|
// Collect every artist mentioned by the album + its tracks so
|
||||||
// the JOINs that drive both the album header and the track rows
|
// the JOINs that drive both the album header and the track rows
|
||||||
// have something to bind to. Without this, drift returns null
|
// have something to bind to. Without this, drift returns null
|
||||||
@@ -228,7 +230,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
b.insertAllOnConflictUpdate(db.cachedTracks,
|
b.insertAllOnConflictUpdate(db.cachedTracks,
|
||||||
fresh.tracks.map((t) => t.toDrift()).toList());
|
fresh.tracks.map((t) => t.toDrift()).toList());
|
||||||
});
|
});
|
||||||
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
||||||
@@ -239,7 +240,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
await for (final albumRows in albumQuery.watch()) {
|
await for (final albumRows in albumQuery.watch()) {
|
||||||
// Case 1: no album row at all → cold-fetch.
|
// Case 1: no album row at all → cold-fetch.
|
||||||
if (albumRows.isEmpty) {
|
if (albumRows.isEmpty) {
|
||||||
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
|
|
||||||
if (fetchAttempted) {
|
if (fetchAttempted) {
|
||||||
// Already tried and got nothing back.
|
// Already tried and got nothing back.
|
||||||
yield (
|
yield (
|
||||||
@@ -249,10 +249,7 @@ final albumProvider = StreamProvider.family<
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fetchAttempted = true;
|
fetchAttempted = true;
|
||||||
final online = await isOnline();
|
if (!await isOnline()) {
|
||||||
debugPrint('albumProvider($albumId): online=$online');
|
|
||||||
if (!online) {
|
|
||||||
debugPrint('albumProvider($albumId): offline, yielding empty');
|
|
||||||
yield (
|
yield (
|
||||||
album: const AlbumRef(id: '', title: '', artistId: ''),
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||||
tracks: const <TrackRef>[],
|
tracks: const <TrackRef>[],
|
||||||
@@ -269,7 +266,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
// On success, drift watch re-emits with rows; loop continues.
|
// On success, drift watch re-emits with rows; loop continues.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
|
|
||||||
|
|
||||||
final albumRow = albumRows.first;
|
final albumRow = albumRows.first;
|
||||||
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
||||||
@@ -284,7 +280,6 @@ final albumProvider = StreamProvider.family<
|
|||||||
// fetchAndPopulate so the album becomes complete; drift watch
|
// fetchAndPopulate so the album becomes complete; drift watch
|
||||||
// re-emits and we land in the populated branch on the next pass.
|
// re-emits and we land in the populated branch on the next pass.
|
||||||
if (trackRows.isEmpty && !fetchAttempted) {
|
if (trackRows.isEmpty && !fetchAttempted) {
|
||||||
debugPrint('albumProvider($albumId): album hit but no tracks; fetching');
|
|
||||||
fetchAttempted = true;
|
fetchAttempted = true;
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
final ok = await fetchAndPopulate();
|
final ok = await fetchAndPopulate();
|
||||||
@@ -303,15 +298,13 @@ final albumProvider = StreamProvider.family<
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// SWR: first complete cache hit triggers one background refresh
|
// Note: NO SWR here on purpose. Prior code kicked a background
|
||||||
// so we don't show stale data indefinitely. Drift watch re-emits
|
// refresh on every first cache hit, which combined with the
|
||||||
// on success and the next iteration yields the fresh data.
|
// metadata prefetcher meant every prewarmed album id triggered
|
||||||
if (!revalidated && trackRows.isNotEmpty) {
|
// an extra fetch even when drift was already populated. Tracks
|
||||||
revalidated = true;
|
// and album metadata don't change on the same timescale as
|
||||||
if (await isOnline()) {
|
// playlists; a stale read is fine until the user invalidates
|
||||||
unawaited(fetchAndPopulate());
|
// (pull-to-refresh) or the album is genuinely re-fetched.
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield (album: album, tracks: tracks);
|
yield (album: album, tracks: tracks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../api/endpoints/library_lists.dart';
|
import '../api/endpoints/library_lists.dart';
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../api/endpoints/me.dart';
|
import '../api/endpoints/me.dart';
|
||||||
|
import '../cache/metadata_prefetcher.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
@@ -207,7 +208,12 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryArtistsProvider).when(
|
return ref.watch(_libraryArtistsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) => page.items.isEmpty
|
data: (page) {
|
||||||
|
// Warm details for the first screenful so taps are instant.
|
||||||
|
ref
|
||||||
|
.read(metadataPrefetcherProvider)
|
||||||
|
.warmArtists(page.items.map((a) => a.id));
|
||||||
|
return page.items.isEmpty
|
||||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
@@ -247,7 +253,8 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,7 +268,8 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryAlbumsProvider).when(
|
return ref.watch(_libraryAlbumsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) => page.items.isEmpty
|
data: (page) {
|
||||||
|
return page.items.isEmpty
|
||||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
@@ -280,8 +288,11 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
gap * (cols - 1)) /
|
gap * (cols - 1)) /
|
||||||
cols;
|
cols;
|
||||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||||
// + artist line (~16) + small fudge.
|
// + artist line (~16) + slack. Slack is generous on
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
// purpose — line-height + font scaling variations
|
||||||
|
// 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>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: (n) {
|
onNotification: (n) {
|
||||||
if (n.metrics.pixels >=
|
if (n.metrics.pixels >=
|
||||||
@@ -315,7 +326,8 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,27 @@ import 'album_cover_cache.dart';
|
|||||||
|
|
||||||
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||||
MinstrelAudioHandler() {
|
MinstrelAudioHandler() {
|
||||||
_player.playbackEventStream.listen(_broadcastState);
|
_player.playbackEventStream.listen(
|
||||||
|
_broadcastState,
|
||||||
|
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
||||||
|
// failure, network drop) here. Without an error sink, the
|
||||||
|
// player just goes quiet — exactly the "starts then stops"
|
||||||
|
// symptom we hit.
|
||||||
|
onError: (Object e, StackTrace st) {
|
||||||
|
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
||||||
|
},
|
||||||
|
);
|
||||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||||
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
||||||
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
||||||
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
||||||
_player.loopModeStream.listen((_) => _broadcastState(null));
|
_player.loopModeStream.listen((_) => _broadcastState(null));
|
||||||
|
// Watch buffered-position so we can register stream-cached files
|
||||||
|
// in the audio cache index once they're fully downloaded. Without
|
||||||
|
// this, files written by LockCachingAudioSource never appear in
|
||||||
|
// the index and the eviction loop can't reclaim them.
|
||||||
|
_player.bufferedPositionStream
|
||||||
|
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||||
}
|
}
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -26,6 +41,33 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
AlbumCoverCache? _coverCache;
|
AlbumCoverCache? _coverCache;
|
||||||
AudioCacheManager? _audioCacheManager;
|
AudioCacheManager? _audioCacheManager;
|
||||||
|
|
||||||
|
/// Trackers to dedupe registration — once we've inserted an index row
|
||||||
|
/// for a trackId, don't repeat the work on every buffered-position
|
||||||
|
/// emit. Cleared on dispose only; surviving across queue rebuilds is
|
||||||
|
/// fine because the index is itself the source of truth.
|
||||||
|
final Set<String> _streamCacheRegistered = {};
|
||||||
|
|
||||||
|
/// Cached on first use so we don't hit the platform channel every
|
||||||
|
/// time the buffered-position stream emits (~200ms cadence).
|
||||||
|
String? _cacheDirPath;
|
||||||
|
|
||||||
|
/// True while _fillRemainingSources is doing backward-fill inserts
|
||||||
|
/// at index 0..initialIndex-1. Each insert shifts the player's
|
||||||
|
/// currentIndex (it tracks the actively-playing source through
|
||||||
|
/// list mutations), and the resulting _onCurrentIndexChanged
|
||||||
|
/// callbacks would push the wrong MediaItem onto the stream
|
||||||
|
/// (queue.value[shifted_idx] != actively-playing track). When
|
||||||
|
/// the fill completes, currentIndex == initialIndex, mediaItem
|
||||||
|
/// is already correct, and we re-enable normal listener behavior.
|
||||||
|
bool _suppressIndexUpdates = false;
|
||||||
|
|
||||||
|
/// Increments on every setQueueFromTracks call. The background
|
||||||
|
/// fill task captures the value at start and bails if the live
|
||||||
|
/// counter has moved on — without this, a stale fill will keep
|
||||||
|
/// addAudioSource'ing tracks from the previous playlist into the
|
||||||
|
/// new queue, leaving the player "locked" to a corrupted state.
|
||||||
|
int _queueGeneration = 0;
|
||||||
|
|
||||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||||
/// volume directly; set via setVolume(double).
|
/// volume directly; set via setVolume(double).
|
||||||
Stream<double> get volumeStream => _player.volumeStream;
|
Stream<double> get volumeStream => _player.volumeStream;
|
||||||
@@ -49,32 +91,86 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
_token = token;
|
_token = token;
|
||||||
if (coverCache != null) _coverCache = coverCache;
|
if (coverCache != null) _coverCache = coverCache;
|
||||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||||
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
|
|
||||||
'tokenPresent=${token != null && token.isNotEmpty} '
|
|
||||||
'coverCachePresent=${_coverCache != null} '
|
|
||||||
'audioCachePresent=${_audioCacheManager != null}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
||||||
|
|
||||||
|
// Bump the generation FIRST. Any in-flight _fillRemainingSources
|
||||||
|
// from a previous play will see the mismatch on its next gen
|
||||||
|
// check and stop calling player mutations — important so a stale
|
||||||
|
// fill doesn't append old-playlist tracks into the new queue.
|
||||||
|
final myGen = ++_queueGeneration;
|
||||||
|
// Reset suppress flag in case a prior backward-fill bailed on
|
||||||
|
// gen check before reaching its `finally`.
|
||||||
|
_suppressIndexUpdates = false;
|
||||||
|
|
||||||
|
// Populate the visible queue + current mediaItem immediately so
|
||||||
|
// the player UI reflects the user's tap before any source has
|
||||||
|
// been built. Source list at the just_audio layer fills in
|
||||||
|
// asynchronously below.
|
||||||
final items = tracks.map(_toMediaItem).toList();
|
final items = tracks.map(_toMediaItem).toList();
|
||||||
queue.add(items);
|
queue.add(items);
|
||||||
if (items.isNotEmpty) {
|
mediaItem.add(items[clampedInitial]);
|
||||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
|
||||||
|
// Fast path: build only the initial source so the player can
|
||||||
|
// start. Remaining sources stream in via _fillRemainingSources()
|
||||||
|
// in the background — addAudioSource for next/auto-advance
|
||||||
|
// tracks, insertAudioSource for skipPrev tracks.
|
||||||
|
final initial = await _buildAudioSource(tracks[clampedInitial]);
|
||||||
|
if (myGen != _queueGeneration) {
|
||||||
|
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('audio_handler.setQueueFromTracks: '
|
await _player.setAudioSources([initial], initialIndex: 0);
|
||||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
|
||||||
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
|
||||||
|
|
||||||
await _player.setAudioSources(
|
|
||||||
sources,
|
|
||||||
initialIndex: initialIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Kick the cover fetch for the initial item — async, doesn't block
|
|
||||||
// playback. Subsequent track changes are handled by the
|
|
||||||
// currentIndexStream listener.
|
|
||||||
unawaited(_loadArtForCurrentItem());
|
unawaited(_loadArtForCurrentItem());
|
||||||
|
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// currentIndex; we suppress _onCurrentIndexChanged side effects
|
||||||
|
/// for those so the mediaItem stream doesn't bounce to the wrong
|
||||||
|
/// queue entry.
|
||||||
|
///
|
||||||
|
/// `gen` is the queue generation captured when this fill started.
|
||||||
|
/// Every loop iteration checks against _queueGeneration; if a
|
||||||
|
/// newer setQueueFromTracks has run (user tapped play on something
|
||||||
|
/// else), bail immediately so we don't pollute the new queue with
|
||||||
|
/// addAudioSource calls from this stale fill.
|
||||||
|
Future<void> _fillRemainingSources(
|
||||||
|
List<TrackRef> tracks, int initialIndex, int gen) async {
|
||||||
|
for (var i = initialIndex + 1; i < tracks.length; i++) {
|
||||||
|
if (gen != _queueGeneration) return;
|
||||||
|
try {
|
||||||
|
final src = await _buildAudioSource(tracks[i]);
|
||||||
|
if (gen != _queueGeneration) return;
|
||||||
|
await _player.addAudioSource(src);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (initialIndex > 0) {
|
||||||
|
_suppressIndexUpdates = true;
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < initialIndex; i++) {
|
||||||
|
if (gen != _queueGeneration) return;
|
||||||
|
final src = await _buildAudioSource(tracks[i]);
|
||||||
|
if (gen != _queueGeneration) return;
|
||||||
|
await _player.insertAudioSource(i, src);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('audio_handler: backward fill failed: $e');
|
||||||
|
} finally {
|
||||||
|
// Only release the flag if we're still the active gen — a
|
||||||
|
// newer setQueueFromTracks already reset it for itself.
|
||||||
|
if (gen == _queueGeneration) _suppressIndexUpdates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolveStreamUrl(TrackRef t) {
|
String _resolveStreamUrl(TrackRef t) {
|
||||||
@@ -103,7 +199,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
if (mgr != null) {
|
if (mgr != null) {
|
||||||
final path = await mgr.pathFor(t.id);
|
final path = await mgr.pathFor(t.id);
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
|
|
||||||
return AudioSource.uri(Uri.file(path));
|
return AudioSource.uri(Uri.file(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,17 +222,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
// pin / Download buttons cover the index path; LockCaching handles
|
// pin / Download buttons cover the index path; LockCaching handles
|
||||||
// the network optimization.
|
// the network optimization.
|
||||||
if (mgr != null) {
|
if (mgr != null) {
|
||||||
final cacheDir = await getApplicationCacheDirectory();
|
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||||
final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3');
|
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
|
||||||
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
|
|
||||||
'using LockCachingAudioSource → ${cacheFile.path}');
|
|
||||||
// ignore: experimental_member_use
|
// ignore: experimental_member_use
|
||||||
return LockCachingAudioSource(parsed,
|
return LockCachingAudioSource(parsed,
|
||||||
headers: headers, cacheFile: cacheFile);
|
headers: headers, cacheFile: cacheFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. No manager configured: plain network source (legacy path).
|
// 3. No manager configured: plain network source (legacy path).
|
||||||
debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"');
|
|
||||||
return AudioSource.uri(parsed, headers: headers);
|
return AudioSource.uri(parsed, headers: headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,8 +276,40 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Once a track is fully buffered (LockCaching has written the whole
|
||||||
|
/// file to disk), insert an audio_cache_index row so the file shows
|
||||||
|
/// up to AudioCacheManager.evict() and clearAll(). No-op if the
|
||||||
|
/// cache manager isn't configured, no current track, the file isn't
|
||||||
|
/// fully buffered yet, the on-disk file is missing, or we already
|
||||||
|
/// registered this trackId during this subscription.
|
||||||
|
Future<void> _maybeRegisterStreamCache() async {
|
||||||
|
final mgr = _audioCacheManager;
|
||||||
|
if (mgr == null) return;
|
||||||
|
final current = mediaItem.value;
|
||||||
|
if (current == null) return;
|
||||||
|
final trackId = current.id;
|
||||||
|
if (_streamCacheRegistered.contains(trackId)) return;
|
||||||
|
|
||||||
|
final dur = _player.duration;
|
||||||
|
if (dur == null) return;
|
||||||
|
final buf = _player.bufferedPosition;
|
||||||
|
// 200ms slack for header bytes / encoding rounding.
|
||||||
|
if (buf < dur - const Duration(milliseconds: 200)) return;
|
||||||
|
|
||||||
|
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||||
|
final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) return;
|
||||||
|
final size = await file.length();
|
||||||
|
if (size <= 0) return;
|
||||||
|
|
||||||
|
_streamCacheRegistered.add(trackId);
|
||||||
|
await mgr.registerStreamCache(trackId, path, size);
|
||||||
|
}
|
||||||
|
|
||||||
void _onCurrentIndexChanged(int? idx) {
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
|
if (_suppressIndexUpdates) return;
|
||||||
// Push the new track's MediaItem onto the mediaItem stream so
|
// Push the new track's MediaItem onto the mediaItem stream so
|
||||||
// the player UI rebuilds with the new title/artist/album/cover.
|
// the player UI rebuilds with the new title/artist/album/cover.
|
||||||
// Without this, the bar and full player stayed pinned to whichever
|
// Without this, the bar and full player stayed pinned to whichever
|
||||||
|
|||||||
@@ -113,20 +113,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 32),
|
|
||||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_PrimaryControls(
|
|
||||||
fs: fs,
|
|
||||||
ref: ref,
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SecondaryControls(
|
_SecondaryControls(
|
||||||
fs: fs,
|
fs: fs,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
shuffleOn: shuffleOn,
|
shuffleOn: shuffleOn,
|
||||||
repeatMode: repeatMode,
|
repeatMode: repeatMode,
|
||||||
|
media: media,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_PrimaryControls(
|
||||||
|
fs: fs,
|
||||||
|
ref: ref,
|
||||||
|
isPlaying: isPlaying,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
@@ -219,48 +220,14 @@ class _TitleRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Stack: title centered absolutely in the row; like + kebab pinned
|
// Title-only — like + kebab moved into _SecondaryControls above
|
||||||
// to the right edge. Padding on the title equals the actions' width
|
// the seek bar so they share the same row as shuffle/repeat/queue.
|
||||||
// so it stays optically centered without colliding with them.
|
return Text(
|
||||||
return SizedBox(
|
media.title,
|
||||||
height: 32,
|
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||||
child: Stack(
|
maxLines: 1,
|
||||||
alignment: Alignment.center,
|
overflow: TextOverflow.ellipsis,
|
||||||
children: [
|
textAlign: TextAlign.center,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,17 +332,23 @@ class _PrimaryControls extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Action row sitting just above the seek bar. Holds shuffle / repeat
|
||||||
|
/// / queue plus the like + kebab that used to live in the title row,
|
||||||
|
/// so the title can sit truly centered above and this row carries
|
||||||
|
/// every per-track action in one place.
|
||||||
class _SecondaryControls extends StatelessWidget {
|
class _SecondaryControls extends StatelessWidget {
|
||||||
const _SecondaryControls({
|
const _SecondaryControls({
|
||||||
required this.fs,
|
required this.fs,
|
||||||
required this.actions,
|
required this.actions,
|
||||||
required this.shuffleOn,
|
required this.shuffleOn,
|
||||||
required this.repeatMode,
|
required this.repeatMode,
|
||||||
|
required this.media,
|
||||||
});
|
});
|
||||||
final FabledSwordTheme fs;
|
final FabledSwordTheme fs;
|
||||||
final PlayerActions actions;
|
final PlayerActions actions;
|
||||||
final bool shuffleOn;
|
final bool shuffleOn;
|
||||||
final AudioServiceRepeatMode repeatMode;
|
final AudioServiceRepeatMode repeatMode;
|
||||||
|
final MediaItem media;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -411,6 +384,31 @@ class _SecondaryControls extends StatelessWidget {
|
|||||||
icon: Icon(Icons.queue_music, color: fs.ash),
|
icon: Icon(Icons.queue_music, color: fs.ash),
|
||||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
// /now-playing lives outside the ShellRoute. Pushing
|
||||||
|
// /artists/:id or /albums/:id (both shell-children) on top
|
||||||
|
// of it would make go_router try to mount a duplicate
|
||||||
|
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
|
||||||
|
// we hit before with /queue). Await our own pop fully
|
||||||
|
// before pushing the destination so go_router never sees
|
||||||
|
// both pages active in the same frame.
|
||||||
|
onNavigate: (path) async {
|
||||||
|
await Navigator.of(context).maybePop();
|
||||||
|
if (context.mounted) GoRouter.of(context).push(path);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,26 @@ import '../theme/theme_extension.dart';
|
|||||||
import 'playlists_provider.dart';
|
import 'playlists_provider.dart';
|
||||||
|
|
||||||
class PlaylistDetailScreen extends ConsumerWidget {
|
class PlaylistDetailScreen extends ConsumerWidget {
|
||||||
const PlaylistDetailScreen({required this.id, super.key});
|
const PlaylistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional Playlist passed via go_router extra so the header
|
||||||
|
/// (title, cover, track count, play/download CTAs) can render
|
||||||
|
/// before the full detail fetch resolves. Same pattern as album
|
||||||
|
/// + artist nav hydration.
|
||||||
|
final Playlist? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final detail = ref.watch(playlistDetailProvider(id));
|
final detail = ref.watch(playlistDetailProvider(id));
|
||||||
|
|
||||||
|
// Resolve the best playlist info available for the AppBar title.
|
||||||
|
final livePlaylist = detail.value?.playlist;
|
||||||
|
final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty)
|
||||||
|
? livePlaylist.name
|
||||||
|
: (seed?.name ?? '');
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -28,17 +41,21 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
|||||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: detail.maybeWhen(
|
title: headerName.isEmpty
|
||||||
data: (d) => Text(
|
? const SizedBox.shrink()
|
||||||
d.playlist.name,
|
: Text(
|
||||||
style: TextStyle(color: fs.parchment),
|
headerName,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(color: fs.parchment),
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
orElse: () => const SizedBox.shrink(),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: detail.when(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
// Loading from a seed: render the header immediately + a
|
||||||
|
// small inline "loading tracks" hint, instead of an opaque
|
||||||
|
// full-screen spinner. The body fills in when tracks arrive.
|
||||||
|
loading: () => seed == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _SeedBody(playlist: seed!),
|
||||||
error: (e, _) =>
|
error: (e, _) =>
|
||||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (d) => _Body(detail: d),
|
data: (d) => _Body(detail: d),
|
||||||
@@ -47,6 +64,43 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loading-state body: renders just the header from the seed
|
||||||
|
/// playlist + a "loading tracks…" placeholder. CTA buttons are
|
||||||
|
/// hidden until the real track list arrives (they need playable
|
||||||
|
/// refs to do anything useful).
|
||||||
|
class _SeedBody extends StatelessWidget {
|
||||||
|
const _SeedBody({required this.playlist});
|
||||||
|
final Playlist playlist;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ListView(children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
if (playlist.description.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
playlist.description,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _Body extends ConsumerWidget {
|
class _Body extends ConsumerWidget {
|
||||||
const _Body({required this.detail});
|
const _Body({required this.detail});
|
||||||
final PlaylistDetail detail;
|
final PlaylistDetail detail;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class PlaylistsListScreen extends ConsumerWidget {
|
|||||||
final p = items[i];
|
final p = items[i];
|
||||||
return _PlaylistTile(
|
return _PlaylistTile(
|
||||||
playlist: p,
|
playlist: p,
|
||||||
onTap: () => ctx.push('/playlists/${p.id}'),
|
onTap: () => ctx.push('/playlists/${p.id}', extra: p),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -83,7 +83,11 @@ class _PlaylistTile extends StatelessWidget {
|
|||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: playlist.coverUrl.isEmpty
|
child: playlist.coverUrl.isEmpty
|
||||||
? Icon(Icons.queue_music, color: fs.ash)
|
? Icon(Icons.queue_music, color: fs.ash)
|
||||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
: ServerImage(
|
||||||
|
url: playlist.coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Icon(Icons.queue_music, color: fs.ash),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -39,12 +40,21 @@ final playlistsListProvider =
|
|||||||
fetchAndPopulate: () async {
|
fetchAndPopulate: () async {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
final fresh = await api.list(kind: kind);
|
final fresh = await api.list(kind: kind);
|
||||||
final ownedSysCount =
|
|
||||||
fresh.owned.where((p) => p.systemVariant != null).length;
|
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
||||||
debugPrint(
|
// every rebuild, so insertOrReplace alone leaves stale rows in
|
||||||
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
|
// drift. Tapping one of those stale tiles 404s. Delete every
|
||||||
'(system=$ownedSysCount) public=${fresh.public.length}');
|
// owned drift row whose id isn't in the fresh response, then
|
||||||
|
// upsert the fresh set.
|
||||||
|
final freshOwnedIds =
|
||||||
|
fresh.owned.map((p) => p.id).toSet();
|
||||||
await db.batch((b) {
|
await db.batch((b) {
|
||||||
|
if (user != null) {
|
||||||
|
b.deleteWhere(db.cachedPlaylists, (t) {
|
||||||
|
return t.userId.equals(user.id) &
|
||||||
|
t.id.isNotIn(freshOwnedIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
for (final p in fresh.all) {
|
for (final p in fresh.all) {
|
||||||
b.insert(db.cachedPlaylists, p.toDrift(),
|
b.insert(db.cachedPlaylists, p.toDrift(),
|
||||||
mode: drift.InsertMode.insertOrReplace);
|
mode: drift.InsertMode.insertOrReplace);
|
||||||
@@ -66,13 +76,6 @@ final playlistsListProvider =
|
|||||||
.where((r) => r.userId != user.id && r.isPublic)
|
.where((r) => r.userId != user.id && r.isPublic)
|
||||||
.map((r) => r.toRef())
|
.map((r) => r.toRef())
|
||||||
.toList();
|
.toList();
|
||||||
final ownedSys = owned.where((p) => p.isSystem).length;
|
|
||||||
final driftSys = filtered.where((r) => r.systemVariant != null).length;
|
|
||||||
debugPrint(
|
|
||||||
'playlistsListProvider($kind): drift rows=${rows.length} '
|
|
||||||
'filtered=${filtered.length} (system=$driftSys) '
|
|
||||||
'owned=${owned.length} (system=$ownedSys) pub=${pub.length} '
|
|
||||||
'userId=${user.id}');
|
|
||||||
return PlaylistsList(owned: owned, public: pub);
|
return PlaylistsList(owned: owned, public: pub);
|
||||||
},
|
},
|
||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
@@ -138,18 +141,31 @@ final playlistDetailProvider =
|
|||||||
Future<bool> fetchAndPopulate() async {
|
Future<bool> fetchAndPopulate() async {
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
debugPrint('playlistDetailProvider($id): calling get');
|
|
||||||
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
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
|
// Collect the track + artist + album rows referenced by these
|
||||||
// the JOINs that build artistName/albumTitle in the row view
|
// playlist entries. Without writing the cachedTracks rows
|
||||||
// have something to bind to. Without this, system-playlist tracks
|
// themselves, the detail-screen JOIN against cachedTracks
|
||||||
// surface with empty artist/album columns.
|
// returns null on every row (only the playlist_tracks join
|
||||||
|
// succeeds), so the UI shows a list with empty titles and
|
||||||
|
// unplayable tracks.
|
||||||
|
final tracks = <String, _TrackRefRow>{};
|
||||||
final artists = <String, ArtistRefRow>{};
|
final artists = <String, ArtistRefRow>{};
|
||||||
final albums = <String, AlbumRefRow>{};
|
final albums = <String, AlbumRefRow>{};
|
||||||
for (final t in fresh.tracks) {
|
for (final t in fresh.tracks) {
|
||||||
|
final tId = t.trackId;
|
||||||
|
if (tId != null && tId.isNotEmpty) {
|
||||||
|
tracks.putIfAbsent(
|
||||||
|
tId,
|
||||||
|
() => _TrackRefRow(
|
||||||
|
id: tId,
|
||||||
|
title: t.title,
|
||||||
|
albumId: t.albumId ?? '',
|
||||||
|
artistId: t.artistId ?? '',
|
||||||
|
durationSec: t.durationSec,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
final aId = t.artistId;
|
final aId = t.artistId;
|
||||||
final aName = t.artistName;
|
final aName = t.artistName;
|
||||||
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
|
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
|
||||||
@@ -198,6 +214,25 @@ final playlistDetailProvider =
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (tracks.isNotEmpty) {
|
||||||
|
// Insert/update cachedTracks so the detail screen's JOIN
|
||||||
|
// produces real titles + durations. We don't have
|
||||||
|
// track_number / disc_number on the wire (PlaylistTrack
|
||||||
|
// omits them), so they default to 0 — UI doesn't surface
|
||||||
|
// them on this screen so it's fine.
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks,
|
||||||
|
tracks.values
|
||||||
|
.map((t) => CachedTracksCompanion.insert(
|
||||||
|
id: t.id,
|
||||||
|
albumId: t.albumId,
|
||||||
|
artistId: t.artistId,
|
||||||
|
title: t.title,
|
||||||
|
durationMs: drift.Value(t.durationSec * 1000),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (albums.isNotEmpty) {
|
if (albums.isNotEmpty) {
|
||||||
b.insertAllOnConflictUpdate(
|
b.insertAllOnConflictUpdate(
|
||||||
db.cachedAlbums,
|
db.cachedAlbums,
|
||||||
@@ -212,10 +247,21 @@ final playlistDetailProvider =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
||||||
|
// rotates UUIDs on each rebuild, so old For-You / Songs-Like
|
||||||
|
// tiles can outlive the actual server-side playlist). Wipe the
|
||||||
|
// stale row + its track positions so the home tile disappears
|
||||||
|
// on next render and we don't keep trying.
|
||||||
|
if (e is DioException && e.response?.statusCode == 404) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.deleteWhere(
|
||||||
|
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||||
|
b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id));
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
|
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -224,11 +270,9 @@ final playlistDetailProvider =
|
|||||||
// Once-per-subscription guard so an empty server response doesn't
|
// Once-per-subscription guard so an empty server response doesn't
|
||||||
// cause repeated re-fetches.
|
// cause repeated re-fetches.
|
||||||
var fetchAttempted = false;
|
var fetchAttempted = false;
|
||||||
var revalidated = false;
|
|
||||||
|
|
||||||
await for (final playlistRows in playlistQuery.watch()) {
|
await for (final playlistRows in playlistQuery.watch()) {
|
||||||
if (playlistRows.isEmpty) {
|
if (playlistRows.isEmpty) {
|
||||||
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
|
|
||||||
if (fetchAttempted) {
|
if (fetchAttempted) {
|
||||||
yield emptyDetail();
|
yield emptyDetail();
|
||||||
continue;
|
continue;
|
||||||
@@ -243,9 +287,6 @@ final playlistDetailProvider =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
|
|
||||||
|
|
||||||
final playlist = playlistRows.first.toRef();
|
final playlist = playlistRows.first.toRef();
|
||||||
final trackRows = await tracksQuery.get();
|
final trackRows = await tracksQuery.get();
|
||||||
|
|
||||||
@@ -254,8 +295,6 @@ final playlistDetailProvider =
|
|||||||
// tracks for system playlists). Trigger the same fetch, drift
|
// tracks for system playlists). Trigger the same fetch, drift
|
||||||
// watch re-emits with populated tracks.
|
// watch re-emits with populated tracks.
|
||||||
if (trackRows.isEmpty && !fetchAttempted) {
|
if (trackRows.isEmpty && !fetchAttempted) {
|
||||||
debugPrint(
|
|
||||||
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
|
|
||||||
fetchAttempted = true;
|
fetchAttempted = true;
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
final ok = await fetchAndPopulate();
|
final ok = await fetchAndPopulate();
|
||||||
@@ -283,13 +322,12 @@ final playlistDetailProvider =
|
|||||||
|
|
||||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||||
|
|
||||||
// SWR: first complete cache hit kicks one background refresh.
|
// No SWR refresh here. The aggregate playlistsListProvider does
|
||||||
if (!revalidated && trackRows.isNotEmpty) {
|
// alwaysRefresh (system playlists rotate UUIDs), but per-detail
|
||||||
revalidated = true;
|
// refresh on every visit was multiplying with the prefetcher's
|
||||||
if (await isOnline()) {
|
// parallel fetches and starving user-initiated playback. Pull-
|
||||||
unawaited(fetchAndPopulate());
|
// to-refresh on the detail page invalidates the provider, which
|
||||||
}
|
// is the right path for an explicit refresh.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +347,21 @@ class AlbumRefRow {
|
|||||||
final String artistId;
|
final String artistId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TrackRefRow {
|
||||||
|
_TrackRefRow({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.albumId,
|
||||||
|
required this.artistId,
|
||||||
|
required this.durationSec,
|
||||||
|
});
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String albumId;
|
||||||
|
final String artistId;
|
||||||
|
final int durationSec;
|
||||||
|
}
|
||||||
|
|
||||||
final systemPlaylistsStatusProvider =
|
final systemPlaylistsStatusProvider =
|
||||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||||
final dio = await ref.watch(dioProvider.future);
|
final dio = await ref.watch(dioProvider.future);
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.push('/playlists/${playlist.id}'),
|
onTap: () =>
|
||||||
|
context.push('/playlists/${playlist.id}', extra: playlist),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
@@ -31,9 +32,19 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
width: 144,
|
width: 144,
|
||||||
height: 144,
|
height: 144,
|
||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
|
// coverUrl is deterministic per playlist (see
|
||||||
|
// CachedPlaylistAdapter.toRef). When the server's
|
||||||
|
// collage isn't built yet, ServerImage's
|
||||||
|
// errorBuilder shows the queue_music icon over the
|
||||||
|
// slate background.
|
||||||
child: playlist.coverUrl.isEmpty
|
child: playlist.coverUrl.isEmpty
|
||||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
: ServerImage(
|
||||||
|
url: playlist.coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback:
|
||||||
|
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../library/album_detail_screen.dart';
|
|||||||
import '../library/artist_detail_screen.dart';
|
import '../library/artist_detail_screen.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
import '../discover/discover_screen.dart';
|
import '../discover/discover_screen.dart';
|
||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../library/library_screen.dart';
|
import '../library/library_screen.dart';
|
||||||
@@ -85,6 +86,14 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// /queue lives outside the ShellRoute too. Why: pushing /queue
|
||||||
|
// from /now-playing (which is also outside the shell) used to
|
||||||
|
// cause go_router to mount a second ShellRoute instance under
|
||||||
|
// the existing one, producing a duplicate page-key assertion
|
||||||
|
// (NavigatorState._debugCheckDuplicatedPageKeys). Top-level
|
||||||
|
// routes can stack on each other freely; shell-children can't
|
||||||
|
// when something on top of the shell is already routing.
|
||||||
|
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -105,7 +114,6 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||||
@@ -113,7 +121,10 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/playlists/:id',
|
path: '/playlists/:id',
|
||||||
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
builder: (_, s) => PlaylistDetailScreen(
|
||||||
|
id: s.pathParameters['id']!,
|
||||||
|
seed: s.extra is Playlist ? s.extra as Playlist : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
||||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class TrackActionsButton extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.track,
|
required this.track,
|
||||||
this.hideQueueActions = false,
|
this.hideQueueActions = false,
|
||||||
|
this.onNavigate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TrackRef track;
|
final TrackRef track;
|
||||||
@@ -19,6 +20,11 @@ class TrackActionsButton extends StatelessWidget {
|
|||||||
/// screen where the menu's track IS the currently-playing one.
|
/// screen where the menu's track IS the currently-playing one.
|
||||||
final bool hideQueueActions;
|
final bool hideQueueActions;
|
||||||
|
|
||||||
|
/// Forwarded to TrackActionsSheet.onNavigate. The host receives the
|
||||||
|
/// destination path AFTER the sheet has popped and is responsible
|
||||||
|
/// for navigating to it (typically: pop self first, then push).
|
||||||
|
final Future<void> Function(String path)? onNavigate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
@@ -29,6 +35,7 @@ class TrackActionsButton extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
track,
|
track,
|
||||||
hideQueueActions: hideQueueActions,
|
hideQueueActions: hideQueueActions,
|
||||||
|
onNavigate: onNavigate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,27 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.hideQueueActions,
|
required this.hideQueueActions,
|
||||||
|
this.onNavigate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TrackRef track;
|
final TrackRef track;
|
||||||
final bool hideQueueActions;
|
final bool hideQueueActions;
|
||||||
|
|
||||||
|
/// Optional hook for "Go to album" / "Go to artist". Called with
|
||||||
|
/// the destination route path AFTER the sheet has popped. Lets
|
||||||
|
/// callers that live OUTSIDE the ShellRoute (e.g. the full player
|
||||||
|
/// at /now-playing) pop themselves first then push, so go_router
|
||||||
|
/// doesn't try to mount a second ShellRoute on top of the active
|
||||||
|
/// top-level route. When null, the sheet does its own
|
||||||
|
/// context.push, which is correct for surfaces already inside the
|
||||||
|
/// shell (mini player, track rows on detail screens, etc.).
|
||||||
|
final Future<void> Function(String path)? onNavigate;
|
||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TrackRef track, {
|
TrackRef track, {
|
||||||
bool hideQueueActions = false,
|
bool hideQueueActions = false,
|
||||||
|
Future<void> Function(String path)? onNavigate,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -37,6 +49,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
builder: (_) => TrackActionsSheet(
|
builder: (_) => TrackActionsSheet(
|
||||||
track: track,
|
track: track,
|
||||||
hideQueueActions: hideQueueActions,
|
hideQueueActions: hideQueueActions,
|
||||||
|
onNavigate: onNavigate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,7 +154,12 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
label: 'Go to album',
|
label: 'Go to album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.push('/albums/${track.albumId}');
|
final path = '/albums/${track.albumId}';
|
||||||
|
if (onNavigate != null) {
|
||||||
|
onNavigate!(path);
|
||||||
|
} else {
|
||||||
|
context.push(path);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
@@ -150,7 +168,12 @@ class TrackActionsSheet extends ConsumerWidget {
|
|||||||
label: 'Go to artist',
|
label: 'Go to artist',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.push('/artists/${track.artistId}');
|
final path = '/artists/${track.artistId}';
|
||||||
|
if (onNavigate != null) {
|
||||||
|
onNavigate!(path);
|
||||||
|
} else {
|
||||||
|
context.push(path);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const _Divider(),
|
const _Divider(),
|
||||||
|
|||||||
+27
-20
@@ -40,19 +40,28 @@ func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
||||||
// tracks (ordered by disc/track number via the underlying query) with
|
// tracks (ordered by disc/track number via the underlying query) with
|
||||||
// duration summed from the track list — keeps one source of truth.
|
// duration summed from the track list — keeps one source of truth.
|
||||||
|
//
|
||||||
|
// Two DB round trips: the album+artist join and the per-user tracks
|
||||||
|
// list. Down from three (separate album, artist, tracks) before
|
||||||
|
// GetAlbumWithArtist landed.
|
||||||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
if apiErr != nil {
|
if !ok {
|
||||||
writeErr(w, apiErr)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
|
row, err := q.GetAlbumWithArtist(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: get album artist failed", "err", err)
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, apierror.NotFound("album"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: get album+artist failed", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
album := row.Album
|
||||||
|
artistName := row.ArtistName
|
||||||
var userID pgtype.UUID
|
var userID pgtype.UUID
|
||||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||||
userID = user.ID
|
userID = user.ID
|
||||||
@@ -68,20 +77,24 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
|||||||
refs := make([]TrackRef, 0, len(tracks))
|
refs := make([]TrackRef, 0, len(tracks))
|
||||||
durSec := 0
|
durSec := 0
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
ref := trackRefFrom(t, album.Title, artist.Name)
|
ref := trackRefFrom(t, album.Title, artistName)
|
||||||
refs = append(refs, ref)
|
refs = append(refs, ref)
|
||||||
durSec += ref.DurationSec
|
durSec += ref.DurationSec
|
||||||
}
|
}
|
||||||
detail := AlbumDetail{
|
detail := AlbumDetail{
|
||||||
AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
|
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
||||||
Tracks: refs,
|
Tracks: refs,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
||||||
// each album carries its own track_count (one count query per album, same
|
// each album carries its own track_count.
|
||||||
// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
|
//
|
||||||
|
// Down from 1 + 1 + N queries (artist, albums, per-album CountTracksByAlbum)
|
||||||
|
// to 1 + 1 (artist, albums-with-track-count via correlated subquery).
|
||||||
|
// On a 30-album artist that's ~32 round trips collapsed to 2 — the
|
||||||
|
// difference between "feels slow" and "feels instant" on detail nav.
|
||||||
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
artist, apiErr := resolveByID(r, "id", q.GetArtistByID, "artist")
|
artist, apiErr := resolveByID(r, "id", q.GetArtistByID, "artist")
|
||||||
@@ -89,25 +102,19 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apiErr)
|
writeErr(w, apiErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
albums, err := q.ListAlbumsByArtist(r.Context(), artist.ID)
|
rows, err := q.ListAlbumsByArtistWithTrackCount(r.Context(), artist.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list albums by artist failed", "err", err)
|
h.logger.Error("api: list albums by artist failed", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refs := make([]AlbumRef, 0, len(albums))
|
refs := make([]AlbumRef, 0, len(rows))
|
||||||
for _, a := range albums {
|
for _, row := range rows {
|
||||||
count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
|
|
||||||
if cerr != nil {
|
|
||||||
h.logger.Error("api: count tracks failed", "err", cerr)
|
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", cerr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||||
refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
|
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
||||||
}
|
}
|
||||||
detail := ArtistDetail{
|
detail := ArtistDetail{
|
||||||
ArtistRef: artistRefFrom(artist, len(albums)),
|
ArtistRef: artistRefFrom(artist, len(rows)),
|
||||||
Albums: refs,
|
Albums: refs,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, detail)
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", imageContentType(path))
|
w.Header().Set("Content-Type", imageContentType(path))
|
||||||
|
// Cover bytes change rarely (cover-source enrichment, manual rescan,
|
||||||
|
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
|
||||||
|
// clients skip the conditional GET for the bulk of a session, but
|
||||||
|
// stale art clears within 24h after a re-scan. ServeContent below
|
||||||
|
// still emits Last-Modified for the conditional path when needed.
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
|
||||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,5 +146,11 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
|
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
|
||||||
w.Header().Set("Accept-Ranges", "bytes")
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
// Track bytes are immutable for a given track id (the scanner
|
||||||
|
// indexes by file_path; re-encoded files take new ids). One-year
|
||||||
|
// max-age + immutable lets the client cache (LockCachingAudioSource
|
||||||
|
// on the Flutter side, browser cache on web) skip even the
|
||||||
|
// conditional GET on repeat plays.
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,6 +386,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
||||||
|
// Playlist collages recompute when the playlist's tracks change
|
||||||
|
// (system playlists re-rendered on rebuild, user playlists when
|
||||||
|
// modified). 5 minutes is short enough for normal edits to feel
|
||||||
|
// fresh, long enough to skip repeat fetches during a session.
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||||
http.ServeFile(w, r, full)
|
http.ServeFile(w, r, full)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,41 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE albums.id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumWithArtistRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined fetch for /api/albums/{id}: returns the album row + the
|
||||||
|
// joined artist name in a single round trip. Replaces a sequential
|
||||||
|
// GetAlbumByID + GetArtistByID pair on the hot detail-page path.
|
||||||
|
func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAlbumWithArtistRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getAlbumWithArtist, id)
|
||||||
|
var i GetAlbumWithArtistRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
|
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
|
||||||
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
@@ -389,6 +424,56 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version,
|
||||||
|
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
|
||||||
|
AS track_count
|
||||||
|
FROM albums
|
||||||
|
WHERE albums.artist_id = $1
|
||||||
|
ORDER BY release_date NULLS LAST, sort_title
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumsByArtistWithTrackCountRow struct {
|
||||||
|
Album Album
|
||||||
|
TrackCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
|
||||||
|
// per album). Returns each album joined with its track count via a
|
||||||
|
// correlated subquery — single round trip regardless of album count.
|
||||||
|
func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID pgtype.UUID) ([]ListAlbumsByArtistWithTrackCountRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumsByArtistWithTrackCount, artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumsByArtistWithTrackCountRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumsByArtistWithTrackCountRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.TrackCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
||||||
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||||
FROM albums
|
FROM albums
|
||||||
|
|||||||
@@ -12,17 +12,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
WITH user_plays AS (
|
||||||
cov.id AS cover_album_id,
|
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||||
cnt.album_count::bigint AS album_count,
|
|
||||||
max_started.started_at::timestamptz AS last_played_at
|
|
||||||
FROM artists a
|
|
||||||
JOIN LATERAL (
|
|
||||||
SELECT max(pe.started_at) AS started_at
|
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
WHERE pe.user_id = $1 AND t.artist_id = a.id
|
WHERE pe.user_id = $1
|
||||||
) max_started ON max_started.started_at IS NOT NULL
|
GROUP BY t.artist_id
|
||||||
|
)
|
||||||
|
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
||||||
|
cov.id AS cover_album_id,
|
||||||
|
cnt.album_count::bigint AS album_count,
|
||||||
|
up.last_started::timestamptz AS last_played_at
|
||||||
|
FROM user_plays up
|
||||||
|
JOIN artists a ON a.id = up.artist_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT id FROM albums
|
SELECT id FROM albums
|
||||||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||||||
@@ -32,7 +34,7 @@ LEFT JOIN LATERAL (
|
|||||||
SELECT count(*) AS album_count
|
SELECT count(*) AS album_count
|
||||||
FROM albums WHERE artist_id = a.id
|
FROM albums WHERE artist_id = a.id
|
||||||
) cnt ON true
|
) cnt ON true
|
||||||
ORDER BY max_started.started_at DESC, a.id
|
ORDER BY up.last_started DESC, a.id
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -52,6 +54,15 @@ type ListLastPlayedArtistsForUserRow struct {
|
|||||||
// a derived cover_album_id via a representative-album lateral join (most
|
// a derived cover_album_id via a representative-album lateral join (most
|
||||||
// recent album that has cover_art_path set). album_count joined for the
|
// recent album that has cover_art_path set). album_count joined for the
|
||||||
// ArtistRef wire shape.
|
// ArtistRef wire shape.
|
||||||
|
//
|
||||||
|
// Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||||||
|
// subquery per artist — O(total_artists) plan even for users with a
|
||||||
|
// handful of plays. New shape aggregates the user's plays by artist
|
||||||
|
// via the play_events → tracks join up front (uses
|
||||||
|
// play_events_user_track_idx + tracks pkey lookups), then attaches
|
||||||
|
// the artist row and lateral cover/count subqueries only for the
|
||||||
|
// artists that actually appear. Distinct-artists set is small for a
|
||||||
|
// typical user, so cost is bounded by play history not library size.
|
||||||
func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) {
|
func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit)
|
rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,24 +98,24 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||||
|
WITH plays AS (
|
||||||
|
SELECT track_id, count(*) AS cnt
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
|
GROUP BY track_id
|
||||||
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM plays p
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN tracks t ON t.id = p.track_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE pe.user_id = $1
|
WHERE NOT EXISTS (
|
||||||
AND pe.was_skipped = false
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
AND NOT EXISTS (
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
)
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
ORDER BY p.cnt DESC, t.id
|
||||||
)
|
|
||||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
|
||||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
|
||||||
albums.title, artists.name
|
|
||||||
ORDER BY count(*) DESC, t.id
|
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -122,6 +133,11 @@ type ListMostPlayedTracksForUserRow struct {
|
|||||||
// M6a: top-N tracks by completed-play count for the user. was_skipped
|
// M6a: top-N tracks by completed-play count for the user. was_skipped
|
||||||
// excludes skips so a user spamming next doesn't fabricate a top track.
|
// excludes skips so a user spamming next doesn't fabricate a top track.
|
||||||
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||||||
|
//
|
||||||
|
// Aggregate play_events first (uses play_events_user_track_idx) and
|
||||||
|
// join through tracks/albums/artists only for the survivors. Earlier
|
||||||
|
// shape did the join across every play_event row before grouping —
|
||||||
|
// O(plays) instead of O(distinct tracks).
|
||||||
func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) {
|
func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit)
|
rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,26 @@ RETURNING *;
|
|||||||
-- name: GetAlbumByID :one
|
-- name: GetAlbumByID :one
|
||||||
SELECT * FROM albums WHERE id = $1;
|
SELECT * FROM albums WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetAlbumWithArtist :one
|
||||||
|
-- Combined fetch for /api/albums/{id}: returns the album row + the
|
||||||
|
-- joined artist name in a single round trip. Replaces a sequential
|
||||||
|
-- GetAlbumByID + GetArtistByID pair on the hot detail-page path.
|
||||||
|
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE albums.id = $1;
|
||||||
|
|
||||||
|
-- name: ListAlbumsByArtistWithTrackCount :many
|
||||||
|
-- Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
|
||||||
|
-- per album). Returns each album joined with its track count via a
|
||||||
|
-- correlated subquery — single round trip regardless of album count.
|
||||||
|
SELECT sqlc.embed(albums),
|
||||||
|
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
|
||||||
|
AS track_count
|
||||||
|
FROM albums
|
||||||
|
WHERE albums.artist_id = $1
|
||||||
|
ORDER BY release_date NULLS LAST, sort_title;
|
||||||
|
|
||||||
-- name: GetAlbumByArtistAndTitle :one
|
-- name: GetAlbumByArtistAndTitle :one
|
||||||
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
|
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
|
||||||
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
|
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
|
||||||
|
|||||||
@@ -206,24 +206,29 @@ LIMIT $3;
|
|||||||
-- M6a: top-N tracks by completed-play count for the user. was_skipped
|
-- M6a: top-N tracks by completed-play count for the user. was_skipped
|
||||||
-- excludes skips so a user spamming next doesn't fabricate a top track.
|
-- excludes skips so a user spamming next doesn't fabricate a top track.
|
||||||
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||||||
|
--
|
||||||
|
-- Aggregate play_events first (uses play_events_user_track_idx) and
|
||||||
|
-- join through tracks/albums/artists only for the survivors. Earlier
|
||||||
|
-- shape did the join across every play_event row before grouping —
|
||||||
|
-- O(plays) instead of O(distinct tracks).
|
||||||
|
WITH plays AS (
|
||||||
|
SELECT track_id, count(*) AS cnt
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
|
GROUP BY track_id
|
||||||
|
)
|
||||||
SELECT sqlc.embed(t),
|
SELECT sqlc.embed(t),
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM plays p
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN tracks t ON t.id = p.track_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE pe.user_id = $1
|
WHERE NOT EXISTS (
|
||||||
AND pe.was_skipped = false
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
AND NOT EXISTS (
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
)
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
ORDER BY p.cnt DESC, t.id
|
||||||
)
|
|
||||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
|
||||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
|
||||||
albums.title, artists.name
|
|
||||||
ORDER BY count(*) DESC, t.id
|
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
|
|
||||||
-- name: ListLastPlayedArtistsForUser :many
|
-- name: ListLastPlayedArtistsForUser :many
|
||||||
@@ -231,17 +236,28 @@ LIMIT $2;
|
|||||||
-- a derived cover_album_id via a representative-album lateral join (most
|
-- a derived cover_album_id via a representative-album lateral join (most
|
||||||
-- recent album that has cover_art_path set). album_count joined for the
|
-- recent album that has cover_art_path set). album_count joined for the
|
||||||
-- ArtistRef wire shape.
|
-- ArtistRef wire shape.
|
||||||
SELECT sqlc.embed(a),
|
--
|
||||||
cov.id AS cover_album_id,
|
-- Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||||||
cnt.album_count::bigint AS album_count,
|
-- subquery per artist — O(total_artists) plan even for users with a
|
||||||
max_started.started_at::timestamptz AS last_played_at
|
-- handful of plays. New shape aggregates the user's plays by artist
|
||||||
FROM artists a
|
-- via the play_events → tracks join up front (uses
|
||||||
JOIN LATERAL (
|
-- play_events_user_track_idx + tracks pkey lookups), then attaches
|
||||||
SELECT max(pe.started_at) AS started_at
|
-- the artist row and lateral cover/count subqueries only for the
|
||||||
|
-- artists that actually appear. Distinct-artists set is small for a
|
||||||
|
-- typical user, so cost is bounded by play history not library size.
|
||||||
|
WITH user_plays AS (
|
||||||
|
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
WHERE pe.user_id = $1 AND t.artist_id = a.id
|
WHERE pe.user_id = $1
|
||||||
) max_started ON max_started.started_at IS NOT NULL
|
GROUP BY t.artist_id
|
||||||
|
)
|
||||||
|
SELECT sqlc.embed(a),
|
||||||
|
cov.id AS cover_album_id,
|
||||||
|
cnt.album_count::bigint AS album_count,
|
||||||
|
up.last_started::timestamptz AS last_played_at
|
||||||
|
FROM user_plays up
|
||||||
|
JOIN artists a ON a.id = up.artist_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT id FROM albums
|
SELECT id FROM albums
|
||||||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||||||
@@ -251,7 +267,7 @@ LEFT JOIN LATERAL (
|
|||||||
SELECT count(*) AS album_count
|
SELECT count(*) AS album_count
|
||||||
FROM albums WHERE artist_id = a.id
|
FROM albums WHERE artist_id = a.id
|
||||||
) cnt ON true
|
) cnt ON true
|
||||||
ORDER BY max_started.started_at DESC, a.id
|
ORDER BY up.last_started DESC, a.id
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
|
|
||||||
-- name: ListRediscoverAlbumsForUser :many
|
-- name: ListRediscoverAlbumsForUser :many
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'for_you') ?? null
|
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'for_you') ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const discoverPlaylist = $derived(
|
||||||
|
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null
|
||||||
|
);
|
||||||
|
|
||||||
const songsLikePlaylists = $derived(
|
const songsLikePlaylists = $derived(
|
||||||
(systemPlaylistsQ.data?.owned ?? [])
|
(systemPlaylistsQ.data?.owned ?? [])
|
||||||
.filter((p) => p.system_variant === 'songs_like_artist')
|
.filter((p) => p.system_variant === 'songs_like_artist')
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
|
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
|
||||||
|
|
||||||
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
|
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
|
||||||
function placeholderVariant(slot: 'for-you' | 'songs-like'): PlaceholderVariant {
|
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
|
||||||
const s = systemStatusQ.data;
|
const s = systemStatusQ.data;
|
||||||
if (!s) return 'pending';
|
if (!s) return 'pending';
|
||||||
if (s.in_flight) return 'building';
|
if (s.in_flight) return 'building';
|
||||||
@@ -71,7 +75,16 @@
|
|||||||
out.push({ kind: 'placeholder', label: 'For You', variant: placeholderVariant('for-you') });
|
out.push({ kind: 'placeholder', label: 'For You', variant: placeholderVariant('for-you') });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slots 2-4: Songs-like (real first, padded with placeholders).
|
// Slot 2: Discover (real or placeholder). Server emits this with
|
||||||
|
// system_variant='discover' once the recommendation engine has
|
||||||
|
// eligible tracks.
|
||||||
|
if (discoverPlaylist) {
|
||||||
|
out.push({ kind: 'real', playlist: discoverPlaylist });
|
||||||
|
} else {
|
||||||
|
out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slots 3-5: Songs-like (real first, padded with placeholders).
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
if (songsLikePlaylists[i]) {
|
if (songsLikePlaylists[i]) {
|
||||||
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
|
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
|
||||||
|
|||||||
@@ -54,10 +54,11 @@ beforeEach(() => {
|
|||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
describe('home Playlists section', () => {
|
describe('home Playlists section', () => {
|
||||||
test('renders 4 placeholder cards when no playlists exist', () => {
|
test('renders 5 placeholder cards when no playlists exist', () => {
|
||||||
|
// Slot layout: For-You, Discover, 3× Songs-like.
|
||||||
render(Page);
|
render(Page);
|
||||||
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||||
expect(placeholders).toHaveLength(4);
|
expect(placeholders).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('building status sets variant=building on placeholders', () => {
|
test('building status sets variant=building on placeholders', () => {
|
||||||
@@ -103,6 +104,8 @@ describe('home Playlists section', () => {
|
|||||||
render(Page);
|
render(Page);
|
||||||
expect(screen.getByText('For You')).toBeInTheDocument();
|
expect(screen.getByText('For You')).toBeInTheDocument();
|
||||||
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||||
expect(placeholders).toHaveLength(3); // 3 songs-like slots
|
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
|
||||||
|
// real For-You tile.
|
||||||
|
expect(placeholders).toHaveLength(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user