max / audiofiles
70 files changed,
+732 insertions,
-34 deletions
| @@ -426,7 +426,12 @@ dependencies = [ | |||
| 426 | 426 | "cpal", | |
| 427 | 427 | "dirs", | |
| 428 | 428 | "eframe", | |
| 429 | + | "open", | |
| 429 | 430 | "parking_lot", | |
| 431 | + | "reqwest", | |
| 432 | + | "semver", | |
| 433 | + | "serde", | |
| 434 | + | "serde_json", | |
| 430 | 435 | "thiserror 2.0.18", | |
| 431 | 436 | "tokio", | |
| 432 | 437 | "tracing", | |
| @@ -472,6 +477,7 @@ dependencies = [ | |||
| 472 | 477 | "symphonia", | |
| 473 | 478 | "tempfile", | |
| 474 | 479 | "thiserror 2.0.18", | |
| 480 | + | "tracing", | |
| 475 | 481 | ] | |
| 476 | 482 | ||
| 477 | 483 | [[package]] | |
| @@ -508,6 +514,7 @@ dependencies = [ | |||
| 508 | 514 | "tempfile", | |
| 509 | 515 | "thiserror 2.0.18", | |
| 510 | 516 | "toml 0.8.23", | |
| 517 | + | "tracing", | |
| 511 | 518 | ] | |
| 512 | 519 | ||
| 513 | 520 | [[package]] | |
| @@ -4983,7 +4990,7 @@ dependencies = [ | |||
| 4983 | 4990 | ||
| 4984 | 4991 | [[package]] | |
| 4985 | 4992 | name = "synckit-client" | |
| 4986 | - | version = "0.2.1" | |
| 4993 | + | version = "0.2.2" | |
| 4987 | 4994 | dependencies = [ | |
| 4988 | 4995 | "argon2", | |
| 4989 | 4996 | "base64", | |
| @@ -4996,13 +5003,13 @@ dependencies = [ | |||
| 4996 | 5003 | "reqwest", | |
| 4997 | 5004 | "serde", | |
| 4998 | 5005 | "serde_json", | |
| 4999 | - | "sha2", | |
| 5000 | 5006 | "thiserror 2.0.18", | |
| 5001 | 5007 | "tokio", | |
| 5002 | 5008 | "tracing", | |
| 5003 | 5009 | "unicode-normalization", | |
| 5004 | 5010 | "urlencoding", | |
| 5005 | 5011 | "uuid 1.21.0", | |
| 5012 | + | "zeroize", | |
| 5006 | 5013 | ] | |
| 5007 | 5014 | ||
| 5008 | 5015 | [[package]] |
| @@ -43,3 +43,6 @@ base64 = "0.22" | |||
| 43 | 43 | chrono = "0.4" | |
| 44 | 44 | rand = "0.8" | |
| 45 | 45 | smallvec = "1.13" | |
| 46 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } | |
| 47 | + | semver = "1" | |
| 48 | + | open = "5" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "audiofiles-app" | |
| 3 | - | version = "0.2.1" | |
| 3 | + | version = "0.3.0" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [dependencies] | |
| @@ -16,3 +16,8 @@ thiserror = { workspace = true } | |||
| 16 | 16 | tracing-subscriber = { workspace = true } | |
| 17 | 17 | tray-icon = { workspace = true } | |
| 18 | 18 | tokio = { workspace = true } | |
| 19 | + | reqwest = { workspace = true } | |
| 20 | + | semver = { workspace = true } | |
| 21 | + | serde = { workspace = true } | |
| 22 | + | serde_json = { workspace = true } | |
| 23 | + | open = { workspace = true } |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | mod audio; | |
| 7 | 7 | mod tray; | |
| 8 | + | pub mod updater; | |
| 8 | 9 | ||
| 9 | 10 | use std::path::{Path, PathBuf}; | |
| 10 | 11 | use std::sync::Arc; | |
| @@ -42,6 +43,9 @@ fn main() -> eframe::Result<()> { | |||
| 42 | 43 | // SyncManager (optional, configured via env vars) | |
| 43 | 44 | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 44 | 45 | ||
| 46 | + | // OTA update checker (runs in background on the tokio runtime) | |
| 47 | + | let update_checker = updater::UpdateChecker::new(runtime.handle()); | |
| 48 | + | ||
| 45 | 49 | let shared = Arc::new(SharedState::new()); | |
| 46 | 50 | ||
| 47 | 51 | // Start cpal audio output stream | |
| @@ -76,7 +80,7 @@ fn main() -> eframe::Result<()> { | |||
| 76 | 80 | options, | |
| 77 | 81 | Box::new(move |_cc| { | |
| 78 | 82 | Ok(Box::new(AudioFilesApp::new( | |
| 79 | - | data_dir, shared, app_tray, sync_manager, runtime, | |
| 83 | + | data_dir, shared, app_tray, sync_manager, update_checker, runtime, | |
| 80 | 84 | ))) | |
| 81 | 85 | }), | |
| 82 | 86 | ) | |
| @@ -106,6 +110,7 @@ struct AudioFilesApp { | |||
| 106 | 110 | error: Option<String>, | |
| 107 | 111 | tray: Option<tray::AppTray>, | |
| 108 | 112 | sync_manager: Option<SyncManager>, | |
| 113 | + | update_checker: updater::UpdateChecker, | |
| 109 | 114 | _runtime: tokio::runtime::Runtime, | |
| 110 | 115 | } | |
| 111 | 116 | ||
| @@ -115,6 +120,7 @@ impl AudioFilesApp { | |||
| 115 | 120 | shared: Arc<SharedState>, | |
| 116 | 121 | tray: Option<tray::AppTray>, | |
| 117 | 122 | sync_manager: Option<SyncManager>, | |
| 123 | + | update_checker: updater::UpdateChecker, | |
| 118 | 124 | runtime: tokio::runtime::Runtime, | |
| 119 | 125 | ) -> Self { | |
| 120 | 126 | let sample_rate = 44100.0; | |
| @@ -133,6 +139,7 @@ impl AudioFilesApp { | |||
| 133 | 139 | error: None, | |
| 134 | 140 | tray, | |
| 135 | 141 | sync_manager, | |
| 142 | + | update_checker, | |
| 136 | 143 | _runtime: runtime, | |
| 137 | 144 | } | |
| 138 | 145 | } | |
| @@ -143,6 +150,7 @@ impl AudioFilesApp { | |||
| 143 | 150 | error: Some(format!("{e}")), | |
| 144 | 151 | tray, | |
| 145 | 152 | sync_manager, | |
| 153 | + | update_checker, | |
| 146 | 154 | _runtime: runtime, | |
| 147 | 155 | } | |
| 148 | 156 | } | |
| @@ -225,5 +233,35 @@ impl eframe::App for AudioFilesApp { | |||
| 225 | 233 | } | |
| 226 | 234 | }); | |
| 227 | 235 | } | |
| 236 | + | ||
| 237 | + | // Show update notification overlay (bottom-right) — user must consent | |
| 238 | + | if self.update_checker.should_show() { | |
| 239 | + | let status = self.update_checker.status.lock().clone(); | |
| 240 | + | egui::Area::new(egui::Id::new("update-banner")) | |
| 241 | + | .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0)) | |
| 242 | + | .order(egui::Order::Foreground) | |
| 243 | + | .show(ctx, |ui| { | |
| 244 | + | egui::Frame::popup(ui.style()) | |
| 245 | + | .inner_margin(12.0) | |
| 246 | + | .show(ui, |ui| { | |
| 247 | + | ui.set_max_width(280.0); | |
| 248 | + | ui.strong(format!("Update Available: v{}", status.version)); | |
| 249 | + | if !status.notes.is_empty() { | |
| 250 | + | ui.label(&status.notes); | |
| 251 | + | } | |
| 252 | + | ui.add_space(4.0); | |
| 253 | + | ui.horizontal(|ui| { | |
| 254 | + | if ui.button("Download").clicked() { | |
| 255 | + | if !status.download_url.is_empty() { | |
| 256 | + | let _ = open::that(&status.download_url); | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | if ui.button("Not Now").clicked() { | |
| 260 | + | self.update_checker.dismiss(); | |
| 261 | + | } | |
| 262 | + | }); | |
| 263 | + | }); | |
| 264 | + | }); | |
| 265 | + | } | |
| 228 | 266 | } | |
| 229 | 267 | } |
| @@ -0,0 +1,159 @@ | |||
| 1 | + | //! OTA update checker for AudioFiles standalone app. | |
| 2 | + | //! | |
| 3 | + | //! Checks the MNW OTA endpoint on startup and periodically. Stores the result | |
| 4 | + | //! in shared state so the egui UI can display a notification. | |
| 5 | + | ||
| 6 | + | use std::sync::Arc; | |
| 7 | + | ||
| 8 | + | use parking_lot::Mutex; | |
| 9 | + | use semver::Version; | |
| 10 | + | ||
| 11 | + | /// OTA updater endpoint base URL. | |
| 12 | + | const OTA_BASE_URL: &str = "https://makenot.work/api/sync/ota/audiofiles"; | |
| 13 | + | ||
| 14 | + | /// How long to wait after startup before first check (seconds). | |
| 15 | + | const INITIAL_DELAY_SECS: u64 = 10; | |
| 16 | + | ||
| 17 | + | /// How often to re-check for updates (seconds). 6 hours. | |
| 18 | + | const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60; | |
| 19 | + | ||
| 20 | + | /// Current app version (from Cargo.toml at compile time). | |
| 21 | + | const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); | |
| 22 | + | ||
| 23 | + | /// The response format from the MNW OTA updater endpoint. | |
| 24 | + | #[derive(serde::Deserialize)] | |
| 25 | + | struct UpdateResponse { | |
| 26 | + | version: String, | |
| 27 | + | url: String, | |
| 28 | + | notes: String, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Shared update status, polled by the UI each frame. | |
| 32 | + | #[derive(Clone)] | |
| 33 | + | pub struct UpdateStatus { | |
| 34 | + | pub available: bool, | |
| 35 | + | pub version: String, | |
| 36 | + | pub notes: String, | |
| 37 | + | pub download_url: String, | |
| 38 | + | pub dismissed: bool, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl Default for UpdateStatus { | |
| 42 | + | fn default() -> Self { | |
| 43 | + | Self { | |
| 44 | + | available: false, | |
| 45 | + | version: String::new(), | |
| 46 | + | notes: String::new(), | |
| 47 | + | download_url: String::new(), | |
| 48 | + | dismissed: false, | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Handle to the update checker. Clone-cheap (Arc-wrapped). | |
| 54 | + | #[derive(Clone)] | |
| 55 | + | pub struct UpdateChecker { | |
| 56 | + | pub status: Arc<Mutex<UpdateStatus>>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl UpdateChecker { | |
| 60 | + | /// Create a new checker and spawn the background check loop on the given runtime. | |
| 61 | + | pub fn new(runtime: &tokio::runtime::Handle) -> Self { | |
| 62 | + | let status = Arc::new(Mutex::new(UpdateStatus::default())); | |
| 63 | + | let checker = Self { status: status.clone() }; | |
| 64 | + | ||
| 65 | + | runtime.spawn(async move { | |
| 66 | + | tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await; | |
| 67 | + | loop { | |
| 68 | + | check_once(&status).await; | |
| 69 | + | tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)).await; | |
| 70 | + | } | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | checker | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Dismiss the update notification (user clicked dismiss). | |
| 77 | + | pub fn dismiss(&self) { | |
| 78 | + | self.status.lock().dismissed = true; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Whether to show the update banner. | |
| 82 | + | pub fn should_show(&self) -> bool { | |
| 83 | + | let s = self.status.lock(); | |
| 84 | + | s.available && !s.dismissed | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Check the MNW OTA endpoint once. | |
| 89 | + | async fn check_once(status: &Arc<Mutex<UpdateStatus>>) { | |
| 90 | + | let current = match Version::parse(CURRENT_VERSION) { | |
| 91 | + | Ok(v) => v, | |
| 92 | + | Err(e) => { | |
| 93 | + | tracing::warn!("Failed to parse current version {CURRENT_VERSION}: {e}"); | |
| 94 | + | return; | |
| 95 | + | } | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | let target = if cfg!(target_os = "macos") { | |
| 99 | + | "darwin" | |
| 100 | + | } else if cfg!(target_os = "linux") { | |
| 101 | + | "linux" | |
| 102 | + | } else if cfg!(target_os = "windows") { | |
| 103 | + | "windows" | |
| 104 | + | } else { | |
| 105 | + | return; | |
| 106 | + | }; | |
| 107 | + | ||
| 108 | + | let arch = if cfg!(target_arch = "x86_64") { | |
| 109 | + | "x86_64" | |
| 110 | + | } else if cfg!(target_arch = "aarch64") { | |
| 111 | + | "aarch64" | |
| 112 | + | } else { | |
| 113 | + | return; | |
| 114 | + | }; | |
| 115 | + | ||
| 116 | + | let url = format!("{OTA_BASE_URL}/{target}/{arch}/{CURRENT_VERSION}"); | |
| 117 | + | ||
| 118 | + | let client = match reqwest::Client::builder() | |
| 119 | + | .timeout(std::time::Duration::from_secs(15)) | |
| 120 | + | .build() | |
| 121 | + | { | |
| 122 | + | Ok(c) => c, | |
| 123 | + | Err(e) => { | |
| 124 | + | tracing::warn!("Failed to build HTTP client for update check: {e}"); | |
| 125 | + | return; | |
| 126 | + | } | |
| 127 | + | }; | |
| 128 | + | ||
| 129 | + | match client.get(&url).send().await { | |
| 130 | + | Ok(resp) if resp.status().as_u16() == 204 => { | |
| 131 | + | tracing::info!("AudioFiles is up to date (v{CURRENT_VERSION})"); | |
| 132 | + | } | |
| 133 | + | Ok(resp) if resp.status().is_success() => { | |
| 134 | + | match resp.json::<UpdateResponse>().await { | |
| 135 | + | Ok(update) => { | |
| 136 | + | if let Ok(remote) = Version::parse(&update.version) { | |
| 137 | + | if remote > current { | |
| 138 | + | tracing::info!("Update available: v{}", update.version); | |
| 139 | + | let mut s = status.lock(); | |
| 140 | + | s.available = true; | |
| 141 | + | s.version = update.version; | |
| 142 | + | s.notes = update.notes; | |
| 143 | + | s.download_url = update.url; | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | Err(e) => { | |
| 148 | + | tracing::warn!("Failed to parse update response: {e}"); | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | Ok(resp) => { | |
| 153 | + | tracing::debug!("Update check returned status {}", resp.status()); | |
| 154 | + | } | |
| 155 | + | Err(e) => { | |
| 156 | + | tracing::warn!("Update check request failed: {e}"); | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "audiofiles-browser" | |
| 3 | - | version = "0.2.1" | |
| 3 | + | version = "0.3.0" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [features] |
| @@ -6,6 +6,8 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use std::path::{Path, PathBuf}; | |
| 8 | 8 | ||
| 9 | + | use tracing::instrument; | |
| 10 | + | ||
| 9 | 11 | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 10 | 12 | use audiofiles_core::analysis::waveform::WaveformData; | |
| 11 | 13 | use audiofiles_core::analysis::AnalysisResult; | |
| @@ -75,6 +77,7 @@ impl DirectBackend { | |||
| 75 | 77 | /// Called before spawning the export worker so profile resolution happens | |
| 76 | 78 | /// on the main thread (where PluginRegistry is accessible). | |
| 77 | 79 | #[cfg(feature = "device-profiles")] | |
| 80 | + | #[instrument(skip_all)] | |
| 78 | 81 | fn resolve_device_profile( | |
| 79 | 82 | &self, | |
| 80 | 83 | config: &mut audiofiles_core::export::ExportConfig, |
| @@ -93,6 +93,7 @@ pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle { | |||
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | #[instrument(skip_all)] | |
| 96 | 97 | fn worker_loop( | |
| 97 | 98 | cmd_rx: mpsc::Receiver<ExportCommand>, | |
| 98 | 99 | event_tx: mpsc::Sender<ExportEvent>, |
| @@ -137,6 +137,7 @@ pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> ImportHandl | |||
| 137 | 137 | ||
| 138 | 138 | /// Recursively count audio files under `dir`. Checks for cancellation between entries. | |
| 139 | 139 | /// Returns `None` if cancelled. | |
| 140 | + | #[instrument(skip_all)] | |
| 140 | 141 | fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<usize> { | |
| 141 | 142 | let mut count = 0; | |
| 142 | 143 | let mut stack = vec![dir.to_path_buf()]; |
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | ||
| 6 | 6 | use std::path::Path; | |
| 7 | 7 | ||
| 8 | + | use tracing::instrument; | |
| 9 | + | ||
| 8 | 10 | use symphonia::core::audio::SampleBuffer; | |
| 9 | 11 | use symphonia::core::codecs::DecoderOptions; | |
| 10 | 12 | use symphonia::core::formats::FormatOptions; | |
| @@ -45,6 +47,7 @@ impl PreviewPlayback { | |||
| 45 | 47 | /// Decode an audio file to interleaved stereo f32. | |
| 46 | 48 | /// Mono files are doubled to stereo. Multi-channel files are mixed down to stereo. | |
| 47 | 49 | /// No resampling — pitch may shift if sample rate != host rate (known Phase 2 limitation). | |
| 50 | + | #[instrument(skip_all)] | |
| 48 | 51 | pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> { | |
| 49 | 52 | let file = std::fs::File::open(path).map_err(|e| PreviewError::Open { | |
| 50 | 53 | path: path.to_path_buf(), |
| @@ -163,11 +163,15 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 163 | 163 | if state.search_filter.is_active() { | |
| 164 | 164 | ui.add_space(8.0); | |
| 165 | 165 | ui.separator(); | |
| 166 | - | ui.label("Save as Smart Folder"); | |
| 166 | + | ui.label(egui::RichText::new("Save as Smart Folder").strong().color(theme::text_secondary())); | |
| 167 | + | ui.label(egui::RichText::new("Save current filters as a reusable preset") | |
| 168 | + | .small() | |
| 169 | + | .color(theme::text_muted())); | |
| 170 | + | ui.add_space(4.0); | |
| 167 | 171 | ui.horizontal(|ui| { | |
| 168 | 172 | ui.add( | |
| 169 | 173 | egui::TextEdit::singleline(&mut state.smart_folder_name_input) | |
| 170 | - | .hint_text("Folder name...") | |
| 174 | + | .hint_text("e.g. Kicks Under 120 BPM") | |
| 171 | 175 | .desired_width(ui.available_width() - 50.0), | |
| 172 | 176 | ); | |
| 173 | 177 | let name = state.smart_folder_name_input.trim().to_string(); |
| @@ -152,11 +152,11 @@ fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 152 | 152 | let fill = if is_active { | |
| 153 | 153 | theme::accent_blue() | |
| 154 | 154 | } else { | |
| 155 | - | egui::Color32::from_gray(240) | |
| 155 | + | theme::piano_white_key() | |
| 156 | 156 | }; | |
| 157 | 157 | ||
| 158 | 158 | painter.rect_filled(key_rect, 2.0, fill); | |
| 159 | - | painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, egui::Color32::from_gray(160)), egui::StrokeKind::Outside); | |
| 159 | + | painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, theme::border_default()), egui::StrokeKind::Outside); | |
| 160 | 160 | ||
| 161 | 161 | if is_root { | |
| 162 | 162 | let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 8.0); | |
| @@ -198,7 +198,7 @@ fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 198 | 198 | let fill = if is_active { | |
| 199 | 199 | theme::accent_blue() | |
| 200 | 200 | } else { | |
| 201 | - | egui::Color32::from_gray(40) | |
| 201 | + | theme::piano_black_key() | |
| 202 | 202 | }; | |
| 203 | 203 | ||
| 204 | 204 | painter.rect_filled(key_rect, 2.0, fill); |
| @@ -387,6 +387,10 @@ pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 387 | 387 | .resizable(false) | |
| 388 | 388 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) | |
| 389 | 389 | .show(ctx, |ui| { | |
| 390 | + | ui.label(egui::RichText::new("Libraries are top-level collections. Right-click inside a library to create folders.") | |
| 391 | + | .small() | |
| 392 | + | .color(super::theme::text_muted())); | |
| 393 | + | ui.add_space(4.0); | |
| 390 | 394 | ui.label("Library name:"); | |
| 391 | 395 | let resp = ui.text_edit_singleline(&mut state.vfs_create_input); | |
| 392 | 396 | if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { |
| @@ -41,7 +41,7 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 41 | 41 | }); | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | - | if ui.small_button("+").on_hover_text("New library").clicked() { | |
| 44 | + | if ui.button("+ New Library").on_hover_text("Create a new library to organize samples").clicked() { | |
| 45 | 45 | state.show_vfs_create = true; | |
| 46 | 46 | state.vfs_create_input.clear(); | |
| 47 | 47 | } | |
| @@ -51,8 +51,12 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 51 | 51 | ||
| 52 | 52 | // Smart Folders section | |
| 53 | 53 | ui.collapsing("Smart Folders", |ui| { | |
| 54 | + | ui.label(egui::RichText::new("Saved filter presets. Set filters in the right panel, then save.") | |
| 55 | + | .small() | |
| 56 | + | .color(theme::text_muted())); | |
| 57 | + | ui.add_space(4.0); | |
| 54 | 58 | if state.smart_folders.is_empty() { | |
| 55 | - | ui.label(egui::RichText::new("No smart folders").color(theme::text_muted())); | |
| 59 | + | ui.label(egui::RichText::new("No smart folders yet").color(theme::text_muted())); | |
| 56 | 60 | } else { | |
| 57 | 61 | let folders = state.smart_folders.clone(); | |
| 58 | 62 | let mut delete_idx = None; |
| @@ -6,6 +6,7 @@ use tracing::{debug, error, warn}; | |||
| 6 | 6 | use audiofiles_sync::{SyncManager, SyncState, SyncStatus}; | |
| 7 | 7 | ||
| 8 | 8 | use crate::state::BrowserState; | |
| 9 | + | use crate::ui::theme; | |
| 9 | 10 | ||
| 10 | 11 | /// Draw the sync settings panel as a floating window. | |
| 11 | 12 | pub fn draw_sync_panel( | |
| @@ -41,7 +42,7 @@ pub fn draw_sync_panel( | |||
| 41 | 42 | // Show last error if any | |
| 42 | 43 | if let Some(ref err) = status.last_error { | |
| 43 | 44 | ui.separator(); | |
| 44 | - | ui.colored_label(egui::Color32::from_rgb(200, 80, 80), err); | |
| 45 | + | ui.colored_label(theme::accent_red(), err); | |
| 45 | 46 | } | |
| 46 | 47 | }); | |
| 47 | 48 | state.show_sync_panel = open; | |
| @@ -259,7 +260,7 @@ fn draw_ready( | |||
| 259 | 260 | ||
| 260 | 261 | // Disconnect button | |
| 261 | 262 | if ui | |
| 262 | - | .button(egui::RichText::new("Disconnect").color(egui::Color32::from_rgb(200, 80, 80))) | |
| 263 | + | .button(egui::RichText::new("Disconnect").color(theme::accent_red())) | |
| 263 | 264 | .clicked() | |
| 264 | 265 | { | |
| 265 | 266 | sync.disconnect(); |
| @@ -27,6 +27,13 @@ static BUNDLED_THEMES: &[(&str, &str)] = &[ | |||
| 27 | 27 | ("flatwhite", include_str!("../../themes/flatwhite.toml")), | |
| 28 | 28 | ("neobrute", include_str!("../../themes/neobrute.toml")), | |
| 29 | 29 | ("high-contrast", include_str!("../../themes/high-contrast.toml")), | |
| 30 | + | ("gruvbox-dark", include_str!("../../themes/gruvbox-dark.toml")), | |
| 31 | + | ("gruvbox-light", include_str!("../../themes/gruvbox-light.toml")), | |
| 32 | + | ("rosepine", include_str!("../../themes/rosepine.toml")), | |
| 33 | + | ("rosepine-dawn", include_str!("../../themes/rosepine-dawn.toml")), | |
| 34 | + | ("everforest", include_str!("../../themes/everforest.toml")), | |
| 35 | + | ("solarized-dark", include_str!("../../themes/solarized-dark.toml")), | |
| 36 | + | ("kanagawa", include_str!("../../themes/kanagawa.toml")), | |
| 30 | 37 | ]; | |
| 31 | 38 | ||
| 32 | 39 | /// The 15-slot universal theme palette. | |
| @@ -146,6 +153,15 @@ pub fn accent_cyan() -> Color32 { THEME.read().accent_cyan } | |||
| 146 | 153 | /// Default border/separator color. | |
| 147 | 154 | pub fn border_default() -> Color32 { THEME.read().border_default } | |
| 148 | 155 | ||
| 156 | + | /// Piano white key — always a light shade regardless of theme variant. | |
| 157 | + | pub fn piano_white_key() -> Color32 { | |
| 158 | + | lerp_color(THEME.read().bg_surface, Color32::WHITE, 0.7) | |
| 159 | + | } | |
| 160 | + | /// Piano black key — always a dark shade regardless of theme variant. | |
| 161 | + | pub fn piano_black_key() -> Color32 { | |
| 162 | + | lerp_color(THEME.read().bg_surface, Color32::BLACK, 0.7) | |
| 163 | + | } | |
| 164 | + | ||
| 149 | 165 | // --- Theme discovery --- | |
| 150 | 166 | ||
| 151 | 167 | /// Return the custom themes directory (`<config>/audiofiles/themes/`). |
| @@ -66,7 +66,7 @@ pub fn draw_waveform( | |||
| 66 | 66 | let x = rect.left() + pos.clamp(0.0, 1.0) * rect.width(); | |
| 67 | 67 | painter.line_segment( | |
| 68 | 68 | [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], | |
| 69 | - | egui::Stroke::new(1.5, egui::Color32::WHITE), | |
| 69 | + | egui::Stroke::new(1.5, theme::text_primary()), | |
| 70 | 70 | ); | |
| 71 | 71 | } | |
| 72 | 72 |
| @@ -1,17 +1,20 @@ | |||
| 1 | + | # Based on Ayu by Konstantin Pschera — MIT License | |
| 2 | + | # https://github.com/ayu-theme | |
| 3 | + | ||
| 1 | 4 | [meta] | |
| 2 | 5 | name = "Ayu Light" | |
| 3 | 6 | variant = "light" | |
| 4 | 7 | ||
| 5 | 8 | [background] | |
| 6 | 9 | primary = "#e7eaed" | |
| 7 | - | secondary = "#e7eaed" | |
| 8 | - | tertiary = "#d8d8d7" | |
| 9 | - | surface = "#fafafa" | |
| 10 | + | secondary = "#dde1e5" | |
| 11 | + | tertiary = "#d0d4d8" | |
| 12 | + | surface = "#f2f4f6" | |
| 10 | 13 | ||
| 11 | 14 | [foreground] | |
| 12 | 15 | primary = "#5c6166" | |
| 13 | 16 | secondary = "#6b7580" | |
| 14 | - | muted = "#6b7580" | |
| 17 | + | muted = "#8b9199" | |
| 15 | 18 | ||
| 16 | 19 | [accent] | |
| 17 | 20 | red = "#f07171" |
| @@ -1,3 +1,6 @@ | |||
| 1 | + | # Based on Catppuccin by Catppuccin Org — MIT License | |
| 2 | + | # https://github.com/catppuccin/catppuccin | |
| 3 | + | ||
| 1 | 4 | [meta] | |
| 2 | 5 | name = "Catppuccin Latte" | |
| 3 | 6 | variant = "light" |
| @@ -1,3 +1,6 @@ | |||
| 1 | + | # Based on Catppuccin by Catppuccin Org — MIT License | |
| 2 | + | # https://github.com/catppuccin/catppuccin | |
| 3 | + | ||
| 1 | 4 | [meta] | |
| 2 | 5 | name = "Catppuccin Mocha" | |
| 3 | 6 | variant = "dark" |