2d9775244c
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>
105 lines
3.9 KiB
Dart
105 lines
3.9 KiB
Dart
// 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-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;
|
|
import 'offline_provider.dart';
|
|
|
|
class ShuffleSource {
|
|
ShuffleSource(this._ref);
|
|
final Ref _ref;
|
|
|
|
/// Shuffle-all. Online: server-random. Offline: every cached
|
|
/// track, shuffled. Empty offline → nothing cached yet.
|
|
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
|
if (_ref.read(offlineProvider)) {
|
|
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);
|
|
}
|
|
|
|
/// Cached tracks, most-recently-played first (liked included).
|
|
/// Cache-only — surfaced on Home only when offline.
|
|
Future<List<TrackRef>> 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<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)
|
|
..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
|
|
};
|
|
return [
|
|
for (final id in orderedIds)
|
|
if (byId[id] case final t?)
|
|
t.toRef(
|
|
artistName: artistName[t.artistId] ?? '',
|
|
albumTitle: albumTitle[t.albumId] ?? '',
|
|
)
|
|
];
|
|
}
|
|
}
|
|
|
|
final shuffleSourceProvider =
|
|
Provider<ShuffleSource>((ref) => ShuffleSource(ref));
|