updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
|
|
@ -8,7 +8,7 @@ import 'package:path_provider/path_provider.dart';
|
|||
|
||||
import '../subsonic/models.dart';
|
||||
|
||||
/// One play event, mirroring Ratune's `PlayRecord` (`history.rs`).
|
||||
/// One play event, mirroring Timbre's `PlayRecord` (`history.rs`).
|
||||
class PlayRecord {
|
||||
PlayRecord({
|
||||
required this.songId,
|
||||
|
|
@ -173,7 +173,7 @@ List<PlayRecord> recentAlbums(List<PlayRecord> history, {int limit = 12}) {
|
|||
}
|
||||
|
||||
/// Rediscover: artists you've heard before but aren't listening to now, biased
|
||||
/// toward low play counts, then sampled for variety (Ratune
|
||||
/// toward low play counts, then sampled for variety (Timbre
|
||||
/// `history.rs:101-167`). [seed] drives the re-roll.
|
||||
///
|
||||
/// The desktop original only surfaces artists last heard more than [minDays]
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class LibraryIndexState {
|
|||
}
|
||||
|
||||
/// Builds and caches a flat "all songs" index — Subsonic has no all-songs
|
||||
/// endpoint, so we crawl every album (mirrors Ratune's `library_index` /
|
||||
/// endpoint, so we crawl every album (mirrors Timbre's `library_index` /
|
||||
/// `fetch_all_library_songs`). The cache is keyed to the server so switching
|
||||
/// servers never serves a stale catalog, and writes are atomic so an
|
||||
/// interrupted crawl can't leave a truncated file.
|
||||
|
|
@ -54,7 +54,7 @@ class LibraryIndexController extends StateNotifier<LibraryIndexState> {
|
|||
|
||||
final SubsonicClient? Function() _clientGetter;
|
||||
|
||||
static const int _albumParallelism = 12; // Ratune's album_parallelism.
|
||||
static const int _albumParallelism = 12; // Timbre's album_parallelism.
|
||||
static const int _pageSize = 500; // Subsonic getAlbumList2 cap.
|
||||
static const Duration _ttl = Duration(hours: 24);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
|
||||
import 'settings/settings_store.dart';
|
||||
import 'shell/app_shell.dart';
|
||||
import 'theme/accent.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
|
@ -35,8 +36,12 @@ class TimbreApp extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// The theme rebuilds whenever the album-art accent changes.
|
||||
final accent = ref.watch(accentProvider);
|
||||
// The accent either tracks the album art (default) or is pinned to a
|
||||
// user-chosen static color. The theme rebuilds whenever it changes.
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final accent = settings.useStaticAccent
|
||||
? settings.staticAccentColor
|
||||
: ref.watch(accentProvider);
|
||||
return MaterialApp(
|
||||
title: 'Timbre',
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import 'package:just_audio_background/just_audio_background.dart';
|
|||
|
||||
import '../subsonic/models.dart';
|
||||
|
||||
/// Immutable snapshot of the player, mirroring Ratune's `QueueState` +
|
||||
/// player-event stream (`ratune-player/src/engine.rs`).
|
||||
/// Immutable snapshot of the player, mirroring Timbre's `QueueState` +
|
||||
/// player-event stream (`timbre-player/src/engine.rs`).
|
||||
class PlaybackState {
|
||||
const PlaybackState({
|
||||
this.queue = const [],
|
||||
|
|
@ -212,7 +212,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
// currentIndexStream fires _notifyCurrent for the started track.
|
||||
}
|
||||
|
||||
/// Insert [song] right after the current track (Ratune's "play next").
|
||||
/// Insert [song] right after the current track (Timbre's "play next").
|
||||
/// Falls back to [playSongs] when nothing is playing.
|
||||
Future<void> playNext(Song song) async {
|
||||
if (_streamUriFor(song) == null) return;
|
||||
|
|
@ -306,7 +306,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cycle off → all → one → off (Ratune's queue-loop toggle, extended with
|
||||
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
|
||||
/// single-track repeat).
|
||||
Future<void> cycleLoop() async {
|
||||
final nextMode = switch (state.loop) {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,25 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../subsonic/models.dart';
|
||||
import '../subsonic/subsonic_client.dart';
|
||||
|
||||
/// Marker stored in a playlist's `comment` to flag it as a Timbre **tag** — a
|
||||
/// playlist surfaced under the Tags UI instead of Playlists. Kept out of the
|
||||
/// visible name so tags read cleanly everywhere (including other Subsonic
|
||||
/// clients, where they still appear as ordinary playlists).
|
||||
///
|
||||
/// Classification relies on `getPlaylists` returning the `comment` field;
|
||||
/// Navidrome does. A server that omitted it would show tags as plain playlists.
|
||||
const String kTagMarker = 'timbre:tag';
|
||||
|
||||
/// Whether [p] is a Timbre tag (vs a user-facing playlist).
|
||||
bool isTagPlaylist(Playlist p) => p.comment == kTagMarker;
|
||||
|
||||
/// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
|
||||
/// that have been opened. Details are cached so an opened playlist keeps working
|
||||
/// offline.
|
||||
|
|
@ -185,6 +198,87 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Tags ---------------------------------------------------------------
|
||||
// A tag is just a playlist whose `comment` carries [kTagMarker]. These reuse
|
||||
// the playlist mutations above; only creation and membership need tag-aware
|
||||
// behaviour (marker stamping + de-duplication).
|
||||
|
||||
/// Create a tag named [name], or return the id of an existing tag with that
|
||||
/// name (case-insensitive) so tags stay unique. `createPlaylist` can't set a
|
||||
/// comment inline, so we create then stamp the marker — and if stamping fails
|
||||
/// we delete the orphan rather than leave an unmarked playlist behind.
|
||||
Future<String?> createTag(String name) async {
|
||||
final client = _clientGetter();
|
||||
if (client == null) return null;
|
||||
|
||||
final existing = state.playlists.firstWhereOrNull(
|
||||
(p) => isTagPlaylist(p) && p.name.toLowerCase() == name.toLowerCase(),
|
||||
);
|
||||
if (existing != null) return existing.id;
|
||||
|
||||
final id = await create(name);
|
||||
if (id == null) return null;
|
||||
try {
|
||||
await client.setPlaylistComment(id, kTagMarker);
|
||||
} catch (_) {
|
||||
await delete(id); // don't strand a nameless, unmarked playlist
|
||||
return null;
|
||||
}
|
||||
_applyComment(id, kTagMarker);
|
||||
await _persist();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Add [songs] to a tag, skipping any already present. Playlists allow
|
||||
/// duplicates but a tag is a set — re-tagging a song must be idempotent, so we
|
||||
/// load current membership first and only append the new ids.
|
||||
Future<void> addToTag(String tagId, List<Song> songs) async {
|
||||
if (songs.isEmpty) return;
|
||||
final detail = await loadDetail(tagId);
|
||||
final present = {for (final s in detail?.songs ?? const <Song>[]) s.id};
|
||||
final fresh = songs.where((s) => !present.contains(s.id)).toList();
|
||||
if (fresh.isEmpty) return;
|
||||
await addTracks(tagId, fresh);
|
||||
}
|
||||
|
||||
/// Patch the cached summary + detail for [id] with [comment] (used right after
|
||||
/// stamping a new tag's marker so it partitions into the Tags view at once).
|
||||
void _applyComment(String id, String comment) {
|
||||
state = state.copyWith(
|
||||
playlists: [
|
||||
for (final p in state.playlists)
|
||||
if (p.id == id) _withComment(p, comment) else p,
|
||||
],
|
||||
details: {
|
||||
for (final e in state.details.entries)
|
||||
e.key: e.key == id ? _detailWithComment(e.value, comment) : e.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Playlist _withComment(Playlist p, String comment) => Playlist(
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
songCount: p.songCount,
|
||||
duration: p.duration,
|
||||
owner: p.owner,
|
||||
public: p.public,
|
||||
coverArt: p.coverArt,
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
static PlaylistDetail _detailWithComment(
|
||||
PlaylistDetail d, String comment) =>
|
||||
PlaylistDetail(
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
songCount: d.songCount,
|
||||
duration: d.duration,
|
||||
coverArt: d.coverArt,
|
||||
comment: comment,
|
||||
songs: d.songs,
|
||||
);
|
||||
|
||||
Future<void> rename(String id, String name) async {
|
||||
final client = _clientGetter();
|
||||
if (client == null) return;
|
||||
|
|
@ -280,6 +374,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
owner: p.owner,
|
||||
public: p.public,
|
||||
coverArt: p.coverArt,
|
||||
comment: p.comment,
|
||||
)
|
||||
else
|
||||
p,
|
||||
|
|
@ -294,6 +389,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
owner: p.owner,
|
||||
public: p.public,
|
||||
coverArt: p.coverArt,
|
||||
comment: p.comment,
|
||||
);
|
||||
|
||||
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
|
||||
|
|
@ -303,6 +399,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
songCount: d.songCount,
|
||||
duration: d.duration,
|
||||
coverArt: d.coverArt,
|
||||
comment: d.comment,
|
||||
songs: d.songs,
|
||||
);
|
||||
|
||||
|
|
@ -313,6 +410,7 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
|
|||
songCount: songs.length,
|
||||
duration: d.duration,
|
||||
coverArt: d.coverArt,
|
||||
comment: d.comment,
|
||||
songs: songs,
|
||||
);
|
||||
|
||||
|
|
|
|||
151
lib/screens/add_tag_sheet.dart
Normal file
151
lib/screens/add_tag_sheet.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
|
||||
|
||||
/// Bottom sheet to apply a tag to [songs] — pick an existing tag or create one.
|
||||
/// Tags are playlists under the hood, so this mirrors the "add to playlist"
|
||||
/// sheet; the difference is membership is idempotent (a song already carrying
|
||||
/// the tag is skipped, not duplicated). No-op when offline.
|
||||
Future<void> showAddTagSheet(
|
||||
BuildContext context, {
|
||||
required List<Song> songs,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _AddTagSheet(songs: songs),
|
||||
);
|
||||
}
|
||||
|
||||
class _AddTagSheet extends ConsumerWidget {
|
||||
const _AddTagSheet({required this.songs});
|
||||
|
||||
final List<Song> songs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final tags = ref.watch(tagsProvider);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Text('Add tag',
|
||||
style:
|
||||
TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
if (!connected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(TimbreSpacing.xl),
|
||||
child: Text('Connect to a server to manage tags.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else ...[
|
||||
_Tile(
|
||||
icon: Icons.add,
|
||||
label: 'New tag…',
|
||||
accent: accent,
|
||||
onTap: () => _createAndAdd(context, ref),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final t in tags)
|
||||
_Tile(
|
||||
icon: Icons.label_outline,
|
||||
label: t.name,
|
||||
trailing:
|
||||
t.songCount != null ? '${t.songCount}' : null,
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playlistsProvider.notifier)
|
||||
.addToTag(t.id, songs);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showToast(context, 'Tagged "${t.name}"');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
|
||||
final name = await promptPlaylistName(context, title: 'New tag');
|
||||
if (name == null || name.isEmpty) return;
|
||||
final id = await ref.read(playlistsProvider.notifier).createTag(name);
|
||||
if (id != null) {
|
||||
await ref.read(playlistsProvider.notifier).addToTag(id, songs);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showToast(context,
|
||||
id != null ? 'Tagged "$name"' : 'Could not create tag');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Tile extends StatelessWidget {
|
||||
const _Tile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
this.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final String? trailing;
|
||||
final Color? accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: accent ?? TimbreColors.dimmed),
|
||||
const SizedBox(width: TimbreSpacing.lg),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: accent ?? TimbreColors.foreground)),
|
||||
),
|
||||
if (trailing != null)
|
||||
Text(trailing!,
|
||||
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/toast.dart';
|
||||
|
||||
/// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
|
||||
/// offline (playlist mutations require the server).
|
||||
|
|
@ -27,7 +28,7 @@ class _AddToPlaylistSheet extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final playlists = ref.watch(playlistsProvider).playlists;
|
||||
final playlists = ref.watch(realPlaylistsProvider);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return SafeArea(
|
||||
|
|
@ -184,8 +185,5 @@ Future<String?> promptPlaylistName(
|
|||
);
|
||||
}
|
||||
|
||||
void _toast(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
void _toast(BuildContext context, String message) =>
|
||||
showToast(context, message);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import '../state/providers.dart';
|
|||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_tag_sheet.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
import 'downloads_screen.dart';
|
||||
import 'favorites_screen.dart';
|
||||
import 'playlists_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'tags_screen.dart';
|
||||
|
||||
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
|
||||
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
|
||||
|
|
@ -58,6 +61,13 @@ class BrowserScreen extends ConsumerWidget {
|
|||
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.label_outline,
|
||||
label: 'Tags',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const TagsScreen()),
|
||||
),
|
||||
),
|
||||
_Action(
|
||||
icon: Icons.download,
|
||||
label: 'Downloads',
|
||||
|
|
@ -344,6 +354,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
|||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onAddToTag: () => showAddTagSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
|
|
@ -472,11 +483,7 @@ class AlbumScreen extends ConsumerWidget {
|
|||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading album…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
showToast(context, 'Downloading album…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
|
|
@ -499,6 +506,7 @@ class AlbumScreen extends ConsumerWidget {
|
|||
onAddToQueue: () => playback.addToQueue(song),
|
||||
onAddToPlaylist: () =>
|
||||
showAddToPlaylistSheet(context, songs: [song]),
|
||||
onAddToTag: () => showAddTagSheet(context, songs: [song]),
|
||||
onDownload: () =>
|
||||
ref.read(downloadManagerProvider.notifier).download(song),
|
||||
onRemoveDownload: () =>
|
||||
|
|
@ -523,6 +531,7 @@ class BrowseRow extends StatelessWidget {
|
|||
this.onPlayNext,
|
||||
this.onAddToQueue,
|
||||
this.onAddToPlaylist,
|
||||
this.onAddToTag,
|
||||
this.onDownload,
|
||||
this.onRemoveDownload,
|
||||
this.downloadStatus,
|
||||
|
|
@ -540,6 +549,7 @@ class BrowseRow extends StatelessWidget {
|
|||
/// Secondary song actions, folded into a trailing overflow menu so the row
|
||||
/// stays uncluttered.
|
||||
final VoidCallback? onAddToPlaylist;
|
||||
final VoidCallback? onAddToTag;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onRemoveDownload;
|
||||
|
||||
|
|
@ -548,7 +558,10 @@ class BrowseRow extends StatelessWidget {
|
|||
final DownloadStatus? downloadStatus;
|
||||
|
||||
bool get _hasMenu =>
|
||||
onAddToPlaylist != null || onDownload != null || onRemoveDownload != null;
|
||||
onAddToPlaylist != null ||
|
||||
onAddToTag != null ||
|
||||
onDownload != null ||
|
||||
onRemoveDownload != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -600,14 +613,27 @@ class BrowseRow extends StatelessWidget {
|
|||
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||
],
|
||||
if (onPlayNext != null)
|
||||
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
|
||||
_RowIcon(
|
||||
icon: Icons.playlist_play,
|
||||
onTap: () {
|
||||
onPlayNext!();
|
||||
showToast(context, 'Playing next', icon: Icons.check);
|
||||
},
|
||||
),
|
||||
if (onAddToQueue != null)
|
||||
_RowIcon(icon: Icons.add, onTap: onAddToQueue!),
|
||||
_RowIcon(
|
||||
icon: Icons.add,
|
||||
onTap: () {
|
||||
onAddToQueue!();
|
||||
showToast(context, 'Added to queue', icon: Icons.check);
|
||||
},
|
||||
),
|
||||
if (_hasMenu)
|
||||
_RowMenu(
|
||||
isDownloaded: isDone,
|
||||
isDownloading: isActive,
|
||||
onAddToPlaylist: onAddToPlaylist,
|
||||
onAddToTag: onAddToTag,
|
||||
onDownload: onDownload,
|
||||
onRemoveDownload: onRemoveDownload,
|
||||
),
|
||||
|
|
@ -626,6 +652,7 @@ class _RowMenu extends StatelessWidget {
|
|||
required this.isDownloaded,
|
||||
required this.isDownloading,
|
||||
this.onAddToPlaylist,
|
||||
this.onAddToTag,
|
||||
this.onDownload,
|
||||
this.onRemoveDownload,
|
||||
});
|
||||
|
|
@ -633,6 +660,7 @@ class _RowMenu extends StatelessWidget {
|
|||
final bool isDownloaded;
|
||||
final bool isDownloading;
|
||||
final VoidCallback? onAddToPlaylist;
|
||||
final VoidCallback? onAddToTag;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onRemoveDownload;
|
||||
|
||||
|
|
@ -648,6 +676,8 @@ class _RowMenu extends StatelessWidget {
|
|||
switch (v) {
|
||||
case 'playlist':
|
||||
onAddToPlaylist?.call();
|
||||
case 'tag':
|
||||
onAddToTag?.call();
|
||||
case 'download':
|
||||
onDownload?.call();
|
||||
case 'remove_download':
|
||||
|
|
@ -658,6 +688,8 @@ class _RowMenu extends StatelessWidget {
|
|||
if (onAddToPlaylist != null)
|
||||
const PopupMenuItem(
|
||||
value: 'playlist', child: Text('Add to playlist')),
|
||||
if (onAddToTag != null)
|
||||
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
|
||||
if (isDownloaded && onRemoveDownload != null)
|
||||
const PopupMenuItem(
|
||||
value: 'remove_download', child: Text('Remove download'))
|
||||
|
|
|
|||
|
|
@ -5,18 +5,145 @@ import '../state/providers.dart';
|
|||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Open the connect-server sheet.
|
||||
Future<void> showConnectSheet(BuildContext context) {
|
||||
/// Open the add/edit-server sheet. Pass [initial] to edit an existing saved
|
||||
/// server (its password is reused if the field is left blank).
|
||||
Future<void> showConnectSheet(BuildContext context,
|
||||
{SubsonicCredentials? initial}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _ConnectSheet(),
|
||||
builder: (_) => _ConnectSheet(initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the server switcher: pick an active server, or add a new one.
|
||||
Future<void> showServerSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: TimbreColors.background,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _ServerSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lists saved servers (tap to switch) with an "Add server" action.
|
||||
class _ServerSheet extends ConsumerWidget {
|
||||
const _ServerSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Text('Servers',
|
||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
if (conn.servers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
|
||||
child: Text('No servers saved yet.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else
|
||||
for (final s in conn.servers)
|
||||
_ServerRow(
|
||||
creds: s,
|
||||
active: s.id == conn.activeId,
|
||||
onTap: () {
|
||||
// Grab the notifier before popping — this widget's `ref` is
|
||||
// disposed with the sheet.
|
||||
final notifier = ref.read(connectionProvider.notifier);
|
||||
Navigator.of(context).pop();
|
||||
notifier.switchTo(s.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Capture the navigator's (still-mounted) context before popping
|
||||
// this sheet, so the next sheet has a valid overlay to show in.
|
||||
final nav = Navigator.of(context);
|
||||
nav.pop();
|
||||
showConnectSheet(nav.context);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add, size: 16, color: TimbreColors.foreground),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Add server',
|
||||
style: TextStyle(color: TimbreColors.foreground)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerRow extends StatelessWidget {
|
||||
const _ServerRow({
|
||||
required this.creds,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SubsonicCredentials creds;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(active ? '● ' : '○ ',
|
||||
style: TextStyle(
|
||||
color: active ? accent : TimbreColors.dimmed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
creds.display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.foreground,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectSheet extends ConsumerStatefulWidget {
|
||||
const _ConnectSheet();
|
||||
const _ConnectSheet({this.initial});
|
||||
|
||||
final SubsonicCredentials? initial;
|
||||
|
||||
@override
|
||||
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
|
||||
|
|
@ -26,15 +153,20 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
final _url = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _alias = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _localError;
|
||||
|
||||
bool get _isEdit => widget.initial != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final creds = ref.read(connectionProvider).credentials;
|
||||
if (creds != null) {
|
||||
_url.text = creds.url;
|
||||
_user.text = creds.username;
|
||||
final initial = widget.initial;
|
||||
if (initial != null) {
|
||||
_url.text = initial.url;
|
||||
_user.text = initial.username;
|
||||
_alias.text = initial.alias ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,27 +175,66 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
_url.dispose();
|
||||
_user.dispose();
|
||||
_pass.dispose();
|
||||
_alias.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Build credentials from the form. Returns null (and sets [_localError]) if
|
||||
/// required fields are missing. On edit, a blank password reuses the saved one.
|
||||
SubsonicCredentials? _readForm() {
|
||||
final url = _url.text.trim();
|
||||
final user = _user.text.trim();
|
||||
if (url.isEmpty || user.isEmpty) {
|
||||
setState(() => _localError = 'Server URL and username are required.');
|
||||
return null;
|
||||
}
|
||||
final password =
|
||||
_pass.text.isEmpty && _isEdit ? widget.initial!.password : _pass.text;
|
||||
if (password.isEmpty) {
|
||||
setState(() => _localError = 'Password is required.');
|
||||
return null;
|
||||
}
|
||||
final alias = _alias.text.trim();
|
||||
return SubsonicCredentials(
|
||||
url: url,
|
||||
username: user,
|
||||
password: password,
|
||||
alias: alias.isEmpty ? null : alias,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
setState(() => _busy = true);
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(
|
||||
SubsonicCredentials(
|
||||
url: _url.text.trim(),
|
||||
username: _user.text.trim(),
|
||||
password: _pass.text,
|
||||
),
|
||||
);
|
||||
final creds = _readForm();
|
||||
if (creds == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_localError = null;
|
||||
});
|
||||
final ok = await ref.read(connectionProvider.notifier).connect(creds);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (ok) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final creds = _readForm();
|
||||
if (creds == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_localError = null;
|
||||
});
|
||||
await ref.read(connectionProvider.notifier).saveServer(creds);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final error = _localError ??
|
||||
(conn.status == ConnStatus.error ? conn.error : null);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: TimbreSpacing.xl,
|
||||
|
|
@ -75,33 +246,53 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Connect to server',
|
||||
Text(_isEdit ? 'Edit server' : 'Add server',
|
||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
_field(_url, 'Server URL', 'https://navidrome.example.com',
|
||||
keyboard: TextInputType.url),
|
||||
_field(_user, 'Username', 'you'),
|
||||
_field(_pass, 'Password', '••••••••', obscure: true),
|
||||
if (conn.status == ConnStatus.error && conn.error != null) ...[
|
||||
_field(_pass, 'Password',
|
||||
_isEdit ? '•••••••• (unchanged)' : '••••••••',
|
||||
obscure: true),
|
||||
_field(_alias, 'Label (optional)', 'Home server'),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
Text(conn.error!,
|
||||
style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
Text(error, style: const TextStyle(color: Color(0xFFE06C75))),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _connect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: TimbreColors.background,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : _save,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: TimbreColors.foreground,
|
||||
side: const BorderSide(color: TimbreColors.border),
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy ? null : _connect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: TimbreColors.background,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import 'package:just_audio/just_audio.dart' show LoopMode;
|
|||
|
||||
import '../downloads/download_manager.dart';
|
||||
import '../playback/playback_engine.dart';
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/block_progress_bar.dart';
|
||||
import '../widgets/cassette_view.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
|
|
@ -210,28 +212,32 @@ class _AlbumArtPanel extends ConsumerWidget {
|
|||
final coverArt =
|
||||
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final cassette =
|
||||
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
|
||||
final artUri = (client != null && coverArt != null)
|
||||
? client.coverArtUri(coverArt, size: 512).toString()
|
||||
: null;
|
||||
|
||||
return HairlinePanel(
|
||||
title: 'Album Art',
|
||||
title: cassette ? 'Cassette' : 'Album Art',
|
||||
padding: const EdgeInsets.all(TimbreSpacing.md),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
child: cassette
|
||||
? Center(child: CassetteView(artUri: artUri))
|
||||
: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ColoredBox(
|
||||
color: TimbreColors.surface,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../state/providers.dart';
|
|||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import '../widgets/toast.dart';
|
||||
import 'add_to_playlist_sheet.dart';
|
||||
|
||||
/// Playlists list — server-backed with an offline mirror. Create from the app
|
||||
|
|
@ -16,7 +17,7 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(playlistsProvider);
|
||||
final playlists = ref.watch(realPlaylistsProvider);
|
||||
final controller = ref.read(playlistsProvider.notifier);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
|
|
@ -38,10 +39,9 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
child: HairlinePanel(
|
||||
title: 'Playlists',
|
||||
active: true,
|
||||
trailing:
|
||||
state.playlists.isEmpty ? null : '(${state.playlists.length})',
|
||||
trailing: playlists.isEmpty ? null : '(${playlists.length})',
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: state.playlists.isEmpty
|
||||
child: playlists.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
connected
|
||||
|
|
@ -53,9 +53,9 @@ class PlaylistsScreen extends ConsumerWidget {
|
|||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: state.playlists.length,
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, i) {
|
||||
final p = state.playlists[i];
|
||||
final p = playlists[i];
|
||||
return _PlaylistRow(
|
||||
name: p.name,
|
||||
subtitle: p.songCount != null
|
||||
|
|
@ -233,11 +233,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
|
|||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.downloadAll(songs);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Downloading playlist…'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
showToast(context, 'Downloading playlist…');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
|
|
@ -337,8 +333,10 @@ class _TrackRow extends StatelessWidget {
|
|||
switch (v) {
|
||||
case 'next':
|
||||
onPlayNext();
|
||||
showToast(context, 'Playing next', icon: Icons.check);
|
||||
case 'queue':
|
||||
onAddToQueue();
|
||||
showToast(context, 'Added to queue', icon: Icons.check);
|
||||
case 'remove':
|
||||
onRemove?.call();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/credentials.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'connect_sheet.dart';
|
||||
|
||||
/// Audio-quality settings: independent streaming and download knobs, both
|
||||
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
|
||||
|
|
@ -14,6 +17,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final controller = ref.read(settingsProvider.notifier);
|
||||
final conn = ref.watch(connectionProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -72,6 +76,143 @@ class SettingsScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Appearance',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'By default the accent color is drawn from the playing '
|
||||
'album art. Turn on a static accent to pin one color.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<bool>(
|
||||
label: 'Static accent',
|
||||
values: const [false, true],
|
||||
selected: settings.useStaticAccent,
|
||||
labelFor: (v) => v ? 'On' : 'Off',
|
||||
onSelect: controller.setUseStaticAccent,
|
||||
),
|
||||
if (settings.useStaticAccent) ...[
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
_ColorChips(
|
||||
label: 'Accent color',
|
||||
values: AppSettings.accentChoices,
|
||||
selected: settings.staticAccentColor,
|
||||
onSelect: controller.setStaticAccentColor,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: TimbreSpacing.lg),
|
||||
const _Caption(
|
||||
'Show Now Playing as an animated cassette — cover art on '
|
||||
'the label, reels that spin and wind with the track.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<bool>(
|
||||
label: 'Now Playing art',
|
||||
values: const [false, true],
|
||||
selected: settings.nowPlayingCassette,
|
||||
labelFor: (v) => v ? 'Cassette' : 'Album cover',
|
||||
onSelect: controller.setNowPlayingCassette,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Browse',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption('Which view the Browse tab opens on.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<BrowseMode>(
|
||||
label: 'Default view',
|
||||
values: BrowseMode.values,
|
||||
selected: settings.defaultBrowseMode,
|
||||
labelFor: AppSettings.browseModeLabel,
|
||||
onSelect: controller.setDefaultBrowseMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Search',
|
||||
active: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'"Standard" matches only names/titles that contain your '
|
||||
'query. "Discovery" uses the server\'s broader matching.'),
|
||||
const SizedBox(height: TimbreSpacing.md),
|
||||
_ChoiceChips<SearchMode>(
|
||||
label: 'Search mode',
|
||||
values: const [SearchMode.standard, SearchMode.discovery],
|
||||
selected: settings.searchMode,
|
||||
labelFor: AppSettings.searchModeLabel,
|
||||
onSelect: controller.setSearchMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.xl),
|
||||
HairlinePanel(
|
||||
title: 'Servers',
|
||||
active: true,
|
||||
trailing:
|
||||
conn.servers.isNotEmpty ? '(${conn.servers.length})' : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _Caption(
|
||||
'Save multiple servers and switch the active one. Only '
|
||||
'one is connected at a time.'),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
if (conn.servers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
|
||||
child: Text('No servers saved yet.',
|
||||
style: TextStyle(color: TimbreColors.dimmed)),
|
||||
)
|
||||
else
|
||||
for (final s in conn.servers)
|
||||
_ServerManageRow(
|
||||
creds: s,
|
||||
active: s.id == conn.activeId,
|
||||
onActivate: () => ref
|
||||
.read(connectionProvider.notifier)
|
||||
.switchTo(s.id),
|
||||
onEdit: () =>
|
||||
showConnectSheet(context, initial: s),
|
||||
onDelete: () => ref
|
||||
.read(connectionProvider.notifier)
|
||||
.removeServer(s.id),
|
||||
),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
child: const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add,
|
||||
size: 16, color: TimbreColors.foreground),
|
||||
SizedBox(width: TimbreSpacing.sm),
|
||||
Text('Add server',
|
||||
style:
|
||||
TextStyle(color: TimbreColors.foreground)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -79,6 +220,72 @@ class SettingsScreen extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A saved-server row in Settings: activate (tap the label), edit, or delete.
|
||||
class _ServerManageRow extends StatelessWidget {
|
||||
const _ServerManageRow({
|
||||
required this.creds,
|
||||
required this.active,
|
||||
required this.onActivate,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SubsonicCredentials creds;
|
||||
final bool active;
|
||||
final VoidCallback onActivate;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: onActivate,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: TimbreSpacing.minTouchTarget),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(active ? '● ' : '○ ',
|
||||
style: TextStyle(
|
||||
color: active ? accent : TimbreColors.dimmed)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
creds.display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: TimbreColors.foreground,
|
||||
fontWeight:
|
||||
active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: onDelete,
|
||||
icon:
|
||||
const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Caption extends StatelessWidget {
|
||||
const _Caption(this.text);
|
||||
final String text;
|
||||
|
|
@ -172,3 +379,90 @@ class _Chip extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
|
||||
/// color swatch and the color's own hue drives the active border/fill.
|
||||
class _ColorChips extends StatelessWidget {
|
||||
const _ColorChips({
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<AccentChoice> values;
|
||||
final Color selected;
|
||||
final ValueChanged<Color> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
|
||||
const SizedBox(height: TimbreSpacing.sm),
|
||||
Wrap(
|
||||
spacing: TimbreSpacing.sm,
|
||||
runSpacing: TimbreSpacing.sm,
|
||||
children: [
|
||||
for (final c in values)
|
||||
_ColorChip(
|
||||
choice: c,
|
||||
active: c.color == selected,
|
||||
onTap: () => onSelect(c.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorChip extends StatelessWidget {
|
||||
const _ColorChip({
|
||||
required this.choice,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AccentChoice choice;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TimbreSpacing.lg,
|
||||
vertical: TimbreSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: active ? choice.color : TimbreColors.border,
|
||||
),
|
||||
color: active
|
||||
? choice.color.withValues(alpha: 0.12)
|
||||
: TimbreColors.surface,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 12, height: 12, color: choice.color),
|
||||
const SizedBox(width: TimbreSpacing.sm),
|
||||
Text(
|
||||
choice.name,
|
||||
style: TextStyle(
|
||||
color: active ? choice.color : TimbreColors.foreground,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
lib/screens/tags_screen.dart
Normal file
187
lib/screens/tags_screen.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../playlists/playlists.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../subsonic/models.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/hairline_panel.dart';
|
||||
import 'add_to_playlist_sheet.dart' show promptPlaylistName;
|
||||
import 'playlists_screen.dart' show PlaylistDetailScreen;
|
||||
|
||||
/// Tags list — the same server-backed store as Playlists, filtered to the
|
||||
/// playlists carrying the tag marker. Tagging a song adds it to the tag's
|
||||
/// playlist; this screen is where tags are created, browsed, renamed, deleted.
|
||||
/// Tapping a tag reuses [PlaylistDetailScreen] (a tag *is* a playlist).
|
||||
class TagsScreen extends ConsumerWidget {
|
||||
const TagsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tags = ref.watch(tagsProvider);
|
||||
final controller = ref.read(playlistsProvider.notifier);
|
||||
final connected = ref.watch(subsonicClientProvider) != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
const Text('Tags', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'New tag',
|
||||
onPressed: connected ? () => _create(context, ref) : null,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(TimbreSpacing.lg),
|
||||
child: HairlinePanel(
|
||||
title: 'Tags',
|
||||
active: true,
|
||||
trailing: tags.isEmpty ? null : '(${tags.length})',
|
||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||
child: tags.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
connected
|
||||
? 'No tags yet. Tap + to create one, then tag songs\n'
|
||||
'from their ⋮ menu.'
|
||||
: 'Connect to a server to see tags.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: TimbreColors.dimmed),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: tags.length,
|
||||
itemBuilder: (context, i) {
|
||||
final t = tags[i];
|
||||
return _TagRow(
|
||||
name: t.name,
|
||||
subtitle: t.songCount != null
|
||||
? '${t.songCount} tracks'
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PlaylistDetailScreen(id: t.id),
|
||||
),
|
||||
),
|
||||
onRename: connected
|
||||
? () async {
|
||||
final name = await promptPlaylistName(context,
|
||||
title: 'Rename tag', initial: t.name);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
controller.rename(t.id, name);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onDelete: connected
|
||||
? () => _confirmDelete(context, controller, t)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _create(BuildContext context, WidgetRef ref) async {
|
||||
final name = await promptPlaylistName(context, title: 'New tag');
|
||||
if (name != null && name.isNotEmpty) {
|
||||
await ref.read(playlistsProvider.notifier).createTag(name);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, PlaylistsController controller, Playlist t) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: TimbreColors.surface,
|
||||
title: Text('Delete tag "${t.name}"?'),
|
||||
content: const Text(
|
||||
'This removes the tag from every song. The songs themselves are '
|
||||
'not deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) controller.delete(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagRow extends StatelessWidget {
|
||||
const _TagRow({
|
||||
required this.name,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
this.onRename,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.label_outline, size: 18, color: TimbreColors.dimmed),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: TimbreColors.foreground)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(
|
||||
color: TimbreColors.dimmed, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onRename != null || onDelete != null)
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert,
|
||||
size: 20, color: TimbreColors.dimmed),
|
||||
color: TimbreColors.surface,
|
||||
onSelected: (v) {
|
||||
if (v == 'rename') onRename?.call();
|
||||
if (v == 'delete') onDelete?.call();
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (onRename != null)
|
||||
const PopupMenuItem(value: 'rename', child: Text('Rename')),
|
||||
if (onDelete != null)
|
||||
const PopupMenuItem(value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,43 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show Color;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// User-tunable audio quality. Streaming and offline downloads have independent
|
||||
/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` +
|
||||
/// `format`; a rate of 0 means original / no transcode). These are global (not
|
||||
/// per-server) and persisted to a single atomic JSON file, following the
|
||||
/// write pattern in `library/library_index.dart`.
|
||||
/// Top-level Browse mode selector. Lives here (not in `state/providers.dart`)
|
||||
/// so [AppSettings] can persist the user's default; `providers.dart` re-exports
|
||||
/// it, so existing importers are unaffected.
|
||||
enum BrowseMode { artists, albums, tracks }
|
||||
|
||||
/// Search matching strategy. `discovery` returns the server's full `search3`
|
||||
/// result (broad — e.g. songs matched via their artist name). `standard`
|
||||
/// post-filters to items whose *own* name/title matches the query.
|
||||
enum SearchMode { discovery, standard }
|
||||
|
||||
/// A selectable static accent color offered by the theme override (TODO #1).
|
||||
class AccentChoice {
|
||||
const AccentChoice(this.name, this.color);
|
||||
final String name;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// Application-wide preferences: audio quality, theme accent, default browse
|
||||
/// view, and search mode. Streaming and offline downloads have independent
|
||||
/// quality knobs, both driven by Subsonic's `stream` transcode params
|
||||
/// (`maxBitRate` + `format`; a rate of 0 means original / no transcode). These
|
||||
/// are global (not per-server) and persisted to a single atomic JSON file,
|
||||
/// following the write pattern in `library/library_index.dart`.
|
||||
class AppSettings {
|
||||
const AppSettings({
|
||||
this.streamMaxBitRate = 0,
|
||||
this.downloadMaxBitRate = 0,
|
||||
this.downloadFormat,
|
||||
this.useStaticAccent = false,
|
||||
this.staticAccentColor = _defaultStaticAccent,
|
||||
this.defaultBrowseMode = BrowseMode.artists,
|
||||
this.searchMode = SearchMode.discovery,
|
||||
this.nowPlayingCassette = false,
|
||||
});
|
||||
|
||||
/// Cap for live streaming, in kbps. 0 = original / no transcode.
|
||||
|
|
@ -26,20 +50,67 @@ class AppSettings {
|
|||
/// the original file / let the server decide.
|
||||
final String? downloadFormat;
|
||||
|
||||
/// When true, ignore album-art-derived accent and use [staticAccentColor]
|
||||
/// as the sole accent color. When false (default), the accent tracks the
|
||||
/// currently-playing album art.
|
||||
final bool useStaticAccent;
|
||||
|
||||
/// The fixed accent used while [useStaticAccent] is on. One of
|
||||
/// [accentChoices]; defaults to Orange.
|
||||
final Color staticAccentColor;
|
||||
|
||||
/// Which sub-view the Browse tab opens on.
|
||||
final BrowseMode defaultBrowseMode;
|
||||
|
||||
/// Which search matching strategy to apply globally.
|
||||
final SearchMode searchMode;
|
||||
|
||||
/// When true, the Now Playing "art" view shows the animated cassette (cover
|
||||
/// art on the label, reels driven by playback) instead of the plain square
|
||||
/// album cover.
|
||||
final bool nowPlayingCassette;
|
||||
|
||||
/// Offered bitrate choices (kbps); 0 renders as "Original".
|
||||
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
|
||||
|
||||
/// Offered download containers; null renders as "Original".
|
||||
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac'];
|
||||
|
||||
/// Static accent palette offered when the override is enabled (TODO #1).
|
||||
static const List<AccentChoice> accentChoices = [
|
||||
AccentChoice('Red', Color(0xFFDF3535)),
|
||||
AccentChoice('Blue', Color(0xFF7177EA)),
|
||||
AccentChoice('Green', Color(0xFF10996B)),
|
||||
AccentChoice('Orange', Color(0xFFDF7E35)),
|
||||
AccentChoice('Yellow', Color(0xFFDFC535)),
|
||||
AccentChoice('Purple', Color(0xFF926CE9)),
|
||||
AccentChoice('Cream', Color(0xFFFFDB9E)),
|
||||
];
|
||||
|
||||
static const Color _defaultStaticAccent = Color(0xFFDF7E35); // Orange
|
||||
|
||||
static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
|
||||
static String formatLabel(String? f) => f ?? 'Original';
|
||||
static String browseModeLabel(BrowseMode m) => switch (m) {
|
||||
BrowseMode.artists => 'Artists',
|
||||
BrowseMode.albums => 'Albums',
|
||||
BrowseMode.tracks => 'Tracks',
|
||||
};
|
||||
static String searchModeLabel(SearchMode m) => switch (m) {
|
||||
SearchMode.standard => 'Standard',
|
||||
SearchMode.discovery => 'Discovery',
|
||||
};
|
||||
|
||||
AppSettings copyWith({
|
||||
int? streamMaxBitRate,
|
||||
int? downloadMaxBitRate,
|
||||
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
|
||||
Object? downloadFormat = _unset,
|
||||
bool? useStaticAccent,
|
||||
Color? staticAccentColor,
|
||||
BrowseMode? defaultBrowseMode,
|
||||
SearchMode? searchMode,
|
||||
bool? nowPlayingCassette,
|
||||
}) =>
|
||||
AppSettings(
|
||||
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
|
||||
|
|
@ -47,6 +118,11 @@ class AppSettings {
|
|||
downloadFormat: identical(downloadFormat, _unset)
|
||||
? this.downloadFormat
|
||||
: downloadFormat as String?,
|
||||
useStaticAccent: useStaticAccent ?? this.useStaticAccent,
|
||||
staticAccentColor: staticAccentColor ?? this.staticAccentColor,
|
||||
defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode,
|
||||
searchMode: searchMode ?? this.searchMode,
|
||||
nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette,
|
||||
);
|
||||
|
||||
static const Object _unset = Object();
|
||||
|
|
@ -55,13 +131,49 @@ class AppSettings {
|
|||
'streamMaxBitRate': streamMaxBitRate,
|
||||
'downloadMaxBitRate': downloadMaxBitRate,
|
||||
if (downloadFormat != null) 'downloadFormat': downloadFormat,
|
||||
'useStaticAccent': useStaticAccent,
|
||||
'staticAccentColor': _hexOf(staticAccentColor),
|
||||
'defaultBrowseMode': defaultBrowseMode.name,
|
||||
'searchMode': searchMode.name,
|
||||
'nowPlayingCassette': nowPlayingCassette,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
|
||||
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
|
||||
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
|
||||
downloadFormat: j['downloadFormat'] as String?,
|
||||
useStaticAccent: j['useStaticAccent'] as bool? ?? false,
|
||||
staticAccentColor:
|
||||
_colorOf(j['staticAccentColor'] as String?) ?? _defaultStaticAccent,
|
||||
defaultBrowseMode:
|
||||
_enumByName(BrowseMode.values, j['defaultBrowseMode'] as String?) ??
|
||||
BrowseMode.artists,
|
||||
searchMode:
|
||||
_enumByName(SearchMode.values, j['searchMode'] as String?) ??
|
||||
SearchMode.discovery,
|
||||
nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false,
|
||||
);
|
||||
|
||||
/// Serialize a color to a `#RRGGBB` hex string.
|
||||
static String _hexOf(Color c) =>
|
||||
'#${(c.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}';
|
||||
|
||||
/// Parse a `#RRGGBB` (or `RRGGBB`) hex string, or null if unparseable.
|
||||
static Color? _colorOf(String? hex) {
|
||||
if (hex == null) return null;
|
||||
final h = hex.startsWith('#') ? hex.substring(1) : hex;
|
||||
final v = int.tryParse(h, radix: 16);
|
||||
if (v == null || h.length != 6) return null;
|
||||
return Color(0xFF000000 | v);
|
||||
}
|
||||
|
||||
static T? _enumByName<T extends Enum>(List<T> values, String? name) {
|
||||
if (name == null) return null;
|
||||
for (final v in values) {
|
||||
if (v.name == name) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads settings on launch and persists every change (atomic temp+rename).
|
||||
|
|
@ -102,6 +214,31 @@ class SettingsController extends StateNotifier<AppSettings> {
|
|||
_persist();
|
||||
}
|
||||
|
||||
void setUseStaticAccent(bool value) {
|
||||
state = state.copyWith(useStaticAccent: value);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setStaticAccentColor(Color color) {
|
||||
state = state.copyWith(staticAccentColor: color);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setDefaultBrowseMode(BrowseMode mode) {
|
||||
state = state.copyWith(defaultBrowseMode: mode);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setSearchMode(SearchMode mode) {
|
||||
state = state.copyWith(searchMode: mode);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setNowPlayingCassette(bool value) {
|
||||
state = state.copyWith(nowPlayingCassette: value);
|
||||
_persist();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
final file = _file;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import '../screens/connect_sheet.dart';
|
|||
import '../screens/home_screen.dart';
|
||||
import '../screens/now_playing_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
import '../settings/settings_store.dart';
|
||||
import '../state/providers.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/mini_player.dart';
|
||||
|
||||
/// Top-level shell: the three Ratune tabs (Home / Browse / Now Playing) with a
|
||||
/// Top-level shell: the three Timbre tabs (Home / Browse / Now Playing) with a
|
||||
/// bottom tab bar and a status bar, mirroring the terminal layout.
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
|
@ -29,12 +30,39 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||
final _homeNavKey = GlobalKey<NavigatorState>();
|
||||
final _browseNavKey = GlobalKey<NavigatorState>();
|
||||
|
||||
// Seed the Browse view from the persisted default exactly once, when settings
|
||||
// finish loading. Guarded so a later change to the default (or a manual browse
|
||||
// selection) is never overridden mid-session.
|
||||
bool _browseSeeded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual<AppSettings>(settingsProvider, (prev, next) {
|
||||
if (_browseSeeded) return;
|
||||
_browseSeeded = true;
|
||||
ref.read(browseModeProvider.notifier).state = next.defaultBrowseMode;
|
||||
});
|
||||
}
|
||||
|
||||
GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) {
|
||||
0 => _homeNavKey,
|
||||
1 => _browseNavKey,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Tap on the bottom tab bar. Selecting a different tab just switches; tapping
|
||||
/// the already-active tab resets it to its root — popping the nested detail
|
||||
/// stack (e.g. Album → Browse home) so the tab button doubles as "go back to
|
||||
/// the top", instead of being a no-op.
|
||||
void _selectTab(int tapped, int current) {
|
||||
if (tapped == current) {
|
||||
_navKeyForTab(tapped)?.currentState?.popUntil((r) => r.isFirst);
|
||||
} else {
|
||||
ref.read(selectedTabProvider.notifier).state = tapped;
|
||||
}
|
||||
}
|
||||
|
||||
/// Android system-back: pop the active tab's nested stack first, then fall
|
||||
/// back to Home, and only exit the app from the Home root.
|
||||
void _handleBack(int tab) {
|
||||
|
|
@ -88,8 +116,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||
_TabBar(
|
||||
tabs: _tabs,
|
||||
index: index,
|
||||
onSelect: (i) =>
|
||||
ref.read(selectedTabProvider.notifier).state = i,
|
||||
onSelect: (i) => _selectTab(i, index),
|
||||
),
|
||||
const _StatusBar(),
|
||||
],
|
||||
|
|
@ -201,10 +228,10 @@ class _StatusBar extends ConsumerWidget {
|
|||
color: TimbreColors.surface,
|
||||
child: Row(
|
||||
children: [
|
||||
// Left: connection status — tap to open the connect sheet.
|
||||
// Left: connection status — tap to open the server switcher.
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
onTap: () => showServerSheet(context),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/widgets.dart' show NetworkImage;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -17,6 +14,11 @@ import '../theme/accent.dart';
|
|||
import '../theme/accent_extract.dart';
|
||||
import 'favorites.dart';
|
||||
|
||||
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
|
||||
// persisted) but historically lived here — re-export so existing importers of
|
||||
// this file keep resolving them.
|
||||
export '../settings/settings_store.dart' show BrowseMode, SearchMode;
|
||||
|
||||
// ---- Connection ---------------------------------------------------------
|
||||
|
||||
enum ConnStatus { disconnected, connecting, online, error }
|
||||
|
|
@ -27,6 +29,7 @@ class ConnectionState {
|
|||
this.client,
|
||||
this.credentials,
|
||||
this.error,
|
||||
this.servers = const [],
|
||||
});
|
||||
|
||||
final ConnStatus status;
|
||||
|
|
@ -34,14 +37,32 @@ class ConnectionState {
|
|||
final SubsonicCredentials? credentials;
|
||||
final String? error;
|
||||
|
||||
/// All saved servers (one may be the active [credentials]). Drives the
|
||||
/// server switcher and the Settings "Servers" section.
|
||||
final List<SubsonicCredentials> servers;
|
||||
|
||||
bool get isOnline => status == ConnStatus.online;
|
||||
|
||||
/// Id of the active server, or null when disconnected with none configured.
|
||||
String? get activeId => credentials?.id;
|
||||
|
||||
/// Only the saved-servers list changes; connection fields are preserved.
|
||||
ConnectionState copyWith({List<SubsonicCredentials>? servers}) =>
|
||||
ConnectionState(
|
||||
status: status,
|
||||
client: client,
|
||||
credentials: credentials,
|
||||
error: error,
|
||||
servers: servers ?? this.servers,
|
||||
);
|
||||
}
|
||||
|
||||
final credentialStoreProvider =
|
||||
Provider<CredentialStore>((_) => CredentialStore());
|
||||
|
||||
/// Owns the active server connection: builds the client, pings, persists
|
||||
/// credentials, and auto-restores on launch.
|
||||
/// Owns the active server connection and the list of saved servers: builds the
|
||||
/// client, pings, persists credentials, auto-restores on launch, and switches
|
||||
/// between servers (one active at a time — TODO #2).
|
||||
class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
ConnectionController(this._store)
|
||||
: super(const ConnectionState(status: ConnStatus.disconnected)) {
|
||||
|
|
@ -49,13 +70,21 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
}
|
||||
|
||||
final CredentialStore _store;
|
||||
List<SubsonicCredentials> _servers = const [];
|
||||
String? _activeId;
|
||||
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
final creds = await _store.load();
|
||||
if (creds != null) {
|
||||
await connect(creds, persist: false);
|
||||
}
|
||||
_servers = await _store.loadAll();
|
||||
_activeId = await _store.loadActiveId();
|
||||
state = state.copyWith(servers: _servers);
|
||||
if (_servers.isEmpty) return;
|
||||
final active = _servers.firstWhere(
|
||||
(s) => s.id == _activeId,
|
||||
orElse: () => _servers.first,
|
||||
);
|
||||
_activeId = active.id;
|
||||
await connect(active, persist: false);
|
||||
} catch (_) {
|
||||
// No secure-storage backend available (e.g. Linux without a running
|
||||
// keyring daemon) — start disconnected rather than crashing.
|
||||
|
|
@ -63,7 +92,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
}
|
||||
|
||||
Future<bool> connect(SubsonicCredentials creds, {bool persist = true}) async {
|
||||
state = const ConnectionState(status: ConnStatus.connecting);
|
||||
state = ConnectionState(status: ConnStatus.connecting, servers: _servers);
|
||||
final client = SubsonicClient(
|
||||
baseUrl: creds.url,
|
||||
username: creds.username,
|
||||
|
|
@ -79,20 +108,25 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
status: ConnStatus.error,
|
||||
credentials: creds,
|
||||
error: 'Could not reach the server.',
|
||||
servers: _servers,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Persistence is best-effort: a locked/absent secure-storage backend
|
||||
// (e.g. a locked Linux keyring) must not stop this session connecting.
|
||||
// A successful connect saves the server and makes it active.
|
||||
if (persist) {
|
||||
try {
|
||||
await _store.save(creds);
|
||||
await _addOrUpdate(creds, makeActive: true);
|
||||
} catch (_) {}
|
||||
} else {
|
||||
_activeId = creds.id;
|
||||
}
|
||||
state = ConnectionState(
|
||||
status: ConnStatus.online,
|
||||
client: client,
|
||||
credentials: creds,
|
||||
servers: _servers,
|
||||
);
|
||||
return true;
|
||||
} on SubsonicError catch (e) {
|
||||
|
|
@ -102,6 +136,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
status: ConnStatus.error,
|
||||
credentials: e.isAuthFailure ? null : creds,
|
||||
error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
|
||||
servers: _servers,
|
||||
);
|
||||
return false;
|
||||
} catch (e) {
|
||||
|
|
@ -109,13 +144,85 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||
status: ConnStatus.error,
|
||||
credentials: creds,
|
||||
error: e.toString(),
|
||||
servers: _servers,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active server to [id] and connect. No password re-entry — saved
|
||||
/// servers keep their credentials. Per-server downloads/playlists reload
|
||||
/// automatically via [serverKeyProvider] listeners.
|
||||
Future<bool> switchTo(String id) async {
|
||||
SubsonicCredentials? creds;
|
||||
for (final s in _servers) {
|
||||
if (s.id == id) {
|
||||
creds = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (creds == null) return false;
|
||||
_activeId = id;
|
||||
try {
|
||||
await _store.saveAll(_servers, _activeId);
|
||||
} catch (_) {}
|
||||
return connect(creds, persist: false);
|
||||
}
|
||||
|
||||
/// Add or update a saved server *without* connecting (used by the add/edit
|
||||
/// form). Updating an entry with the same [SubsonicCredentials.id] overwrites
|
||||
/// its password/alias.
|
||||
Future<void> saveServer(SubsonicCredentials creds) async {
|
||||
await _addOrUpdate(creds, makeActive: false);
|
||||
state = state.copyWith(servers: _servers);
|
||||
}
|
||||
|
||||
/// Remove a saved server. If it was the active one, switch to another saved
|
||||
/// server (or go disconnected when none remain).
|
||||
Future<void> removeServer(String id) async {
|
||||
_servers = _servers.where((s) => s.id != id).toList();
|
||||
final removedActive = id == _activeId;
|
||||
if (removedActive) {
|
||||
_activeId = _servers.isEmpty ? null : _servers.first.id;
|
||||
}
|
||||
try {
|
||||
await _store.saveAll(_servers, _activeId);
|
||||
} catch (_) {}
|
||||
if (removedActive) {
|
||||
if (_servers.isNotEmpty) {
|
||||
await connect(_servers.first, persist: false);
|
||||
} else {
|
||||
state = ConnectionState(
|
||||
status: ConnStatus.disconnected,
|
||||
servers: _servers,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(servers: _servers);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addOrUpdate(SubsonicCredentials creds,
|
||||
{required bool makeActive}) async {
|
||||
final idx = _servers.indexWhere((s) => s.id == creds.id);
|
||||
final next = [..._servers];
|
||||
if (idx >= 0) {
|
||||
next[idx] = creds;
|
||||
} else {
|
||||
next.add(creds);
|
||||
}
|
||||
_servers = next;
|
||||
if (makeActive) _activeId = creds.id;
|
||||
await _store.saveAll(_servers, _activeId);
|
||||
}
|
||||
|
||||
/// Forget every saved server and drop the connection.
|
||||
Future<void> disconnect() async {
|
||||
await _store.clear();
|
||||
_servers = const [];
|
||||
_activeId = null;
|
||||
try {
|
||||
await _store.clear();
|
||||
} catch (_) {}
|
||||
state = const ConnectionState(status: ConnStatus.disconnected);
|
||||
}
|
||||
}
|
||||
|
|
@ -135,12 +242,7 @@ final subsonicClientProvider = Provider<SubsonicClient?>(
|
|||
/// retains credentials). Null when no server has ever been configured. Used to
|
||||
/// scope on-disk downloads/playlists to the server they belong to.
|
||||
final serverKeyProvider = Provider<String?>((ref) {
|
||||
final creds = ref.watch(connectionProvider).credentials;
|
||||
if (creds == null) return null;
|
||||
final base = creds.url.endsWith('/')
|
||||
? creds.url.substring(0, creds.url.length - 1)
|
||||
: creds.url;
|
||||
return md5.convert(utf8.encode('$base|${creds.username}')).toString();
|
||||
return ref.watch(connectionProvider).credentials?.id;
|
||||
});
|
||||
|
||||
// ---- Navigation / browse mode -------------------------------------------
|
||||
|
|
@ -152,9 +254,8 @@ final selectedTabProvider = StateProvider<int>((_) => 0);
|
|||
/// Index of the Now Playing tab in the shell's tab list.
|
||||
const int nowPlayingTabIndex = 2;
|
||||
|
||||
/// Top-level Browse mode selector.
|
||||
enum BrowseMode { artists, albums, tracks }
|
||||
|
||||
/// Top-level Browse mode selector. Seeded once from the persisted
|
||||
/// `AppSettings.defaultBrowseMode` by `AppShell`; manual selection then wins.
|
||||
final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
|
||||
|
||||
// ---- Library ------------------------------------------------------------
|
||||
|
|
@ -227,12 +328,30 @@ final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
|
|||
final searchProvider =
|
||||
FutureProvider.family<SearchResult3, String>((ref, query) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null || query.trim().isEmpty) {
|
||||
final q = query.trim();
|
||||
if (client == null || q.isEmpty) {
|
||||
return SearchResult3(artists: const [], albums: const [], songs: const []);
|
||||
}
|
||||
return client.search3(query.trim());
|
||||
final result = await client.search3(q);
|
||||
// "Standard" trims the server's broad matches down to name/title hits;
|
||||
// "Discovery" (default) returns the server result unchanged.
|
||||
final mode = ref.watch(settingsProvider).searchMode;
|
||||
return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result;
|
||||
});
|
||||
|
||||
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
|
||||
/// (case-insensitive) — dropping songs that only matched via their artist or
|
||||
/// album fields. Backs the "Standard" search mode (TODO #4).
|
||||
SearchResult3 filterSearchToStandard(SearchResult3 r, String query) {
|
||||
final q = query.toLowerCase();
|
||||
bool has(String? s) => s != null && s.toLowerCase().contains(q);
|
||||
return SearchResult3(
|
||||
artists: r.artists.where((a) => has(a.name)).toList(),
|
||||
albums: r.albums.where((a) => has(a.name)).toList(),
|
||||
songs: r.songs.where((s) => has(s.title)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- History (Home tab) -------------------------------------------------
|
||||
|
||||
final playHistoryProvider =
|
||||
|
|
@ -318,6 +437,20 @@ final playlistsProvider =
|
|||
return controller;
|
||||
});
|
||||
|
||||
/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the
|
||||
/// Playlists screen and the "add to playlist" sheet.
|
||||
final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
|
||||
.watch(playlistsProvider)
|
||||
.playlists
|
||||
.where((p) => !isTagPlaylist(p))
|
||||
.toList());
|
||||
|
||||
/// Tags — playlists carrying the tag comment marker. Backs the Tags screen and
|
||||
/// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the
|
||||
/// partition differs.
|
||||
final tagsProvider = Provider<List<Playlist>>(
|
||||
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList());
|
||||
|
||||
// ---- Playback -----------------------------------------------------------
|
||||
|
||||
final playbackProvider =
|
||||
|
|
@ -345,6 +478,8 @@ final playbackProvider =
|
|||
streamUriFor: streamUriFor,
|
||||
coverArtUriFor: coverArtUriFor,
|
||||
onArt: (artUri) async {
|
||||
// Skip extraction entirely when the user has pinned a static accent.
|
||||
if (ref.read(settingsProvider).useStaticAccent) return;
|
||||
final color = await extractAccent(NetworkImage(artUri.toString()));
|
||||
if (color != null) ref.read(accentProvider.notifier).set(color);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// A Subsonic server connection. The password is only ever held in memory and
|
||||
/// in the platform secure store — never in plain app storage (mirrors Ratune's
|
||||
/// in the platform secure store — never in plain app storage (mirrors Timbre's
|
||||
/// keyring-first secret handling).
|
||||
class SubsonicCredentials {
|
||||
const SubsonicCredentials({
|
||||
|
|
@ -15,50 +18,121 @@ class SubsonicCredentials {
|
|||
final String username;
|
||||
final String password;
|
||||
|
||||
/// Optional display label shown instead of the raw URL (Ratune `[server].alias`).
|
||||
/// Optional display label shown instead of the raw URL (Timbre `[server].alias`).
|
||||
final String? alias;
|
||||
|
||||
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
|
||||
|
||||
/// Stable identity for a saved server: `md5(baseUrl|username)`, matching the
|
||||
/// cache key in `serverKeyProvider` so per-server data lines up. Two entries
|
||||
/// with the same URL + username are the "same" server (different password /
|
||||
/// alias overwrites it).
|
||||
String get id {
|
||||
final base = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
return md5.convert(utf8.encode('$base|$username')).toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (alias != null && alias!.isNotEmpty) 'alias': alias,
|
||||
};
|
||||
|
||||
factory SubsonicCredentials.fromJson(Map<String, dynamic> j) {
|
||||
final alias = j['alias'] as String?;
|
||||
return SubsonicCredentials(
|
||||
url: j['url'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
password: j['password'] as String? ?? '',
|
||||
alias: (alias == null || alias.isEmpty) ? null : alias,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [SubsonicCredentials] to the platform secure store
|
||||
/// (Keychain / Keystore / libsecret).
|
||||
/// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one
|
||||
/// to the platform secure store (Keychain / Keystore / libsecret). Legacy
|
||||
/// single-server installs are migrated into the list on first load.
|
||||
class CredentialStore {
|
||||
CredentialStore([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
/// JSON array of all saved servers.
|
||||
static const _kServers = 'subsonic_servers';
|
||||
|
||||
/// Id ([SubsonicCredentials.id]) of the active server.
|
||||
static const _kActive = 'subsonic_active';
|
||||
|
||||
// Legacy single-server keys, read once for migration then removed.
|
||||
static const _kUrl = 'subsonic_url';
|
||||
static const _kUser = 'subsonic_username';
|
||||
static const _kPass = 'subsonic_password';
|
||||
static const _kAlias = 'subsonic_alias';
|
||||
|
||||
Future<void> save(SubsonicCredentials creds) async {
|
||||
await _storage.write(key: _kUrl, value: creds.url);
|
||||
await _storage.write(key: _kUser, value: creds.username);
|
||||
await _storage.write(key: _kPass, value: creds.password);
|
||||
await _storage.write(key: _kAlias, value: creds.alias ?? '');
|
||||
/// All saved servers. Empty if none configured. Migrates a legacy
|
||||
/// single-server install into the new list format on first call.
|
||||
Future<List<SubsonicCredentials>> loadAll() async {
|
||||
final raw = await _storage.read(key: _kServers);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List)
|
||||
.whereType<Map>()
|
||||
.map((m) => SubsonicCredentials.fromJson(m.cast<String, dynamic>()))
|
||||
.where((c) => c.url.isNotEmpty && c.username.isNotEmpty)
|
||||
.toList();
|
||||
return list;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
// Migrate a legacy single-server install, if present.
|
||||
final migrated = await _migrateLegacy();
|
||||
return migrated == null ? const [] : [migrated];
|
||||
}
|
||||
|
||||
Future<SubsonicCredentials?> load() async {
|
||||
/// Id of the active server, or null.
|
||||
Future<String?> loadActiveId() => _storage.read(key: _kActive);
|
||||
|
||||
/// Overwrite the saved list and active pointer atomically-ish (two writes).
|
||||
Future<void> saveAll(
|
||||
List<SubsonicCredentials> servers, String? activeId) async {
|
||||
await _storage.write(
|
||||
key: _kServers,
|
||||
value: jsonEncode([for (final s in servers) s.toJson()]),
|
||||
);
|
||||
if (activeId == null) {
|
||||
await _storage.delete(key: _kActive);
|
||||
} else {
|
||||
await _storage.write(key: _kActive, value: activeId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SubsonicCredentials?> _migrateLegacy() async {
|
||||
final url = await _storage.read(key: _kUrl);
|
||||
final user = await _storage.read(key: _kUser);
|
||||
final pass = await _storage.read(key: _kPass);
|
||||
if (url == null || user == null || pass == null) return null;
|
||||
final alias = await _storage.read(key: _kAlias);
|
||||
return SubsonicCredentials(
|
||||
final creds = SubsonicCredentials(
|
||||
url: url,
|
||||
username: user,
|
||||
password: pass,
|
||||
alias: (alias == null || alias.isEmpty) ? null : alias,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
// Write the new format, point at it, then drop the legacy keys.
|
||||
await saveAll([creds], creds.id);
|
||||
await _storage.delete(key: _kUrl);
|
||||
await _storage.delete(key: _kUser);
|
||||
await _storage.delete(key: _kPass);
|
||||
await _storage.delete(key: _kAlias);
|
||||
return creds;
|
||||
}
|
||||
|
||||
/// Remove all saved servers and the active pointer.
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _kServers);
|
||||
await _storage.delete(key: _kActive);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/// Defensive JSON helpers for the Subsonic protocol.
|
||||
///
|
||||
/// Subsonic servers are inconsistent (Ratune handles the same quirks in
|
||||
/// `ratune-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
|
||||
/// Subsonic servers are inconsistent (Timbre handles the same quirks in
|
||||
/// `timbre-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
|
||||
/// * a field that is sometimes a single object and sometimes an array
|
||||
/// * IDs that are sometimes strings and sometimes integers
|
||||
/// Every accessor below tolerates nulls and mixed types rather than throwing.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'json_helpers.dart';
|
||||
|
||||
/// Subsonic domain models, ported from `ratune-subsonic/src/models.rs`.
|
||||
/// Subsonic domain models, ported from `timbre-subsonic/src/models.rs`.
|
||||
/// Nearly every field is nullable — servers omit fields freely. All parsing
|
||||
/// goes through the defensive helpers in `json_helpers.dart`.
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ class SearchResult3 {
|
|||
}
|
||||
|
||||
/// A playlist summary from `getPlaylists` (no tracks), ported from
|
||||
/// `ratune-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
|
||||
/// `timbre-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
|
||||
/// (`playlists/playlists.dart`).
|
||||
class Playlist {
|
||||
Playlist({
|
||||
|
|
@ -253,6 +253,7 @@ class Playlist {
|
|||
this.owner,
|
||||
this.public,
|
||||
this.coverArt,
|
||||
this.comment,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
|
@ -263,6 +264,10 @@ class Playlist {
|
|||
final bool? public;
|
||||
final String? coverArt;
|
||||
|
||||
/// Free-text playlist comment. Timbre stores a marker here to flag a playlist
|
||||
/// as a "tag" (see `playlists.dart`'s `kTagMarker`); otherwise usually null.
|
||||
final String? comment;
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: asString(j['id']) ?? '',
|
||||
name: asString(j['name']) ?? 'Untitled',
|
||||
|
|
@ -271,6 +276,7 @@ class Playlist {
|
|||
owner: asString(j['owner']),
|
||||
public: j['public'] is bool ? j['public'] as bool : null,
|
||||
coverArt: asString(j['coverArt']),
|
||||
comment: asString(j['comment']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
|
@ -281,6 +287,7 @@ class Playlist {
|
|||
if (owner != null) 'owner': owner,
|
||||
if (public != null) 'public': public,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
if (comment != null) 'comment': comment,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +300,7 @@ class PlaylistDetail {
|
|||
this.songCount,
|
||||
this.duration,
|
||||
this.coverArt,
|
||||
this.comment,
|
||||
this.songs = const [],
|
||||
});
|
||||
|
||||
|
|
@ -301,6 +309,9 @@ class PlaylistDetail {
|
|||
final int? songCount;
|
||||
final int? duration;
|
||||
final String? coverArt;
|
||||
|
||||
/// See [Playlist.comment] — carries the Timbre tag marker when present.
|
||||
final String? comment;
|
||||
final List<Song> songs;
|
||||
|
||||
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
|
||||
|
|
@ -309,6 +320,7 @@ class PlaylistDetail {
|
|||
songCount: asInt(j['songCount']),
|
||||
duration: asInt(j['duration']),
|
||||
coverArt: asString(j['coverArt']),
|
||||
comment: asString(j['comment']),
|
||||
songs: oneOrMany(j['entry'], Song.fromJson),
|
||||
);
|
||||
|
||||
|
|
@ -318,6 +330,7 @@ class PlaylistDetail {
|
|||
if (songCount != null) 'songCount': songCount,
|
||||
if (duration != null) 'duration': duration,
|
||||
if (coverArt != null) 'coverArt': coverArt,
|
||||
if (comment != null) 'comment': comment,
|
||||
'entry': songs.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
|
||||
|
|
@ -327,5 +340,6 @@ class PlaylistDetail {
|
|||
songCount: songCount ?? songs.length,
|
||||
duration: duration,
|
||||
coverArt: coverArt,
|
||||
comment: comment,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import 'json_helpers.dart';
|
|||
import 'models.dart';
|
||||
|
||||
/// A Subsonic API error (server returned `status: "failed"`), ported from
|
||||
/// `ratune-subsonic/src/error.rs`. Code 40 is the auth-failure code.
|
||||
/// `timbre-subsonic/src/error.rs`. Code 40 is the auth-failure code.
|
||||
class SubsonicError implements Exception {
|
||||
SubsonicError(this.code, this.message);
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ class SubsonicError implements Exception {
|
|||
String toString() => 'SubsonicError($code): $message';
|
||||
}
|
||||
|
||||
/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`.
|
||||
/// Subsonic HTTP client, ported from `timbre-subsonic/src/client.rs`.
|
||||
///
|
||||
/// Auth is the classic token scheme: a fresh random salt per request and
|
||||
/// `token = MD5(password + salt)`, sent as query params
|
||||
|
|
@ -57,7 +57,7 @@ class SubsonicClient {
|
|||
url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
|
||||
/// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in
|
||||
/// for the platform CSPRNG; Ratune uses a weaker LCG here).
|
||||
/// for the platform CSPRNG; Timbre uses a weaker LCG here).
|
||||
String _makeSalt([int length = 12]) {
|
||||
final rng = Random.secure();
|
||||
return List.generate(
|
||||
|
|
@ -190,7 +190,7 @@ class SubsonicClient {
|
|||
}
|
||||
|
||||
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId)
|
||||
/// exactly like Ratune's `set_starred` (`client.rs`).
|
||||
/// exactly like Timbre's `set_starred` (`client.rs`).
|
||||
Future<void> setStarred({
|
||||
required bool starred,
|
||||
String? songId,
|
||||
|
|
@ -218,7 +218,7 @@ class SubsonicClient {
|
|||
}
|
||||
|
||||
// ---- Playlists ----------------------------------------------------------
|
||||
// Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through
|
||||
// Ported from `timbre-subsonic/src/client.rs`. Track mutations all go through
|
||||
// `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`.
|
||||
|
||||
/// `getPlaylists` — every playlist visible to the authenticated user.
|
||||
|
|
@ -266,6 +266,13 @@ class SubsonicClient {
|
|||
await _get('updatePlaylist', {'playlistId': playlistId, 'name': name});
|
||||
}
|
||||
|
||||
/// `updatePlaylist` + `comment` — set a playlist's comment. Timbre uses this
|
||||
/// to mark a playlist as a tag (`createPlaylist` can't set a comment inline).
|
||||
Future<void> setPlaylistComment(String playlistId, String comment) async {
|
||||
await _get(
|
||||
'updatePlaylist', {'playlistId': playlistId, 'comment': comment});
|
||||
}
|
||||
|
||||
/// `deletePlaylist` — delete a playlist by id.
|
||||
Future<void> deletePlaylist(String id) async {
|
||||
await _get('deletePlaylist', {'id': id});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
|
||||
import 'tokens.dart';
|
||||
|
||||
/// Holds the current accent color — the "hero" of Ratune's aesthetic.
|
||||
/// Holds the current accent color — the "hero" of Timbre's aesthetic.
|
||||
///
|
||||
/// In the `dynamic` theme this is extracted from the playing track's album
|
||||
/// art and boosted for readability in OKLab space (Ratune `color.rs:11-64`),
|
||||
/// art and boosted for readability in OKLab space (Timbre `color.rs:11-64`),
|
||||
/// then interpolated toward over ~400ms. Phase 1 just exposes the default and
|
||||
/// a setter; the extraction + animation land in Phase 2 with playback.
|
||||
class AccentNotifier extends StateNotifier<Color> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
/// Extract a lively accent color from album art, mirroring Ratune's
|
||||
/// Extract a lively accent color from album art, mirroring Timbre's
|
||||
/// art-driven `dynamic` theme (`color.rs`): pick the most vibrant swatch, then
|
||||
/// nudge it into a readable lightness/saturation band. Ratune does the boost in
|
||||
/// nudge it into a readable lightness/saturation band. Timbre does the boost in
|
||||
/// OKLab; this HSL approximation is close enough for now (OKLab is a later
|
||||
/// refinement noted in the roadmap).
|
||||
Future<Color?> extractAccent(ImageProvider image) async {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import 'package:google_fonts/google_fonts.dart';
|
|||
|
||||
import 'tokens.dart';
|
||||
|
||||
/// Builds the app [ThemeData] from the Ratune tokens.
|
||||
/// Builds the app [ThemeData] from the Timbre tokens.
|
||||
///
|
||||
/// Monospace type is core to the identity — every surface uses JetBrains Mono
|
||||
/// (a close analog to the terminal fonts in Ratune's screenshots). The [accent]
|
||||
/// (a close analog to the terminal fonts in Timbre's screenshots). The [accent]
|
||||
/// is passed in so the theme rebuilds when album-art extraction changes it.
|
||||
ThemeData buildTimbreTheme(Color accent) {
|
||||
final mono = GoogleFonts.jetBrainsMonoTextTheme(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Design tokens mirroring Ratune's `dynamic`/`static` theme defaults
|
||||
/// (see ratune `docs/sample-config.toml`, `theme.rs`, `color.rs`).
|
||||
/// Design tokens mirroring Timbre's `dynamic`/`static` theme defaults
|
||||
/// (see timbre `docs/sample-config.toml`, `theme.rs`, `color.rs`).
|
||||
///
|
||||
/// The palette is intentionally near-black and low-contrast; the *accent*
|
||||
/// is the one lively color and, in the `dynamic` theme, is extracted live
|
||||
|
|
@ -25,7 +25,7 @@ class TimbreColors {
|
|||
static const Color border = Color(0xFF252525);
|
||||
|
||||
/// Hairline borders when a pane is focused — `border_active`.
|
||||
/// Ratune tints this toward the accent; we start with a lighter grey and
|
||||
/// Timbre tints this toward the accent; we start with a lighter grey and
|
||||
/// swap in the accent at runtime once art extraction lands.
|
||||
static const Color borderActive = Color(0xFF3A3A3A);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// The segmented block progress bar from Ratune's now-playing strip
|
||||
/// The segmented block progress bar from Timbre's now-playing strip
|
||||
/// (`progress_style = "██░"`). Discrete cells: filled cells use the accent,
|
||||
/// empty cells the border grey, with hairline gaps between them.
|
||||
class BlockProgressBar extends StatelessWidget {
|
||||
|
|
|
|||
252
lib/widgets/cassette_view.dart
Normal file
252
lib/widgets/cassette_view.dart
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart' show Ticker;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../state/providers.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Animated cassette for the Now Playing screen. Composites, in the shell's
|
||||
/// `469×298` coordinate space, from back to front:
|
||||
/// 1. the cover art on the label (the big inner body panel, cropped to fill),
|
||||
/// 2. the two tape reels — a dark "well" plus the winding tape,
|
||||
/// 3. the two spindle cogs, rotating while the track plays,
|
||||
/// 4. the static shell SVG on top, which frames everything — the reels and
|
||||
/// cogs show through its circular reel holes, so they read as sitting
|
||||
/// *inside* the cassette rather than pasted over it.
|
||||
///
|
||||
/// The reels are modelled on a real compact cassette: tape moves at a constant
|
||||
/// *linear* speed, so a reel's wound radius scales with √(remaining length) and
|
||||
/// its angular speed with 1/radius (the fuller reel turns slower). See
|
||||
/// [_CassetteConfig].
|
||||
class CassetteView extends ConsumerStatefulWidget {
|
||||
const CassetteView({super.key, required this.artUri});
|
||||
|
||||
/// Current track's cover-art URL (already sized by the caller), or null.
|
||||
final String? artUri;
|
||||
|
||||
@override
|
||||
ConsumerState<CassetteView> createState() => _CassetteViewState();
|
||||
}
|
||||
|
||||
class _CassetteViewState extends ConsumerState<CassetteView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final Ticker _ticker;
|
||||
Duration _last = Duration.zero;
|
||||
final _model = _CassetteModel();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ticker = createTicker(_onTick)..start();
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
|
||||
_last = elapsed;
|
||||
if (dt <= 0) return;
|
||||
// Read (not watch) inside the ticker: the model drives repaints itself, and
|
||||
// watching here would rebuild the whole widget every position tick.
|
||||
final s = ref.read(playbackProvider);
|
||||
_model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const cfg = _CassetteConfig.instance;
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: cfg.viewBox.width / cfg.viewBox.height,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: cfg.viewBox.width,
|
||||
height: cfg.viewBox.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 1. Cover art on the label — the big inner panel. The shell (on
|
||||
// top) is transparent there, so the art shows as the "label".
|
||||
Positioned.fromRect(
|
||||
rect: cfg.labelRect,
|
||||
child: ClipRect(child: _LabelArt(artUri: widget.artUri)),
|
||||
),
|
||||
// 2. Tape reels (well + winding tape). Repaints off the model.
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _TapePainter(_model)),
|
||||
),
|
||||
// 3. Spinning cogs — one SvgPicture each, rotated by an
|
||||
// AnimatedBuilder so only the transform rebuilds per frame.
|
||||
_spindle(cfg.leftReel, () => _model.angleLeft),
|
||||
_spindle(cfg.rightReel, () => _model.angleRight),
|
||||
// 4. Static shell on top: frames the reels through its holes.
|
||||
Positioned.fill(
|
||||
child: SvgPicture.asset(
|
||||
'assets/casette/casette_shell.svg',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _spindle(Offset center, double Function() angle) {
|
||||
const size = _CassetteConfig.spindleSize;
|
||||
// Built once; AnimatedBuilder rotates this cached child rather than
|
||||
// re-inflating the SVG every frame.
|
||||
final cog = SvgPicture.asset(
|
||||
'assets/casette/spindle.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
);
|
||||
return Positioned(
|
||||
left: center.dx - size / 2,
|
||||
top: center.dy - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
child: AnimatedBuilder(
|
||||
animation: _model,
|
||||
child: cog,
|
||||
builder: (_, child) =>
|
||||
Transform.rotate(angle: angle(), child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cover art (or a neutral fallback) for the label region.
|
||||
class _LabelArt extends StatelessWidget {
|
||||
const _LabelArt({required this.artUri});
|
||||
|
||||
final String? artUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (artUri == null) {
|
||||
return const ColoredBox(color: TimbreColors.surface);
|
||||
}
|
||||
return Image.network(
|
||||
artUri!,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover, // square art → wide label: crop the sides/top
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const ColoredBox(color: TimbreColors.surface),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the live reel angles + progress and advances them each tick. A
|
||||
/// [ChangeNotifier] so the spindle transforms and tape painter repaint without
|
||||
/// rebuilding the whole [CassetteView].
|
||||
class _CassetteModel extends ChangeNotifier {
|
||||
double angleLeft = 0;
|
||||
double angleRight = 0;
|
||||
double progress = 0;
|
||||
|
||||
void update({
|
||||
required double dt,
|
||||
required bool playing,
|
||||
required double progress,
|
||||
}) {
|
||||
final progressChanged = progress != this.progress;
|
||||
this.progress = progress;
|
||||
if (playing) {
|
||||
const cfg = _CassetteConfig.instance;
|
||||
// ω = v / R, same (clockwise) direction for both reels.
|
||||
angleLeft += cfg.linearSpeed / cfg.radius(progress, supply: true) * dt;
|
||||
angleRight += cfg.linearSpeed / cfg.radius(progress, supply: false) * dt;
|
||||
}
|
||||
// Idle + no seek ⇒ nothing moved, so skip the repaint.
|
||||
if (playing || progressChanged) notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the two reels as large tape discs centred on the spindles, plus a
|
||||
/// light pad behind them. Everything is clipped to the window band so it never
|
||||
/// bleeds onto the label; the shell (painted on top) then masks it to the parts
|
||||
/// that should show — the reel holes and the centre window, where each disc's
|
||||
/// inner edge appears as a crescent that grows/shrinks with
|
||||
/// [_CassetteModel.progress] as tape winds from one reel to the other.
|
||||
class _TapePainter extends CustomPainter {
|
||||
_TapePainter(this.model) : super(repaint: model);
|
||||
|
||||
final _CassetteModel model;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const cfg = _CassetteConfig.instance;
|
||||
canvas.save();
|
||||
// Keep the tape inside the window band — off the (transparent) label.
|
||||
canvas.clipRect(cfg.windowRect);
|
||||
// Light pad behind the tape; the shell masks it down to the centre window.
|
||||
canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor);
|
||||
final tape = Paint()..color = cfg.tapeColor;
|
||||
canvas.drawCircle(
|
||||
cfg.leftReel, cfg.radius(model.progress, supply: true), tape);
|
||||
canvas.drawCircle(
|
||||
cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// Repaint is driven by `repaint: model`; geometry is fully derived from it.
|
||||
@override
|
||||
bool shouldRepaint(_TapePainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// All the fixed geometry, measured off `casette_shell.svg` (viewBox 469×298),
|
||||
/// plus the reel physics. Grouped and tunable in one place.
|
||||
class _CassetteConfig {
|
||||
const _CassetteConfig();
|
||||
static const instance = _CassetteConfig();
|
||||
|
||||
final Size viewBox = const Size(469, 298);
|
||||
|
||||
/// The big inner body panel — the transparent "label" the cover art fills.
|
||||
/// Slightly oversized so it fully backs the shell's hole (excess is masked
|
||||
/// by the shell frame on top).
|
||||
final Rect labelRect = const Rect.fromLTRB(25, 27, 444, 220);
|
||||
|
||||
// The window band the tape is confined to (clip), so it never touches the
|
||||
// label; the shell then masks it to the reel holes + centre window.
|
||||
final Rect windowRect = const Rect.fromLTRB(62.5, 98.5, 405.5, 172);
|
||||
|
||||
// Reel + spindle centres (the two window holes).
|
||||
final Offset leftReel = const Offset(135, 135);
|
||||
final Offset rightReel = const Offset(334, 135);
|
||||
|
||||
// Wound-tape radius bounds: [hubRadius] = empty reel (just covers the hole),
|
||||
// [fullRadius] = full reel (its crescent reaches well into the centre window).
|
||||
final double hubRadius = 36;
|
||||
final double fullRadius = 80;
|
||||
|
||||
// Cog display size (native art is 105×105, centred on the axle). ~ the reel
|
||||
// hole so the cog seats in the well with a thin tape rim showing around it.
|
||||
static const double spindleSize = 36;
|
||||
|
||||
// Tape linear speed in viewBox units/second — sets how fast the cogs spin
|
||||
// (ω = linearSpeed / radius). Purely cosmetic.
|
||||
final double linearSpeed = 40;
|
||||
|
||||
final Color tapeColor = const Color(0xFF121212); // wound tape
|
||||
final Color padColor = const Color(0xFF1A1A1A); // centre-window backing
|
||||
|
||||
/// Wound radius at [progress]. Radius scales with √(wound length): the supply
|
||||
/// reel is full at p=0 and empty at p=1; the take-up reel is the reverse.
|
||||
double radius(double progress, {required bool supply}) {
|
||||
final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0);
|
||||
final r2 = hubRadius * hubRadius +
|
||||
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
|
||||
return math.sqrt(r2);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import '../theme/tokens.dart';
|
||||
|
||||
/// A thin-stroked panel with its title label sitting *on* the top border —
|
||||
/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics",
|
||||
/// Timbre's signature container ("Album Art", "Queue (127)", "Lyrics",
|
||||
/// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
|
||||
/// then overlay the title with a background that occludes the segment behind
|
||||
/// it, so the label appears to break the line.
|
||||
|
|
|
|||
38
lib/widgets/toast.dart
Normal file
38
lib/widgets/toast.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Shows a themed, transient confirmation toast plus a light haptic tick.
|
||||
///
|
||||
/// Rendered as a fixed [SnackBar], so it settles just above the app's bottom
|
||||
/// chrome (mini-player + tab bar) rather than over it. Styled to match the
|
||||
/// dense, near-black terminal aesthetic — [TimbreColors.surface] fill, warm
|
||||
/// [TimbreColors.foreground] text, with the live accent used for the leading
|
||||
/// [icon]. Any in-flight toast is cleared first so rapid taps don't stack.
|
||||
void showToast(BuildContext context, String message, {IconData? icon}) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
HapticFeedback.selectionClick();
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: TimbreColors.surface,
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 16, color: accent),
|
||||
const SizedBox(width: TimbreSpacing.md),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: TimbreColors.foreground),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue