334 lines
10 KiB
Dart
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 (_) {}
|
|
}
|
|
}
|