mobile-music/lib/playlists/playlists.dart
2026-07-29 14:14:18 -04:00

334 lines
10 KiB
Dart

// Callback fields are assigned from named required params, which can't be
// private initializing formals.
// ignore_for_file: prefer_initializing_formals
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
/// that have been opened. Details are cached so an opened playlist keeps working
/// offline.
class PlaylistsState {
const PlaylistsState({
this.playlists = const [],
this.details = const {},
this.loading = false,
});
final List<Playlist> playlists;
final Map<String, PlaylistDetail> details;
final bool loading;
PlaylistsState copyWith({
List<Playlist>? playlists,
Map<String, PlaylistDetail>? details,
bool? loading,
}) =>
PlaylistsState(
playlists: playlists ?? this.playlists,
details: details ?? this.details,
loading: loading ?? this.loading,
);
}
/// Source of truth for playlists. The server is authoritative while online
/// (mutations are optimistic and reverted on failure, mirroring
/// `state/favorites.dart`); a per-server on-disk mirror
/// (`playlists_<serverKey>.json`, atomic temp+rename like
/// `library/library_index.dart`) keeps the last-known playlists + opened track
/// lists available offline.
class PlaylistsController extends StateNotifier<PlaylistsState> {
PlaylistsController({
required SubsonicClient? Function() clientGetter,
required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter,
_serverKeyGetter = serverKeyGetter,
super(const PlaylistsState()) {
reloadForServer();
}
final SubsonicClient? Function() _clientGetter;
final String? Function() _serverKeyGetter;
int _generation = 0;
String? _loadedKey;
// ---- Server switching ---------------------------------------------------
Future<File> _mirrorFile(String key) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/playlists_$key.json');
}
/// Load the offline mirror for the active server, then refresh from the
/// server when online.
Future<void> reloadForServer() async {
final key = _serverKeyGetter();
if (key == _loadedKey && state.playlists.isNotEmpty) {
// Same server, already loaded — just refresh from server if possible.
await hydrate();
return;
}
_generation++;
final gen = _generation;
_loadedKey = key;
if (key == null) {
state = const PlaylistsState();
return;
}
// 1. Offline mirror first (instant, works with no connection).
try {
final file = await _mirrorFile(key);
if (await file.exists()) {
final raw = jsonDecode(await file.readAsString());
if (raw is Map && gen == _generation) {
state = _fromMirror(raw.cast<String, dynamic>());
}
}
} catch (_) {
// Missing/corrupt mirror is non-fatal.
}
// 2. Refresh from server if connected.
await hydrate();
}
PlaylistsState _fromMirror(Map<String, dynamic> j) {
final playlists = (j['playlists'] as List? ?? const [])
.whereType<Map>()
.map((e) => Playlist.fromJson(e.cast<String, dynamic>()))
.toList();
final details = <String, PlaylistDetail>{};
final rawDetails = (j['details'] as Map?)?.cast<String, dynamic>() ?? const {};
rawDetails.forEach((id, v) {
if (v is Map) {
details[id] = PlaylistDetail.fromJson(v.cast<String, dynamic>());
}
});
return PlaylistsState(playlists: playlists, details: details);
}
// ---- Fetch --------------------------------------------------------------
/// Refresh the playlist list from the server (no-op offline). Keeps cached
/// details for playlists that still exist.
Future<void> hydrate() async {
final client = _clientGetter();
if (client == null) return;
final gen = _generation;
state = state.copyWith(loading: true);
try {
final playlists = await client.getPlaylists();
if (gen != _generation) return;
final liveIds = playlists.map((p) => p.id).toSet();
final details = {
for (final e in state.details.entries)
if (liveIds.contains(e.key)) e.key: e.value,
};
state = PlaylistsState(playlists: playlists, details: details);
await _persist();
} catch (_) {
if (gen == _generation) state = state.copyWith(loading: false);
}
}
/// Fetch (and cache) a playlist's full track list. Returns the cached copy
/// when offline, or null if never fetched.
Future<PlaylistDetail?> loadDetail(String id) async {
final client = _clientGetter();
if (client == null) return state.details[id];
final gen = _generation;
try {
final detail = await client.getPlaylist(id);
if (gen != _generation) return state.details[id];
state = state.copyWith(details: {...state.details, id: detail});
await _persist();
return detail;
} catch (_) {
return state.details[id];
}
}
// ---- Mutations (optimistic) --------------------------------------------
/// Create a playlist and return its id (null on failure / offline).
Future<String?> create(String name) async {
final client = _clientGetter();
if (client == null) return null;
try {
final created = await client.createPlaylist(name);
if (created != null) {
state = state.copyWith(
playlists: [...state.playlists, created.toSummary()],
details: {...state.details, created.id: created},
);
await _persist();
return created.id;
}
// Server didn't echo the new playlist — refetch and locate it by name.
final priorIds = state.playlists.map((p) => p.id).toSet();
await hydrate();
final match = state.playlists
.where((p) => !priorIds.contains(p.id) && p.name == name)
.toList();
return match.isNotEmpty ? match.last.id : null;
} catch (_) {
return null;
}
}
Future<void> rename(String id, String name) async {
final client = _clientGetter();
if (client == null) return;
final prev = state;
state = state.copyWith(
playlists: [
for (final p in state.playlists)
if (p.id == id) _renamed(p, name) else p,
],
details: {
for (final e in state.details.entries)
e.key: e.key == id ? _renamedDetail(e.value, name) : e.value,
},
);
try {
await client.renamePlaylist(id, name);
await _persist();
} catch (_) {
state = prev; // revert
}
}
Future<void> delete(String id) async {
final client = _clientGetter();
if (client == null) return;
final prev = state;
state = state.copyWith(
playlists: state.playlists.where((p) => p.id != id).toList(),
details: {
for (final e in state.details.entries)
if (e.key != id) e.key: e.value,
},
);
try {
await client.deletePlaylist(id);
await _persist();
} catch (_) {
state = prev; // revert
}
}
Future<void> addTracks(String id, List<Song> songs) async {
final client = _clientGetter();
if (client == null || songs.isEmpty) return;
final prev = state;
final detail = state.details[id];
if (detail != null) {
state = state.copyWith(details: {
...state.details,
id: _withSongs(detail, [...detail.songs, ...songs]),
});
}
_bumpSummaryCount(id, songs.length);
try {
await client.addTracksToPlaylist(id, songs.map((s) => s.id).toList());
await _persist();
} catch (_) {
state = prev; // revert
}
}
Future<void> removeAt(String id, int index) async {
final client = _clientGetter();
if (client == null) return;
final prev = state;
final detail = state.details[id];
if (detail == null || index < 0 || index >= detail.songs.length) return;
final nextSongs = [...detail.songs]..removeAt(index);
state = state.copyWith(details: {
...state.details,
id: _withSongs(detail, nextSongs),
});
_bumpSummaryCount(id, -1);
try {
await client.removeTrackFromPlaylist(id, index);
await _persist();
} catch (_) {
state = prev; // revert
}
}
// ---- Helpers ------------------------------------------------------------
void _bumpSummaryCount(String id, int delta) {
state = state.copyWith(playlists: [
for (final p in state.playlists)
if (p.id == id)
Playlist(
id: p.id,
name: p.name,
songCount: ((p.songCount ?? 0) + delta).clamp(0, 1 << 30),
duration: p.duration,
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
)
else
p,
]);
}
static Playlist _renamed(Playlist p, String name) => Playlist(
id: p.id,
name: name,
songCount: p.songCount,
duration: p.duration,
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
);
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
PlaylistDetail(
id: d.id,
name: name,
songCount: d.songCount,
duration: d.duration,
coverArt: d.coverArt,
songs: d.songs,
);
static PlaylistDetail _withSongs(PlaylistDetail d, List<Song> songs) =>
PlaylistDetail(
id: d.id,
name: d.name,
songCount: songs.length,
duration: d.duration,
coverArt: d.coverArt,
songs: songs,
);
Future<void> _persist() async {
final key = _serverKeyGetter();
if (key == null) return;
try {
final file = await _mirrorFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(jsonEncode({
'playlists': state.playlists.map((p) => p.toJson()).toList(),
'details': {
for (final e in state.details.entries) e.key: e.value.toJson(),
},
}));
await tmp.rename(file.path);
} catch (_) {}
}
}