// 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 playlists; final Map details; final bool loading; PlaylistsState copyWith({ List? playlists, Map? 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_.json`, atomic temp+rename like /// `library/library_index.dart`) keeps the last-known playlists + opened track /// lists available offline. class PlaylistsController extends StateNotifier { 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 _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 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()); } } } catch (_) { // Missing/corrupt mirror is non-fatal. } // 2. Refresh from server if connected. await hydrate(); } PlaylistsState _fromMirror(Map j) { final playlists = (j['playlists'] as List? ?? const []) .whereType() .map((e) => Playlist.fromJson(e.cast())) .toList(); final details = {}; final rawDetails = (j['details'] as Map?)?.cast() ?? const {}; rawDetails.forEach((id, v) { if (v is Map) { details[id] = PlaylistDetail.fromJson(v.cast()); } }); 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 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 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 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 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 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 addTracks(String id, List 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 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 songs) => PlaylistDetail( id: d.id, name: d.name, songCount: songs.length, duration: d.duration, coverArt: d.coverArt, songs: songs, ); Future _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 (_) {} } }