max / audiofiles
5 files changed,
+187 insertions,
-16 deletions
| @@ -221,6 +221,10 @@ pub struct SyncUiState { | |||
| 221 | 221 | pub setup_status: SyncSetupStatus, | |
| 222 | 222 | /// Set by the UI, consumed by the app layer each frame. | |
| 223 | 223 | pub pending_action: Option<SyncSetupAction>, | |
| 224 | + | /// Whether a subscription fetch is in progress. | |
| 225 | + | pub subscription_loading: bool, | |
| 226 | + | /// Whether a checkout request is in progress. | |
| 227 | + | pub checkout_loading: bool, | |
| 224 | 228 | } | |
| 225 | 229 | ||
| 226 | 230 | impl Default for SyncUiState { | |
| @@ -232,6 +236,8 @@ impl Default for SyncUiState { | |||
| 232 | 236 | api_key_input: String::new(), | |
| 233 | 237 | setup_status: SyncSetupStatus::Idle, | |
| 234 | 238 | pending_action: None, | |
| 239 | + | subscription_loading: false, | |
| 240 | + | checkout_loading: false, | |
| 235 | 241 | } | |
| 236 | 242 | } | |
| 237 | 243 | } |
| @@ -48,6 +48,98 @@ pub fn draw_sync_panel( | |||
| 48 | 48 | state.sync.show_panel = open; | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | /// Draw the subscription status/purchase section for blob sync. | |
| 52 | + | fn draw_subscription_section( | |
| 53 | + | ui: &mut egui::Ui, | |
| 54 | + | state: &mut BrowserState, | |
| 55 | + | sync: &SyncManager, | |
| 56 | + | ) { | |
| 57 | + | let sync_status = sync.status(); | |
| 58 | + | ||
| 59 | + | // Trigger initial fetch if not yet loaded | |
| 60 | + | if sync_status.subscription.is_none() && !state.sync.subscription_loading { | |
| 61 | + | state.sync.subscription_loading = true; | |
| 62 | + | sync.fetch_subscription_status(); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | if state.sync.subscription_loading && sync_status.subscription.is_none() { | |
| 66 | + | ui.label(egui::RichText::new("Checking subscription...").weak()); | |
| 67 | + | return; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Once loaded, clear the loading flag | |
| 71 | + | if sync_status.subscription.is_some() { | |
| 72 | + | state.sync.subscription_loading = false; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | match &sync_status.subscription { | |
| 76 | + | Some(sub) if sub.active => { | |
| 77 | + | // Show tier + usage | |
| 78 | + | let tier = sub.tier.as_deref().unwrap_or("standard"); | |
| 79 | + | let limit = sub.storage_limit_bytes.unwrap_or(0); | |
| 80 | + | let used = sub.storage_used_bytes.unwrap_or(0); | |
| 81 | + | ||
| 82 | + | ui.horizontal(|ui| { | |
| 83 | + | ui.label(format!( | |
| 84 | + | "Subscribed: {} tier", | |
| 85 | + | tier[..1].to_uppercase() + &tier[1..] | |
| 86 | + | )); | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | if limit > 0 { | |
| 90 | + | let used_gb = used as f64 / (1024.0 * 1024.0 * 1024.0); | |
| 91 | + | let limit_gb = limit as f64 / (1024.0 * 1024.0 * 1024.0); | |
| 92 | + | let fraction = if limit > 0 { | |
| 93 | + | (used as f32) / (limit as f32) | |
| 94 | + | } else { | |
| 95 | + | 0.0 | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | ui.add( | |
| 99 | + | egui::ProgressBar::new(fraction) | |
| 100 | + | .text(format!("{used_gb:.1} / {limit_gb:.0} GB")), | |
| 101 | + | ); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | _ => { | |
| 105 | + | // Not subscribed — show tier options | |
| 106 | + | ui.label("Choose a storage tier for audio file sync:"); | |
| 107 | + | ui.add_space(4.0); | |
| 108 | + | ||
| 109 | + | let tiers = [ | |
| 110 | + | ("light", "Light — 10 GB", "$1/mo or $10/year"), | |
| 111 | + | ("standard", "Standard — 50 GB", "$3/mo or $30/year"), | |
| 112 | + | ("large", "Large — 200 GB", "$8/mo or $80/year"), | |
| 113 | + | ]; | |
| 114 | + | ||
| 115 | + | for (tier_id, label, price) in tiers { | |
| 116 | + | ui.horizontal(|ui| { | |
| 117 | + | ui.label(label); | |
| 118 | + | ui.label(egui::RichText::new(price).weak()); | |
| 119 | + | }); | |
| 120 | + | ui.horizontal(|ui| { | |
| 121 | + | let loading = state.sync.checkout_loading; | |
| 122 | + | if ui | |
| 123 | + | .add_enabled(!loading, egui::Button::new("Annual")) | |
| 124 | + | .clicked() | |
| 125 | + | { | |
| 126 | + | state.sync.checkout_loading = true; | |
| 127 | + | sync.subscribe(tier_id, "annual"); | |
| 128 | + | } | |
| 129 | + | if ui | |
| 130 | + | .add_enabled(!loading, egui::Button::new("Monthly")) | |
| 131 | + | .clicked() | |
| 132 | + | { | |
| 133 | + | state.sync.checkout_loading = true; | |
| 134 | + | sync.subscribe(tier_id, "monthly"); | |
| 135 | + | } | |
| 136 | + | }); | |
| 137 | + | ui.add_space(2.0); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 51 | 143 | /// Draw the sync setup panel when no SyncManager is available. | |
| 52 | 144 | /// | |
| 53 | 145 | /// Prompts the user to enter their API key, validates it against the server, | |
| @@ -316,23 +408,36 @@ fn draw_ready( | |||
| 316 | 408 | ui.add_space(8.0); | |
| 317 | 409 | ui.separator(); | |
| 318 | 410 | ||
| 319 | - | // Per-VFS "Sync audio files" toggles | |
| 411 | + | // Blob sync subscription status | |
| 320 | 412 | ui.label(egui::RichText::new("Sync audio files to cloud").strong()); | |
| 321 | 413 | ui.label( | |
| 322 | - | egui::RichText::new("Metadata always syncs. Toggle per-vault audio file backup.") | |
| 414 | + | egui::RichText::new("Metadata always syncs free. Audio file sync requires a subscription.") | |
| 323 | 415 | .small() | |
| 324 | 416 | .weak(), | |
| 325 | 417 | ); | |
| 326 | 418 | ui.add_space(4.0); | |
| 327 | 419 | ||
| 328 | - | let vfs_list = state.vfs_list.clone(); | |
| 329 | - | for vfs in vfs_list.iter() { | |
| 330 | - | let mut sync_files = vfs.sync_files; | |
| 331 | - | if ui.checkbox(&mut sync_files, &vfs.name).changed() { | |
| 332 | - | if let Err(e) = state.backend.set_vfs_sync_files(vfs.id, sync_files) { | |
| 333 | - | warn!("Failed to update sync_files for VFS {}: {e}", vfs.name); | |
| 334 | - | } else { | |
| 335 | - | state.refresh_vfs_list(); | |
| 420 | + | draw_subscription_section(ui, state, sync); | |
| 421 | + | ||
| 422 | + | ui.add_space(8.0); | |
| 423 | + | ||
| 424 | + | // Per-VFS "Sync audio files" toggles (only show if subscribed) | |
| 425 | + | let subscribed = sync | |
| 426 | + | .status() | |
| 427 | + | .subscription | |
| 428 | + | .as_ref() | |
| 429 | + | .is_some_and(|s| s.active); | |
| 430 | + | ||
| 431 | + | if subscribed { | |
| 432 | + | let vfs_list = state.vfs_list.clone(); | |
| 433 | + | for vfs in vfs_list.iter() { | |
| 434 | + | let mut sync_files = vfs.sync_files; | |
| 435 | + | if ui.checkbox(&mut sync_files, &vfs.name).changed() { | |
| 436 | + | if let Err(e) = state.backend.set_vfs_sync_files(vfs.id, sync_files) { | |
| 437 | + | warn!("Failed to update sync_files for VFS {}: {e}", vfs.name); | |
| 438 | + | } else { | |
| 439 | + | state.refresh_vfs_list(); | |
| 440 | + | } | |
| 336 | 441 | } | |
| 337 | 442 | } | |
| 338 | 443 | } |
| @@ -18,6 +18,7 @@ rand = { workspace = true } | |||
| 18 | 18 | base64 = { workspace = true } | |
| 19 | 19 | thiserror = { workspace = true } | |
| 20 | 20 | chrono = { workspace = true } | |
| 21 | + | open = { workspace = true } | |
| 21 | 22 | ||
| 22 | 23 | [dev-dependencies] | |
| 23 | 24 | tempfile = "3.25.0" |
| @@ -44,6 +44,8 @@ pub struct SyncStatus { | |||
| 44 | 44 | pub sync_interval_minutes: u32, | |
| 45 | 45 | /// Set to true when remote changes were pulled — GUI should reload VFS/contents. | |
| 46 | 46 | pub needs_refresh: bool, | |
| 47 | + | /// Subscription status for blob sync tier (populated async). | |
| 48 | + | pub subscription: Option<synckit_client::SubscriptionStatus>, | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | impl Default for SyncStatus { | |
| @@ -57,6 +59,7 @@ impl Default for SyncStatus { | |||
| 57 | 59 | auto_sync_enabled: false, | |
| 58 | 60 | sync_interval_minutes: 15, | |
| 59 | 61 | needs_refresh: false, | |
| 62 | + | subscription: None, | |
| 60 | 63 | } | |
| 61 | 64 | } | |
| 62 | 65 | } | |
| @@ -239,6 +242,41 @@ impl SyncManager { | |||
| 239 | 242 | } | |
| 240 | 243 | } | |
| 241 | 244 | ||
| 245 | + | /// Fetch subscription status from the server (async, result goes to status.subscription). | |
| 246 | + | pub fn fetch_subscription_status(&self) { | |
| 247 | + | let client = self.client.clone(); | |
| 248 | + | let status = self.status.clone(); | |
| 249 | + | self.runtime.spawn(async move { | |
| 250 | + | match client.get_subscription_status().await { | |
| 251 | + | Ok(sub) => { | |
| 252 | + | status.lock().subscription = Some(sub); | |
| 253 | + | } | |
| 254 | + | Err(e) => { | |
| 255 | + | tracing::debug!("Failed to fetch subscription status: {e}"); | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | }); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// Create a Stripe checkout session and open it in the browser. | |
| 262 | + | pub fn subscribe(&self, tier: &str, interval: &str) { | |
| 263 | + | let client = self.client.clone(); | |
| 264 | + | let tier = tier.to_string(); | |
| 265 | + | let interval = interval.to_string(); | |
| 266 | + | self.runtime.spawn(async move { | |
| 267 | + | match client.create_subscription_checkout(&tier, &interval).await { | |
| 268 | + | Ok(resp) => { | |
| 269 | + | if let Err(e) = open::that(&resp.checkout_url) { | |
| 270 | + | tracing::warn!("Failed to open browser: {e}"); | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | Err(e) => { | |
| 274 | + | tracing::error!("Failed to create checkout: {e}"); | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | }); | |
| 278 | + | } | |
| 279 | + | ||
| 242 | 280 | /// Spawn the background sync scheduler task. | |
| 243 | 281 | #[instrument(skip_all)] | |
| 244 | 282 | pub fn start_scheduler(&self) { |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ## Status | |
| 4 | 4 | Done: All pre-beta phases + Phase 11. Active: None. Next: Vocal layer 2, sample forge (phases 10-16). | |
| 5 | 5 | ||
| 6 | - | v0.4.0. Audit grade A (Run 20, 2026-05-04). 780 tests. All remediations complete. | |
| 6 | + | v0.4.0. Audit grade A (Run 20, 2026-05-04). 780 tests. All remediations complete. Rust 2024 edition (2026-05-06). rand 0.9. Fixed `unsafe extern`, removed explicit `ref` in patterns. | |
| 7 | 7 | ||
| 8 | 8 | --- | |
| 9 | 9 | ||
| @@ -21,13 +21,14 @@ All items resolved: | |||
| 21 | 21 | ||
| 22 | 22 | AF is PWYW (suggested $15, floor $0). Metadata sync is free. Blob sync (sample files via `sync_files` VFS flag) is tiered by storage. See `MNW/server/docs/internal/business/app_sync_pricing.md` for full pricing rationale. | |
| 23 | 23 | ||
| 24 | - | - [ ] Stripe products + prices: Light 10 GB ($1/mo, $10/yr), Standard 50 GB ($3/mo, $30/yr), Large 200 GB ($8/mo, $80/yr) | |
| 25 | - | - [ ] Blob sync gate: check tier subscription before allowing blob upload/download | |
| 26 | - | - [ ] Metadata sync remains ungated (free for all users) | |
| 27 | - | - [ ] Subscription UI: in-app tier selection + manage flow (settings panel) | |
| 28 | - | - [ ] Storage usage display: show current blob usage vs tier allocation | |
| 24 | + | - [x] Stripe pricing: inline price_data (Light $1/$10, Standard $3/$30, Large $8/$80), no pre-created products | |
| 25 | + | - [x] Blob sync gate: server returns 402 on blob endpoints when no subscription, blob errors non-fatal in scheduler | |
| 26 | + | - [x] Metadata sync remains ungated (free for all users) | |
| 27 | + | - [x] Subscription UI: egui tier selector (Light/Standard/Large) with Annual/Monthly buttons, storage usage progress bar | |
| 28 | + | - [x] Storage usage display: progress bar showing used/limit GB from subscription status | |
| 29 | 29 | - [ ] Tier upgrade/downgrade flow | |
| 30 | 30 | - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency) | |
| 31 | + | - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → blob sync gate passes) | |
| 31 | 32 | ||
| 32 | 33 | --- | |
| 33 | 34 | ||
| @@ -116,6 +117,26 @@ AF is PWYW (suggested $15, floor $0). Metadata sync is free. Blob sync (sample f | |||
| 116 | 117 | - [ ] Compare snapshots side-by-side (A/B waveform + playback) | |
| 117 | 118 | - [ ] Fork: branch from any snapshot to try different processing paths | |
| 118 | 119 | ||
| 120 | + | ## Aesthetic-Usability Polish (2026-05-05) | |
| 121 | + | ||
| 122 | + | egui Visuals overhaul — the only FAIR grade in the Laws of UX audit. | |
| 123 | + | ||
| 124 | + | - [x] Extend ThemeColors with `section_spacing` (16.0), `grid_row_spacing` (6.0), `button_padding_x` (8.0), `button_padding_y` (4.0) — TOML-configurable with defaults | |
| 125 | + | - [x] Waveform height: 100px → 120px (more breathing room) | |
| 126 | + | - [x] Detail panel section spacing: hardcoded 12/4px → theme-driven `section_spacing()` (16px default) | |
| 127 | + | - [x] Metadata grid row spacing: 4px → `grid_row_spacing()` (6px default) | |
| 128 | + | - [x] Sample name spacing: 4px → 8px after title | |
| 129 | + | - [x] Tag suggestions spacing: 2px → 6px | |
| 130 | + | - [x] Discovery buttons spacing: 4px → 6px | |
| 131 | + | - [x] Softer widget borders: thinner strokes (0.5px) on inactive state, full on hover/active | |
| 132 | + | - [x] Softer separator color: blended with background (40% lighter) | |
| 133 | + | - [x] Widget hover expansion: 1.0px grow on hover for tactile feedback | |
| 134 | + | - [x] Button padding: 6x3 → 8x4 (theme-configurable) | |
| 135 | + | - [x] Window margin: set to 10x10 (was egui default) | |
| 136 | + | - [x] Indent: 18px (was egui default ~21px) | |
| 137 | + | ||
| 138 | + | --- | |
| 139 | + | ||
| 119 | 140 | ## UX Audit Findings (2026-05-02) | |
| 120 | 141 | ||
| 121 | 142 | Usability audit across complexity, feature completeness, learnability, and discoverability. |