From a9ea7c9daef7703eee6bd3b24a25ee0c1077ea06 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Fri, 4 Sep 2026 12:37:35 +0100 Subject: [PATCH 1/2] feat(export): drag a stem or a loop out to a DAW or a folder Getting audio out of StemDeck was three actions every time: pick a format, click Export, find the folder in a system dialog. For someone auditioning loops against a project that is not one interruption, it is the session. A WebView cannot hand the OS a file. HTML5 dragstart carries text and, in Chromium only, a DownloadURL some file managers accept; a DAW wants a real path and no browser will produce one. So the gesture is cancelled in JavaScript and the platform drag is started in Rust. Desktop only, because nowhere else can do it at all, and the grip is hidden rather than offering a gesture that would silently do nothing. Nothing new on the server. GET /stems/{name}.wav and mixdown.{ext} with start/end already produced exactly what has to be dragged. Three things shaped the implementation: - The path never crosses IPC. tauri-plugin-drag exposes start_drag only as a #[command], so using it would mean JavaScript naming the file to drag. That is the rule download_to_path exists to keep: the page is served over http and Tauri treats it as a remote origin, so app-defined commands are not ACL-gated and nothing else is holding. The drag crate is taken directly and StemDeck's own command resolves the folder, the way open_url is hand-rolled rather than taking tauri-plugin-opener. - A drag cannot wait for a render. It has to reach the OS while the button is still down, and a region mixdown is an ffmpeg run, so rendering during the gesture means the button is released before the drag ever attaches. The region is warmed on pointerup instead, with Range: bytes=0-0, which costs the render and not the transfer. pointerup rather than a loop-change hook because moving a fader also changes the mixdown, and so the cache key. - The file has to outlive the drop. Reaper references a dropped file where it lies, so it cannot come from the mixdown cache, which is pruned at 20 files / 500 MB and would eventually delete audio a project still points at. It goes to an exports folder nothing cleans up, beside the stems folder, changeable in Settings. The grip is a separate element inside #loop-region and is excluded from wireLoopRegionAdjust: an HTML5 drag and a pointer drag on one element fight, and the region would slide away as it was dragged out. Region filenames carry their bounds, unlike Export Region, because the Rust side reuses a file that is already there and two loops of one song sharing a name would mean the second drag handing over the first one's audio. uv.lock is untouched, so existing desktop installs are still offered this release in-app. 12 Rust unit tests over filename sanitising and folder resolution, 8 e2e tests over everything on the JavaScript side of the platform call. The drag itself lands in another application and is verified by hand per platform. Closes #570 --- desktop/src-tauri/Cargo.lock | 182 ++++++++++++++++++-- desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/icons/drag.png | Bin 0 -> 5783 bytes desktop/src-tauri/src/dragout.rs | 283 +++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 117 ++++++++++++- static/css/waves.css | 34 ++++ static/index.html | 2 +- static/js/catalog.js | 63 +++++++ static/js/i18n.js | 51 ++++++ static/js/main.js | 72 +++++++- static/js/player.js | 39 +++++ static/js/transport.js | 3 + tests/e2e/drag-out.spec.mjs | 153 +++++++++++++++++ 13 files changed, 984 insertions(+), 20 deletions(-) create mode 100644 desktop/src-tauri/icons/drag.png create mode 100644 desktop/src-tauri/src/dragout.rs create mode 100644 tests/e2e/drag-out.spec.mjs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6842f018..ddbef1a9 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -376,6 +376,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -745,6 +758,27 @@ dependencies = [ "serde", ] +[[package]] +name = "drag" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c" +dependencies = [ + "core-graphics 0.24.0", + "dunce", + "gdk", + "gdkx11", + "gtk", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "raw-window-handle", + "thiserror 2.0.18", + "windows 0.52.0", + "windows-core 0.58.0", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -2084,9 +2118,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2106,6 +2148,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.11.1", "objc2", "objc2-foundation", ] @@ -2166,6 +2209,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3352,6 +3408,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stemdeck" version = "0.0.0" dependencies = [ + "drag", "flate2", "libc", "local-ip-address", @@ -3482,7 +3539,7 @@ dependencies = [ "bitflags 2.11.1", "block2", "core-foundation", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", @@ -3507,7 +3564,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3589,7 +3646,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3750,7 +3807,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3775,7 +3832,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4616,10 +4673,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4640,7 +4697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4690,6 +4747,18 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-implement 0.52.0", + "windows-interface 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4712,14 +4781,36 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4731,8 +4822,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4749,6 +4840,28 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4760,6 +4873,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4793,6 +4928,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4811,6 +4955,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5250,7 +5404,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8c5a493a..291935e1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -22,6 +22,11 @@ local-ip-address = "0.6" # worse than plain http because it looks secure. See src/certs.rs. rcgen = "0.13" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } +# Native drag-out of a rendered file to the OS (a DAW, Explorer, Finder). +# The crate tauri-plugin-drag wraps; taken directly because the plugin exposes +# start_drag only as a #[command], which would mean JavaScript naming the file +# path. See dragout.rs. +drag = "2.1.1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/desktop/src-tauri/icons/drag.png b/desktop/src-tauri/icons/drag.png new file mode 100644 index 0000000000000000000000000000000000000000..d99a3ec85181fee8819f427b3bd19f9ea2b3e2d7 GIT binary patch literal 5783 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 V{d4nU3LF3c002ovPDHLkV1geiDzg9p literal 0 HcmV?d00001 diff --git a/desktop/src-tauri/src/dragout.rs b/desktop/src-tauri/src/dragout.rs new file mode 100644 index 00000000..9879d44e --- /dev/null +++ b/desktop/src-tauri/src/dragout.rs @@ -0,0 +1,283 @@ +//! Dragging audio out of StemDeck and into a DAW or a folder. +//! +//! Every export today is three actions: pick a format, click Export, choose a +//! folder. For someone auditioning loops against a project that is the whole +//! session, repeated. Dragging is one action, and it is the gesture a DAW user +//! already has in their hands. +//! +//! A WebView cannot produce an OS drag. HTML5 `dragstart` can carry text and, +//! in Chromium only, a `DownloadURL` that some file managers accept -- but a +//! DAW wants a real path on a real filesystem, and no browser will give it one. +//! So the gesture is cancelled in JavaScript and handed to the platform here. +//! +//! ## Why not tauri-plugin-drag +//! +//! The plugin exists and works, but it exposes `start_drag` only as a +//! `#[command]`, which means **JavaScript names the file path**. That breaks +//! the rule the rest of StemDeck's exports are built on: in `download_to_path` +//! the destination is held in Rust behind an opaque token precisely so nothing +//! running in the WebView can write an arbitrary URL to an arbitrary location. +//! The page is served over http by the Python backend, which Tauri treats as a +//! remote origin, so app-defined commands are not ACL-gated and that rule is +//! the only thing holding. +//! +//! Here the same rule holds: JavaScript passes a localhost URL and a bare +//! filename. The directory is resolved in Rust, the filename is reduced to a +//! single path component, and the resulting path never crosses the IPC +//! boundary in either direction. +//! +//! ## Why the file has to persist +//! +//! DAWs disagree about what a dropped file means. Reaper references it where +//! it lies; others copy it into the project. So a dragged file cannot live in +//! the render cache, which is pruned at 20 files / 500 MB and would eventually +//! delete audio somebody's project still points at. It goes to an exports +//! folder that nothing cleans up, beside the stems folder, and the user can +//! move it in Settings. + +use std::path::{Path, PathBuf}; + +use drag::{DragItem, DragMode, DragResult, Image, Options}; + +/// Embedded rather than read from disk: the icon's path differs between a dev +/// run, the portable zip and an installed bundle, and a drag with no preview +/// looks broken. 96px, which is what the platforms draw it at. +const DRAG_IMAGE: &[u8] = include_bytes!("../icons/drag.png"); + +/// Characters no Windows filename may contain, plus the separators every +/// platform uses. `:` also matters on macOS, where Finder still shows it as a +/// path separator. +const ILLEGAL: &[char] = &['<', '>', ':', '"', '|', '?', '*', '/', '\\']; + +/// Device names Windows resolves before it ever looks at the directory, with +/// or without an extension. `CON.wav` is not a file. +const RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +/// Reduce a caller-supplied name to a single, safe path component. +/// +/// Rejects rather than repairs anything that looks like an attempt to escape +/// the exports folder, because a caller asking to write `../../autoexec` is +/// not a caller whose intent is worth guessing at. Cosmetic problems (illegal +/// characters, a trailing dot) are repaired, since those come from song titles +/// and rejecting them would make ordinary tracks undraggable. +pub fn sanitize_filename(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("empty filename".to_string()); + } + if trimmed == "." || trimmed == ".." || trimmed.contains("..") { + return Err("filename may not traverse directories".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') { + return Err("filename may not contain a path separator".to_string()); + } + + let cleaned: String = trimmed + .chars() + .map(|c| { + if ILLEGAL.contains(&c) || c.is_control() { + '_' + } else { + c + } + }) + .collect(); + + // Windows silently strips these, so a name ending in one resolves to a + // different file than the one reported back to the user. + let cleaned = cleaned.trim_end_matches(['.', ' ']).to_string(); + if cleaned.is_empty() { + return Err("filename is empty once cleaned".to_string()); + } + + let stem = cleaned.split('.').next().unwrap_or("").to_ascii_uppercase(); + if RESERVED.contains(&stem.as_str()) { + return Err(format!("{cleaned} is a reserved device name")); + } + + // Long titles plus a region suffix can pass NTFS's 255-byte component + // limit. Truncate the stem, never the extension, or the DAW cannot tell + // what it was handed. + Ok(truncate_component(&cleaned, 200)) +} + +/// Shorten a filename to `max` bytes while keeping its extension intact. +fn truncate_component(name: &str, max: usize) -> String { + if name.len() <= max { + return name.to_string(); + } + let (stem, ext) = match name.rfind('.') { + Some(i) if i > 0 => (&name[..i], &name[i..]), + _ => (name, ""), + }; + let room = max.saturating_sub(ext.len()); + let mut cut = room.min(stem.len()); + // Never split a multi-byte character. + while cut > 0 && !stem.is_char_boundary(cut) { + cut -= 1; + } + format!("{}{}", &stem[..cut], ext) +} + +/// Where dragged files land. +/// +/// `configured` is whatever Settings recorded, which may be stale: a folder on +/// a drive that is no longer mounted must not silently swallow exports, so an +/// absolute path that does not exist and cannot be created falls back rather +/// than failing the drag. A relative path is ignored outright, because it +/// would resolve against the process working directory, which is not +/// somewhere the user chose. +pub fn resolve_exports_dir(configured: Option<&str>, fallback: &Path) -> PathBuf { + if let Some(raw) = configured { + let candidate = PathBuf::from(raw.trim()); + if candidate.is_absolute() && std::fs::create_dir_all(&candidate).is_ok() { + return candidate; + } + } + fallback.to_path_buf() +} + +/// The default exports folder: a sibling of the stems folder, so everything +/// StemDeck writes for the user sits in one place they already know about. +pub fn default_exports_dir(jobs_dir: &Path, data_dir: &Path) -> PathBuf { + match jobs_dir.parent() { + Some(parent) if parent.as_os_str() != "" => parent.join("exports"), + _ => data_dir.join("exports"), + } +} + +/// Hand `path` to the platform's drag-and-drop, from the main thread. +/// +/// Must be called on the main thread: all three backends talk to UI toolkit +/// state (OLE on Windows, AppKit on macOS, GTK on Linux) that is not +/// thread-safe. The caller is responsible for that; see `start_audio_drag`. +pub fn begin_drag(window: &tauri::WebviewWindow, path: PathBuf) -> Result<(), String> { + let options = Options { + // Copy, never Move: the exports folder is the user's copy and a DAW + // must not relocate it out from under them. + mode: DragMode::Copy, + skip_animatation_on_cancel_or_failure: false, + }; + let item = DragItem::Files(vec![path]); + let image = Image::Raw(DRAG_IMAGE.to_vec()); + let on_drop = |result: DragResult, _: drag::CursorPosition| { + // Nothing to undo either way. The file stays in the exports folder + // whether it was dropped or the drag was abandoned, which is what a + // user who drags twice expects. + if matches!(result, DragResult::Cancel) { + eprintln!("[stemdeck] drag cancelled"); + } + }; + + #[cfg(target_os = "linux")] + { + let gtk = window.gtk_window().map_err(|e| e.to_string())?; + drag::start_drag(>k, item, image, on_drop, options).map_err(|e| e.to_string()) + } + #[cfg(not(target_os = "linux"))] + { + drag::start_drag(&window.clone(), item, image, on_drop, options).map_err(|e| e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_an_ordinary_name() { + assert_eq!( + sanitize_filename("Song - vocals.wav").unwrap(), + "Song - vocals.wav" + ); + } + + #[test] + fn rejects_traversal() { + assert!(sanitize_filename("../secret.wav").is_err()); + assert!(sanitize_filename("..").is_err()); + assert!(sanitize_filename("a/../../b.wav").is_err()); + } + + #[test] + fn rejects_separators() { + assert!(sanitize_filename("sub/dir.wav").is_err()); + assert!(sanitize_filename("sub\\dir.wav").is_err()); + } + + #[test] + fn rejects_empty() { + assert!(sanitize_filename("").is_err()); + assert!(sanitize_filename(" ").is_err()); + assert!(sanitize_filename("...").is_err()); + } + + #[test] + fn repairs_illegal_characters() { + assert_eq!( + sanitize_filename("AC*DC: Back?.wav").unwrap(), + "AC_DC_ Back_.wav" + ); + } + + #[test] + fn rejects_windows_device_names() { + assert!(sanitize_filename("CON.wav").is_err()); + assert!(sanitize_filename("nul.mp3").is_err()); + assert!(sanitize_filename("LPT1").is_err()); + // Only the exact device name, not anything starting with it. + assert!(sanitize_filename("CONCERT.wav").is_ok()); + } + + #[test] + fn strips_trailing_dots_and_spaces() { + assert_eq!(sanitize_filename("track.wav . ").unwrap(), "track.wav"); + } + + #[test] + fn truncates_a_long_name_but_keeps_the_extension() { + let long = format!("{}.wav", "a".repeat(400)); + let got = sanitize_filename(&long).unwrap(); + assert!(got.len() <= 200, "{}", got.len()); + assert!(got.ends_with(".wav")); + } + + #[test] + fn truncation_does_not_split_a_character() { + let long = format!("{}.wav", "é".repeat(300)); + let got = sanitize_filename(&long).unwrap(); + assert!(got.ends_with(".wav")); + // Round-trips, so no partial code unit survived. + assert_eq!(got, String::from_utf8(got.clone().into_bytes()).unwrap()); + } + + #[test] + fn exports_dir_prefers_a_usable_configured_path() { + let dir = tempfile::tempdir().unwrap(); + let chosen = dir.path().join("elsewhere"); + let got = resolve_exports_dir(Some(chosen.to_str().unwrap()), dir.path()); + assert_eq!(got, chosen); + assert!(chosen.is_dir(), "resolving should create it"); + } + + #[test] + fn exports_dir_falls_back_when_unset_or_relative() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(resolve_exports_dir(None, dir.path()), dir.path()); + // Relative would resolve against the working directory, not a choice. + assert_eq!(resolve_exports_dir(Some("exports"), dir.path()), dir.path()); + } + + #[test] + fn default_exports_dir_sits_beside_the_stems_folder() { + let jobs = PathBuf::from("/home/u/Documents/StemDeck/jobs"); + let data = PathBuf::from("/home/u/.local/share/stemdeck"); + assert_eq!( + default_exports_dir(&jobs, &data), + PathBuf::from("/home/u/Documents/StemDeck/exports") + ); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3478d640..c450832d 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,4 +1,5 @@ mod certs; +mod dragout; use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; @@ -456,6 +457,9 @@ fn main() { pick_export_destination, download_to_path, pick_stems_folder, + pick_exports_folder, + current_exports_dir, + start_audio_drag, store_get, store_set, reset_user_data, @@ -622,6 +626,61 @@ async fn pick_stems_folder(app: tauri::AppHandle) -> Result, Stri Ok(picked.map(|p| p.to_string())) } +/// Settings key holding the folder the user picked for exports and drags. +const EXPORTS_DIR_KEY: &str = "exports_dir"; + +/// Native folder picker for the exports location, persisted here rather than +/// handed back for JS to store: the drag path is resolved in Rust and nothing +/// in the WebView should be able to point it somewhere by itself. +#[tauri::command] +async fn pick_exports_folder(app: tauri::AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let (tx, rx) = std::sync::mpsc::channel(); + app.dialog() + .file() + .set_title("Choose where StemDeck puts dragged and exported audio") + .pick_folder(move |path| { + let _ = tx.send(path); + }); + let Some(picked) = rx.recv().map_err(|e| e.to_string())? else { + return Ok(None); // user cancelled + }; + + let chosen = picked.to_string(); + let store_path = documents_store_path(&app)?; + let store = app.store(store_path).map_err(|e| e.to_string())?; + store.set(EXPORTS_DIR_KEY, serde_json::Value::String(chosen.clone())); + store.save().map_err(|e| e.to_string())?; + Ok(Some(chosen)) +} + +/// The exports folder as it stands, for the Settings row to display. +#[tauri::command] +fn current_exports_dir(app: tauri::AppHandle) -> Result { + exports_dir(&app).map(|p| p.to_string_lossy().to_string()) +} + +/// Read a string out of the persistent store, or None if it is absent or is +/// not a string. Never fails the caller: a missing store means "not chosen". +fn stored_string(app: &tauri::AppHandle, key: &str) -> Option { + let path = documents_store_path(app).ok()?; + let store = app.store(path).ok()?; + let value = store.get(key)?; + value.as_str().map(|s| s.to_string()) +} + +/// Where dragged and exported audio lands, created if it does not exist. +fn exports_dir(app: &tauri::AppHandle) -> Result { + let jobs = current_jobs_dir(app); + let data = local_data_dir()?; + let fallback = dragout::default_exports_dir(&jobs, &data); + let dir = + dragout::resolve_exports_dir(stored_string(app, EXPORTS_DIR_KEY).as_deref(), &fallback); + fs::create_dir_all(&dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + Ok(dir) +} + /// Get a value from the persistent user-data store. #[tauri::command] fn store_get(app: tauri::AppHandle, key: String) -> Result, String> { @@ -2703,15 +2762,22 @@ async fn download_to_path( ) -> Result<(), String> { validate_download_url(&url)?; let dest = take_pending_save(&state, &token)?; + stream_url_to_file(&url, &dest).await +} - // Stream response to disk to avoid buffering a large audio file in memory (#139). - // 5-minute timeout covers large WAV exports over a slow loopback. +/// Streams a URL to `dest`, via a temp file so a failure never leaves a +/// half-written export looking complete. +/// +/// Streamed rather than buffered to avoid holding a large audio file in memory +/// (#139); the 5-minute timeout covers large WAV exports over a slow loopback. +/// The caller has already validated the URL. +async fn stream_url_to_file(url: &str, dest: &Path) -> Result<(), String> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(300)) .build() .map_err(|e| format!("failed to build client: {e}"))?; let mut resp = client - .get(&url) + .get(url) .send() .await .map_err(|e| format!("fetch failed: {e}"))?; @@ -2731,7 +2797,7 @@ async fn download_to_path( } file.sync_all().map_err(|e| format!("flush failed: {e}"))?; drop(file); - std::fs::rename(&tmp, &dest).map_err(|e| format!("rename failed: {e}"))?; + std::fs::rename(&tmp, dest).map_err(|e| format!("rename failed: {e}"))?; Ok(()) } @@ -2754,6 +2820,49 @@ async fn save_audio_file( download_to_path(state, token, url).await } +/// Renders a localhost URL into the exports folder and starts an OS drag of it. +/// +/// JavaScript cancels its own `dragstart` and calls this, because a WebView +/// cannot hand the OS a file. It passes a URL and a bare filename; the folder +/// is resolved here and the path never crosses back, for the same reason +/// `download_to_path` takes a token (see dragout.rs). +#[tauri::command] +async fn start_audio_drag( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + url: String, + filename: String, +) -> Result<(), String> { + validate_download_url(&url)?; + let name = dragout::sanitize_filename(&filename)?; + let dest = exports_dir(&app)?.join(&name); + + // Reuse an identical earlier export. The backend already caches the render; + // this saves the transfer too, so dragging the same loop twice is instant. + // Zero-length means an interrupted write, which must not be handed to a DAW. + let usable = matches!(fs::metadata(&dest), Ok(m) if m.len() > 0); + if !usable { + stream_url_to_file(&url, &dest).await?; + } + + // Every platform backend touches UI state that is not thread-safe, and this + // command runs on a worker, so the drag has to be started on the main + // thread. + // + // Deliberately not waited on. The platform drag is modal -- Windows' + // DoDragDrop does not return until the drop completes -- so blocking here + // would hold a runtime worker for as long as the user keeps the mouse + // down. There is nothing useful the caller could do with the result by + // then either: the gesture is over. Everything that can fail in a way JS + // can act on (a bad name, a failed render) has already happened above. + app.run_on_main_thread(move || { + if let Err(e) = dragout::begin_drag(&window, dest) { + eprintln!("[stemdeck] could not start the drag: {e}"); + } + }) + .map_err(|e| e.to_string()) +} + fn stop_backend(state: &BackendState) { let (handles, _setup_child_pid) = match state.inner.lock() { Ok(mut guard) => (guard.handles.take(), guard.setup_child_pid.take()), diff --git a/static/css/waves.css b/static/css/waves.css index 6f490621..d44578d3 100644 --- a/static/css/waves.css +++ b/static/css/waves.css @@ -777,6 +777,40 @@ cursor: grabbing; } +/* Drag the rendered region out to a DAW or a folder. Only the desktop app can + hand the OS a file, so the grip is hidden everywhere else rather than + offering a gesture that would silently do nothing (see dragout.rs). */ +.loop-grip { + display: none; +} + +.can-drag-out .loop-grip { + display: block; + position: absolute; + top: 3px; + left: 50%; + transform: translateX(-50%); + width: 26px; + height: 14px; + border-radius: 3px; + background: var(--gold); + opacity: 0; + cursor: grab; + /* Above the resize handles: it overlaps neither, but a narrow region + squeezes all three together and the grip must stay grabbable. */ + z-index: 5; + transition: opacity 0.12s ease; +} + +.can-drag-out .loop-region:hover .loop-grip { + opacity: 0.85; +} + +.can-drag-out .loop-grip:active { + cursor: grabbing; + opacity: 1; +} + /* Wider than the 2px border they sit on: an edge you cannot reliably grab is the finnicky behaviour this replaces. Extends outside the region as well as in, so the handle is catchable from either side. */ diff --git a/static/index.html b/static/index.html index 02255da6..4ace1d29 100644 --- a/static/index.html +++ b/static/index.html @@ -633,7 +633,7 @@
- +
+
+
+
Exports folder
+
Where audio goes when you drag a stem or a loop out of StemDeck. Nothing here is ever deleted automatically, because a project that references a dragged file needs it to stay put.
+
+
+ + +
+
+
Automatically delete finished tracks
@@ -4080,6 +4142,7 @@ function openLibraryEditor() { wireLanguageSetting(overlay); wireGeneralSettings(overlay); wireStemsLocation(overlay); + wireExportsLocation(overlay); wireNetworkSetting(overlay); if (!isDesktop) { overlay.querySelector(".net-access-input")?.setAttribute("disabled", ""); diff --git a/static/js/i18n.js b/static/js/i18n.js index 610a8380..425412d7 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -544,6 +544,11 @@ const en = { "settings.autoDelete.daysTitle": "Delete after", "settings.autoDelete.daysDesc": "Days a finished track is kept before it is deleted (max {max}).", "settings.stemsLocation.change": "Change…", + "loop.dragOut": "Drag this region into a DAW or folder", + "settings.exportsLocation.title": "Exports folder", + "settings.exportsLocation.desc": "Where audio goes when you drag a stem or a loop out of StemDeck. Nothing here is deleted automatically, because a project that references a dragged file needs it to stay put.", + "settings.exportsLocation.change": "Change…", + "settings.exportsLocation.pickerFailed": "The folder picker could not be opened.", "settings.stemsLocation.resetting": "Resetting…", "settings.stemsLocation.resetFailed": "Reset failed — check your connection.", "settings.stemsLocation.syncing": "Syncing…", @@ -1136,6 +1141,11 @@ const pl = { "settings.autoDelete.daysTitle": "Usuń po", "settings.autoDelete.daysDesc": "Liczba dni przechowywania ukończonego utworu przed usunięciem (maks. {max}).", "settings.stemsLocation.change": "Zmień…", + "loop.dragOut": "Przeciągnij ten fragment do DAW lub folderu", + "settings.exportsLocation.title": "Folder eksportu", + "settings.exportsLocation.desc": "Tu trafia dźwięk, gdy przeciągasz ścieżkę lub pętlę poza StemDeck. Nic nie jest tu usuwane automatycznie, ponieważ projekt odwołujący się do przeciągniętego pliku potrzebuje go w tym miejscu.", + "settings.exportsLocation.change": "Zmień…", + "settings.exportsLocation.pickerFailed": "Nie udało się otworzyć okna wyboru folderu.", "settings.stemsLocation.resetting": "Resetowanie…", "settings.stemsLocation.resetFailed": "Resetowanie nie powiodło się — sprawdź połączenie.", "settings.stemsLocation.syncing": "Synchronizowanie…", @@ -1720,6 +1730,11 @@ const ja = { "settings.autoDelete.daysTitle": "削除するまでの日数", "settings.autoDelete.daysDesc": "完了したトラックを削除するまで保持する日数(最大{max}日)。", "settings.stemsLocation.change": "変更…", + "loop.dragOut": "この区間をDAWやフォルダーにドラッグ", + "settings.exportsLocation.title": "書き出し先フォルダー", + "settings.exportsLocation.desc": "StemDeckからステムやループをドラッグしたときの保存先です。ドラッグしたファイルを参照するプロジェクトのため、ここのファイルが自動で削除されることはありません。", + "settings.exportsLocation.change": "変更…", + "settings.exportsLocation.pickerFailed": "フォルダー選択画面を開けませんでした。", "settings.stemsLocation.resetting": "リセット中…", "settings.stemsLocation.resetFailed": "リセットに失敗しました — 接続を確認してください。", "settings.stemsLocation.syncing": "同期中…", @@ -2280,6 +2295,11 @@ const zhHans = { "settings.autoDelete.daysTitle": "保留天数", "settings.autoDelete.daysDesc": "已完成的音轨在删除前保留的天数(最多 {max} 天)。", "settings.stemsLocation.change": "更改…", + "loop.dragOut": "将此区域拖入 DAW 或文件夹", + "settings.exportsLocation.title": "导出文件夹", + "settings.exportsLocation.desc": "从 StemDeck 拖出分轨或循环段时音频的存放位置。这里的文件不会被自动删除,因为引用已拖出文件的工程需要它们保留在原处。", + "settings.exportsLocation.change": "更改…", + "settings.exportsLocation.pickerFailed": "无法打开文件夹选择器。", "settings.stemsLocation.resetting": "正在重置…", "settings.stemsLocation.resetFailed": "重置失败 — 请检查你的网络连接。", "settings.stemsLocation.syncing": "正在同步…", @@ -2841,6 +2861,11 @@ const de = { "settings.autoDelete.daysTitle": "Löschen nach", "settings.autoDelete.daysDesc": "Tage, die ein fertiger Titel aufbewahrt wird, bevor er gelöscht wird (max. {max}).", "settings.stemsLocation.change": "Ändern…", + "loop.dragOut": "Diesen Bereich in eine DAW oder einen Ordner ziehen", + "settings.exportsLocation.title": "Export-Ordner", + "settings.exportsLocation.desc": "Hierhin kommt Audio, wenn du eine Spur oder eine Schleife aus StemDeck ziehst. Hier wird nichts automatisch gelöscht, denn ein Projekt, das eine gezogene Datei referenziert, braucht sie an Ort und Stelle.", + "settings.exportsLocation.change": "Ändern…", + "settings.exportsLocation.pickerFailed": "Die Ordnerauswahl konnte nicht geöffnet werden.", "settings.stemsLocation.resetting": "Wird zurückgesetzt…", "settings.stemsLocation.resetFailed": "Zurücksetzen fehlgeschlagen — Verbindung prüfen.", "settings.stemsLocation.syncing": "Wird synchronisiert…", @@ -3412,6 +3437,11 @@ const pt = { "settings.autoDelete.daysTitle": "Excluir após", "settings.autoDelete.daysDesc": "Dias que uma faixa concluída é mantida antes de ser excluída (máx. {max}).", "settings.stemsLocation.change": "Alterar…", + "loop.dragOut": "Arraste este trecho para uma DAW ou pasta", + "settings.exportsLocation.title": "Pasta de exportação", + "settings.exportsLocation.desc": "Onde o áudio vai quando você arrasta um stem ou um loop para fora do StemDeck. Nada aqui é apagado automaticamente, porque um projeto que referencia um arquivo arrastado precisa que ele continue no lugar.", + "settings.exportsLocation.change": "Alterar…", + "settings.exportsLocation.pickerFailed": "Não foi possível abrir o seletor de pastas.", "settings.stemsLocation.resetting": "Redefinindo…", "settings.stemsLocation.resetFailed": "Falha ao redefinir — verifique sua conexão.", "settings.stemsLocation.syncing": "Sincronizando…", @@ -3984,6 +4014,11 @@ const id = { "settings.autoDelete.daysTitle": "Hapus setelah", "settings.autoDelete.daysDesc": "Jumlah hari trek yang selesai disimpan sebelum dihapus (maks. {max}).", "settings.stemsLocation.change": "Ubah…", + "loop.dragOut": "Seret bagian ini ke DAW atau folder", + "settings.exportsLocation.title": "Folder ekspor", + "settings.exportsLocation.desc": "Tempat audio disimpan saat Anda menyeret stem atau loop keluar dari StemDeck. Tidak ada yang dihapus otomatis di sini, karena proyek yang merujuk berkas yang diseret membutuhkannya tetap di tempatnya.", + "settings.exportsLocation.change": "Ubah…", + "settings.exportsLocation.pickerFailed": "Pemilih folder tidak dapat dibuka.", "settings.stemsLocation.resetting": "Mengatur ulang…", "settings.stemsLocation.resetFailed": "Gagal mengatur ulang — periksa koneksi Anda.", "settings.stemsLocation.syncing": "Menyinkronkan…", @@ -4546,6 +4581,11 @@ const fr = { "settings.autoDelete.daysTitle": "Supprimer après", "settings.autoDelete.daysDesc": "Nombre de jours pendant lesquels un morceau terminé est conservé avant suppression (max. {max}).", "settings.stemsLocation.change": "Modifier…", + "loop.dragOut": "Glissez cette sélection vers une STAN ou un dossier", + "settings.exportsLocation.title": "Dossier d'export", + "settings.exportsLocation.desc": "Là où va l'audio quand vous faites glisser une piste ou une boucle hors de StemDeck. Rien n'y est supprimé automatiquement, car un projet qui référence un fichier glissé a besoin qu'il reste en place.", + "settings.exportsLocation.change": "Modifier…", + "settings.exportsLocation.pickerFailed": "Impossible d'ouvrir le sélecteur de dossier.", "settings.stemsLocation.resetting": "Réinitialisation…", "settings.stemsLocation.resetFailed": "Échec de la réinitialisation — vérifiez votre connexion.", "settings.stemsLocation.syncing": "Synchronisation…", @@ -4820,6 +4860,7 @@ const ptPT = { "settings.exportLogs.preparing": "A preparar…", "settings.logs.loading": "A carregar…", "settings.stemsLocation.resetting": "A repor…", + "settings.exportsLocation.desc": "Onde o áudio vai quando arrasta um stem ou um loop para fora do StemDeck. Nada aqui é apagado automaticamente, porque um projeto que referência um ficheiro arrastado precisa que ele continue no lugar.", "settings.stemsLocation.syncing": "A sincronizar…", "playlist.skippingPrefix": "A ignorar: {list}.", "settings.tab.registry": "Registo", @@ -5234,6 +5275,11 @@ const es = { "settings.autoDelete.daysTitle": "Eliminar después de", "settings.autoDelete.daysDesc": "Días que se conserva una pista terminada antes de eliminarla (máx. {max}).", "settings.stemsLocation.change": "Cambiar…", + "loop.dragOut": "Arrastra esta región a una DAW o a una carpeta", + "settings.exportsLocation.title": "Carpeta de exportación", + "settings.exportsLocation.desc": "Donde va el audio cuando arrastras una pista o un bucle fuera de StemDeck. Aquí no se borra nada automáticamente, porque un proyecto que referencia un archivo arrastrado necesita que siga en su sitio.", + "settings.exportsLocation.change": "Cambiar…", + "settings.exportsLocation.pickerFailed": "No se pudo abrir el selector de carpetas.", "settings.stemsLocation.resetting": "Restableciendo…", "settings.stemsLocation.resetFailed": "No se pudo restablecer — revisa tu conexión.", "settings.stemsLocation.syncing": "Sincronizando…", @@ -5826,6 +5872,11 @@ const ko = { "settings.autoDelete.daysTitle": "삭제까지", "settings.autoDelete.daysDesc": "완료된 트랙을 삭제하기 전까지 보관하는 일수예요 (최대 {max}).", "settings.stemsLocation.change": "변경…", + "loop.dragOut": "이 구간을 DAW나 폴더로 끌어다 놓기", + "settings.exportsLocation.title": "내보내기 폴더", + "settings.exportsLocation.desc": "StemDeck에서 스템이나 루프를 끌어낼 때 오디오가 저장되는 곳입니다. 끌어다 놓은 파일을 참조하는 프로젝트가 그 파일을 그대로 필요로 하므로, 여기서는 아무것도 자동으로 삭제되지 않습니다.", + "settings.exportsLocation.change": "변경…", + "settings.exportsLocation.pickerFailed": "폴더 선택창을 열 수 없습니다.", "settings.stemsLocation.resetting": "되돌리는 중…", "settings.stemsLocation.resetFailed": "되돌리지 못했어요. 연결을 확인해 주세요.", "settings.stemsLocation.syncing": "동기화 중…", diff --git a/static/js/main.js b/static/js/main.js index 80e6f331..72de8548 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -5,7 +5,7 @@ import { setAutoSectionsResetFn, } from "./state.js"; import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; -import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js"; +import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder, regionDragPayload, prewarmRegionMix } from "./player.js"; import { wireJobForm, showError } from "./job.js"; import { initSearch } from "./search.js"; import { wireTransportButtons } from "./transport.js"; @@ -683,6 +683,76 @@ document.addEventListener("keydown", (e) => { } }); +// ─── Drag audio out to a DAW or a folder ─── +// +// Only the desktop app can do this: handing the OS a file needs a real path, +// which no browser will produce. The gesture is cancelled here and the +// platform drag is started in Rust (desktop/src-tauri/src/dragout.rs). + +const canDragOut = Boolean(window.__TAURI__?.core?.invoke); +if (canDragOut) document.body.classList.add("can-drag-out"); + +// The export panel's format, read from the DOM rather than its closure. +// MP4 is a video mux that cannot be region-trimmed, so a drag is always audio. +function exportFormat() { + const ext = document.querySelector(".export-fmt.active")?.id?.replace("t-fmt-", "") || "wav"; + return ext === "mp4" ? "wav" : ext; +} + +if (canDragOut) { + document.addEventListener("dragstart", (e) => { + const grip = e.target.closest("[data-loop-drag-out]"); + const lane = e.target.closest("a.lane-dl"); + if (!grip && !lane) return; + + // The lane anchor already carries the song-prefixed filename the click + // path saves under, set in player.js. Reuse it rather than deriving a + // second one. Placeholder rows for absent stems keep href="#". + const payload = grip + ? regionDragPayload(exportFormat()) + : lane.download && !lane.getAttribute("href").endsWith("#") + ? { url: lane.href, filename: lane.download } + : null; + + // Always cancel: an HTML5 drag of a lane anchor would otherwise offer the + // page's own URL to the drop target, which is worse than doing nothing. + e.preventDefault(); + if (!payload) return; + invokeDrag(payload); + }); +} + +function invokeDrag({ url, filename }) { + const invoke = window.__TAURI__?.core?.invoke; + invoke?.("start_audio_drag", { url, filename }).catch((err) => { + console.warn("[stemdeck] drag failed:", err); + }); +} + +// Render the region before it is grabbed. +// +// A platform drag must start while the button is still down, so the file +// cannot be rendered during the gesture. Warming on pointerup covers both +// moving the loop and moving a fader, since a gain change alters the mixdown +// and so the cache key the drag will ask for. +let prewarmTimer = null; +let lastWarmed = ""; +if (canDragOut) { + document.addEventListener( + "pointerup", + () => { + clearTimeout(prewarmTimer); + prewarmTimer = setTimeout(() => { + const payload = regionDragPayload(exportFormat()); + if (!payload || payload.url === lastWarmed) return; + lastWarmed = payload.url; + prewarmRegionMix(exportFormat()); + }, 500); + }, + true, + ); +} + // ─── External links ─── document.addEventListener("click", (e) => { diff --git a/static/js/player.js b/static/js/player.js index 0a8a3503..f191afaa 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -1919,6 +1919,45 @@ function _regionFilename(ext) { return `${safe || "region"}_region.${ext}`; } +// The region a drag hands over, or null when there is nothing to drag. +// +// Named with the region bounds, unlike the Export Region filename: a dragged +// file lands in a folder nothing ever cleans up, and the Rust side reuses a +// file that is already there. Two different loops of one song sharing a name +// would mean the second drag silently handed over the first one's audio. +function _regionDragFilename(ext) { + const safe = _currentTitle + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/_{2,}/g, "_") + .slice(0, 80) + .replace(/^_+|_+$/g, ""); + const span = `${loopStart.toFixed(1)}-${loopEnd.toFixed(1)}`.replace(/\./g, "_"); + return `${safe || "region"}_region_${span}.${ext}`; +} + +export function regionDragPayload(ext = "wav") { + if (!loopEnabled || loopStart >= loopEnd) return null; + const url = _mixdownUrl(ext, true); + if (!url) return null; + return { url, filename: _regionDragFilename(ext) }; +} + +// Render the region before the user reaches for it. +// +// A drag has to be handed to the OS while the mouse button is still down, so +// the file cannot be rendered during the gesture: ffmpeg takes long enough +// that the button would be released first and the drag would never attach. +// Warming the server's render cache (_mixdown_cache_key in app/api/stems.py) +// is what makes the drag itself a file copy. +// +// Range: bytes=0-0 so this costs a render, which is the point, and not the +// transfer, which would be the whole region twice. +export function prewarmRegionMix(ext = "wav") { + const payload = regionDragPayload(ext); + if (!payload) return; + fetch(payload.url, { headers: { Range: "bytes=0-0" } }).catch(() => {}); +} + export function downloadRegionMix(ext = "wav", onTransferStart) { if (!loopEnabled || loopStart >= loopEnd) return false; const url = _mixdownUrl(ext, true); diff --git a/static/js/transport.js b/static/js/transport.js index 5a2be58e..166a1752 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -558,6 +558,9 @@ function wireLoopRegionAdjust() { loopRegionEl.addEventListener("pointerdown", (e) => { if (e.button !== 0 || !totalDuration) return; + // The drag-out grip runs an HTML5 drag. Starting a pointer-drag from the + // same gesture would move the region while it is being dragged out. + if (e.target.closest("[data-loop-drag-out]")) return; const t = timeFromClientX(e.clientX); if (t === null) return; mode = e.target.closest("[data-loop-handle]")?.dataset.loopHandle ?? "move"; diff --git a/tests/e2e/drag-out.spec.mjs b/tests/e2e/drag-out.spec.mjs new file mode 100644 index 00000000..ef00537d --- /dev/null +++ b/tests/e2e/drag-out.spec.mjs @@ -0,0 +1,153 @@ +// Dragging a stem or a loop region out to a DAW or a folder. +// +// The OS drag itself cannot be tested from a browser: it is started in Rust +// (desktop/src-tauri/src/dragout.rs) and lands in another application. What is +// testable, and what actually breaks, is everything on this side of that call. +// +// Two things in particular: +// +// The grip sits inside #loop-region, which already runs its own pointer drag +// for moving the selection and adjusting both edges. An HTML5 drag and a +// pointer drag on one element fight, and the visible result is the region +// sliding away while it is being dragged out. The exclusion that prevents that +// is one line in transport.js and nothing else would notice if it were lost. +// +// And the payload. A dragged file lands in a folder nothing ever cleans up, +// and the Rust side reuses a file that is already there, so two regions of one +// song sharing a filename would mean the second drag silently handing over the +// first one's audio. + +import { test, expect } from "@playwright/test"; +import { openStudio } from "./helpers.mjs"; + +const GRIP = "[data-loop-drag-out]"; + +// Percentages of the ruler, so this does not depend on the fixture's duration. +async function markLoop(page, fromFrac = 0.2, toFrac = 0.6) { + const ruler = await page.locator("#ruler-time").boundingBox(); + const y = ruler.y + ruler.height / 2; + await page.mouse.move(ruler.x + ruler.width * fromFrac, y); + await page.mouse.down(); + await page.mouse.move(ruler.x + ruler.width * toFrac, y, { steps: 8 }); + await page.mouse.up(); + await expect(page.locator("#t-loop")).toHaveClass(/active/); +} + +const loopBounds = (page) => + page.evaluate(() => { + const el = document.getElementById("loop-region"); + const parent = el.parentElement.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return { + start: +((box.left - parent.left) / parent.width).toFixed(4), + end: +((box.right - parent.left) / parent.width).toFixed(4), + }; + }); + +// A real HTML5 drag cannot be driven from Playwright, and it is not what is +// under test: the delegated listener is. Dispatching the event it listens for +// exercises exactly the code this feature adds. +const fireDragStart = (page, selector) => + page.evaluate((sel) => { + document.querySelector(sel).dispatchEvent(new DragEvent("dragstart", { bubbles: true })); + }, selector); + +const dragCalls = (page) => page.evaluate(() => window.__e2e.callsFor("start_audio_drag")); + +test.describe("dragging audio out", () => { + test("the grip appears only where a drag can actually happen", async ({ page }) => { + await openStudio(page, { tauri: true }); + await expect(page.locator("body")).toHaveClass(/can-drag-out/); + await markLoop(page); + await expect(page.locator(GRIP)).toBeVisible(); + }); + + test("served in a browser, nothing offers a gesture that would do nothing", async ({ page }) => { + await openStudio(page); + await expect(page.locator("body")).not.toHaveClass(/can-drag-out/); + await markLoop(page); + // Present in the markup, but display:none without the class, so it can be + // neither seen nor grabbed. + await expect(page.locator(GRIP)).toBeHidden(); + }); + + test("grabbing the grip does not drag the region with it", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + const before = await loopBounds(page); + + const grip = await page.locator(GRIP).boundingBox(); + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await page.mouse.down(); + await page.mouse.move(grip.x + 220, grip.y + grip.height / 2, { steps: 10 }); + await page.mouse.up(); + + expect(await loopBounds(page)).toEqual(before); + }); + + test("the region drag carries the loop bounds in its filename", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page, 0.2, 0.6); + await fireDragStart(page, GRIP); + + const calls = await dragCalls(page); + expect(calls).toHaveLength(1); + const { url, filename } = calls[0].args; + expect(url).toContain("/mixdown.wav"); + expect(url).toMatch(/[?&]start=/); + expect(url).toMatch(/[?&]end=/); + expect(filename).toMatch(/_region_[\d_]+-[\d_]+\.wav$/); + }); + + test("two different regions of one song are two different files", async ({ page }) => { + await openStudio(page, { tauri: true }); + + await markLoop(page, 0.1, 0.3); + await fireDragStart(page, GRIP); + await markLoop(page, 0.5, 0.9); + await fireDragStart(page, GRIP); + + const [first, second] = await dragCalls(page); + expect(first.args.filename).not.toBe(second.args.filename); + expect(first.args.url).not.toBe(second.args.url); + }); + + test("a stem lane drags the stem, under the name its download uses", async ({ page }) => { + await openStudio(page, { tauri: true }); + // Not simply the first lane: rows for stems that are absent keep href="#" + // and are covered by the next test. + const real = 'a.lane-dl:not([href="#"])'; + const lane = page.locator(real).first(); + await expect(lane).toHaveAttribute("download", /\.wav$/); + const expected = await lane.getAttribute("download"); + + await fireDragStart(page, real); + + const calls = await dragCalls(page); + expect(calls).toHaveLength(1); + // The name the click path saves under, not a second one derived here. + // Deriving it again once prefixed the song title twice. + expect(calls[0].args.filename).toBe(expected); + expect(calls[0].args.url).toContain("/stems/"); + }); + + test("a placeholder lane for an absent stem drags nothing", async ({ page }) => { + await openStudio(page, { tauri: true }); + // It carries a download name but no real href. Handing that to the OS + // would drag the page's own URL, which is worse than doing nothing. + const placeholder = 'a.lane-dl[href="#"]'; + await expect(page.locator(placeholder).first()).toBeAttached(); + + await fireDragStart(page, placeholder); + + expect(await dragCalls(page)).toHaveLength(0); + }); + + test("no loop, no drag", async ({ page }) => { + await openStudio(page, { tauri: true }); + // The grip is inside the region, which is hidden until a loop is marked, + // so there is nothing to grab and nothing is invoked. + await expect(page.locator("#loop-region")).toHaveClass(/hidden/); + expect(await dragCalls(page)).toHaveLength(0); + }); +}); From 524e6f3f41dfc6bc9a0686761a2d790b5feac704 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Fri, 4 Sep 2026 14:35:05 +0100 Subject: [PATCH 2/2] feat(export): a drag handle per lane, and a zoom that has the data to back it Follows the first pass on #570, after using it. One handle above the lanes could not say which track it would produce, so it was read as belonging to the first one. Every lane now carries its own handle, tucked inside the right edge of the selection and drawn in that lane's own waveform colour. The wide handle on the top edge stays, in gold, and is the mix. A lane handle gives that stem at unity gain, ignoring its fader, mute and solo, exactly as the lane's download button does. Mute and solo shape a mixdown, and the mix is what the top handle carries. Zoom goes to 10x. That is a data change, not a constant: peaks.json carried 1500 points, which backs about 5x on a full-width panel, and past the point count the bars repeat their neighbours instead of revealing anything. It now carries 3000, and both constants name each other so they cannot drift. Tracks separated before this keep their 1500 points and stop gaining detail past 5x on the streaming path; the Web Audio path scans the decoded buffer itself and is sharp either way. The drag preview is the lane's name on a plate in its colour. The instrument glyph was tried first and abandoned: these are 24px line icons, and at the size a drag image is drawn the bass reads as a key and the kit as a face. A word cannot be misread. Six bugs found by using it, each with a test where one can exist: - The region drag passed a relative URL. validate_download_url takes only http/https, so it rejected every one of them, and the error went to a console that a release build does not show. There is now a single _absolute() both the drag and the export path go through. - wireLoopDrag turns a drag anywhere on the waves column into a new selection and calls preventDefault, which kills an HTML5 drag before dragstart fires. It excluded .loop-region, so the mix handle worked and the lane ones silently redefined the loop instead. Both are named in one selector now. - The handles were gated on the selection being wider than 2% of the track, which is a gate on the fraction of the song and so never met by the short loops people actually work with. Removed rather than tuned; a pixel threshold would go stale, since zooming changes the rendered width without passing through that code. - The preview SVG had no xmlns. Inline in the document the parser infers it; loaded through an img the file is parsed standalone and simply fails, so every lane drag quietly carried the app badge instead. - The handle lived in the row's innerHTML, which a redraw rewrites, so it vanished on the second wheel notch. - A lane's region had no warm render of its own. It is warmed on hover, which always precedes the grab, rather than warming all six on every loop change. Verified: 127 e2e, 16 Rust unit tests, ruff and clippy clean on Windows and Linux, i18n complete across all eleven tables, uv.lock untouched. Closes #570 --- app/pipeline/collect.py | 6 +- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/src/dragout.rs | 85 ++++++++++++++- desktop/src-tauri/src/main.rs | 3 +- static/css/waves.css | 126 ++++++++++++++++++++-- static/js/i18n.js | 10 ++ static/js/main.js | 79 +++++++++++--- static/js/mixer.js | 55 ++++++++++ static/js/player.js | 70 ++++++++++++- static/js/transport.js | 63 +++++++++-- tests/e2e/drag-out.spec.mjs | 172 +++++++++++++++++++++++++++++++ tests/e2e/zoom.spec.mjs | 4 +- 13 files changed, 634 insertions(+), 43 deletions(-) diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py index cea996d1..8cd251bd 100644 --- a/app/pipeline/collect.py +++ b/app/pipeline/collect.py @@ -182,7 +182,11 @@ def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | Non return out if _run_ffmpeg(job, cmd) else None -_PEAK_POINTS = 1500 # matches OVERVIEW_WAVE_POINTS in player.js +# Enough points to back the deepest zoom, not the 1x bar count: a full-width +# panel draws about 2400 bars at WAVE_ZOOM_MAX (10x, transport.js), and past +# the point count the bars repeat their neighbours instead of revealing +# anything. Raise this and WAVE_ZOOM_MAX together. +_PEAK_POINTS = 3000 def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> dict[str, float]: diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ddbef1a9..b6a073b8 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3408,6 +3408,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stemdeck" version = "0.0.0" dependencies = [ + "base64 0.22.1", "drag", "flate2", "libc", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 291935e1..154d59f4 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -22,6 +22,9 @@ local-ip-address = "0.6" # worse than plain http because it looks secure. See src/certs.rs. rcgen = "0.13" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } +# Decoding the drag preview the UI renders for a lane. Already in the tree via +# reqwest; named here because dragout.rs uses it directly. +base64 = "0.22" # Native drag-out of a rendered file to the OS (a DAW, Explorer, Finder). # The crate tauri-plugin-drag wraps; taken directly because the plugin exposes # start_drag only as a #[command], which would mean JavaScript naming the file diff --git a/desktop/src-tauri/src/dragout.rs b/desktop/src-tauri/src/dragout.rs index 9879d44e..abe2abec 100644 --- a/desktop/src-tauri/src/dragout.rs +++ b/desktop/src-tauri/src/dragout.rs @@ -39,11 +39,52 @@ use std::path::{Path, PathBuf}; use drag::{DragItem, DragMode, DragResult, Image, Options}; -/// Embedded rather than read from disk: the icon's path differs between a dev -/// run, the portable zip and an installed bundle, and a drag with no preview -/// looks broken. 96px, which is what the platforms draw it at. +/// The fallback drag preview: StemDeck's own icon. +/// +/// Embedded rather than read from disk, because the icon's path differs between +/// a dev run, the portable zip and an installed bundle, and a drag with no +/// preview looks broken. Used for the mix and whenever a lane's own icon is +/// unavailable. const DRAG_IMAGE: &[u8] = include_bytes!("../icons/drag.png"); +/// Ceiling on a caller-supplied preview. It is a small label on a plate; a +/// megabyte of one is a mistake or an attempt at one, and the app icon is a +/// better answer than either. +const MAX_ICON_BYTES: usize = 512 * 1024; + +/// Decode the preview the UI rendered for this lane, or fall back. +/// +/// The picture in flight should say which track is coming, and the UI is where +/// the lane's name and colour already live, so it draws the label to a canvas +/// and sends the PNG rather than this module keeping a second set of assets +/// that would drift from the mixer. +/// +/// Nothing here is trusted: a malformed, empty or oversized payload silently +/// becomes the app icon, because a drag carrying the wrong picture is still a +/// working drag and refusing one would cost the user the gesture. +fn decode_icon(icon: Option) -> Vec { + use base64::Engine; + + let Some(encoded) = icon else { + return DRAG_IMAGE.to_vec(); + }; + match base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) { + Ok(bytes) if !bytes.is_empty() && bytes.len() <= MAX_ICON_BYTES => bytes, + Ok(bytes) => { + log_icon_rejected(bytes.len()); + DRAG_IMAGE.to_vec() + } + Err(_) => { + log_icon_rejected(0); + DRAG_IMAGE.to_vec() + } + } +} + +fn log_icon_rejected(len: usize) { + eprintln!("[stemdeck] ignoring a drag preview of {len} bytes; using the app icon"); +} + /// Characters no Windows filename may contain, plus the separators every /// platform uses. `:` also matters on macOS, where Finder still shows it as a /// path separator. @@ -154,7 +195,11 @@ pub fn default_exports_dir(jobs_dir: &Path, data_dir: &Path) -> PathBuf { /// Must be called on the main thread: all three backends talk to UI toolkit /// state (OLE on Windows, AppKit on macOS, GTK on Linux) that is not /// thread-safe. The caller is responsible for that; see `start_audio_drag`. -pub fn begin_drag(window: &tauri::WebviewWindow, path: PathBuf) -> Result<(), String> { +pub fn begin_drag( + window: &tauri::WebviewWindow, + path: PathBuf, + icon: Option, +) -> Result<(), String> { let options = Options { // Copy, never Move: the exports folder is the user's copy and a DAW // must not relocate it out from under them. @@ -162,7 +207,7 @@ pub fn begin_drag(window: &tauri::WebviewWindow, path: PathBuf) -> Result<(), St skip_animatation_on_cancel_or_failure: false, }; let item = DragItem::Files(vec![path]); - let image = Image::Raw(DRAG_IMAGE.to_vec()); + let image = Image::Raw(decode_icon(icon)); let on_drop = |result: DragResult, _: drag::CursorPosition| { // Nothing to undo either way. The file stays in the exports folder // whether it was dropped or the drag was abandoned, which is what a @@ -254,6 +299,36 @@ mod tests { assert_eq!(got, String::from_utf8(got.clone().into_bytes()).unwrap()); } + #[test] + fn no_icon_falls_back_to_the_app_icon() { + assert_eq!(decode_icon(None), DRAG_IMAGE); + } + + #[test] + fn a_valid_icon_is_decoded() { + use base64::Engine; + // Contents are never inspected here; the platform decides whether + // it is a usable image and tolerates one that is not. + let png = b"pretend this is a PNG"; + let encoded = base64::engine::general_purpose::STANDARD.encode(png); + assert_eq!(decode_icon(Some(encoded)), png); + } + + #[test] + fn a_broken_icon_falls_back_rather_than_failing_the_drag() { + // Wrong picture beats no drag. + assert_eq!(decode_icon(Some("not base64 at all!!".into())), DRAG_IMAGE); + assert_eq!(decode_icon(Some(String::new())), DRAG_IMAGE); + } + + #[test] + fn an_oversized_icon_falls_back() { + use base64::Engine; + let huge = vec![0u8; MAX_ICON_BYTES + 1]; + let encoded = base64::engine::general_purpose::STANDARD.encode(&huge); + assert_eq!(decode_icon(Some(encoded)), DRAG_IMAGE); + } + #[test] fn exports_dir_prefers_a_usable_configured_path() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index c450832d..f882b0ff 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2832,6 +2832,7 @@ async fn start_audio_drag( window: tauri::WebviewWindow, url: String, filename: String, + icon: Option, ) -> Result<(), String> { validate_download_url(&url)?; let name = dragout::sanitize_filename(&filename)?; @@ -2856,7 +2857,7 @@ async fn start_audio_drag( // then either: the gesture is over. Everything that can fail in a way JS // can act on (a bad name, a failed render) has already happened above. app.run_on_main_thread(move || { - if let Err(e) = dragout::begin_drag(&window, dest) { + if let Err(e) = dragout::begin_drag(&window, dest, icon) { eprintln!("[stemdeck] could not start the drag: {e}"); } }) diff --git a/static/css/waves.css b/static/css/waves.css index d44578d3..ed3ff077 100644 --- a/static/css/waves.css +++ b/static/css/waves.css @@ -787,30 +787,140 @@ .can-drag-out .loop-grip { display: block; position: absolute; - top: 3px; + /* Flush with the top of the selection, hanging off its own border like the + lane grips hang off the right one. It cannot go any higher: .waves-column + clips its overflow, and 3px down was enough to look like it had come + loose and landed on the first lane's waveform. */ + top: 0; left: 50%; transform: translateX(-50%); - width: 26px; - height: 14px; - border-radius: 3px; - background: var(--gold); + width: 44px; + height: 13px; + border-radius: 0 0 4px 4px; + background-color: rgba(8, 13, 18, 0.88); + /* Gold, where a lane grip takes its own stem's colour: this one is the mix + and belongs to no single track. */ + background-image: repeating-linear-gradient( + to right, + var(--gold) 0 2px, + transparent 2px 5px + ); + background-size: 20px 7px; + background-repeat: no-repeat; + background-position: center; + border: 1px solid rgba(216, 168, 74, 0.55); + border-top: 0; opacity: 0; cursor: grab; - /* Above the resize handles: it overlaps neither, but a narrow region - squeezes all three together and the grip must stay grabbable. */ + /* Above the resize handles: a narrow region squeezes all three together and + the grip must stay grabbable. */ z-index: 5; - transition: opacity 0.12s ease; + transition: + opacity 0.12s ease, + border-color 0.12s ease; } .can-drag-out .loop-region:hover .loop-grip { opacity: 0.85; } +.can-drag-out .loop-grip:hover { + opacity: 1; + border-color: var(--gold); +} + .can-drag-out .loop-grip:active { cursor: grabbing; opacity: 1; } +/* Nothing audible to export, so there is nothing to drag. Shown rather than + hidden: a grip that vanishes when you mute a lane reads as a bug, and this + says the selection is empty. */ +.can-drag-out .loop-region:hover .loop-grip.disabled { + opacity: 0.3; + background: var(--muted, #6b7280); + cursor: not-allowed; +} + +/* One nugget per lane, so which track a drag produces is something you look at + rather than remember. Positioned from --loop-right on .waves-column, which + updateLoopRegionVisual writes once per loop change. + + The waveform layer is pointer-events: none so the waveforms never swallow a + click meant for the loop underneath; the nugget puts its own back. */ +.lane-drag-nugget { + display: none; +} + +.can-drag-out .loop-armed .lane-drag-nugget { + display: block; + position: absolute; + /* Along the bottom edge of the lane rather than over the middle of it: the + waveform is the thing being read, and a handle sitting on top of the loudest + part of it hides exactly what the user is aiming at. The 1px lane divider + is at 0, so this clears it. */ + bottom: 3px; + /* Tucked inside the right edge of the selection. The 6px backs it off the + end resize handle, which reaches 5px into the region (.loop-handle-end), + so the two never sit on top of each other. */ + /* Flush against the inside of the loop's right border, with the right + corners square and no right edge of its own, so it reads as a tab on the + selection rather than a marker floating near it. The end resize handle + reaches 5px in and is overlapped here, but it also extends 7px outside the + region, which is the half people grab anyway. */ + left: var(--loop-right, 50%); + transform: translateX(-100%); + width: 24px; + height: 14px; + border-radius: 4px 0 0 4px; + /* A grip, not a slab. A solid gold block this size reads as a bug in the + waveform; three bars on a recessed plate is the shape every drag handle + in every DAW already uses, so it needs no explaining. Drawn with a + gradient rather than child elements, since the nugget is rebuilt on every + redraw. */ + background-color: rgba(8, 13, 18, 0.88); + /* Its own lane's colour, the one its waveform is drawn in. The row carries + it as --stem-color (renderOverviewWaveformPath in player.js), so which + track a grip belongs to is the same signal as the waveform above it. Gold + is the fallback, and what the mix grip keeps. */ + background-image: repeating-linear-gradient( + to right, + var(--stem-color, var(--gold)) 0 2px, + transparent 2px 5px + ); + background-size: 11px 7px; + background-repeat: no-repeat; + background-position: center; + border: 1px solid rgba(216, 168, 74, 0.55); + /* Declared after the fallback, so a renderer without color-mix keeps the + line above rather than losing the border entirely. */ + border-color: color-mix(in srgb, var(--stem-color, #d8a84a) 60%, transparent); + border-right: 0; + opacity: 0; + cursor: grab; + pointer-events: auto; + z-index: 6; + transition: + opacity 0.12s ease, + border-color 0.12s ease; +} + +/* Revealed together on hover anywhere over the waveforms, so the whole set + reads as one column of handles rather than something that appears per lane. */ +.can-drag-out .loop-armed:hover .lane-drag-nugget { + opacity: 0.9; +} + +.can-drag-out .loop-armed .lane-drag-nugget:hover { + opacity: 1; + border-color: var(--stem-color, var(--gold)); +} + +.can-drag-out .loop-armed .lane-drag-nugget:active { + cursor: grabbing; +} + /* Wider than the 2px border they sit on: an edge you cannot reliably grab is the finnicky behaviour this replaces. Extends outside the region as well as in, so the handle is catchable from either side. */ diff --git a/static/js/i18n.js b/static/js/i18n.js index 425412d7..a3d14e0a 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -545,6 +545,7 @@ const en = { "settings.autoDelete.daysDesc": "Days a finished track is kept before it is deleted (max {max}).", "settings.stemsLocation.change": "Change…", "loop.dragOut": "Drag this region into a DAW or folder", + "loop.dragOutStem": "Drag {name} for this region into a DAW or folder", "settings.exportsLocation.title": "Exports folder", "settings.exportsLocation.desc": "Where audio goes when you drag a stem or a loop out of StemDeck. Nothing here is deleted automatically, because a project that references a dragged file needs it to stay put.", "settings.exportsLocation.change": "Change…", @@ -1142,6 +1143,7 @@ const pl = { "settings.autoDelete.daysDesc": "Liczba dni przechowywania ukończonego utworu przed usunięciem (maks. {max}).", "settings.stemsLocation.change": "Zmień…", "loop.dragOut": "Przeciągnij ten fragment do DAW lub folderu", + "loop.dragOutStem": "Przeciągnij ścieżkę {name} z tego fragmentu do DAW lub folderu", "settings.exportsLocation.title": "Folder eksportu", "settings.exportsLocation.desc": "Tu trafia dźwięk, gdy przeciągasz ścieżkę lub pętlę poza StemDeck. Nic nie jest tu usuwane automatycznie, ponieważ projekt odwołujący się do przeciągniętego pliku potrzebuje go w tym miejscu.", "settings.exportsLocation.change": "Zmień…", @@ -1731,6 +1733,7 @@ const ja = { "settings.autoDelete.daysDesc": "完了したトラックを削除するまで保持する日数(最大{max}日)。", "settings.stemsLocation.change": "変更…", "loop.dragOut": "この区間をDAWやフォルダーにドラッグ", + "loop.dragOutStem": "この区間の{name}をDAWやフォルダーにドラッグ", "settings.exportsLocation.title": "書き出し先フォルダー", "settings.exportsLocation.desc": "StemDeckからステムやループをドラッグしたときの保存先です。ドラッグしたファイルを参照するプロジェクトのため、ここのファイルが自動で削除されることはありません。", "settings.exportsLocation.change": "変更…", @@ -2296,6 +2299,7 @@ const zhHans = { "settings.autoDelete.daysDesc": "已完成的音轨在删除前保留的天数(最多 {max} 天)。", "settings.stemsLocation.change": "更改…", "loop.dragOut": "将此区域拖入 DAW 或文件夹", + "loop.dragOutStem": "将此区域的{name}拖入 DAW 或文件夹", "settings.exportsLocation.title": "导出文件夹", "settings.exportsLocation.desc": "从 StemDeck 拖出分轨或循环段时音频的存放位置。这里的文件不会被自动删除,因为引用已拖出文件的工程需要它们保留在原处。", "settings.exportsLocation.change": "更改…", @@ -2862,6 +2866,7 @@ const de = { "settings.autoDelete.daysDesc": "Tage, die ein fertiger Titel aufbewahrt wird, bevor er gelöscht wird (max. {max}).", "settings.stemsLocation.change": "Ändern…", "loop.dragOut": "Diesen Bereich in eine DAW oder einen Ordner ziehen", + "loop.dragOutStem": "{name} aus diesem Bereich in eine DAW oder einen Ordner ziehen", "settings.exportsLocation.title": "Export-Ordner", "settings.exportsLocation.desc": "Hierhin kommt Audio, wenn du eine Spur oder eine Schleife aus StemDeck ziehst. Hier wird nichts automatisch gelöscht, denn ein Projekt, das eine gezogene Datei referenziert, braucht sie an Ort und Stelle.", "settings.exportsLocation.change": "Ändern…", @@ -3438,6 +3443,7 @@ const pt = { "settings.autoDelete.daysDesc": "Dias que uma faixa concluída é mantida antes de ser excluída (máx. {max}).", "settings.stemsLocation.change": "Alterar…", "loop.dragOut": "Arraste este trecho para uma DAW ou pasta", + "loop.dragOutStem": "Arraste {name} deste trecho para uma DAW ou pasta", "settings.exportsLocation.title": "Pasta de exportação", "settings.exportsLocation.desc": "Onde o áudio vai quando você arrasta um stem ou um loop para fora do StemDeck. Nada aqui é apagado automaticamente, porque um projeto que referencia um arquivo arrastado precisa que ele continue no lugar.", "settings.exportsLocation.change": "Alterar…", @@ -4015,6 +4021,7 @@ const id = { "settings.autoDelete.daysDesc": "Jumlah hari trek yang selesai disimpan sebelum dihapus (maks. {max}).", "settings.stemsLocation.change": "Ubah…", "loop.dragOut": "Seret bagian ini ke DAW atau folder", + "loop.dragOutStem": "Seret {name} dari bagian ini ke DAW atau folder", "settings.exportsLocation.title": "Folder ekspor", "settings.exportsLocation.desc": "Tempat audio disimpan saat Anda menyeret stem atau loop keluar dari StemDeck. Tidak ada yang dihapus otomatis di sini, karena proyek yang merujuk berkas yang diseret membutuhkannya tetap di tempatnya.", "settings.exportsLocation.change": "Ubah…", @@ -4582,6 +4589,7 @@ const fr = { "settings.autoDelete.daysDesc": "Nombre de jours pendant lesquels un morceau terminé est conservé avant suppression (max. {max}).", "settings.stemsLocation.change": "Modifier…", "loop.dragOut": "Glissez cette sélection vers une STAN ou un dossier", + "loop.dragOutStem": "Glissez {name} de cette sélection vers une STAN ou un dossier", "settings.exportsLocation.title": "Dossier d'export", "settings.exportsLocation.desc": "Là où va l'audio quand vous faites glisser une piste ou une boucle hors de StemDeck. Rien n'y est supprimé automatiquement, car un projet qui référence un fichier glissé a besoin qu'il reste en place.", "settings.exportsLocation.change": "Modifier…", @@ -5276,6 +5284,7 @@ const es = { "settings.autoDelete.daysDesc": "Días que se conserva una pista terminada antes de eliminarla (máx. {max}).", "settings.stemsLocation.change": "Cambiar…", "loop.dragOut": "Arrastra esta región a una DAW o a una carpeta", + "loop.dragOutStem": "Arrastra {name} de esta región a una DAW o a una carpeta", "settings.exportsLocation.title": "Carpeta de exportación", "settings.exportsLocation.desc": "Donde va el audio cuando arrastras una pista o un bucle fuera de StemDeck. Aquí no se borra nada automáticamente, porque un proyecto que referencia un archivo arrastrado necesita que siga en su sitio.", "settings.exportsLocation.change": "Cambiar…", @@ -5873,6 +5882,7 @@ const ko = { "settings.autoDelete.daysDesc": "완료된 트랙을 삭제하기 전까지 보관하는 일수예요 (최대 {max}).", "settings.stemsLocation.change": "변경…", "loop.dragOut": "이 구간을 DAW나 폴더로 끌어다 놓기", + "loop.dragOutStem": "이 구간의 {name}을(를) DAW나 폴더로 끌어다 놓기", "settings.exportsLocation.title": "내보내기 폴더", "settings.exportsLocation.desc": "StemDeck에서 스템이나 루프를 끌어낼 때 오디오가 저장되는 곳입니다. 끌어다 놓은 파일을 참조하는 프로젝트가 그 파일을 그대로 필요로 하므로, 여기서는 아무것도 자동으로 삭제되지 않습니다.", "settings.exportsLocation.change": "변경…", diff --git a/static/js/main.js b/static/js/main.js index 72de8548..77aed7d6 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -5,13 +5,13 @@ import { setAutoSectionsResetFn, } from "./state.js"; import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; -import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder, regionDragPayload, prewarmRegionMix } from "./player.js"; +import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder, regionDragPayload, stemRegionDragPayload, prewarmRegionMix } from "./player.js"; import { wireJobForm, showError } from "./job.js"; import { initSearch } from "./search.js"; import { wireTransportButtons } from "./transport.js"; import { wireBeatGridUi } from "./beatgridUi.js"; import { togglePlayPause, updateLoopRegionVisual, toggleMetronome, transport, setPlayheadTime } from "./transport.js"; -import { wireStemListControls, wireMixerToolbar } from "./mixer.js"; +import { wireStemListControls, wireMixerToolbar, laneDragIcon } from "./mixer.js"; import { initCatalog, collectDiagnostics } from "./catalog.js"; import { initNotifications, notifyFailure, dismissFailuresByJobId } from "./notifications.js"; import { runStoreMigrationIfNeeded } from "./utils.js"; @@ -702,17 +702,26 @@ function exportFormat() { if (canDragOut) { document.addEventListener("dragstart", (e) => { const grip = e.target.closest("[data-loop-drag-out]"); + const nugget = e.target.closest("[data-lane-drag-out]"); const lane = e.target.closest("a.lane-dl"); - if (!grip && !lane) return; - - // The lane anchor already carries the song-prefixed filename the click - // path saves under, set in player.js. Reuse it rather than deriving a - // second one. Placeholder rows for absent stems keep href="#". - const payload = grip - ? regionDragPayload(exportFormat()) - : lane.download && !lane.getAttribute("href").endsWith("#") - ? { url: lane.href, filename: lane.download } - : null; + if (!grip && !nugget && !lane) return; + + let payload = null; + if (grip) { + payload = regionDragPayload(exportFormat()); + } else if (nugget) { + const stem = nugget.dataset.laneDragOut; + payload = stemRegionDragPayload(stem, exportFormat()); + // The instrument, so what is in flight says which track it is. A miss + // falls back to the app icon in Rust rather than blocking the drag. + if (payload) payload.icon = laneDragIcon(stem); + } else if (lane.download && !lane.getAttribute("href").endsWith("#")) { + // The anchor already carries the song-prefixed filename the click path + // saves under, set in player.js, and an href the browser has made + // absolute. Reuse both rather than deriving a second copy of either. + // Placeholder rows for absent stems keep href="#". + payload = { url: lane.href, filename: lane.download }; + } // Always cancel: an HTML5 drag of a lane anchor would otherwise offer the // page's own URL to the drop target, which is worse than doing nothing. @@ -722,9 +731,46 @@ if (canDragOut) { }); } -function invokeDrag({ url, filename }) { +// Render a lane's slice before it is grabbed. +// +// The mix is warmed on pointerup, but a lane's region is a different render +// with its own cache key, and warming all six on every loop change would be +// six ffmpeg runs for the one the user might want. Hovering a grip is the +// cheapest honest signal of which that is, and it always precedes the grab. +const warmedLanes = new Set(); + +function warmLaneRegion(stem) { + const payload = stemRegionDragPayload(stem, exportFormat()); + if (!payload || warmedLanes.has(payload.url)) return; + warmedLanes.add(payload.url); + // Range so this costs the render, which is the point, and not the transfer. + fetch(payload.url, { headers: { Range: "bytes=0-0" } }).catch(() => { + // Let it be retried; a failed warm just means the drag renders instead. + warmedLanes.delete(payload.url); + }); +} + +if (canDragOut) { + document.addEventListener( + "pointerover", + (e) => { + const stem = e.target.closest?.("[data-lane-drag-out]")?.dataset.laneDragOut; + if (stem) warmLaneRegion(stem); + }, + true, + ); +} + +function refreshDragGrip(enabled) { + const grip = document.querySelector("[data-loop-drag-out]"); + if (!grip) return; + grip.draggable = enabled; + grip.classList.toggle("disabled", !enabled); +} + +function invokeDrag({ url, filename, icon = null }) { const invoke = window.__TAURI__?.core?.invoke; - invoke?.("start_audio_drag", { url, filename }).catch((err) => { + invoke?.("start_audio_drag", { url, filename, icon }).catch((err) => { console.warn("[stemdeck] drag failed:", err); }); } @@ -744,6 +790,11 @@ if (canDragOut) { clearTimeout(prewarmTimer); prewarmTimer = setTimeout(() => { const payload = regionDragPayload(exportFormat()); + // Mute every lane and there is nothing to export. The menu says so; + // a drag has nowhere to say it, so the grip stops being draggable + // instead of starting a gesture that silently produces nothing. + // pointerup is the right moment: muting and soloing are both clicks. + refreshDragGrip(Boolean(payload)); if (!payload || payload.url === lastWarmed) return; lastWarmed = payload.url; prewarmRegionMix(exportFormat()); diff --git a/static/js/mixer.js b/static/js/mixer.js index 33ccd78e..10b84314 100644 --- a/static/js/mixer.js +++ b/static/js/mixer.js @@ -452,6 +452,61 @@ function stemIconMarkup(stemName) { return icons[stemName] || icons.other; } +// ─── Drag preview ─── +// +// What follows the cursor when a lane is dragged out to the OS. +// +// The lane's instrument glyph was the obvious choice and was wrong: these are +// 24px line icons, and at the size a drag image is drawn they stop reading as +// instruments -- the bass turns into a key, the kit into a face. A word cannot +// be misread. It is also the same word the lane is labelled with, so nothing +// has to be recognised at all. +// +// Built on demand rather than cached: it is a few canvas calls with no image +// decode, so there is nothing to save, and a cache would go stale the moment +// the language changed. + +const PREVIEW_H = 64; +const PREVIEW_FONT = '700 30px system-ui, -apple-system, "Segoe UI", sans-serif'; + +export function laneDragIcon(stemName) { + try { + const label = t(`stem.${stemName}`) || stemName; + const canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + ctx.font = PREVIEW_FONT; + // Sizing the canvas resets every context property, so the font is set + // once to measure and again to draw. + canvas.width = Math.ceil(ctx.measureText(label).width) + 52; + canvas.height = PREVIEW_H; + ctx = canvas.getContext("2d"); + ctx.font = PREVIEW_FONT; + + // An opaque plate: a drag image that is transparent except for the letters + // is close to unreadable over a DAW timeline. + ctx.fillStyle = "rgba(10, 16, 22, 0.94)"; + if (ctx.roundRect) { + ctx.beginPath(); + ctx.roundRect(0, 0, canvas.width, PREVIEW_H, 14); + ctx.fill(); + } else { + ctx.fillRect(0, 0, canvas.width, PREVIEW_H); + } + + ctx.fillStyle = STEM_COLORS[stemName] || "#d8a84a"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, canvas.width / 2, PREVIEW_H / 2 + 1); + + return canvas.toDataURL("image/png").split(",")[1]; + } catch (e) { + // Rust falls back to the app icon, which is a worse picture and a working + // drag. + console.warn("[stemdeck] could not build a drag preview for", stemName, e); + return null; + } +} + export function renderMixerRow(stem) { const state = mixerState[stem.name]; const color = STEM_COLORS[stem.name] || "#a0a0a0"; diff --git a/static/js/player.js b/static/js/player.js index f191afaa..f3bfec42 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -421,6 +421,27 @@ function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount, orde ${barsWaveformSvg(peaks, norm, bars)} `; + addLaneDragNugget(row, stemName); +} + +// The handle for dragging this lane's slice of the loop out. One per lane, so +// which track a drag produces is a matter of looking at it rather than +// remembering. +// +// Added after the art, not with the row: the row's innerHTML is rewritten on +// every redraw and a zoom step is a redraw, so anything placed inside it when +// the row was created is gone by the second wheel notch. Only lanes that drew +// something get one; a lane with no audio has nothing to hand over. +// +// The waveform layer is pointer-events: none so it never swallows a click +// meant for the loop underneath. The nugget puts its own back, in CSS. +function addLaneDragNugget(row, stemName) { + const nugget = document.createElement("div"); + nugget.className = "lane-drag-nugget"; + nugget.dataset.laneDragOut = stemName; + nugget.draggable = true; + nugget.title = t("loop.dragOutStem", { name: t(`stem.${stemName}`) }); + row.appendChild(nugget); } // The lane set must mirror the mixer/multitrack lanes (orderedNames in @@ -1735,8 +1756,16 @@ export function updateFooterTrack({ title, thumbnail, key, bpm, stemCount } = {} // the button read "Exporting…" for however long the save dialog sat open, when // nothing was being exported yet (#338). Callers enter their busy state here // rather than on click. +// The Rust side only accepts an absolute localhost URL: a bare path would be +// resolved against nothing there, so validate_download_url rejects it. Lane +// links get this for free because href is a DOM property; anything built as a +// string has to be absolutised here. +function _absolute(url) { + return url.startsWith("http") ? url : `${location.origin}${url}`; +} + function _triggerDownload(url, filename, onTransferStart) { - const fullUrl = url.startsWith("http") ? url : `${location.origin}${url}`; + const fullUrl = _absolute(url); const invoke = window.__TAURI__?.core?.invoke; if (invoke) { // Two commands: the dialog, then the transfer. A cancelled dialog resolves @@ -1931,15 +1960,48 @@ function _regionDragFilename(ext) { .replace(/_{2,}/g, "_") .slice(0, 80) .replace(/^_+|_+$/g, ""); - const span = `${loopStart.toFixed(1)}-${loopEnd.toFixed(1)}`.replace(/\./g, "_"); - return `${safe || "region"}_region_${span}.${ext}`; + return `${safe || "region"}_region_${_loopSpan()}.${ext}`; +} + +// The bounds, as a filename fragment. Both region drags carry it, because the +// exports folder is never cleaned up and the Rust side reuses a file already +// sitting there. +function _loopSpan() { + return `${loopStart.toFixed(1)}-${loopEnd.toFixed(1)}`.replace(/\./g, "_"); +} + +// One lane's slice of the loop. +// +// At unity gain, and regardless of mute or solo: this is the stem, the way the +// lane's own download button gives you the stem, just trimmed to the loop. The +// mix, with the balance you set, is what the grip above the lanes carries. No +// click track or count-in either, for the same reason -- those belong to a +// mixdown, not to a single stem. +export function stemRegionDragPayload(name, ext = "wav") { + if (!currentJobId || !loopEnabled || loopStart >= loopEnd) return null; + const q = new URLSearchParams({ + stems: name, + gains: "1.000", + start: loopStart.toFixed(3), + end: loopEnd.toFixed(3), + }); + const safe = _currentTitle + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/_{2,}/g, "_") + .slice(0, 80) + .replace(/^_+|_+$/g, ""); + const stem = safe ? `${safe}_${name}` : name; + return { + url: _absolute(`/api/jobs/${currentJobId}/mixdown.${ext}?${q}`), + filename: `${stem}_region_${_loopSpan()}.${ext}`, + }; } export function regionDragPayload(ext = "wav") { if (!loopEnabled || loopStart >= loopEnd) return null; const url = _mixdownUrl(ext, true); if (!url) return null; - return { url, filename: _regionDragFilename(ext) }; + return { url: _absolute(url), filename: _regionDragFilename(ext) }; } // Render the region before the user reaches for it. diff --git a/static/js/transport.js b/static/js/transport.js index 166a1752..095c3b5c 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -31,15 +31,30 @@ import { t } from "./i18n.js"; import { pitchBlockedKey } from "./pitchBus.js"; // Zoom range. 1 is the whole track fitted to the panel; there is nothing below -// it to show, so it is the floor rather than a soft default. 5 is the ceiling -// because peaks.json carries 1500 points per stem: past roughly 5x a typical -// panel asks for more bars than there are samples behind them, and the extra -// bars repeat their neighbours instead of revealing anything. +// it to show, so it is the floor rather than a soft default. +// +// The ceiling is set by how many points back the picture, not by taste. A bar +// occupies OVERVIEW_BAR_SLOT_PX, so a panel of W pixels draws W/5 bars at 1x +// and Z times that at Zx. Ask for more bars than there are points and the +// extra ones repeat their neighbours: a fatter picture, not a closer one, +// which is the exact failure this feature exists to avoid. +// +// 10x needs roughly 2400 points for a full-width panel, so _PEAK_POINTS in +// app/pipeline/collect.py carries 3000. Raise them together or not at all. +// +// Tracks separated before that change kept 1500-point peaks.json and repeat +// bars past 5x on the streaming path. The Web Audio path is unaffected either +// way: it scans the decoded buffer itself, at whatever this ceiling asks for. const WAVE_ZOOM_MIN = 1; -export const WAVE_ZOOM_MAX = 5; +export const WAVE_ZOOM_MAX = 10; // One wheel notch. Multiplicative, so a notch covers the same proportion of the // range at 1x as at 4x; linear steps feel fast at the bottom and stuck at the top. const WAVE_ZOOM_STEP = 1.18; + +// The handles that drag audio out to the OS. Both run an HTML5 drag, and any +// pointer handler that calls preventDefault on their pointerdown stops that +// drag before it starts. Excluded from both loop gestures for that reason. +const DRAG_OUT_SELECTOR = "[data-loop-drag-out], [data-lane-drag-out]"; // Below this visible width the waveform stops compressing to fit and instead // keeps a minimum size, overflowing horizontally so .wave-scroll can scroll. const WAVE_MIN_WIDTH = 720; @@ -244,6 +259,7 @@ export function updateLoopRegionVisual() { syncLoopInputs(); if (!loopEnabled || !totalDuration) { loopRegionEl.classList.add("hidden"); + document.querySelector(".waves-column")?.classList.remove("loop-armed"); return; } ensureLoopRegionParent(); @@ -256,6 +272,32 @@ export function updateLoopRegionVisual() { loopRegionEl.style.left = `${startPct}%`; loopRegionEl.style.width = `${Math.max(0, endPct - startPct)}%`; loopRegionEl.classList.remove("hidden"); + positionLaneNuggets(endPct); +} + +// The per-lane drag nuggets live in the waveform overlay, not in the loop +// region, because each one has to sit on its own lane. They follow the loop +// through a custom property on the column both subtrees share: one write per +// loop change rather than one per lane per frame. +// +// No minimum size. A width threshold in percent is a threshold on the fraction +// of the song selected, so a four-bar loop in a four-minute track never meets +// it and the handles simply never appear -- which is what shipped first and is +// the whole reason this note exists. Measuring pixels instead would go stale, +// since zooming changes the rendered width without going through here. +// +// On a selection narrower than the nugget it does cover the resize handles, +// but those extend 7px outside the region on each side (.loop-handle in +// waves.css), so both stay grabbable from the outer edge. +function positionLaneNuggets(endPct) { + // The column itself, not whatever the region is parented to: + // loopOverlayParent falls back to the ruler when the column does not exist + // yet, and the class would then be added to one element and removed from + // another, leaving the handles showing with no loop behind them. + const column = document.querySelector(".waves-column"); + if (!column) return; + column.style.setProperty("--loop-right", `${endPct}%`); + column.classList.add("loop-armed"); } // Keep the exact-loop text fields in sync with loopStart/loopEnd after any @@ -558,9 +600,9 @@ function wireLoopRegionAdjust() { loopRegionEl.addEventListener("pointerdown", (e) => { if (e.button !== 0 || !totalDuration) return; - // The drag-out grip runs an HTML5 drag. Starting a pointer-drag from the - // same gesture would move the region while it is being dragged out. - if (e.target.closest("[data-loop-drag-out]")) return; + // Starting a pointer-drag from the same gesture would move the region + // while it is being dragged out. + if (e.target.closest(DRAG_OUT_SELECTOR)) return; const t = timeFromClientX(e.clientX); if (t === null) return; mode = e.target.closest("[data-loop-handle]")?.dataset.loopHandle ?? "move"; @@ -613,7 +655,12 @@ function wireLoopDrag() { let moved = false; const startDrag = (e, surface) => { + // .loop-region covers the mix grip, which lives inside it. The lane + // nuggets sit in the waveform overlay instead, so they need naming: without + // this, grabbing one starts a new selection and preventDefault below kills + // the drag-out before dragstart ever fires. if (e.button !== 0 || e.target.closest(".loop-region")) return; + if (e.target.closest(DRAG_OUT_SELECTOR)) return; const t = timeFromClientX(e.clientX); if (t === null) return; dragging = true; diff --git a/tests/e2e/drag-out.spec.mjs b/tests/e2e/drag-out.spec.mjs index ef00537d..974dca05 100644 --- a/tests/e2e/drag-out.spec.mjs +++ b/tests/e2e/drag-out.spec.mjs @@ -85,6 +85,25 @@ test.describe("dragging audio out", () => { expect(await loopBounds(page)).toEqual(before); }); + test("grabbing a lane nugget does not redraw the loop", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + const before = await loopBounds(page); + + // wireLoopDrag turns a drag anywhere on the waves column into a new + // selection, and calls preventDefault, which kills the HTML5 drag before + // dragstart fires. The nuggets live in the waveform overlay inside that + // column, so without an explicit exclusion grabbing one silently + // redefines the loop instead of dragging the stem out. + const nugget = await page.locator("[data-lane-drag-out]").first().boundingBox(); + await page.mouse.move(nugget.x + nugget.width / 2, nugget.y + nugget.height / 2); + await page.mouse.down(); + await page.mouse.move(nugget.x + 200, nugget.y, { steps: 10 }); + await page.mouse.up(); + + expect(await loopBounds(page)).toEqual(before); + }); + test("the region drag carries the loop bounds in its filename", async ({ page }) => { await openStudio(page, { tauri: true }); await markLoop(page, 0.2, 0.6); @@ -93,6 +112,11 @@ test.describe("dragging audio out", () => { const calls = await dragCalls(page); expect(calls).toHaveLength(1); const { url, filename } = calls[0].args; + // Absolute, not a bare path. validate_download_url on the Rust side takes + // only http/https, so a relative URL rejects every region drag and the + // gesture does nothing at all, with the error going only to a console + // nobody can see in a release build. + expect(url).toMatch(/^https?:\/\//); expect(url).toContain("/mixdown.wav"); expect(url).toMatch(/[?&]start=/); expect(url).toMatch(/[?&]end=/); @@ -128,6 +152,7 @@ test.describe("dragging audio out", () => { // The name the click path saves under, not a second one derived here. // Deriving it again once prefixed the song title twice. expect(calls[0].args.filename).toBe(expected); + expect(calls[0].args.url).toMatch(/^https?:\/\//); expect(calls[0].args.url).toContain("/stems/"); }); @@ -143,6 +168,153 @@ test.describe("dragging audio out", () => { expect(await dragCalls(page)).toHaveLength(0); }); + // One nugget per lane. The grip above them carries the mix, which is not + // something a single bar could ever say on its own. + + test("every lane that drew something gets its own nugget", async ({ page }) => { + await openStudio(page, { tauri: true }); + + // A lane with no audio draws no waveform and has nothing to hand over. + const drawn = await page.locator(".stem-waveform-row[data-stem] svg").count(); + expect(drawn).toBeGreaterThan(1); + await expect(page.locator("[data-lane-drag-out]")).toHaveCount(drawn); + + // Present from the first draw, but nothing to drag until a loop exists. + await expect(page.locator("[data-lane-drag-out]").first()).toBeHidden(); + await markLoop(page); + await expect(page.locator("[data-lane-drag-out]").first()).toBeVisible(); + }); + + // The first version of this gated the nuggets on the selection being wider + // than 2% of the track, which hides them for exactly the short loops people + // work with and does it silently. Note that the fixture is 6 seconds and + // MIN_LOOP_SEC is 0.2s, so every loop it can make is already over 3% -- this + // suite could not have caught that, and the gate is gone rather than tuned. + test("the nuggets come back after the loop is redrawn several times", async ({ page }) => { + await openStudio(page, { tauri: true }); + + for (const [from, to] of [[0.1, 0.8], [0.2, 0.3], [0.6, 0.95], [0.4, 0.44]]) { + await markLoop(page, from, to); + await expect(page.locator("[data-lane-drag-out]").first()).toBeVisible(); + } + }); + + test("the nuggets survive a zoom, which redraws every row", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + const before = await page.locator("[data-lane-drag-out]").count(); + + // A redraw rewrites each row's innerHTML. Anything living in there is gone + // unless it is put back, and the second wheel notch is where that shows. + for (let i = 0; i < 6; i++) { + await page.mouse.move(900, 400); + await page.mouse.wheel(0, -240); + } + + await expect(page.locator("[data-lane-drag-out]")).toHaveCount(before); + await expect(page.locator("[data-lane-drag-out]").first()).toBeVisible(); + }); + + test("a lane nugget drags that stem alone, at unity gain", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + + const nugget = page.locator("[data-lane-drag-out]").first(); + const stem = await nugget.getAttribute("data-lane-drag-out"); + await fireDragStart(page, "[data-lane-drag-out]"); + + const calls = await dragCalls(page); + expect(calls).toHaveLength(1); + const url = new URL(calls[0].args.url); + expect(url.searchParams.get("stems")).toBe(stem); + // Its own stem, not the balance: the lane download gives the stem, and so + // does this. The mix is the grip's job. + expect(url.searchParams.get("gains")).toBe("1.000"); + expect(url.searchParams.get("start")).toBeTruthy(); + expect(url.searchParams.get("end")).toBeTruthy(); + expect(calls[0].args.filename).toContain(stem); + expect(calls[0].args.filename).toMatch(/_region_[\d_]+-[\d_]+\.wav$/); + }); + + test("the drag carries a preview naming the lane", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + await fireDragStart(page, "[data-lane-drag-out]"); + + const { icon } = (await dragCalls(page))[0].args; + // A null icon is not an error in Rust: it falls back to the app badge and + // the drag still works, which is exactly why it has to be asserted here. + // An earlier version rendered the lane's SVG glyph without an xmlns, so + // the image never loaded and every drag silently carried the app badge. + expect(typeof icon).toBe("string"); + expect(icon.length).toBeGreaterThan(100); + // base64 of a PNG: every one of them starts with this signature. + expect(icon.startsWith("iVBORw0KGgo")).toBe(true); + }); + + test("the mix grip keeps the app icon, so the two drags differ in flight", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + await fireDragStart(page, GRIP); + + expect((await dragCalls(page))[0].args.icon).toBeNull(); + }); + + test("a muted lane still drags its own stem", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + const stem = await page.locator("[data-lane-drag-out]").first().getAttribute("data-lane-drag-out"); + await page.locator(`.lane-header[data-stem="${stem}"] .mute`).click(); + + await fireDragStart(page, "[data-lane-drag-out]"); + + // Muting is a monitoring choice. Asking for that stem explicitly is not + // the same as asking for a mix it happens to be silent in. + const url = new URL((await dragCalls(page))[0].args.url); + expect(url.searchParams.get("stems")).toBe(stem); + }); + + // Mute and solo are not re-implemented for the drag: it goes through the same + // _effectiveMixGains the Export menu uses, which drops a silent lane from the + // stem list rather than summing it at zero. These guard that it stays wired + // to that and does not drift into exporting whatever happens to be loaded. + + const laneNames = (url) => new URL(url).searchParams.get("stems").split(","); + + const clickLane = async (page, stem, control) => + page.locator(`.lane-header[data-stem="${stem}"] .${control}`).click(); + + test("a muted lane is left out of the region entirely", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + await fireDragStart(page, GRIP); + const before = laneNames((await dragCalls(page))[0].args.url); + expect(before.length).toBeGreaterThan(1); + + const victim = before[0]; + await clickLane(page, victim, "mute"); + await fireDragStart(page, GRIP); + + const after = laneNames((await dragCalls(page))[1].args.url); + // Absent, not present at gain 0. Summing silence is still summing. + expect(after).not.toContain(victim); + expect(after).toEqual(before.filter((n) => n !== victim)); + }); + + test("soloing one lane drags only that lane", async ({ page }) => { + await openStudio(page, { tauri: true }); + await markLoop(page); + await fireDragStart(page, GRIP); + const all = laneNames((await dragCalls(page))[0].args.url); + expect(all.length).toBeGreaterThan(1); + + const chosen = all[1]; + await clickLane(page, chosen, "solo"); + await fireDragStart(page, GRIP); + + expect(laneNames((await dragCalls(page))[1].args.url)).toEqual([chosen]); + }); + test("no loop, no drag", async ({ page }) => { await openStudio(page, { tauri: true }); // The grip is inside the region, which is hidden until a loop is marked, diff --git a/tests/e2e/zoom.spec.mjs b/tests/e2e/zoom.spec.mjs index b6b56432..92881695 100644 --- a/tests/e2e/zoom.spec.mjs +++ b/tests/e2e/zoom.spec.mjs @@ -131,13 +131,13 @@ test.describe("waveform zoom", () => { expect(zoomed.bars / base.bars).toBeCloseTo(zoomed.content / base.content, 1); }); - test("zoom stops at 5x and never goes below the fitted view", async ({ page }) => { + test("zoom stops at 10x and never goes below the fitted view", async ({ page }) => { await openStudio(page, { tauri: true }); const fitted = await zoomState(page); for (let i = 0; i < 40; i++) await wheel(page, -240); const maxed = await zoomState(page); - expect(maxed.content / fitted.content).toBeCloseTo(5, 1); + expect(maxed.content / fitted.content).toBeCloseTo(10, 1); for (let i = 0; i < 60; i++) await wheel(page, 240); const floored = await zoomState(page);