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

View file

@ -8,7 +8,7 @@ import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
/// One play event, mirroring Ratune's `PlayRecord` (`history.rs`).
/// One play event, mirroring Timbre's `PlayRecord` (`history.rs`).
class PlayRecord {
PlayRecord({
required this.songId,
@ -173,7 +173,7 @@ List<PlayRecord> recentAlbums(List<PlayRecord> history, {int limit = 12}) {
}
/// Rediscover: artists you've heard before but aren't listening to now, biased
/// toward low play counts, then sampled for variety (Ratune
/// toward low play counts, then sampled for variety (Timbre
/// `history.rs:101-167`). [seed] drives the re-roll.
///
/// The desktop original only surfaces artists last heard more than [minDays]

View file

@ -45,7 +45,7 @@ class LibraryIndexState {
}
/// Builds and caches a flat "all songs" index — Subsonic has no all-songs
/// endpoint, so we crawl every album (mirrors Ratune's `library_index` /
/// endpoint, so we crawl every album (mirrors Timbre's `library_index` /
/// `fetch_all_library_songs`). The cache is keyed to the server so switching
/// servers never serves a stale catalog, and writes are atomic so an
/// interrupted crawl can't leave a truncated file.
@ -54,7 +54,7 @@ class LibraryIndexController extends StateNotifier<LibraryIndexState> {
final SubsonicClient? Function() _clientGetter;
static const int _albumParallelism = 12; // Ratune's album_parallelism.
static const int _albumParallelism = 12; // Timbre's album_parallelism.
static const int _pageSize = 500; // Subsonic getAlbumList2 cap.
static const Duration _ttl = Duration(hours: 24);

View file

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

View file

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

View file

@ -5,12 +5,25 @@
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Marker stored in a playlist's `comment` to flag it as a Timbre **tag** — a
/// playlist surfaced under the Tags UI instead of Playlists. Kept out of the
/// visible name so tags read cleanly everywhere (including other Subsonic
/// clients, where they still appear as ordinary playlists).
///
/// Classification relies on `getPlaylists` returning the `comment` field;
/// Navidrome does. A server that omitted it would show tags as plain playlists.
const String kTagMarker = 'timbre:tag';
/// Whether [p] is a Timbre tag (vs a user-facing playlist).
bool isTagPlaylist(Playlist p) => p.comment == kTagMarker;
/// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
/// that have been opened. Details are cached so an opened playlist keeps working
/// offline.
@ -185,6 +198,87 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
}
}
// ---- Tags ---------------------------------------------------------------
// A tag is just a playlist whose `comment` carries [kTagMarker]. These reuse
// the playlist mutations above; only creation and membership need tag-aware
// behaviour (marker stamping + de-duplication).
/// Create a tag named [name], or return the id of an existing tag with that
/// name (case-insensitive) so tags stay unique. `createPlaylist` can't set a
/// comment inline, so we create then stamp the marker — and if stamping fails
/// we delete the orphan rather than leave an unmarked playlist behind.
Future<String?> createTag(String name) async {
final client = _clientGetter();
if (client == null) return null;
final existing = state.playlists.firstWhereOrNull(
(p) => isTagPlaylist(p) && p.name.toLowerCase() == name.toLowerCase(),
);
if (existing != null) return existing.id;
final id = await create(name);
if (id == null) return null;
try {
await client.setPlaylistComment(id, kTagMarker);
} catch (_) {
await delete(id); // don't strand a nameless, unmarked playlist
return null;
}
_applyComment(id, kTagMarker);
await _persist();
return id;
}
/// Add [songs] to a tag, skipping any already present. Playlists allow
/// duplicates but a tag is a set — re-tagging a song must be idempotent, so we
/// load current membership first and only append the new ids.
Future<void> addToTag(String tagId, List<Song> songs) async {
if (songs.isEmpty) return;
final detail = await loadDetail(tagId);
final present = {for (final s in detail?.songs ?? const <Song>[]) s.id};
final fresh = songs.where((s) => !present.contains(s.id)).toList();
if (fresh.isEmpty) return;
await addTracks(tagId, fresh);
}
/// Patch the cached summary + detail for [id] with [comment] (used right after
/// stamping a new tag's marker so it partitions into the Tags view at once).
void _applyComment(String id, String comment) {
state = state.copyWith(
playlists: [
for (final p in state.playlists)
if (p.id == id) _withComment(p, comment) else p,
],
details: {
for (final e in state.details.entries)
e.key: e.key == id ? _detailWithComment(e.value, comment) : e.value,
},
);
}
static Playlist _withComment(Playlist p, String comment) => Playlist(
id: p.id,
name: p.name,
songCount: p.songCount,
duration: p.duration,
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
comment: comment,
);
static PlaylistDetail _detailWithComment(
PlaylistDetail d, String comment) =>
PlaylistDetail(
id: d.id,
name: d.name,
songCount: d.songCount,
duration: d.duration,
coverArt: d.coverArt,
comment: comment,
songs: d.songs,
);
Future<void> rename(String id, String name) async {
final client = _clientGetter();
if (client == null) return;
@ -280,6 +374,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
comment: p.comment,
)
else
p,
@ -294,6 +389,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
comment: p.comment,
);
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
@ -303,6 +399,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
songCount: d.songCount,
duration: d.duration,
coverArt: d.coverArt,
comment: d.comment,
songs: d.songs,
);
@ -313,6 +410,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
songCount: songs.length,
duration: d.duration,
coverArt: d.coverArt,
comment: d.comment,
songs: songs,
);

View file

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/toast.dart';
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
/// Bottom sheet to apply a tag to [songs] — pick an existing tag or create one.
/// Tags are playlists under the hood, so this mirrors the "add to playlist"
/// sheet; the difference is membership is idempotent (a song already carrying
/// the tag is skipped, not duplicated). No-op when offline.
Future<void> showAddTagSheet(
BuildContext context, {
required List<Song> songs,
}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => _AddTagSheet(songs: songs),
);
}
class _AddTagSheet extends ConsumerWidget {
const _AddTagSheet({required this.songs});
final List<Song> songs;
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final tags = ref.watch(tagsProvider);
final connected = ref.watch(subsonicClientProvider) != null;
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Text('Add tag',
style:
TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: TimbreSpacing.md),
if (!connected)
const Padding(
padding: EdgeInsets.all(TimbreSpacing.xl),
child: Text('Connect to a server to manage tags.',
style: TextStyle(color: TimbreColors.dimmed)),
)
else ...[
_Tile(
icon: Icons.add,
label: 'New tag…',
accent: accent,
onTap: () => _createAndAdd(context, ref),
),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final t in tags)
_Tile(
icon: Icons.label_outline,
label: t.name,
trailing:
t.songCount != null ? '${t.songCount}' : null,
onTap: () async {
await ref
.read(playlistsProvider.notifier)
.addToTag(t.id, songs);
if (context.mounted) {
Navigator.of(context).pop();
showToast(context, 'Tagged "${t.name}"');
}
},
),
],
),
),
],
],
),
),
);
}
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
final name = await promptPlaylistName(context, title: 'New tag');
if (name == null || name.isEmpty) return;
final id = await ref.read(playlistsProvider.notifier).createTag(name);
if (id != null) {
await ref.read(playlistsProvider.notifier).addToTag(id, songs);
}
if (context.mounted) {
Navigator.of(context).pop();
showToast(context,
id != null ? 'Tagged "$name"' : 'Could not create tag');
}
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.onTap,
this.trailing,
this.accent,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final String? trailing;
final Color? accent;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Row(
children: [
Icon(icon, size: 20, color: accent ?? TimbreColors.dimmed),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent ?? TimbreColors.foreground)),
),
if (trailing != null)
Text(trailing!,
style: const TextStyle(color: TimbreColors.dimmed)),
],
),
),
);
}
}

View file

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

View file

@ -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'))

View file

@ -5,18 +5,145 @@ import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
/// Open the connect-server sheet.
Future<void> showConnectSheet(BuildContext context) {
/// Open the add/edit-server sheet. Pass [initial] to edit an existing saved
/// server (its password is reused if the field is left blank).
Future<void> showConnectSheet(BuildContext context,
{SubsonicCredentials? initial}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => const _ConnectSheet(),
builder: (_) => _ConnectSheet(initial: initial),
);
}
/// Open the server switcher: pick an active server, or add a new one.
Future<void> showServerSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => const _ServerSheet(),
);
}
/// Lists saved servers (tap to switch) with an "Add server" action.
class _ServerSheet extends ConsumerWidget {
const _ServerSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Text('Servers',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: TimbreSpacing.md),
if (conn.servers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Text('No servers saved yet.',
style: TextStyle(color: TimbreColors.dimmed)),
)
else
for (final s in conn.servers)
_ServerRow(
creds: s,
active: s.id == conn.activeId,
onTap: () {
// Grab the notifier before popping — this widget's `ref` is
// disposed with the sheet.
final notifier = ref.read(connectionProvider.notifier);
Navigator.of(context).pop();
notifier.switchTo(s.id);
},
),
const SizedBox(height: TimbreSpacing.sm),
InkWell(
onTap: () {
// Capture the navigator's (still-mounted) context before popping
// this sheet, so the next sheet has a valid overlay to show in.
final nav = Navigator.of(context);
nav.pop();
showConnectSheet(nav.context);
},
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Row(
children: [
Icon(Icons.add, size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Add server',
style: TextStyle(color: TimbreColors.foreground)),
],
),
),
),
],
),
),
);
}
}
class _ServerRow extends StatelessWidget {
const _ServerRow({
required this.creds,
required this.active,
required this.onTap,
});
final SubsonicCredentials creds;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Row(
children: [
Text(active ? '● ' : '○ ',
style: TextStyle(
color: active ? accent : TimbreColors.dimmed)),
Expanded(
child: Text(
creds.display,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
),
),
);
}
}
class _ConnectSheet extends ConsumerStatefulWidget {
const _ConnectSheet();
const _ConnectSheet({this.initial});
final SubsonicCredentials? initial;
@override
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
@ -26,15 +153,20 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final _url = TextEditingController();
final _user = TextEditingController();
final _pass = TextEditingController();
final _alias = TextEditingController();
bool _busy = false;
String? _localError;
bool get _isEdit => widget.initial != null;
@override
void initState() {
super.initState();
final creds = ref.read(connectionProvider).credentials;
if (creds != null) {
_url.text = creds.url;
_user.text = creds.username;
final initial = widget.initial;
if (initial != null) {
_url.text = initial.url;
_user.text = initial.username;
_alias.text = initial.alias ?? '';
}
}
@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
_url.dispose();
_user.dispose();
_pass.dispose();
_alias.dispose();
super.dispose();
}
/// Build credentials from the form. Returns null (and sets [_localError]) if
/// required fields are missing. On edit, a blank password reuses the saved one.
SubsonicCredentials? _readForm() {
final url = _url.text.trim();
final user = _user.text.trim();
if (url.isEmpty || user.isEmpty) {
setState(() => _localError = 'Server URL and username are required.');
return null;
}
final password =
_pass.text.isEmpty && _isEdit ? widget.initial!.password : _pass.text;
if (password.isEmpty) {
setState(() => _localError = 'Password is required.');
return null;
}
final alias = _alias.text.trim();
return SubsonicCredentials(
url: url,
username: user,
password: password,
alias: alias.isEmpty ? null : alias,
);
}
Future<void> _connect() async {
setState(() => _busy = true);
final ok = await ref.read(connectionProvider.notifier).connect(
SubsonicCredentials(
url: _url.text.trim(),
username: _user.text.trim(),
password: _pass.text,
),
);
final creds = _readForm();
if (creds == null) return;
setState(() {
_busy = true;
_localError = null;
});
final ok = await ref.read(connectionProvider.notifier).connect(creds);
if (!mounted) return;
setState(() => _busy = false);
if (ok) Navigator.of(context).pop();
}
Future<void> _save() async {
final creds = _readForm();
if (creds == null) return;
setState(() {
_busy = true;
_localError = null;
});
await ref.read(connectionProvider.notifier).saveServer(creds);
if (!mounted) return;
setState(() => _busy = false);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
final error = _localError ??
(conn.status == ConnStatus.error ? conn.error : null);
return Padding(
padding: EdgeInsets.only(
left: TimbreSpacing.xl,
@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Connect to server',
Text(_isEdit ? 'Edit server' : 'Add server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
const SizedBox(height: TimbreSpacing.lg),
_field(_url, 'Server URL', 'https://navidrome.example.com',
keyboard: TextInputType.url),
_field(_user, 'Username', 'you'),
_field(_pass, 'Password', '••••••••', obscure: true),
if (conn.status == ConnStatus.error && conn.error != null) ...[
_field(_pass, 'Password',
_isEdit ? '•••••••• (unchanged)' : '••••••••',
obscure: true),
_field(_alias, 'Label (optional)', 'Home server'),
if (error != null) ...[
const SizedBox(height: TimbreSpacing.md),
Text(conn.error!,
style: const TextStyle(color: Color(0xFFE06C75))),
Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
],
const SizedBox(height: TimbreSpacing.lg),
FilledButton(
onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: TimbreColors.background,
shape: const RoundedRectangleBorder(),
),
child: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : _save,
style: OutlinedButton.styleFrom(
foregroundColor: TimbreColors.foreground,
side: const BorderSide(color: TimbreColors.border),
shape: const RoundedRectangleBorder(),
),
child: const Text('Save'),
),
),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: FilledButton(
onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: TimbreColors.background,
shape: const RoundedRectangleBorder(),
),
child: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
),
],
),
],
),

View file

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

View file

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

View file

@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'connect_sheet.dart';
/// Audio-quality settings: independent streaming and download knobs, both
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
@ -14,6 +17,7 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final controller = ref.read(settingsProvider.notifier);
final conn = ref.watch(connectionProvider);
return Scaffold(
appBar: AppBar(
@ -72,6 +76,143 @@ class SettingsScreen extends ConsumerWidget {
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Appearance',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'By default the accent color is drawn from the playing '
'album art. Turn on a static accent to pin one color.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>(
label: 'Static accent',
values: const [false, true],
selected: settings.useStaticAccent,
labelFor: (v) => v ? 'On' : 'Off',
onSelect: controller.setUseStaticAccent,
),
if (settings.useStaticAccent) ...[
const SizedBox(height: TimbreSpacing.lg),
_ColorChips(
label: 'Accent color',
values: AppSettings.accentChoices,
selected: settings.staticAccentColor,
onSelect: controller.setStaticAccentColor,
),
],
const SizedBox(height: TimbreSpacing.lg),
const _Caption(
'Show Now Playing as an animated cassette — cover art on '
'the label, reels that spin and wind with the track.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>(
label: 'Now Playing art',
values: const [false, true],
selected: settings.nowPlayingCassette,
labelFor: (v) => v ? 'Cassette' : 'Album cover',
onSelect: controller.setNowPlayingCassette,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Browse',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption('Which view the Browse tab opens on.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<BrowseMode>(
label: 'Default view',
values: BrowseMode.values,
selected: settings.defaultBrowseMode,
labelFor: AppSettings.browseModeLabel,
onSelect: controller.setDefaultBrowseMode,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Search',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'"Standard" matches only names/titles that contain your '
'query. "Discovery" uses the server\'s broader matching.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<SearchMode>(
label: 'Search mode',
values: const [SearchMode.standard, SearchMode.discovery],
selected: settings.searchMode,
labelFor: AppSettings.searchModeLabel,
onSelect: controller.setSearchMode,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Servers',
active: true,
trailing:
conn.servers.isNotEmpty ? '(${conn.servers.length})' : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Save multiple servers and switch the active one. Only '
'one is connected at a time.'),
const SizedBox(height: TimbreSpacing.sm),
if (conn.servers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
child: Text('No servers saved yet.',
style: TextStyle(color: TimbreColors.dimmed)),
)
else
for (final s in conn.servers)
_ServerManageRow(
creds: s,
active: s.id == conn.activeId,
onActivate: () => ref
.read(connectionProvider.notifier)
.switchTo(s.id),
onEdit: () =>
showConnectSheet(context, initial: s),
onDelete: () => ref
.read(connectionProvider.notifier)
.removeServer(s.id),
),
const SizedBox(height: TimbreSpacing.sm),
InkWell(
onTap: () => showConnectSheet(context),
child: const Padding(
padding:
EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add,
size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Add server',
style:
TextStyle(color: TimbreColors.foreground)),
],
),
),
),
],
),
),
],
),
),
@ -79,6 +220,72 @@ class SettingsScreen extends ConsumerWidget {
}
}
/// A saved-server row in Settings: activate (tap the label), edit, or delete.
class _ServerManageRow extends StatelessWidget {
const _ServerManageRow({
required this.creds,
required this.active,
required this.onActivate,
required this.onEdit,
required this.onDelete,
});
final SubsonicCredentials creds;
final bool active;
final VoidCallback onActivate;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Row(
children: [
Expanded(
child: InkWell(
onTap: onActivate,
child: Container(
constraints: const BoxConstraints(
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(active ? '● ' : '○ ',
style: TextStyle(
color: active ? accent : TimbreColors.dimmed)),
Expanded(
child: Text(
creds.display,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight:
active ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
),
),
),
),
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon:
const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed),
),
],
);
}
}
class _Caption extends StatelessWidget {
const _Caption(this.text);
final String text;
@ -172,3 +379,90 @@ class _Chip extends StatelessWidget {
);
}
}
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
/// color swatch and the color's own hue drives the active border/fill.
class _ColorChips extends StatelessWidget {
const _ColorChips({
required this.label,
required this.values,
required this.selected,
required this.onSelect,
});
final String label;
final List<AccentChoice> values;
final Color selected;
final ValueChanged<Color> onSelect;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Wrap(
spacing: TimbreSpacing.sm,
runSpacing: TimbreSpacing.sm,
children: [
for (final c in values)
_ColorChip(
choice: c,
active: c.color == selected,
onTap: () => onSelect(c.color),
),
],
),
],
);
}
}
class _ColorChip extends StatelessWidget {
const _ColorChip({
required this.choice,
required this.active,
required this.onTap,
});
final AccentChoice choice;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? choice.color : TimbreColors.border,
),
color: active
? choice.color.withValues(alpha: 0.12)
: TimbreColors.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, color: choice.color),
const SizedBox(width: TimbreSpacing.sm),
Text(
choice.name,
style: TextStyle(
color: active ? choice.color : TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../playlists/playlists.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
import 'playlists_screen.dart' show PlaylistDetailScreen;
/// Tags list — the same server-backed store as Playlists, filtered to the
/// playlists carrying the tag marker. Tagging a song adds it to the tag's
/// playlist; this screen is where tags are created, browsed, renamed, deleted.
/// Tapping a tag reuses [PlaylistDetailScreen] (a tag *is* a playlist).
class TagsScreen extends ConsumerWidget {
const TagsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tags = ref.watch(tagsProvider);
final controller = ref.read(playlistsProvider.notifier);
final connected = ref.watch(subsonicClientProvider) != null;
return Scaffold(
appBar: AppBar(
title:
const Text('Tags', style: TextStyle(fontWeight: FontWeight.w700)),
actions: [
IconButton(
tooltip: 'New tag',
onPressed: connected ? () => _create(context, ref) : null,
icon: const Icon(Icons.add),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.lg),
child: HairlinePanel(
title: 'Tags',
active: true,
trailing: tags.isEmpty ? null : '(${tags.length})',
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: tags.isEmpty
? Center(
child: Text(
connected
? 'No tags yet. Tap + to create one, then tag songs\n'
'from their ⋮ menu.'
: 'Connect to a server to see tags.',
textAlign: TextAlign.center,
style: const TextStyle(color: TimbreColors.dimmed),
),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: tags.length,
itemBuilder: (context, i) {
final t = tags[i];
return _TagRow(
name: t.name,
subtitle: t.songCount != null
? '${t.songCount} tracks'
: null,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(id: t.id),
),
),
onRename: connected
? () async {
final name = await promptPlaylistName(context,
title: 'Rename tag', initial: t.name);
if (name != null && name.isNotEmpty) {
controller.rename(t.id, name);
}
}
: null,
onDelete: connected
? () => _confirmDelete(context, controller, t)
: null,
);
},
),
),
),
),
);
}
Future<void> _create(BuildContext context, WidgetRef ref) async {
final name = await promptPlaylistName(context, title: 'New tag');
if (name != null && name.isNotEmpty) {
await ref.read(playlistsProvider.notifier).createTag(name);
}
}
Future<void> _confirmDelete(
BuildContext context, PlaylistsController controller, Playlist t) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: TimbreColors.surface,
title: Text('Delete tag "${t.name}"?'),
content: const Text(
'This removes the tag from every song. The songs themselves are '
'not deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete')),
],
),
);
if (ok == true) controller.delete(t.id);
}
}
class _TagRow extends StatelessWidget {
const _TagRow({
required this.name,
required this.onTap,
this.subtitle,
this.onRename,
this.onDelete,
});
final String name;
final String? subtitle;
final VoidCallback onTap;
final VoidCallback? onRename;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
const Icon(Icons.label_outline, size: 18, color: TimbreColors.dimmed),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
color: TimbreColors.dimmed, fontSize: 12)),
],
),
),
if (onRename != null || onDelete != null)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {
if (v == 'rename') onRename?.call();
if (v == 'delete') onDelete?.call();
},
itemBuilder: (_) => [
if (onRename != null)
const PopupMenuItem(value: 'rename', child: Text('Rename')),
if (onDelete != null)
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
);
}
}

View file

@ -1,19 +1,43 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart' show Color;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
/// User-tunable audio quality. Streaming and offline downloads have independent
/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` +
/// `format`; a rate of 0 means original / no transcode). These are global (not
/// per-server) and persisted to a single atomic JSON file, following the
/// write pattern in `library/library_index.dart`.
/// Top-level Browse mode selector. Lives here (not in `state/providers.dart`)
/// so [AppSettings] can persist the user's default; `providers.dart` re-exports
/// it, so existing importers are unaffected.
enum BrowseMode { artists, albums, tracks }
/// Search matching strategy. `discovery` returns the server's full `search3`
/// result (broad — e.g. songs matched via their artist name). `standard`
/// post-filters to items whose *own* name/title matches the query.
enum SearchMode { discovery, standard }
/// A selectable static accent color offered by the theme override (TODO #1).
class AccentChoice {
const AccentChoice(this.name, this.color);
final String name;
final Color color;
}
/// Application-wide preferences: audio quality, theme accent, default browse
/// view, and search mode. Streaming and offline downloads have independent
/// quality knobs, both driven by Subsonic's `stream` transcode params
/// (`maxBitRate` + `format`; a rate of 0 means original / no transcode). These
/// are global (not per-server) and persisted to a single atomic JSON file,
/// following the write pattern in `library/library_index.dart`.
class AppSettings {
const AppSettings({
this.streamMaxBitRate = 0,
this.downloadMaxBitRate = 0,
this.downloadFormat,
this.useStaticAccent = false,
this.staticAccentColor = _defaultStaticAccent,
this.defaultBrowseMode = BrowseMode.artists,
this.searchMode = SearchMode.discovery,
this.nowPlayingCassette = false,
});
/// Cap for live streaming, in kbps. 0 = original / no transcode.
@ -26,20 +50,67 @@ class AppSettings {
/// the original file / let the server decide.
final String? downloadFormat;
/// When true, ignore album-art-derived accent and use [staticAccentColor]
/// as the sole accent color. When false (default), the accent tracks the
/// currently-playing album art.
final bool useStaticAccent;
/// The fixed accent used while [useStaticAccent] is on. One of
/// [accentChoices]; defaults to Orange.
final Color staticAccentColor;
/// Which sub-view the Browse tab opens on.
final BrowseMode defaultBrowseMode;
/// Which search matching strategy to apply globally.
final SearchMode searchMode;
/// When true, the Now Playing "art" view shows the animated cassette (cover
/// art on the label, reels driven by playback) instead of the plain square
/// album cover.
final bool nowPlayingCassette;
/// Offered bitrate choices (kbps); 0 renders as "Original".
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
/// Offered download containers; null renders as "Original".
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac'];
/// Static accent palette offered when the override is enabled (TODO #1).
static const List<AccentChoice> accentChoices = [
AccentChoice('Red', Color(0xFFDF3535)),
AccentChoice('Blue', Color(0xFF7177EA)),
AccentChoice('Green', Color(0xFF10996B)),
AccentChoice('Orange', Color(0xFFDF7E35)),
AccentChoice('Yellow', Color(0xFFDFC535)),
AccentChoice('Purple', Color(0xFF926CE9)),
AccentChoice('Cream', Color(0xFFFFDB9E)),
];
static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange
static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
static String formatLabel(String? f) => f ?? 'Original';
static String browseModeLabel(BrowseMode m) => switch (m) {
BrowseMode.artists => 'Artists',
BrowseMode.albums => 'Albums',
BrowseMode.tracks => 'Tracks',
};
static String searchModeLabel(SearchMode m) => switch (m) {
SearchMode.standard => 'Standard',
SearchMode.discovery => 'Discovery',
};
AppSettings copyWith({
int? streamMaxBitRate,
int? downloadMaxBitRate,
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
Object? downloadFormat = _unset,
bool? useStaticAccent,
Color? staticAccentColor,
BrowseMode? defaultBrowseMode,
SearchMode? searchMode,
bool? nowPlayingCassette,
}) =>
AppSettings(
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
@ -47,6 +118,11 @@ class AppSettings {
downloadFormat: identical(downloadFormat, _unset)
? this.downloadFormat
: downloadFormat as String?,
useStaticAccent: useStaticAccent ?? this.useStaticAccent,
staticAccentColor: staticAccentColor ?? this.staticAccentColor,
defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode,
searchMode: searchMode ?? this.searchMode,
nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette,
);
static const Object _unset = Object();
@ -55,13 +131,49 @@ class AppSettings {
'streamMaxBitRate': streamMaxBitRate,
'downloadMaxBitRate': downloadMaxBitRate,
if (downloadFormat != null) 'downloadFormat': downloadFormat,
'useStaticAccent': useStaticAccent,
'staticAccentColor': _hexOf(staticAccentColor),
'defaultBrowseMode': defaultBrowseMode.name,
'searchMode': searchMode.name,
'nowPlayingCassette': nowPlayingCassette,
};
factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
downloadFormat: j['downloadFormat'] as String?,
useStaticAccent: j['useStaticAccent'] as bool? ?? false,
staticAccentColor:
_colorOf(j['staticAccentColor'] as String?) ?? _defaultStaticAccent,
defaultBrowseMode:
_enumByName(BrowseMode.values, j['defaultBrowseMode'] as String?) ??
BrowseMode.artists,
searchMode:
_enumByName(SearchMode.values, j['searchMode'] as String?) ??
SearchMode.discovery,
nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false,
);
/// Serialize a color to a `#RRGGBB` hex string.
static String _hexOf(Color c) =>
'#${(c.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}';
/// Parse a `#RRGGBB` (or `RRGGBB`) hex string, or null if unparseable.
static Color? _colorOf(String? hex) {
if (hex == null) return null;
final h = hex.startsWith('#') ? hex.substring(1) : hex;
final v = int.tryParse(h, radix: 16);
if (v == null || h.length != 6) return null;
return Color(0xFF000000 | v);
}
static T? _enumByName<T extends Enum>(List<T> values, String? name) {
if (name == null) return null;
for (final v in values) {
if (v.name == name) return v;
}
return null;
}
}
/// Loads settings on launch and persists every change (atomic temp+rename).
@ -102,6 +214,31 @@ class SettingsController extends StateNotifier<AppSettings> {
_persist();
}
void setUseStaticAccent(bool value) {
state = state.copyWith(useStaticAccent: value);
_persist();
}
void setStaticAccentColor(Color color) {
state = state.copyWith(staticAccentColor: color);
_persist();
}
void setDefaultBrowseMode(BrowseMode mode) {
state = state.copyWith(defaultBrowseMode: mode);
_persist();
}
void setSearchMode(SearchMode mode) {
state = state.copyWith(searchMode: mode);
_persist();
}
void setNowPlayingCassette(bool value) {
state = state.copyWith(nowPlayingCassette: value);
_persist();
}
Future<void> _persist() async {
try {
final file = _file;

View file

@ -7,11 +7,12 @@ import '../screens/connect_sheet.dart';
import '../screens/home_screen.dart';
import '../screens/now_playing_screen.dart';
import '../screens/settings_screen.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/mini_player.dart';
/// Top-level shell: the three Ratune tabs (Home / Browse / Now Playing) with a
/// Top-level shell: the three Timbre tabs (Home / Browse / Now Playing) with a
/// bottom tab bar and a status bar, mirroring the terminal layout.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@ -29,12 +30,39 @@ class _AppShellState extends ConsumerState<AppShell> {
final _homeNavKey = GlobalKey<NavigatorState>();
final _browseNavKey = GlobalKey<NavigatorState>();
// Seed the Browse view from the persisted default exactly once, when settings
// finish loading. Guarded so a later change to the default (or a manual browse
// selection) is never overridden mid-session.
bool _browseSeeded = false;
@override
void initState() {
super.initState();
ref.listenManual<AppSettings>(settingsProvider, (prev, next) {
if (_browseSeeded) return;
_browseSeeded = true;
ref.read(browseModeProvider.notifier).state = next.defaultBrowseMode;
});
}
GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) {
0 => _homeNavKey,
1 => _browseNavKey,
_ => null,
};
/// Tap on the bottom tab bar. Selecting a different tab just switches; tapping
/// the already-active tab resets it to its root — popping the nested detail
/// stack (e.g. Album → Browse home) so the tab button doubles as "go back to
/// the top", instead of being a no-op.
void _selectTab(int tapped, int current) {
if (tapped == current) {
_navKeyForTab(tapped)?.currentState?.popUntil((r) => r.isFirst);
} else {
ref.read(selectedTabProvider.notifier).state = tapped;
}
}
/// Android system-back: pop the active tab's nested stack first, then fall
/// back to Home, and only exit the app from the Home root.
void _handleBack(int tab) {
@ -88,8 +116,7 @@ class _AppShellState extends ConsumerState<AppShell> {
_TabBar(
tabs: _tabs,
index: index,
onSelect: (i) =>
ref.read(selectedTabProvider.notifier).state = i,
onSelect: (i) => _selectTab(i, index),
),
const _StatusBar(),
],
@ -201,10 +228,10 @@ class _StatusBar extends ConsumerWidget {
color: TimbreColors.surface,
child: Row(
children: [
// Left: connection status — tap to open the connect sheet.
// Left: connection status — tap to open the server switcher.
Expanded(
child: InkWell(
onTap: () => showConnectSheet(context),
onTap: () => showServerSheet(context),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),

View file

@ -1,6 +1,3 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/widgets.dart' show NetworkImage;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -17,6 +14,11 @@ import '../theme/accent.dart';
import '../theme/accent_extract.dart';
import 'favorites.dart';
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
// persisted) but historically lived here — re-export so existing importers of
// this file keep resolving them.
export '../settings/settings_store.dart' show BrowseMode, SearchMode;
// ---- Connection ---------------------------------------------------------
enum ConnStatus { disconnected, connecting, online, error }
@ -27,6 +29,7 @@ class ConnectionState {
this.client,
this.credentials,
this.error,
this.servers = const [],
});
final ConnStatus status;
@ -34,14 +37,32 @@ class ConnectionState {
final SubsonicCredentials? credentials;
final String? error;
/// All saved servers (one may be the active [credentials]). Drives the
/// server switcher and the Settings "Servers" section.
final List<SubsonicCredentials> servers;
bool get isOnline => status == ConnStatus.online;
/// Id of the active server, or null when disconnected with none configured.
String? get activeId => credentials?.id;
/// Only the saved-servers list changes; connection fields are preserved.
ConnectionState copyWith({List<SubsonicCredentials>? servers}) =>
ConnectionState(
status: status,
client: client,
credentials: credentials,
error: error,
servers: servers ?? this.servers,
);
}
final credentialStoreProvider =
Provider<CredentialStore>((_) => CredentialStore());
/// Owns the active server connection: builds the client, pings, persists
/// credentials, and auto-restores on launch.
/// Owns the active server connection and the list of saved servers: builds the
/// client, pings, persists credentials, auto-restores on launch, and switches
/// between servers (one active at a time — TODO #2).
class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._store)
: super(const ConnectionState(status: ConnStatus.disconnected)) {
@ -49,13 +70,21 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
final CredentialStore _store;
List<SubsonicCredentials> _servers = const [];
String? _activeId;
Future<void> _restore() async {
try {
final creds = await _store.load();
if (creds != null) {
await connect(creds, persist: false);
}
_servers = await _store.loadAll();
_activeId = await _store.loadActiveId();
state = state.copyWith(servers: _servers);
if (_servers.isEmpty) return;
final active = _servers.firstWhere(
(s) => s.id == _activeId,
orElse: () => _servers.first,
);
_activeId = active.id;
await connect(active, persist: false);
} catch (_) {
// No secure-storage backend available (e.g. Linux without a running
// keyring daemon) — start disconnected rather than crashing.
@ -63,7 +92,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
Future<bool> connect(SubsonicCredentials creds, {bool persist = true}) async {
state = const ConnectionState(status: ConnStatus.connecting);
state = ConnectionState(status: ConnStatus.connecting, servers: _servers);
final client = SubsonicClient(
baseUrl: creds.url,
username: creds.username,
@ -79,20 +108,25 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error,
credentials: creds,
error: 'Could not reach the server.',
servers: _servers,
);
return false;
}
// Persistence is best-effort: a locked/absent secure-storage backend
// (e.g. a locked Linux keyring) must not stop this session connecting.
// A successful connect saves the server and makes it active.
if (persist) {
try {
await _store.save(creds);
await _addOrUpdate(creds, makeActive: true);
} catch (_) {}
} else {
_activeId = creds.id;
}
state = ConnectionState(
status: ConnStatus.online,
client: client,
credentials: creds,
servers: _servers,
);
return true;
} on SubsonicError catch (e) {
@ -102,6 +136,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error,
credentials: e.isAuthFailure ? null : creds,
error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
servers: _servers,
);
return false;
} catch (e) {
@ -109,13 +144,85 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error,
credentials: creds,
error: e.toString(),
servers: _servers,
);
return false;
}
}
/// Switch the active server to [id] and connect. No password re-entry — saved
/// servers keep their credentials. Per-server downloads/playlists reload
/// automatically via [serverKeyProvider] listeners.
Future<bool> switchTo(String id) async {
SubsonicCredentials? creds;
for (final s in _servers) {
if (s.id == id) {
creds = s;
break;
}
}
if (creds == null) return false;
_activeId = id;
try {
await _store.saveAll(_servers, _activeId);
} catch (_) {}
return connect(creds, persist: false);
}
/// Add or update a saved server *without* connecting (used by the add/edit
/// form). Updating an entry with the same [SubsonicCredentials.id] overwrites
/// its password/alias.
Future<void> saveServer(SubsonicCredentials creds) async {
await _addOrUpdate(creds, makeActive: false);
state = state.copyWith(servers: _servers);
}
/// Remove a saved server. If it was the active one, switch to another saved
/// server (or go disconnected when none remain).
Future<void> removeServer(String id) async {
_servers = _servers.where((s) => s.id != id).toList();
final removedActive = id == _activeId;
if (removedActive) {
_activeId = _servers.isEmpty ? null : _servers.first.id;
}
try {
await _store.saveAll(_servers, _activeId);
} catch (_) {}
if (removedActive) {
if (_servers.isNotEmpty) {
await connect(_servers.first, persist: false);
} else {
state = ConnectionState(
status: ConnStatus.disconnected,
servers: _servers,
);
}
} else {
state = state.copyWith(servers: _servers);
}
}
Future<void> _addOrUpdate(SubsonicCredentials creds,
{required bool makeActive}) async {
final idx = _servers.indexWhere((s) => s.id == creds.id);
final next = [..._servers];
if (idx >= 0) {
next[idx] = creds;
} else {
next.add(creds);
}
_servers = next;
if (makeActive) _activeId = creds.id;
await _store.saveAll(_servers, _activeId);
}
/// Forget every saved server and drop the connection.
Future<void> disconnect() async {
await _store.clear();
_servers = const [];
_activeId = null;
try {
await _store.clear();
} catch (_) {}
state = const ConnectionState(status: ConnStatus.disconnected);
}
}
@ -135,12 +242,7 @@ final subsonicClientProvider = Provider<SubsonicClient?>(
/// retains credentials). Null when no server has ever been configured. Used to
/// scope on-disk downloads/playlists to the server they belong to.
final serverKeyProvider = Provider<String?>((ref) {
final creds = ref.watch(connectionProvider).credentials;
if (creds == null) return null;
final base = creds.url.endsWith('/')
? creds.url.substring(0, creds.url.length - 1)
: creds.url;
return md5.convert(utf8.encode('$base|${creds.username}')).toString();
return ref.watch(connectionProvider).credentials?.id;
});
// ---- Navigation / browse mode -------------------------------------------
@ -152,9 +254,8 @@ final selectedTabProvider = StateProvider<int>((_) => 0);
/// Index of the Now Playing tab in the shell's tab list.
const int nowPlayingTabIndex = 2;
/// Top-level Browse mode selector.
enum BrowseMode { artists, albums, tracks }
/// Top-level Browse mode selector. Seeded once from the persisted
/// `AppSettings.defaultBrowseMode` by `AppShell`; manual selection then wins.
final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
// ---- Library ------------------------------------------------------------
@ -227,12 +328,30 @@ final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
final searchProvider =
FutureProvider.family<SearchResult3, String>((ref, query) async {
final client = ref.watch(subsonicClientProvider);
if (client == null || query.trim().isEmpty) {
final q = query.trim();
if (client == null || q.isEmpty) {
return SearchResult3(artists: const [], albums: const [], songs: const []);
}
return client.search3(query.trim());
final result = await client.search3(q);
// "Standard" trims the server's broad matches down to name/title hits;
// "Discovery" (default) returns the server result unchanged.
final mode = ref.watch(settingsProvider).searchMode;
return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result;
});
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
/// (case-insensitive) — dropping songs that only matched via their artist or
/// album fields. Backs the "Standard" search mode (TODO #4).
SearchResult3 filterSearchToStandard(SearchResult3 r, String query) {
final q = query.toLowerCase();
bool has(String? s) => s != null && s.toLowerCase().contains(q);
return SearchResult3(
artists: r.artists.where((a) => has(a.name)).toList(),
albums: r.albums.where((a) => has(a.name)).toList(),
songs: r.songs.where((s) => has(s.title)).toList(),
);
}
// ---- History (Home tab) -------------------------------------------------
final playHistoryProvider =
@ -318,6 +437,20 @@ final playlistsProvider =
return controller;
});
/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the
/// Playlists screen and the "add to playlist" sheet.
final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
.watch(playlistsProvider)
.playlists
.where((p) => !isTagPlaylist(p))
.toList());
/// Tags — playlists carrying the tag comment marker. Backs the Tags screen and
/// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the
/// partition differs.
final tagsProvider = Provider<List<Playlist>>(
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList());
// ---- Playback -----------------------------------------------------------
final playbackProvider =
@ -345,6 +478,8 @@ final playbackProvider =
streamUriFor: streamUriFor,
coverArtUriFor: coverArtUriFor,
onArt: (artUri) async {
// Skip extraction entirely when the user has pinned a static accent.
if (ref.read(settingsProvider).useStaticAccent) return;
final color = await extractAccent(NetworkImage(artUri.toString()));
if (color != null) ref.read(accentProvider.notifier).set(color);
},

View file

@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A Subsonic server connection. The password is only ever held in memory and
/// in the platform secure store — never in plain app storage (mirrors Ratune's
/// in the platform secure store — never in plain app storage (mirrors Timbre's
/// keyring-first secret handling).
class SubsonicCredentials {
const SubsonicCredentials({
@ -15,50 +18,121 @@ class SubsonicCredentials {
final String username;
final String password;
/// Optional display label shown instead of the raw URL (Ratune `[server].alias`).
/// Optional display label shown instead of the raw URL (Timbre `[server].alias`).
final String? alias;
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
/// Stable identity for a saved server: `md5(baseUrl|username)`, matching the
/// cache key in `serverKeyProvider` so per-server data lines up. Two entries
/// with the same URL + username are the "same" server (different password /
/// alias overwrites it).
String get id {
final base = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
return md5.convert(utf8.encode('$base|$username')).toString();
}
Map<String, dynamic> toJson() => {
'url': url,
'username': username,
'password': password,
if (alias != null && alias!.isNotEmpty) 'alias': alias,
};
factory SubsonicCredentials.fromJson(Map<String, dynamic> j) {
final alias = j['alias'] as String?;
return SubsonicCredentials(
url: j['url'] as String? ?? '',
username: j['username'] as String? ?? '',
password: j['password'] as String? ?? '',
alias: (alias == null || alias.isEmpty) ? null : alias,
);
}
}
/// Persists [SubsonicCredentials] to the platform secure store
/// (Keychain / Keystore / libsecret).
/// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one
/// to the platform secure store (Keychain / Keystore / libsecret). Legacy
/// single-server installs are migrated into the list on first load.
class CredentialStore {
CredentialStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
/// JSON array of all saved servers.
static const _kServers = 'subsonic_servers';
/// Id ([SubsonicCredentials.id]) of the active server.
static const _kActive = 'subsonic_active';
// Legacy single-server keys, read once for migration then removed.
static const _kUrl = 'subsonic_url';
static const _kUser = 'subsonic_username';
static const _kPass = 'subsonic_password';
static const _kAlias = 'subsonic_alias';
Future<void> save(SubsonicCredentials creds) async {
await _storage.write(key: _kUrl, value: creds.url);
await _storage.write(key: _kUser, value: creds.username);
await _storage.write(key: _kPass, value: creds.password);
await _storage.write(key: _kAlias, value: creds.alias ?? '');
/// All saved servers. Empty if none configured. Migrates a legacy
/// single-server install into the new list format on first call.
Future<List<SubsonicCredentials>> loadAll() async {
final raw = await _storage.read(key: _kServers);
if (raw != null && raw.isNotEmpty) {
try {
final list = (jsonDecode(raw) as List)
.whereType<Map>()
.map((m) => SubsonicCredentials.fromJson(m.cast<String, dynamic>()))
.where((c) => c.url.isNotEmpty && c.username.isNotEmpty)
.toList();
return list;
} catch (_) {
return const [];
}
}
// Migrate a legacy single-server install, if present.
final migrated = await _migrateLegacy();
return migrated == null ? const [] : [migrated];
}
Future<SubsonicCredentials?> load() async {
/// Id of the active server, or null.
Future<String?> loadActiveId() => _storage.read(key: _kActive);
/// Overwrite the saved list and active pointer atomically-ish (two writes).
Future<void> saveAll(
List<SubsonicCredentials> servers, String? activeId) async {
await _storage.write(
key: _kServers,
value: jsonEncode([for (final s in servers) s.toJson()]),
);
if (activeId == null) {
await _storage.delete(key: _kActive);
} else {
await _storage.write(key: _kActive, value: activeId);
}
}
Future<SubsonicCredentials?> _migrateLegacy() async {
final url = await _storage.read(key: _kUrl);
final user = await _storage.read(key: _kUser);
final pass = await _storage.read(key: _kPass);
if (url == null || user == null || pass == null) return null;
final alias = await _storage.read(key: _kAlias);
return SubsonicCredentials(
final creds = SubsonicCredentials(
url: url,
username: user,
password: pass,
alias: (alias == null || alias.isEmpty) ? null : alias,
);
}
Future<void> clear() async {
// Write the new format, point at it, then drop the legacy keys.
await saveAll([creds], creds.id);
await _storage.delete(key: _kUrl);
await _storage.delete(key: _kUser);
await _storage.delete(key: _kPass);
await _storage.delete(key: _kAlias);
return creds;
}
/// Remove all saved servers and the active pointer.
Future<void> clear() async {
await _storage.delete(key: _kServers);
await _storage.delete(key: _kActive);
}
}

View file

@ -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.

View file

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

View file

@ -8,7 +8,7 @@ import 'json_helpers.dart';
import 'models.dart';
/// A Subsonic API error (server returned `status: "failed"`), ported from
/// `ratune-subsonic/src/error.rs`. Code 40 is the auth-failure code.
/// `timbre-subsonic/src/error.rs`. Code 40 is the auth-failure code.
class SubsonicError implements Exception {
SubsonicError(this.code, this.message);
@ -22,7 +22,7 @@ class SubsonicError implements Exception {
String toString() => 'SubsonicError($code): $message';
}
/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`.
/// Subsonic HTTP client, ported from `timbre-subsonic/src/client.rs`.
///
/// Auth is the classic token scheme: a fresh random salt per request and
/// `token = MD5(password + salt)`, sent as query params
@ -57,7 +57,7 @@ class SubsonicClient {
url.endsWith('/') ? url.substring(0, url.length - 1) : url;
/// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in
/// for the platform CSPRNG; Ratune uses a weaker LCG here).
/// for the platform CSPRNG; Timbre uses a weaker LCG here).
String _makeSalt([int length = 12]) {
final rng = Random.secure();
return List.generate(
@ -190,7 +190,7 @@ class SubsonicClient {
}
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId)
/// exactly like Ratune's `set_starred` (`client.rs`).
/// exactly like Timbre's `set_starred` (`client.rs`).
Future<void> setStarred({
required bool starred,
String? songId,
@ -218,7 +218,7 @@ class SubsonicClient {
}
// ---- Playlists ----------------------------------------------------------
// Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through
// Ported from `timbre-subsonic/src/client.rs`. Track mutations all go through
// `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`.
/// `getPlaylists` — every playlist visible to the authenticated user.
@ -266,6 +266,13 @@ class SubsonicClient {
await _get('updatePlaylist', {'playlistId': playlistId, 'name': name});
}
/// `updatePlaylist` + `comment` — set a playlist's comment. Timbre uses this
/// to mark a playlist as a tag (`createPlaylist` can't set a comment inline).
Future<void> setPlaylistComment(String playlistId, String comment) async {
await _get(
'updatePlaylist', {'playlistId': playlistId, 'comment': comment});
}
/// `deletePlaylist` — delete a playlist by id.
Future<void> deletePlaylist(String id) async {
await _get('deletePlaylist', {'id': id});

View file

@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'tokens.dart';
/// Holds the current accent color — the "hero" of Ratune's aesthetic.
/// Holds the current accent color — the "hero" of Timbre's aesthetic.
///
/// In the `dynamic` theme this is extracted from the playing track's album
/// art and boosted for readability in OKLab space (Ratune `color.rs:11-64`),
/// art and boosted for readability in OKLab space (Timbre `color.rs:11-64`),
/// then interpolated toward over ~400ms. Phase 1 just exposes the default and
/// a setter; the extraction + animation land in Phase 2 with playback.
class AccentNotifier extends StateNotifier<Color> {

View file

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
/// Extract a lively accent color from album art, mirroring Ratune's
/// Extract a lively accent color from album art, mirroring Timbre's
/// art-driven `dynamic` theme (`color.rs`): pick the most vibrant swatch, then
/// nudge it into a readable lightness/saturation band. Ratune does the boost in
/// nudge it into a readable lightness/saturation band. Timbre does the boost in
/// OKLab; this HSL approximation is close enough for now (OKLab is a later
/// refinement noted in the roadmap).
Future<Color?> extractAccent(ImageProvider image) async {

View file

@ -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(

View file

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

View file

@ -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 {

View file

@ -0,0 +1,252 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show Ticker;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
/// Animated cassette for the Now Playing screen. Composites, in the shell's
/// `469×298` coordinate space, from back to front:
/// 1. the cover art on the label (the big inner body panel, cropped to fill),
/// 2. the two tape reels — a dark "well" plus the winding tape,
/// 3. the two spindle cogs, rotating while the track plays,
/// 4. the static shell SVG on top, which frames everything — the reels and
/// cogs show through its circular reel holes, so they read as sitting
/// *inside* the cassette rather than pasted over it.
///
/// The reels are modelled on a real compact cassette: tape moves at a constant
/// *linear* speed, so a reel's wound radius scales with √(remaining length) and
/// its angular speed with 1/radius (the fuller reel turns slower). See
/// [_CassetteConfig].
class CassetteView extends ConsumerStatefulWidget {
const CassetteView({super.key, required this.artUri});
/// Current track's cover-art URL (already sized by the caller), or null.
final String? artUri;
@override
ConsumerState<CassetteView> createState() => _CassetteViewState();
}
class _CassetteViewState extends ConsumerState<CassetteView>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
Duration _last = Duration.zero;
final _model = _CassetteModel();
@override
void initState() {
super.initState();
_ticker = createTicker(_onTick)..start();
}
void _onTick(Duration elapsed) {
final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
_last = elapsed;
if (dt <= 0) return;
// Read (not watch) inside the ticker: the model drives repaints itself, and
// watching here would rebuild the whole widget every position tick.
final s = ref.read(playbackProvider);
_model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
}
@override
void dispose() {
_ticker.dispose();
_model.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const cfg = _CassetteConfig.instance;
return AspectRatio(
aspectRatio: cfg.viewBox.width / cfg.viewBox.height,
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: cfg.viewBox.width,
height: cfg.viewBox.height,
child: Stack(
children: [
// 1. Cover art on the label — the big inner panel. The shell (on
// top) is transparent there, so the art shows as the "label".
Positioned.fromRect(
rect: cfg.labelRect,
child: ClipRect(child: _LabelArt(artUri: widget.artUri)),
),
// 2. Tape reels (well + winding tape). Repaints off the model.
Positioned.fill(
child: CustomPaint(painter: _TapePainter(_model)),
),
// 3. Spinning cogs — one SvgPicture each, rotated by an
// AnimatedBuilder so only the transform rebuilds per frame.
_spindle(cfg.leftReel, () => _model.angleLeft),
_spindle(cfg.rightReel, () => _model.angleRight),
// 4. Static shell on top: frames the reels through its holes.
Positioned.fill(
child: SvgPicture.asset(
'assets/casette/casette_shell.svg',
fit: BoxFit.fill,
),
),
],
),
),
),
);
}
Widget _spindle(Offset center, double Function() angle) {
const size = _CassetteConfig.spindleSize;
// Built once; AnimatedBuilder rotates this cached child rather than
// re-inflating the SVG every frame.
final cog = SvgPicture.asset(
'assets/casette/spindle.svg',
width: size,
height: size,
);
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: AnimatedBuilder(
animation: _model,
child: cog,
builder: (_, child) =>
Transform.rotate(angle: angle(), child: child),
),
);
}
}
/// Cover art (or a neutral fallback) for the label region.
class _LabelArt extends StatelessWidget {
const _LabelArt({required this.artUri});
final String? artUri;
@override
Widget build(BuildContext context) {
if (artUri == null) {
return const ColoredBox(color: TimbreColors.surface);
}
return Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover, // square art → wide label: crop the sides/top
gaplessPlayback: true,
errorBuilder: (_, _, _) => const ColoredBox(color: TimbreColors.surface),
);
}
}
/// Holds the live reel angles + progress and advances them each tick. A
/// [ChangeNotifier] so the spindle transforms and tape painter repaint without
/// rebuilding the whole [CassetteView].
class _CassetteModel extends ChangeNotifier {
double angleLeft = 0;
double angleRight = 0;
double progress = 0;
void update({
required double dt,
required bool playing,
required double progress,
}) {
final progressChanged = progress != this.progress;
this.progress = progress;
if (playing) {
const cfg = _CassetteConfig.instance;
// ω = v / R, same (clockwise) direction for both reels.
angleLeft += cfg.linearSpeed / cfg.radius(progress, supply: true) * dt;
angleRight += cfg.linearSpeed / cfg.radius(progress, supply: false) * dt;
}
// Idle + no seek ⇒ nothing moved, so skip the repaint.
if (playing || progressChanged) notifyListeners();
}
}
/// Paints the two reels as large tape discs centred on the spindles, plus a
/// light pad behind them. Everything is clipped to the window band so it never
/// bleeds onto the label; the shell (painted on top) then masks it to the parts
/// that should show — the reel holes and the centre window, where each disc's
/// inner edge appears as a crescent that grows/shrinks with
/// [_CassetteModel.progress] as tape winds from one reel to the other.
class _TapePainter extends CustomPainter {
_TapePainter(this.model) : super(repaint: model);
final _CassetteModel model;
@override
void paint(Canvas canvas, Size size) {
const cfg = _CassetteConfig.instance;
canvas.save();
// Keep the tape inside the window band — off the (transparent) label.
canvas.clipRect(cfg.windowRect);
// Light pad behind the tape; the shell masks it down to the centre window.
canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor);
final tape = Paint()..color = cfg.tapeColor;
canvas.drawCircle(
cfg.leftReel, cfg.radius(model.progress, supply: true), tape);
canvas.drawCircle(
cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
canvas.restore();
}
// Repaint is driven by `repaint: model`; geometry is fully derived from it.
@override
bool shouldRepaint(_TapePainter oldDelegate) => false;
}
/// All the fixed geometry, measured off `casette_shell.svg` (viewBox 469×298),
/// plus the reel physics. Grouped and tunable in one place.
class _CassetteConfig {
const _CassetteConfig();
static const instance = _CassetteConfig();
final Size viewBox = const Size(469, 298);
/// The big inner body panel — the transparent "label" the cover art fills.
/// Slightly oversized so it fully backs the shell's hole (excess is masked
/// by the shell frame on top).
final Rect labelRect = const Rect.fromLTRB(25, 27, 444, 220);
// The window band the tape is confined to (clip), so it never touches the
// label; the shell then masks it to the reel holes + centre window.
final Rect windowRect = const Rect.fromLTRB(62.5, 98.5, 405.5, 172);
// Reel + spindle centres (the two window holes).
final Offset leftReel = const Offset(135, 135);
final Offset rightReel = const Offset(334, 135);
// Wound-tape radius bounds: [hubRadius] = empty reel (just covers the hole),
// [fullRadius] = full reel (its crescent reaches well into the centre window).
final double hubRadius = 36;
final double fullRadius = 80;
// Cog display size (native art is 105×105, centred on the axle). ~ the reel
// hole so the cog seats in the well with a thin tape rim showing around it.
static const double spindleSize = 36;
// Tape linear speed in viewBox units/second — sets how fast the cogs spin
// (ω = linearSpeed / radius). Purely cosmetic.
final double linearSpeed = 40;
final Color tapeColor = const Color(0xFF121212); // wound tape
final Color padColor = const Color(0xFF1A1A1A); // centre-window backing
/// Wound radius at [progress]. Radius scales with √(wound length): the supply
/// reel is full at p=0 and empty at p=1; the take-up reel is the reverse.
double radius(double progress, {required bool supply}) {
final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0);
final r2 = hubRadius * hubRadius +
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
return math.sqrt(r2);
}
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// A thin-stroked panel with its title label sitting *on* the top border —
/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics",
/// Timbre's signature container ("Album Art", "Queue (127)", "Lyrics",
/// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
/// then overlay the title with a background that occludes the segment behind
/// it, so the label appears to break the line.

38
lib/widgets/toast.dart Normal file
View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/tokens.dart';
/// Shows a themed, transient confirmation toast plus a light haptic tick.
///
/// Rendered as a fixed [SnackBar], so it settles just above the app's bottom
/// chrome (mini-player + tab bar) rather than over it. Styled to match the
/// dense, near-black terminal aesthetic — [TimbreColors.surface] fill, warm
/// [TimbreColors.foreground] text, with the live accent used for the leading
/// [icon]. Any in-flight toast is cleared first so rapid taps don't stack.
void showToast(BuildContext context, String message, {IconData? icon}) {
final accent = Theme.of(context).colorScheme.primary;
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(
backgroundColor: TimbreColors.surface,
duration: const Duration(seconds: 2),
content: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: accent),
const SizedBox(width: TimbreSpacing.md),
],
Expanded(
child: Text(
message,
style: const TextStyle(color: TimbreColors.foreground),
),
),
],
),
),
);
}