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

BIN
assets/casette/casette.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View 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

View 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

View file

@ -8,7 +8,7 @@ import 'package:path_provider/path_provider.dart';
import '../subsonic/models.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 { class PlayRecord {
PlayRecord({ PlayRecord({
required this.songId, 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 /// 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. /// `history.rs:101-167`). [seed] drives the re-roll.
/// ///
/// The desktop original only surfaces artists last heard more than [minDays] /// The desktop original only surfaces artists last heard more than [minDays]

View file

@ -45,7 +45,7 @@ class LibraryIndexState {
} }
/// Builds and caches a flat "all songs" index — Subsonic has no all-songs /// 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 /// `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 /// servers never serves a stale catalog, and writes are atomic so an
/// interrupted crawl can't leave a truncated file. /// interrupted crawl can't leave a truncated file.
@ -54,7 +54,7 @@ class LibraryIndexController extends StateNotifier<LibraryIndexState> {
final SubsonicClient? Function() _clientGetter; 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 int _pageSize = 500; // Subsonic getAlbumList2 cap.
static const Duration _ttl = Duration(hours: 24); static const Duration _ttl = Duration(hours: 24);

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio_background/just_audio_background.dart'; import 'package:just_audio_background/just_audio_background.dart';
import 'settings/settings_store.dart';
import 'shell/app_shell.dart'; import 'shell/app_shell.dart';
import 'theme/accent.dart'; import 'theme/accent.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@ -35,8 +36,12 @@ class TimbreApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// The theme rebuilds whenever the album-art accent changes. // The accent either tracks the album art (default) or is pinned to a
final accent = ref.watch(accentProvider); // 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( return MaterialApp(
title: 'Timbre', title: 'Timbre',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,

View file

@ -11,8 +11,8 @@ import 'package:just_audio_background/just_audio_background.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
/// Immutable snapshot of the player, mirroring Ratune's `QueueState` + /// Immutable snapshot of the player, mirroring Timbre's `QueueState` +
/// player-event stream (`ratune-player/src/engine.rs`). /// player-event stream (`timbre-player/src/engine.rs`).
class PlaybackState { class PlaybackState {
const PlaybackState({ const PlaybackState({
this.queue = const [], this.queue = const [],
@ -212,7 +212,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
// currentIndexStream fires _notifyCurrent for the started track. // 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. /// Falls back to [playSongs] when nothing is playing.
Future<void> playNext(Song song) async { Future<void> playNext(Song song) async {
if (_streamUriFor(song) == null) return; 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). /// single-track repeat).
Future<void> cycleLoop() async { Future<void> cycleLoop() async {
final nextMode = switch (state.loop) { final nextMode = switch (state.loop) {

View file

@ -5,12 +5,25 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
import '../subsonic/subsonic_client.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 /// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
/// that have been opened. Details are cached so an opened playlist keeps working /// that have been opened. Details are cached so an opened playlist keeps working
/// offline. /// 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 { Future<void> rename(String id, String name) async {
final client = _clientGetter(); final client = _clientGetter();
if (client == null) return; if (client == null) return;
@ -280,6 +374,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
owner: p.owner, owner: p.owner,
public: p.public, public: p.public,
coverArt: p.coverArt, coverArt: p.coverArt,
comment: p.comment,
) )
else else
p, p,
@ -294,6 +389,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
owner: p.owner, owner: p.owner,
public: p.public, public: p.public,
coverArt: p.coverArt, coverArt: p.coverArt,
comment: p.comment,
); );
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) => static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
@ -303,6 +399,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
songCount: d.songCount, songCount: d.songCount,
duration: d.duration, duration: d.duration,
coverArt: d.coverArt, coverArt: d.coverArt,
comment: d.comment,
songs: d.songs, songs: d.songs,
); );
@ -313,6 +410,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
songCount: songs.length, songCount: songs.length,
duration: d.duration, duration: d.duration,
coverArt: d.coverArt, coverArt: d.coverArt,
comment: d.comment,
songs: songs, songs: songs,
); );

View 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)),
],
),
),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart'; import '../state/providers.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
import '../theme/tokens.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 /// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
/// offline (playlist mutations require the server). /// offline (playlist mutations require the server).
@ -27,7 +28,7 @@ class _AddToPlaylistSheet extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary; final accent = Theme.of(context).colorScheme.primary;
final playlists = ref.watch(playlistsProvider).playlists; final playlists = ref.watch(realPlaylistsProvider);
final connected = ref.watch(subsonicClientProvider) != null; final connected = ref.watch(subsonicClientProvider) != null;
return SafeArea( return SafeArea(
@ -184,8 +185,5 @@ Future<String?> promptPlaylistName(
); );
} }
void _toast(BuildContext context, String message) { void _toast(BuildContext context, String message) =>
ScaffoldMessenger.of(context).showSnackBar( showToast(context, message);
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}

View file

@ -6,11 +6,14 @@ import '../state/providers.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart'; import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
import 'add_tag_sheet.dart';
import 'add_to_playlist_sheet.dart'; import 'add_to_playlist_sheet.dart';
import 'downloads_screen.dart'; import 'downloads_screen.dart';
import 'favorites_screen.dart'; import 'favorites_screen.dart';
import 'playlists_screen.dart'; import 'playlists_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
import 'tags_screen.dart';
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic /// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
/// server. Artists and Albums drill down by pushing onto the (nested) navigator; /// server. Artists and Albums drill down by pushing onto the (nested) navigator;
@ -58,6 +61,13 @@ class BrowserScreen extends ConsumerWidget {
MaterialPageRoute(builder: (_) => const PlaylistsScreen()), MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
), ),
), ),
_Action(
icon: Icons.label_outline,
label: 'Tags',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TagsScreen()),
),
),
_Action( _Action(
icon: Icons.download, icon: Icons.download,
label: 'Downloads', label: 'Downloads',
@ -344,6 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
onAddToQueue: () => playback.addToQueue(song), onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () => onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]), showAddToPlaylistSheet(context, songs: [song]),
onAddToTag: () => showAddTagSheet(context, songs: [song]),
onDownload: () => onDownload: () =>
ref.read(downloadManagerProvider.notifier).download(song), ref.read(downloadManagerProvider.notifier).download(song),
onRemoveDownload: () => onRemoveDownload: () =>
@ -472,11 +483,7 @@ class AlbumScreen extends ConsumerWidget {
ref ref
.read(downloadManagerProvider.notifier) .read(downloadManagerProvider.notifier)
.downloadAll(songs); .downloadAll(songs);
ScaffoldMessenger.of(context).showSnackBar( showToast(context, 'Downloading album…');
const SnackBar(
content: Text('Downloading album…'),
duration: Duration(seconds: 2)),
);
}, },
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
), ),
@ -499,6 +506,7 @@ class AlbumScreen extends ConsumerWidget {
onAddToQueue: () => playback.addToQueue(song), onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () => onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]), showAddToPlaylistSheet(context, songs: [song]),
onAddToTag: () => showAddTagSheet(context, songs: [song]),
onDownload: () => onDownload: () =>
ref.read(downloadManagerProvider.notifier).download(song), ref.read(downloadManagerProvider.notifier).download(song),
onRemoveDownload: () => onRemoveDownload: () =>
@ -523,6 +531,7 @@ class BrowseRow extends StatelessWidget {
this.onPlayNext, this.onPlayNext,
this.onAddToQueue, this.onAddToQueue,
this.onAddToPlaylist, this.onAddToPlaylist,
this.onAddToTag,
this.onDownload, this.onDownload,
this.onRemoveDownload, this.onRemoveDownload,
this.downloadStatus, this.downloadStatus,
@ -540,6 +549,7 @@ class BrowseRow extends StatelessWidget {
/// Secondary song actions, folded into a trailing overflow menu so the row /// Secondary song actions, folded into a trailing overflow menu so the row
/// stays uncluttered. /// stays uncluttered.
final VoidCallback? onAddToPlaylist; final VoidCallback? onAddToPlaylist;
final VoidCallback? onAddToTag;
final VoidCallback? onDownload; final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload; final VoidCallback? onRemoveDownload;
@ -548,7 +558,10 @@ class BrowseRow extends StatelessWidget {
final DownloadStatus? downloadStatus; final DownloadStatus? downloadStatus;
bool get _hasMenu => bool get _hasMenu =>
onAddToPlaylist != null || onDownload != null || onRemoveDownload != null; onAddToPlaylist != null ||
onAddToTag != null ||
onDownload != null ||
onRemoveDownload != null;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -600,14 +613,27 @@ class BrowseRow extends StatelessWidget {
style: const TextStyle(color: TimbreColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
], ],
if (onPlayNext != null) 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) 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) if (_hasMenu)
_RowMenu( _RowMenu(
isDownloaded: isDone, isDownloaded: isDone,
isDownloading: isActive, isDownloading: isActive,
onAddToPlaylist: onAddToPlaylist, onAddToPlaylist: onAddToPlaylist,
onAddToTag: onAddToTag,
onDownload: onDownload, onDownload: onDownload,
onRemoveDownload: onRemoveDownload, onRemoveDownload: onRemoveDownload,
), ),
@ -626,6 +652,7 @@ class _RowMenu extends StatelessWidget {
required this.isDownloaded, required this.isDownloaded,
required this.isDownloading, required this.isDownloading,
this.onAddToPlaylist, this.onAddToPlaylist,
this.onAddToTag,
this.onDownload, this.onDownload,
this.onRemoveDownload, this.onRemoveDownload,
}); });
@ -633,6 +660,7 @@ class _RowMenu extends StatelessWidget {
final bool isDownloaded; final bool isDownloaded;
final bool isDownloading; final bool isDownloading;
final VoidCallback? onAddToPlaylist; final VoidCallback? onAddToPlaylist;
final VoidCallback? onAddToTag;
final VoidCallback? onDownload; final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload; final VoidCallback? onRemoveDownload;
@ -648,6 +676,8 @@ class _RowMenu extends StatelessWidget {
switch (v) { switch (v) {
case 'playlist': case 'playlist':
onAddToPlaylist?.call(); onAddToPlaylist?.call();
case 'tag':
onAddToTag?.call();
case 'download': case 'download':
onDownload?.call(); onDownload?.call();
case 'remove_download': case 'remove_download':
@ -658,6 +688,8 @@ class _RowMenu extends StatelessWidget {
if (onAddToPlaylist != null) if (onAddToPlaylist != null)
const PopupMenuItem( const PopupMenuItem(
value: 'playlist', child: Text('Add to playlist')), value: 'playlist', child: Text('Add to playlist')),
if (onAddToTag != null)
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
if (isDownloaded && onRemoveDownload != null) if (isDownloaded && onRemoveDownload != null)
const PopupMenuItem( const PopupMenuItem(
value: 'remove_download', child: Text('Remove download')) value: 'remove_download', child: Text('Remove download'))

View file

@ -5,18 +5,145 @@ import '../state/providers.dart';
import '../subsonic/credentials.dart'; import '../subsonic/credentials.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
/// Open the connect-server sheet. /// Open the add/edit-server sheet. Pass [initial] to edit an existing saved
Future<void> showConnectSheet(BuildContext context) { /// server (its password is reused if the field is left blank).
Future<void> showConnectSheet(BuildContext context,
{SubsonicCredentials? initial}) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
backgroundColor: TimbreColors.background, backgroundColor: TimbreColors.background,
isScrollControlled: true, 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 { class _ConnectSheet extends ConsumerStatefulWidget {
const _ConnectSheet(); const _ConnectSheet({this.initial});
final SubsonicCredentials? initial;
@override @override
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState(); ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
@ -26,15 +153,20 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final _url = TextEditingController(); final _url = TextEditingController();
final _user = TextEditingController(); final _user = TextEditingController();
final _pass = TextEditingController(); final _pass = TextEditingController();
final _alias = TextEditingController();
bool _busy = false; bool _busy = false;
String? _localError;
bool get _isEdit => widget.initial != null;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final creds = ref.read(connectionProvider).credentials; final initial = widget.initial;
if (creds != null) { if (initial != null) {
_url.text = creds.url; _url.text = initial.url;
_user.text = creds.username; _user.text = initial.username;
_alias.text = initial.alias ?? '';
} }
} }
@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
_url.dispose(); _url.dispose();
_user.dispose(); _user.dispose();
_pass.dispose(); _pass.dispose();
_alias.dispose();
super.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 { Future<void> _connect() async {
setState(() => _busy = true); final creds = _readForm();
final ok = await ref.read(connectionProvider.notifier).connect( if (creds == null) return;
SubsonicCredentials( setState(() {
url: _url.text.trim(), _busy = true;
username: _user.text.trim(), _localError = null;
password: _pass.text, });
), final ok = await ref.read(connectionProvider.notifier).connect(creds);
);
if (!mounted) return; if (!mounted) return;
setState(() => _busy = false); setState(() => _busy = false);
if (ok) Navigator.of(context).pop(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final conn = ref.watch(connectionProvider); final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary; final accent = Theme.of(context).colorScheme.primary;
final error = _localError ??
(conn.status == ConnStatus.error ? conn.error : null);
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: TimbreSpacing.xl, left: TimbreSpacing.xl,
@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('Connect to server', Text(_isEdit ? 'Edit server' : 'Add server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)), style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
const SizedBox(height: TimbreSpacing.lg), const SizedBox(height: TimbreSpacing.lg),
_field(_url, 'Server URL', 'https://navidrome.example.com', _field(_url, 'Server URL', 'https://navidrome.example.com',
keyboard: TextInputType.url), keyboard: TextInputType.url),
_field(_user, 'Username', 'you'), _field(_user, 'Username', 'you'),
_field(_pass, 'Password', '••••••••', obscure: true), _field(_pass, 'Password',
if (conn.status == ConnStatus.error && conn.error != null) ...[ _isEdit ? '•••••••• (unchanged)' : '••••••••',
obscure: true),
_field(_alias, 'Label (optional)', 'Home server'),
if (error != null) ...[
const SizedBox(height: TimbreSpacing.md), const SizedBox(height: TimbreSpacing.md),
Text(conn.error!, Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
style: const TextStyle(color: Color(0xFFE06C75))),
], ],
const SizedBox(height: TimbreSpacing.lg), const SizedBox(height: TimbreSpacing.lg),
FilledButton( Row(
onPressed: _busy ? null : _connect, children: [
style: FilledButton.styleFrom( Expanded(
backgroundColor: accent, child: OutlinedButton(
foregroundColor: TimbreColors.background, onPressed: _busy ? null : _save,
shape: const RoundedRectangleBorder(), style: OutlinedButton.styleFrom(
), foregroundColor: TimbreColors.foreground,
child: _busy side: const BorderSide(color: TimbreColors.border),
? const SizedBox( shape: const RoundedRectangleBorder(),
height: 16, ),
width: 16, child: const Text('Save'),
child: CircularProgressIndicator(strokeWidth: 2), ),
) ),
: const Text('Connect'), 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'),
),
),
],
), ),
], ],
), ),

View file

@ -4,10 +4,12 @@ import 'package:just_audio/just_audio.dart' show LoopMode;
import '../downloads/download_manager.dart'; import '../downloads/download_manager.dart';
import '../playback/playback_engine.dart'; import '../playback/playback_engine.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart'; import '../state/providers.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/block_progress_bar.dart'; import '../widgets/block_progress_bar.dart';
import '../widgets/cassette_view.dart';
import '../widgets/hairline_panel.dart'; import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart'; import 'add_to_playlist_sheet.dart';
@ -210,28 +212,32 @@ class _AlbumArtPanel extends ConsumerWidget {
final coverArt = final coverArt =
ref.watch(playbackProvider.select((s) => s.current?.coverArt)); ref.watch(playbackProvider.select((s) => s.current?.coverArt));
final client = ref.watch(subsonicClientProvider); final client = ref.watch(subsonicClientProvider);
final cassette =
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
final artUri = (client != null && coverArt != null) final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 512).toString() ? client.coverArtUri(coverArt, size: 512).toString()
: null; : null;
return HairlinePanel( return HairlinePanel(
title: 'Album Art', title: cassette ? 'Cassette' : 'Album Art',
padding: const EdgeInsets.all(TimbreSpacing.md), padding: const EdgeInsets.all(TimbreSpacing.md),
child: AspectRatio( child: cassette
aspectRatio: 1, ? Center(child: CassetteView(artUri: artUri))
child: ColoredBox( : AspectRatio(
color: TimbreColors.surface, aspectRatio: 1,
child: artUri != null child: ColoredBox(
? Image.network( color: TimbreColors.surface,
artUri, child: artUri != null
key: ValueKey(artUri), ? Image.network(
fit: BoxFit.cover, artUri,
gaplessPlayback: true, key: ValueKey(artUri),
errorBuilder: (_, _, _) => const _ArtFallback(), fit: BoxFit.cover,
) gaplessPlayback: true,
: const _ArtFallback(), errorBuilder: (_, _, _) => const _ArtFallback(),
), )
), : const _ArtFallback(),
),
),
); );
} }
} }

View file

@ -7,6 +7,7 @@ import '../state/providers.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart'; import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
import 'add_to_playlist_sheet.dart'; import 'add_to_playlist_sheet.dart';
/// Playlists list — server-backed with an offline mirror. Create from the app /// Playlists list — server-backed with an offline mirror. Create from the app
@ -16,7 +17,7 @@ class PlaylistsScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playlistsProvider); final playlists = ref.watch(realPlaylistsProvider);
final controller = ref.read(playlistsProvider.notifier); final controller = ref.read(playlistsProvider.notifier);
final connected = ref.watch(subsonicClientProvider) != null; final connected = ref.watch(subsonicClientProvider) != null;
@ -38,10 +39,9 @@ class PlaylistsScreen extends ConsumerWidget {
child: HairlinePanel( child: HairlinePanel(
title: 'Playlists', title: 'Playlists',
active: true, active: true,
trailing: trailing: playlists.isEmpty ? null : '(${playlists.length})',
state.playlists.isEmpty ? null : '(${state.playlists.length})',
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: state.playlists.isEmpty child: playlists.isEmpty
? Center( ? Center(
child: Text( child: Text(
connected connected
@ -53,9 +53,9 @@ class PlaylistsScreen extends ConsumerWidget {
) )
: ListView.builder( : ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: state.playlists.length, itemCount: playlists.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final p = state.playlists[i]; final p = playlists[i];
return _PlaylistRow( return _PlaylistRow(
name: p.name, name: p.name,
subtitle: p.songCount != null subtitle: p.songCount != null
@ -233,11 +233,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
ref ref
.read(downloadManagerProvider.notifier) .read(downloadManagerProvider.notifier)
.downloadAll(songs); .downloadAll(songs);
ScaffoldMessenger.of(context).showSnackBar( showToast(context, 'Downloading playlist…');
const SnackBar(
content: Text('Downloading playlist…'),
duration: Duration(seconds: 2)),
);
}, },
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
), ),
@ -337,8 +333,10 @@ class _TrackRow extends StatelessWidget {
switch (v) { switch (v) {
case 'next': case 'next':
onPlayNext(); onPlayNext();
showToast(context, 'Playing next', icon: Icons.check);
case 'queue': case 'queue':
onAddToQueue(); onAddToQueue();
showToast(context, 'Added to queue', icon: Icons.check);
case 'remove': case 'remove':
onRemove?.call(); onRemove?.call();
} }

View file

@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart'; import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart'; import '../widgets/hairline_panel.dart';
import 'connect_sheet.dart';
/// Audio-quality settings: independent streaming and download knobs, both /// Audio-quality settings: independent streaming and download knobs, both
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`). /// 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) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final controller = ref.read(settingsProvider.notifier); final controller = ref.read(settingsProvider.notifier);
final conn = ref.watch(connectionProvider);
return Scaffold( return Scaffold(
appBar: AppBar( 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 { class _Caption extends StatelessWidget {
const _Caption(this.text); const _Caption(this.text);
final String 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,
),
),
],
),
),
);
}
}

View 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')),
],
),
],
),
),
);
}
}

View file

@ -1,19 +1,43 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// User-tunable audio quality. Streaming and offline downloads have independent /// Top-level Browse mode selector. Lives here (not in `state/providers.dart`)
/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` + /// so [AppSettings] can persist the user's default; `providers.dart` re-exports
/// `format`; a rate of 0 means original / no transcode). These are global (not /// it, so existing importers are unaffected.
/// per-server) and persisted to a single atomic JSON file, following the enum BrowseMode { artists, albums, tracks }
/// write pattern in `library/library_index.dart`.
/// 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 { class AppSettings {
const AppSettings({ const AppSettings({
this.streamMaxBitRate = 0, this.streamMaxBitRate = 0,
this.downloadMaxBitRate = 0, this.downloadMaxBitRate = 0,
this.downloadFormat, 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. /// Cap for live streaming, in kbps. 0 = original / no transcode.
@ -26,20 +50,67 @@ class AppSettings {
/// the original file / let the server decide. /// the original file / let the server decide.
final String? downloadFormat; 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". /// Offered bitrate choices (kbps); 0 renders as "Original".
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320]; static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
/// Offered download containers; null renders as "Original". /// Offered download containers; null renders as "Original".
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac']; 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 bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
static String formatLabel(String? f) => f ?? 'Original'; 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({ AppSettings copyWith({
int? streamMaxBitRate, int? streamMaxBitRate,
int? downloadMaxBitRate, int? downloadMaxBitRate,
// Sentinel so an explicit null (→ original) is distinguishable from "unset". // Sentinel so an explicit null (→ original) is distinguishable from "unset".
Object? downloadFormat = _unset, Object? downloadFormat = _unset,
bool? useStaticAccent,
Color? staticAccentColor,
BrowseMode? defaultBrowseMode,
SearchMode? searchMode,
bool? nowPlayingCassette,
}) => }) =>
AppSettings( AppSettings(
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate, streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
@ -47,6 +118,11 @@ class AppSettings {
downloadFormat: identical(downloadFormat, _unset) downloadFormat: identical(downloadFormat, _unset)
? this.downloadFormat ? this.downloadFormat
: downloadFormat as String?, : 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(); static const Object _unset = Object();
@ -55,13 +131,49 @@ class AppSettings {
'streamMaxBitRate': streamMaxBitRate, 'streamMaxBitRate': streamMaxBitRate,
'downloadMaxBitRate': downloadMaxBitRate, 'downloadMaxBitRate': downloadMaxBitRate,
if (downloadFormat != null) 'downloadFormat': downloadFormat, 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( factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0, streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0, downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
downloadFormat: j['downloadFormat'] as String?, 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). /// Loads settings on launch and persists every change (atomic temp+rename).
@ -102,6 +214,31 @@ class SettingsController extends StateNotifier<AppSettings> {
_persist(); _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 { Future<void> _persist() async {
try { try {
final file = _file; final file = _file;

View file

@ -7,11 +7,12 @@ import '../screens/connect_sheet.dart';
import '../screens/home_screen.dart'; import '../screens/home_screen.dart';
import '../screens/now_playing_screen.dart'; import '../screens/now_playing_screen.dart';
import '../screens/settings_screen.dart'; import '../screens/settings_screen.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart'; import '../state/providers.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/mini_player.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. /// bottom tab bar and a status bar, mirroring the terminal layout.
class AppShell extends ConsumerStatefulWidget { class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key}); const AppShell({super.key});
@ -29,12 +30,39 @@ class _AppShellState extends ConsumerState<AppShell> {
final _homeNavKey = GlobalKey<NavigatorState>(); final _homeNavKey = GlobalKey<NavigatorState>();
final _browseNavKey = 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) { GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) {
0 => _homeNavKey, 0 => _homeNavKey,
1 => _browseNavKey, 1 => _browseNavKey,
_ => null, _ => 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 /// 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. /// back to Home, and only exit the app from the Home root.
void _handleBack(int tab) { void _handleBack(int tab) {
@ -88,8 +116,7 @@ class _AppShellState extends ConsumerState<AppShell> {
_TabBar( _TabBar(
tabs: _tabs, tabs: _tabs,
index: index, index: index,
onSelect: (i) => onSelect: (i) => _selectTab(i, index),
ref.read(selectedTabProvider.notifier).state = i,
), ),
const _StatusBar(), const _StatusBar(),
], ],
@ -201,10 +228,10 @@ class _StatusBar extends ConsumerWidget {
color: TimbreColors.surface, color: TimbreColors.surface,
child: Row( child: Row(
children: [ children: [
// Left: connection status — tap to open the connect sheet. // Left: connection status — tap to open the server switcher.
Expanded( Expanded(
child: InkWell( child: InkWell(
onTap: () => showConnectSheet(context), onTap: () => showServerSheet(context),
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg), const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),

View file

@ -1,6 +1,3 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/widgets.dart' show NetworkImage; import 'package:flutter/widgets.dart' show NetworkImage;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -17,6 +14,11 @@ import '../theme/accent.dart';
import '../theme/accent_extract.dart'; import '../theme/accent_extract.dart';
import 'favorites.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 --------------------------------------------------------- // ---- Connection ---------------------------------------------------------
enum ConnStatus { disconnected, connecting, online, error } enum ConnStatus { disconnected, connecting, online, error }
@ -27,6 +29,7 @@ class ConnectionState {
this.client, this.client,
this.credentials, this.credentials,
this.error, this.error,
this.servers = const [],
}); });
final ConnStatus status; final ConnStatus status;
@ -34,14 +37,32 @@ class ConnectionState {
final SubsonicCredentials? credentials; final SubsonicCredentials? credentials;
final String? error; 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; 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 = final credentialStoreProvider =
Provider<CredentialStore>((_) => CredentialStore()); Provider<CredentialStore>((_) => CredentialStore());
/// Owns the active server connection: builds the client, pings, persists /// Owns the active server connection and the list of saved servers: builds the
/// credentials, and auto-restores on launch. /// client, pings, persists credentials, auto-restores on launch, and switches
/// between servers (one active at a time — TODO #2).
class ConnectionController extends StateNotifier<ConnectionState> { class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._store) ConnectionController(this._store)
: super(const ConnectionState(status: ConnStatus.disconnected)) { : super(const ConnectionState(status: ConnStatus.disconnected)) {
@ -49,13 +70,21 @@ class ConnectionController extends StateNotifier<ConnectionState> {
} }
final CredentialStore _store; final CredentialStore _store;
List<SubsonicCredentials> _servers = const [];
String? _activeId;
Future<void> _restore() async { Future<void> _restore() async {
try { try {
final creds = await _store.load(); _servers = await _store.loadAll();
if (creds != null) { _activeId = await _store.loadActiveId();
await connect(creds, persist: false); 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 (_) { } catch (_) {
// No secure-storage backend available (e.g. Linux without a running // No secure-storage backend available (e.g. Linux without a running
// keyring daemon) — start disconnected rather than crashing. // 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 { 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( final client = SubsonicClient(
baseUrl: creds.url, baseUrl: creds.url,
username: creds.username, username: creds.username,
@ -79,20 +108,25 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error, status: ConnStatus.error,
credentials: creds, credentials: creds,
error: 'Could not reach the server.', error: 'Could not reach the server.',
servers: _servers,
); );
return false; return false;
} }
// Persistence is best-effort: a locked/absent secure-storage backend // Persistence is best-effort: a locked/absent secure-storage backend
// (e.g. a locked Linux keyring) must not stop this session connecting. // (e.g. a locked Linux keyring) must not stop this session connecting.
// A successful connect saves the server and makes it active.
if (persist) { if (persist) {
try { try {
await _store.save(creds); await _addOrUpdate(creds, makeActive: true);
} catch (_) {} } catch (_) {}
} else {
_activeId = creds.id;
} }
state = ConnectionState( state = ConnectionState(
status: ConnStatus.online, status: ConnStatus.online,
client: client, client: client,
credentials: creds, credentials: creds,
servers: _servers,
); );
return true; return true;
} on SubsonicError catch (e) { } on SubsonicError catch (e) {
@ -102,6 +136,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error, status: ConnStatus.error,
credentials: e.isAuthFailure ? null : creds, credentials: e.isAuthFailure ? null : creds,
error: e.isAuthFailure ? 'Wrong username or password.' : e.message, error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
servers: _servers,
); );
return false; return false;
} catch (e) { } catch (e) {
@ -109,13 +144,85 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error, status: ConnStatus.error,
credentials: creds, credentials: creds,
error: e.toString(), error: e.toString(),
servers: _servers,
); );
return false; 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 { Future<void> disconnect() async {
await _store.clear(); _servers = const [];
_activeId = null;
try {
await _store.clear();
} catch (_) {}
state = const ConnectionState(status: ConnStatus.disconnected); 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 /// retains credentials). Null when no server has ever been configured. Used to
/// scope on-disk downloads/playlists to the server they belong to. /// scope on-disk downloads/playlists to the server they belong to.
final serverKeyProvider = Provider<String?>((ref) { final serverKeyProvider = Provider<String?>((ref) {
final creds = ref.watch(connectionProvider).credentials; return ref.watch(connectionProvider).credentials?.id;
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();
}); });
// ---- Navigation / browse mode ------------------------------------------- // ---- Navigation / browse mode -------------------------------------------
@ -152,9 +254,8 @@ final selectedTabProvider = StateProvider<int>((_) => 0);
/// Index of the Now Playing tab in the shell's tab list. /// Index of the Now Playing tab in the shell's tab list.
const int nowPlayingTabIndex = 2; const int nowPlayingTabIndex = 2;
/// Top-level Browse mode selector. /// Top-level Browse mode selector. Seeded once from the persisted
enum BrowseMode { artists, albums, tracks } /// `AppSettings.defaultBrowseMode` by `AppShell`; manual selection then wins.
final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists); final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
// ---- Library ------------------------------------------------------------ // ---- Library ------------------------------------------------------------
@ -227,12 +328,30 @@ final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
final searchProvider = final searchProvider =
FutureProvider.family<SearchResult3, String>((ref, query) async { FutureProvider.family<SearchResult3, String>((ref, query) async {
final client = ref.watch(subsonicClientProvider); 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 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) ------------------------------------------------- // ---- History (Home tab) -------------------------------------------------
final playHistoryProvider = final playHistoryProvider =
@ -318,6 +437,20 @@ final playlistsProvider =
return controller; 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 ----------------------------------------------------------- // ---- Playback -----------------------------------------------------------
final playbackProvider = final playbackProvider =
@ -345,6 +478,8 @@ final playbackProvider =
streamUriFor: streamUriFor, streamUriFor: streamUriFor,
coverArtUriFor: coverArtUriFor, coverArtUriFor: coverArtUriFor,
onArt: (artUri) async { 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())); final color = await extractAccent(NetworkImage(artUri.toString()));
if (color != null) ref.read(accentProvider.notifier).set(color); if (color != null) ref.read(accentProvider.notifier).set(color);
}, },

View file

@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A Subsonic server connection. The password is only ever held in memory and /// 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). /// keyring-first secret handling).
class SubsonicCredentials { class SubsonicCredentials {
const SubsonicCredentials({ const SubsonicCredentials({
@ -15,50 +18,121 @@ class SubsonicCredentials {
final String username; final String username;
final String password; 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; final String? alias;
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host; 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 /// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one
/// (Keychain / Keystore / libsecret). /// to the platform secure store (Keychain / Keystore / libsecret). Legacy
/// single-server installs are migrated into the list on first load.
class CredentialStore { class CredentialStore {
CredentialStore([FlutterSecureStorage? storage]) CredentialStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage(); : _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage; 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 _kUrl = 'subsonic_url';
static const _kUser = 'subsonic_username'; static const _kUser = 'subsonic_username';
static const _kPass = 'subsonic_password'; static const _kPass = 'subsonic_password';
static const _kAlias = 'subsonic_alias'; static const _kAlias = 'subsonic_alias';
Future<void> save(SubsonicCredentials creds) async { /// All saved servers. Empty if none configured. Migrates a legacy
await _storage.write(key: _kUrl, value: creds.url); /// single-server install into the new list format on first call.
await _storage.write(key: _kUser, value: creds.username); Future<List<SubsonicCredentials>> loadAll() async {
await _storage.write(key: _kPass, value: creds.password); final raw = await _storage.read(key: _kServers);
await _storage.write(key: _kAlias, value: creds.alias ?? ''); 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 url = await _storage.read(key: _kUrl);
final user = await _storage.read(key: _kUser); final user = await _storage.read(key: _kUser);
final pass = await _storage.read(key: _kPass); final pass = await _storage.read(key: _kPass);
if (url == null || user == null || pass == null) return null; if (url == null || user == null || pass == null) return null;
final alias = await _storage.read(key: _kAlias); final alias = await _storage.read(key: _kAlias);
return SubsonicCredentials( final creds = SubsonicCredentials(
url: url, url: url,
username: user, username: user,
password: pass, password: pass,
alias: (alias == null || alias.isEmpty) ? null : alias, alias: (alias == null || alias.isEmpty) ? null : alias,
); );
} // Write the new format, point at it, then drop the legacy keys.
await saveAll([creds], creds.id);
Future<void> clear() async {
await _storage.delete(key: _kUrl); await _storage.delete(key: _kUrl);
await _storage.delete(key: _kUser); await _storage.delete(key: _kUser);
await _storage.delete(key: _kPass); await _storage.delete(key: _kPass);
await _storage.delete(key: _kAlias); 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);
} }
} }

View file

@ -1,7 +1,7 @@
/// Defensive JSON helpers for the Subsonic protocol. /// Defensive JSON helpers for the Subsonic protocol.
/// ///
/// Subsonic servers are inconsistent (Ratune handles the same quirks in /// Subsonic servers are inconsistent (Timbre handles the same quirks in
/// `ratune-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`): /// `timbre-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
/// * a field that is sometimes a single object and sometimes an array /// * a field that is sometimes a single object and sometimes an array
/// * IDs that are sometimes strings and sometimes integers /// * IDs that are sometimes strings and sometimes integers
/// Every accessor below tolerates nulls and mixed types rather than throwing. /// Every accessor below tolerates nulls and mixed types rather than throwing.

View file

@ -1,6 +1,6 @@
import 'json_helpers.dart'; 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 /// Nearly every field is nullable — servers omit fields freely. All parsing
/// goes through the defensive helpers in `json_helpers.dart`. /// goes through the defensive helpers in `json_helpers.dart`.
@ -242,7 +242,7 @@ class SearchResult3 {
} }
/// A playlist summary from `getPlaylists` (no tracks), ported from /// 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`). /// (`playlists/playlists.dart`).
class Playlist { class Playlist {
Playlist({ Playlist({
@ -253,6 +253,7 @@ class Playlist {
this.owner, this.owner,
this.public, this.public,
this.coverArt, this.coverArt,
this.comment,
}); });
final String id; final String id;
@ -263,6 +264,10 @@ class Playlist {
final bool? public; final bool? public;
final String? coverArt; 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( factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
id: asString(j['id']) ?? '', id: asString(j['id']) ?? '',
name: asString(j['name']) ?? 'Untitled', name: asString(j['name']) ?? 'Untitled',
@ -271,6 +276,7 @@ class Playlist {
owner: asString(j['owner']), owner: asString(j['owner']),
public: j['public'] is bool ? j['public'] as bool : null, public: j['public'] is bool ? j['public'] as bool : null,
coverArt: asString(j['coverArt']), coverArt: asString(j['coverArt']),
comment: asString(j['comment']),
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -281,6 +287,7 @@ class Playlist {
if (owner != null) 'owner': owner, if (owner != null) 'owner': owner,
if (public != null) 'public': public, if (public != null) 'public': public,
if (coverArt != null) 'coverArt': coverArt, if (coverArt != null) 'coverArt': coverArt,
if (comment != null) 'comment': comment,
}; };
} }
@ -293,6 +300,7 @@ class PlaylistDetail {
this.songCount, this.songCount,
this.duration, this.duration,
this.coverArt, this.coverArt,
this.comment,
this.songs = const [], this.songs = const [],
}); });
@ -301,6 +309,9 @@ class PlaylistDetail {
final int? songCount; final int? songCount;
final int? duration; final int? duration;
final String? coverArt; final String? coverArt;
/// See [Playlist.comment] — carries the Timbre tag marker when present.
final String? comment;
final List<Song> songs; final List<Song> songs;
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail( factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
@ -309,6 +320,7 @@ class PlaylistDetail {
songCount: asInt(j['songCount']), songCount: asInt(j['songCount']),
duration: asInt(j['duration']), duration: asInt(j['duration']),
coverArt: asString(j['coverArt']), coverArt: asString(j['coverArt']),
comment: asString(j['comment']),
songs: oneOrMany(j['entry'], Song.fromJson), songs: oneOrMany(j['entry'], Song.fromJson),
); );
@ -318,6 +330,7 @@ class PlaylistDetail {
if (songCount != null) 'songCount': songCount, if (songCount != null) 'songCount': songCount,
if (duration != null) 'duration': duration, if (duration != null) 'duration': duration,
if (coverArt != null) 'coverArt': coverArt, if (coverArt != null) 'coverArt': coverArt,
if (comment != null) 'comment': comment,
'entry': songs.map((s) => s.toJson()).toList(), 'entry': songs.map((s) => s.toJson()).toList(),
}; };
@ -327,5 +340,6 @@ class PlaylistDetail {
songCount: songCount ?? songs.length, songCount: songCount ?? songs.length,
duration: duration, duration: duration,
coverArt: coverArt, coverArt: coverArt,
comment: comment,
); );
} }

View file

@ -8,7 +8,7 @@ import 'json_helpers.dart';
import 'models.dart'; import 'models.dart';
/// A Subsonic API error (server returned `status: "failed"`), ported from /// 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 { class SubsonicError implements Exception {
SubsonicError(this.code, this.message); SubsonicError(this.code, this.message);
@ -22,7 +22,7 @@ class SubsonicError implements Exception {
String toString() => 'SubsonicError($code): $message'; 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 /// Auth is the classic token scheme: a fresh random salt per request and
/// `token = MD5(password + salt)`, sent as query params /// `token = MD5(password + salt)`, sent as query params
@ -57,7 +57,7 @@ class SubsonicClient {
url.endsWith('/') ? url.substring(0, url.length - 1) : url; url.endsWith('/') ? url.substring(0, url.length - 1) : url;
/// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in /// 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]) { String _makeSalt([int length = 12]) {
final rng = Random.secure(); final rng = Random.secure();
return List.generate( return List.generate(
@ -190,7 +190,7 @@ class SubsonicClient {
} }
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId) /// `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({ Future<void> setStarred({
required bool starred, required bool starred,
String? songId, String? songId,
@ -218,7 +218,7 @@ class SubsonicClient {
} }
// ---- Playlists ---------------------------------------------------------- // ---- 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`. // `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`.
/// `getPlaylists` — every playlist visible to the authenticated user. /// `getPlaylists` — every playlist visible to the authenticated user.
@ -266,6 +266,13 @@ class SubsonicClient {
await _get('updatePlaylist', {'playlistId': playlistId, 'name': name}); 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. /// `deletePlaylist` — delete a playlist by id.
Future<void> deletePlaylist(String id) async { Future<void> deletePlaylist(String id) async {
await _get('deletePlaylist', {'id': id}); await _get('deletePlaylist', {'id': id});

View file

@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'tokens.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 /// 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 /// then interpolated toward over ~400ms. Phase 1 just exposes the default and
/// a setter; the extraction + animation land in Phase 2 with playback. /// a setter; the extraction + animation land in Phase 2 with playback.
class AccentNotifier extends StateNotifier<Color> { class AccentNotifier extends StateNotifier<Color> {

View file

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.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 /// 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 /// OKLab; this HSL approximation is close enough for now (OKLab is a later
/// refinement noted in the roadmap). /// refinement noted in the roadmap).
Future<Color?> extractAccent(ImageProvider image) async { Future<Color?> extractAccent(ImageProvider image) async {

View file

@ -3,10 +3,10 @@ import 'package:google_fonts/google_fonts.dart';
import 'tokens.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 /// 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. /// is passed in so the theme rebuilds when album-art extraction changes it.
ThemeData buildTimbreTheme(Color accent) { ThemeData buildTimbreTheme(Color accent) {
final mono = GoogleFonts.jetBrainsMonoTextTheme( final mono = GoogleFonts.jetBrainsMonoTextTheme(

View file

@ -1,7 +1,7 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Design tokens mirroring Ratune's `dynamic`/`static` theme defaults /// Design tokens mirroring Timbre's `dynamic`/`static` theme defaults
/// (see ratune `docs/sample-config.toml`, `theme.rs`, `color.rs`). /// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`).
/// ///
/// The palette is intentionally near-black and low-contrast; the *accent* /// The palette is intentionally near-black and low-contrast; the *accent*
/// is the one lively color and, in the `dynamic` theme, is extracted live /// 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); static const Color border = Color(0xFF252525);
/// Hairline borders when a pane is focused — `border_active`. /// 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. /// swap in the accent at runtime once art extraction lands.
static const Color borderActive = Color(0xFF3A3A3A); static const Color borderActive = Color(0xFF3A3A3A);

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/tokens.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, /// (`progress_style = "██░"`). Discrete cells: filled cells use the accent,
/// empty cells the border grey, with hairline gaps between them. /// empty cells the border grey, with hairline gaps between them.
class BlockProgressBar extends StatelessWidget { class BlockProgressBar extends StatelessWidget {

View 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);
}
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
/// A thin-stroked panel with its title label sitting *on* the top border — /// 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, /// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
/// then overlay the title with a background that occludes the segment behind /// then overlay the title with a background that occludes the segment behind
/// it, so the label appears to break the line. /// it, so the label appears to break the line.

38
lib/widgets/toast.dart Normal file
View 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),
),
),
],
),
),
);
}

View file

@ -270,6 +270,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.2" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -496,6 +504,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -733,6 +749,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:

View file

@ -44,6 +44,7 @@ dependencies:
just_audio: ^0.10.6 just_audio: ^0.10.6
just_audio_background: ^0.0.1-beta.17 just_audio_background: ^0.0.1-beta.17
palette_generator: ^0.3.3+7 palette_generator: ^0.3.3+7
flutter_svg: ^2.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -77,6 +78,9 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/casette/
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg

View file

@ -1,6 +1,9 @@
import 'package:flutter/widgets.dart' show Color;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:timbre/playlists/playlists.dart'; import 'package:timbre/playlists/playlists.dart';
import 'package:timbre/settings/settings_store.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/models.dart';
import 'package:timbre/subsonic/subsonic_client.dart'; import 'package:timbre/subsonic/subsonic_client.dart';
@ -12,6 +15,9 @@ class _FakeClient extends SubsonicClient {
final List<Playlist> playlists = []; final List<Playlist> playlists = [];
final Map<String, PlaylistDetail> details = {}; final Map<String, PlaylistDetail> details = {};
bool failNext = false; 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; int _seq = 0;
void _maybeThrow() { void _maybeThrow() {
@ -37,11 +43,55 @@ class _FakeClient extends SubsonicClient {
return d; 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 @override
Future<void> addTracksToPlaylist(String playlistId, List<String> songIds) async { Future<void> addTracksToPlaylist(String playlistId, List<String> songIds) async {
_maybeThrow(); _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 @override
Future<void> removeTrackFromPlaylist(String playlistId, int index) async { Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
_maybeThrow(); _maybeThrow();
@ -106,6 +156,101 @@ void main() {
expect(back.downloadMaxBitRate, 0); expect(back.downloadMaxBitRate, 0);
expect(back.downloadFormat, 'opus'); 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', () { group('PlaylistsController optimistic mutations', () {
@ -166,4 +311,52 @@ void main() {
expect(controller.state.playlists.single.name, 'Keep'); 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);
});
});
} }