updates
This commit is contained in:
parent
fc22d11df1
commit
27a8a62475
32 changed files with 354 additions and 335 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# ratune_mobile
|
# Timbre
|
||||||
|
|
||||||
A new Flutter project.
|
A Subsonic music client, built with Flutter.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.conversionpath.ratune_mobile
|
package com.laforrestchurch.timbre
|
||||||
|
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue