diff --git a/flutter_client/lib/cache/shuffle_source.dart b/flutter_client/lib/cache/shuffle_source.dart index 45c68919..e42d9767 100644 --- a/flutter_client/lib/cache/shuffle_source.dart +++ b/flutter_client/lib/cache/shuffle_source.dart @@ -1,18 +1,22 @@ -// "Shuffle all" source (#427 S4). Always-present; the pool degrades -// gracefully with reachability: +// Offline play sources over the local cache index (#427 S4). +// +// "Shuffle all" is always-present and degrades with reachability: // online → GET /api/library/shuffle (random over the whole library) // offline → a client shuffle over the entire local cache index // -// Offline is a UNION over every cached track regardless of storage -// bucket — liked AND recently-played both included. The two-bucket -// split (S2) is storage/eviction-only and never filters playback, -// which is exactly the property this relies on. +// Offline-only pools surfaced on Home beside the (disabled) system +// playlists: "Recently played" (cache by lastPlayedAt desc) and +// "Liked" (cache ∩ liked set). All of these are UNIONs over the +// cache regardless of storage bucket — liked AND recently-played +// both included. The two-bucket split (S2) is storage/eviction-only +// and never filters playback, which is exactly what this relies on. import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../library/library_providers.dart' show libraryApiProvider; +import '../likes/likes_provider.dart' show likedIdsProvider; import '../models/track.dart'; import 'adapters.dart'; import 'audio_cache_manager.dart' show appDbProvider; @@ -22,44 +26,77 @@ class ShuffleSource { ShuffleSource(this._ref); final Ref _ref; - /// Returns up to [limit] tracks to play as a shuffle-all. Online: - /// server-random. Offline: every cached track (liked + recent), - /// shuffled client-side. Empty offline → nothing is cached yet. + /// Shuffle-all. Online: server-random. Offline: every cached + /// track, shuffled. Empty offline → nothing cached yet. Future> tracks({int limit = 100}) async { if (_ref.read(offlineProvider)) { - return _offline(limit); + final ids = await _cachedIdsByRecency(); + final list = await _refs(ids); + list.shuffle(Random()); + return list.length > limit ? list.sublist(0, limit) : list; } final api = await _ref.read(libraryApiProvider.future); return api.shuffle(limit: limit); } - Future> _offline(int limit) async { - final db = _ref.read(appDbProvider); - final cachedIds = - (await db.select(db.audioCacheIndex).get()).map((r) => r.trackId).toSet(); - if (cachedIds.isEmpty) return const []; + /// Cached tracks, most-recently-played first (liked included). + /// Cache-only — surfaced on Home only when offline. + Future> recentlyPlayed({int limit = 100}) async { + final ids = await _cachedIdsByRecency(); + final list = await _refs(ids); + return list.length > limit ? list.sublist(0, limit) : list; + } + /// Cached tracks that are in the user's liked set. Cache-only — + /// surfaced on Home only when offline. + Future> liked({int limit = 100}) async { + final likedSet = + _ref.read(likedIdsProvider).value?.tracks ?? const {}; + final ids = + (await _cachedIdsByRecency()).where(likedSet.contains).toList(); + final list = await _refs(ids); + return list.length > limit ? list.sublist(0, limit) : list; + } + + /// Cached track ids ordered by lastPlayedAt desc (nulls last so + /// never-touched downloads sort after real plays). + Future> _cachedIdsByRecency() async { + final db = _ref.read(appDbProvider); + final rows = await db.select(db.audioCacheIndex).get(); + rows.sort((a, b) { + final av = a.lastPlayedAt, bv = b.lastPlayedAt; + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + return bv.compareTo(av); + }); + return rows.map((r) => r.trackId).toList(); + } + + /// Materializes ordered track ids into TrackRefs from the cached + /// metadata tables, preserving the given order. + Future> _refs(List orderedIds) async { + if (orderedIds.isEmpty) return const []; + final db = _ref.read(appDbProvider); final meta = await (db.select(db.cachedTracks) - ..where((t) => t.id.isIn(cachedIds.toList()))) + ..where((t) => t.id.isIn(orderedIds))) .get(); if (meta.isEmpty) return const []; - + final byId = {for (final t in meta) t.id: t}; final artistName = { for (final a in await db.select(db.cachedArtists).get()) a.id: a.name }; final albumTitle = { for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title }; - - final refs = [ - for (final t in meta) - t.toRef( - artistName: artistName[t.artistId] ?? '', - albumTitle: albumTitle[t.albumId] ?? '', - ) + return [ + for (final id in orderedIds) + if (byId[id] case final t?) + t.toRef( + artistName: artistName[t.artistId] ?? '', + albumTitle: albumTitle[t.albumId] ?? '', + ) ]; - refs.shuffle(Random()); - return refs.length > limit ? refs.sublist(0, limit) : refs; } } diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index 968ed772..a3eff3dc 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -4,10 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/errors.dart'; +import '../cache/offline_provider.dart'; +import '../cache/shuffle_source.dart'; import '../cache/tile_providers.dart'; import '../models/playlist.dart'; import '../models/system_playlists_status.dart'; import '../models/track.dart'; +import '../player/player_provider.dart'; import '../playlists/playlists_provider.dart'; import '../playlists/widgets/playlist_card.dart'; import '../playlists/widgets/playlist_placeholder_card.dart'; @@ -63,6 +66,7 @@ class HomeScreen extends ConsumerWidget { _PlaylistsSection( playlists: allPlaylists.value?.owned ?? const [], status: status.value ?? SystemPlaylistsStatus.empty(), + offline: ref.watch(offlineProvider), ), _RecentlyAddedSection(ids: h.recentlyAddedAlbums), _RediscoverSection( @@ -222,23 +226,124 @@ class _CompactTrackSkeleton extends StatelessWidget { // ─── Sections ──────────────────────────────────────────────────────── class _PlaylistsSection extends StatelessWidget { - const _PlaylistsSection({required this.playlists, required this.status}); + const _PlaylistsSection({ + required this.playlists, + required this.status, + required this.offline, + }); final List playlists; final SystemPlaylistsStatus status; + final bool offline; @override Widget build(BuildContext context) { final items = _buildPlaylistsRow(playlists, status); + final children = [ + // Offline: surface the cache-backed pools where the (now + // play-disabled) system playlists sit, so there's something + // to play. #427 S4b. + if (offline) ...const [ + _OfflinePoolCard( + label: 'Recently played', + icon: Icons.history, + kind: _OfflinePoolKind.recentlyPlayed, + ), + _OfflinePoolCard( + label: 'Liked', + icon: Icons.favorite, + kind: _OfflinePoolKind.liked, + ), + ], + for (final item in items) + if (item is _RealPlaylist) + PlaylistCard(playlist: item.playlist) + else + PlaylistPlaceholderCard( + label: (item as _PlaceholderPlaylist).label, + variant: item.variant, + ), + ]; return HorizontalScrollRow( title: 'Playlists', height: 220, - children: items.map((item) { - if (item is _RealPlaylist) { - return PlaylistCard(playlist: item.playlist); - } - final ph = item as _PlaceholderPlaylist; - return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant); - }).toList(), + children: children, + ); + } +} + +enum _OfflinePoolKind { recentlyPlayed, liked } + +/// Home tile for an offline cache-backed pool. Tapping shuffles + +/// plays that pool from the local cache. Sized to match +/// PlaylistCard so the row stays visually consistent. +class _OfflinePoolCard extends ConsumerWidget { + const _OfflinePoolCard({ + required this.label, + required this.icon, + required this.kind, + }); + final String label; + final IconData icon; + final _OfflinePoolKind kind; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return SizedBox( + width: 176, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + final src = ref.read(shuffleSourceProvider); + final refs = await switch (kind) { + _OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(), + _OfflinePoolKind.liked => src.liked(), + }; + if (refs.isEmpty) { + messenger.showSnackBar( + SnackBar(content: Text('No cached $label tracks yet')), + ); + return; + } + await ref + .read(playerActionsProvider) + .playTracks(refs, shuffle: true); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 144, + height: 144, + decoration: BoxDecoration( + color: fs.slate, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, color: fs.accent, size: 56), + ), + const SizedBox(height: 8), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'Offline', + style: TextStyle(color: fs.ash, fontSize: 11), + ), + ), + ], + ), + ), + ), + ), ); } }