updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
BIN
assets/casette/casette.png
Normal file
BIN
assets/casette/casette.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
17
assets/casette/casette_shell.svg
Normal file
17
assets/casette/casette_shell.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<svg width="469" height="298" viewBox="0 0 469 298" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M405 99V171H404V99H405Z" fill="#252525"/>
|
||||
<path d="M64 99V171H63V99H64Z" fill="#252525"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M454 1C461.732 1 468 7.26801 468 15V282C468 289.732 461.732 296 454 296H15C7.26801 296 1 289.732 1 282V15C1 7.26801 7.26801 1 15 1H454ZM121.5 268C115.701 268 111 272.701 111 278.5C111 284.299 115.701 289 121.5 289C127.299 289 132 284.299 132 278.5C132 272.701 127.299 268 121.5 268ZM347.5 268C341.701 268 337 272.701 337 278.5C337 284.299 341.701 289 347.5 289C353.299 289 358 284.299 358 278.5C358 272.701 353.299 268 347.5 268ZM162 260C160.343 260 159 261.343 159 263V278C159 279.657 160.343 281 162 281H177C178.657 281 180 279.657 180 278V263C180 261.343 178.657 260 177 260H162ZM292 260C290.343 260 289 261.343 289 263V278C289 279.657 290.343 281 292 281H307C308.657 281 310 279.657 310 278V263C310 261.343 308.657 260 307 260H292ZM36 28L26 38L25.5 218.5V219H443V38L435 28H36Z" fill="#252525"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M405.5 98.5V172H62.5V98.5H405.5ZM173 106V165H296V106H173ZM135 111C121.745 111 111 121.745 111 135C111 148.255 121.745 159 135 159C148.255 159 159 148.255 159 135C159 121.745 148.255 111 135 111ZM334 111C320.745 111 310 121.745 310 135C310 148.255 320.745 159 334 159C347.255 159 358 148.255 358 135C358 121.745 347.255 111 334 111Z" fill="#252525"/>
|
||||
<path d="M225.213 247.497C226.319 242.369 231.374 239.107 236.503 240.213C241.631 241.319 244.892 246.374 243.786 251.503C242.68 256.631 237.626 259.893 232.497 258.786C227.369 257.68 224.107 252.626 225.213 247.497Z" fill="#252525"/>
|
||||
<path d="M10.0713 273.704C14.377 270.706 20.2979 271.766 23.2961 276.071C26.2943 280.377 25.2343 286.298 20.9286 289.296C16.623 292.294 10.702 291.234 7.70385 286.929C4.70568 282.623 5.76562 276.702 10.0713 273.704Z" fill="#252525"/>
|
||||
<path d="M10.6841 10.3111C15.2067 7.6514 21.0291 9.16158 23.6888 13.6842C26.3485 18.2068 24.8383 24.0292 20.3157 26.6889C15.7931 29.3486 9.97073 27.8384 7.31103 23.3158C4.65134 18.7932 6.16152 12.9708 10.6841 10.3111Z" fill="#252525"/>
|
||||
<path d="M453.279 27.968C448.05 28.3981 443.462 24.5078 443.032 19.2788C442.602 14.0497 446.492 9.46209 451.721 9.03197C456.95 8.60185 461.538 12.4921 461.968 17.7212C462.398 22.9502 458.508 27.5379 453.279 27.968Z" fill="#252525"/>
|
||||
<path d="M458.522 273.153C462.58 276.479 463.173 282.465 459.847 286.522C456.521 290.58 450.535 291.173 446.477 287.847C442.42 284.521 441.827 278.535 445.153 274.478C448.479 270.42 454.465 269.827 458.522 273.153Z" fill="#252525"/>
|
||||
<path d="M225.213 247.497C226.319 242.369 231.374 239.107 236.503 240.213C241.631 241.319 244.892 246.374 243.786 251.503C242.68 256.631 237.626 259.893 232.497 258.786C227.369 257.68 224.107 252.626 225.213 247.497Z" fill="#5C5C5C"/>
|
||||
<path d="M10.0713 273.704C14.377 270.706 20.2979 271.766 23.2961 276.071C26.2943 280.377 25.2343 286.298 20.9286 289.296C16.623 292.294 10.702 291.234 7.70385 286.929C4.70568 282.623 5.76562 276.702 10.0713 273.704Z" fill="#5C5C5C"/>
|
||||
<path d="M10.6841 10.3111C15.2067 7.6514 21.0291 9.16158 23.6888 13.6842C26.3485 18.2068 24.8383 24.0292 20.3157 26.6889C15.7931 29.3486 9.97073 27.8384 7.31103 23.3158C4.65134 18.7932 6.16152 12.9708 10.6841 10.3111Z" fill="#5C5C5C"/>
|
||||
<path d="M453.279 27.968C448.05 28.3981 443.462 24.5078 443.032 19.2788C442.602 14.0497 446.492 9.46209 451.721 9.03197C456.95 8.60185 461.538 12.4921 461.968 17.7212C462.398 22.9502 458.508 27.5379 453.279 27.968Z" fill="#5C5C5C"/>
|
||||
<path d="M458.522 273.153C462.58 276.479 463.173 282.465 459.847 286.522C456.521 290.58 450.535 291.173 446.477 287.847C442.42 284.521 441.827 278.535 445.153 274.478C448.479 270.42 454.465 269.827 458.522 273.153Z" fill="#5C5C5C"/>
|
||||
<path d="M228.882 257.163L240.116 241.841M6.16541 283.276L24.8307 279.726M6.05677 19.5562L24.9391 17.4452M461.464 21.6523L443.54 15.3482M448.248 272.002L456.749 288.994M454 1C461.732 1 468 7.26801 468 15V282C468 289.732 461.732 296 454 296H15C7.26801 296 1 289.732 1 282V15C1 7.26801 7.26801 1 15 1H454ZM121.5 268C115.701 268 111 272.701 111 278.5C111 284.299 115.701 289 121.5 289C127.299 289 132 284.299 132 278.5C132 272.701 127.299 268 121.5 268ZM347.5 268C341.701 268 337 272.701 337 278.5C337 284.299 341.701 289 347.5 289C353.299 289 358 284.299 358 278.5C358 272.701 353.299 268 347.5 268ZM162 260C160.343 260 159 261.343 159 263V278C159 279.657 160.343 281 162 281H177C178.657 281 180 279.657 180 278V263C180 261.343 178.657 260 177 260H162ZM292 260C290.343 260 289 261.343 289 263V278C289 279.657 290.343 281 292 281H307C308.657 281 310 279.657 310 278V263C310 261.343 308.657 260 307 260H292ZM36 28L26 38L25.5 218.5V219H443V38L435 28H36ZM405 99V171H404V99H405ZM64 99V171H63V99H64ZM73.5 296.5L88.5 223H381.5L398 296.5H73.5ZM405.5 98.5V172H62.5V98.5H405.5ZM173 106V165H296V106H173ZM135 111C121.745 111 111 121.745 111 135C111 148.255 121.745 159 135 159C148.255 159 159 148.255 159 135C159 121.745 148.255 111 135 111ZM334 111C320.745 111 310 121.745 310 135C310 148.255 320.745 159 334 159C347.255 159 358 148.255 358 135C358 121.745 347.255 111 334 111ZM225.213 247.497C226.319 242.369 231.374 239.107 236.503 240.213C241.631 241.319 244.892 246.374 243.786 251.503C242.68 256.631 237.626 259.893 232.497 258.786C227.369 257.68 224.107 252.626 225.213 247.497ZM10.0713 273.704C14.377 270.706 20.2979 271.766 23.2961 276.071C26.2943 280.377 25.2343 286.298 20.9286 289.296C16.623 292.294 10.702 291.234 7.70385 286.929C4.70568 282.623 5.76562 276.702 10.0713 273.704ZM10.6841 10.3111C15.2067 7.6514 21.0291 9.16158 23.6888 13.6842C26.3485 18.2068 24.8383 24.0292 20.3157 26.6889C15.7931 29.3486 9.97073 27.8384 7.31103 23.3158C4.65134 18.7932 6.16152 12.9708 10.6841 10.3111ZM453.279 27.968C448.05 28.3981 443.462 24.5078 443.032 19.2788C442.602 14.0497 446.492 9.46209 451.721 9.03197C456.95 8.60185 461.538 12.4921 461.968 17.7212C462.398 22.9502 458.508 27.5379 453.279 27.968ZM458.522 273.153C462.58 276.479 463.173 282.465 459.847 286.522C456.521 290.58 450.535 291.173 446.477 287.847C442.42 284.521 441.827 278.535 445.153 274.478C448.479 270.42 454.465 269.827 458.522 273.153Z" stroke="#2C2C2C" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
3
assets/casette/spindle.svg
Normal file
3
assets/casette/spindle.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23 0C30.5023 0.708822 36.8544 5.36434 39.96 11.8682L36.0889 13.3086L37.4834 17.0576L41.3301 15.626C41.7669 17.3129 42 19.0818 42 20.9053C42 26.8175 39.5549 32.1572 35.623 35.9736L33.0791 32.9053L30 35.458L32.502 38.4756C29.1978 40.6429 25.2467 41.9053 21 41.9053C16.7293 41.9053 12.7578 40.6283 9.44238 38.4385L11.8809 35.417L8.7666 32.9053L6.32812 35.9277C2.42438 32.1145 0 26.7934 0 20.9053C0 19.3925 0.160905 17.9172 0.464844 16.4951L3.72559 17.5166L4.92188 13.6992L1.67188 12.6807C4.62306 5.7541 11.1887 0.738021 19 0V3.90527H23V0Z" fill="#D9D9D9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
|
|
@ -8,7 +8,7 @@ import 'package:path_provider/path_provider.dart';
|
|||
|
||||
import '../subsonic/models.dart';
|
||||
|
||||
/// One play event, mirroring Ratune's `PlayRecord` (`history.rs`).
|
||||
/// One play event, mirroring Timbre's `PlayRecord` (`history.rs`).
|
||||
class PlayRecord {
|
||||
PlayRecord({
|
||||
required this.songId,
|
||||
|
|
@ -173,7 +173,7 @@ List<PlayRecord> recentAlbums(List<PlayRecord> history, {int limit = 12}) {
|
|||
}
|
||||
|
||||
/// Rediscover: artists you've heard before but aren't listening to now, biased
|
||||
/// toward low play counts, then sampled for variety (Ratune
|
||||
/// toward low play counts, then sampled for variety (Timbre
|
||||
/// `history.rs:101-167`). [seed] drives the re-roll.
|
||||
///
|
||||
/// The desktop original only surfaces artists last heard more than [minDays]
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class LibraryIndexState {
|
|||
}
|
||||
|
||||
/// Builds and caches a flat "all songs" index — Subsonic has no all-songs
|
||||
/// endpoint, so we crawl every album (mirrors Ratune's `library_index` /
|
||||
/// endpoint, so we crawl every album (mirrors Timbre's `library_index` /
|
||||
/// `fetch_all_library_songs`). The cache is keyed to the server so switching
|
||||
/// servers never serves a stale catalog, and writes are atomic so an
|
||||
/// interrupted crawl can't leave a truncated file.
|
||||
|
|
@ -54,7 +54,7 @@ class LibraryIndexController extends StateNotifier<LibraryIndexState> {
|
|||
|
||||
final SubsonicClient? Function() _clientGetter;
|
||||
|
||||
static const int _albumParallelism = 12; // Ratune's album_parallelism.
|
||||
static const int _albumParallelism = 12; // Timbre's album_parallelism.
|
||||
static const int _pageSize = 500; // Subsonic getAlbumList2 cap.
|
||||
static const Duration _ttl = Duration(hours: 24);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
|
||||
import 'settings/settings_store.dart';
|
||||
import 'shell/app_shell.dart';
|
||||
import 'theme/accent.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
|
@ -35,8 +36,12 @@ class TimbreApp extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// The theme rebuilds whenever the album-art accent changes.
|
||||
final accent = ref.watch(accentProvider);
|
||||
// The accent either tracks the album art (default) or is pinned to a
|
||||
// user-chosen static color. The theme rebuilds whenever it changes.
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final accent = settings.useStaticAccent
|
||||
? settings.staticAccentColor
|
||||
: ref.watch(accentProvider);
|
||||
return MaterialApp(
|
||||
title: 'Timbre',
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import 'package:just_audio_background/just_audio_background.dart';
|
|||
|
||||
import '../subsonic/models.dart';
|
||||
|
||||
/// Immutable snapshot of the player, mirroring Ratune's `QueueState` +
|
||||
/// player-event stream (`ratune-player/src/engine.rs`).
|
||||
/// Immutable snapshot of the player, mirroring Timbre's `QueueState` +
|
||||
/// player-event stream (`timbre-player/src/engine.rs`).
|
||||
class PlaybackState {
|
||||
const PlaybackState({
|
||||
this.queue = const [],
|
||||
|
|
@ -212,7 +212,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
// currentIndexStream fires _notifyCurrent for the started track.
|
||||
}
|
||||
|
||||
/// Insert [song] right after the current track (Ratune's "play next").
|
||||
/// Insert [song] right after the current track (Timbre's "play next").
|
||||
/// Falls back to [playSongs] when nothing is playing.
|
||||
Future<void> playNext(Song song) async {
|
||||
if (_streamUriFor(song) == null) return;
|
||||
|
|
@ -306,7 +306,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cycle off → all → one → off (Ratune's queue-loop toggle, extended with
|
||||
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
|
||||
/// single-track repeat).
|
||||
Future<void> cycleLoop() async {
|
||||
final nextMode = switch (state.loop) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
151
lib/screens/add_tag_sheet.dart
Normal file
151
lib/screens/add_tag_sheet.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
|
||||
|
||||
/// Bottom sheet to apply a tag to [songs] — pick an existing tag or create one.
|
||||
/// Tags are playlists under the hood, so this mirrors the "add to playlist"
|
||||
/// sheet; the difference is membership is idempotent (a song already carrying
|
||||
/// the tag is skipped, not duplicated). No-op when offline.
|
||||
Future<void> showAddTagSheet(
|
||||
BuildContext context, {
|
||||
required List<Song> songs,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _AddTagSheet(songs: songs),
|
||||
);
|
||||
}
|
||||
|
||||
class _AddTagSheet extends ConsumerWidget {
|
||||
const _AddTagSheet({required this.songs});
|
||||
|
||||
final List<Song> songs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final tags = ref.watch(tagsProvider);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Text('Add tag',
|
||||
style:
|
||||
TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
if (!connected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(TimbreSpacing.xl),
|
||||
child: Text('Connect to a server to manage tags.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else ...[
|
||||
_Tile(
|
||||
icon: Icons.add,
|
||||
label: 'New tag…',
|
||||
accent: accent,
|
||||
onTap: () => _createAndAdd(context, ref),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final t in tags)
|
||||
_Tile(
|
||||
icon: Icons.label_outline,
|
||||
label: t.name,
|
||||
trailing:
|
||||
t.songCount != null ? '${t.songCount}' : null,
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playlistsProvider.notifier)
|
||||
.addToTag(t.id, songs);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showToast(context, 'Tagged "${t.name}"');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
|
||||
final name = await promptPlaylistName(context, title: 'New tag');
|
||||
if (name == null || name.isEmpty) return;
|
||||
final id = await ref.read(playlistsProvider.notifier).createTag(name);
|
||||
if (id != null) {
|
||||
await ref.read(playlistsProvider.notifier).addToTag(id, songs);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showToast(context,
|
||||
id != null ? 'Tagged "$name"' : 'Could not create tag');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Tile extends StatelessWidget {
|
||||
const _Tile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
this.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final String? trailing;
|
||||
final Color? accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: accent ?? TimbreColors.dimmed),
|
||||
const SizedBox(width: TimbreSpacing.lg),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: accent ?? TimbreColors.foreground)),
|
||||
),
|
||||
if (trailing != null)
|
||||
Text(trailing!,
|
||||
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/toast.dart';
|
||||
|
||||
/// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
|
||||
/// offline (playlist mutations require the server).
|
||||
|
|
@ -27,7 +28,7 @@ class _AddToPlaylistSheet extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final playlists = ref.watch(playlistsProvider).playlists;
|
||||
final playlists = ref.watch(realPlaylistsProvider);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return SafeArea(
|
||||
|
|
@ -184,8 +185,5 @@ Future<String?> promptPlaylistName(
|
|||
);
|
||||
}
|
||||
|
||||
void _toast(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
void _toast(BuildContext context, String message) =>
|
||||
showToast(context, message);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import '../state/providers.dart';
|
|||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_tag_sheet.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
import 'downloads_screen.dart';
|
||||
import 'favorites_screen.dart';
|
||||
import 'playlists_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'tags_screen.dart';
|
||||
|
||||
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
|
||||
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
|
||||
|
|
@ -58,6 +61,13 @@ class BrowserScreen extends ConsumerWidget {
|
|||
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.label_outline,
|
||||
label: 'Tags',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const TagsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.download,
|
||||
label: 'Downloads',
|
||||
|
|
@ -344,6 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onAddToTag: () => showAddTagSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
|
|
@ -472,11 +483,7 @@ class AlbumScreen extends ConsumerWidget {
|
|||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading album…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
showToast(context, 'Downloading album…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
|
|
@ -499,6 +506,7 @@ class AlbumScreen extends ConsumerWidget {
|
|||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onAddToTag: () => showAddTagSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
|
|
@ -523,6 +531,7 @@ class BrowseRow extends StatelessWidget {
|
|||
this.onPlayNext,
|
||||
this.onAddToQueue,
|
||||
this.onAddToPlaylist,
|
||||
this.onAddToTag,
|
||||
this.onDownload,
|
||||
this.onRemoveDownload,
|
||||
this.downloadStatus,
|
||||
|
|
@ -540,6 +549,7 @@ class BrowseRow extends StatelessWidget {
|
|||
/// Secondary song actions, folded into a trailing overflow menu so the row
|
||||
/// stays uncluttered.
|
||||
final VoidCallback? onAddToPlaylist;
|
||||
final VoidCallback? onAddToTag;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onRemoveDownload;
|
||||
|
||||
|
|
@ -548,7 +558,10 @@ class BrowseRow extends StatelessWidget {
|
|||
final DownloadStatus? downloadStatus;
|
||||
|
||||
bool get _hasMenu =>
|
||||
onAddToPlaylist != null || onDownload != null || onRemoveDownload != null;
|
||||
onAddToPlaylist != null ||
|
||||
onAddToTag != null ||
|
||||
onDownload != null ||
|
||||
onRemoveDownload != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -600,14 +613,27 @@ class BrowseRow extends StatelessWidget {
|
|||
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||
],
|
||||
if (onPlayNext != null)
|
||||
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
|
||||
_RowIcon(
|
||||
icon: Icons.playlist_play,
|
||||
onTap: () {
|
||||
onPlayNext!();
|
||||
showToast(context, 'Playing next', icon: Icons.check);
|
||||
},
|
||||
),
|
||||
if (onAddToQueue != null)
|
||||
_RowIcon(icon: Icons.add, onTap: onAddToQueue!),
|
||||
_RowIcon(
|
||||
icon: Icons.add,
|
||||
onTap: () {
|
||||
onAddToQueue!();
|
||||
showToast(context, 'Added to queue', icon: Icons.check);
|
||||
},
|
||||
),
|
||||
if (_hasMenu)
|
||||
_RowMenu(
|
||||
isDownloaded: isDone,
|
||||
isDownloading: isActive,
|
||||
onAddToPlaylist: onAddToPlaylist,
|
||||
onAddToTag: onAddToTag,
|
||||
onDownload: onDownload,
|
||||
onRemoveDownload: onRemoveDownload,
|
||||
),
|
||||
|
|
@ -626,6 +652,7 @@ class _RowMenu extends StatelessWidget {
|
|||
required this.isDownloaded,
|
||||
required this.isDownloading,
|
||||
this.onAddToPlaylist,
|
||||
this.onAddToTag,
|
||||
this.onDownload,
|
||||
this.onRemoveDownload,
|
||||
});
|
||||
|
|
@ -633,6 +660,7 @@ class _RowMenu extends StatelessWidget {
|
|||
final bool isDownloaded;
|
||||
final bool isDownloading;
|
||||
final VoidCallback? onAddToPlaylist;
|
||||
final VoidCallback? onAddToTag;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onRemoveDownload;
|
||||
|
||||
|
|
@ -648,6 +676,8 @@ class _RowMenu extends StatelessWidget {
|
|||
switch (v) {
|
||||
case 'playlist':
|
||||
onAddToPlaylist?.call();
|
||||
case 'tag':
|
||||
onAddToTag?.call();
|
||||
case 'download':
|
||||
onDownload?.call();
|
||||
case 'remove_download':
|
||||
|
|
@ -658,6 +688,8 @@ class _RowMenu extends StatelessWidget {
|
|||
if (onAddToPlaylist != null)
|
||||
const PopupMenuItem(
|
||||
value: 'playlist', child: Text('Add to playlist')),
|
||||
if (onAddToTag != null)
|
||||
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
|
||||
if (isDownloaded && onRemoveDownload != null)
|
||||
const PopupMenuItem(
|
||||
value: 'remove_download', child: Text('Remove download'))
|
||||
|
|
|
|||
|
|
@ -5,18 +5,145 @@ import '../state/providers.dart';
|
|||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Open the connect-server sheet.
|
||||
Future<void> showConnectSheet(BuildContext context) {
|
||||
/// Open the add/edit-server sheet. Pass [initial] to edit an existing saved
|
||||
/// server (its password is reused if the field is left blank).
|
||||
Future<void> showConnectSheet(BuildContext context,
|
||||
{SubsonicCredentials? initial}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _ConnectSheet(),
|
||||
builder: (_) => _ConnectSheet(initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the server switcher: pick an active server, or add a new one.
|
||||
Future<void> showServerSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _ServerSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lists saved servers (tap to switch) with an "Add server" action.
|
||||
class _ServerSheet extends ConsumerWidget {
|
||||
const _ServerSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Text('Servers',
|
||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
if (conn.servers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
|
||||
child: Text('No servers saved yet.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else
|
||||
for (final s in conn.servers)
|
||||
_ServerRow(
|
||||
creds: s,
|
||||
active: s.id == conn.activeId,
|
||||
onTap: () {
|
||||
// Grab the notifier before popping — this widget's `ref` is
|
||||
// disposed with the sheet.
|
||||
final notifier = ref.read(connectionProvider.notifier);
|
||||
Navigator.of(context).pop();
|
||||
notifier.switchTo(s.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Capture the navigator's (still-mounted) context before popping
|
||||
// this sheet, so the next sheet has a valid overlay to show in.
|
||||
final nav = Navigator.of(context);
|
||||
nav.pop();
|
||||
showConnectSheet(nav.context);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add, size: 16, color: TimbreColors.foreground),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Add server',
|
||||
style: TextStyle(color: TimbreColors.foreground)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerRow extends StatelessWidget {
|
||||
const _ServerRow({
|
||||
required this.creds,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SubsonicCredentials creds;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(active ? '● ' : '○ ',
|
||||
style: TextStyle(
|
||||
color: active ? accent : TimbreColors.dimmed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
creds.display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.foreground,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectSheet extends ConsumerStatefulWidget {
|
||||
const _ConnectSheet();
|
||||
const _ConnectSheet({this.initial});
|
||||
|
||||
final SubsonicCredentials? initial;
|
||||
|
||||
@override
|
||||
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
|
||||
|
|
@ -26,15 +153,20 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
final _url = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _alias = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _localError;
|
||||
|
||||
bool get _isEdit => widget.initial != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final creds = ref.read(connectionProvider).credentials;
|
||||
if (creds != null) {
|
||||
_url.text = creds.url;
|
||||
_user.text = creds.username;
|
||||
final initial = widget.initial;
|
||||
if (initial != null) {
|
||||
_url.text = initial.url;
|
||||
_user.text = initial.username;
|
||||
_alias.text = initial.alias ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
_url.dispose();
|
||||
_user.dispose();
|
||||
_pass.dispose();
|
||||
_alias.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Build credentials from the form. Returns null (and sets [_localError]) if
|
||||
/// required fields are missing. On edit, a blank password reuses the saved one.
|
||||
SubsonicCredentials? _readForm() {
|
||||
final url = _url.text.trim();
|
||||
final user = _user.text.trim();
|
||||
if (url.isEmpty || user.isEmpty) {
|
||||
setState(() => _localError = 'Server URL and username are required.');
|
||||
return null;
|
||||
}
|
||||
final password =
|
||||
_pass.text.isEmpty && _isEdit ? widget.initial!.password : _pass.text;
|
||||
if (password.isEmpty) {
|
||||
setState(() => _localError = 'Password is required.');
|
||||
return null;
|
||||
}
|
||||
final alias = _alias.text.trim();
|
||||
return SubsonicCredentials(
|
||||
url: url,
|
||||
username: user,
|
||||
password: password,
|
||||
alias: alias.isEmpty ? null : alias,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
setState(() => _busy = true);
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(
|
||||
SubsonicCredentials(
|
||||
url: _url.text.trim(),
|
||||
username: _user.text.trim(),
|
||||
password: _pass.text,
|
||||
),
|
||||
);
|
||||
final creds = _readForm();
|
||||
if (creds == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_localError = null;
|
||||
});
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(creds);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (ok) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final creds = _readForm();
|
||||
if (creds == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_localError = null;
|
||||
});
|
||||
await ref.read(connectionProvider.notifier).saveServer(creds);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final error = _localError ??
|
||||
(conn.status == ConnStatus.error ? conn.error : null);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: TimbreSpacing.xl,
|
||||
|
|
@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Connect to server',
|
||||
Text(_isEdit ? 'Edit server' : 'Add server',
|
||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
_field(_url, 'Server URL', 'https://navidrome.example.com',
|
||||
keyboard: TextInputType.url),
|
||||
_field(_user, 'Username', 'you'),
|
||||
_field(_pass, 'Password', '••••••••', obscure: true),
|
||||
if (conn.status == ConnStatus.error && conn.error != null) ...[
|
||||
_field(_pass, 'Password',
|
||||
_isEdit ? '•••••••• (unchanged)' : '••••••••',
|
||||
obscure: true),
|
||||
_field(_alias, 'Label (optional)', 'Home server'),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
Text(conn.error!,
|
||||
style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _connect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: TimbreColors.background,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : _save,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: TimbreColors.foreground,
|
||||
side: const BorderSide(color: TimbreColors.border),
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy ? null : _connect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: TimbreColors.background,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import 'package:just_audio/just_audio.dart' show LoopMode;
|
|||
|
||||
import '../downloads/download_manager.dart';
|
||||
import '../playback/playback_engine.dart';
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/block_progress_bar.dart';
|
||||
import '../widgets/cassette_view.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
|
|
@ -210,28 +212,32 @@ class _AlbumArtPanel extends ConsumerWidget {
|
|||
final coverArt =
|
||||
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final cassette =
|
||||
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
|
||||
final artUri = (client != null && coverArt != null)
|
||||
? client.coverArtUri(coverArt, size: 512).toString()
|
||||
: null;
|
||||
|
||||
return HairlinePanel(
|
||||
title: 'Album Art',
|
||||
title: cassette ? 'Cassette' : 'Album Art',
|
||||
padding: const EdgeInsets.all(TimbreSpacing.md),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
child: cassette
|
||||
? Center(child: CassetteView(artUri: artUri))
|
||||
: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../state/providers.dart';
|
|||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
/// Playlists list — server-backed with an offline mirror. Create from the app
|
||||
|
|
@ -16,7 +17,7 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(playlistsProvider);
|
||||
final playlists = ref.watch(realPlaylistsProvider);
|
||||
final controller = ref.read(playlistsProvider.notifier);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
|
|
@ -38,10 +39,9 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
child: HairlinePanel(
|
||||
title: 'Playlists',
|
||||
active: true,
|
||||
trailing:
|
||||
state.playlists.isEmpty ? null : '(${state.playlists.length})',
|
||||
trailing: playlists.isEmpty ? null : '(${playlists.length})',
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: state.playlists.isEmpty
|
||||
child: playlists.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
connected
|
||||
|
|
@ -53,9 +53,9 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: state.playlists.length,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, i) {
|
||||
final p = state.playlists[i];
|
||||
final p = playlists[i];
|
||||
return _PlaylistRow(
|
||||
name: p.name,
|
||||
subtitle: p.songCount != null
|
||||
|
|
@ -233,11 +233,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
|
|||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading playlist…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
showToast(context, 'Downloading playlist…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
|
|
@ -337,8 +333,10 @@ class _TrackRow extends StatelessWidget {
|
|||
switch (v) {
|
||||
case 'next':
|
||||
onPlayNext();
|
||||
showToast(context, 'Playing next', icon: Icons.check);
|
||||
case 'queue':
|
||||
onAddToQueue();
|
||||
showToast(context, 'Added to queue', icon: Icons.check);
|
||||
case 'remove':
|
||||
onRemove?.call();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'connect_sheet.dart';
|
||||
|
||||
/// Audio-quality settings: independent streaming and download knobs, both
|
||||
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
|
||||
|
|
@ -14,6 +17,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final controller = ref.read(settingsProvider.notifier);
|
||||
final conn = ref.watch(connectionProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -72,6 +76,143 @@ class SettingsScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Appearance',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'By default the accent color is drawn from the playing '
|
||||
'album art. Turn on a static accent to pin one color.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<bool>(
|
||||
label: 'Static accent',
|
||||
values: const [false, true],
|
||||
selected: settings.useStaticAccent,
|
||||
labelFor: (v) => v ? 'On' : 'Off',
|
||||
onSelect: controller.setUseStaticAccent,
|
||||
),
|
||||
if (settings.useStaticAccent) ...[
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
_ColorChips(
|
||||
label: 'Accent color',
|
||||
values: AppSettings.accentChoices,
|
||||
selected: settings.staticAccentColor,
|
||||
onSelect: controller.setStaticAccentColor,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
const _Caption(
|
||||
'Show Now Playing as an animated cassette — cover art on '
|
||||
'the label, reels that spin and wind with the track.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<bool>(
|
||||
label: 'Now Playing art',
|
||||
values: const [false, true],
|
||||
selected: settings.nowPlayingCassette,
|
||||
labelFor: (v) => v ? 'Cassette' : 'Album cover',
|
||||
onSelect: controller.setNowPlayingCassette,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Browse',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption('Which view the Browse tab opens on.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<BrowseMode>(
|
||||
label: 'Default view',
|
||||
values: BrowseMode.values,
|
||||
selected: settings.defaultBrowseMode,
|
||||
labelFor: AppSettings.browseModeLabel,
|
||||
onSelect: controller.setDefaultBrowseMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Search',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'"Standard" matches only names/titles that contain your '
|
||||
'query. "Discovery" uses the server\'s broader matching.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<SearchMode>(
|
||||
label: 'Search mode',
|
||||
values: const [SearchMode.standard, SearchMode.discovery],
|
||||
selected: settings.searchMode,
|
||||
labelFor: AppSettings.searchModeLabel,
|
||||
onSelect: controller.setSearchMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Servers',
|
||||
active: true,
|
||||
trailing:
|
||||
conn.servers.isNotEmpty ? '(${conn.servers.length})' : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'Save multiple servers and switch the active one. Only '
|
||||
'one is connected at a time.'),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
if (conn.servers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
|
||||
child: Text('No servers saved yet.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else
|
||||
for (final s in conn.servers)
|
||||
_ServerManageRow(
|
||||
creds: s,
|
||||
active: s.id == conn.activeId,
|
||||
onActivate: () => ref
|
||||
.read(connectionProvider.notifier)
|
||||
.switchTo(s.id),
|
||||
onEdit: () =>
|
||||
showConnectSheet(context, initial: s),
|
||||
onDelete: () => ref
|
||||
.read(connectionProvider.notifier)
|
||||
.removeServer(s.id),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
child: const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add,
|
||||
size: 16, color: TimbreColors.foreground),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Add server',
|
||||
style:
|
||||
TextStyle(color: TimbreColors.foreground)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -79,6 +220,72 @@ class SettingsScreen extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A saved-server row in Settings: activate (tap the label), edit, or delete.
|
||||
class _ServerManageRow extends StatelessWidget {
|
||||
const _ServerManageRow({
|
||||
required this.creds,
|
||||
required this.active,
|
||||
required this.onActivate,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SubsonicCredentials creds;
|
||||
final bool active;
|
||||
final VoidCallback onActivate;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: onActivate,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: TimbreSpacing.minTouchTarget),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(active ? '● ' : '○ ',
|
||||
style: TextStyle(
|
||||
color: active ? accent : TimbreColors.dimmed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
creds.display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.foreground,
|
||||
fontWeight:
|
||||
active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: onDelete,
|
||||
icon:
|
||||
const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Caption extends StatelessWidget {
|
||||
const _Caption(this.text);
|
||||
final String text;
|
||||
|
|
@ -172,3 +379,90 @@ class _Chip extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
|
||||
/// color swatch and the color's own hue drives the active border/fill.
|
||||
class _ColorChips extends StatelessWidget {
|
||||
const _ColorChips({
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<AccentChoice> values;
|
||||
final Color selected;
|
||||
final ValueChanged<Color> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
Wrap(
|
||||
spacing: TimbreSpacing.sm,
|
||||
runSpacing: TimbreSpacing.sm,
|
||||
children: [
|
||||
for (final c in values)
|
||||
_ColorChip(
|
||||
choice: c,
|
||||
active: c.color == selected,
|
||||
onTap: () => onSelect(c.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorChip extends StatelessWidget {
|
||||
const _ColorChip({
|
||||
required this.choice,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AccentChoice choice;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.lg,
|
||||
vertical: TimbreSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: active ? choice.color : TimbreColors.border,
|
||||
),
|
||||
color: active
|
||||
? choice.color.withValues(alpha: 0.12)
|
||||
: TimbreColors.surface,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 12, height: 12, color: choice.color),
|
||||
const SizedBox(width: TimbreSpacing.sm),
|
||||
Text(
|
||||
choice.name,
|
||||
style: TextStyle(
|
||||
color: active ? choice.color : TimbreColors.foreground,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
lib/screens/tags_screen.dart
Normal file
187
lib/screens/tags_screen.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../playlists/playlists.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
|
||||
import 'playlists_screen.dart' show PlaylistDetailScreen;
|
||||
|
||||
/// Tags list — the same server-backed store as Playlists, filtered to the
|
||||
/// playlists carrying the tag marker. Tagging a song adds it to the tag's
|
||||
/// playlist; this screen is where tags are created, browsed, renamed, deleted.
|
||||
/// Tapping a tag reuses [PlaylistDetailScreen] (a tag *is* a playlist).
|
||||
class TagsScreen extends ConsumerWidget {
|
||||
const TagsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tags = ref.watch(tagsProvider);
|
||||
final controller = ref.read(playlistsProvider.notifier);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
const Text('Tags', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'New tag',
|
||||
onPressed: connected ? () => _create(context, ref) : null,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(TimbreSpacing.lg),
|
||||
child: HairlinePanel(
|
||||
title: 'Tags',
|
||||
active: true,
|
||||
trailing: tags.isEmpty ? null : '(${tags.length})',
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: tags.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
connected
|
||||
? 'No tags yet. Tap + to create one, then tag songs\n'
|
||||
'from their ⋮ menu.'
|
||||
: 'Connect to a server to see tags.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: TimbreColors.dimmed),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: tags.length,
|
||||
itemBuilder: (context, i) {
|
||||
final t = tags[i];
|
||||
return _TagRow(
|
||||
name: t.name,
|
||||
subtitle: t.songCount != null
|
||||
? '${t.songCount} tracks'
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PlaylistDetailScreen(id: t.id),
|
||||
),
|
||||
),
|
||||
onRename: connected
|
||||
? () async {
|
||||
final name = await promptPlaylistName(context,
|
||||
title: 'Rename tag', initial: t.name);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
controller.rename(t.id, name);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onDelete: connected
|
||||
? () => _confirmDelete(context, controller, t)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _create(BuildContext context, WidgetRef ref) async {
|
||||
final name = await promptPlaylistName(context, title: 'New tag');
|
||||
if (name != null && name.isNotEmpty) {
|
||||
await ref.read(playlistsProvider.notifier).createTag(name);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, PlaylistsController controller, Playlist t) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: TimbreColors.surface,
|
||||
title: Text('Delete tag "${t.name}"?'),
|
||||
content: const Text(
|
||||
'This removes the tag from every song. The songs themselves are '
|
||||
'not deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) controller.delete(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagRow extends StatelessWidget {
|
||||
const _TagRow({
|
||||
required this.name,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
this.onRename,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.label_outline, size: 18, color: TimbreColors.dimmed),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: TimbreColors.foreground)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(
|
||||
color: TimbreColors.dimmed, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onRename != null || onDelete != null)
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert,
|
||||
size: 20, color: TimbreColors.dimmed),
|
||||
color: TimbreColors.surface,
|
||||
onSelected: (v) {
|
||||
if (v == 'rename') onRename?.call();
|
||||
if (v == 'delete') onDelete?.call();
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (onRename != null)
|
||||
const PopupMenuItem(value: 'rename', child: Text('Rename')),
|
||||
if (onDelete != null)
|
||||
const PopupMenuItem(value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,43 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show Color;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// User-tunable audio quality. Streaming and offline downloads have independent
|
||||
/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` +
|
||||
/// `format`; a rate of 0 means original / no transcode). These are global (not
|
||||
/// per-server) and persisted to a single atomic JSON file, following the
|
||||
/// write pattern in `library/library_index.dart`.
|
||||
/// Top-level Browse mode selector. Lives here (not in `state/providers.dart`)
|
||||
/// so [AppSettings] can persist the user's default; `providers.dart` re-exports
|
||||
/// it, so existing importers are unaffected.
|
||||
enum BrowseMode { artists, albums, tracks }
|
||||
|
||||
/// Search matching strategy. `discovery` returns the server's full `search3`
|
||||
/// result (broad — e.g. songs matched via their artist name). `standard`
|
||||
/// post-filters to items whose *own* name/title matches the query.
|
||||
enum SearchMode { discovery, standard }
|
||||
|
||||
/// A selectable static accent color offered by the theme override (TODO #1).
|
||||
class AccentChoice {
|
||||
const AccentChoice(this.name, this.color);
|
||||
final String name;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// Application-wide preferences: audio quality, theme accent, default browse
|
||||
/// view, and search mode. Streaming and offline downloads have independent
|
||||
/// quality knobs, both driven by Subsonic's `stream` transcode params
|
||||
/// (`maxBitRate` + `format`; a rate of 0 means original / no transcode). These
|
||||
/// are global (not per-server) and persisted to a single atomic JSON file,
|
||||
/// following the write pattern in `library/library_index.dart`.
|
||||
class AppSettings {
|
||||
const AppSettings({
|
||||
this.streamMaxBitRate = 0,
|
||||
this.downloadMaxBitRate = 0,
|
||||
this.downloadFormat,
|
||||
this.useStaticAccent = false,
|
||||
this.staticAccentColor = _defaultStaticAccent,
|
||||
this.defaultBrowseMode = BrowseMode.artists,
|
||||
this.searchMode = SearchMode.discovery,
|
||||
this.nowPlayingCassette = false,
|
||||
});
|
||||
|
||||
/// Cap for live streaming, in kbps. 0 = original / no transcode.
|
||||
|
|
@ -26,20 +50,67 @@ class AppSettings {
|
|||
/// the original file / let the server decide.
|
||||
final String? downloadFormat;
|
||||
|
||||
/// When true, ignore album-art-derived accent and use [staticAccentColor]
|
||||
/// as the sole accent color. When false (default), the accent tracks the
|
||||
/// currently-playing album art.
|
||||
final bool useStaticAccent;
|
||||
|
||||
/// The fixed accent used while [useStaticAccent] is on. One of
|
||||
/// [accentChoices]; defaults to Orange.
|
||||
final Color staticAccentColor;
|
||||
|
||||
/// Which sub-view the Browse tab opens on.
|
||||
final BrowseMode defaultBrowseMode;
|
||||
|
||||
/// Which search matching strategy to apply globally.
|
||||
final SearchMode searchMode;
|
||||
|
||||
/// When true, the Now Playing "art" view shows the animated cassette (cover
|
||||
/// art on the label, reels driven by playback) instead of the plain square
|
||||
/// album cover.
|
||||
final bool nowPlayingCassette;
|
||||
|
||||
/// Offered bitrate choices (kbps); 0 renders as "Original".
|
||||
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
|
||||
|
||||
/// Offered download containers; null renders as "Original".
|
||||
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac'];
|
||||
|
||||
/// Static accent palette offered when the override is enabled (TODO #1).
|
||||
static const List<AccentChoice> accentChoices = [
|
||||
AccentChoice('Red', Color(0xFFDF3535)),
|
||||
AccentChoice('Blue', Color(0xFF7177EA)),
|
||||
AccentChoice('Green', Color(0xFF10996B)),
|
||||
AccentChoice('Orange', Color(0xFFDF7E35)),
|
||||
AccentChoice('Yellow', Color(0xFFDFC535)),
|
||||
AccentChoice('Purple', Color(0xFF926CE9)),
|
||||
AccentChoice('Cream', Color(0xFFFFDB9E)),
|
||||
];
|
||||
|
||||
static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange
|
||||
|
||||
static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
|
||||
static String formatLabel(String? f) => f ?? 'Original';
|
||||
static String browseModeLabel(BrowseMode m) => switch (m) {
|
||||
BrowseMode.artists => 'Artists',
|
||||
BrowseMode.albums => 'Albums',
|
||||
BrowseMode.tracks => 'Tracks',
|
||||
};
|
||||
static String searchModeLabel(SearchMode m) => switch (m) {
|
||||
SearchMode.standard => 'Standard',
|
||||
SearchMode.discovery => 'Discovery',
|
||||
};
|
||||
|
||||
AppSettings copyWith({
|
||||
int? streamMaxBitRate,
|
||||
int? downloadMaxBitRate,
|
||||
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
|
||||
Object? downloadFormat = _unset,
|
||||
bool? useStaticAccent,
|
||||
Color? staticAccentColor,
|
||||
BrowseMode? defaultBrowseMode,
|
||||
SearchMode? searchMode,
|
||||
bool? nowPlayingCassette,
|
||||
}) =>
|
||||
AppSettings(
|
||||
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
|
||||
|
|
@ -47,6 +118,11 @@ class AppSettings {
|
|||
downloadFormat: identical(downloadFormat, _unset)
|
||||
? this.downloadFormat
|
||||
: downloadFormat as String?,
|
||||
useStaticAccent: useStaticAccent ?? this.useStaticAccent,
|
||||
staticAccentColor: staticAccentColor ?? this.staticAccentColor,
|
||||
defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode,
|
||||
searchMode: searchMode ?? this.searchMode,
|
||||
nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette,
|
||||
);
|
||||
|
||||
static const Object _unset = Object();
|
||||
|
|
@ -55,13 +131,49 @@ class AppSettings {
|
|||
'streamMaxBitRate': streamMaxBitRate,
|
||||
'downloadMaxBitRate': downloadMaxBitRate,
|
||||
if (downloadFormat != null) 'downloadFormat': downloadFormat,
|
||||
'useStaticAccent': useStaticAccent,
|
||||
'staticAccentColor': _hexOf(staticAccentColor),
|
||||
'defaultBrowseMode': defaultBrowseMode.name,
|
||||
'searchMode': searchMode.name,
|
||||
'nowPlayingCassette': nowPlayingCassette,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
|
||||
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
|
||||
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
|
||||
downloadFormat: j['downloadFormat'] as String?,
|
||||
useStaticAccent: j['useStaticAccent'] as bool? ?? false,
|
||||
staticAccentColor:
|
||||
_colorOf(j['staticAccentColor'] as String?) ?? _defaultStaticAccent,
|
||||
defaultBrowseMode:
|
||||
_enumByName(BrowseMode.values, j['defaultBrowseMode'] as String?) ??
|
||||
BrowseMode.artists,
|
||||
searchMode:
|
||||
_enumByName(SearchMode.values, j['searchMode'] as String?) ??
|
||||
SearchMode.discovery,
|
||||
nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false,
|
||||
);
|
||||
|
||||
/// Serialize a color to a `#RRGGBB` hex string.
|
||||
static String _hexOf(Color c) =>
|
||||
'#${(c.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}';
|
||||
|
||||
/// Parse a `#RRGGBB` (or `RRGGBB`) hex string, or null if unparseable.
|
||||
static Color? _colorOf(String? hex) {
|
||||
if (hex == null) return null;
|
||||
final h = hex.startsWith('#') ? hex.substring(1) : hex;
|
||||
final v = int.tryParse(h, radix: 16);
|
||||
if (v == null || h.length != 6) return null;
|
||||
return Color(0xFF000000 | v);
|
||||
}
|
||||
|
||||
static T? _enumByName<T extends Enum>(List<T> values, String? name) {
|
||||
if (name == null) return null;
|
||||
for (final v in values) {
|
||||
if (v.name == name) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads settings on launch and persists every change (atomic temp+rename).
|
||||
|
|
@ -102,6 +214,31 @@ class SettingsController extends StateNotifier<AppSettings> {
|
|||
_persist();
|
||||
}
|
||||
|
||||
void setUseStaticAccent(bool value) {
|
||||
state = state.copyWith(useStaticAccent: value);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setStaticAccentColor(Color color) {
|
||||
state = state.copyWith(staticAccentColor: color);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setDefaultBrowseMode(BrowseMode mode) {
|
||||
state = state.copyWith(defaultBrowseMode: mode);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setSearchMode(SearchMode mode) {
|
||||
state = state.copyWith(searchMode: mode);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setNowPlayingCassette(bool value) {
|
||||
state = state.copyWith(nowPlayingCassette: value);
|
||||
_persist();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
final file = _file;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import '../screens/connect_sheet.dart';
|
|||
import '../screens/home_screen.dart';
|
||||
import '../screens/now_playing_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/mini_player.dart';
|
||||
|
||||
/// Top-level shell: the three Ratune tabs (Home / Browse / Now Playing) with a
|
||||
/// Top-level shell: the three Timbre tabs (Home / Browse / Now Playing) with a
|
||||
/// bottom tab bar and a status bar, mirroring the terminal layout.
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
|
@ -29,12 +30,39 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||
final _homeNavKey = GlobalKey<NavigatorState>();
|
||||
final _browseNavKey = GlobalKey<NavigatorState>();
|
||||
|
||||
// Seed the Browse view from the persisted default exactly once, when settings
|
||||
// finish loading. Guarded so a later change to the default (or a manual browse
|
||||
// selection) is never overridden mid-session.
|
||||
bool _browseSeeded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual<AppSettings>(settingsProvider, (prev, next) {
|
||||
if (_browseSeeded) return;
|
||||
_browseSeeded = true;
|
||||
ref.read(browseModeProvider.notifier).state = next.defaultBrowseMode;
|
||||
});
|
||||
}
|
||||
|
||||
GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) {
|
||||
0 => _homeNavKey,
|
||||
1 => _browseNavKey,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Tap on the bottom tab bar. Selecting a different tab just switches; tapping
|
||||
/// the already-active tab resets it to its root — popping the nested detail
|
||||
/// stack (e.g. Album → Browse home) so the tab button doubles as "go back to
|
||||
/// the top", instead of being a no-op.
|
||||
void _selectTab(int tapped, int current) {
|
||||
if (tapped == current) {
|
||||
_navKeyForTab(tapped)?.currentState?.popUntil((r) => r.isFirst);
|
||||
} else {
|
||||
ref.read(selectedTabProvider.notifier).state = tapped;
|
||||
}
|
||||
}
|
||||
|
||||
/// Android system-back: pop the active tab's nested stack first, then fall
|
||||
/// back to Home, and only exit the app from the Home root.
|
||||
void _handleBack(int tab) {
|
||||
|
|
@ -88,8 +116,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||
_TabBar(
|
||||
tabs: _tabs,
|
||||
index: index,
|
||||
onSelect: (i) =>
|
||||
ref.read(selectedTabProvider.notifier).state = i,
|
||||
onSelect: (i) => _selectTab(i, index),
|
||||
),
|
||||
const _StatusBar(),
|
||||
],
|
||||
|
|
@ -201,10 +228,10 @@ class _StatusBar extends ConsumerWidget {
|
|||
color: TimbreColors.surface,
|
||||
child: Row(
|
||||
children: [
|
||||
// Left: connection status — tap to open the connect sheet.
|
||||
// Left: connection status — tap to open the server switcher.
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
onTap: () => showServerSheet(context),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/widgets.dart' show NetworkImage;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -17,6 +14,11 @@ import '../theme/accent.dart';
|
|||
import '../theme/accent_extract.dart';
|
||||
import 'favorites.dart';
|
||||
|
||||
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
|
||||
// persisted) but historically lived here — re-export so existing importers of
|
||||
// this file keep resolving them.
|
||||
export '../settings/settings_store.dart' show BrowseMode, SearchMode;
|
||||
|
||||
// ---- Connection ---------------------------------------------------------
|
||||
|
||||
enum ConnStatus { disconnected, connecting, online, error }
|
||||
|
|
@ -27,6 +29,7 @@ class ConnectionState {
|
|||
this.client,
|
||||
this.credentials,
|
||||
this.error,
|
||||
this.servers = const [],
|
||||
});
|
||||
|
||||
final ConnStatus status;
|
||||
|
|
@ -34,14 +37,32 @@ class ConnectionState {
|
|||
final SubsonicCredentials? credentials;
|
||||
final String? error;
|
||||
|
||||
/// All saved servers (one may be the active [credentials]). Drives the
|
||||
/// server switcher and the Settings "Servers" section.
|
||||
final List<SubsonicCredentials> servers;
|
||||
|
||||
bool get isOnline => status == ConnStatus.online;
|
||||
|
||||
/// Id of the active server, or null when disconnected with none configured.
|
||||
String? get activeId => credentials?.id;
|
||||
|
||||
/// Only the saved-servers list changes; connection fields are preserved.
|
||||
ConnectionState copyWith({List<SubsonicCredentials>? servers}) =>
|
||||
ConnectionState(
|
||||
status: status,
|
||||
client: client,
|
||||
credentials: credentials,
|
||||
error: error,
|
||||
servers: servers ?? this.servers,
|
||||
);
|
||||
}
|
||||
|
||||
final credentialStoreProvider =
|
||||
Provider<CredentialStore>((_) => CredentialStore());
|
||||
|
||||
/// Owns the active server connection: builds the client, pings, persists
|
||||
/// credentials, and auto-restores on launch.
|
||||
/// Owns the active server connection and the list of saved servers: builds the
|
||||
/// client, pings, persists credentials, auto-restores on launch, and switches
|
||||
/// between servers (one active at a time — TODO #2).
|
||||
class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
ConnectionController(this._store)
|
||||
: super(const ConnectionState(status: ConnStatus.disconnected)) {
|
||||
|
|
@ -49,13 +70,21 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
}
|
||||
|
||||
final CredentialStore _store;
|
||||
List<SubsonicCredentials> _servers = const [];
|
||||
String? _activeId;
|
||||
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
final creds = await _store.load();
|
||||
if (creds != null) {
|
||||
await connect(creds, persist: false);
|
||||
}
|
||||
_servers = await _store.loadAll();
|
||||
_activeId = await _store.loadActiveId();
|
||||
state = state.copyWith(servers: _servers);
|
||||
if (_servers.isEmpty) return;
|
||||
final active = _servers.firstWhere(
|
||||
(s) => s.id == _activeId,
|
||||
orElse: () => _servers.first,
|
||||
);
|
||||
_activeId = active.id;
|
||||
await connect(active, persist: false);
|
||||
} catch (_) {
|
||||
// No secure-storage backend available (e.g. Linux without a running
|
||||
// keyring daemon) — start disconnected rather than crashing.
|
||||
|
|
@ -63,7 +92,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
}
|
||||
|
||||
Future<bool> connect(SubsonicCredentials creds, {bool persist = true}) async {
|
||||
state = const ConnectionState(status: ConnStatus.connecting);
|
||||
state = ConnectionState(status: ConnStatus.connecting, servers: _servers);
|
||||
final client = SubsonicClient(
|
||||
baseUrl: creds.url,
|
||||
username: creds.username,
|
||||
|
|
@ -79,20 +108,25 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
status: ConnStatus.error,
|
||||
credentials: creds,
|
||||
error: 'Could not reach the server.',
|
||||
servers: _servers,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Persistence is best-effort: a locked/absent secure-storage backend
|
||||
// (e.g. a locked Linux keyring) must not stop this session connecting.
|
||||
// A successful connect saves the server and makes it active.
|
||||
if (persist) {
|
||||
try {
|
||||
await _store.save(creds);
|
||||
await _addOrUpdate(creds, makeActive: true);
|
||||
} catch (_) {}
|
||||
} else {
|
||||
_activeId = creds.id;
|
||||
}
|
||||
state = ConnectionState(
|
||||
status: ConnStatus.online,
|
||||
client: client,
|
||||
credentials: creds,
|
||||
servers: _servers,
|
||||
);
|
||||
return true;
|
||||
} on SubsonicError catch (e) {
|
||||
|
|
@ -102,6 +136,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
status: ConnStatus.error,
|
||||
credentials: e.isAuthFailure ? null : creds,
|
||||
error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
|
||||
servers: _servers,
|
||||
);
|
||||
return false;
|
||||
} catch (e) {
|
||||
|
|
@ -109,13 +144,85 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
status: ConnStatus.error,
|
||||
credentials: creds,
|
||||
error: e.toString(),
|
||||
servers: _servers,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active server to [id] and connect. No password re-entry — saved
|
||||
/// servers keep their credentials. Per-server downloads/playlists reload
|
||||
/// automatically via [serverKeyProvider] listeners.
|
||||
Future<bool> switchTo(String id) async {
|
||||
SubsonicCredentials? creds;
|
||||
for (final s in _servers) {
|
||||
if (s.id == id) {
|
||||
creds = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (creds == null) return false;
|
||||
_activeId = id;
|
||||
try {
|
||||
await _store.saveAll(_servers, _activeId);
|
||||
} catch (_) {}
|
||||
return connect(creds, persist: false);
|
||||
}
|
||||
|
||||
/// Add or update a saved server *without* connecting (used by the add/edit
|
||||
/// form). Updating an entry with the same [SubsonicCredentials.id] overwrites
|
||||
/// its password/alias.
|
||||
Future<void> saveServer(SubsonicCredentials creds) async {
|
||||
await _addOrUpdate(creds, makeActive: false);
|
||||
state = state.copyWith(servers: _servers);
|
||||
}
|
||||
|
||||
/// Remove a saved server. If it was the active one, switch to another saved
|
||||
/// server (or go disconnected when none remain).
|
||||
Future<void> removeServer(String id) async {
|
||||
_servers = _servers.where((s) => s.id != id).toList();
|
||||
final removedActive = id == _activeId;
|
||||
if (removedActive) {
|
||||
_activeId = _servers.isEmpty ? null : _servers.first.id;
|
||||
}
|
||||
try {
|
||||
await _store.saveAll(_servers, _activeId);
|
||||
} catch (_) {}
|
||||
if (removedActive) {
|
||||
if (_servers.isNotEmpty) {
|
||||
await connect(_servers.first, persist: false);
|
||||
} else {
|
||||
state = ConnectionState(
|
||||
status: ConnStatus.disconnected,
|
||||
servers: _servers,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(servers: _servers);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addOrUpdate(SubsonicCredentials creds,
|
||||
{required bool makeActive}) async {
|
||||
final idx = _servers.indexWhere((s) => s.id == creds.id);
|
||||
final next = [..._servers];
|
||||
if (idx >= 0) {
|
||||
next[idx] = creds;
|
||||
} else {
|
||||
next.add(creds);
|
||||
}
|
||||
_servers = next;
|
||||
if (makeActive) _activeId = creds.id;
|
||||
await _store.saveAll(_servers, _activeId);
|
||||
}
|
||||
|
||||
/// Forget every saved server and drop the connection.
|
||||
Future<void> disconnect() async {
|
||||
await _store.clear();
|
||||
_servers = const [];
|
||||
_activeId = null;
|
||||
try {
|
||||
await _store.clear();
|
||||
} catch (_) {}
|
||||
state = const ConnectionState(status: ConnStatus.disconnected);
|
||||
}
|
||||
}
|
||||
|
|
@ -135,12 +242,7 @@ final subsonicClientProvider = Provider<SubsonicClient?>(
|
|||
/// retains credentials). Null when no server has ever been configured. Used to
|
||||
/// scope on-disk downloads/playlists to the server they belong to.
|
||||
final serverKeyProvider = Provider<String?>((ref) {
|
||||
final creds = ref.watch(connectionProvider).credentials;
|
||||
if (creds == null) return null;
|
||||
final base = creds.url.endsWith('/')
|
||||
? creds.url.substring(0, creds.url.length - 1)
|
||||
: creds.url;
|
||||
return md5.convert(utf8.encode('$base|${creds.username}')).toString();
|
||||
return ref.watch(connectionProvider).credentials?.id;
|
||||
});
|
||||
|
||||
// ---- Navigation / browse mode -------------------------------------------
|
||||
|
|
@ -152,9 +254,8 @@ final selectedTabProvider = StateProvider<int>((_) => 0);
|
|||
/// Index of the Now Playing tab in the shell's tab list.
|
||||
const int nowPlayingTabIndex = 2;
|
||||
|
||||
/// Top-level Browse mode selector.
|
||||
enum BrowseMode { artists, albums, tracks }
|
||||
|
||||
/// Top-level Browse mode selector. Seeded once from the persisted
|
||||
/// `AppSettings.defaultBrowseMode` by `AppShell`; manual selection then wins.
|
||||
final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
|
||||
|
||||
// ---- Library ------------------------------------------------------------
|
||||
|
|
@ -227,12 +328,30 @@ final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
|
|||
final searchProvider =
|
||||
FutureProvider.family<SearchResult3, String>((ref, query) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null || query.trim().isEmpty) {
|
||||
final q = query.trim();
|
||||
if (client == null || q.isEmpty) {
|
||||
return SearchResult3(artists: const [], albums: const [], songs: const []);
|
||||
}
|
||||
return client.search3(query.trim());
|
||||
final result = await client.search3(q);
|
||||
// "Standard" trims the server's broad matches down to name/title hits;
|
||||
// "Discovery" (default) returns the server result unchanged.
|
||||
final mode = ref.watch(settingsProvider).searchMode;
|
||||
return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result;
|
||||
});
|
||||
|
||||
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
|
||||
/// (case-insensitive) — dropping songs that only matched via their artist or
|
||||
/// album fields. Backs the "Standard" search mode (TODO #4).
|
||||
SearchResult3 filterSearchToStandard(SearchResult3 r, String query) {
|
||||
final q = query.toLowerCase();
|
||||
bool has(String? s) => s != null && s.toLowerCase().contains(q);
|
||||
return SearchResult3(
|
||||
artists: r.artists.where((a) => has(a.name)).toList(),
|
||||
albums: r.albums.where((a) => has(a.name)).toList(),
|
||||
songs: r.songs.where((s) => has(s.title)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- History (Home tab) -------------------------------------------------
|
||||
|
||||
final playHistoryProvider =
|
||||
|
|
@ -318,6 +437,20 @@ final playlistsProvider =
|
|||
return controller;
|
||||
});
|
||||
|
||||
/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the
|
||||
/// Playlists screen and the "add to playlist" sheet.
|
||||
final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
|
||||
.watch(playlistsProvider)
|
||||
.playlists
|
||||
.where((p) => !isTagPlaylist(p))
|
||||
.toList());
|
||||
|
||||
/// Tags — playlists carrying the tag comment marker. Backs the Tags screen and
|
||||
/// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the
|
||||
/// partition differs.
|
||||
final tagsProvider = Provider<List<Playlist>>(
|
||||
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList());
|
||||
|
||||
// ---- Playback -----------------------------------------------------------
|
||||
|
||||
final playbackProvider =
|
||||
|
|
@ -345,6 +478,8 @@ final playbackProvider =
|
|||
streamUriFor: streamUriFor,
|
||||
coverArtUriFor: coverArtUriFor,
|
||||
onArt: (artUri) async {
|
||||
// Skip extraction entirely when the user has pinned a static accent.
|
||||
if (ref.read(settingsProvider).useStaticAccent) return;
|
||||
final color = await extractAccent(NetworkImage(artUri.toString()));
|
||||
if (color != null) ref.read(accentProvider.notifier).set(color);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// A Subsonic server connection. The password is only ever held in memory and
|
||||
/// in the platform secure store — never in plain app storage (mirrors Ratune's
|
||||
/// in the platform secure store — never in plain app storage (mirrors Timbre's
|
||||
/// keyring-first secret handling).
|
||||
class SubsonicCredentials {
|
||||
const SubsonicCredentials({
|
||||
|
|
@ -15,50 +18,121 @@ class SubsonicCredentials {
|
|||
final String username;
|
||||
final String password;
|
||||
|
||||
/// Optional display label shown instead of the raw URL (Ratune `[server].alias`).
|
||||
/// Optional display label shown instead of the raw URL (Timbre `[server].alias`).
|
||||
final String? alias;
|
||||
|
||||
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
|
||||
|
||||
/// Stable identity for a saved server: `md5(baseUrl|username)`, matching the
|
||||
/// cache key in `serverKeyProvider` so per-server data lines up. Two entries
|
||||
/// with the same URL + username are the "same" server (different password /
|
||||
/// alias overwrites it).
|
||||
String get id {
|
||||
final base = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
return md5.convert(utf8.encode('$base|$username')).toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (alias != null && alias!.isNotEmpty) 'alias': alias,
|
||||
};
|
||||
|
||||
factory SubsonicCredentials.fromJson(Map<String, dynamic> j) {
|
||||
final alias = j['alias'] as String?;
|
||||
return SubsonicCredentials(
|
||||
url: j['url'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
password: j['password'] as String? ?? '',
|
||||
alias: (alias == null || alias.isEmpty) ? null : alias,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [SubsonicCredentials] to the platform secure store
|
||||
/// (Keychain / Keystore / libsecret).
|
||||
/// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one
|
||||
/// to the platform secure store (Keychain / Keystore / libsecret). Legacy
|
||||
/// single-server installs are migrated into the list on first load.
|
||||
class CredentialStore {
|
||||
CredentialStore([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
/// JSON array of all saved servers.
|
||||
static const _kServers = 'subsonic_servers';
|
||||
|
||||
/// Id ([SubsonicCredentials.id]) of the active server.
|
||||
static const _kActive = 'subsonic_active';
|
||||
|
||||
// Legacy single-server keys, read once for migration then removed.
|
||||
static const _kUrl = 'subsonic_url';
|
||||
static const _kUser = 'subsonic_username';
|
||||
static const _kPass = 'subsonic_password';
|
||||
static const _kAlias = 'subsonic_alias';
|
||||
|
||||
Future<void> save(SubsonicCredentials creds) async {
|
||||
await _storage.write(key: _kUrl, value: creds.url);
|
||||
await _storage.write(key: _kUser, value: creds.username);
|
||||
await _storage.write(key: _kPass, value: creds.password);
|
||||
await _storage.write(key: _kAlias, value: creds.alias ?? '');
|
||||
/// All saved servers. Empty if none configured. Migrates a legacy
|
||||
/// single-server install into the new list format on first call.
|
||||
Future<List<SubsonicCredentials>> loadAll() async {
|
||||
final raw = await _storage.read(key: _kServers);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List)
|
||||
.whereType<Map>()
|
||||
.map((m) => SubsonicCredentials.fromJson(m.cast<String, dynamic>()))
|
||||
.where((c) => c.url.isNotEmpty && c.username.isNotEmpty)
|
||||
.toList();
|
||||
return list;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
// Migrate a legacy single-server install, if present.
|
||||
final migrated = await _migrateLegacy();
|
||||
return migrated == null ? const [] : [migrated];
|
||||
}
|
||||
|
||||
Future<SubsonicCredentials?> load() async {
|
||||
/// Id of the active server, or null.
|
||||
Future<String?> loadActiveId() => _storage.read(key: _kActive);
|
||||
|
||||
/// Overwrite the saved list and active pointer atomically-ish (two writes).
|
||||
Future<void> saveAll(
|
||||
List<SubsonicCredentials> servers, String? activeId) async {
|
||||
await _storage.write(
|
||||
key: _kServers,
|
||||
value: jsonEncode([for (final s in servers) s.toJson()]),
|
||||
);
|
||||
if (activeId == null) {
|
||||
await _storage.delete(key: _kActive);
|
||||
} else {
|
||||
await _storage.write(key: _kActive, value: activeId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SubsonicCredentials?> _migrateLegacy() async {
|
||||
final url = await _storage.read(key: _kUrl);
|
||||
final user = await _storage.read(key: _kUser);
|
||||
final pass = await _storage.read(key: _kPass);
|
||||
if (url == null || user == null || pass == null) return null;
|
||||
final alias = await _storage.read(key: _kAlias);
|
||||
return SubsonicCredentials(
|
||||
final creds = SubsonicCredentials(
|
||||
url: url,
|
||||
username: user,
|
||||
password: pass,
|
||||
alias: (alias == null || alias.isEmpty) ? null : alias,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
// Write the new format, point at it, then drop the legacy keys.
|
||||
await saveAll([creds], creds.id);
|
||||
await _storage.delete(key: _kUrl);
|
||||
await _storage.delete(key: _kUser);
|
||||
await _storage.delete(key: _kPass);
|
||||
await _storage.delete(key: _kAlias);
|
||||
return creds;
|
||||
}
|
||||
|
||||
/// Remove all saved servers and the active pointer.
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _kServers);
|
||||
await _storage.delete(key: _kActive);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/// Defensive JSON helpers for the Subsonic protocol.
|
||||
///
|
||||
/// Subsonic servers are inconsistent (Ratune handles the same quirks in
|
||||
/// `ratune-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
|
||||
/// Subsonic servers are inconsistent (Timbre handles the same quirks in
|
||||
/// `timbre-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
|
||||
/// * a field that is sometimes a single object and sometimes an array
|
||||
/// * IDs that are sometimes strings and sometimes integers
|
||||
/// Every accessor below tolerates nulls and mixed types rather than throwing.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'json_helpers.dart';
|
||||
|
||||
/// Subsonic domain models, ported from `ratune-subsonic/src/models.rs`.
|
||||
/// Subsonic domain models, ported from `timbre-subsonic/src/models.rs`.
|
||||
/// Nearly every field is nullable — servers omit fields freely. All parsing
|
||||
/// goes through the defensive helpers in `json_helpers.dart`.
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ class SearchResult3 {
|
|||
}
|
||||
|
||||
/// A playlist summary from `getPlaylists` (no tracks), ported from
|
||||
/// `ratune-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
|
||||
/// `timbre-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
|
||||
/// (`playlists/playlists.dart`).
|
||||
class Playlist {
|
||||
Playlist({
|
||||
|
|
@ -253,6 +253,7 @@ class Playlist {
|
|||
this.owner,
|
||||
this.public,
|
||||
this.coverArt,
|
||||
this.comment,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
|
@ -263,6 +264,10 @@ class Playlist {
|
|||
final bool? public;
|
||||
final String? coverArt;
|
||||
|
||||
/// Free-text playlist comment. Timbre stores a marker here to flag a playlist
|
||||
/// as a "tag" (see `playlists.dart`'s `kTagMarker`); otherwise usually null.
|
||||
final String? comment;
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: asString(j['id']) ?? '',
|
||||
name: asString(j['name']) ?? 'Untitled',
|
||||
|
|
@ -271,6 +276,7 @@ class Playlist {
|
|||
owner: asString(j['owner']),
|
||||
public: j['public'] is bool ? j['public'] as bool : null,
|
||||
coverArt: asString(j['coverArt']),
|
||||
comment: asString(j['comment']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
|
@ -281,6 +287,7 @@ class Playlist {
|
|||
if (owner != null) 'owner': owner,
|
||||
if (public != null) 'public': public,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
if (comment != null) 'comment': comment,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +300,7 @@ class PlaylistDetail {
|
|||
this.songCount,
|
||||
this.duration,
|
||||
this.coverArt,
|
||||
this.comment,
|
||||
this.songs = const [],
|
||||
});
|
||||
|
||||
|
|
@ -301,6 +309,9 @@ class PlaylistDetail {
|
|||
final int? songCount;
|
||||
final int? duration;
|
||||
final String? coverArt;
|
||||
|
||||
/// See [Playlist.comment] — carries the Timbre tag marker when present.
|
||||
final String? comment;
|
||||
final List<Song> songs;
|
||||
|
||||
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
|
||||
|
|
@ -309,6 +320,7 @@ class PlaylistDetail {
|
|||
songCount: asInt(j['songCount']),
|
||||
duration: asInt(j['duration']),
|
||||
coverArt: asString(j['coverArt']),
|
||||
comment: asString(j['comment']),
|
||||
songs: oneOrMany(j['entry'], Song.fromJson),
|
||||
);
|
||||
|
||||
|
|
@ -318,6 +330,7 @@ class PlaylistDetail {
|
|||
if (songCount != null) 'songCount': songCount,
|
||||
if (duration != null) 'duration': duration,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
if (comment != null) 'comment': comment,
|
||||
'entry': songs.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
|
||||
|
|
@ -327,5 +340,6 @@ class PlaylistDetail {
|
|||
songCount: songCount ?? songs.length,
|
||||
duration: duration,
|
||||
coverArt: coverArt,
|
||||
comment: comment,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import 'json_helpers.dart';
|
|||
import 'models.dart';
|
||||
|
||||
/// A Subsonic API error (server returned `status: "failed"`), ported from
|
||||
/// `ratune-subsonic/src/error.rs`. Code 40 is the auth-failure code.
|
||||
/// `timbre-subsonic/src/error.rs`. Code 40 is the auth-failure code.
|
||||
class SubsonicError implements Exception {
|
||||
SubsonicError(this.code, this.message);
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ class SubsonicError implements Exception {
|
|||
String toString() => 'SubsonicError($code): $message';
|
||||
}
|
||||
|
||||
/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`.
|
||||
/// Subsonic HTTP client, ported from `timbre-subsonic/src/client.rs`.
|
||||
///
|
||||
/// Auth is the classic token scheme: a fresh random salt per request and
|
||||
/// `token = MD5(password + salt)`, sent as query params
|
||||
|
|
@ -57,7 +57,7 @@ class SubsonicClient {
|
|||
url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
|
||||
/// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in
|
||||
/// for the platform CSPRNG; Ratune uses a weaker LCG here).
|
||||
/// for the platform CSPRNG; Timbre uses a weaker LCG here).
|
||||
String _makeSalt([int length = 12]) {
|
||||
final rng = Random.secure();
|
||||
return List.generate(
|
||||
|
|
@ -190,7 +190,7 @@ class SubsonicClient {
|
|||
}
|
||||
|
||||
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId)
|
||||
/// exactly like Ratune's `set_starred` (`client.rs`).
|
||||
/// exactly like Timbre's `set_starred` (`client.rs`).
|
||||
Future<void> setStarred({
|
||||
required bool starred,
|
||||
String? songId,
|
||||
|
|
@ -218,7 +218,7 @@ class SubsonicClient {
|
|||
}
|
||||
|
||||
// ---- Playlists ----------------------------------------------------------
|
||||
// Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through
|
||||
// Ported from `timbre-subsonic/src/client.rs`. Track mutations all go through
|
||||
// `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`.
|
||||
|
||||
/// `getPlaylists` — every playlist visible to the authenticated user.
|
||||
|
|
@ -266,6 +266,13 @@ class SubsonicClient {
|
|||
await _get('updatePlaylist', {'playlistId': playlistId, 'name': name});
|
||||
}
|
||||
|
||||
/// `updatePlaylist` + `comment` — set a playlist's comment. Timbre uses this
|
||||
/// to mark a playlist as a tag (`createPlaylist` can't set a comment inline).
|
||||
Future<void> setPlaylistComment(String playlistId, String comment) async {
|
||||
await _get(
|
||||
'updatePlaylist', {'playlistId': playlistId, 'comment': comment});
|
||||
}
|
||||
|
||||
/// `deletePlaylist` — delete a playlist by id.
|
||||
Future<void> deletePlaylist(String id) async {
|
||||
await _get('deletePlaylist', {'id': id});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
|
||||
import 'tokens.dart';
|
||||
|
||||
/// Holds the current accent color — the "hero" of Ratune's aesthetic.
|
||||
/// Holds the current accent color — the "hero" of Timbre's aesthetic.
|
||||
///
|
||||
/// In the `dynamic` theme this is extracted from the playing track's album
|
||||
/// art and boosted for readability in OKLab space (Ratune `color.rs:11-64`),
|
||||
/// art and boosted for readability in OKLab space (Timbre `color.rs:11-64`),
|
||||
/// then interpolated toward over ~400ms. Phase 1 just exposes the default and
|
||||
/// a setter; the extraction + animation land in Phase 2 with playback.
|
||||
class AccentNotifier extends StateNotifier<Color> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
/// Extract a lively accent color from album art, mirroring Ratune's
|
||||
/// Extract a lively accent color from album art, mirroring Timbre's
|
||||
/// art-driven `dynamic` theme (`color.rs`): pick the most vibrant swatch, then
|
||||
/// nudge it into a readable lightness/saturation band. Ratune does the boost in
|
||||
/// nudge it into a readable lightness/saturation band. Timbre does the boost in
|
||||
/// OKLab; this HSL approximation is close enough for now (OKLab is a later
|
||||
/// refinement noted in the roadmap).
|
||||
Future<Color?> extractAccent(ImageProvider image) async {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import 'package:google_fonts/google_fonts.dart';
|
|||
|
||||
import 'tokens.dart';
|
||||
|
||||
/// Builds the app [ThemeData] from the Ratune tokens.
|
||||
/// Builds the app [ThemeData] from the Timbre tokens.
|
||||
///
|
||||
/// Monospace type is core to the identity — every surface uses JetBrains Mono
|
||||
/// (a close analog to the terminal fonts in Ratune's screenshots). The [accent]
|
||||
/// (a close analog to the terminal fonts in Timbre's screenshots). The [accent]
|
||||
/// is passed in so the theme rebuilds when album-art extraction changes it.
|
||||
ThemeData buildTimbreTheme(Color accent) {
|
||||
final mono = GoogleFonts.jetBrainsMonoTextTheme(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Design tokens mirroring Ratune's `dynamic`/`static` theme defaults
|
||||
/// (see ratune `docs/sample-config.toml`, `theme.rs`, `color.rs`).
|
||||
/// Design tokens mirroring Timbre's `dynamic`/`static` theme defaults
|
||||
/// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`).
|
||||
///
|
||||
/// The palette is intentionally near-black and low-contrast; the *accent*
|
||||
/// is the one lively color and, in the `dynamic` theme, is extracted live
|
||||
|
|
@ -25,7 +25,7 @@ class TimbreColors {
|
|||
static const Color border = Color(0xFF252525);
|
||||
|
||||
/// Hairline borders when a pane is focused — `border_active`.
|
||||
/// Ratune tints this toward the accent; we start with a lighter grey and
|
||||
/// Timbre tints this toward the accent; we start with a lighter grey and
|
||||
/// swap in the accent at runtime once art extraction lands.
|
||||
static const Color borderActive = Color(0xFF3A3A3A);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// The segmented block progress bar from Ratune's now-playing strip
|
||||
/// The segmented block progress bar from Timbre's now-playing strip
|
||||
/// (`progress_style = "██░"`). Discrete cells: filled cells use the accent,
|
||||
/// empty cells the border grey, with hairline gaps between them.
|
||||
class BlockProgressBar extends StatelessWidget {
|
||||
|
|
|
|||
252
lib/widgets/cassette_view.dart
Normal file
252
lib/widgets/cassette_view.dart
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart' show Ticker;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../state/providers.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Animated cassette for the Now Playing screen. Composites, in the shell's
|
||||
/// `469×298` coordinate space, from back to front:
|
||||
/// 1. the cover art on the label (the big inner body panel, cropped to fill),
|
||||
/// 2. the two tape reels — a dark "well" plus the winding tape,
|
||||
/// 3. the two spindle cogs, rotating while the track plays,
|
||||
/// 4. the static shell SVG on top, which frames everything — the reels and
|
||||
/// cogs show through its circular reel holes, so they read as sitting
|
||||
/// *inside* the cassette rather than pasted over it.
|
||||
///
|
||||
/// The reels are modelled on a real compact cassette: tape moves at a constant
|
||||
/// *linear* speed, so a reel's wound radius scales with √(remaining length) and
|
||||
/// its angular speed with 1/radius (the fuller reel turns slower). See
|
||||
/// [_CassetteConfig].
|
||||
class CassetteView extends ConsumerStatefulWidget {
|
||||
const CassetteView({super.key, required this.artUri});
|
||||
|
||||
/// Current track's cover-art URL (already sized by the caller), or null.
|
||||
final String? artUri;
|
||||
|
||||
@override
|
||||
ConsumerState<CassetteView> createState() => _CassetteViewState();
|
||||
}
|
||||
|
||||
class _CassetteViewState extends ConsumerState<CassetteView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final Ticker _ticker;
|
||||
Duration _last = Duration.zero;
|
||||
final _model = _CassetteModel();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ticker = createTicker(_onTick)..start();
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
|
||||
_last = elapsed;
|
||||
if (dt <= 0) return;
|
||||
// Read (not watch) inside the ticker: the model drives repaints itself, and
|
||||
// watching here would rebuild the whole widget every position tick.
|
||||
final s = ref.read(playbackProvider);
|
||||
_model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const cfg = _CassetteConfig.instance;
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: cfg.viewBox.width / cfg.viewBox.height,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: cfg.viewBox.width,
|
||||
height: cfg.viewBox.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 1. Cover art on the label — the big inner panel. The shell (on
|
||||
// top) is transparent there, so the art shows as the "label".
|
||||
Positioned.fromRect(
|
||||
rect: cfg.labelRect,
|
||||
child: ClipRect(child: _LabelArt(artUri: widget.artUri)),
|
||||
),
|
||||
// 2. Tape reels (well + winding tape). Repaints off the model.
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _TapePainter(_model)),
|
||||
),
|
||||
// 3. Spinning cogs — one SvgPicture each, rotated by an
|
||||
// AnimatedBuilder so only the transform rebuilds per frame.
|
||||
_spindle(cfg.leftReel, () => _model.angleLeft),
|
||||
_spindle(cfg.rightReel, () => _model.angleRight),
|
||||
// 4. Static shell on top: frames the reels through its holes.
|
||||
Positioned.fill(
|
||||
child: SvgPicture.asset(
|
||||
'assets/casette/casette_shell.svg',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _spindle(Offset center, double Function() angle) {
|
||||
const size = _CassetteConfig.spindleSize;
|
||||
// Built once; AnimatedBuilder rotates this cached child rather than
|
||||
// re-inflating the SVG every frame.
|
||||
final cog = SvgPicture.asset(
|
||||
'assets/casette/spindle.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
);
|
||||
return Positioned(
|
||||
left: center.dx - size / 2,
|
||||
top: center.dy - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
child: AnimatedBuilder(
|
||||
animation: _model,
|
||||
child: cog,
|
||||
builder: (_, child) =>
|
||||
Transform.rotate(angle: angle(), child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cover art (or a neutral fallback) for the label region.
|
||||
class _LabelArt extends StatelessWidget {
|
||||
const _LabelArt({required this.artUri});
|
||||
|
||||
final String? artUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (artUri == null) {
|
||||
return const ColoredBox(color: TimbreColors.surface);
|
||||
}
|
||||
return Image.network(
|
||||
artUri!,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover, // square art → wide label: crop the sides/top
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const ColoredBox(color: TimbreColors.surface),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the live reel angles + progress and advances them each tick. A
|
||||
/// [ChangeNotifier] so the spindle transforms and tape painter repaint without
|
||||
/// rebuilding the whole [CassetteView].
|
||||
class _CassetteModel extends ChangeNotifier {
|
||||
double angleLeft = 0;
|
||||
double angleRight = 0;
|
||||
double progress = 0;
|
||||
|
||||
void update({
|
||||
required double dt,
|
||||
required bool playing,
|
||||
required double progress,
|
||||
}) {
|
||||
final progressChanged = progress != this.progress;
|
||||
this.progress = progress;
|
||||
if (playing) {
|
||||
const cfg = _CassetteConfig.instance;
|
||||
// ω = v / R, same (clockwise) direction for both reels.
|
||||
angleLeft += cfg.linearSpeed / cfg.radius(progress, supply: true) * dt;
|
||||
angleRight += cfg.linearSpeed / cfg.radius(progress, supply: false) * dt;
|
||||
}
|
||||
// Idle + no seek ⇒ nothing moved, so skip the repaint.
|
||||
if (playing || progressChanged) notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the two reels as large tape discs centred on the spindles, plus a
|
||||
/// light pad behind them. Everything is clipped to the window band so it never
|
||||
/// bleeds onto the label; the shell (painted on top) then masks it to the parts
|
||||
/// that should show — the reel holes and the centre window, where each disc's
|
||||
/// inner edge appears as a crescent that grows/shrinks with
|
||||
/// [_CassetteModel.progress] as tape winds from one reel to the other.
|
||||
class _TapePainter extends CustomPainter {
|
||||
_TapePainter(this.model) : super(repaint: model);
|
||||
|
||||
final _CassetteModel model;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const cfg = _CassetteConfig.instance;
|
||||
canvas.save();
|
||||
// Keep the tape inside the window band — off the (transparent) label.
|
||||
canvas.clipRect(cfg.windowRect);
|
||||
// Light pad behind the tape; the shell masks it down to the centre window.
|
||||
canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor);
|
||||
final tape = Paint()..color = cfg.tapeColor;
|
||||
canvas.drawCircle(
|
||||
cfg.leftReel, cfg.radius(model.progress, supply: true), tape);
|
||||
canvas.drawCircle(
|
||||
cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// Repaint is driven by `repaint: model`; geometry is fully derived from it.
|
||||
@override
|
||||
bool shouldRepaint(_TapePainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// All the fixed geometry, measured off `casette_shell.svg` (viewBox 469×298),
|
||||
/// plus the reel physics. Grouped and tunable in one place.
|
||||
class _CassetteConfig {
|
||||
const _CassetteConfig();
|
||||
static const instance = _CassetteConfig();
|
||||
|
||||
final Size viewBox = const Size(469, 298);
|
||||
|
||||
/// The big inner body panel — the transparent "label" the cover art fills.
|
||||
/// Slightly oversized so it fully backs the shell's hole (excess is masked
|
||||
/// by the shell frame on top).
|
||||
final Rect labelRect = const Rect.fromLTRB(25, 27, 444, 220);
|
||||
|
||||
// The window band the tape is confined to (clip), so it never touches the
|
||||
// label; the shell then masks it to the reel holes + centre window.
|
||||
final Rect windowRect = const Rect.fromLTRB(62.5, 98.5, 405.5, 172);
|
||||
|
||||
// Reel + spindle centres (the two window holes).
|
||||
final Offset leftReel = const Offset(135, 135);
|
||||
final Offset rightReel = const Offset(334, 135);
|
||||
|
||||
// Wound-tape radius bounds: [hubRadius] = empty reel (just covers the hole),
|
||||
// [fullRadius] = full reel (its crescent reaches well into the centre window).
|
||||
final double hubRadius = 36;
|
||||
final double fullRadius = 80;
|
||||
|
||||
// Cog display size (native art is 105×105, centred on the axle). ~ the reel
|
||||
// hole so the cog seats in the well with a thin tape rim showing around it.
|
||||
static const double spindleSize = 36;
|
||||
|
||||
// Tape linear speed in viewBox units/second — sets how fast the cogs spin
|
||||
// (ω = linearSpeed / radius). Purely cosmetic.
|
||||
final double linearSpeed = 40;
|
||||
|
||||
final Color tapeColor = const Color(0xFF121212); // wound tape
|
||||
final Color padColor = const Color(0xFF1A1A1A); // centre-window backing
|
||||
|
||||
/// Wound radius at [progress]. Radius scales with √(wound length): the supply
|
||||
/// reel is full at p=0 and empty at p=1; the take-up reel is the reverse.
|
||||
double radius(double progress, {required bool supply}) {
|
||||
final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0);
|
||||
final r2 = hubRadius * hubRadius +
|
||||
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
|
||||
return math.sqrt(r2);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import '../theme/tokens.dart';
|
||||
|
||||
/// A thin-stroked panel with its title label sitting *on* the top border —
|
||||
/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics",
|
||||
/// Timbre's signature container ("Album Art", "Queue (127)", "Lyrics",
|
||||
/// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
|
||||
/// then overlay the title with a background that occludes the segment behind
|
||||
/// it, so the label appears to break the line.
|
||||
|
|
|
|||
38
lib/widgets/toast.dart
Normal file
38
lib/widgets/toast.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Shows a themed, transient confirmation toast plus a light haptic tick.
|
||||
///
|
||||
/// Rendered as a fixed [SnackBar], so it settles just above the app's bottom
|
||||
/// chrome (mini-player + tab bar) rather than over it. Styled to match the
|
||||
/// dense, near-black terminal aesthetic — [TimbreColors.surface] fill, warm
|
||||
/// [TimbreColors.foreground] text, with the live accent used for the leading
|
||||
/// [icon]. Any in-flight toast is cleared first so rapid taps don't stack.
|
||||
void showToast(BuildContext context, String message, {IconData? icon}) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
HapticFeedback.selectionClick();
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: TimbreColors.surface,
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 16, color: accent),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: TimbreColors.foreground),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
40
pubspec.lock
40
pubspec.lock
|
|
@ -270,6 +270,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
|
@ -496,6 +504,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -733,6 +749,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ dependencies:
|
|||
just_audio: ^0.10.6
|
||||
just_audio_background: ^0.0.1-beta.17
|
||||
palette_generator: ^0.3.3+7
|
||||
flutter_svg: ^2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -77,6 +78,9 @@ flutter:
|
|||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/casette/
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'package:flutter/widgets.dart' show Color;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:timbre/playlists/playlists.dart';
|
||||
import 'package:timbre/settings/settings_store.dart';
|
||||
import 'package:timbre/state/providers.dart';
|
||||
import 'package:timbre/subsonic/credentials.dart';
|
||||
import 'package:timbre/subsonic/models.dart';
|
||||
import 'package:timbre/subsonic/subsonic_client.dart';
|
||||
|
||||
|
|
@ -12,6 +15,9 @@ class _FakeClient extends SubsonicClient {
|
|||
final List<Playlist> playlists = [];
|
||||
final Map<String, PlaylistDetail> details = {};
|
||||
bool failNext = false;
|
||||
// Fails only the `setPlaylistComment` call — lets a test drive the tag
|
||||
// orphan-cleanup path (create succeeds, marker stamp fails).
|
||||
bool failComment = false;
|
||||
int _seq = 0;
|
||||
|
||||
void _maybeThrow() {
|
||||
|
|
@ -37,11 +43,55 @@ class _FakeClient extends SubsonicClient {
|
|||
return d;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPlaylistComment(String playlistId, String comment) async {
|
||||
if (failComment) {
|
||||
failComment = false;
|
||||
throw Exception('comment failed');
|
||||
}
|
||||
final i = playlists.indexWhere((p) => p.id == playlistId);
|
||||
if (i >= 0) playlists[i] = _copy(playlists[i], comment: comment);
|
||||
final d = details[playlistId];
|
||||
if (d != null) details[playlistId] = _copyDetail(d, comment: comment);
|
||||
}
|
||||
|
||||
// The server persists appends, so getPlaylist reflects real membership — the
|
||||
// fake must too, or `addToTag`'s de-dupe check has nothing to read.
|
||||
@override
|
||||
Future<void> addTracksToPlaylist(String playlistId, List<String> songIds) async {
|
||||
_maybeThrow();
|
||||
final d = details[playlistId];
|
||||
if (d != null) {
|
||||
details[playlistId] = _copyDetail(d, songs: [
|
||||
...d.songs,
|
||||
for (final sid in songIds) Song(id: sid, title: 'Song $sid'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
static Playlist _copy(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 ?? p.comment,
|
||||
);
|
||||
|
||||
static PlaylistDetail _copyDetail(PlaylistDetail d,
|
||||
{String? comment, List<Song>? songs}) =>
|
||||
PlaylistDetail(
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
songCount: d.songCount,
|
||||
duration: d.duration,
|
||||
coverArt: d.coverArt,
|
||||
comment: comment ?? d.comment,
|
||||
songs: songs ?? d.songs,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
|
||||
_maybeThrow();
|
||||
|
|
@ -106,6 +156,101 @@ void main() {
|
|||
expect(back.downloadMaxBitRate, 0);
|
||||
expect(back.downloadFormat, 'opus');
|
||||
});
|
||||
|
||||
test('toJson/fromJson round-trip preserves the new preference fields', () {
|
||||
const s = AppSettings(
|
||||
useStaticAccent: true,
|
||||
staticAccentColor: Color(0xFF926CE9), // Purple
|
||||
defaultBrowseMode: BrowseMode.albums,
|
||||
searchMode: SearchMode.standard,
|
||||
nowPlayingCassette: true,
|
||||
);
|
||||
final back = AppSettings.fromJson(s.toJson());
|
||||
expect(back.useStaticAccent, isTrue);
|
||||
expect(back.staticAccentColor, const Color(0xFF926CE9));
|
||||
expect(back.defaultBrowseMode, BrowseMode.albums);
|
||||
expect(back.searchMode, SearchMode.standard);
|
||||
expect(back.nowPlayingCassette, isTrue);
|
||||
});
|
||||
|
||||
test('fromJson falls back to defaults for missing/unknown values', () {
|
||||
// An older settings.json without the new keys loads with safe defaults.
|
||||
final back = AppSettings.fromJson({'streamMaxBitRate': 128});
|
||||
expect(back.streamMaxBitRate, 128);
|
||||
expect(back.useStaticAccent, isFalse);
|
||||
expect(back.staticAccentColor, const Color(0xFFDF7E35)); // Orange
|
||||
expect(back.defaultBrowseMode, BrowseMode.artists);
|
||||
expect(back.searchMode, SearchMode.discovery);
|
||||
expect(back.nowPlayingCassette, isFalse);
|
||||
// An unrecognised enum name also falls back rather than throwing.
|
||||
expect(
|
||||
AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode,
|
||||
BrowseMode.artists,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('SubsonicCredentials', () {
|
||||
test('toJson/fromJson round-trip', () {
|
||||
const c = SubsonicCredentials(
|
||||
url: 'https://x', username: 'u', password: 'p', alias: 'Home');
|
||||
final back = SubsonicCredentials.fromJson(c.toJson());
|
||||
expect(back.url, 'https://x');
|
||||
expect(back.username, 'u');
|
||||
expect(back.password, 'p');
|
||||
expect(back.alias, 'Home');
|
||||
});
|
||||
|
||||
test('id ignores a trailing slash so the same server maps to one key', () {
|
||||
const a =
|
||||
SubsonicCredentials(url: 'https://x/', username: 'u', password: 'p');
|
||||
const b =
|
||||
SubsonicCredentials(url: 'https://x', username: 'u', password: 'q');
|
||||
expect(a.id, b.id);
|
||||
});
|
||||
|
||||
test('display prefers a non-empty alias, else the host', () {
|
||||
expect(
|
||||
const SubsonicCredentials(url: 'https://x', username: 'u', password: 'p')
|
||||
.display,
|
||||
'x',
|
||||
);
|
||||
expect(
|
||||
const SubsonicCredentials(
|
||||
url: 'https://x', username: 'u', password: 'p', alias: 'Home')
|
||||
.display,
|
||||
'Home',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('filterSearchToStandard (TODO #4)', () {
|
||||
test('keeps name/title matches and drops artist-only song matches', () {
|
||||
final result = SearchResult3(
|
||||
artists: [Artist(id: 'a1', name: 'Chard')],
|
||||
albums: [
|
||||
Album(id: 'al1', name: 'Cha Cha'),
|
||||
Album(id: 'al2', name: 'Nope'),
|
||||
],
|
||||
songs: [
|
||||
Song(id: 's1', title: 'Cha times', artist: 'Zed'),
|
||||
Song(id: 's2', title: 'Other', artist: 'Cha Band'), // artist-only
|
||||
],
|
||||
);
|
||||
final filtered = filterSearchToStandard(result, 'Cha');
|
||||
expect(filtered.artists.map((a) => a.id), ['a1']);
|
||||
expect(filtered.albums.map((a) => a.id), ['al1']);
|
||||
expect(filtered.songs.map((s) => s.id), ['s1']); // s2 dropped
|
||||
});
|
||||
|
||||
test('is case-insensitive', () {
|
||||
final result = SearchResult3(
|
||||
artists: const [],
|
||||
albums: const [],
|
||||
songs: [Song(id: 's1', title: 'CHA')],
|
||||
);
|
||||
expect(filterSearchToStandard(result, 'cha').songs.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaylistsController optimistic mutations', () {
|
||||
|
|
@ -166,4 +311,52 @@ void main() {
|
|||
expect(controller.state.playlists.single.name, 'Keep');
|
||||
});
|
||||
});
|
||||
|
||||
group('Tags (playlists marked with the tag comment)', () {
|
||||
late _FakeClient client;
|
||||
late PlaylistsController controller;
|
||||
|
||||
setUp(() {
|
||||
client = _FakeClient();
|
||||
controller = PlaylistsController(
|
||||
clientGetter: () => client,
|
||||
serverKeyGetter: () => null,
|
||||
);
|
||||
});
|
||||
|
||||
test('createTag stamps the marker so the playlist reads as a tag', () async {
|
||||
final id = (await controller.createTag('cooking'))!;
|
||||
final summary = controller.state.playlists.single;
|
||||
expect(summary.id, id);
|
||||
expect(summary.comment, kTagMarker);
|
||||
expect(isTagPlaylist(summary), isTrue);
|
||||
});
|
||||
|
||||
test('createTag is unique per name (case-insensitive)', () async {
|
||||
final first = await controller.createTag('Cooking');
|
||||
final second = await controller.createTag('cooking');
|
||||
expect(second, first); // reused, not duplicated
|
||||
expect(controller.state.playlists.length, 1);
|
||||
});
|
||||
|
||||
test('createTag deletes the orphan if the marker stamp fails', () async {
|
||||
client.failComment = true;
|
||||
final id = await controller.createTag('running');
|
||||
expect(id, isNull);
|
||||
// No unmarked, nameless playlist left behind.
|
||||
expect(controller.state.playlists, isEmpty);
|
||||
});
|
||||
|
||||
test('addToTag skips songs already carrying the tag', () async {
|
||||
final id = (await controller.createTag('run'))!;
|
||||
await controller.addToTag(id, [_song('a'), _song('b')]);
|
||||
await controller.addToTag(id, [_song('b'), _song('c')]); // b duplicate
|
||||
final ids = controller.state.details[id]!.songs.map((s) => s.id).toList();
|
||||
expect(ids, ['a', 'b', 'c']);
|
||||
|
||||
// Re-adding an existing member is a no-op.
|
||||
await controller.addToTag(id, [_song('a')]);
|
||||
expect(controller.state.details[id]!.songs.length, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue