Skip to content

Commit 074df23

Browse files
ophiocusclaude
andcommitted
record-tab UX: paged recordings list with one-click play in mixer
The Record tab now follows the shape the user asked for: recorder header at the top (existing controls — profile, device, source, name, ⏺/⏹ transport, live waveform/spectrum/level meters), and a paged "Recordings" list below showing every take in the persistent recordings filespace. Each entry has a ▶ Play button that swaps the active project to the recordings project, switches to the Mix tab, solos that take, and starts playback in one click. The "main mixer" stays the single playback engine — recordings just route through it. Concrete changes: src/ui/record.rs: • New show_recordings_list() function rendered under the existing header. Loads the recordings project fresh on every frame (cheap — manifest only, no WAVs decoded). Page size 10, newest first (track-NNN ids ascend, so reverse iteration). Page nav in the header row when total > one page. • Per-entry columns: ▶ button | name (hover = relative file path) | duration | mode (stereo / Ch N / mix) | recording profile | 🗑 delete button. Empty state: "No recordings yet — hit ⏺ above". src/app.rs: • New TinyBoothApp fields: - recordings_page: usize — Record-tab list pagination - mix_autoplay_pending: bool — set by ▶ click, consumed by Mix-tab show() once the player has rebuilt - mix_autoplay_solo_idx: Option<usize> — the entry to solo • New methods on TinyBoothApp: - play_recording_in_mixer(idx) — swaps project, sets tab, queues autoplay flags. Re-loads from disk first to guard against a stale idx if the manifest changed between frames. - delete_recording(idx) — removes the WAV from disk, removes the Track row from the recordings manifest, saves. If the recordings project happens to be the active project too, also drops that Track row + invalidates the player. src/ui/mix.rs: • Auto-play hand-off block right after the lazy player rebuild: when mix_autoplay_pending is set, solo the target track, rewind to 0, and start playback. Clears the flag. Tests: 44 pass, clippy clean, fmt clean. Workflow now end-to-end: 1. User has a Suno project open. 2. Switch to Record tab. Header shows the recorder controls; the recordings list shows every prior take. 3. Hit ⏺ → ⏹. New entry appears at the top of the list. 4. Click ▶ on any entry → mixer switches to Recordings, solos the take, plays it. Suno project is preserved (in Recents). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f93e72c commit 074df23

3 files changed

Lines changed: 246 additions & 10 deletions

File tree

src/app.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ pub struct TinyBoothApp {
117117
/// console deck (vs. the multitrack lane area).
118118
pub mix_console_fraction: f32,
119119

120+
/// Page index for the Record-tab "Recent recordings" list (10
121+
/// entries per page, newest first). Survives tab switches.
122+
pub recordings_page: usize,
123+
/// When the user hits ▶ on a recording entry, we swap `project`
124+
/// to the recordings project and queue this flag so the Mix-tab
125+
/// view starts playback automatically on its next render. Cleared
126+
/// once acted on. v0.4.0.
127+
pub mix_autoplay_pending: bool,
128+
/// Optional track index to solo on autoplay — the entry the user
129+
/// actually clicked. `None` = autoplay without changing solos.
130+
pub mix_autoplay_solo_idx: Option<usize>,
131+
120132
// Self-update plumbing.
121133
pub update_state: UpdateState,
122134
pub update_error: Option<String>,
@@ -233,6 +245,9 @@ impl TinyBoothApp {
233245
import_conflict: None,
234246
recorder: crate::automation::Recorder::default(),
235247
mix_console_fraction: 0.42,
248+
recordings_page: 0,
249+
mix_autoplay_pending: false,
250+
mix_autoplay_solo_idx: None,
236251
update_state: UpdateState::Checking,
237252
update_error: None,
238253
update_rx: Some(rx),
@@ -405,6 +420,79 @@ impl TinyBoothApp {
405420
}
406421
}
407422

423+
/// Send a recording to the main mixer for playback in one click:
424+
/// swap `project` to the recordings project, switch to the Mix tab,
425+
/// solo the selected take, and queue auto-play for the next Mix
426+
/// render. The Mix-tab show() consumes the auto-play flags after
427+
/// the player rebuilds itself for the new project.
428+
///
429+
/// Called from the Record-tab recordings list ▶ buttons. `idx` is
430+
/// the index in the recordings project's `tracks` list (loaded
431+
/// fresh by the caller — we re-load here to guard against stale
432+
/// indices if the file changed between frames).
433+
pub fn play_recording_in_mixer(&mut self, idx: usize) {
434+
let rec = match Project::open_or_create_recordings() {
435+
Ok(p) => p,
436+
Err(e) => {
437+
self.status = Some(format!("could not open Recordings: {e:#}"));
438+
return;
439+
}
440+
};
441+
if idx >= rec.tracks.len() {
442+
self.status = Some("recording entry no longer exists.".into());
443+
return;
444+
}
445+
self.project = rec;
446+
self.project_dirty = false;
447+
self.player = None;
448+
self.tab = Tab::Mix;
449+
self.mix_autoplay_pending = true;
450+
self.mix_autoplay_solo_idx = Some(idx);
451+
}
452+
453+
/// Delete a recording by index in the recordings project's
454+
/// `tracks` list. Removes the WAV from disk and the `Track` row
455+
/// from the recordings manifest. Caller should refresh its view
456+
/// of the recordings filespace afterward (the Record-tab list
457+
/// re-loads on every frame, so just calling this is enough).
458+
pub fn delete_recording(&mut self, idx: usize) {
459+
let mut rec = match Project::open_or_create_recordings() {
460+
Ok(p) => p,
461+
Err(e) => {
462+
self.status = Some(format!("could not open Recordings: {e:#}"));
463+
return;
464+
}
465+
};
466+
if idx >= rec.tracks.len() {
467+
self.status = Some("recording entry no longer exists.".into());
468+
return;
469+
}
470+
let removed = rec.tracks.remove(idx);
471+
let abs = rec.root.join(&removed.file);
472+
let _ = std::fs::remove_file(&abs);
473+
match rec.save() {
474+
Ok(()) => {
475+
self.status = Some(format!("Deleted recording '{}'.", removed.name));
476+
// If the user has the recordings project open as the
477+
// active one, drop the player so it rebuilds without
478+
// the deleted track on the next Mix visit.
479+
if Config::recordings_root()
480+
.map(|root| self.project.root == root)
481+
.unwrap_or(false)
482+
{
483+
// Reflect on the active project too.
484+
if let Some(pos) = self.project.tracks.iter().position(|t| t.id == removed.id) {
485+
self.project.tracks.remove(pos);
486+
}
487+
self.player = None;
488+
}
489+
}
490+
Err(e) => {
491+
self.status = Some(format!("recordings save error: {e:#}"));
492+
}
493+
}
494+
}
495+
408496
pub fn set_project_root(&mut self, root: PathBuf, name: String) {
409497
self.project = Project::new(name, root);
410498
self.project_dirty = true;

src/ui/mix.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,28 @@ pub fn show(app: &mut TinyBoothApp, ui: &mut egui::Ui) {
6969
return;
7070
}
7171

72+
// Auto-play hand-off from the Record-tab "▶" buttons. The Record
73+
// tab swaps `app.project` to the recordings project and sets
74+
// these flags; we consume them here once the player has rebuilt
75+
// for the new project. Solo-and-play is the natural mode for
76+
// "audition this single take through the main mixer".
77+
if app.mix_autoplay_pending {
78+
if let Some(player) = app.player.as_ref() {
79+
if let Some(idx) = app.mix_autoplay_solo_idx.take() {
80+
for (i, t) in player.state.tracks.iter().enumerate() {
81+
t.solo.store(i == idx, std::sync::atomic::Ordering::Relaxed);
82+
}
83+
}
84+
// Position 0 on the freshly-loaded project.
85+
player
86+
.state
87+
.position_frames
88+
.store(0, std::sync::atomic::Ordering::Release);
89+
player.state.set_play_state(PlayState::Playing);
90+
}
91+
app.mix_autoplay_pending = false;
92+
}
93+
7294
ui.separator();
7395

7496
// Capture fader values for any armed strips while playing.

src/ui/record.rs

Lines changed: 136 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
use crate::app::TinyBoothApp;
22
use crate::audio;
3+
use crate::project::Project;
34
use crate::ui::viz;
45
use eframe::egui;
56

7+
/// Page size for the recordings-list view. Small enough to fit on
8+
/// reasonable screen heights without scrolling, large enough to
9+
/// avoid constant page flipping after a few takes.
10+
const RECORDINGS_PAGE_SIZE: usize = 10;
11+
612
pub fn show(app: &mut TinyBoothApp, ui: &mut egui::Ui) {
713
ui.heading("Record");
814
ui.separator();
@@ -212,19 +218,139 @@ pub fn show(app: &mut TinyBoothApp, ui: &mut egui::Ui) {
212218

213219
ui.add_space(8.0);
214220
ui.horizontal_wrapped(|ui| {
215-
ui.label("Each take is saved into the persistent recordings filespace at");
221+
ui.label("Each take saves to");
216222
let recordings_path = crate::config::Config::recordings_root()
217223
.map(|p| p.join("tracks").display().to_string())
218224
.unwrap_or_else(|| "(no platform config dir)".into());
219225
ui.monospace(recordings_path);
220226
});
221-
ui.label(
222-
egui::RichText::new(
223-
"Recordings stay separate from any stem-mixing project. \
224-
Use File → Open Recordings to review or mix them.",
225-
)
226-
.italics()
227-
.weak(),
228-
);
229-
let _ = app; // keep arg used after the switch away from app.project
227+
228+
ui.add_space(10.0);
229+
ui.separator();
230+
show_recordings_list(app, ui);
231+
}
232+
233+
/// "Recent recordings" — paged list of every take in the persistent
234+
/// recordings filespace, newest first. Each entry has play / delete
235+
/// actions; ▶ swaps the active project to the recordings project,
236+
/// switches to the Mix tab, solos that take, and starts playback.
237+
fn show_recordings_list(app: &mut TinyBoothApp, ui: &mut egui::Ui) {
238+
// Load fresh from disk on every Record-tab frame. The recordings
239+
// manifest is small (JSON only — WAV samples are not loaded by
240+
// Project::load), so this costs microseconds and avoids any
241+
// cache-staleness bugs around external edits / deletions.
242+
let rec = match Project::open_or_create_recordings() {
243+
Ok(p) => p,
244+
Err(e) => {
245+
ui.colored_label(
246+
egui::Color32::LIGHT_RED,
247+
format!("could not open recordings filespace: {e:#}"),
248+
);
249+
return;
250+
}
251+
};
252+
253+
let total = rec.tracks.len();
254+
let total_pages = total.div_ceil(RECORDINGS_PAGE_SIZE).max(1);
255+
if app.recordings_page >= total_pages {
256+
app.recordings_page = total_pages - 1;
257+
}
258+
259+
// Header row: title + count + page nav.
260+
ui.horizontal(|ui| {
261+
ui.heading(format!("Recordings ({total})"));
262+
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
263+
if total_pages > 1 {
264+
ui.add_enabled_ui(app.recordings_page + 1 < total_pages, |ui| {
265+
if ui.button("Next ▶").clicked() {
266+
app.recordings_page += 1;
267+
}
268+
});
269+
ui.label(format!(
270+
"page {} / {}",
271+
app.recordings_page + 1,
272+
total_pages
273+
));
274+
ui.add_enabled_ui(app.recordings_page > 0, |ui| {
275+
if ui.button("◀ Prev").clicked() {
276+
app.recordings_page -= 1;
277+
}
278+
});
279+
}
280+
});
281+
});
282+
283+
if total == 0 {
284+
ui.label(
285+
egui::RichText::new("No recordings yet — hit ⏺ above to capture one.")
286+
.italics()
287+
.weak(),
288+
);
289+
return;
290+
}
291+
292+
// Newest first: walk the project's tracks in reverse (track-NNN
293+
// ids are minted ascending, so reverse iteration is newest-first).
294+
// Pagination: skip and take across the reversed sequence.
295+
let entries: Vec<(usize, &crate::project::Track)> =
296+
rec.tracks.iter().enumerate().rev().collect();
297+
let start = app.recordings_page * RECORDINGS_PAGE_SIZE;
298+
let end = (start + RECORDINGS_PAGE_SIZE).min(entries.len());
299+
let slice = &entries[start..end];
300+
301+
let mut click_play_idx: Option<usize> = None;
302+
let mut click_delete_idx: Option<usize> = None;
303+
304+
egui::Grid::new("recordings_list_grid")
305+
.num_columns(6)
306+
.striped(true)
307+
.spacing([10.0, 4.0])
308+
.show(ui, |ui| {
309+
ui.strong(""); // play
310+
ui.strong("Name");
311+
ui.strong("Duration");
312+
ui.strong("Mode");
313+
ui.strong("Profile");
314+
ui.strong(""); // delete
315+
ui.end_row();
316+
317+
for (idx, t) in slice {
318+
if ui
319+
.button("▶")
320+
.on_hover_text("Play in mixer (switches to Mix tab)")
321+
.clicked()
322+
{
323+
click_play_idx = Some(*idx);
324+
}
325+
ui.label(&t.name).on_hover_text(&t.file);
326+
ui.label(format!("{:.1}s", t.duration_secs));
327+
let mode = if t.stereo {
328+
"stereo".to_string()
329+
} else {
330+
match t.channel_source {
331+
Some(c) => format!("Ch {}", c + 1),
332+
None => "mix".to_string(),
333+
}
334+
};
335+
ui.label(mode);
336+
let prof = t.profile.as_ref().map(|p| p.name.as_str()).unwrap_or("—");
337+
ui.label(prof);
338+
if ui
339+
.button("🗑")
340+
.on_hover_text("Delete this take (removes the WAV)")
341+
.clicked()
342+
{
343+
click_delete_idx = Some(*idx);
344+
}
345+
ui.end_row();
346+
}
347+
});
348+
349+
// Apply clicks AFTER the closure so we don't double-borrow `app`.
350+
if let Some(i) = click_play_idx {
351+
app.play_recording_in_mixer(i);
352+
}
353+
if let Some(i) = click_delete_idx {
354+
app.delete_recording(i);
355+
}
230356
}

0 commit comments

Comments
 (0)