diff --git a/assets/casette/casette.png b/assets/casette/casette.png
new file mode 100644
index 0000000..1180c43
Binary files /dev/null and b/assets/casette/casette.png differ
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