This commit is contained in:
Forrest 2026-07-29 22:41:38 -04:00
parent 981b4836f9
commit ed910748cb
34 changed files with 2054 additions and 153 deletions

View file

@ -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,
);