init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
334
lib/playlists/playlists.dart
Normal file
334
lib/playlists/playlists.dart
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// 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 (_) {}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue