This commit is contained in:
Forrest 2026-07-29 16:22:00 -04:00
parent fc22d11df1
commit 27a8a62475
32 changed files with 354 additions and 335 deletions

View file

@ -129,7 +129,7 @@ class DownloadController extends StateNotifier<DownloadState> {
final Dio _dio = Dio(BaseOptions(
receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'ratune-mobile'},
headers: {'User-Agent': 'timbre'},
));
static const int _maxConcurrent = 3;

View file

@ -19,28 +19,28 @@ Future<void> main() async {
// hang we still start the app (playback just loses lock-screen controls).
try {
await JustAudioBackground.init(
androidNotificationChannelId: 'com.conversionpath.ratune_mobile.audio',
androidNotificationChannelName: 'Ratune playback',
androidNotificationChannelId: 'com.laforrestchurch.timbre.audio',
androidNotificationChannelName: 'Timbre playback',
androidNotificationOngoing: true,
).timeout(const Duration(seconds: 8));
} catch (e, st) {
debugPrint('JustAudioBackground.init failed: $e\n$st');
}
}
runApp(const ProviderScope(child: RatuneApp()));
runApp(const ProviderScope(child: TimbreApp()));
}
class RatuneApp extends ConsumerWidget {
const RatuneApp({super.key});
class TimbreApp extends ConsumerWidget {
const TimbreApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// The theme rebuilds whenever the album-art accent changes.
final accent = ref.watch(accentProvider);
return MaterialApp(
title: 'Ratune',
title: 'Timbre',
debugShowCheckedModeBanner: false,
theme: buildRatuneTheme(accent),
theme: buildTimbreTheme(accent),
home: const AppShell(),
);
}

View file

@ -13,7 +13,7 @@ Future<void> showAddToPlaylistSheet(
}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: RatuneColors.background,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => _AddToPlaylistSheet(songs: songs),
);
@ -32,24 +32,24 @@ class _AddToPlaylistSheet extends ConsumerWidget {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.lg),
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: RatuneSpacing.xl),
const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Text('Add to playlist',
style:
TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
if (!connected)
const Padding(
padding: EdgeInsets.all(RatuneSpacing.xl),
padding: EdgeInsets.all(TimbreSpacing.xl),
child: Text('Connect to a server to manage playlists.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
)
else ...[
_Tile(
@ -123,21 +123,21 @@ class _Tile extends StatelessWidget {
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.xl),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Row(
children: [
Icon(icon, size: 20, color: accent ?? RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.lg),
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 ?? RatuneColors.foreground)),
style: TextStyle(color: accent ?? TimbreColors.foreground)),
),
if (trailing != null)
Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
],
),
),
@ -158,15 +158,15 @@ Future<String?> promptPlaylistName(
builder: (ctx) {
final accent = Theme.of(ctx).colorScheme.primary;
return AlertDialog(
backgroundColor: RatuneColors.surface,
backgroundColor: TimbreColors.surface,
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: RatuneColors.foreground),
style: const TextStyle(color: TimbreColors.foreground),
decoration: const InputDecoration(
hintText: 'Playlist name',
hintStyle: TextStyle(color: RatuneColors.dimmed),
hintStyle: TextStyle(color: TimbreColors.dimmed),
),
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
),

View file

@ -25,17 +25,17 @@ class BrowserScreen extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: RatuneSpacing.xl,
runSpacing: RatuneSpacing.xs,
spacing: TimbreSpacing.xl,
runSpacing: TimbreSpacing.xs,
children: [
_Action(
icon: Icons.search,
@ -67,9 +67,9 @@ class BrowserScreen extends ConsumerWidget {
),
],
),
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
_ModeSelector(mode: mode),
const SizedBox(height: RatuneSpacing.lg),
const SizedBox(height: TimbreSpacing.lg),
Expanded(
child: client == null
? const HairlinePanel(
@ -105,13 +105,13 @@ class _ModeSelector extends ConsumerWidget {
onTap: () => ref.read(browseModeProvider.notifier).state = m,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.md),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: active ? RatuneColors.foreground : RatuneColors.dimmed,
color: active ? TimbreColors.foreground : TimbreColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration:
active ? TextDecoration.underline : TextDecoration.none,
@ -127,9 +127,9 @@ class _ModeSelector extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
label('Artists', BrowseMode.artists),
const Text('|', style: TextStyle(color: RatuneColors.border)),
const Text('|', style: TextStyle(color: TimbreColors.border)),
label('Albums', BrowseMode.albums),
const Text('|', style: TextStyle(color: RatuneColors.border)),
const Text('|', style: TextStyle(color: TimbreColors.border)),
label('Tracks', BrowseMode.tracks),
],
);
@ -148,7 +148,7 @@ class _ArtistsPanel extends ConsumerWidget {
trailing: artists.hasValue && artists.value!.isNotEmpty
? '(${artists.value!.length})'
: null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: artists.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
@ -186,7 +186,7 @@ class _AlbumsPanel extends ConsumerWidget {
trailing: albums.hasValue && albums.value!.isNotEmpty
? '(${albums.value!.length})'
: null,
padding: const EdgeInsets.all(RatuneSpacing.md),
padding: const EdgeInsets.all(TimbreSpacing.md),
child: albums.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
@ -200,8 +200,8 @@ class _AlbumsPanel extends ConsumerWidget {
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: RatuneSpacing.md,
crossAxisSpacing: RatuneSpacing.md,
mainAxisSpacing: TimbreSpacing.md,
crossAxisSpacing: TimbreSpacing.md,
// Square art + two caption lines; extra vertical slack so
// the tile never sub-pixel-overflows.
childAspectRatio: 0.68,
@ -241,7 +241,7 @@ class _AlbumTile extends StatelessWidget {
AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: RatuneColors.surface,
color: TimbreColors.surface,
child: artUri != null
? Image.network(
artUri!,
@ -253,12 +253,12 @@ class _AlbumTile extends StatelessWidget {
: const _AlbumArtFallback(),
),
),
const SizedBox(height: RatuneSpacing.xs),
const SizedBox(height: TimbreSpacing.xs),
Text(
album.name ?? 'Unknown album',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground),
style: const TextStyle(color: TimbreColors.foreground),
),
if (album.artist != null)
Text(
@ -266,7 +266,7 @@ class _AlbumTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12),
color: TimbreColors.dimmed, fontSize: 12),
),
],
),
@ -279,7 +279,7 @@ class _AlbumArtFallback extends StatelessWidget {
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 32),
color: TimbreColors.dimmed, size: 32),
);
}
@ -317,8 +317,8 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
mainAxisSize: MainAxisSize.min,
children: [
const _Loading(),
const SizedBox(height: RatuneSpacing.md),
Text(label, style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(height: TimbreSpacing.md),
Text(label, style: const TextStyle(color: TimbreColors.dimmed)),
],
),
);
@ -357,7 +357,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
title: 'Tracks',
active: true,
trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Column(
children: [
Align(
@ -368,11 +368,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.sm,
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
child: Text('↻ refresh',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
),
),
),
@ -395,13 +395,13 @@ class _Action extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.sm),
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.sm),
Text(label, style: const TextStyle(color: RatuneColors.foreground)),
Icon(icon, size: 16, color: TimbreColors.dimmed),
const SizedBox(width: TimbreSpacing.sm),
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
],
),
),
@ -561,33 +561,33 @@ class BrowseRow extends StatelessWidget {
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
if (leading != null)
SizedBox(
width: 28,
child: Text(leading!,
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground),
style: const TextStyle(color: TimbreColors.foreground),
),
),
if (isDone)
Padding(
padding: const EdgeInsets.only(left: RatuneSpacing.sm),
padding: const EdgeInsets.only(left: TimbreSpacing.sm),
child:
Icon(Icons.download_done, size: 14, color: accent),
)
else if (isActive)
const Padding(
padding: EdgeInsets.only(left: RatuneSpacing.sm),
padding: EdgeInsets.only(left: TimbreSpacing.sm),
child: SizedBox(
width: 12,
height: 12,
@ -595,9 +595,9 @@ class BrowseRow extends StatelessWidget {
),
),
if (trailing != null) ...[
const SizedBox(width: RatuneSpacing.md),
const SizedBox(width: TimbreSpacing.md),
Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
],
if (onPlayNext != null)
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
@ -612,7 +612,7 @@ class BrowseRow extends StatelessWidget {
onRemoveDownload: onRemoveDownload,
),
if (onPlayNext == null && onAddToQueue == null && !_hasMenu)
const SizedBox(width: RatuneSpacing.lg),
const SizedBox(width: TimbreSpacing.lg),
],
),
),
@ -639,11 +639,11 @@ class _RowMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: RatuneSpacing.minTouchTarget),
const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
onSelected: (v) {
switch (v) {
case 'playlist':
@ -684,9 +684,9 @@ class _RowIcon extends StatelessWidget {
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(icon, size: 20, color: RatuneColors.dimmed),
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(icon, size: 20, color: TimbreColors.dimmed),
),
);
}
@ -728,11 +728,11 @@ class _NotConnected extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text('Not connected.',
style: TextStyle(color: RatuneColors.foreground)),
SizedBox(height: RatuneSpacing.sm),
style: TextStyle(color: TimbreColors.foreground)),
SizedBox(height: TimbreSpacing.sm),
Text('Tap the status bar to add a Subsonic server.',
textAlign: TextAlign.center,
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
],
),
);
@ -744,7 +744,7 @@ class _Centered extends StatelessWidget {
final Widget child;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(RatuneSpacing.xl),
padding: const EdgeInsets.all(TimbreSpacing.xl),
child: Center(child: child),
);
}
@ -766,7 +766,7 @@ class _ErrorText extends StatelessWidget {
Widget build(BuildContext context) => Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: RatuneColors.dimmed),
style: const TextStyle(color: TimbreColors.dimmed),
);
}

View file

@ -9,7 +9,7 @@ import '../theme/tokens.dart';
Future<void> showConnectSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: RatuneColors.background,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => const _ConnectSheet(),
);
@ -66,10 +66,10 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final accent = Theme.of(context).colorScheme.primary;
return Padding(
padding: EdgeInsets.only(
left: RatuneSpacing.xl,
right: RatuneSpacing.xl,
top: RatuneSpacing.xl,
bottom: MediaQuery.of(context).viewInsets.bottom + RatuneSpacing.xl,
left: TimbreSpacing.xl,
right: TimbreSpacing.xl,
top: TimbreSpacing.xl,
bottom: MediaQuery.of(context).viewInsets.bottom + TimbreSpacing.xl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -77,22 +77,22 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
children: [
Text('Connect to server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
const SizedBox(height: RatuneSpacing.lg),
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) ...[
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
Text(conn.error!,
style: const TextStyle(color: Color(0xFFE06C75))),
],
const SizedBox(height: RatuneSpacing.lg),
const SizedBox(height: TimbreSpacing.lg),
FilledButton(
onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: RatuneColors.background,
foregroundColor: TimbreColors.background,
shape: const RoundedRectangleBorder(),
),
child: _busy
@ -116,22 +116,22 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
TextInputType? keyboard,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.md),
padding: const EdgeInsets.only(bottom: TimbreSpacing.md),
child: TextField(
controller: c,
obscureText: obscure,
keyboardType: keyboard,
autocorrect: false,
enableSuggestions: false,
style: const TextStyle(color: RatuneColors.foreground),
style: const TextStyle(color: TimbreColors.foreground),
decoration: InputDecoration(
labelText: label,
hintText: hint,
labelStyle: const TextStyle(color: RatuneColors.dimmed),
hintStyle: const TextStyle(color: RatuneColors.dimmed),
labelStyle: const TextStyle(color: TimbreColors.dimmed),
hintStyle: const TextStyle(color: TimbreColors.dimmed),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: RatuneColors.border),
borderSide: BorderSide(color: TimbreColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,

View file

@ -29,7 +29,7 @@ class DownloadsScreen extends ConsumerWidget {
TextButton(
onPressed: () => _confirmClear(context, controller),
child: const Text('Clear all',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
),
],
),
@ -37,24 +37,24 @@ class DownloadsScreen extends ConsumerWidget {
child: (active.isEmpty && completed.isEmpty)
? const Center(
child: Text('No downloads yet.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
)
: ListView(
padding: const EdgeInsets.all(RatuneSpacing.lg),
padding: const EdgeInsets.all(TimbreSpacing.lg),
children: [
if (active.isNotEmpty) ...[
HairlinePanel(
title: 'Downloading',
trailing: '(${active.length})',
padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md),
vertical: TimbreSpacing.md),
child: Column(
children: [
for (final d in active) _ActiveRow(info: d),
],
),
),
const SizedBox(height: RatuneSpacing.xl),
const SizedBox(height: TimbreSpacing.xl),
],
HairlinePanel(
title: 'Saved',
@ -63,12 +63,12 @@ class DownloadsScreen extends ConsumerWidget {
? null
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
padding:
const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: completed.isEmpty
? const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
padding: EdgeInsets.all(TimbreSpacing.lg),
child: Text('Nothing saved for offline yet.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
)
: Column(
children: [
@ -93,7 +93,7 @@ class DownloadsScreen extends ConsumerWidget {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface,
backgroundColor: TimbreColors.surface,
title: const Text('Remove all downloads?'),
content: const Text(
'This deletes every saved file for this server. It cannot be undone.'),
@ -121,7 +121,7 @@ class _ActiveRow extends StatelessWidget {
final failed = info.status == DownloadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg, vertical: RatuneSpacing.xs),
horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs),
child: Row(
children: [
Expanded(
@ -131,8 +131,8 @@ class _ActiveRow extends StatelessWidget {
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
const SizedBox(height: RatuneSpacing.xs),
style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.xs),
if (failed)
const Text('Failed',
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12))
@ -140,18 +140,18 @@ class _ActiveRow extends StatelessWidget {
LinearProgressIndicator(
value: info.progress > 0 ? info.progress : null,
minHeight: 3,
backgroundColor: RatuneColors.border,
backgroundColor: TimbreColors.border,
color: accent,
),
],
),
),
const SizedBox(width: RatuneSpacing.md),
const SizedBox(width: TimbreSpacing.md),
Text(
failed
? '—'
: (info.status == DownloadStatus.queued ? 'Queued' : ''),
style: const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
@ -178,8 +178,8 @@ class _SavedRow extends StatelessWidget {
onTap: onPlay,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
Expanded(
@ -190,7 +190,7 @@ class _SavedRow extends StatelessWidget {
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
style: const TextStyle(color: TimbreColors.foreground)),
Text(
[
info.song.artist,
@ -199,7 +199,7 @@ class _SavedRow extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
const TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
],
),
@ -208,10 +208,10 @@ class _SavedRow extends StatelessWidget {
onTap: onRemove,
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.delete_outline,
size: 20, color: RatuneColors.dimmed),
size: 20, color: TimbreColors.dimmed),
),
),
],

View file

@ -28,13 +28,13 @@ class FavoritesScreen extends ConsumerWidget {
),
error: (e, _) => Center(
child: Text('$e',
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
),
data: (s) {
if (s.songs.isEmpty && s.albums.isEmpty && s.artists.isEmpty) {
return const Center(
child: Text('No favorites yet.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
);
}
return ListView(
@ -96,10 +96,10 @@ class _SectionLabel extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.lg,
RatuneSpacing.lg,
RatuneSpacing.sm,
TimbreSpacing.lg,
TimbreSpacing.lg,
TimbreSpacing.lg,
TimbreSpacing.sm,
),
child: Text(
text,

View file

@ -29,14 +29,14 @@ class HomeScreen extends ConsumerWidget {
return ListView(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
),
children: [
const _HeroCard(),
const SizedBox(height: RatuneSpacing.xl),
const SizedBox(height: TimbreSpacing.xl),
// Recently Played — from local history.
if (recentAlbums.isNotEmpty)
@ -93,10 +93,10 @@ class HomeScreen extends ConsumerWidget {
if (recentAlbums.isEmpty && client == null)
const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.xl),
padding: EdgeInsets.only(top: TimbreSpacing.xl),
child: Text(
'Connect to a server and start listening — your home fills in as you play.',
style: TextStyle(color: RatuneColors.dimmed),
style: TextStyle(color: TimbreColors.dimmed),
),
),
],
@ -143,11 +143,11 @@ class _HeroCard extends ConsumerWidget {
child: const Row(
children: [
Icon(Icons.library_music_outlined,
color: RatuneColors.dimmed, size: 40),
SizedBox(width: RatuneSpacing.lg),
color: TimbreColors.dimmed, size: 40),
SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Text('Browse your library to start listening',
style: TextStyle(color: RatuneColors.foreground)),
style: TextStyle(color: TimbreColors.foreground)),
),
],
),
@ -170,19 +170,19 @@ class _HeroCard extends ConsumerWidget {
width: 64,
height: 64,
child: ColoredBox(
color: RatuneColors.surface,
color: TimbreColors.surface,
child: artUri != null
? Image.network(artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon(
Icons.album_outlined, color: RatuneColors.dimmed))
Icons.album_outlined, color: TimbreColors.dimmed))
: const Icon(Icons.album_outlined,
color: RatuneColors.dimmed),
color: TimbreColors.dimmed),
),
),
const SizedBox(width: RatuneSpacing.lg),
const SizedBox(width: TimbreSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -192,7 +192,7 @@ class _HeroCard extends ConsumerWidget {
children: [
Icon(hasCurrent ? Icons.play_arrow : Icons.history,
size: 14, color: accent),
const SizedBox(width: RatuneSpacing.xs),
const SizedBox(width: TimbreSpacing.xs),
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle(
color: accent,
@ -201,20 +201,20 @@ class _HeroCard extends ConsumerWidget {
fontWeight: FontWeight.w700)),
],
),
const SizedBox(height: RatuneSpacing.xs),
const SizedBox(height: TimbreSpacing.xs),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.foreground,
color: TimbreColors.foreground,
fontWeight: FontWeight.w700)),
if (subtitle != null)
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
if (hasCurrent) ...[
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
BlockProgressBar(progress: progress, cells: 32, height: 6),
],
],
@ -239,10 +239,10 @@ class _HeroShell extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(RatuneSpacing.lg),
padding: const EdgeInsets.all(TimbreSpacing.lg),
decoration: BoxDecoration(
color: RatuneColors.surface,
border: Border.all(color: RatuneColors.borderActive),
color: TimbreColors.surface,
border: Border.all(color: TimbreColors.borderActive),
),
child: child,
),
@ -263,19 +263,19 @@ class _Shelf extends StatelessWidget {
Widget build(BuildContext context) {
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.xl),
padding: const EdgeInsets.only(bottom: TimbreSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShelfHeader(title: title, onShuffle: onShuffle),
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
SizedBox(
height: 182,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: cards.length,
separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.md),
const SizedBox(width: TimbreSpacing.md),
itemBuilder: (_, i) => cards[i],
),
),
@ -297,7 +297,7 @@ class _ShelfHeader extends StatelessWidget {
children: [
Text(title,
style: const TextStyle(
color: RatuneColors.foreground,
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5)),
const Spacer(),
@ -306,9 +306,9 @@ class _ShelfHeader extends StatelessWidget {
onTap: onShuffle,
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
width: TimbreSpacing.minTouchTarget,
height: 28,
child: Icon(Icons.shuffle, size: 18, color: RatuneColors.dimmed),
child: Icon(Icons.shuffle, size: 18, color: TimbreColors.dimmed),
),
),
],
@ -392,29 +392,29 @@ class _ArtCard extends StatelessWidget {
width: _size,
height: _size,
child: ColoredBox(
color: RatuneColors.surface,
color: TimbreColors.surface,
child: artUri != null
? Image.network(artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
Icon(fallbackIcon, color: RatuneColors.dimmed))
: Icon(fallbackIcon, color: RatuneColors.dimmed),
Icon(fallbackIcon, color: TimbreColors.dimmed))
: Icon(fallbackIcon, color: TimbreColors.dimmed),
),
),
),
const SizedBox(height: RatuneSpacing.sm),
const SizedBox(height: TimbreSpacing.sm),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
style: const TextStyle(color: TimbreColors.foreground)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: RatuneColors.dimmed, fontSize: 12)),
const TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
],
),
),
@ -434,7 +434,7 @@ class _ArtCardSkeleton extends StatelessWidget {
child: const SizedBox(
width: _ArtCard._size,
height: _ArtCard._size,
child: ColoredBox(color: RatuneColors.surface),
child: ColoredBox(color: TimbreColors.surface),
),
),
);

View file

@ -34,16 +34,16 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
if (current == null) {
return const Center(
child: Text('Nothing playing.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -53,21 +53,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
Expanded(
child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(),
),
const SizedBox(height: RatuneSpacing.xl),
const SizedBox(height: TimbreSpacing.xl),
_InfoStrip(song: current, state: state, accent: accent),
const SizedBox(height: RatuneSpacing.sm),
const SizedBox(height: TimbreSpacing.sm),
_FavRating(song: current),
const SizedBox(height: RatuneSpacing.xs),
const SizedBox(height: TimbreSpacing.xs),
_Transport(state: state, ref: ref, accent: accent),
if (!state.supported)
const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.sm),
padding: EdgeInsets.only(top: TimbreSpacing.sm),
child: Text(
'Audio output unavailable on this platform — test on Android/iOS.',
style: TextStyle(color: RatuneColors.dimmed, fontSize: 11),
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
),
),
const SizedBox(height: RatuneSpacing.sm),
const SizedBox(height: TimbreSpacing.sm),
_QueueToggle(
showQueue: _showQueue,
queueLength: state.queue.length,
@ -102,8 +102,8 @@ class _QueueToggle extends StatelessWidget {
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.sm,
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
@ -111,13 +111,13 @@ class _QueueToggle extends StatelessWidget {
Icon(
showQueue ? Icons.album_outlined : Icons.queue_music,
size: 16,
color: showQueue ? accent : RatuneColors.dimmed,
color: showQueue ? accent : TimbreColors.dimmed,
),
const SizedBox(width: RatuneSpacing.sm),
const SizedBox(width: TimbreSpacing.sm),
Text(
showQueue ? 'Album art' : 'Queue ($queueLength)',
style: TextStyle(
color: showQueue ? accent : RatuneColors.foreground,
color: showQueue ? accent : TimbreColors.foreground,
),
),
],
@ -140,7 +140,7 @@ class _QueuePanel extends ConsumerWidget {
return HairlinePanel(
title: 'Queue',
trailing: '(${state.queue.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.queue.length,
@ -153,15 +153,15 @@ class _QueuePanel extends ConsumerWidget {
.playSongs(state.queue, startIndex: i),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.xs,
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.xs,
),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('${i + 1}',
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
),
Expanded(
child: Text(
@ -169,23 +169,23 @@ class _QueuePanel extends ConsumerWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isCurrent ? accent : RatuneColors.foreground,
color: isCurrent ? accent : TimbreColors.foreground,
fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400,
),
),
),
Text(_fmt(song.duration),
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
InkWell(
onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i),
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.close,
size: 18, color: RatuneColors.dimmed),
size: 18, color: TimbreColors.dimmed),
),
),
],
@ -216,11 +216,11 @@ class _AlbumArtPanel extends ConsumerWidget {
return HairlinePanel(
title: 'Album Art',
padding: const EdgeInsets.all(RatuneSpacing.md),
padding: const EdgeInsets.all(TimbreSpacing.md),
child: AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: RatuneColors.surface,
color: TimbreColors.surface,
child: artUri != null
? Image.network(
artUri,
@ -263,15 +263,15 @@ class _FavRating extends ConsumerWidget {
onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song),
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm),
padding: const EdgeInsets.all(TimbreSpacing.sm),
child: Icon(
starred ? Icons.favorite : Icons.favorite_border,
color: starred ? accent : RatuneColors.dimmed,
color: starred ? accent : TimbreColors.dimmed,
size: 22,
),
),
),
const SizedBox(width: RatuneSpacing.md),
const SizedBox(width: TimbreSpacing.md),
for (int i = 1; i <= 5; i++)
GestureDetector(
onTap: () => ref
@ -282,7 +282,7 @@ class _FavRating extends ConsumerWidget {
child: Icon(
i <= rating ? Icons.star : Icons.star_border,
size: 18,
color: i <= rating ? accent : RatuneColors.dimmed,
color: i <= rating ? accent : TimbreColors.dimmed,
),
),
),
@ -291,9 +291,9 @@ class _FavRating extends ConsumerWidget {
onTap: () => showAddToPlaylistSheet(context, songs: [song]),
customBorder: const CircleBorder(),
child: const Padding(
padding: EdgeInsets.all(RatuneSpacing.sm),
padding: EdgeInsets.all(TimbreSpacing.sm),
child: Icon(Icons.playlist_add,
size: 22, color: RatuneColors.dimmed),
size: 22, color: TimbreColors.dimmed),
),
),
InkWell(
@ -305,7 +305,7 @@ class _FavRating extends ConsumerWidget {
},
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm),
padding: const EdgeInsets.all(TimbreSpacing.sm),
child: isDownloading
? const SizedBox(
width: 18,
@ -317,7 +317,7 @@ class _FavRating extends ConsumerWidget {
? Icons.download_done
: Icons.download_outlined,
size: 22,
color: isDownloaded ? accent : RatuneColors.dimmed,
color: isDownloaded ? accent : TimbreColors.dimmed,
),
),
),
@ -348,19 +348,19 @@ class _InfoStrip extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist',
style: const TextStyle(color: RatuneColors.foreground)),
style: const TextStyle(color: TimbreColors.foreground)),
if (album.isNotEmpty)
Text(album, style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(height: RatuneSpacing.md),
Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(height: TimbreSpacing.md),
Row(
children: [
Text(_fmtDur(state.position),
style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(width: RatuneSpacing.md),
style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(width: TimbreSpacing.md),
Expanded(child: BlockProgressBar(progress: state.progress)),
const SizedBox(width: RatuneSpacing.md),
const SizedBox(width: TimbreSpacing.md),
Text(_fmtDur(state.duration),
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
],
),
],
@ -387,20 +387,20 @@ class _Transport extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_btn(Icons.shuffle, controller.toggleShuffle,
color: state.shuffle ? accent : RatuneColors.dimmed),
color: state.shuffle ? accent : TimbreColors.dimmed),
_btn(Icons.skip_previous, controller.previous),
_btn(state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent, size: 40),
_btn(Icons.skip_next, controller.next),
_btn(loopIcon, controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : RatuneColors.dimmed),
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground, double size = 28}) {
{Color color = TimbreColors.foreground, double size = 28}) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: color, size: size),
@ -413,7 +413,7 @@ class _ArtFallback extends StatelessWidget {
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 48),
color: TimbreColors.dimmed, size: 48),
);
}

View file

@ -34,13 +34,13 @@ class PlaylistsScreen extends ConsumerWidget {
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.lg),
padding: const EdgeInsets.all(TimbreSpacing.lg),
child: HairlinePanel(
title: 'Playlists',
active: true,
trailing:
state.playlists.isEmpty ? null : '(${state.playlists.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: state.playlists.isEmpty
? Center(
child: Text(
@ -48,7 +48,7 @@ class PlaylistsScreen extends ConsumerWidget {
? 'No playlists yet. Tap + to create one.'
: 'Connect to a server to see playlists.',
textAlign: TextAlign.center,
style: const TextStyle(color: RatuneColors.dimmed),
style: const TextStyle(color: TimbreColors.dimmed),
),
)
: ListView.builder(
@ -99,7 +99,7 @@ class PlaylistsScreen extends ConsumerWidget {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface,
backgroundColor: TimbreColors.surface,
title: Text('Delete "${p.name}"?'),
actions: [
TextButton(
@ -136,12 +136,12 @@ class _PlaylistRow extends StatelessWidget {
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
const Icon(Icons.queue_music, size: 18, color: RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.md),
const Icon(Icons.queue_music, size: 18, color: TimbreColors.dimmed),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -150,19 +150,19 @@ class _PlaylistRow extends StatelessWidget {
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
style: const TextStyle(color: TimbreColors.foreground)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)),
color: TimbreColors.dimmed, fontSize: 12)),
],
),
),
if (onRename != null || onDelete != null)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {
if (v == 'rename') onRename?.call();
if (v == 'delete') onDelete?.call();
@ -247,16 +247,16 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
child: detail == null
? const Center(
child: Text('Loading…',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
)
: songs.isEmpty
? const Center(
child: Text('This playlist is empty.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md),
vertical: TimbreSpacing.md),
itemCount: songs.length,
itemBuilder: (context, i) {
final song = songs[i];
@ -302,14 +302,14 @@ class _TrackRow extends StatelessWidget {
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('$index',
style: const TextStyle(color: RatuneColors.dimmed)),
style: const TextStyle(color: TimbreColors.dimmed)),
),
Expanded(
child: Column(
@ -319,20 +319,20 @@ class _TrackRow extends StatelessWidget {
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
style: const TextStyle(color: TimbreColors.foreground)),
if (song.artist != null)
Text(song.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)),
color: TimbreColors.dimmed, fontSize: 12)),
],
),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {
switch (v) {
case 'next':

View file

@ -32,11 +32,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
controller: _controller,
autofocus: true,
textInputAction: TextInputAction.search,
style: const TextStyle(color: RatuneColors.foreground),
style: const TextStyle(color: TimbreColors.foreground),
cursorColor: accent,
decoration: const InputDecoration(
hintText: 'Search artists, albums, songs…',
hintStyle: TextStyle(color: RatuneColors.dimmed),
hintStyle: TextStyle(color: TimbreColors.dimmed),
border: InputBorder.none,
),
onSubmitted: (v) => setState(() => _query = v),
@ -46,7 +46,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
child: _query.trim().isEmpty
? const Center(
child: Text('Type and press search.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
)
: _Results(query: _query),
),
@ -71,13 +71,13 @@ class _Results extends ConsumerWidget {
),
),
error: (e, _) => Center(
child: Text('$e', style: const TextStyle(color: RatuneColors.dimmed)),
child: Text('$e', style: const TextStyle(color: TimbreColors.dimmed)),
),
data: (r) {
if (r.artists.isEmpty && r.albums.isEmpty && r.songs.isEmpty) {
return const Center(
child: Text('No results.',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
);
}
return ListView(

View file

@ -22,7 +22,7 @@ class SettingsScreen extends ConsumerWidget {
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(RatuneSpacing.lg),
padding: const EdgeInsets.all(TimbreSpacing.lg),
children: [
HairlinePanel(
title: 'Streaming',
@ -32,7 +32,7 @@ class SettingsScreen extends ConsumerWidget {
children: [
const _Caption(
'Quality used when playing tracks that are not downloaded.'),
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
@ -43,7 +43,7 @@ class SettingsScreen extends ConsumerWidget {
],
),
),
const SizedBox(height: RatuneSpacing.xl),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Downloads',
active: true,
@ -53,7 +53,7 @@ class SettingsScreen extends ConsumerWidget {
const _Caption(
'Quality used when saving tracks for offline playback. '
'"Original" keeps the source file (best quality, largest).'),
const SizedBox(height: RatuneSpacing.md),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
@ -61,7 +61,7 @@ class SettingsScreen extends ConsumerWidget {
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setDownloadMaxBitRate,
),
const SizedBox(height: RatuneSpacing.lg),
const SizedBox(height: TimbreSpacing.lg),
_ChoiceChips<String?>(
label: 'Format',
values: AppSettings.formatChoices,
@ -85,7 +85,7 @@ class _Caption extends StatelessWidget {
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12),
);
}
@ -112,11 +112,11 @@ class _ChoiceChips<T> extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: RatuneColors.foreground)),
const SizedBox(height: RatuneSpacing.sm),
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Wrap(
spacing: RatuneSpacing.sm,
runSpacing: RatuneSpacing.sm,
spacing: TimbreSpacing.sm,
runSpacing: TimbreSpacing.sm,
children: [
for (final v in values)
_Chip(
@ -150,21 +150,21 @@ class _Chip extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.md,
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? accent : RatuneColors.border,
color: active ? accent : TimbreColors.border,
),
color: active ? accent.withValues(alpha: 0.12) : RatuneColors.surface,
color: active ? accent.withValues(alpha: 0.12) : TimbreColors.surface,
),
child: Text(
text,
style: TextStyle(
color: active ? accent : RatuneColors.foreground,
color: active ? accent : TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),

View file

@ -141,16 +141,16 @@ class _TabBar extends StatelessWidget {
onTap: () => onSelect(i),
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.md,
vertical: RatuneSpacing.md,
horizontal: TimbreSpacing.md,
vertical: TimbreSpacing.md,
),
child: Center(
child: Text(
tabs[i],
style: TextStyle(
color: active ? RatuneColors.foreground : RatuneColors.dimmed,
color: active ? TimbreColors.foreground : TimbreColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration:
active ? TextDecoration.underline : TextDecoration.none,
@ -164,13 +164,13 @@ class _TabBar extends StatelessWidget {
);
if (i < tabs.length - 1) {
children.add(
const Text('|', style: TextStyle(color: RatuneColors.border)),
const Text('|', style: TextStyle(color: TimbreColors.border)),
);
}
}
return Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: RatuneColors.border)),
border: Border(top: BorderSide(color: TimbreColors.border)),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: children),
);
@ -191,14 +191,14 @@ class _StatusBar extends ConsumerWidget {
conn.credentials?.display ?? 'online',
accent,
),
ConnStatus.connecting => ('◐', 'connecting…', RatuneColors.dimmed),
ConnStatus.connecting => ('◐', 'connecting…', TimbreColors.dimmed),
ConnStatus.error => ('○', 'offline', const Color(0xFFE06C75)),
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
ConnStatus.disconnected => ('○', 'tap to connect', TimbreColors.dimmed),
};
return Container(
height: 22,
color: RatuneColors.surface,
color: TimbreColors.surface,
child: Row(
children: [
// Left: connection status — tap to open the connect sheet.
@ -207,7 +207,7 @@ class _StatusBar extends ConsumerWidget {
onTap: () => showConnectSheet(context),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
const EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
@ -230,14 +230,14 @@ class _StatusBar extends ConsumerWidget {
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.lg),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings, size: 12, color: RatuneColors.dimmed),
SizedBox(width: RatuneSpacing.xs),
Icon(Icons.settings, size: 12, color: TimbreColors.dimmed),
SizedBox(width: TimbreSpacing.xs),
Text('settings',
style: TextStyle(color: RatuneColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed)),
],
),
),

View file

@ -40,7 +40,7 @@ class SubsonicClient {
Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {'User-Agent': 'ratune-mobile'},
headers: {'User-Agent': 'timbre'},
));
final String baseUrl;
@ -49,7 +49,7 @@ class SubsonicClient {
final Dio _dio;
static const String apiVersion = '1.16.1';
static const String clientName = 'ratune-mobile';
static const String clientName = 'timbre';
static const String _saltAlphabet =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

View file

@ -10,13 +10,13 @@ import 'tokens.dart';
/// 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> {
AccentNotifier() : super(RatuneColors.accentDefault);
AccentNotifier() : super(TimbreColors.accentDefault);
/// Snap to a new accent (e.g. from album-art extraction).
void set(Color color) => state = color;
/// Return to the fixed default (e.g. no art / `static` theme).
void reset() => state = RatuneColors.accentDefault;
void reset() => state = TimbreColors.accentDefault;
}
final accentProvider = StateNotifierProvider<AccentNotifier, Color>(

View file

@ -8,45 +8,45 @@ import 'tokens.dart';
/// 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]
/// is passed in so the theme rebuilds when album-art extraction changes it.
ThemeData buildRatuneTheme(Color accent) {
ThemeData buildTimbreTheme(Color accent) {
final mono = GoogleFonts.jetBrainsMonoTextTheme(
ThemeData.dark().textTheme,
).apply(
bodyColor: RatuneColors.foreground,
displayColor: RatuneColors.foreground,
bodyColor: TimbreColors.foreground,
displayColor: TimbreColors.foreground,
);
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.dark,
surface: RatuneColors.surface,
surface: TimbreColors.surface,
).copyWith(
primary: accent,
secondary: accent,
onSurface: RatuneColors.foreground,
onSurface: TimbreColors.foreground,
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: RatuneColors.background,
canvasColor: RatuneColors.background,
scaffoldBackgroundColor: TimbreColors.background,
canvasColor: TimbreColors.background,
colorScheme: scheme,
textTheme: mono,
primaryTextTheme: mono,
dividerColor: RatuneColors.border,
dividerColor: TimbreColors.border,
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
// Keep chrome flat and quiet — the content (and accent) carry the look.
appBarTheme: const AppBarTheme(
backgroundColor: RatuneColors.background,
backgroundColor: TimbreColors.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
),
listTileTheme: const ListTileThemeData(
dense: true,
minVerticalPadding: RatuneSpacing.sm,
minVerticalPadding: TimbreSpacing.sm,
),
);
}

View file

@ -6,8 +6,8 @@ import 'package:flutter/widgets.dart';
/// The palette is intentionally near-black and low-contrast; the *accent*
/// is the one lively color and, in the `dynamic` theme, is extracted live
/// from the current album art (Phase 2). Everything else stays fixed.
class RatuneColors {
const RatuneColors._();
class TimbreColors {
const TimbreColors._();
/// App canvas — `background = #1a1a1a`.
static const Color background = Color(0xFF1A1A1A);
@ -34,8 +34,8 @@ class RatuneColors {
}
/// Spacing scale — dense, "player as instrument" rather than airy mobile cards.
class RatuneSpacing {
const RatuneSpacing._();
class TimbreSpacing {
const TimbreSpacing._();
static const double xs = 2;
static const double sm = 4;

View file

@ -37,7 +37,7 @@ class BlockProgressBar extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 0.5),
child: ColoredBox(
color: i < filled ? filledColor : RatuneColors.border,
color: i < filled ? filledColor : TimbreColors.border,
),
),
);

View file

@ -14,8 +14,8 @@ class HairlinePanel extends StatelessWidget {
required this.child,
this.trailing,
this.active = false,
this.padding = const EdgeInsets.all(RatuneSpacing.lg),
this.backgroundColor = RatuneColors.background,
this.padding = const EdgeInsets.all(TimbreSpacing.lg),
this.backgroundColor = TimbreColors.background,
});
/// Panel label, e.g. "Queue". Rendered uppercase-ish in mono.
@ -41,9 +41,9 @@ class HairlinePanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final borderColor =
active ? RatuneColors.borderActive : RatuneColors.border;
active ? TimbreColors.borderActive : TimbreColors.border;
final titleColor =
active ? Theme.of(context).colorScheme.primary : RatuneColors.dimmed;
active ? Theme.of(context).colorScheme.primary : TimbreColors.dimmed;
return Stack(
clipBehavior: Clip.none,
@ -56,12 +56,12 @@ class HairlinePanel extends StatelessWidget {
child: Padding(padding: padding, child: child),
),
Positioned(
left: RatuneSpacing.lg,
left: TimbreSpacing.lg,
top: 0,
child: Container(
color: backgroundColor,
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.md,
horizontal: TimbreSpacing.md,
),
child: Text.rich(
TextSpan(
@ -77,7 +77,7 @@ class HairlinePanel extends StatelessWidget {
if (trailing != null)
TextSpan(
text: ' $trailing',
style: const TextStyle(color: RatuneColors.dimmed),
style: const TextStyle(color: TimbreColors.dimmed),
),
],
),

View file

@ -38,15 +38,15 @@ class MiniPlayer extends ConsumerWidget {
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex,
child: Container(
height: 52,
color: RatuneColors.surface,
padding: const EdgeInsets.only(left: RatuneSpacing.md),
color: TimbreColors.surface,
padding: const EdgeInsets.only(left: TimbreSpacing.md),
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
child: ColoredBox(
color: RatuneColors.background,
color: TimbreColors.background,
child: artUri != null
? Image.network(
artUri,
@ -58,7 +58,7 @@ class MiniPlayer extends ConsumerWidget {
: const _ArtFallback(),
),
),
const SizedBox(width: RatuneSpacing.md),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -69,7 +69,7 @@ class MiniPlayer extends ConsumerWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.foreground,
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
@ -77,7 +77,7 @@ class MiniPlayer extends ConsumerWidget {
current.artist ?? 'Unknown artist',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.dimmed),
style: const TextStyle(color: TimbreColors.dimmed),
),
],
),
@ -96,7 +96,7 @@ class MiniPlayer extends ConsumerWidget {
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground}) {
{Color color = TimbreColors.foreground}) {
return IconButton(
onPressed: onTap,
visualDensity: VisualDensity.compact,
@ -119,7 +119,7 @@ class _MiniProgress extends ConsumerWidget {
child: LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: RatuneColors.border,
backgroundColor: TimbreColors.border,
valueColor: AlwaysStoppedAnimation<Color>(accent),
),
);
@ -131,6 +131,6 @@ class _ArtFallback extends StatelessWidget {
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 22),
color: TimbreColors.dimmed, size: 22),
);
}