diff --git a/flutter_client/lib/cache/cache_first.dart b/flutter_client/lib/cache/cache_first.dart index ad4e3beb..8d24d11b 100644 --- a/flutter_client/lib/cache/cache_first.dart +++ b/flutter_client/lib/cache/cache_first.dart @@ -19,6 +19,10 @@ import 'dart:async'; 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: /// D — the drift row type (or TypedResult for joins) /// T — the result type the caller wants (e.g. `List`) @@ -43,13 +47,9 @@ Stream cacheFirst({ // refresh for this stream subscription, so we don't fire one on every // drift re-emission (otherwise the populate cycles forever). var revalidated = false; - void log(String msg) { - if (tag != null) debugPrint('cacheFirst[$tag]: $msg'); - } await for (final rows in driftStream) { if (rows.isNotEmpty) { - log('drift hit (${rows.length} rows)'); yield toResult(rows); // Stale-while-revalidate: yield cache immediately, then kick off // a REST refresh in the background. Drift watch() picks up the @@ -62,20 +62,18 @@ Stream cacheFirst({ } continue; } - log('drift miss; checking connectivity'); if (await isOnline()) { - log('online; calling fetchAndPopulate'); try { await fetchAndPopulate(); - log('fetchAndPopulate done; awaiting drift re-emit'); // The drift watch() stream re-emits with the populated rows on // the next loop iteration. Don't yield here. } 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 } } else { - log('offline; yielding empty'); yield toResult(rows); // empty result; offline } } diff --git a/flutter_client/lib/cache/metadata_prefetcher.dart b/flutter_client/lib/cache/metadata_prefetcher.dart index 2eef60cb..7acee535 100644 --- a/flutter_client/lib/cache/metadata_prefetcher.dart +++ b/flutter_client/lib/cache/metadata_prefetcher.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../library/library_providers.dart'; @@ -38,7 +37,6 @@ class MetadataPrefetcher { if (n++ >= _topN) break; _swallow(_ref.read(artistProvider(id).future)); } - if (n > 0) debugPrint('metadataPrefetcher: warming $n artists'); } void _warmHome(HomeData h) { diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart index 3f624418..9f83ec38 100644 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ b/flutter_client/lib/library/artist_detail_screen.dart @@ -95,8 +95,6 @@ class ArtistDetailScreen extends ConsumerWidget { try { final tracks = await ref.read(artistTracksProvider(id).future); - debugPrint( - 'artist_detail: play tapped — ${tracks.length} tracks for $id'); if (tracks.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index bf455251..41dc5d04 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -135,7 +135,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow( ? _RealPlaylist(forYou) : _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 .where((p) => p.systemVariant == 'songs_like_artist') .take(3) diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index 28e0a296..9482c05c 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -101,6 +101,14 @@ final artistAlbumsProvider = isOnline: () async => (await ref .read(connectivityProvider.future) .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, tag: 'artistAlbums($artistId)', ); }); @@ -187,11 +195,9 @@ final albumProvider = StreamProvider.family< Future fetchAndPopulate() async { try { final api = await ref.read(libraryApiProvider.future); - debugPrint('albumProvider($albumId): calling getAlbum'); final fresh = await api .getAlbum(albumId) .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 // the JOINs that drive both the album header and the track rows // have something to bind to. Without this, drift returns null @@ -224,7 +230,6 @@ final albumProvider = StreamProvider.family< b.insertAllOnConflictUpdate(db.cachedTracks, fresh.tracks.map((t) => t.toDrift()).toList()); }); - debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit'); return true; } catch (e, st) { debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st'); @@ -235,7 +240,6 @@ final albumProvider = StreamProvider.family< await for (final albumRows in albumQuery.watch()) { // Case 1: no album row at all → cold-fetch. if (albumRows.isEmpty) { - debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch'); if (fetchAttempted) { // Already tried and got nothing back. yield ( @@ -245,10 +249,7 @@ final albumProvider = StreamProvider.family< continue; } fetchAttempted = true; - final online = await isOnline(); - debugPrint('albumProvider($albumId): online=$online'); - if (!online) { - debugPrint('albumProvider($albumId): offline, yielding empty'); + if (!await isOnline()) { yield ( album: const AlbumRef(id: '', title: '', artistId: ''), tracks: const [], @@ -265,7 +266,6 @@ final albumProvider = StreamProvider.family< // On success, drift watch re-emits with rows; loop continues. continue; } - debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)'); final albumRow = albumRows.first; final album = albumRow.readTable(db.cachedAlbums).toRef( @@ -280,7 +280,6 @@ final albumProvider = StreamProvider.family< // fetchAndPopulate so the album becomes complete; drift watch // re-emits and we land in the populated branch on the next pass. if (trackRows.isEmpty && !fetchAttempted) { - debugPrint('albumProvider($albumId): album hit but no tracks; fetching'); fetchAttempted = true; if (await isOnline()) { final ok = await fetchAndPopulate(); diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 3bade142..db622187 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -33,15 +33,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl // the index and the eviction loop can't reclaim them. _player.bufferedPositionStream .listen((_) => unawaited(_maybeRegisterStreamCache())); - // Diagnostic: log every player-state transition so silent stops - // surface as something we can read in the log. - _player.playerStateStream.listen((s) { - debugPrint( - 'audio_handler: state playing=${s.playing} processing=${s.processingState}'); - }); - _player.processingStateStream.listen((ps) { - debugPrint('audio_handler: processingState=$ps'); - }); } final AudioPlayer _player = AudioPlayer(); @@ -60,6 +51,23 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl /// 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 directly; set via setVolume(double). Stream get volumeStream => _player.volumeStream; @@ -83,38 +91,86 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl _token = token; if (coverCache != null) _coverCache = coverCache; if (audioCacheManager != null) _audioCacheManager = audioCacheManager; - debugPrint('audio_handler.configure: baseUrl="$baseUrl" ' - 'tokenPresent=${token != null && token.isNotEmpty} ' - 'coverCachePresent=${_coverCache != null} ' - 'audioCachePresent=${_audioCacheManager != null}'); } Future setQueueFromTracks(List 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(); queue.add(items); - if (items.isNotEmpty) { - mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]); + mediaItem.add(items[clampedInitial]); + + // 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: ' - '_baseUrl="$_baseUrl" trackCount=${tracks.length}'); - final sw = Stopwatch()..start(); - final sources = await Future.wait(tracks.map(_buildAudioSource)); - debugPrint('audio_handler: built ${sources.length} sources in ' - '${sw.elapsedMilliseconds}ms'); - sw.reset(); - sw.start(); + await _player.setAudioSources([initial], initialIndex: 0); - await _player.setAudioSources( - sources, - initialIndex: initialIndex, - ); - debugPrint('audio_handler: setAudioSources ${sw.elapsedMilliseconds}ms'); - - // 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(_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 _fillRemainingSources( + List 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) { @@ -143,7 +199,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl if (mgr != null) { final path = await mgr.pathFor(t.id); if (path != null) { - debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path'); return AudioSource.uri(Uri.file(path)); } } @@ -169,15 +224,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl if (mgr != null) { _cacheDirPath ??= (await getApplicationCacheDirectory()).path; 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 return LockCachingAudioSource(parsed, headers: headers, cacheFile: cacheFile); } // 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); } @@ -253,12 +305,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl _streamCacheRegistered.add(trackId); await mgr.registerStreamCache(trackId, path, size); - debugPrint( - 'audio_handler: registered stream cache for $trackId ($size bytes)'); } void _onCurrentIndexChanged(int? idx) { if (idx == null) return; + if (_suppressIndexUpdates) return; // Push the new track's MediaItem onto the mediaItem stream so // the player UI rebuilds with the new title/artist/album/cover. // Without this, the bar and full player stayed pinned to whichever diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index 804c4ef3..e04a2d64 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -397,6 +397,17 @@ class _SecondaryControls extends StatelessWidget { 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); + }, ), ], ); diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index 47e51879..baca1af3 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -1,5 +1,4 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/radio.dart'; @@ -54,22 +53,8 @@ class PlayerActions { final Ref _ref; Future playTracks(List tracks, {int initialIndex = 0}) async { - // Stage-by-stage timing so we can see exactly where the - // "tap → audio" delay lives. Look for "playTracks: ..." lines - // in the next log capture; each stage label is followed by its - // elapsed millis since the previous stage. - final sw = Stopwatch()..start(); - int lap() { - final ms = sw.elapsedMilliseconds; - sw.reset(); - sw.start(); - return ms; - } - final url = await _ref.read(serverUrlProvider.future); - debugPrint('playTracks: serverUrl ${lap()}ms'); final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); - debugPrint('playTracks: token ${lap()}ms'); final cache = _ref.read(albumCoverCacheProvider); final audioCache = _ref.read(audioCacheManagerProvider); final h = _ref.read(audioHandlerProvider) @@ -79,11 +64,8 @@ class PlayerActions { coverCache: cache, audioCacheManager: audioCache, ); - debugPrint('playTracks: configure ${lap()}ms'); await h.setQueueFromTracks(tracks, initialIndex: initialIndex); - debugPrint('playTracks: setQueue (${tracks.length} tracks) ${lap()}ms'); await h.play(); - debugPrint('playTracks: play() returned ${lap()}ms'); } Future playNext(TrackRef track) async { diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index 468ce5a2..37d7ce9a 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -12,13 +12,26 @@ import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; class PlaylistDetailScreen extends ConsumerWidget { - const PlaylistDetailScreen({required this.id, super.key}); + const PlaylistDetailScreen({required this.id, this.seed, super.key}); 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 Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; 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( backgroundColor: fs.obsidian, appBar: AppBar( @@ -28,17 +41,21 @@ class PlaylistDetailScreen extends ConsumerWidget { icon: Icon(Icons.arrow_back, color: fs.parchment), onPressed: () => context.pop(), ), - title: detail.maybeWhen( - data: (d) => Text( - d.playlist.name, - style: TextStyle(color: fs.parchment), - overflow: TextOverflow.ellipsis, - ), - orElse: () => const SizedBox.shrink(), - ), + title: headerName.isEmpty + ? const SizedBox.shrink() + : Text( + headerName, + style: TextStyle(color: fs.parchment), + overflow: TextOverflow.ellipsis, + ), ), 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, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), 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()!; + 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 { const _Body({required this.detail}); final PlaylistDetail detail; diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart index 7cf4172a..bfddbf9a 100644 --- a/flutter_client/lib/playlists/playlists_list_screen.dart +++ b/flutter_client/lib/playlists/playlists_list_screen.dart @@ -51,7 +51,7 @@ class PlaylistsListScreen extends ConsumerWidget { final p = items[i]; return _PlaylistTile( playlist: p, - onTap: () => ctx.push('/playlists/${p.id}'), + onTap: () => ctx.push('/playlists/${p.id}', extra: p), ); }, ), diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index dc4105d0..9b4e9c64 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -40,11 +40,6 @@ final playlistsListProvider = fetchAndPopulate: () async { final api = await ref.read(playlistsApiProvider.future); final fresh = await api.list(kind: kind); - final ownedSysCount = - fresh.owned.where((p) => p.systemVariant != null).length; - debugPrint( - 'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} ' - '(system=$ownedSysCount) public=${fresh.public.length}'); // Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs // every rebuild, so insertOrReplace alone leaves stale rows in @@ -81,13 +76,6 @@ final playlistsListProvider = .where((r) => r.userId != user.id && r.isPublic) .map((r) => r.toRef()) .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); }, isOnline: () async => (await ref @@ -153,10 +141,7 @@ final playlistDetailProvider = Future fetchAndPopulate() async { try { final api = await ref.read(playlistsApiProvider.future); - debugPrint('playlistDetailProvider($id): calling get'); final fresh = await api.get(id).timeout(const Duration(seconds: 10)); - debugPrint( - 'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing'); // Collect the track + artist + album rows referenced by these // playlist entries. Without writing the cachedTracks rows @@ -262,8 +247,6 @@ final playlistDetailProvider = ); } }); - debugPrint( - 'playlistDetailProvider($id): drift write done; awaiting watch re-emit'); return true; } catch (e, st) { // 404 = the playlist row in drift is stale (BuildSystemPlaylists @@ -272,8 +255,6 @@ final playlistDetailProvider = // 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) { - debugPrint( - 'playlistDetailProvider($id): server says 404, evicting stale drift rows'); await db.batch((b) { b.deleteWhere( db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); @@ -292,7 +273,6 @@ final playlistDetailProvider = await for (final playlistRows in playlistQuery.watch()) { if (playlistRows.isEmpty) { - debugPrint('playlistDetailProvider($id): drift miss, cold-fetching'); if (fetchAttempted) { yield emptyDetail(); continue; @@ -307,9 +287,6 @@ final playlistDetailProvider = continue; } - debugPrint( - 'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)'); - final playlist = playlistRows.first.toRef(); final trackRows = await tracksQuery.get(); @@ -318,8 +295,6 @@ final playlistDetailProvider = // tracks for system playlists). Trigger the same fetch, drift // watch re-emits with populated tracks. if (trackRows.isEmpty && !fetchAttempted) { - debugPrint( - 'playlistDetailProvider($id): playlist hit but no tracks; fetching'); fetchAttempted = true; if (await isOnline()) { final ok = await fetchAndPopulate(); diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index 060351a4..515bd55d 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -21,7 +21,8 @@ class PlaylistCard extends StatelessWidget { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => context.push('/playlists/${playlist.id}'), + onTap: () => + context.push('/playlists/${playlist.id}', extra: playlist), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 73c3eac5..cc29e387 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -9,6 +9,7 @@ import '../library/album_detail_screen.dart'; import '../library/artist_detail_screen.dart'; import '../models/album.dart'; import '../models/artist.dart'; +import '../models/playlist.dart'; import '../discover/discover_screen.dart'; import '../library/home_screen.dart'; import '../library/library_screen.dart'; @@ -120,7 +121,10 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), GoRoute( 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: '/admin', builder: (_, __) => const AdminLandingScreen()), diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart index cd293209..6937e9a9 100644 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart +++ b/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart @@ -11,6 +11,7 @@ class TrackActionsButton extends StatelessWidget { super.key, required this.track, this.hideQueueActions = false, + this.onNavigate, }); final TrackRef track; @@ -19,6 +20,11 @@ class TrackActionsButton extends StatelessWidget { /// screen where the menu's track IS the currently-playing one. 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 Function(String path)? onNavigate; + @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; @@ -29,6 +35,7 @@ class TrackActionsButton extends StatelessWidget { context, track, hideQueueActions: hideQueueActions, + onNavigate: onNavigate, ), ); } diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart index c3adaf7e..bb5aeef5 100644 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart +++ b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart @@ -21,15 +21,27 @@ class TrackActionsSheet extends ConsumerWidget { super.key, required this.track, required this.hideQueueActions, + this.onNavigate, }); final TrackRef track; 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 Function(String path)? onNavigate; + static Future show( BuildContext context, TrackRef track, { bool hideQueueActions = false, + Future Function(String path)? onNavigate, }) { return showModalBottomSheet( context: context, @@ -37,6 +49,7 @@ class TrackActionsSheet extends ConsumerWidget { builder: (_) => TrackActionsSheet( track: track, hideQueueActions: hideQueueActions, + onNavigate: onNavigate, ), ); } @@ -141,7 +154,12 @@ class TrackActionsSheet extends ConsumerWidget { label: 'Go to album', onTap: () { Navigator.pop(context); - context.push('/albums/${track.albumId}'); + final path = '/albums/${track.albumId}'; + if (onNavigate != null) { + onNavigate!(path); + } else { + context.push(path); + } }, ), _MenuItem( @@ -150,7 +168,12 @@ class TrackActionsSheet extends ConsumerWidget { label: 'Go to artist', onTap: () { Navigator.pop(context); - context.push('/artists/${track.artistId}'); + final path = '/artists/${track.artistId}'; + if (onNavigate != null) { + onNavigate!(path); + } else { + context.push(path); + } }, ), const _Divider(), diff --git a/internal/api/media.go b/internal/api/media.go index 1e0e7d8e..0ff6cbbb 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -112,6 +112,12 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) { return } 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) } @@ -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("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) } diff --git a/internal/api/playlists.go b/internal/api/playlists.go index 4c8079bb..4d845cee 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -386,6 +386,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request return } 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) } diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 1ecdfb48..5649a904 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -39,6 +39,10 @@ (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( (systemPlaylistsQ.data?.owned ?? []) .filter((p) => p.system_variant === 'songs_like_artist') @@ -48,7 +52,7 @@ const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []); 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; if (!s) return 'pending'; if (s.in_flight) return 'building'; @@ -71,7 +75,16 @@ 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++) { if (songsLikePlaylists[i]) { out.push({ kind: 'real', playlist: songsLikePlaylists[i] }); diff --git a/web/src/routes/page.test.ts b/web/src/routes/page.test.ts index 10a5466e..884dd067 100644 --- a/web/src/routes/page.test.ts +++ b/web/src/routes/page.test.ts @@ -54,10 +54,11 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()); 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); const placeholders = screen.queryAllByTestId('playlist-placeholder-card'); - expect(placeholders).toHaveLength(4); + expect(placeholders).toHaveLength(5); }); test('building status sets variant=building on placeholders', () => { @@ -103,6 +104,8 @@ describe('home Playlists section', () => { render(Page); expect(screen.getByText('For You')).toBeInTheDocument(); 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); }); });