// 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> 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> 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(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((ref) => ShuffleSource(ref));