feat(offline): #427 S4b — offline pools on Home beside system tiles
Operator's model: offline, surface the cache-backed pools right where the (now play-disabled, S4a) system playlists sit, so Home still has something to play. - ShuffleSource gains recentlyPlayed() (cache by lastPlayedAt desc, liked included) and liked() (cache ∩ liked set); shared _refs() materializes ordered ids from cached_tracks/artists/albums. Shuffle-all reuses the same recency walk. - Home Playlists row: when offlineProvider is true, prepend two tiles — "Recently played" / "Liked" — sized to PlaylistCard. Tap → shuffle+play that pool from cache; empty → snackbar. System tiles still render (play disabled per S4a) beside them. - offline=false (online + all widget tests) → no extra tiles; placeholder-count tests unaffected. Closes the S4 thread of the #427 umbrella (S4a + S4b). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+63
-26
@@ -1,18 +1,22 @@
|
|||||||
// "Shuffle all" source (#427 S4). Always-present; the pool degrades
|
// Offline play sources over the local cache index (#427 S4).
|
||||||
// gracefully with reachability:
|
//
|
||||||
|
// "Shuffle all" is always-present and degrades with reachability:
|
||||||
// online → GET /api/library/shuffle (random over the whole library)
|
// online → GET /api/library/shuffle (random over the whole library)
|
||||||
// offline → a client shuffle over the entire local cache index
|
// offline → a client shuffle over the entire local cache index
|
||||||
//
|
//
|
||||||
// Offline is a UNION over every cached track regardless of storage
|
// Offline-only pools surfaced on Home beside the (disabled) system
|
||||||
// bucket — liked AND recently-played both included. The two-bucket
|
// playlists: "Recently played" (cache by lastPlayedAt desc) and
|
||||||
// split (S2) is storage/eviction-only and never filters playback,
|
// "Liked" (cache ∩ liked set). All of these are UNIONs over the
|
||||||
// which is exactly the property this relies on.
|
// 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 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../library/library_providers.dart' show libraryApiProvider;
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import 'adapters.dart';
|
import 'adapters.dart';
|
||||||
import 'audio_cache_manager.dart' show appDbProvider;
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
@@ -22,44 +26,77 @@ class ShuffleSource {
|
|||||||
ShuffleSource(this._ref);
|
ShuffleSource(this._ref);
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
/// Returns up to [limit] tracks to play as a shuffle-all. Online:
|
/// Shuffle-all. Online: server-random. Offline: every cached
|
||||||
/// server-random. Offline: every cached track (liked + recent),
|
/// track, shuffled. Empty offline → nothing cached yet.
|
||||||
/// shuffled client-side. Empty offline → nothing is cached yet.
|
|
||||||
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
||||||
if (_ref.read(offlineProvider)) {
|
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);
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
return api.shuffle(limit: limit);
|
return api.shuffle(limit: limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TrackRef>> _offline(int limit) async {
|
/// Cached tracks, most-recently-played first (liked included).
|
||||||
final db = _ref.read(appDbProvider);
|
/// Cache-only — surfaced on Home only when offline.
|
||||||
final cachedIds =
|
Future<List<TrackRef>> recentlyPlayed({int limit = 100}) async {
|
||||||
(await db.select(db.audioCacheIndex).get()).map((r) => r.trackId).toSet();
|
final ids = await _cachedIdsByRecency();
|
||||||
if (cachedIds.isEmpty) return const [];
|
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<List<TrackRef>> liked({int limit = 100}) async {
|
||||||
|
final likedSet =
|
||||||
|
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||||
|
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<List<String>> _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<List<TrackRef>> _refs(List<String> orderedIds) async {
|
||||||
|
if (orderedIds.isEmpty) return const [];
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
final meta = await (db.select(db.cachedTracks)
|
final meta = await (db.select(db.cachedTracks)
|
||||||
..where((t) => t.id.isIn(cachedIds.toList())))
|
..where((t) => t.id.isIn(orderedIds)))
|
||||||
.get();
|
.get();
|
||||||
if (meta.isEmpty) return const [];
|
if (meta.isEmpty) return const [];
|
||||||
|
final byId = {for (final t in meta) t.id: t};
|
||||||
final artistName = {
|
final artistName = {
|
||||||
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
|
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
|
||||||
};
|
};
|
||||||
final albumTitle = {
|
final albumTitle = {
|
||||||
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
|
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
|
||||||
};
|
};
|
||||||
|
return [
|
||||||
final refs = [
|
for (final id in orderedIds)
|
||||||
for (final t in meta)
|
if (byId[id] case final t?)
|
||||||
t.toRef(
|
t.toRef(
|
||||||
artistName: artistName[t.artistId] ?? '',
|
artistName: artistName[t.artistId] ?? '',
|
||||||
albumTitle: albumTitle[t.albumId] ?? '',
|
albumTitle: albumTitle[t.albumId] ?? '',
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
refs.shuffle(Random());
|
|
||||||
return refs.length > limit ? refs.sublist(0, limit) : refs;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
|
import '../cache/offline_provider.dart';
|
||||||
|
import '../cache/shuffle_source.dart';
|
||||||
import '../cache/tile_providers.dart';
|
import '../cache/tile_providers.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import '../models/system_playlists_status.dart';
|
import '../models/system_playlists_status.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
|
import '../player/player_provider.dart';
|
||||||
import '../playlists/playlists_provider.dart';
|
import '../playlists/playlists_provider.dart';
|
||||||
import '../playlists/widgets/playlist_card.dart';
|
import '../playlists/widgets/playlist_card.dart';
|
||||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||||
@@ -63,6 +66,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
_PlaylistsSection(
|
_PlaylistsSection(
|
||||||
playlists: allPlaylists.value?.owned ?? const [],
|
playlists: allPlaylists.value?.owned ?? const [],
|
||||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||||
|
offline: ref.watch(offlineProvider),
|
||||||
),
|
),
|
||||||
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
|
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
|
||||||
_RediscoverSection(
|
_RediscoverSection(
|
||||||
@@ -222,23 +226,124 @@ class _CompactTrackSkeleton extends StatelessWidget {
|
|||||||
// ─── Sections ────────────────────────────────────────────────────────
|
// ─── Sections ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _PlaylistsSection extends StatelessWidget {
|
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<Playlist> playlists;
|
final List<Playlist> playlists;
|
||||||
final SystemPlaylistsStatus status;
|
final SystemPlaylistsStatus status;
|
||||||
|
final bool offline;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final items = _buildPlaylistsRow(playlists, status);
|
final items = _buildPlaylistsRow(playlists, status);
|
||||||
|
final children = <Widget>[
|
||||||
|
// 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(
|
return HorizontalScrollRow(
|
||||||
title: 'Playlists',
|
title: 'Playlists',
|
||||||
height: 220,
|
height: 220,
|
||||||
children: items.map((item) {
|
children: children,
|
||||||
if (item is _RealPlaylist) {
|
);
|
||||||
return PlaylistCard(playlist: item.playlist);
|
}
|
||||||
}
|
}
|
||||||
final ph = item as _PlaceholderPlaylist;
|
|
||||||
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
enum _OfflinePoolKind { recentlyPlayed, liked }
|
||||||
}).toList(),
|
|
||||||
|
/// 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<FabledSwordTheme>()!;
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user