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:
2026-05-15 21:37:29 -04:00
parent a4f293b7cf
commit a5e2abb8c4
9 changed files with 248 additions and 10 deletions
+24 -1
View File
@@ -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(