updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
|
|
@ -5,12 +5,25 @@
|
|||
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;
|
||||
|
||||
/// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
|
||||
/// that have been opened. Details are cached so an opened playlist keeps working
|
||||
/// offline.
|
||||
|
|
@ -185,6 +198,87 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- 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,
|
||||
);
|
||||
|
||||
Future<void> rename(String id, String name) async {
|
||||
final client = _clientGetter();
|
||||
if (client == null) return;
|
||||
|
|
@ -280,6 +374,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
owner: p.owner,
|
||||
public: p.public,
|
||||
coverArt: p.coverArt,
|
||||
comment: p.comment,
|
||||
)
|
||||
else
|
||||
p,
|
||||
|
|
@ -294,6 +389,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
owner: p.owner,
|
||||
public: p.public,
|
||||
coverArt: p.coverArt,
|
||||
comment: p.comment,
|
||||
);
|
||||
|
||||
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
|
||||
|
|
@ -303,6 +399,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
songCount: d.songCount,
|
||||
duration: d.duration,
|
||||
coverArt: d.coverArt,
|
||||
comment: d.comment,
|
||||
songs: d.songs,
|
||||
);
|
||||
|
||||
|
|
@ -313,6 +410,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
songCount: songs.length,
|
||||
duration: d.duration,
|
||||
coverArt: d.coverArt,
|
||||
comment: d.comment,
|
||||
songs: songs,
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue