Skip to main content

max / audiofiles

Add blob sync subscription gate and tier UI SyncManager: fetch_subscription_status() and subscribe() methods. Sync panel shows tier selector (Light 10GB / Standard 50GB / Large 200GB) with storage usage progress bar when subscribed. VFS sync toggles gated behind subscription. Server enforces 402 on blob endpoints; metadata sync remains free. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:18 UTC
Commit: ab85102470665c349a390a15943d312d07b96795
Parent: 653a0bc
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) {
M docs/todo.md +27 -6
@@ -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.