mobile-music/lib/state/favorites.dart
2026-07-30 11:39:33 -04:00

119 lines
4 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Client-side mirror of the server's stars + ratings, so toggles feel instant
/// (optimistic update, reverted on failure). Hydrated from `getStarred2`.
class FavoritesState {
const FavoritesState({
this.songIds = const {},
this.albumIds = const {},
this.artistIds = const {},
this.ratings = const {},
});
final Set<String> songIds;
final Set<String> albumIds;
final Set<String> artistIds;
final Map<String, int> ratings;
bool isSongStarred(String id) => songIds.contains(id);
int ratingFor(String id) => ratings[id] ?? 0;
FavoritesState copyWith({
Set<String>? songIds,
Set<String>? albumIds,
Set<String>? artistIds,
Map<String, int>? ratings,
}) =>
FavoritesState(
songIds: songIds ?? this.songIds,
albumIds: albumIds ?? this.albumIds,
artistIds: artistIds ?? this.artistIds,
ratings: ratings ?? this.ratings,
);
}
class FavoritesController extends StateNotifier<FavoritesState> {
FavoritesController(this._clientGetter) : super(const FavoritesState()) {
if (_clientGetter() != null) hydrate();
}
final SubsonicClient? Function() _clientGetter;
Future<void> hydrate() async {
final client = _clientGetter();
if (client == null) return;
try {
final starred = await client.getStarred2();
final ratings = <String, int>{};
for (final s in starred.songs) {
if (s.userRating != null) ratings[s.id] = s.userRating!;
}
state = FavoritesState(
songIds: starred.songs.map((s) => s.id).toSet(),
albumIds: starred.albums.map((a) => a.id).toSet(),
artistIds: starred.artists.map((a) => a.id).toSet(),
ratings: ratings,
);
} catch (_) {
// Leave current state on failure.
}
}
void clear() => state = const FavoritesState();
/// Fold a song's own server-provided star / rating into local state when it
/// isn't tracked yet. Songs surfaced outside the Favorites tab (browse, home,
/// queue) carry their `starred` flag directly, so this lets the heart light
/// up for previously-favorited tracks even when `getStarred2` hydration is
/// incomplete. Only fills gaps — it never re-adds a star the user cleared
/// this session, since callers invoke it on track change (not on toggle) and
/// it only adds when the song itself reports starred.
void seedSong(Song song) {
Set<String>? songIds;
Map<String, int>? ratings;
if (song.starred && !state.songIds.contains(song.id)) {
songIds = {...state.songIds, song.id};
}
if (song.userRating != null && !state.ratings.containsKey(song.id)) {
ratings = {...state.ratings, song.id: song.userRating!};
}
if (songIds != null || ratings != null) {
state = state.copyWith(songIds: songIds, ratings: ratings);
}
}
Future<void> toggleSong(Song song) async {
final client = _clientGetter();
if (client == null) return;
final wasStarred = state.isSongStarred(song.id);
final next = Set<String>.from(state.songIds);
wasStarred ? next.remove(song.id) : next.add(song.id);
state = state.copyWith(songIds: next); // optimistic
try {
await client.setStarred(starred: !wasStarred, songId: song.id);
} catch (_) {
// Revert on failure.
final reverted = Set<String>.from(state.songIds);
wasStarred ? reverted.add(song.id) : reverted.remove(song.id);
state = state.copyWith(songIds: reverted);
}
}
Future<void> rateSong(String songId, int rating) async {
final client = _clientGetter();
if (client == null) return;
final previous = state.ratings[songId] ?? 0;
final next = Map<String, int>.from(state.ratings)..[songId] = rating;
state = state.copyWith(ratings: next); // optimistic
try {
await client.setRating(songId, rating);
} catch (_) {
final reverted = Map<String, int>.from(state.ratings)
..[songId] = previous;
state = state.copyWith(ratings: reverted);
}
}
}