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

@ -1,6 +1,6 @@
# ratune_mobile # Timbre
A new Flutter project. A Subsonic music client, built with Flutter.
## Getting Started ## Getting Started

View file

@ -5,7 +5,7 @@ plugins {
} }
android { android {
namespace = "com.conversionpath.ratune_mobile" namespace = "com.laforrestchurch.timbre"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@ -16,7 +16,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.conversionpath.ratune_mobile" applicationId = "com.laforrestchurch.timbre"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View file

@ -5,7 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<application <application
android:label="ratune_mobile" android:label="Timbre"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View file

@ -1,4 +1,4 @@
package com.conversionpath.ratune_mobile package com.laforrestchurch.timbre
import com.ryanheise.audioservice.AudioServiceActivity import com.ryanheise.audioservice.AudioServiceActivity

View file

@ -1,4 +1,4 @@
# Codemagic CI/CD for ratune_mobile (Flutter). # Codemagic CI/CD for Timbre (Flutter).
# The repo root IS the Flutter project, so scripts run from the app directory # The repo root IS the Flutter project, so scripts run from the app directory
# with no `working_directory` override. Two workflows: Android (Linux) and # with no `working_directory` override. Two workflows: Android (Linux) and
# iOS (mac). Docs: https://docs.codemagic.io/yaml/yaml-getting-started/ # iOS (mac). Docs: https://docs.codemagic.io/yaml/yaml-getting-started/
@ -81,12 +81,27 @@ workflows:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ios: ios:
name: iOS (unsigned build) name: iOS (TestFlight)
instance_type: mac_mini_m1 instance_type: mac_mini_m1
max_build_duration: 60 max_build_duration: 60
# Reference NAME of the App Store Connect API key integration you added in
# Codemagic (Teams → Integrations → App Store Connect). Replace the value
# below with whatever you named it there.
integrations:
app_store_connect: Ratune ASC key
environment: environment:
flutter: 3.44.8 flutter: 3.44.8
java: 17 java: 17
ios_signing:
distribution_type: app_store
bundle_identifier: com.laforrestchurch.timbre
vars:
# The app's numeric Apple ID (App Store Connect → your app → App
# Information → General → Apple ID). Used to auto-bump the build number
# so TestFlight never rejects a duplicate. Fill in after you create the
# app record in App Store Connect. (Until then the build number falls
# back to 1 — fine for the very first upload.)
APP_STORE_APPLE_ID: 0000000000
cache: cache:
cache_paths: cache_paths:
- $HOME/.pub-cache - $HOME/.pub-cache
@ -96,21 +111,25 @@ workflows:
- *get_deps - *get_deps
- *analyze - *analyze
- *unit_tests - *unit_tests
# Compiles the iOS app without code signing — verifies the build on CI. - name: Fetch signing files
# Produces a .app (not an installable/uploadable .ipa). # Pulls (or creates) the distribution cert + provisioning profile for
- name: Build iOS (no codesign) # the bundle id via the App Store Connect integration, and writes the
script: flutter build ios --release --no-codesign # export_options.plist the build step reads.
script: xcode-project use-profiles
- name: Build signed IPA
script: |
BUILD_NUMBER=$(app-store-connect get-latest-testflight-build-number "$APP_STORE_APPLE_ID" 2>/dev/null || echo 0)
flutter build ipa --release \
--build-number=$(( BUILD_NUMBER + 1 )) \
--export-options-plist=/Users/builder/export_options.plist
artifacts: artifacts:
- build/ios/iphoneos/*.app - build/ios/ipa/*.ipa
- flutter_drive.log publishing:
app_store_connect:
# --- To produce a signed, uploadable IPA instead: configure App Store auth: integration
# --- Connect + signing in Codemagic (Teams → Integrations → App Store submit_to_testflight: true
# --- Connect), then add `ios_signing` to `environment` and replace the # Internal testers automatically receive every build once Apple finishes
# --- build step with: # processing (no Beta App Review). To also push to a named group,
# - name: Set up provisioning profiles # uncomment and match the group name from App Store Connect → TestFlight:
# script: xcode-project use-profiles # beta_groups:
# - name: Build IPA # - Internal Testers
# script: flutter build ipa --release --export-options-plist=/Users/builder/export_options.plist
# --- with artifacts: build/ios/ipa/*.ipa and
# --- publishing.app_store_connect.submit_to_testflight: true

View file

@ -382,7 +382,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.conversionpath.ratuneMobile; PRODUCT_BUNDLE_IDENTIFIER = com.laforrestchurch.timbre;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -398,7 +398,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.conversionpath.ratuneMobile.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.laforrestchurch.timbre.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -415,7 +415,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.conversionpath.ratuneMobile.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.laforrestchurch.timbre.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -430,7 +430,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.conversionpath.ratuneMobile.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.laforrestchurch.timbre.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -561,7 +561,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.conversionpath.ratuneMobile; PRODUCT_BUNDLE_IDENTIFIER = com.laforrestchurch.timbre;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -583,7 +583,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.conversionpath.ratuneMobile; PRODUCT_BUNDLE_IDENTIFIER = com.laforrestchurch.timbre;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View file

@ -11,7 +11,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Ratune Mobile</string> <string>Timbre</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -19,7 +19,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>ratune_mobile</string> <string>timbre</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View file

@ -129,7 +129,7 @@ class DownloadController extends StateNotifier<DownloadState> {
final Dio _dio = Dio(BaseOptions( final Dio _dio = Dio(BaseOptions(
receiveTimeout: const Duration(minutes: 5), receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'ratune-mobile'}, headers: {'User-Agent': 'timbre'},
)); ));
static const int _maxConcurrent = 3; 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). // hang we still start the app (playback just loses lock-screen controls).
try { try {
await JustAudioBackground.init( await JustAudioBackground.init(
androidNotificationChannelId: 'com.conversionpath.ratune_mobile.audio', androidNotificationChannelId: 'com.laforrestchurch.timbre.audio',
androidNotificationChannelName: 'Ratune playback', androidNotificationChannelName: 'Timbre playback',
androidNotificationOngoing: true, androidNotificationOngoing: true,
).timeout(const Duration(seconds: 8)); ).timeout(const Duration(seconds: 8));
} catch (e, st) { } catch (e, st) {
debugPrint('JustAudioBackground.init failed: $e\n$st'); debugPrint('JustAudioBackground.init failed: $e\n$st');
} }
} }
runApp(const ProviderScope(child: RatuneApp())); runApp(const ProviderScope(child: TimbreApp()));
} }
class RatuneApp extends ConsumerWidget { class TimbreApp extends ConsumerWidget {
const RatuneApp({super.key}); const TimbreApp({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// The theme rebuilds whenever the album-art accent changes. // The theme rebuilds whenever the album-art accent changes.
final accent = ref.watch(accentProvider); final accent = ref.watch(accentProvider);
return MaterialApp( return MaterialApp(
title: 'Ratune', title: 'Timbre',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildRatuneTheme(accent), theme: buildTimbreTheme(accent),
home: const AppShell(), home: const AppShell(),
); );
} }

View file

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

View file

@ -25,17 +25,17 @@ class BrowserScreen extends ConsumerWidget {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg, TimbreSpacing.lg,
RatuneSpacing.xl, TimbreSpacing.xl,
RatuneSpacing.lg, TimbreSpacing.lg,
RatuneSpacing.lg, TimbreSpacing.lg,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Wrap( Wrap(
spacing: RatuneSpacing.xl, spacing: TimbreSpacing.xl,
runSpacing: RatuneSpacing.xs, runSpacing: TimbreSpacing.xs,
children: [ children: [
_Action( _Action(
icon: Icons.search, icon: Icons.search,
@ -67,9 +67,9 @@ class BrowserScreen extends ConsumerWidget {
), ),
], ],
), ),
const SizedBox(height: RatuneSpacing.md), const SizedBox(height: TimbreSpacing.md),
_ModeSelector(mode: mode), _ModeSelector(mode: mode),
const SizedBox(height: RatuneSpacing.lg), const SizedBox(height: TimbreSpacing.lg),
Expanded( Expanded(
child: client == null child: client == null
? const HairlinePanel( ? const HairlinePanel(
@ -105,13 +105,13 @@ class _ModeSelector extends ConsumerWidget {
onTap: () => ref.read(browseModeProvider.notifier).state = m, onTap: () => ref.read(browseModeProvider.notifier).state = m,
child: Container( child: Container(
constraints: constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget), const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.md), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
text, text,
style: TextStyle( style: TextStyle(
color: active ? RatuneColors.foreground : RatuneColors.dimmed, color: active ? TimbreColors.foreground : TimbreColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400, fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration: decoration:
active ? TextDecoration.underline : TextDecoration.none, active ? TextDecoration.underline : TextDecoration.none,
@ -127,9 +127,9 @@ class _ModeSelector extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
label('Artists', BrowseMode.artists), label('Artists', BrowseMode.artists),
const Text('|', style: TextStyle(color: RatuneColors.border)), const Text('|', style: TextStyle(color: TimbreColors.border)),
label('Albums', BrowseMode.albums), label('Albums', BrowseMode.albums),
const Text('|', style: TextStyle(color: RatuneColors.border)), const Text('|', style: TextStyle(color: TimbreColors.border)),
label('Tracks', BrowseMode.tracks), label('Tracks', BrowseMode.tracks),
], ],
); );
@ -148,7 +148,7 @@ class _ArtistsPanel extends ConsumerWidget {
trailing: artists.hasValue && artists.value!.isNotEmpty trailing: artists.hasValue && artists.value!.isNotEmpty
? '(${artists.value!.length})' ? '(${artists.value!.length})'
: null, : null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md), padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: artists.when( child: artists.when(
loading: () => const _Centered(child: _Loading()), loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')), error: (e, _) => _Centered(child: _ErrorText('$e')),
@ -186,7 +186,7 @@ class _AlbumsPanel extends ConsumerWidget {
trailing: albums.hasValue && albums.value!.isNotEmpty trailing: albums.hasValue && albums.value!.isNotEmpty
? '(${albums.value!.length})' ? '(${albums.value!.length})'
: null, : null,
padding: const EdgeInsets.all(RatuneSpacing.md), padding: const EdgeInsets.all(TimbreSpacing.md),
child: albums.when( child: albums.when(
loading: () => const _Centered(child: _Loading()), loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')), error: (e, _) => _Centered(child: _ErrorText('$e')),
@ -200,8 +200,8 @@ class _AlbumsPanel extends ConsumerWidget {
gridDelegate: gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount( SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols, crossAxisCount: cols,
mainAxisSpacing: RatuneSpacing.md, mainAxisSpacing: TimbreSpacing.md,
crossAxisSpacing: RatuneSpacing.md, crossAxisSpacing: TimbreSpacing.md,
// Square art + two caption lines; extra vertical slack so // Square art + two caption lines; extra vertical slack so
// the tile never sub-pixel-overflows. // the tile never sub-pixel-overflows.
childAspectRatio: 0.68, childAspectRatio: 0.68,
@ -241,7 +241,7 @@ class _AlbumTile extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: ColoredBox( child: ColoredBox(
color: RatuneColors.surface, color: TimbreColors.surface,
child: artUri != null child: artUri != null
? Image.network( ? Image.network(
artUri!, artUri!,
@ -253,12 +253,12 @@ class _AlbumTile extends StatelessWidget {
: const _AlbumArtFallback(), : const _AlbumArtFallback(),
), ),
), ),
const SizedBox(height: RatuneSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
Text( Text(
album.name ?? 'Unknown album', album.name ?? 'Unknown album',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground), style: const TextStyle(color: TimbreColors.foreground),
), ),
if (album.artist != null) if (album.artist != null)
Text( Text(
@ -266,7 +266,7 @@ class _AlbumTile extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12), color: TimbreColors.dimmed, fontSize: 12),
), ),
], ],
), ),
@ -279,7 +279,7 @@ class _AlbumArtFallback extends StatelessWidget {
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined, 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
const _Loading(), const _Loading(),
const SizedBox(height: RatuneSpacing.md), const SizedBox(height: TimbreSpacing.md),
Text(label, style: const TextStyle(color: RatuneColors.dimmed)), Text(label, style: const TextStyle(color: TimbreColors.dimmed)),
], ],
), ),
); );
@ -357,7 +357,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
title: 'Tracks', title: 'Tracks',
active: true, active: true,
trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null, trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md), padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Column( child: Column(
children: [ children: [
Align( Align(
@ -368,11 +368,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
: () => ref.read(libraryIndexProvider.notifier).refresh(), : () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg, horizontal: TimbreSpacing.lg,
vertical: RatuneSpacing.sm, vertical: TimbreSpacing.sm,
), ),
child: Text('↻ refresh', child: Text('↻ refresh',
style: TextStyle(color: RatuneColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
), ),
), ),
), ),
@ -395,13 +395,13 @@ class _Action extends StatelessWidget {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.sm), padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 16, color: RatuneColors.dimmed), Icon(icon, size: 16, color: TimbreColors.dimmed),
const SizedBox(width: RatuneSpacing.sm), const SizedBox(width: TimbreSpacing.sm),
Text(label, style: const TextStyle(color: RatuneColors.foreground)), Text(label, style: const TextStyle(color: TimbreColors.foreground)),
], ],
), ),
), ),
@ -561,33 +561,33 @@ class BrowseRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
constraints: constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget), const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg), padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row( child: Row(
children: [ children: [
if (leading != null) if (leading != null)
SizedBox( SizedBox(
width: 28, width: 28,
child: Text(leading!, child: Text(leading!,
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
), ),
Expanded( Expanded(
child: Text( child: Text(
title, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground), style: const TextStyle(color: TimbreColors.foreground),
), ),
), ),
if (isDone) if (isDone)
Padding( Padding(
padding: const EdgeInsets.only(left: RatuneSpacing.sm), padding: const EdgeInsets.only(left: TimbreSpacing.sm),
child: child:
Icon(Icons.download_done, size: 14, color: accent), Icon(Icons.download_done, size: 14, color: accent),
) )
else if (isActive) else if (isActive)
const Padding( const Padding(
padding: EdgeInsets.only(left: RatuneSpacing.sm), padding: EdgeInsets.only(left: TimbreSpacing.sm),
child: SizedBox( child: SizedBox(
width: 12, width: 12,
height: 12, height: 12,
@ -595,9 +595,9 @@ class BrowseRow extends StatelessWidget {
), ),
), ),
if (trailing != null) ...[ if (trailing != null) ...[
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
Text(trailing!, Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
], ],
if (onPlayNext != null) if (onPlayNext != null)
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!), _RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
@ -612,7 +612,7 @@ class BrowseRow extends StatelessWidget {
onRemoveDownload: onRemoveDownload, onRemoveDownload: onRemoveDownload,
), ),
if (onPlayNext == null && onAddToQueue == null && !_hasMenu) 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20, color: RatuneColors.dimmed), icon: const Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
color: RatuneColors.surface, color: TimbreColors.surface,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: constraints:
const BoxConstraints(minWidth: RatuneSpacing.minTouchTarget), const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
onSelected: (v) { onSelected: (v) {
switch (v) { switch (v) {
case 'playlist': case 'playlist':
@ -684,9 +684,9 @@ class _RowIcon extends StatelessWidget {
onTap: onTap, onTap: onTap,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: SizedBox( child: SizedBox(
width: RatuneSpacing.minTouchTarget, width: TimbreSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget,
child: Icon(icon, size: 20, color: RatuneColors.dimmed), child: Icon(icon, size: 20, color: TimbreColors.dimmed),
), ),
); );
} }
@ -728,11 +728,11 @@ class _NotConnected extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Not connected.', Text('Not connected.',
style: TextStyle(color: RatuneColors.foreground)), style: TextStyle(color: TimbreColors.foreground)),
SizedBox(height: RatuneSpacing.sm), SizedBox(height: TimbreSpacing.sm),
Text('Tap the status bar to add a Subsonic server.', Text('Tap the status bar to add a Subsonic server.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: RatuneColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
], ],
), ),
); );
@ -744,7 +744,7 @@ class _Centered extends StatelessWidget {
final Widget child; final Widget child;
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(RatuneSpacing.xl), padding: const EdgeInsets.all(TimbreSpacing.xl),
child: Center(child: child), child: Center(child: child),
); );
} }
@ -766,7 +766,7 @@ class _ErrorText extends StatelessWidget {
Widget build(BuildContext context) => Text( Widget build(BuildContext context) => Text(
message, message,
textAlign: TextAlign.center, 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) { Future<void> showConnectSheet(BuildContext context) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
backgroundColor: RatuneColors.background, backgroundColor: TimbreColors.background,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => const _ConnectSheet(), builder: (_) => const _ConnectSheet(),
); );
@ -66,10 +66,10 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final accent = Theme.of(context).colorScheme.primary; final accent = Theme.of(context).colorScheme.primary;
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: RatuneSpacing.xl, left: TimbreSpacing.xl,
right: RatuneSpacing.xl, right: TimbreSpacing.xl,
top: RatuneSpacing.xl, top: TimbreSpacing.xl,
bottom: MediaQuery.of(context).viewInsets.bottom + RatuneSpacing.xl, bottom: MediaQuery.of(context).viewInsets.bottom + TimbreSpacing.xl,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -77,22 +77,22 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
children: [ children: [
Text('Connect to server', Text('Connect to server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)), 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', _field(_url, 'Server URL', 'https://navidrome.example.com',
keyboard: TextInputType.url), keyboard: TextInputType.url),
_field(_user, 'Username', 'you'), _field(_user, 'Username', 'you'),
_field(_pass, 'Password', '••••••••', obscure: true), _field(_pass, 'Password', '••••••••', obscure: true),
if (conn.status == ConnStatus.error && conn.error != null) ...[ if (conn.status == ConnStatus.error && conn.error != null) ...[
const SizedBox(height: RatuneSpacing.md), const SizedBox(height: TimbreSpacing.md),
Text(conn.error!, Text(conn.error!,
style: const TextStyle(color: Color(0xFFE06C75))), style: const TextStyle(color: Color(0xFFE06C75))),
], ],
const SizedBox(height: RatuneSpacing.lg), const SizedBox(height: TimbreSpacing.lg),
FilledButton( FilledButton(
onPressed: _busy ? null : _connect, onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: accent, backgroundColor: accent,
foregroundColor: RatuneColors.background, foregroundColor: TimbreColors.background,
shape: const RoundedRectangleBorder(), shape: const RoundedRectangleBorder(),
), ),
child: _busy child: _busy
@ -116,22 +116,22 @@ class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
TextInputType? keyboard, TextInputType? keyboard,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.md), padding: const EdgeInsets.only(bottom: TimbreSpacing.md),
child: TextField( child: TextField(
controller: c, controller: c,
obscureText: obscure, obscureText: obscure,
keyboardType: keyboard, keyboardType: keyboard,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
style: const TextStyle(color: RatuneColors.foreground), style: const TextStyle(color: TimbreColors.foreground),
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
hintText: hint, hintText: hint,
labelStyle: const TextStyle(color: RatuneColors.dimmed), labelStyle: const TextStyle(color: TimbreColors.dimmed),
hintStyle: const TextStyle(color: RatuneColors.dimmed), hintStyle: const TextStyle(color: TimbreColors.dimmed),
enabledBorder: const OutlineInputBorder( enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: RatuneColors.border), borderSide: BorderSide(color: TimbreColors.border),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,

View file

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

View file

@ -29,14 +29,14 @@ class HomeScreen extends ConsumerWidget {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg, TimbreSpacing.lg,
RatuneSpacing.xl, TimbreSpacing.xl,
RatuneSpacing.lg, TimbreSpacing.lg,
RatuneSpacing.lg, TimbreSpacing.lg,
), ),
children: [ children: [
const _HeroCard(), const _HeroCard(),
const SizedBox(height: RatuneSpacing.xl), const SizedBox(height: TimbreSpacing.xl),
// Recently Played — from local history. // Recently Played — from local history.
if (recentAlbums.isNotEmpty) if (recentAlbums.isNotEmpty)
@ -93,10 +93,10 @@ class HomeScreen extends ConsumerWidget {
if (recentAlbums.isEmpty && client == null) if (recentAlbums.isEmpty && client == null)
const Padding( const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.xl), padding: EdgeInsets.only(top: TimbreSpacing.xl),
child: Text( child: Text(
'Connect to a server and start listening — your home fills in as you play.', '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( child: const Row(
children: [ children: [
Icon(Icons.library_music_outlined, Icon(Icons.library_music_outlined,
color: RatuneColors.dimmed, size: 40), color: TimbreColors.dimmed, size: 40),
SizedBox(width: RatuneSpacing.lg), SizedBox(width: TimbreSpacing.lg),
Expanded( Expanded(
child: Text('Browse your library to start listening', 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, width: 64,
height: 64, height: 64,
child: ColoredBox( child: ColoredBox(
color: RatuneColors.surface, color: TimbreColors.surface,
child: artUri != null child: artUri != null
? Image.network(artUri, ? Image.network(artUri,
key: ValueKey(artUri), key: ValueKey(artUri),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon( errorBuilder: (_, _, _) => const Icon(
Icons.album_outlined, color: RatuneColors.dimmed)) Icons.album_outlined, color: TimbreColors.dimmed))
: const Icon(Icons.album_outlined, : const Icon(Icons.album_outlined,
color: RatuneColors.dimmed), color: TimbreColors.dimmed),
), ),
), ),
const SizedBox(width: RatuneSpacing.lg), const SizedBox(width: TimbreSpacing.lg),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -192,7 +192,7 @@ class _HeroCard extends ConsumerWidget {
children: [ children: [
Icon(hasCurrent ? Icons.play_arrow : Icons.history, Icon(hasCurrent ? Icons.play_arrow : Icons.history,
size: 14, color: accent), size: 14, color: accent),
const SizedBox(width: RatuneSpacing.xs), const SizedBox(width: TimbreSpacing.xs),
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME', Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle( style: TextStyle(
color: accent, color: accent,
@ -201,20 +201,20 @@ class _HeroCard extends ConsumerWidget {
fontWeight: FontWeight.w700)), fontWeight: FontWeight.w700)),
], ],
), ),
const SizedBox(height: RatuneSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
Text(title, Text(title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: RatuneColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700)), fontWeight: FontWeight.w700)),
if (subtitle != null) if (subtitle != null)
Text(subtitle, Text(subtitle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
if (hasCurrent) ...[ if (hasCurrent) ...[
const SizedBox(height: RatuneSpacing.md), const SizedBox(height: TimbreSpacing.md),
BlockProgressBar(progress: progress, cells: 32, height: 6), BlockProgressBar(progress: progress, cells: 32, height: 6),
], ],
], ],
@ -239,10 +239,10 @@ class _HeroShell extends StatelessWidget {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.all(RatuneSpacing.lg), padding: const EdgeInsets.all(TimbreSpacing.lg),
decoration: BoxDecoration( decoration: BoxDecoration(
color: RatuneColors.surface, color: TimbreColors.surface,
border: Border.all(color: RatuneColors.borderActive), border: Border.all(color: TimbreColors.borderActive),
), ),
child: child, child: child,
), ),
@ -263,19 +263,19 @@ class _Shelf extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (cards.isEmpty) return const SizedBox.shrink(); if (cards.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.xl), padding: const EdgeInsets.only(bottom: TimbreSpacing.xl),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_ShelfHeader(title: title, onShuffle: onShuffle), _ShelfHeader(title: title, onShuffle: onShuffle),
const SizedBox(height: RatuneSpacing.md), const SizedBox(height: TimbreSpacing.md),
SizedBox( SizedBox(
height: 182, height: 182,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: cards.length, itemCount: cards.length,
separatorBuilder: (_, _) => separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
itemBuilder: (_, i) => cards[i], itemBuilder: (_, i) => cards[i],
), ),
), ),
@ -297,7 +297,7 @@ class _ShelfHeader extends StatelessWidget {
children: [ children: [
Text(title, Text(title,
style: const TextStyle( style: const TextStyle(
color: RatuneColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5)), letterSpacing: 0.5)),
const Spacer(), const Spacer(),
@ -306,9 +306,9 @@ class _ShelfHeader extends StatelessWidget {
onTap: onShuffle, onTap: onShuffle,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const SizedBox( child: const SizedBox(
width: RatuneSpacing.minTouchTarget, width: TimbreSpacing.minTouchTarget,
height: 28, 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, width: _size,
height: _size, height: _size,
child: ColoredBox( child: ColoredBox(
color: RatuneColors.surface, color: TimbreColors.surface,
child: artUri != null child: artUri != null
? Image.network(artUri!, ? Image.network(artUri!,
key: ValueKey(artUri), key: ValueKey(artUri),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (_, _, _) => errorBuilder: (_, _, _) =>
Icon(fallbackIcon, color: RatuneColors.dimmed)) Icon(fallbackIcon, color: TimbreColors.dimmed))
: Icon(fallbackIcon, color: RatuneColors.dimmed), : Icon(fallbackIcon, color: TimbreColors.dimmed),
), ),
), ),
), ),
const SizedBox(height: RatuneSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
Text(title, Text(title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)), style: const TextStyle(color: TimbreColors.foreground)),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: 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( child: const SizedBox(
width: _ArtCard._size, width: _ArtCard._size,
height: _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) { if (current == null) {
return const Center( return const Center(
child: Text('Nothing playing.', child: Text('Nothing playing.',
style: TextStyle(color: RatuneColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
); );
} }
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg, TimbreSpacing.lg,
RatuneSpacing.xl, TimbreSpacing.xl,
RatuneSpacing.lg, TimbreSpacing.lg,
RatuneSpacing.lg, TimbreSpacing.lg,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -53,21 +53,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
Expanded( Expanded(
child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(), child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(),
), ),
const SizedBox(height: RatuneSpacing.xl), const SizedBox(height: TimbreSpacing.xl),
_InfoStrip(song: current, state: state, accent: accent), _InfoStrip(song: current, state: state, accent: accent),
const SizedBox(height: RatuneSpacing.sm), const SizedBox(height: TimbreSpacing.sm),
_FavRating(song: current), _FavRating(song: current),
const SizedBox(height: RatuneSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
_Transport(state: state, ref: ref, accent: accent), _Transport(state: state, ref: ref, accent: accent),
if (!state.supported) if (!state.supported)
const Padding( const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.sm), padding: EdgeInsets.only(top: TimbreSpacing.sm),
child: Text( child: Text(
'Audio output unavailable on this platform — test on Android/iOS.', '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( _QueueToggle(
showQueue: _showQueue, showQueue: _showQueue,
queueLength: state.queue.length, queueLength: state.queue.length,
@ -102,8 +102,8 @@ class _QueueToggle extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg, horizontal: TimbreSpacing.lg,
vertical: RatuneSpacing.sm, vertical: TimbreSpacing.sm,
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -111,13 +111,13 @@ class _QueueToggle extends StatelessWidget {
Icon( Icon(
showQueue ? Icons.album_outlined : Icons.queue_music, showQueue ? Icons.album_outlined : Icons.queue_music,
size: 16, size: 16,
color: showQueue ? accent : RatuneColors.dimmed, color: showQueue ? accent : TimbreColors.dimmed,
), ),
const SizedBox(width: RatuneSpacing.sm), const SizedBox(width: TimbreSpacing.sm),
Text( Text(
showQueue ? 'Album art' : 'Queue ($queueLength)', showQueue ? 'Album art' : 'Queue ($queueLength)',
style: TextStyle( style: TextStyle(
color: showQueue ? accent : RatuneColors.foreground, color: showQueue ? accent : TimbreColors.foreground,
), ),
), ),
], ],
@ -140,7 +140,7 @@ class _QueuePanel extends ConsumerWidget {
return HairlinePanel( return HairlinePanel(
title: 'Queue', title: 'Queue',
trailing: '(${state.queue.length})', trailing: '(${state.queue.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md), padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: ListView.builder( child: ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: state.queue.length, itemCount: state.queue.length,
@ -153,15 +153,15 @@ class _QueuePanel extends ConsumerWidget {
.playSongs(state.queue, startIndex: i), .playSongs(state.queue, startIndex: i),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg, horizontal: TimbreSpacing.lg,
vertical: RatuneSpacing.xs, vertical: TimbreSpacing.xs,
), ),
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
width: 28, width: 28,
child: Text('${i + 1}', child: Text('${i + 1}',
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
), ),
Expanded( Expanded(
child: Text( child: Text(
@ -169,23 +169,23 @@ class _QueuePanel extends ConsumerWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: isCurrent ? accent : RatuneColors.foreground, color: isCurrent ? accent : TimbreColors.foreground,
fontWeight: fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400, isCurrent ? FontWeight.w700 : FontWeight.w400,
), ),
), ),
), ),
Text(_fmt(song.duration), Text(_fmt(song.duration),
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
InkWell( InkWell(
onTap: () => onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i), ref.read(playbackProvider.notifier).removeAt(i),
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const SizedBox( child: const SizedBox(
width: RatuneSpacing.minTouchTarget, width: TimbreSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.close, child: Icon(Icons.close,
size: 18, color: RatuneColors.dimmed), size: 18, color: TimbreColors.dimmed),
), ),
), ),
], ],
@ -216,11 +216,11 @@ class _AlbumArtPanel extends ConsumerWidget {
return HairlinePanel( return HairlinePanel(
title: 'Album Art', title: 'Album Art',
padding: const EdgeInsets.all(RatuneSpacing.md), padding: const EdgeInsets.all(TimbreSpacing.md),
child: AspectRatio( child: AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: ColoredBox( child: ColoredBox(
color: RatuneColors.surface, color: TimbreColors.surface,
child: artUri != null child: artUri != null
? Image.network( ? Image.network(
artUri, artUri,
@ -263,15 +263,15 @@ class _FavRating extends ConsumerWidget {
onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song), onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song),
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Padding( child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm), padding: const EdgeInsets.all(TimbreSpacing.sm),
child: Icon( child: Icon(
starred ? Icons.favorite : Icons.favorite_border, starred ? Icons.favorite : Icons.favorite_border,
color: starred ? accent : RatuneColors.dimmed, color: starred ? accent : TimbreColors.dimmed,
size: 22, size: 22,
), ),
), ),
), ),
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
for (int i = 1; i <= 5; i++) for (int i = 1; i <= 5; i++)
GestureDetector( GestureDetector(
onTap: () => ref onTap: () => ref
@ -282,7 +282,7 @@ class _FavRating extends ConsumerWidget {
child: Icon( child: Icon(
i <= rating ? Icons.star : Icons.star_border, i <= rating ? Icons.star : Icons.star_border,
size: 18, 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]), onTap: () => showAddToPlaylistSheet(context, songs: [song]),
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(RatuneSpacing.sm), padding: EdgeInsets.all(TimbreSpacing.sm),
child: Icon(Icons.playlist_add, child: Icon(Icons.playlist_add,
size: 22, color: RatuneColors.dimmed), size: 22, color: TimbreColors.dimmed),
), ),
), ),
InkWell( InkWell(
@ -305,7 +305,7 @@ class _FavRating extends ConsumerWidget {
}, },
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Padding( child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm), padding: const EdgeInsets.all(TimbreSpacing.sm),
child: isDownloading child: isDownloading
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
@ -317,7 +317,7 @@ class _FavRating extends ConsumerWidget {
? Icons.download_done ? Icons.download_done
: Icons.download_outlined, : Icons.download_outlined,
size: 22, size: 22,
color: isDownloaded ? accent : RatuneColors.dimmed, color: isDownloaded ? accent : TimbreColors.dimmed,
), ),
), ),
), ),
@ -348,19 +348,19 @@ class _InfoStrip extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)), style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist', Text(song.artist ?? 'Unknown artist',
style: const TextStyle(color: RatuneColors.foreground)), style: const TextStyle(color: TimbreColors.foreground)),
if (album.isNotEmpty) if (album.isNotEmpty)
Text(album, style: const TextStyle(color: RatuneColors.dimmed)), Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(height: RatuneSpacing.md), const SizedBox(height: TimbreSpacing.md),
Row( Row(
children: [ children: [
Text(_fmtDur(state.position), Text(_fmtDur(state.position),
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
Expanded(child: BlockProgressBar(progress: state.progress)), Expanded(child: BlockProgressBar(progress: state.progress)),
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
Text(_fmtDur(state.duration), 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, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
_btn(Icons.shuffle, controller.toggleShuffle, _btn(Icons.shuffle, controller.toggleShuffle,
color: state.shuffle ? accent : RatuneColors.dimmed), color: state.shuffle ? accent : TimbreColors.dimmed),
_btn(Icons.skip_previous, controller.previous), _btn(Icons.skip_previous, controller.previous),
_btn(state.playing ? Icons.pause : Icons.play_arrow, _btn(state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause, controller.togglePlayPause,
color: accent, size: 40), color: accent, size: 40),
_btn(Icons.skip_next, controller.next), _btn(Icons.skip_next, controller.next),
_btn(loopIcon, controller.cycleLoop, _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, Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground, double size = 28}) { {Color color = TimbreColors.foreground, double size = 28}) {
return IconButton( return IconButton(
onPressed: onTap, onPressed: onTap,
icon: Icon(icon, color: color, size: size), icon: Icon(icon, color: color, size: size),
@ -413,7 +413,7 @@ class _ArtFallback extends StatelessWidget {
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined, 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( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.lg), padding: const EdgeInsets.all(TimbreSpacing.lg),
child: HairlinePanel( child: HairlinePanel(
title: 'Playlists', title: 'Playlists',
active: true, active: true,
trailing: trailing:
state.playlists.isEmpty ? null : '(${state.playlists.length})', 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 child: state.playlists.isEmpty
? Center( ? Center(
child: Text( child: Text(
@ -48,7 +48,7 @@ class PlaylistsScreen extends ConsumerWidget {
? 'No playlists yet. Tap + to create one.' ? 'No playlists yet. Tap + to create one.'
: 'Connect to a server to see playlists.', : 'Connect to a server to see playlists.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: RatuneColors.dimmed), style: const TextStyle(color: TimbreColors.dimmed),
), ),
) )
: ListView.builder( : ListView.builder(
@ -99,7 +99,7 @@ class PlaylistsScreen extends ConsumerWidget {
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface, backgroundColor: TimbreColors.surface,
title: Text('Delete "${p.name}"?'), title: Text('Delete "${p.name}"?'),
actions: [ actions: [
TextButton( TextButton(
@ -136,12 +136,12 @@ class _PlaylistRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
constraints: constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget), const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg), padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.queue_music, size: 18, color: RatuneColors.dimmed), const Icon(Icons.queue_music, size: 18, color: TimbreColors.dimmed),
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -150,19 +150,19 @@ class _PlaylistRow extends StatelessWidget {
Text(name, Text(name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)), style: const TextStyle(color: TimbreColors.foreground)),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
style: const TextStyle( style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
), ),
if (onRename != null || onDelete != null) if (onRename != null || onDelete != null)
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed), size: 20, color: TimbreColors.dimmed),
color: RatuneColors.surface, color: TimbreColors.surface,
onSelected: (v) { onSelected: (v) {
if (v == 'rename') onRename?.call(); if (v == 'rename') onRename?.call();
if (v == 'delete') onDelete?.call(); if (v == 'delete') onDelete?.call();
@ -247,16 +247,16 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
child: detail == null child: detail == null
? const Center( ? const Center(
child: Text('Loading…', child: Text('Loading…',
style: TextStyle(color: RatuneColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
) )
: songs.isEmpty : songs.isEmpty
? const Center( ? const Center(
child: Text('This playlist is empty.', child: Text('This playlist is empty.',
style: TextStyle(color: RatuneColors.dimmed)), style: TextStyle(color: TimbreColors.dimmed)),
) )
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md), vertical: TimbreSpacing.md),
itemCount: songs.length, itemCount: songs.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final song = songs[i]; final song = songs[i];
@ -302,14 +302,14 @@ class _TrackRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
constraints: constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget), const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg), padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
width: 28, width: 28,
child: Text('$index', child: Text('$index',
style: const TextStyle(color: RatuneColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
), ),
Expanded( Expanded(
child: Column( child: Column(
@ -319,20 +319,20 @@ class _TrackRow extends StatelessWidget {
Text(song.title ?? 'Untitled', Text(song.title ?? 'Untitled',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)), style: const TextStyle(color: TimbreColors.foreground)),
if (song.artist != null) if (song.artist != null)
Text(song.artist!, Text(song.artist!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)), color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed), size: 20, color: TimbreColors.dimmed),
color: RatuneColors.surface, color: TimbreColors.surface,
onSelected: (v) { onSelected: (v) {
switch (v) { switch (v) {
case 'next': case 'next':

View file

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

View file

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

View file

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

View file

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

View file

@ -10,13 +10,13 @@ import 'tokens.dart';
/// then interpolated toward over ~400ms. Phase 1 just exposes the default and /// then interpolated toward over ~400ms. Phase 1 just exposes the default and
/// a setter; the extraction + animation land in Phase 2 with playback. /// a setter; the extraction + animation land in Phase 2 with playback.
class AccentNotifier extends StateNotifier<Color> { class AccentNotifier extends StateNotifier<Color> {
AccentNotifier() : super(RatuneColors.accentDefault); AccentNotifier() : super(TimbreColors.accentDefault);
/// Snap to a new accent (e.g. from album-art extraction). /// Snap to a new accent (e.g. from album-art extraction).
void set(Color color) => state = color; void set(Color color) => state = color;
/// Return to the fixed default (e.g. no art / `static` theme). /// 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>( 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 /// 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 Ratune's screenshots). The [accent]
/// is passed in so the theme rebuilds when album-art extraction changes it. /// 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( final mono = GoogleFonts.jetBrainsMonoTextTheme(
ThemeData.dark().textTheme, ThemeData.dark().textTheme,
).apply( ).apply(
bodyColor: RatuneColors.foreground, bodyColor: TimbreColors.foreground,
displayColor: RatuneColors.foreground, displayColor: TimbreColors.foreground,
); );
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(
seedColor: accent, seedColor: accent,
brightness: Brightness.dark, brightness: Brightness.dark,
surface: RatuneColors.surface, surface: TimbreColors.surface,
).copyWith( ).copyWith(
primary: accent, primary: accent,
secondary: accent, secondary: accent,
onSurface: RatuneColors.foreground, onSurface: TimbreColors.foreground,
); );
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: RatuneColors.background, scaffoldBackgroundColor: TimbreColors.background,
canvasColor: RatuneColors.background, canvasColor: TimbreColors.background,
colorScheme: scheme, colorScheme: scheme,
textTheme: mono, textTheme: mono,
primaryTextTheme: mono, primaryTextTheme: mono,
dividerColor: RatuneColors.border, dividerColor: TimbreColors.border,
splashFactory: NoSplash.splashFactory, splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
// Keep chrome flat and quiet — the content (and accent) carry the look. // Keep chrome flat and quiet — the content (and accent) carry the look.
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: RatuneColors.background, backgroundColor: TimbreColors.background,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
), ),
listTileTheme: const ListTileThemeData( listTileTheme: const ListTileThemeData(
dense: true, 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* /// The palette is intentionally near-black and low-contrast; the *accent*
/// is the one lively color and, in the `dynamic` theme, is extracted live /// is the one lively color and, in the `dynamic` theme, is extracted live
/// from the current album art (Phase 2). Everything else stays fixed. /// from the current album art (Phase 2). Everything else stays fixed.
class RatuneColors { class TimbreColors {
const RatuneColors._(); const TimbreColors._();
/// App canvas — `background = #1a1a1a`. /// App canvas — `background = #1a1a1a`.
static const Color background = Color(0xFF1A1A1A); static const Color background = Color(0xFF1A1A1A);
@ -34,8 +34,8 @@ class RatuneColors {
} }
/// Spacing scale — dense, "player as instrument" rather than airy mobile cards. /// Spacing scale — dense, "player as instrument" rather than airy mobile cards.
class RatuneSpacing { class TimbreSpacing {
const RatuneSpacing._(); const TimbreSpacing._();
static const double xs = 2; static const double xs = 2;
static const double sm = 4; static const double sm = 4;

View file

@ -37,7 +37,7 @@ class BlockProgressBar extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 0.5), padding: const EdgeInsets.symmetric(horizontal: 0.5),
child: ColoredBox( 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, required this.child,
this.trailing, this.trailing,
this.active = false, this.active = false,
this.padding = const EdgeInsets.all(RatuneSpacing.lg), this.padding = const EdgeInsets.all(TimbreSpacing.lg),
this.backgroundColor = RatuneColors.background, this.backgroundColor = TimbreColors.background,
}); });
/// Panel label, e.g. "Queue". Rendered uppercase-ish in mono. /// Panel label, e.g. "Queue". Rendered uppercase-ish in mono.
@ -41,9 +41,9 @@ class HairlinePanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final borderColor = final borderColor =
active ? RatuneColors.borderActive : RatuneColors.border; active ? TimbreColors.borderActive : TimbreColors.border;
final titleColor = final titleColor =
active ? Theme.of(context).colorScheme.primary : RatuneColors.dimmed; active ? Theme.of(context).colorScheme.primary : TimbreColors.dimmed;
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
@ -56,12 +56,12 @@ class HairlinePanel extends StatelessWidget {
child: Padding(padding: padding, child: child), child: Padding(padding: padding, child: child),
), ),
Positioned( Positioned(
left: RatuneSpacing.lg, left: TimbreSpacing.lg,
top: 0, top: 0,
child: Container( child: Container(
color: backgroundColor, color: backgroundColor,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.md, horizontal: TimbreSpacing.md,
), ),
child: Text.rich( child: Text.rich(
TextSpan( TextSpan(
@ -77,7 +77,7 @@ class HairlinePanel extends StatelessWidget {
if (trailing != null) if (trailing != null)
TextSpan( TextSpan(
text: ' $trailing', 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, ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex,
child: Container( child: Container(
height: 52, height: 52,
color: RatuneColors.surface, color: TimbreColors.surface,
padding: const EdgeInsets.only(left: RatuneSpacing.md), padding: const EdgeInsets.only(left: TimbreSpacing.md),
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: ColoredBox( child: ColoredBox(
color: RatuneColors.background, color: TimbreColors.background,
child: artUri != null child: artUri != null
? Image.network( ? Image.network(
artUri, artUri,
@ -58,7 +58,7 @@ class MiniPlayer extends ConsumerWidget {
: const _ArtFallback(), : const _ArtFallback(),
), ),
), ),
const SizedBox(width: RatuneSpacing.md), const SizedBox(width: TimbreSpacing.md),
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -69,7 +69,7 @@ class MiniPlayer extends ConsumerWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: RatuneColors.foreground, color: TimbreColors.foreground,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@ -77,7 +77,7 @@ class MiniPlayer extends ConsumerWidget {
current.artist ?? 'Unknown artist', current.artist ?? 'Unknown artist',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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, Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground}) { {Color color = TimbreColors.foreground}) {
return IconButton( return IconButton(
onPressed: onTap, onPressed: onTap,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -119,7 +119,7 @@ class _MiniProgress extends ConsumerWidget {
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: progress, value: progress,
minHeight: 2, minHeight: 2,
backgroundColor: RatuneColors.border, backgroundColor: TimbreColors.border,
valueColor: AlwaysStoppedAnimation<Color>(accent), valueColor: AlwaysStoppedAnimation<Color>(accent),
), ),
); );
@ -131,6 +131,6 @@ class _ArtFallback extends StatelessWidget {
@override @override
Widget build(BuildContext context) => const Center( Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined, child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 22), color: TimbreColors.dimmed, size: 22),
); );
} }

View file

@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "ratune_mobile") set(BINARY_NAME "timbre")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.conversionpath.ratune_mobile") set(APPLICATION_ID "com.laforrestchurch.timbre")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View file

@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "ratune_mobile"); gtk_header_bar_set_title(header_bar, "Timbre");
gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "ratune_mobile"); gtk_window_set_title(window, "Timbre");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View file

@ -1,5 +1,5 @@
name: ratune_mobile name: timbre
description: "A new Flutter project." description: "Timbre — a Subsonic music client."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev

View file

@ -1,8 +1,8 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ratune_mobile/playlists/playlists.dart'; import 'package:timbre/playlists/playlists.dart';
import 'package:ratune_mobile/settings/settings_store.dart'; import 'package:timbre/settings/settings_store.dart';
import 'package:ratune_mobile/subsonic/models.dart'; import 'package:timbre/subsonic/models.dart';
import 'package:ratune_mobile/subsonic/subsonic_client.dart'; import 'package:timbre/subsonic/subsonic_client.dart';
/// Fake client so playlist mutations can be tested without a server. Methods /// Fake client so playlist mutations can be tested without a server. Methods
/// are overridden; the (unused) real Dio is created but never hit. /// are overridden; the (unused) real Dio is created but never hit.

View file

@ -1,11 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ratune_mobile/main.dart'; import 'package:timbre/main.dart';
void main() { void main() {
testWidgets('App shell renders the three tabs', (WidgetTester tester) async { testWidgets('App shell renders the three tabs', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: RatuneApp())); await tester.pumpWidget(const ProviderScope(child: TimbreApp()));
await tester.pump(); await tester.pump();
expect(find.text('Home'), findsOneWidget); expect(find.text('Home'), findsOneWidget);