Files
minstrel/flutter_client/lib/playlists/playlists_provider.dart
T
bvandeusen a8e6e1c2f7 fix(web,flutter): PlayerBar test scoping + system-playlists revalidate
Two bundled fixes:

### Web: PlayerBar test failures
PlayerBar renders both compact (md:hidden) and desktop (hidden md:flex)
blocks; jsdom doesn't apply CSS media queries so both DOM trees are
present in tests. screen.getByRole/getByText found duplicates →
14 test failures.

Added data-testid="player-bar-compact" + "player-bar-desktop"; tests
scope queries via within(getByTestId(...)). Compact is the focus of
#358, so most tests scope there. The "Up next" subgroup explicitly
scopes to desktop since that copy was dropped from compact.

### Flutter: system playlists not loading
cacheFirst was too conservative — when drift had user-created
playlists from sync but no system playlists (For-You / Discover),
fetchAndPopulate never fired (drift wasn't empty). Result: home
tile row showed user playlists but never the system ones.

Added cacheFirst alwaysRefresh option = stale-while-revalidate.
playlistsListProvider opts in: yields cache immediately, then kicks
off REST refresh in background. Drift watch() picks up the new rows
and re-emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:22 -04:00

173 lines
6.1 KiB
Dart

import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/me.dart';
import '../api/endpoints/playlists.dart';
import '../auth/auth_provider.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
return PlaylistsApi(await ref.watch(dioProvider.future));
});
/// Drift-first per #357 plan C. Returns cached playlists filtered to
/// match the `kind` family arg:
/// - 'user' → only user-created (systemVariant null), owned by current user
/// - 'system' → only system-generated (systemVariant non-null), owned
/// - 'all' → everything: own user playlists + own system + others' public
///
/// systemVariant column added in drift schema v2 (#357 follow-up); previous
/// behavior leaked system playlists into add-to-playlist sheet because we
/// couldn't filter locally.
final playlistsListProvider =
StreamProvider.family<PlaylistsList, String>((ref, kind) {
final db = ref.watch(appDbProvider);
final user = ref.watch(authControllerProvider).value;
return cacheFirst<CachedPlaylist, PlaylistsList>(
driftStream: db.select(db.cachedPlaylists).watch(),
fetchAndPopulate: () async {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.list(kind: kind);
await db.batch((b) {
for (final p in fresh.all) {
b.insert(db.cachedPlaylists, p.toDrift(),
mode: drift.InsertMode.insertOrReplace);
}
});
},
toResult: (rows) {
if (user == null) return PlaylistsList.empty();
final filtered = rows.where((r) {
if (kind == 'user') return r.systemVariant == null;
if (kind == 'system') return r.systemVariant != null;
return true; // 'all'
});
final owned = filtered
.where((r) => r.userId == user.id)
.map((r) => r.toRef())
.toList();
final pub = filtered
.where((r) => r.userId != user.id && r.isPublic)
.map((r) => r.toRef())
.toList();
return PlaylistsList(owned: owned, public: pub);
},
isOnline: () async => (await ref.read(connectivityProvider.future)),
// Aggregate list — server may add system playlists (For-You /
// Discover) that the per-user delta sync doesn't always emit.
// Stale-while-revalidate ensures the home tile row reflects the
// current server state on every screen visit.
alwaysRefresh: true,
);
});
/// Composite shape (playlist + tracks). async* over the playlist watch
/// stream + a one-shot tracks fetch per emission.
final playlistDetailProvider =
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
final db = ref.watch(appDbProvider);
final user = ref.watch(authControllerProvider).value;
final playlistQuery = db.select(db.cachedPlaylists)
..where((t) => t.id.equals(id));
final tracksQuery = (db.select(db.cachedPlaylistTracks)
..where((t) => t.playlistId.equals(id))
..orderBy([(t) => drift.OrderingTerm.asc(t.position)]))
.join([
drift.leftOuterJoin(db.cachedTracks,
db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)),
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
]);
PlaylistDetail emptyDetail() => PlaylistDetail(
playlist: Playlist(
id: id,
userId: user?.id ?? '',
name: '',
description: '',
isPublic: false,
systemVariant: null,
trackCount: 0,
coverUrl: '',
ownerUsername: '',
createdAt: '',
updatedAt: '',
),
tracks: const [],
);
await for (final playlistRows in playlistQuery.watch()) {
if (playlistRows.isEmpty) {
if (await ref.read(connectivityProvider.future)) {
try {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.get(id);
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
});
// watch() re-emits next iteration with populated row.
} catch (_) {
yield emptyDetail();
}
} else {
yield emptyDetail();
}
continue;
}
final playlist = playlistRows.first.toRef();
final trackRows = await tracksQuery.get();
final tracks = trackRows.asMap().entries.map((e) {
final r = e.value;
final track = r.readTableOrNull(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
final album = r.readTableOrNull(db.cachedAlbums);
return PlaylistTrack(
position: e.key,
trackId: track?.id,
title: track?.title ?? '',
albumId: album?.id,
albumTitle: album?.title ?? '',
artistId: artist?.id,
artistName: artist?.name ?? '',
durationSec: track == null ? 0 : track.durationMs ~/ 1000,
streamUrl: null,
);
}).toList();
yield PlaylistDetail(playlist: playlist, tracks: tracks);
}
});
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);
return MeApi(dio).systemPlaylistsStatus();
});