482 lines
16 KiB
Dart
482 lines
16 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:collection/collection.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../subsonic/models.dart';
|
|
import '../subsonic/subsonic_client.dart';
|
|
|
|
/// Marker stored in a playlist's `comment` to flag it as a Timbre **tag** — a
|
|
/// playlist surfaced under the Tags UI instead of Playlists. Kept out of the
|
|
/// visible name so tags read cleanly everywhere (including other Subsonic
|
|
/// clients, where they still appear as ordinary playlists).
|
|
///
|
|
/// Classification relies on `getPlaylists` returning the `comment` field;
|
|
/// Navidrome does. A server that omitted it would show tags as plain playlists.
|
|
const String kTagMarker = 'timbre:tag';
|
|
|
|
/// Whether [p] is a Timbre tag (vs a user-facing playlist).
|
|
bool isTagPlaylist(Playlist p) => p.comment == kTagMarker;
|
|
|
|
/// Whether [p] belongs to the user [me] (vs a *shared* playlist owned by
|
|
/// someone else on the same server). An unknown owner — or unknown viewer —
|
|
/// counts as "mine" so the split never hides a playlist the user can edit.
|
|
bool isOwnedBy(Playlist p, String? me) =>
|
|
p.owner == null || me == null || p.owner == me;
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
// ---- Tags ---------------------------------------------------------------
|
|
// A tag is just a playlist whose `comment` carries [kTagMarker]. These reuse
|
|
// the playlist mutations above; only creation and membership need tag-aware
|
|
// behaviour (marker stamping + de-duplication).
|
|
|
|
/// Create a tag named [name], or return the id of an existing tag with that
|
|
/// name (case-insensitive) so tags stay unique. `createPlaylist` can't set a
|
|
/// comment inline, so we create then stamp the marker — and if stamping fails
|
|
/// we delete the orphan rather than leave an unmarked playlist behind.
|
|
Future<String?> createTag(String name) async {
|
|
final client = _clientGetter();
|
|
if (client == null) return null;
|
|
|
|
final existing = state.playlists.firstWhereOrNull(
|
|
(p) => isTagPlaylist(p) && p.name.toLowerCase() == name.toLowerCase(),
|
|
);
|
|
if (existing != null) return existing.id;
|
|
|
|
final id = await create(name);
|
|
if (id == null) return null;
|
|
try {
|
|
await client.setPlaylistComment(id, kTagMarker);
|
|
} catch (_) {
|
|
await delete(id); // don't strand a nameless, unmarked playlist
|
|
return null;
|
|
}
|
|
_applyComment(id, kTagMarker);
|
|
await _persist();
|
|
return id;
|
|
}
|
|
|
|
/// Add [songs] to a tag, skipping any already present. Playlists allow
|
|
/// duplicates but a tag is a set — re-tagging a song must be idempotent, so we
|
|
/// load current membership first and only append the new ids.
|
|
Future<void> addToTag(String tagId, List<Song> songs) async {
|
|
if (songs.isEmpty) return;
|
|
final detail = await loadDetail(tagId);
|
|
final present = {for (final s in detail?.songs ?? const <Song>[]) s.id};
|
|
final fresh = songs.where((s) => !present.contains(s.id)).toList();
|
|
if (fresh.isEmpty) return;
|
|
await addTracks(tagId, fresh);
|
|
}
|
|
|
|
/// Patch the cached summary + detail for [id] with [comment] (used right after
|
|
/// stamping a new tag's marker so it partitions into the Tags view at once).
|
|
void _applyComment(String id, String comment) {
|
|
state = state.copyWith(
|
|
playlists: [
|
|
for (final p in state.playlists)
|
|
if (p.id == id) _withComment(p, comment) else p,
|
|
],
|
|
details: {
|
|
for (final e in state.details.entries)
|
|
e.key: e.key == id ? _detailWithComment(e.value, comment) : e.value,
|
|
},
|
|
);
|
|
}
|
|
|
|
static Playlist _withComment(Playlist p, String comment) => Playlist(
|
|
id: p.id,
|
|
name: p.name,
|
|
songCount: p.songCount,
|
|
duration: p.duration,
|
|
owner: p.owner,
|
|
public: p.public,
|
|
coverArt: p.coverArt,
|
|
comment: comment,
|
|
);
|
|
|
|
static PlaylistDetail _detailWithComment(
|
|
PlaylistDetail d, String comment) =>
|
|
PlaylistDetail(
|
|
id: d.id,
|
|
name: d.name,
|
|
songCount: d.songCount,
|
|
duration: d.duration,
|
|
coverArt: d.coverArt,
|
|
comment: comment,
|
|
songs: d.songs,
|
|
);
|
|
|
|
/// Share (make server-wide public) or unshare a playlist. Optimistic like
|
|
/// [rename]; reverts on failure. Owner/public live on the summary only.
|
|
Future<void> setPublic(String id, bool value) 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) _withPublic(p, value) else p,
|
|
]);
|
|
try {
|
|
await client.setPlaylistPublic(id, value);
|
|
await _persist();
|
|
} catch (_) {
|
|
state = prev; // revert
|
|
}
|
|
}
|
|
|
|
/// Clone a (typically shared) playlist into a new one owned by the current
|
|
/// user — the client-only alternative to editing someone else's playlist.
|
|
/// Loads [source]'s tracks (server, falling back to cache), creates a fresh
|
|
/// playlist, and copies them in. Returns the new id, or null on failure.
|
|
Future<String?> saveCopy(Playlist source) async {
|
|
final client = _clientGetter();
|
|
if (client == null) return null;
|
|
final detail = await loadDetail(source.id);
|
|
final songs = detail?.songs ?? const <Song>[];
|
|
final newId = await create(source.name);
|
|
if (newId == null) return null;
|
|
if (songs.isNotEmpty) await addTracks(newId, songs);
|
|
return newId;
|
|
}
|
|
|
|
static Playlist _withPublic(Playlist p, bool value) => Playlist(
|
|
id: p.id,
|
|
name: p.name,
|
|
songCount: p.songCount,
|
|
duration: p.duration,
|
|
owner: p.owner,
|
|
public: value,
|
|
coverArt: p.coverArt,
|
|
comment: p.comment,
|
|
);
|
|
|
|
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,
|
|
comment: p.comment,
|
|
)
|
|
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,
|
|
comment: p.comment,
|
|
);
|
|
|
|
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
|
|
PlaylistDetail(
|
|
id: d.id,
|
|
name: name,
|
|
songCount: d.songCount,
|
|
duration: d.duration,
|
|
coverArt: d.coverArt,
|
|
comment: d.comment,
|
|
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,
|
|
comment: d.comment,
|
|
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 (_) {}
|
|
}
|
|
}
|