From ed910748cb226efc901a95b7a2f86b59908d1c01 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 29 Jul 2026 22:41:38 -0400 Subject: [PATCH] updates --- assets/casette/casette.png | Bin 0 -> 8503 bytes assets/casette/casette_shell.svg | 17 ++ assets/casette/spindle.svg | 3 + lib/history/play_history.dart | 4 +- lib/library/library_index.dart | 4 +- lib/main.dart | 9 +- lib/playback/playback_engine.dart | 8 +- lib/playlists/playlists.dart | 98 +++++++++ lib/screens/add_tag_sheet.dart | 151 +++++++++++++ lib/screens/add_to_playlist_sheet.dart | 10 +- lib/screens/browser_screen.dart | 48 +++- lib/screens/connect_sheet.dart | 261 +++++++++++++++++++--- lib/screens/now_playing_screen.dart | 38 ++-- lib/screens/playlists_screen.dart | 20 +- lib/screens/settings_screen.dart | 294 +++++++++++++++++++++++++ lib/screens/tags_screen.dart | 187 ++++++++++++++++ lib/settings/settings_store.dart | 147 ++++++++++++- lib/shell/app_shell.dart | 37 +++- lib/state/providers.dart | 181 +++++++++++++-- lib/subsonic/credentials.dart | 102 +++++++-- lib/subsonic/json_helpers.dart | 4 +- lib/subsonic/models.dart | 18 +- lib/subsonic/subsonic_client.dart | 17 +- lib/theme/accent.dart | 4 +- lib/theme/accent_extract.dart | 4 +- lib/theme/app_theme.dart | 4 +- lib/theme/tokens.dart | 6 +- lib/widgets/block_progress_bar.dart | 2 +- lib/widgets/cassette_view.dart | 252 +++++++++++++++++++++ lib/widgets/hairline_panel.dart | 2 +- lib/widgets/toast.dart | 38 ++++ pubspec.lock | 40 ++++ pubspec.yaml | 4 + test/features_test.dart | 193 ++++++++++++++++ 34 files changed, 2054 insertions(+), 153 deletions(-) create mode 100644 assets/casette/casette.png create mode 100644 assets/casette/casette_shell.svg create mode 100644 assets/casette/spindle.svg create mode 100644 lib/screens/add_tag_sheet.dart create mode 100644 lib/screens/tags_screen.dart create mode 100644 lib/widgets/cassette_view.dart create mode 100644 lib/widgets/toast.dart diff --git a/assets/casette/casette.png b/assets/casette/casette.png new file mode 100644 index 0000000000000000000000000000000000000000..1180c43478f1029f53dbc142983c0368646a8030 GIT binary patch literal 8503 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 K3gvPyL;e@sVJ4^m literal 0 HcmV?d00001 diff --git a/assets/casette/casette_shell.svg b/assets/casette/casette_shell.svg new file mode 100644 index 0000000..13549b9 --- /dev/null +++ b/assets/casette/casette_shell.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/casette/spindle.svg b/assets/casette/spindle.svg new file mode 100644 index 0000000..41c6135 --- /dev/null +++ b/assets/casette/spindle.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/history/play_history.dart b/lib/history/play_history.dart index b5729f1..fc6812e 100644 --- a/lib/history/play_history.dart +++ b/lib/history/play_history.dart @@ -8,7 +8,7 @@ import 'package:path_provider/path_provider.dart'; import '../subsonic/models.dart'; -/// One play event, mirroring Ratune's `PlayRecord` (`history.rs`). +/// One play event, mirroring Timbre's `PlayRecord` (`history.rs`). class PlayRecord { PlayRecord({ required this.songId, @@ -173,7 +173,7 @@ List recentAlbums(List history, {int limit = 12}) { } /// Rediscover: artists you've heard before but aren't listening to now, biased -/// toward low play counts, then sampled for variety (Ratune +/// toward low play counts, then sampled for variety (Timbre /// `history.rs:101-167`). [seed] drives the re-roll. /// /// The desktop original only surfaces artists last heard more than [minDays] diff --git a/lib/library/library_index.dart b/lib/library/library_index.dart index 04c07c2..a9b6b6f 100644 --- a/lib/library/library_index.dart +++ b/lib/library/library_index.dart @@ -45,7 +45,7 @@ class LibraryIndexState { } /// Builds and caches a flat "all songs" index — Subsonic has no all-songs -/// endpoint, so we crawl every album (mirrors Ratune's `library_index` / +/// endpoint, so we crawl every album (mirrors Timbre's `library_index` / /// `fetch_all_library_songs`). The cache is keyed to the server so switching /// servers never serves a stale catalog, and writes are atomic so an /// interrupted crawl can't leave a truncated file. @@ -54,7 +54,7 @@ class LibraryIndexController extends StateNotifier { final SubsonicClient? Function() _clientGetter; - static const int _albumParallelism = 12; // Ratune's album_parallelism. + static const int _albumParallelism = 12; // Timbre's album_parallelism. static const int _pageSize = 500; // Subsonic getAlbumList2 cap. static const Duration _ttl = Duration(hours: 24); diff --git a/lib/main.dart b/lib/main.dart index 03de738..7e89838 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio_background/just_audio_background.dart'; +import 'settings/settings_store.dart'; import 'shell/app_shell.dart'; import 'theme/accent.dart'; import 'theme/app_theme.dart'; @@ -35,8 +36,12 @@ class TimbreApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // The theme rebuilds whenever the album-art accent changes. - final accent = ref.watch(accentProvider); + // The accent either tracks the album art (default) or is pinned to a + // user-chosen static color. The theme rebuilds whenever it changes. + final settings = ref.watch(settingsProvider); + final accent = settings.useStaticAccent + ? settings.staticAccentColor + : ref.watch(accentProvider); return MaterialApp( title: 'Timbre', debugShowCheckedModeBanner: false, diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 2e25ab6..1cb3804 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -11,8 +11,8 @@ import 'package:just_audio_background/just_audio_background.dart'; import '../subsonic/models.dart'; -/// Immutable snapshot of the player, mirroring Ratune's `QueueState` + -/// player-event stream (`ratune-player/src/engine.rs`). +/// Immutable snapshot of the player, mirroring Timbre's `QueueState` + +/// player-event stream (`timbre-player/src/engine.rs`). class PlaybackState { const PlaybackState({ this.queue = const [], @@ -212,7 +212,7 @@ class PlaybackController extends StateNotifier { // currentIndexStream fires _notifyCurrent for the started track. } - /// Insert [song] right after the current track (Ratune's "play next"). + /// Insert [song] right after the current track (Timbre's "play next"). /// Falls back to [playSongs] when nothing is playing. Future playNext(Song song) async { if (_streamUriFor(song) == null) return; @@ -306,7 +306,7 @@ class PlaybackController extends StateNotifier { } } - /// Cycle off → all → one → off (Ratune's queue-loop toggle, extended with + /// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with /// single-track repeat). Future cycleLoop() async { final nextMode = switch (state.loop) { diff --git a/lib/playlists/playlists.dart b/lib/playlists/playlists.dart index cf6c7b9..58d8462 100644 --- a/lib/playlists/playlists.dart +++ b/lib/playlists/playlists.dart @@ -5,12 +5,25 @@ import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import '../subsonic/models.dart'; import '../subsonic/subsonic_client.dart'; +/// Marker stored in a playlist's `comment` to flag it as a Timbre **tag** — a +/// playlist surfaced under the Tags UI instead of Playlists. Kept out of the +/// visible name so tags read cleanly everywhere (including other Subsonic +/// clients, where they still appear as ordinary playlists). +/// +/// Classification relies on `getPlaylists` returning the `comment` field; +/// Navidrome does. A server that omitted it would show tags as plain playlists. +const String kTagMarker = 'timbre:tag'; + +/// Whether [p] is a Timbre tag (vs a user-facing playlist). +bool isTagPlaylist(Playlist p) => p.comment == kTagMarker; + /// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details /// that have been opened. Details are cached so an opened playlist keeps working /// offline. @@ -185,6 +198,87 @@ class PlaylistsController extends StateNotifier { } } + // ---- 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 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 addToTag(String tagId, List songs) async { + if (songs.isEmpty) return; + final detail = await loadDetail(tagId); + final present = {for (final s in detail?.songs ?? const []) 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 rename(String id, String name) async { final client = _clientGetter(); if (client == null) return; @@ -280,6 +374,7 @@ class PlaylistsController extends StateNotifier { owner: p.owner, public: p.public, coverArt: p.coverArt, + comment: p.comment, ) else p, @@ -294,6 +389,7 @@ class PlaylistsController extends StateNotifier { owner: p.owner, public: p.public, coverArt: p.coverArt, + comment: p.comment, ); static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) => @@ -303,6 +399,7 @@ class PlaylistsController extends StateNotifier { songCount: d.songCount, duration: d.duration, coverArt: d.coverArt, + comment: d.comment, songs: d.songs, ); @@ -313,6 +410,7 @@ class PlaylistsController extends StateNotifier { songCount: songs.length, duration: d.duration, coverArt: d.coverArt, + comment: d.comment, songs: songs, ); diff --git a/lib/screens/add_tag_sheet.dart b/lib/screens/add_tag_sheet.dart new file mode 100644 index 0000000..547ae36 --- /dev/null +++ b/lib/screens/add_tag_sheet.dart @@ -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 showAddTagSheet( + BuildContext context, { + required List songs, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: TimbreColors.background, + isScrollControlled: true, + builder: (_) => _AddTagSheet(songs: songs), + ); +} + +class _AddTagSheet extends ConsumerWidget { + const _AddTagSheet({required this.songs}); + + final List 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 _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)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/add_to_playlist_sheet.dart b/lib/screens/add_to_playlist_sheet.dart index 95a8c08..9bade6a 100644 --- a/lib/screens/add_to_playlist_sheet.dart +++ b/lib/screens/add_to_playlist_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; +import '../widgets/toast.dart'; /// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when /// offline (playlist mutations require the server). @@ -27,7 +28,7 @@ class _AddToPlaylistSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accent = Theme.of(context).colorScheme.primary; - final playlists = ref.watch(playlistsProvider).playlists; + final playlists = ref.watch(realPlaylistsProvider); final connected = ref.watch(subsonicClientProvider) != null; return SafeArea( @@ -184,8 +185,5 @@ Future promptPlaylistName( ); } -void _toast(BuildContext context, String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), duration: const Duration(seconds: 2)), - ); -} +void _toast(BuildContext context, String message) => + showToast(context, message); diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index fb2eea1..3631af3 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -6,11 +6,14 @@ import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/hairline_panel.dart'; +import '../widgets/toast.dart'; +import 'add_tag_sheet.dart'; import 'add_to_playlist_sheet.dart'; import 'downloads_screen.dart'; import 'favorites_screen.dart'; import 'playlists_screen.dart'; import 'search_screen.dart'; +import 'tags_screen.dart'; /// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic /// server. Artists and Albums drill down by pushing onto the (nested) navigator; @@ -58,6 +61,13 @@ class BrowserScreen extends ConsumerWidget { MaterialPageRoute(builder: (_) => const PlaylistsScreen()), ), ), + _Action( + icon: Icons.label_outline, + label: 'Tags', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TagsScreen()), + ), + ), _Action( icon: Icons.download, label: 'Downloads', @@ -344,6 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { onAddToQueue: () => playback.addToQueue(song), onAddToPlaylist: () => showAddToPlaylistSheet(context, songs: [song]), + onAddToTag: () => showAddTagSheet(context, songs: [song]), onDownload: () => ref.read(downloadManagerProvider.notifier).download(song), onRemoveDownload: () => @@ -472,11 +483,7 @@ class AlbumScreen extends ConsumerWidget { ref .read(downloadManagerProvider.notifier) .downloadAll(songs); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Downloading album…'), - duration: Duration(seconds: 2)), - ); + showToast(context, 'Downloading album…'); }, icon: const Icon(Icons.download), ), @@ -499,6 +506,7 @@ class AlbumScreen extends ConsumerWidget { onAddToQueue: () => playback.addToQueue(song), onAddToPlaylist: () => showAddToPlaylistSheet(context, songs: [song]), + onAddToTag: () => showAddTagSheet(context, songs: [song]), onDownload: () => ref.read(downloadManagerProvider.notifier).download(song), onRemoveDownload: () => @@ -523,6 +531,7 @@ class BrowseRow extends StatelessWidget { this.onPlayNext, this.onAddToQueue, this.onAddToPlaylist, + this.onAddToTag, this.onDownload, this.onRemoveDownload, this.downloadStatus, @@ -540,6 +549,7 @@ class BrowseRow extends StatelessWidget { /// Secondary song actions, folded into a trailing overflow menu so the row /// stays uncluttered. final VoidCallback? onAddToPlaylist; + final VoidCallback? onAddToTag; final VoidCallback? onDownload; final VoidCallback? onRemoveDownload; @@ -548,7 +558,10 @@ class BrowseRow extends StatelessWidget { final DownloadStatus? downloadStatus; bool get _hasMenu => - onAddToPlaylist != null || onDownload != null || onRemoveDownload != null; + onAddToPlaylist != null || + onAddToTag != null || + onDownload != null || + onRemoveDownload != null; @override Widget build(BuildContext context) { @@ -600,14 +613,27 @@ class BrowseRow extends StatelessWidget { style: const TextStyle(color: TimbreColors.dimmed)), ], if (onPlayNext != null) - _RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!), + _RowIcon( + icon: Icons.playlist_play, + onTap: () { + onPlayNext!(); + showToast(context, 'Playing next', icon: Icons.check); + }, + ), if (onAddToQueue != null) - _RowIcon(icon: Icons.add, onTap: onAddToQueue!), + _RowIcon( + icon: Icons.add, + onTap: () { + onAddToQueue!(); + showToast(context, 'Added to queue', icon: Icons.check); + }, + ), if (_hasMenu) _RowMenu( isDownloaded: isDone, isDownloading: isActive, onAddToPlaylist: onAddToPlaylist, + onAddToTag: onAddToTag, onDownload: onDownload, onRemoveDownload: onRemoveDownload, ), @@ -626,6 +652,7 @@ class _RowMenu extends StatelessWidget { required this.isDownloaded, required this.isDownloading, this.onAddToPlaylist, + this.onAddToTag, this.onDownload, this.onRemoveDownload, }); @@ -633,6 +660,7 @@ class _RowMenu extends StatelessWidget { final bool isDownloaded; final bool isDownloading; final VoidCallback? onAddToPlaylist; + final VoidCallback? onAddToTag; final VoidCallback? onDownload; final VoidCallback? onRemoveDownload; @@ -648,6 +676,8 @@ class _RowMenu extends StatelessWidget { switch (v) { case 'playlist': onAddToPlaylist?.call(); + case 'tag': + onAddToTag?.call(); case 'download': onDownload?.call(); case 'remove_download': @@ -658,6 +688,8 @@ class _RowMenu extends StatelessWidget { if (onAddToPlaylist != null) const PopupMenuItem( value: 'playlist', child: Text('Add to playlist')), + if (onAddToTag != null) + const PopupMenuItem(value: 'tag', child: Text('Add tag…')), if (isDownloaded && onRemoveDownload != null) const PopupMenuItem( value: 'remove_download', child: Text('Remove download')) diff --git a/lib/screens/connect_sheet.dart b/lib/screens/connect_sheet.dart index 741cbf8..f11a422 100644 --- a/lib/screens/connect_sheet.dart +++ b/lib/screens/connect_sheet.dart @@ -5,18 +5,145 @@ import '../state/providers.dart'; import '../subsonic/credentials.dart'; import '../theme/tokens.dart'; -/// Open the connect-server sheet. -Future showConnectSheet(BuildContext context) { +/// Open the add/edit-server sheet. Pass [initial] to edit an existing saved +/// server (its password is reused if the field is left blank). +Future showConnectSheet(BuildContext context, + {SubsonicCredentials? initial}) { return showModalBottomSheet( context: context, backgroundColor: TimbreColors.background, isScrollControlled: true, - builder: (_) => const _ConnectSheet(), + builder: (_) => _ConnectSheet(initial: initial), ); } +/// Open the server switcher: pick an active server, or add a new one. +Future showServerSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: TimbreColors.background, + isScrollControlled: true, + builder: (_) => const _ServerSheet(), + ); +} + +/// Lists saved servers (tap to switch) with an "Add server" action. +class _ServerSheet extends ConsumerWidget { + const _ServerSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conn = ref.watch(connectionProvider); + final accent = Theme.of(context).colorScheme.primary; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl), + child: Text('Servers', + style: TextStyle(color: accent, fontWeight: FontWeight.w700)), + ), + const SizedBox(height: TimbreSpacing.md), + if (conn.servers.isEmpty) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), + child: Text('No servers saved yet.', + style: TextStyle(color: TimbreColors.dimmed)), + ) + else + for (final s in conn.servers) + _ServerRow( + creds: s, + active: s.id == conn.activeId, + onTap: () { + // Grab the notifier before popping — this widget's `ref` is + // disposed with the sheet. + final notifier = ref.read(connectionProvider.notifier); + Navigator.of(context).pop(); + notifier.switchTo(s.id); + }, + ), + const SizedBox(height: TimbreSpacing.sm), + InkWell( + onTap: () { + // Capture the navigator's (still-mounted) context before popping + // this sheet, so the next sheet has a valid overlay to show in. + final nav = Navigator.of(context); + nav.pop(); + showConnectSheet(nav.context); + }, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), + child: Row( + children: [ + Icon(Icons.add, size: 16, color: TimbreColors.foreground), + SizedBox(width: TimbreSpacing.sm), + Text('Add server', + style: TextStyle(color: TimbreColors.foreground)), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ServerRow extends StatelessWidget { + const _ServerRow({ + required this.creds, + required this.active, + required this.onTap, + }); + + final SubsonicCredentials creds; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + return InkWell( + onTap: onTap, + child: Container( + constraints: + const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), + padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl), + child: Row( + children: [ + Text(active ? '● ' : '○ ', + style: TextStyle( + color: active ? accent : TimbreColors.dimmed)), + Expanded( + child: Text( + creds.display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + ], + ), + ), + ); + } +} + class _ConnectSheet extends ConsumerStatefulWidget { - const _ConnectSheet(); + const _ConnectSheet({this.initial}); + + final SubsonicCredentials? initial; @override ConsumerState<_ConnectSheet> createState() => _ConnectSheetState(); @@ -26,15 +153,20 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> { final _url = TextEditingController(); final _user = TextEditingController(); final _pass = TextEditingController(); + final _alias = TextEditingController(); bool _busy = false; + String? _localError; + + bool get _isEdit => widget.initial != null; @override void initState() { super.initState(); - final creds = ref.read(connectionProvider).credentials; - if (creds != null) { - _url.text = creds.url; - _user.text = creds.username; + final initial = widget.initial; + if (initial != null) { + _url.text = initial.url; + _user.text = initial.username; + _alias.text = initial.alias ?? ''; } } @@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> { _url.dispose(); _user.dispose(); _pass.dispose(); + _alias.dispose(); super.dispose(); } + /// Build credentials from the form. Returns null (and sets [_localError]) if + /// required fields are missing. On edit, a blank password reuses the saved one. + SubsonicCredentials? _readForm() { + final url = _url.text.trim(); + final user = _user.text.trim(); + if (url.isEmpty || user.isEmpty) { + setState(() => _localError = 'Server URL and username are required.'); + return null; + } + final password = + _pass.text.isEmpty && _isEdit ? widget.initial!.password : _pass.text; + if (password.isEmpty) { + setState(() => _localError = 'Password is required.'); + return null; + } + final alias = _alias.text.trim(); + return SubsonicCredentials( + url: url, + username: user, + password: password, + alias: alias.isEmpty ? null : alias, + ); + } + Future _connect() async { - setState(() => _busy = true); - final ok = await ref.read(connectionProvider.notifier).connect( - SubsonicCredentials( - url: _url.text.trim(), - username: _user.text.trim(), - password: _pass.text, - ), - ); + final creds = _readForm(); + if (creds == null) return; + setState(() { + _busy = true; + _localError = null; + }); + final ok = await ref.read(connectionProvider.notifier).connect(creds); if (!mounted) return; setState(() => _busy = false); if (ok) Navigator.of(context).pop(); } + Future _save() async { + final creds = _readForm(); + if (creds == null) return; + setState(() { + _busy = true; + _localError = null; + }); + await ref.read(connectionProvider.notifier).saveServer(creds); + if (!mounted) return; + setState(() => _busy = false); + Navigator.of(context).pop(); + } + @override Widget build(BuildContext context) { final conn = ref.watch(connectionProvider); final accent = Theme.of(context).colorScheme.primary; + final error = _localError ?? + (conn.status == ConnStatus.error ? conn.error : null); return Padding( padding: EdgeInsets.only( left: TimbreSpacing.xl, @@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Connect to server', + Text(_isEdit ? 'Edit server' : 'Add server', style: TextStyle(color: accent, fontWeight: FontWeight.w700)), const SizedBox(height: TimbreSpacing.lg), _field(_url, 'Server URL', 'https://navidrome.example.com', keyboard: TextInputType.url), _field(_user, 'Username', 'you'), - _field(_pass, 'Password', '••••••••', obscure: true), - if (conn.status == ConnStatus.error && conn.error != null) ...[ + _field(_pass, 'Password', + _isEdit ? '•••••••• (unchanged)' : '••••••••', + obscure: true), + _field(_alias, 'Label (optional)', 'Home server'), + if (error != null) ...[ const SizedBox(height: TimbreSpacing.md), - Text(conn.error!, - style: const TextStyle(color: Color(0xFFE06C75))), + Text(error, style: const TextStyle(color: Color(0xFFE06C75))), ], const SizedBox(height: TimbreSpacing.lg), - FilledButton( - onPressed: _busy ? null : _connect, - style: FilledButton.styleFrom( - backgroundColor: accent, - foregroundColor: TimbreColors.background, - shape: const RoundedRectangleBorder(), - ), - child: _busy - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Connect'), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy ? null : _save, + style: OutlinedButton.styleFrom( + foregroundColor: TimbreColors.foreground, + side: const BorderSide(color: TimbreColors.border), + shape: const RoundedRectangleBorder(), + ), + child: const Text('Save'), + ), + ), + const SizedBox(width: TimbreSpacing.md), + Expanded( + child: FilledButton( + onPressed: _busy ? null : _connect, + style: FilledButton.styleFrom( + backgroundColor: accent, + foregroundColor: TimbreColors.background, + shape: const RoundedRectangleBorder(), + ), + child: _busy + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Connect'), + ), + ), + ], ), ], ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 487ceed..f18df1c 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -4,10 +4,12 @@ import 'package:just_audio/just_audio.dart' show LoopMode; import '../downloads/download_manager.dart'; import '../playback/playback_engine.dart'; +import '../settings/settings_store.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/block_progress_bar.dart'; +import '../widgets/cassette_view.dart'; import '../widgets/hairline_panel.dart'; import 'add_to_playlist_sheet.dart'; @@ -210,28 +212,32 @@ class _AlbumArtPanel extends ConsumerWidget { final coverArt = ref.watch(playbackProvider.select((s) => s.current?.coverArt)); final client = ref.watch(subsonicClientProvider); + final cassette = + ref.watch(settingsProvider.select((s) => s.nowPlayingCassette)); final artUri = (client != null && coverArt != null) ? client.coverArtUri(coverArt, size: 512).toString() : null; return HairlinePanel( - title: 'Album Art', + title: cassette ? 'Cassette' : 'Album Art', padding: const EdgeInsets.all(TimbreSpacing.md), - child: AspectRatio( - aspectRatio: 1, - child: ColoredBox( - color: TimbreColors.surface, - child: artUri != null - ? Image.network( - artUri, - key: ValueKey(artUri), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const _ArtFallback(), - ) - : const _ArtFallback(), - ), - ), + child: cassette + ? Center(child: CassetteView(artUri: artUri)) + : AspectRatio( + aspectRatio: 1, + child: ColoredBox( + color: TimbreColors.surface, + child: artUri != null + ? Image.network( + artUri, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const _ArtFallback(), + ) + : const _ArtFallback(), + ), + ), ); } } diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index f2bb989..2ff7b22 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -7,6 +7,7 @@ import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/hairline_panel.dart'; +import '../widgets/toast.dart'; import 'add_to_playlist_sheet.dart'; /// Playlists list — server-backed with an offline mirror. Create from the app @@ -16,7 +17,7 @@ class PlaylistsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(playlistsProvider); + final playlists = ref.watch(realPlaylistsProvider); final controller = ref.read(playlistsProvider.notifier); final connected = ref.watch(subsonicClientProvider) != null; @@ -38,10 +39,9 @@ class PlaylistsScreen extends ConsumerWidget { child: HairlinePanel( title: 'Playlists', active: true, - trailing: - state.playlists.isEmpty ? null : '(${state.playlists.length})', + trailing: playlists.isEmpty ? null : '(${playlists.length})', padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), - child: state.playlists.isEmpty + child: playlists.isEmpty ? Center( child: Text( connected @@ -53,9 +53,9 @@ class PlaylistsScreen extends ConsumerWidget { ) : ListView.builder( padding: EdgeInsets.zero, - itemCount: state.playlists.length, + itemCount: playlists.length, itemBuilder: (context, i) { - final p = state.playlists[i]; + final p = playlists[i]; return _PlaylistRow( name: p.name, subtitle: p.songCount != null @@ -233,11 +233,7 @@ class _PlaylistDetailScreenState extends ConsumerState { ref .read(downloadManagerProvider.notifier) .downloadAll(songs); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Downloading playlist…'), - duration: Duration(seconds: 2)), - ); + showToast(context, 'Downloading playlist…'); }, icon: const Icon(Icons.download), ), @@ -337,8 +333,10 @@ class _TrackRow extends StatelessWidget { switch (v) { case 'next': onPlayNext(); + showToast(context, 'Playing next', icon: Icons.check); case 'queue': onAddToQueue(); + showToast(context, 'Added to queue', icon: Icons.check); case 'remove': onRemove?.call(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index be392c8..b318935 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../settings/settings_store.dart'; +import '../state/providers.dart'; +import '../subsonic/credentials.dart'; import '../theme/tokens.dart'; import '../widgets/hairline_panel.dart'; +import 'connect_sheet.dart'; /// Audio-quality settings: independent streaming and download knobs, both /// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`). @@ -14,6 +17,7 @@ class SettingsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); final controller = ref.read(settingsProvider.notifier); + final conn = ref.watch(connectionProvider); return Scaffold( appBar: AppBar( @@ -72,6 +76,143 @@ class SettingsScreen extends ConsumerWidget { ], ), ), + const SizedBox(height: TimbreSpacing.xl), + HairlinePanel( + title: 'Appearance', + active: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Caption( + 'By default the accent color is drawn from the playing ' + 'album art. Turn on a static accent to pin one color.'), + const SizedBox(height: TimbreSpacing.md), + _ChoiceChips( + 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( + 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( + 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( + label: 'Search mode', + values: const [SearchMode.standard, SearchMode.discovery], + selected: settings.searchMode, + labelFor: AppSettings.searchModeLabel, + onSelect: controller.setSearchMode, + ), + ], + ), + ), + const SizedBox(height: TimbreSpacing.xl), + HairlinePanel( + title: 'Servers', + active: true, + trailing: + conn.servers.isNotEmpty ? '(${conn.servers.length})' : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Caption( + 'Save multiple servers and switch the active one. Only ' + 'one is connected at a time.'), + const SizedBox(height: TimbreSpacing.sm), + if (conn.servers.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm), + child: Text('No servers saved yet.', + style: TextStyle(color: TimbreColors.dimmed)), + ) + else + for (final s in conn.servers) + _ServerManageRow( + creds: s, + active: s.id == conn.activeId, + onActivate: () => ref + .read(connectionProvider.notifier) + .switchTo(s.id), + onEdit: () => + showConnectSheet(context, initial: s), + onDelete: () => ref + .read(connectionProvider.notifier) + .removeServer(s.id), + ), + const SizedBox(height: TimbreSpacing.sm), + InkWell( + onTap: () => showConnectSheet(context), + child: const Padding( + padding: + EdgeInsets.symmetric(vertical: TimbreSpacing.md), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, + size: 16, color: TimbreColors.foreground), + SizedBox(width: TimbreSpacing.sm), + Text('Add server', + style: + TextStyle(color: TimbreColors.foreground)), + ], + ), + ), + ), + ], + ), + ), ], ), ), @@ -79,6 +220,72 @@ class SettingsScreen extends ConsumerWidget { } } +/// A saved-server row in Settings: activate (tap the label), edit, or delete. +class _ServerManageRow extends StatelessWidget { + const _ServerManageRow({ + required this.creds, + required this.active, + required this.onActivate, + required this.onEdit, + required this.onDelete, + }); + + final SubsonicCredentials creds; + final bool active; + final VoidCallback onActivate; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + return Row( + children: [ + Expanded( + child: InkWell( + onTap: onActivate, + child: Container( + constraints: const BoxConstraints( + minHeight: TimbreSpacing.minTouchTarget), + alignment: Alignment.centerLeft, + child: Row( + children: [ + Text(active ? '● ' : '○ ', + style: TextStyle( + color: active ? accent : TimbreColors.dimmed)), + Expanded( + child: Text( + creds.display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: + active ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + ], + ), + ), + ), + ), + IconButton( + tooltip: 'Edit', + onPressed: onEdit, + icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed), + ), + IconButton( + tooltip: 'Delete', + onPressed: onDelete, + icon: + const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed), + ), + ], + ); + } +} + class _Caption extends StatelessWidget { const _Caption(this.text); final String text; @@ -172,3 +379,90 @@ class _Chip extends StatelessWidget { ); } } + +/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a +/// color swatch and the color's own hue drives the active border/fill. +class _ColorChips extends StatelessWidget { + const _ColorChips({ + required this.label, + required this.values, + required this.selected, + required this.onSelect, + }); + + final String label; + final List values; + final Color selected; + final ValueChanged 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/tags_screen.dart b/lib/screens/tags_screen.dart new file mode 100644 index 0000000..69a1d47 --- /dev/null +++ b/lib/screens/tags_screen.dart @@ -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 _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 _confirmDelete( + BuildContext context, PlaylistsController controller, Playlist t) async { + final ok = await showDialog( + 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( + 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')), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/settings/settings_store.dart b/lib/settings/settings_store.dart index b31bbf4..8e7ecf5 100644 --- a/lib/settings/settings_store.dart +++ b/lib/settings/settings_store.dart @@ -1,19 +1,43 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/widgets.dart' show Color; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; -/// User-tunable audio quality. Streaming and offline downloads have independent -/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` + -/// `format`; a rate of 0 means original / no transcode). These are global (not -/// per-server) and persisted to a single atomic JSON file, following the -/// write pattern in `library/library_index.dart`. +/// Top-level Browse mode selector. Lives here (not in `state/providers.dart`) +/// so [AppSettings] can persist the user's default; `providers.dart` re-exports +/// it, so existing importers are unaffected. +enum BrowseMode { artists, albums, tracks } + +/// Search matching strategy. `discovery` returns the server's full `search3` +/// result (broad — e.g. songs matched via their artist name). `standard` +/// post-filters to items whose *own* name/title matches the query. +enum SearchMode { discovery, standard } + +/// A selectable static accent color offered by the theme override (TODO #1). +class AccentChoice { + const AccentChoice(this.name, this.color); + final String name; + final Color color; +} + +/// Application-wide preferences: audio quality, theme accent, default browse +/// view, and search mode. Streaming and offline downloads have independent +/// quality knobs, both driven by Subsonic's `stream` transcode params +/// (`maxBitRate` + `format`; a rate of 0 means original / no transcode). These +/// are global (not per-server) and persisted to a single atomic JSON file, +/// following the write pattern in `library/library_index.dart`. class AppSettings { const AppSettings({ this.streamMaxBitRate = 0, this.downloadMaxBitRate = 0, this.downloadFormat, + this.useStaticAccent = false, + this.staticAccentColor = _defaultStaticAccent, + this.defaultBrowseMode = BrowseMode.artists, + this.searchMode = SearchMode.discovery, + this.nowPlayingCassette = false, }); /// Cap for live streaming, in kbps. 0 = original / no transcode. @@ -26,20 +50,67 @@ class AppSettings { /// the original file / let the server decide. final String? downloadFormat; + /// When true, ignore album-art-derived accent and use [staticAccentColor] + /// as the sole accent color. When false (default), the accent tracks the + /// currently-playing album art. + final bool useStaticAccent; + + /// The fixed accent used while [useStaticAccent] is on. One of + /// [accentChoices]; defaults to Orange. + final Color staticAccentColor; + + /// Which sub-view the Browse tab opens on. + final BrowseMode defaultBrowseMode; + + /// Which search matching strategy to apply globally. + final SearchMode searchMode; + + /// When true, the Now Playing "art" view shows the animated cassette (cover + /// art on the label, reels driven by playback) instead of the plain square + /// album cover. + final bool nowPlayingCassette; + /// Offered bitrate choices (kbps); 0 renders as "Original". static const List bitrateChoices = [0, 96, 128, 192, 256, 320]; /// Offered download containers; null renders as "Original". static const List formatChoices = [null, 'mp3', 'opus', 'aac']; + /// Static accent palette offered when the override is enabled (TODO #1). + static const List accentChoices = [ + AccentChoice('Red', Color(0xFFDF3535)), + AccentChoice('Blue', Color(0xFF7177EA)), + AccentChoice('Green', Color(0xFF10996B)), + AccentChoice('Orange', Color(0xFFDF7E35)), + AccentChoice('Yellow', Color(0xFFDFC535)), + AccentChoice('Purple', Color(0xFF926CE9)), + AccentChoice('Cream', Color(0xFFFFDB9E)), + ]; + + static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange + static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps'; static String formatLabel(String? f) => f ?? 'Original'; + static String browseModeLabel(BrowseMode m) => switch (m) { + BrowseMode.artists => 'Artists', + BrowseMode.albums => 'Albums', + BrowseMode.tracks => 'Tracks', + }; + static String searchModeLabel(SearchMode m) => switch (m) { + SearchMode.standard => 'Standard', + SearchMode.discovery => 'Discovery', + }; AppSettings copyWith({ int? streamMaxBitRate, int? downloadMaxBitRate, // Sentinel so an explicit null (→ original) is distinguishable from "unset". Object? downloadFormat = _unset, + bool? useStaticAccent, + Color? staticAccentColor, + BrowseMode? defaultBrowseMode, + SearchMode? searchMode, + bool? nowPlayingCassette, }) => AppSettings( streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate, @@ -47,6 +118,11 @@ class AppSettings { downloadFormat: identical(downloadFormat, _unset) ? this.downloadFormat : downloadFormat as String?, + useStaticAccent: useStaticAccent ?? this.useStaticAccent, + staticAccentColor: staticAccentColor ?? this.staticAccentColor, + defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode, + searchMode: searchMode ?? this.searchMode, + nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette, ); static const Object _unset = Object(); @@ -55,13 +131,49 @@ class AppSettings { 'streamMaxBitRate': streamMaxBitRate, 'downloadMaxBitRate': downloadMaxBitRate, if (downloadFormat != null) 'downloadFormat': downloadFormat, + 'useStaticAccent': useStaticAccent, + 'staticAccentColor': _hexOf(staticAccentColor), + 'defaultBrowseMode': defaultBrowseMode.name, + 'searchMode': searchMode.name, + 'nowPlayingCassette': nowPlayingCassette, }; factory AppSettings.fromJson(Map j) => AppSettings( streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0, downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0, downloadFormat: j['downloadFormat'] as String?, + useStaticAccent: j['useStaticAccent'] as bool? ?? false, + staticAccentColor: + _colorOf(j['staticAccentColor'] as String?) ?? _defaultStaticAccent, + defaultBrowseMode: + _enumByName(BrowseMode.values, j['defaultBrowseMode'] as String?) ?? + BrowseMode.artists, + searchMode: + _enumByName(SearchMode.values, j['searchMode'] as String?) ?? + SearchMode.discovery, + nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false, ); + + /// Serialize a color to a `#RRGGBB` hex string. + static String _hexOf(Color c) => + '#${(c.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}'; + + /// Parse a `#RRGGBB` (or `RRGGBB`) hex string, or null if unparseable. + static Color? _colorOf(String? hex) { + if (hex == null) return null; + final h = hex.startsWith('#') ? hex.substring(1) : hex; + final v = int.tryParse(h, radix: 16); + if (v == null || h.length != 6) return null; + return Color(0xFF000000 | v); + } + + static T? _enumByName(List values, String? name) { + if (name == null) return null; + for (final v in values) { + if (v.name == name) return v; + } + return null; + } } /// Loads settings on launch and persists every change (atomic temp+rename). @@ -102,6 +214,31 @@ class SettingsController extends StateNotifier { _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 _persist() async { try { final file = _file; diff --git a/lib/shell/app_shell.dart b/lib/shell/app_shell.dart index 3751315..47aadf6 100644 --- a/lib/shell/app_shell.dart +++ b/lib/shell/app_shell.dart @@ -7,11 +7,12 @@ import '../screens/connect_sheet.dart'; import '../screens/home_screen.dart'; import '../screens/now_playing_screen.dart'; import '../screens/settings_screen.dart'; +import '../settings/settings_store.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; import '../widgets/mini_player.dart'; -/// Top-level shell: the three Ratune tabs (Home / Browse / Now Playing) with a +/// Top-level shell: the three Timbre tabs (Home / Browse / Now Playing) with a /// bottom tab bar and a status bar, mirroring the terminal layout. class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @@ -29,12 +30,39 @@ class _AppShellState extends ConsumerState { final _homeNavKey = GlobalKey(); final _browseNavKey = GlobalKey(); + // 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(settingsProvider, (prev, next) { + if (_browseSeeded) return; + _browseSeeded = true; + ref.read(browseModeProvider.notifier).state = next.defaultBrowseMode; + }); + } + GlobalKey? _navKeyForTab(int tab) => switch (tab) { 0 => _homeNavKey, 1 => _browseNavKey, _ => null, }; + /// Tap on the bottom tab bar. Selecting a different tab just switches; tapping + /// the already-active tab resets it to its root — popping the nested detail + /// stack (e.g. Album → Browse home) so the tab button doubles as "go back to + /// the top", instead of being a no-op. + void _selectTab(int tapped, int current) { + if (tapped == current) { + _navKeyForTab(tapped)?.currentState?.popUntil((r) => r.isFirst); + } else { + ref.read(selectedTabProvider.notifier).state = tapped; + } + } + /// Android system-back: pop the active tab's nested stack first, then fall /// back to Home, and only exit the app from the Home root. void _handleBack(int tab) { @@ -88,8 +116,7 @@ class _AppShellState extends ConsumerState { _TabBar( tabs: _tabs, index: index, - onSelect: (i) => - ref.read(selectedTabProvider.notifier).state = i, + onSelect: (i) => _selectTab(i, index), ), const _StatusBar(), ], @@ -201,10 +228,10 @@ class _StatusBar extends ConsumerWidget { color: TimbreColors.surface, child: Row( children: [ - // Left: connection status — tap to open the connect sheet. + // Left: connection status — tap to open the server switcher. Expanded( child: InkWell( - onTap: () => showConnectSheet(context), + onTap: () => showServerSheet(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg), diff --git a/lib/state/providers.dart b/lib/state/providers.dart index b55581d..265a3fc 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -1,6 +1,3 @@ -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; import 'package:flutter/widgets.dart' show NetworkImage; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -17,6 +14,11 @@ import '../theme/accent.dart'; import '../theme/accent_extract.dart'; import 'favorites.dart'; +// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be +// persisted) but historically lived here — re-export so existing importers of +// this file keep resolving them. +export '../settings/settings_store.dart' show BrowseMode, SearchMode; + // ---- Connection --------------------------------------------------------- enum ConnStatus { disconnected, connecting, online, error } @@ -27,6 +29,7 @@ class ConnectionState { this.client, this.credentials, this.error, + this.servers = const [], }); final ConnStatus status; @@ -34,14 +37,32 @@ class ConnectionState { final SubsonicCredentials? credentials; final String? error; + /// All saved servers (one may be the active [credentials]). Drives the + /// server switcher and the Settings "Servers" section. + final List servers; + bool get isOnline => status == ConnStatus.online; + + /// Id of the active server, or null when disconnected with none configured. + String? get activeId => credentials?.id; + + /// Only the saved-servers list changes; connection fields are preserved. + ConnectionState copyWith({List? servers}) => + ConnectionState( + status: status, + client: client, + credentials: credentials, + error: error, + servers: servers ?? this.servers, + ); } final credentialStoreProvider = Provider((_) => CredentialStore()); -/// Owns the active server connection: builds the client, pings, persists -/// credentials, and auto-restores on launch. +/// Owns the active server connection and the list of saved servers: builds the +/// client, pings, persists credentials, auto-restores on launch, and switches +/// between servers (one active at a time — TODO #2). class ConnectionController extends StateNotifier { ConnectionController(this._store) : super(const ConnectionState(status: ConnStatus.disconnected)) { @@ -49,13 +70,21 @@ class ConnectionController extends StateNotifier { } final CredentialStore _store; + List _servers = const []; + String? _activeId; Future _restore() async { try { - final creds = await _store.load(); - if (creds != null) { - await connect(creds, persist: false); - } + _servers = await _store.loadAll(); + _activeId = await _store.loadActiveId(); + state = state.copyWith(servers: _servers); + if (_servers.isEmpty) return; + final active = _servers.firstWhere( + (s) => s.id == _activeId, + orElse: () => _servers.first, + ); + _activeId = active.id; + await connect(active, persist: false); } catch (_) { // No secure-storage backend available (e.g. Linux without a running // keyring daemon) — start disconnected rather than crashing. @@ -63,7 +92,7 @@ class ConnectionController extends StateNotifier { } Future connect(SubsonicCredentials creds, {bool persist = true}) async { - state = const ConnectionState(status: ConnStatus.connecting); + state = ConnectionState(status: ConnStatus.connecting, servers: _servers); final client = SubsonicClient( baseUrl: creds.url, username: creds.username, @@ -79,20 +108,25 @@ class ConnectionController extends StateNotifier { status: ConnStatus.error, credentials: creds, error: 'Could not reach the server.', + servers: _servers, ); return false; } // Persistence is best-effort: a locked/absent secure-storage backend // (e.g. a locked Linux keyring) must not stop this session connecting. + // A successful connect saves the server and makes it active. if (persist) { try { - await _store.save(creds); + await _addOrUpdate(creds, makeActive: true); } catch (_) {} + } else { + _activeId = creds.id; } state = ConnectionState( status: ConnStatus.online, client: client, credentials: creds, + servers: _servers, ); return true; } on SubsonicError catch (e) { @@ -102,6 +136,7 @@ class ConnectionController extends StateNotifier { status: ConnStatus.error, credentials: e.isAuthFailure ? null : creds, error: e.isAuthFailure ? 'Wrong username or password.' : e.message, + servers: _servers, ); return false; } catch (e) { @@ -109,13 +144,85 @@ class ConnectionController extends StateNotifier { status: ConnStatus.error, credentials: creds, error: e.toString(), + servers: _servers, ); return false; } } + /// Switch the active server to [id] and connect. No password re-entry — saved + /// servers keep their credentials. Per-server downloads/playlists reload + /// automatically via [serverKeyProvider] listeners. + Future 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 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 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 _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 disconnect() async { - await _store.clear(); + _servers = const []; + _activeId = null; + try { + await _store.clear(); + } catch (_) {} state = const ConnectionState(status: ConnStatus.disconnected); } } @@ -135,12 +242,7 @@ final subsonicClientProvider = Provider( /// retains credentials). Null when no server has ever been configured. Used to /// scope on-disk downloads/playlists to the server they belong to. final serverKeyProvider = Provider((ref) { - final creds = ref.watch(connectionProvider).credentials; - if (creds == null) return null; - final base = creds.url.endsWith('/') - ? creds.url.substring(0, creds.url.length - 1) - : creds.url; - return md5.convert(utf8.encode('$base|${creds.username}')).toString(); + return ref.watch(connectionProvider).credentials?.id; }); // ---- Navigation / browse mode ------------------------------------------- @@ -152,9 +254,8 @@ final selectedTabProvider = StateProvider((_) => 0); /// Index of the Now Playing tab in the shell's tab list. const int nowPlayingTabIndex = 2; -/// Top-level Browse mode selector. -enum BrowseMode { artists, albums, tracks } - +/// Top-level Browse mode selector. Seeded once from the persisted +/// `AppSettings.defaultBrowseMode` by `AppShell`; manual selection then wins. final browseModeProvider = StateProvider((_) => BrowseMode.artists); // ---- Library ------------------------------------------------------------ @@ -227,12 +328,30 @@ final albumProvider = FutureProvider.family((ref, id) async { final searchProvider = FutureProvider.family((ref, query) async { final client = ref.watch(subsonicClientProvider); - if (client == null || query.trim().isEmpty) { + final q = query.trim(); + if (client == null || q.isEmpty) { return SearchResult3(artists: const [], albums: const [], songs: const []); } - return client.search3(query.trim()); + final result = await client.search3(q); + // "Standard" trims the server's broad matches down to name/title hits; + // "Discovery" (default) returns the server result unchanged. + final mode = ref.watch(settingsProvider).searchMode; + return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result; }); +/// Narrows a [SearchResult3] to items whose *own* name/title contains [query] +/// (case-insensitive) — dropping songs that only matched via their artist or +/// album fields. Backs the "Standard" search mode (TODO #4). +SearchResult3 filterSearchToStandard(SearchResult3 r, String query) { + final q = query.toLowerCase(); + bool has(String? s) => s != null && s.toLowerCase().contains(q); + return SearchResult3( + artists: r.artists.where((a) => has(a.name)).toList(), + albums: r.albums.where((a) => has(a.name)).toList(), + songs: r.songs.where((s) => has(s.title)).toList(), + ); +} + // ---- History (Home tab) ------------------------------------------------- final playHistoryProvider = @@ -318,6 +437,20 @@ final playlistsProvider = return controller; }); +/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the +/// Playlists screen and the "add to playlist" sheet. +final realPlaylistsProvider = Provider>((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>( + (ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList()); + // ---- Playback ----------------------------------------------------------- final playbackProvider = @@ -345,6 +478,8 @@ final playbackProvider = streamUriFor: streamUriFor, coverArtUriFor: coverArtUriFor, onArt: (artUri) async { + // Skip extraction entirely when the user has pinned a static accent. + if (ref.read(settingsProvider).useStaticAccent) return; final color = await extractAccent(NetworkImage(artUri.toString())); if (color != null) ref.read(accentProvider.notifier).set(color); }, diff --git a/lib/subsonic/credentials.dart b/lib/subsonic/credentials.dart index b2dba21..065aacd 100644 --- a/lib/subsonic/credentials.dart +++ b/lib/subsonic/credentials.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; /// A Subsonic server connection. The password is only ever held in memory and -/// in the platform secure store — never in plain app storage (mirrors Ratune's +/// in the platform secure store — never in plain app storage (mirrors Timbre's /// keyring-first secret handling). class SubsonicCredentials { const SubsonicCredentials({ @@ -15,50 +18,121 @@ class SubsonicCredentials { final String username; final String password; - /// Optional display label shown instead of the raw URL (Ratune `[server].alias`). + /// Optional display label shown instead of the raw URL (Timbre `[server].alias`). final String? alias; String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host; + + /// Stable identity for a saved server: `md5(baseUrl|username)`, matching the + /// cache key in `serverKeyProvider` so per-server data lines up. Two entries + /// with the same URL + username are the "same" server (different password / + /// alias overwrites it). + String get id { + final base = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + return md5.convert(utf8.encode('$base|$username')).toString(); + } + + Map toJson() => { + 'url': url, + 'username': username, + 'password': password, + if (alias != null && alias!.isNotEmpty) 'alias': alias, + }; + + factory SubsonicCredentials.fromJson(Map j) { + final alias = j['alias'] as String?; + return SubsonicCredentials( + url: j['url'] as String? ?? '', + username: j['username'] as String? ?? '', + password: j['password'] as String? ?? '', + alias: (alias == null || alias.isEmpty) ? null : alias, + ); + } } -/// Persists [SubsonicCredentials] to the platform secure store -/// (Keychain / Keystore / libsecret). +/// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one +/// to the platform secure store (Keychain / Keystore / libsecret). Legacy +/// single-server installs are migrated into the list on first load. class CredentialStore { CredentialStore([FlutterSecureStorage? storage]) : _storage = storage ?? const FlutterSecureStorage(); final FlutterSecureStorage _storage; + /// JSON array of all saved servers. + static const _kServers = 'subsonic_servers'; + + /// Id ([SubsonicCredentials.id]) of the active server. + static const _kActive = 'subsonic_active'; + + // Legacy single-server keys, read once for migration then removed. static const _kUrl = 'subsonic_url'; static const _kUser = 'subsonic_username'; static const _kPass = 'subsonic_password'; static const _kAlias = 'subsonic_alias'; - Future save(SubsonicCredentials creds) async { - await _storage.write(key: _kUrl, value: creds.url); - await _storage.write(key: _kUser, value: creds.username); - await _storage.write(key: _kPass, value: creds.password); - await _storage.write(key: _kAlias, value: creds.alias ?? ''); + /// All saved servers. Empty if none configured. Migrates a legacy + /// single-server install into the new list format on first call. + Future> loadAll() async { + final raw = await _storage.read(key: _kServers); + if (raw != null && raw.isNotEmpty) { + try { + final list = (jsonDecode(raw) as List) + .whereType() + .map((m) => SubsonicCredentials.fromJson(m.cast())) + .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 load() async { + /// Id of the active server, or null. + Future loadActiveId() => _storage.read(key: _kActive); + + /// Overwrite the saved list and active pointer atomically-ish (two writes). + Future saveAll( + List 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 _migrateLegacy() async { final url = await _storage.read(key: _kUrl); final user = await _storage.read(key: _kUser); final pass = await _storage.read(key: _kPass); if (url == null || user == null || pass == null) return null; final alias = await _storage.read(key: _kAlias); - return SubsonicCredentials( + final creds = SubsonicCredentials( url: url, username: user, password: pass, alias: (alias == null || alias.isEmpty) ? null : alias, ); - } - - Future clear() async { + // Write the new format, point at it, then drop the legacy keys. + await saveAll([creds], creds.id); await _storage.delete(key: _kUrl); await _storage.delete(key: _kUser); await _storage.delete(key: _kPass); await _storage.delete(key: _kAlias); + return creds; + } + + /// Remove all saved servers and the active pointer. + Future clear() async { + await _storage.delete(key: _kServers); + await _storage.delete(key: _kActive); } } diff --git a/lib/subsonic/json_helpers.dart b/lib/subsonic/json_helpers.dart index c3bc8dc..206f92a 100644 --- a/lib/subsonic/json_helpers.dart +++ b/lib/subsonic/json_helpers.dart @@ -1,7 +1,7 @@ /// Defensive JSON helpers for the Subsonic protocol. /// -/// Subsonic servers are inconsistent (Ratune handles the same quirks in -/// `ratune-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`): +/// Subsonic servers are inconsistent (Timbre handles the same quirks in +/// `timbre-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`): /// * a field that is sometimes a single object and sometimes an array /// * IDs that are sometimes strings and sometimes integers /// Every accessor below tolerates nulls and mixed types rather than throwing. diff --git a/lib/subsonic/models.dart b/lib/subsonic/models.dart index 43e9599..2c5236d 100644 --- a/lib/subsonic/models.dart +++ b/lib/subsonic/models.dart @@ -1,6 +1,6 @@ import 'json_helpers.dart'; -/// Subsonic domain models, ported from `ratune-subsonic/src/models.rs`. +/// Subsonic domain models, ported from `timbre-subsonic/src/models.rs`. /// Nearly every field is nullable — servers omit fields freely. All parsing /// goes through the defensive helpers in `json_helpers.dart`. @@ -242,7 +242,7 @@ class SearchResult3 { } /// A playlist summary from `getPlaylists` (no tracks), ported from -/// `ratune-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror +/// `timbre-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror /// (`playlists/playlists.dart`). class Playlist { Playlist({ @@ -253,6 +253,7 @@ class Playlist { this.owner, this.public, this.coverArt, + this.comment, }); final String id; @@ -263,6 +264,10 @@ class Playlist { final bool? public; final String? coverArt; + /// Free-text playlist comment. Timbre stores a marker here to flag a playlist + /// as a "tag" (see `playlists.dart`'s `kTagMarker`); otherwise usually null. + final String? comment; + factory Playlist.fromJson(Map j) => Playlist( id: asString(j['id']) ?? '', name: asString(j['name']) ?? 'Untitled', @@ -271,6 +276,7 @@ class Playlist { owner: asString(j['owner']), public: j['public'] is bool ? j['public'] as bool : null, coverArt: asString(j['coverArt']), + comment: asString(j['comment']), ); Map toJson() => { @@ -281,6 +287,7 @@ class Playlist { if (owner != null) 'owner': owner, if (public != null) 'public': public, if (coverArt != null) 'coverArt': coverArt, + if (comment != null) 'comment': comment, }; } @@ -293,6 +300,7 @@ class PlaylistDetail { this.songCount, this.duration, this.coverArt, + this.comment, this.songs = const [], }); @@ -301,6 +309,9 @@ class PlaylistDetail { final int? songCount; final int? duration; final String? coverArt; + + /// See [Playlist.comment] — carries the Timbre tag marker when present. + final String? comment; final List songs; factory PlaylistDetail.fromJson(Map j) => PlaylistDetail( @@ -309,6 +320,7 @@ class PlaylistDetail { songCount: asInt(j['songCount']), duration: asInt(j['duration']), coverArt: asString(j['coverArt']), + comment: asString(j['comment']), songs: oneOrMany(j['entry'], Song.fromJson), ); @@ -318,6 +330,7 @@ class PlaylistDetail { if (songCount != null) 'songCount': songCount, if (duration != null) 'duration': duration, if (coverArt != null) 'coverArt': coverArt, + if (comment != null) 'comment': comment, 'entry': songs.map((s) => s.toJson()).toList(), }; @@ -327,5 +340,6 @@ class PlaylistDetail { songCount: songCount ?? songs.length, duration: duration, coverArt: coverArt, + comment: comment, ); } diff --git a/lib/subsonic/subsonic_client.dart b/lib/subsonic/subsonic_client.dart index fff0684..dadcec4 100644 --- a/lib/subsonic/subsonic_client.dart +++ b/lib/subsonic/subsonic_client.dart @@ -8,7 +8,7 @@ import 'json_helpers.dart'; import 'models.dart'; /// A Subsonic API error (server returned `status: "failed"`), ported from -/// `ratune-subsonic/src/error.rs`. Code 40 is the auth-failure code. +/// `timbre-subsonic/src/error.rs`. Code 40 is the auth-failure code. class SubsonicError implements Exception { SubsonicError(this.code, this.message); @@ -22,7 +22,7 @@ class SubsonicError implements Exception { String toString() => 'SubsonicError($code): $message'; } -/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`. +/// Subsonic HTTP client, ported from `timbre-subsonic/src/client.rs`. /// /// Auth is the classic token scheme: a fresh random salt per request and /// `token = MD5(password + salt)`, sent as query params @@ -57,7 +57,7 @@ class SubsonicClient { url.endsWith('/') ? url.substring(0, url.length - 1) : url; /// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in - /// for the platform CSPRNG; Ratune uses a weaker LCG here). + /// for the platform CSPRNG; Timbre uses a weaker LCG here). String _makeSalt([int length = 12]) { final rng = Random.secure(); return List.generate( @@ -190,7 +190,7 @@ class SubsonicClient { } /// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId) - /// exactly like Ratune's `set_starred` (`client.rs`). + /// exactly like Timbre's `set_starred` (`client.rs`). Future setStarred({ required bool starred, String? songId, @@ -218,7 +218,7 @@ class SubsonicClient { } // ---- Playlists ---------------------------------------------------------- - // Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through + // Ported from `timbre-subsonic/src/client.rs`. Track mutations all go through // `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`. /// `getPlaylists` — every playlist visible to the authenticated user. @@ -266,6 +266,13 @@ class SubsonicClient { await _get('updatePlaylist', {'playlistId': playlistId, 'name': name}); } + /// `updatePlaylist` + `comment` — set a playlist's comment. Timbre uses this + /// to mark a playlist as a tag (`createPlaylist` can't set a comment inline). + Future setPlaylistComment(String playlistId, String comment) async { + await _get( + 'updatePlaylist', {'playlistId': playlistId, 'comment': comment}); + } + /// `deletePlaylist` — delete a playlist by id. Future deletePlaylist(String id) async { await _get('deletePlaylist', {'id': id}); diff --git a/lib/theme/accent.dart b/lib/theme/accent.dart index 6154212..a6618c9 100644 --- a/lib/theme/accent.dart +++ b/lib/theme/accent.dart @@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'tokens.dart'; -/// Holds the current accent color — the "hero" of Ratune's aesthetic. +/// Holds the current accent color — the "hero" of Timbre's aesthetic. /// /// In the `dynamic` theme this is extracted from the playing track's album -/// art and boosted for readability in OKLab space (Ratune `color.rs:11-64`), +/// art and boosted for readability in OKLab space (Timbre `color.rs:11-64`), /// then interpolated toward over ~400ms. Phase 1 just exposes the default and /// a setter; the extraction + animation land in Phase 2 with playback. class AccentNotifier extends StateNotifier { diff --git a/lib/theme/accent_extract.dart b/lib/theme/accent_extract.dart index 1d1500f..c31428a 100644 --- a/lib/theme/accent_extract.dart +++ b/lib/theme/accent_extract.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:palette_generator/palette_generator.dart'; -/// Extract a lively accent color from album art, mirroring Ratune's +/// Extract a lively accent color from album art, mirroring Timbre's /// art-driven `dynamic` theme (`color.rs`): pick the most vibrant swatch, then -/// nudge it into a readable lightness/saturation band. Ratune does the boost in +/// nudge it into a readable lightness/saturation band. Timbre does the boost in /// OKLab; this HSL approximation is close enough for now (OKLab is a later /// refinement noted in the roadmap). Future extractAccent(ImageProvider image) async { diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 01732e1..69c1c2b 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -3,10 +3,10 @@ import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; -/// Builds the app [ThemeData] from the Ratune tokens. +/// Builds the app [ThemeData] from the Timbre tokens. /// /// Monospace type is core to the identity — every surface uses JetBrains Mono -/// (a close analog to the terminal fonts in Ratune's screenshots). The [accent] +/// (a close analog to the terminal fonts in Timbre's screenshots). The [accent] /// is passed in so the theme rebuilds when album-art extraction changes it. ThemeData buildTimbreTheme(Color accent) { final mono = GoogleFonts.jetBrainsMonoTextTheme( diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index f05ff47..393ed59 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; -/// Design tokens mirroring Ratune's `dynamic`/`static` theme defaults -/// (see ratune `docs/sample-config.toml`, `theme.rs`, `color.rs`). +/// Design tokens mirroring Timbre's `dynamic`/`static` theme defaults +/// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`). /// /// The palette is intentionally near-black and low-contrast; the *accent* /// is the one lively color and, in the `dynamic` theme, is extracted live @@ -25,7 +25,7 @@ class TimbreColors { static const Color border = Color(0xFF252525); /// Hairline borders when a pane is focused — `border_active`. - /// Ratune tints this toward the accent; we start with a lighter grey and + /// Timbre tints this toward the accent; we start with a lighter grey and /// swap in the accent at runtime once art extraction lands. static const Color borderActive = Color(0xFF3A3A3A); diff --git a/lib/widgets/block_progress_bar.dart b/lib/widgets/block_progress_bar.dart index a5d6f9e..1202443 100644 --- a/lib/widgets/block_progress_bar.dart +++ b/lib/widgets/block_progress_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../theme/tokens.dart'; -/// The segmented block progress bar from Ratune's now-playing strip +/// The segmented block progress bar from Timbre's now-playing strip /// (`progress_style = "██░"`). Discrete cells: filled cells use the accent, /// empty cells the border grey, with hairline gaps between them. class BlockProgressBar extends StatelessWidget { diff --git a/lib/widgets/cassette_view.dart b/lib/widgets/cassette_view.dart new file mode 100644 index 0000000..b930875 --- /dev/null +++ b/lib/widgets/cassette_view.dart @@ -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 createState() => _CassetteViewState(); +} + +class _CassetteViewState extends ConsumerState + 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); + } +} diff --git a/lib/widgets/hairline_panel.dart b/lib/widgets/hairline_panel.dart index 8cc5075..64456f2 100644 --- a/lib/widgets/hairline_panel.dart +++ b/lib/widgets/hairline_panel.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../theme/tokens.dart'; /// A thin-stroked panel with its title label sitting *on* the top border — -/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics", +/// Timbre's signature container ("Album Art", "Queue (127)", "Lyrics", /// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border, /// then overlay the title with a background that occludes the segment behind /// it, so the label appears to break the line. diff --git a/lib/widgets/toast.dart b/lib/widgets/toast.dart new file mode 100644 index 0000000..27d27fa --- /dev/null +++ b/lib/widgets/toast.dart @@ -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), + ), + ), + ], + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index 9dbdcc4..2d29dcc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -270,6 +270,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -496,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -733,6 +749,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8dd4623..fdc77c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: just_audio: ^0.10.6 just_audio_background: ^0.0.1-beta.17 palette_generator: ^0.3.3+7 + flutter_svg: ^2.3.0 dev_dependencies: flutter_test: @@ -77,6 +78,9 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/casette/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/features_test.dart b/test/features_test.dart index 169fa6e..78f459c 100644 --- a/test/features_test.dart +++ b/test/features_test.dart @@ -1,6 +1,9 @@ +import 'package:flutter/widgets.dart' show Color; import 'package:flutter_test/flutter_test.dart'; import 'package:timbre/playlists/playlists.dart'; import 'package:timbre/settings/settings_store.dart'; +import 'package:timbre/state/providers.dart'; +import 'package:timbre/subsonic/credentials.dart'; import 'package:timbre/subsonic/models.dart'; import 'package:timbre/subsonic/subsonic_client.dart'; @@ -12,6 +15,9 @@ class _FakeClient extends SubsonicClient { final List playlists = []; final Map details = {}; bool failNext = false; + // Fails only the `setPlaylistComment` call — lets a test drive the tag + // orphan-cleanup path (create succeeds, marker stamp fails). + bool failComment = false; int _seq = 0; void _maybeThrow() { @@ -37,11 +43,55 @@ class _FakeClient extends SubsonicClient { return d; } + @override + Future setPlaylistComment(String playlistId, String comment) async { + if (failComment) { + failComment = false; + throw Exception('comment failed'); + } + final i = playlists.indexWhere((p) => p.id == playlistId); + if (i >= 0) playlists[i] = _copy(playlists[i], comment: comment); + final d = details[playlistId]; + if (d != null) details[playlistId] = _copyDetail(d, comment: comment); + } + + // The server persists appends, so getPlaylist reflects real membership — the + // fake must too, or `addToTag`'s de-dupe check has nothing to read. @override Future addTracksToPlaylist(String playlistId, List songIds) async { _maybeThrow(); + final d = details[playlistId]; + if (d != null) { + details[playlistId] = _copyDetail(d, songs: [ + ...d.songs, + for (final sid in songIds) Song(id: sid, title: 'Song $sid'), + ]); + } } + static Playlist _copy(Playlist p, {String? comment}) => Playlist( + id: p.id, + name: p.name, + songCount: p.songCount, + duration: p.duration, + owner: p.owner, + public: p.public, + coverArt: p.coverArt, + comment: comment ?? p.comment, + ); + + static PlaylistDetail _copyDetail(PlaylistDetail d, + {String? comment, List? songs}) => + PlaylistDetail( + id: d.id, + name: d.name, + songCount: d.songCount, + duration: d.duration, + coverArt: d.coverArt, + comment: comment ?? d.comment, + songs: songs ?? d.songs, + ); + @override Future removeTrackFromPlaylist(String playlistId, int index) async { _maybeThrow(); @@ -106,6 +156,101 @@ void main() { expect(back.downloadMaxBitRate, 0); expect(back.downloadFormat, 'opus'); }); + + test('toJson/fromJson round-trip preserves the new preference fields', () { + const s = AppSettings( + useStaticAccent: true, + staticAccentColor: Color(0xFF926CE9), // Purple + defaultBrowseMode: BrowseMode.albums, + searchMode: SearchMode.standard, + nowPlayingCassette: true, + ); + final back = AppSettings.fromJson(s.toJson()); + expect(back.useStaticAccent, isTrue); + expect(back.staticAccentColor, const Color(0xFF926CE9)); + expect(back.defaultBrowseMode, BrowseMode.albums); + expect(back.searchMode, SearchMode.standard); + expect(back.nowPlayingCassette, isTrue); + }); + + test('fromJson falls back to defaults for missing/unknown values', () { + // An older settings.json without the new keys loads with safe defaults. + final back = AppSettings.fromJson({'streamMaxBitRate': 128}); + expect(back.streamMaxBitRate, 128); + expect(back.useStaticAccent, isFalse); + expect(back.staticAccentColor, const Color(0xFFDF7E35)); // Orange + expect(back.defaultBrowseMode, BrowseMode.artists); + expect(back.searchMode, SearchMode.discovery); + expect(back.nowPlayingCassette, isFalse); + // An unrecognised enum name also falls back rather than throwing. + expect( + AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode, + BrowseMode.artists, + ); + }); + }); + + group('SubsonicCredentials', () { + test('toJson/fromJson round-trip', () { + const c = SubsonicCredentials( + url: 'https://x', username: 'u', password: 'p', alias: 'Home'); + final back = SubsonicCredentials.fromJson(c.toJson()); + expect(back.url, 'https://x'); + expect(back.username, 'u'); + expect(back.password, 'p'); + expect(back.alias, 'Home'); + }); + + test('id ignores a trailing slash so the same server maps to one key', () { + const a = + SubsonicCredentials(url: 'https://x/', username: 'u', password: 'p'); + const b = + SubsonicCredentials(url: 'https://x', username: 'u', password: 'q'); + expect(a.id, b.id); + }); + + test('display prefers a non-empty alias, else the host', () { + expect( + const SubsonicCredentials(url: 'https://x', username: 'u', password: 'p') + .display, + 'x', + ); + expect( + const SubsonicCredentials( + url: 'https://x', username: 'u', password: 'p', alias: 'Home') + .display, + 'Home', + ); + }); + }); + + group('filterSearchToStandard (TODO #4)', () { + test('keeps name/title matches and drops artist-only song matches', () { + final result = SearchResult3( + artists: [Artist(id: 'a1', name: 'Chard')], + albums: [ + Album(id: 'al1', name: 'Cha Cha'), + Album(id: 'al2', name: 'Nope'), + ], + songs: [ + Song(id: 's1', title: 'Cha times', artist: 'Zed'), + Song(id: 's2', title: 'Other', artist: 'Cha Band'), // artist-only + ], + ); + final filtered = filterSearchToStandard(result, 'Cha'); + expect(filtered.artists.map((a) => a.id), ['a1']); + expect(filtered.albums.map((a) => a.id), ['al1']); + expect(filtered.songs.map((s) => s.id), ['s1']); // s2 dropped + }); + + test('is case-insensitive', () { + final result = SearchResult3( + artists: const [], + albums: const [], + songs: [Song(id: 's1', title: 'CHA')], + ); + expect(filterSearchToStandard(result, 'cha').songs.length, 1); + }); }); group('PlaylistsController optimistic mutations', () { @@ -166,4 +311,52 @@ void main() { expect(controller.state.playlists.single.name, 'Keep'); }); }); + + group('Tags (playlists marked with the tag comment)', () { + late _FakeClient client; + late PlaylistsController controller; + + setUp(() { + client = _FakeClient(); + controller = PlaylistsController( + clientGetter: () => client, + serverKeyGetter: () => null, + ); + }); + + test('createTag stamps the marker so the playlist reads as a tag', () async { + final id = (await controller.createTag('cooking'))!; + final summary = controller.state.playlists.single; + expect(summary.id, id); + expect(summary.comment, kTagMarker); + expect(isTagPlaylist(summary), isTrue); + }); + + test('createTag is unique per name (case-insensitive)', () async { + final first = await controller.createTag('Cooking'); + final second = await controller.createTag('cooking'); + expect(second, first); // reused, not duplicated + expect(controller.state.playlists.length, 1); + }); + + test('createTag deletes the orphan if the marker stamp fails', () async { + client.failComment = true; + final id = await controller.createTag('running'); + expect(id, isNull); + // No unmarked, nameless playlist left behind. + expect(controller.state.playlists, isEmpty); + }); + + test('addToTag skips songs already carrying the tag', () async { + final id = (await controller.createTag('run'))!; + await controller.addToTag(id, [_song('a'), _song('b')]); + await controller.addToTag(id, [_song('b'), _song('c')]); // b duplicate + final ids = controller.state.details[id]!.songs.map((s) => s.id).toList(); + expect(ids, ['a', 'b', 'c']); + + // Re-adding an existing member is a no-op. + await controller.addToTag(id, [_song('a')]); + expect(controller.state.details[id]!.songs.length, 3); + }); + }); }