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