feat(offline): #427 S4a — Shuffle all + offline system-playlist gate
Headline of S4. "Shuffle all" is always present (home app-bar shuffle icon); the pool degrades with reachability: - online → GET /api/library/shuffle?limit=N (new): N random library tracks server-side, per-user quarantine filtered (ListRandomTracksForUser, ORDER BY random()). - offline → ShuffleSource shuffles the whole local cache index (audio_cache_index ∩ cached_tracks, names from cached_artists/ albums) — a UNION over liked AND recently-played, since the two-bucket split is storage-only and never filters playback. Offline gating: refreshable (singleton) system playlists need the live build/shuffle endpoints, so their tile play is disabled when offlineProvider is true — Shuffle all is the offline path. User playlists still play from cache. playlist_card now reads offlineProvider in build → wrapped the direct-render widget test in ProviderScope (offlineProvider is smoke-safe from S1: tracked timers, no connectivity mount). S4b (offline Recently-played / Liked browsable surfaces over the cache index) next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,19 @@ class LibraryApi {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
|
||||
/// tracks — the online source for "Shuffle all". Bare JSON array.
|
||||
Future<List<TrackRef>> shuffle({int limit = 100}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
'/api/library/shuffle',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
||||
/// type the response as `List<dynamic>` rather than a Map envelope.
|
||||
Future<List<TrackRef>> getArtistTracks(String id) async {
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
// "Shuffle all" source (#427 S4). Always-present; the pool degrades
|
||||
// gracefully 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.
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart' show libraryApiProvider;
|
||||
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;
|
||||
|
||||
/// 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.
|
||||
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
||||
if (_ref.read(offlineProvider)) {
|
||||
return _offline(limit);
|
||||
}
|
||||
final api = await _ref.read(libraryApiProvider.future);
|
||||
return api.shuffle(limit: limit);
|
||||
}
|
||||
|
||||
Future<List<TrackRef>> _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 [];
|
||||
|
||||
final meta = await (db.select(db.cachedTracks)
|
||||
..where((t) => t.id.isIn(cachedIds.toList())))
|
||||
.get();
|
||||
if (meta.isEmpty) return const [];
|
||||
|
||||
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] ?? '',
|
||||
)
|
||||
];
|
||||
refs.shuffle(Random());
|
||||
return refs.length > limit ? refs.sublist(0, limit) : refs;
|
||||
}
|
||||
}
|
||||
|
||||
final shuffleSourceProvider =
|
||||
Provider<ShuffleSource>((ref) => ShuffleSource(ref));
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/errors.dart';
|
||||
import '../cache/shuffle_source.dart';
|
||||
import '../cache/tile_providers.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
import '../models/track.dart';
|
||||
@@ -41,7 +43,28 @@ class HomeScreen extends ConsumerWidget {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const Key('shuffle_all_button'),
|
||||
tooltip: 'Shuffle all',
|
||||
icon: Icon(Icons.shuffle, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final refs =
|
||||
await ref.read(shuffleSourceProvider).tracks();
|
||||
if (refs.isEmpty) {
|
||||
messenger.showSnackBar(const SnackBar(
|
||||
content: Text('Nothing to shuffle yet'),
|
||||
));
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(refs, shuffle: true);
|
||||
},
|
||||
),
|
||||
const MainAppBarActions(currentRoute: '/home'),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: index.when(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../cache/offline_provider.dart';
|
||||
import '../../library/widgets/play_circle_button.dart';
|
||||
import '../../models/playlist.dart';
|
||||
import '../../models/track.dart';
|
||||
@@ -23,7 +24,12 @@ class PlaylistCard extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final hasTracks = playlist.trackCount > 0;
|
||||
// #427 S4: refreshable system playlists need the live build/
|
||||
// shuffle endpoints — unavailable offline. Disable their play
|
||||
// (use Shuffle all instead). User playlists play from cache.
|
||||
final offline = ref.watch(offlineProvider);
|
||||
final hasTracks = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable);
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: Material(
|
||||
|
||||
Reference in New Issue
Block a user