feat(flutter): liked tabs on per-item rendering (Slice E)
The three liked-tab providers now yield ordered lists of entity IDs
(read from cached_likes ORDER BY likedAt DESC). The UI renders
per-tile widgets that hydrate each entity individually via
albumTileProvider / artistTileProvider / trackTileProvider.
fetchAndPopulate dropped from the per-provider bulk endpoints to a
single shared call against /api/likes/ids — much cheaper, and the
tile providers handle entity hydration themselves. The bulk
/api/likes/{tracks,albums,artists} endpoints are no longer in the
Flutter cold path (server keeps serving them for web compat).
Cross-device SSE invalidate paths preserved so cross-device likes
still feel instant. Local LikesController mutations propagate via
cached_likes optimistic writes — same drift watch() route as before.
Tap-to-play on a track row uses currently-hydrated TrackRefs as the
play queue; still-loading tracks are skipped and join on next
rebuild as hydrations land.
This commit is contained in:
@@ -15,6 +15,7 @@ import '../cache/cache_first.dart';
|
|||||||
import '../cache/connectivity_provider.dart';
|
import '../cache/connectivity_provider.dart';
|
||||||
import '../cache/db.dart';
|
import '../cache/db.dart';
|
||||||
import '../cache/metadata_prefetcher.dart';
|
import '../cache/metadata_prefetcher.dart';
|
||||||
|
import '../cache/tile_providers.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';
|
||||||
@@ -28,6 +29,7 @@ import '../player/player_provider.dart';
|
|||||||
import '../quarantine/quarantine_provider.dart';
|
import '../quarantine/quarantine_provider.dart';
|
||||||
import '../shared/live_events_provider.dart';
|
import '../shared/live_events_provider.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
|
import '../shared/widgets/skeletons.dart';
|
||||||
import '../shared/widgets/server_image.dart';
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
@@ -196,190 +198,121 @@ String _encodeHistoryPage(HistoryPage h) => jsonEncode({
|
|||||||
'has_more': h.hasMore,
|
'has_more': h.hasMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drift-first Liked tabs (#357 plan C). Read from cached_likes joined
|
// Per-item Liked tabs (Slice E of the per-item rendering pass).
|
||||||
// against cached_tracks / cached_albums / cached_artists. SyncController
|
// Each provider yields just the ordered list of entity IDs; the UI
|
||||||
// keeps both sides fresh; LikesController mutates cached_likes
|
// then renders per-tile widgets that hydrate each entity individually
|
||||||
// optimistically, so toggling a like re-emits these streams instantly
|
// via albumTileProvider / artistTileProvider / trackTileProvider.
|
||||||
// for snappy UI. REST cold-cache fallback hits /api/likes/* when drift
|
|
||||||
// is empty (fresh install, first sync still pending). SWR refresh on
|
|
||||||
// every visit catches server-side likes the library_changes log hasn't
|
|
||||||
// shipped yet (e.g. a like from another device that fired just before
|
|
||||||
// this screen mounted).
|
|
||||||
//
|
//
|
||||||
// The original FutureProvider versions fetched the first 50 rows; drift
|
// Reads come from cached_likes (sync- and optimistic-write-populated),
|
||||||
// returns everything cached_likes knows about. Pagination can come back
|
// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the
|
||||||
// when liked lists are big enough to matter — typical libraries are
|
// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints
|
||||||
// well under the 50-row ceiling.
|
// that returned fully denormalized entities are no longer needed for
|
||||||
|
// this path since tile providers handle entity hydration themselves.
|
||||||
|
//
|
||||||
|
// likedAt ordering note: cached_likes.likedAt is whatever drift
|
||||||
|
// assigned via currentDateAndTime when the row was first inserted
|
||||||
|
// (either by sync or LikesController). insertOrIgnore on subsequent
|
||||||
|
// fetches preserves the existing likedAt so ordering stays stable.
|
||||||
|
// Approximate but acceptable — matches prior Slice 3 behavior.
|
||||||
|
|
||||||
final _likedTracksProvider = StreamProvider<wire.Paged<TrackRef>>((ref) {
|
List<String> _idsForEntity(List<CachedLike> rows) =>
|
||||||
|
rows.map((r) => r.entityId).toList(growable: false);
|
||||||
|
|
||||||
|
final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
final db = ref.watch(appDbProvider);
|
final db = ref.watch(appDbProvider);
|
||||||
final query = db.select(db.cachedLikes).join([
|
final query = (db.select(db.cachedLikes)
|
||||||
drift.innerJoin(db.cachedTracks,
|
..where((t) => t.entityType.equals('track'))
|
||||||
db.cachedTracks.id.equalsExp(db.cachedLikes.entityId)),
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
drift.leftOuterJoin(db.cachedArtists,
|
.watch();
|
||||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
drift.leftOuterJoin(db.cachedAlbums,
|
driftStream: query,
|
||||||
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
])
|
toResult: _idsForEntity,
|
||||||
..where(db.cachedLikes.entityType.equals('track'))
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||||
..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]);
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedTrackIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return cacheFirst<drift.TypedResult, wire.Paged<TrackRef>>(
|
final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
driftStream: query.watch(),
|
final db = ref.watch(appDbProvider);
|
||||||
fetchAndPopulate: () async {
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('album'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedAlbumIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('artist'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedArtistIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Shared cold-cache populator: hits /api/likes/ids once and writes
|
||||||
|
/// rows for all three entity types via insertOrIgnore. The three
|
||||||
|
/// providers above all trigger this on their first empty-drift
|
||||||
|
/// emission; the dedup in cacheFirst's revalidate state plus drift's
|
||||||
|
/// insertOrIgnore semantics make the multiple-trigger case cheap.
|
||||||
|
Future<void> _populateLikeIds(Ref ref) async {
|
||||||
final api = await ref.read(_likesApiProvider.future);
|
final api = await ref.read(_likesApiProvider.future);
|
||||||
final user = ref.read(authControllerProvider).value;
|
final user = ref.read(authControllerProvider).value;
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
final fresh = await api.listTracks();
|
final fresh = await api.ids();
|
||||||
|
final db = ref.read(appDbProvider);
|
||||||
await db.batch((b) {
|
await db.batch((b) {
|
||||||
// Track metadata via toDrift() — artistName/albumTitle come
|
for (final id in fresh.tracks) {
|
||||||
// from the join, so we don't need to denormalize them here.
|
|
||||||
b.insertAllOnConflictUpdate(
|
|
||||||
db.cachedTracks,
|
|
||||||
fresh.items.map((t) => t.toDrift()).toList(),
|
|
||||||
);
|
|
||||||
// Like rows via insertOrIgnore so existing rows keep their
|
|
||||||
// original likedAt (preserves the ORDER BY likedAt DESC).
|
|
||||||
for (final t in fresh.items) {
|
|
||||||
b.insert(
|
b.insert(
|
||||||
db.cachedLikes,
|
db.cachedLikes,
|
||||||
CachedLikesCompanion.insert(
|
CachedLikesCompanion.insert(
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
entityType: 'track',
|
entityType: 'track',
|
||||||
entityId: t.id,
|
entityId: id,
|
||||||
),
|
),
|
||||||
mode: drift.InsertMode.insertOrIgnore,
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
for (final id in fresh.albums) {
|
||||||
},
|
|
||||||
toResult: (rows) {
|
|
||||||
final tracks = rows.map((r) {
|
|
||||||
final t = r.readTable(db.cachedTracks);
|
|
||||||
final a = r.readTableOrNull(db.cachedArtists);
|
|
||||||
final al = r.readTableOrNull(db.cachedAlbums);
|
|
||||||
return t.toRef(
|
|
||||||
artistName: a?.name ?? '',
|
|
||||||
albumTitle: al?.title ?? '',
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
return wire.Paged(
|
|
||||||
items: tracks,
|
|
||||||
total: tracks.length,
|
|
||||||
limit: tracks.length,
|
|
||||||
offset: 0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
|
||||||
alwaysRefresh: true,
|
|
||||||
tag: 'likedTracks',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final _likedAlbumsProvider = StreamProvider<wire.Paged<AlbumRef>>((ref) {
|
|
||||||
final db = ref.watch(appDbProvider);
|
|
||||||
final query = db.select(db.cachedLikes).join([
|
|
||||||
drift.innerJoin(db.cachedAlbums,
|
|
||||||
db.cachedAlbums.id.equalsExp(db.cachedLikes.entityId)),
|
|
||||||
drift.leftOuterJoin(db.cachedArtists,
|
|
||||||
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
||||||
])
|
|
||||||
..where(db.cachedLikes.entityType.equals('album'))
|
|
||||||
..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]);
|
|
||||||
|
|
||||||
return cacheFirst<drift.TypedResult, wire.Paged<AlbumRef>>(
|
|
||||||
driftStream: query.watch(),
|
|
||||||
fetchAndPopulate: () async {
|
|
||||||
final api = await ref.read(_likesApiProvider.future);
|
|
||||||
final user = ref.read(authControllerProvider).value;
|
|
||||||
if (user == null) return;
|
|
||||||
final fresh = await api.listAlbums();
|
|
||||||
await db.batch((b) {
|
|
||||||
b.insertAllOnConflictUpdate(
|
|
||||||
db.cachedAlbums,
|
|
||||||
fresh.items.map((a) => a.toDrift()).toList(),
|
|
||||||
);
|
|
||||||
for (final a in fresh.items) {
|
|
||||||
b.insert(
|
b.insert(
|
||||||
db.cachedLikes,
|
db.cachedLikes,
|
||||||
CachedLikesCompanion.insert(
|
CachedLikesCompanion.insert(
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
entityType: 'album',
|
entityType: 'album',
|
||||||
entityId: a.id,
|
entityId: id,
|
||||||
),
|
),
|
||||||
mode: drift.InsertMode.insertOrIgnore,
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
for (final id in fresh.artists) {
|
||||||
},
|
|
||||||
toResult: (rows) {
|
|
||||||
final albums = rows.map((r) {
|
|
||||||
final al = r.readTable(db.cachedAlbums);
|
|
||||||
final a = r.readTableOrNull(db.cachedArtists);
|
|
||||||
return al.toRef(artistName: a?.name ?? '');
|
|
||||||
}).toList();
|
|
||||||
return wire.Paged(
|
|
||||||
items: albums,
|
|
||||||
total: albums.length,
|
|
||||||
limit: albums.length,
|
|
||||||
offset: 0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
|
||||||
alwaysRefresh: true,
|
|
||||||
tag: 'likedAlbums',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final _likedArtistsProvider = StreamProvider<wire.Paged<ArtistRef>>((ref) {
|
|
||||||
final db = ref.watch(appDbProvider);
|
|
||||||
final query = db.select(db.cachedLikes).join([
|
|
||||||
drift.innerJoin(db.cachedArtists,
|
|
||||||
db.cachedArtists.id.equalsExp(db.cachedLikes.entityId)),
|
|
||||||
])
|
|
||||||
..where(db.cachedLikes.entityType.equals('artist'))
|
|
||||||
..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]);
|
|
||||||
|
|
||||||
return cacheFirst<drift.TypedResult, wire.Paged<ArtistRef>>(
|
|
||||||
driftStream: query.watch(),
|
|
||||||
fetchAndPopulate: () async {
|
|
||||||
final api = await ref.read(_likesApiProvider.future);
|
|
||||||
final user = ref.read(authControllerProvider).value;
|
|
||||||
if (user == null) return;
|
|
||||||
final fresh = await api.listArtists();
|
|
||||||
await db.batch((b) {
|
|
||||||
b.insertAllOnConflictUpdate(
|
|
||||||
db.cachedArtists,
|
|
||||||
fresh.items.map((a) => a.toDrift()).toList(),
|
|
||||||
);
|
|
||||||
for (final a in fresh.items) {
|
|
||||||
b.insert(
|
b.insert(
|
||||||
db.cachedLikes,
|
db.cachedLikes,
|
||||||
CachedLikesCompanion.insert(
|
CachedLikesCompanion.insert(
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
entityType: 'artist',
|
entityType: 'artist',
|
||||||
entityId: a.id,
|
entityId: id,
|
||||||
),
|
),
|
||||||
mode: drift.InsertMode.insertOrIgnore,
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
toResult: (rows) {
|
|
||||||
final artists =
|
|
||||||
rows.map((r) => r.readTable(db.cachedArtists).toRef()).toList();
|
|
||||||
return wire.Paged(
|
|
||||||
items: artists,
|
|
||||||
total: artists.length,
|
|
||||||
limit: artists.length,
|
|
||||||
offset: 0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
|
||||||
alwaysRefresh: true,
|
|
||||||
tag: 'likedArtists',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
||||||
// optimistic flag/unflag) so flagging from any kebab and unflagging from
|
// optimistic flag/unflag) so flagging from any kebab and unflagging from
|
||||||
@@ -616,10 +549,11 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
@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>()!;
|
||||||
// #402 wire-up: when any like/unlike event arrives via SSE,
|
// SSE wire-up: any cross-device like / unlike triggers a refresh
|
||||||
// invalidate all three Liked sub-lists. Server-side events are
|
// of the discovery providers. LikesController handles local
|
||||||
// already user-scoped (publishLikeEvent attaches user_id), so the
|
// mutations optimistically through the same cached_likes table,
|
||||||
// dispatcher filters out other users' events upstream.
|
// so toggling a like locally re-emits the streams instantly
|
||||||
|
// without needing an invalidate here.
|
||||||
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
||||||
final e = next.asData?.value;
|
final e = next.asData?.value;
|
||||||
if (e == null) return;
|
if (e == null) return;
|
||||||
@@ -630,84 +564,67 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
case 'album.unliked':
|
case 'album.unliked':
|
||||||
case 'artist.liked':
|
case 'artist.liked':
|
||||||
case 'artist.unliked':
|
case 'artist.unliked':
|
||||||
ref.invalidate(_likedTracksProvider);
|
ref.invalidate(_likedTrackIdsProvider);
|
||||||
ref.invalidate(_likedAlbumsProvider);
|
ref.invalidate(_likedAlbumIdsProvider);
|
||||||
ref.invalidate(_likedArtistsProvider);
|
ref.invalidate(_likedArtistIdsProvider);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final tracksA = ref.watch(_likedTracksProvider);
|
final tracksA = ref.watch(_likedTrackIdsProvider);
|
||||||
final albumsA = ref.watch(_likedAlbumsProvider);
|
final albumsA = ref.watch(_likedAlbumIdsProvider);
|
||||||
final artistsA = ref.watch(_likedArtistsProvider);
|
final artistsA = ref.watch(_likedArtistIdsProvider);
|
||||||
|
|
||||||
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
final t = tracksA.value;
|
final trackIds = tracksA.value;
|
||||||
final al = albumsA.value;
|
final albumIds = albumsA.value;
|
||||||
final ar = artistsA.value;
|
final artistIds = artistsA.value;
|
||||||
if (t == null || al == null || ar == null) {
|
if (trackIds == null || albumIds == null || artistIds == null) {
|
||||||
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||||
}
|
}
|
||||||
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) {
|
||||||
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
ref.refresh(_likedTracksProvider.future),
|
ref.refresh(_likedTrackIdsProvider.future),
|
||||||
ref.refresh(_likedAlbumsProvider.future),
|
ref.refresh(_likedAlbumIdsProvider.future),
|
||||||
ref.refresh(_likedArtistsProvider.future),
|
ref.refresh(_likedArtistIdsProvider.future),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
child: ListView(children: [
|
child: ListView(children: [
|
||||||
if (ar.items.isNotEmpty) ...[
|
if (artistIds.isNotEmpty) ...[
|
||||||
_SectionHeader(label: 'Artists', count: ar.total),
|
_SectionHeader(label: 'Artists', count: artistIds.length),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 168,
|
height: 168,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: ar.items.length,
|
itemCount: artistIds.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) =>
|
||||||
final artist = ar.items[i];
|
_LikedArtistTile(id: artistIds[i]),
|
||||||
return ArtistCard(
|
|
||||||
artist: artist,
|
|
||||||
onTap: () =>
|
|
||||||
ctx.push('/artists/${artist.id}', extra: artist),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (al.items.isNotEmpty) ...[
|
if (albumIds.isNotEmpty) ...[
|
||||||
_SectionHeader(label: 'Albums', count: al.total),
|
_SectionHeader(label: 'Albums', count: albumIds.length),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: al.items.length,
|
itemCount: albumIds.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) =>
|
||||||
final album = al.items[i];
|
_LikedAlbumTile(id: albumIds[i]),
|
||||||
return AlbumCard(
|
|
||||||
album: album,
|
|
||||||
onTap: () =>
|
|
||||||
ctx.push('/albums/${album.id}', extra: album),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (t.items.isNotEmpty) ...[
|
if (trackIds.isNotEmpty) ...[
|
||||||
_SectionHeader(label: 'Tracks', count: t.total),
|
_SectionHeader(label: 'Tracks', count: trackIds.length),
|
||||||
...t.items.asMap().entries.map((e) {
|
...trackIds.map(
|
||||||
return TrackRow(
|
(id) => _LikedTrackRow(id: id, sectionIds: trackIds),
|
||||||
track: e.value,
|
),
|
||||||
onTap: () => ref
|
|
||||||
.read(playerActionsProvider)
|
|
||||||
.playTracks(t.items, initialIndex: e.key),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 96),
|
const SizedBox(height: 96),
|
||||||
]),
|
]),
|
||||||
@@ -715,6 +632,71 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liked-Artists carousel tile. Skeleton until artistTileProvider
|
||||||
|
/// yields a populated row.
|
||||||
|
class _LikedArtistTile extends ConsumerWidget {
|
||||||
|
const _LikedArtistTile({required this.id});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final artist = ref.watch(artistTileProvider(id)).asData?.value;
|
||||||
|
if (artist == null) return const SkeletonArtistTile();
|
||||||
|
return ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
onTap: () => context.push('/artists/${artist.id}', extra: artist),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liked-Albums carousel tile. Skeleton until albumTileProvider
|
||||||
|
/// yields a populated row.
|
||||||
|
class _LikedAlbumTile extends ConsumerWidget {
|
||||||
|
const _LikedAlbumTile({required this.id});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final album = ref.watch(albumTileProvider(id)).asData?.value;
|
||||||
|
if (album == null) return const SkeletonAlbumTile();
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
onTap: () => context.push('/albums/${album.id}', extra: album),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liked-Tracks list row. Skeleton until trackTileProvider yields a
|
||||||
|
/// populated row. Tap plays the section starting at this track,
|
||||||
|
/// using whichever tracks are currently hydrated; still-loading
|
||||||
|
/// tracks are skipped from the play queue and join on next rebuild.
|
||||||
|
class _LikedTrackRow extends ConsumerWidget {
|
||||||
|
const _LikedTrackRow({required this.id, required this.sectionIds});
|
||||||
|
final String id;
|
||||||
|
final List<String> sectionIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final track = ref.watch(trackTileProvider(id)).asData?.value;
|
||||||
|
if (track == null) return const SkeletonTrackRow();
|
||||||
|
return TrackRow(
|
||||||
|
track: track,
|
||||||
|
onTap: () {
|
||||||
|
final hydrated = <TrackRef>[];
|
||||||
|
for (final i in sectionIds) {
|
||||||
|
final v = ref.read(trackTileProvider(i)).asData?.value;
|
||||||
|
if (v != null) hydrated.add(v);
|
||||||
|
}
|
||||||
|
final start = hydrated.indexWhere((t) => t.id == id);
|
||||||
|
ref.read(playerActionsProvider).playTracks(
|
||||||
|
hydrated,
|
||||||
|
initialIndex: start < 0 ? 0 : start,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _HiddenTab extends ConsumerWidget {
|
class _HiddenTab extends ConsumerWidget {
|
||||||
const _HiddenTab();
|
const _HiddenTab();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user