P6a: mobile music surface (keyboard-primary instrument pane)
The mobile Piano Roll becomes a unified, keyboard-primary instrument surface (wireframe Plate 04/08): a playable keyboard as the base, revealing a Synthesia-style falling-notes roll above it when the pane is tall enough. - Shared keyboard geometry (panes/keyboard_layout.rs): width-driven, pan-aware pitch->x, so the roll's note columns stay aligned with the Virtual Piano keys. Shared keyboard_octave + keyboard_pan_x in SharedPaneState. - Piano Roll vertical mode (is_mobile): notes as columns falling toward an amber now-line by the keys, tempo-map converted (beats->seconds) so onsets cross the line exactly when they sound. Vertical drag scrubs the timeline; horizontal drag smoothly scrolls the keys (snaps to nearest key on release). Long-press creates a note (drag to size) or resizes an existing one; pan suppresses it. The keyboard is embedded (reuses Virtual Piano render + MIDI); the standalone VirtualPiano stack slot is removed. show_roll is driven by the snapped pane size-class so the keyboard<->roll reveal lands on a stack snap point. - Virtual Piano: renders via the shared layout on mobile, colors playback_notes like pressed keys, gates note-on/glissando to presses that start on the keys (never gating release), and hides QWERTY hints on mobile. - Transport formats by document.timeline_mode (Measures->bar.beat.tick, Frames->MM:SS:FF, Seconds->MM:SS.mmm) — per project type, like desktop. - In-pane instrument header (name + Presets + REC). Recording is driven from the app each frame (not the Timeline pane's render) so REC works regardless of visible panes, and stopping playback stops recording. - Compose/Record intent opens Timeline + instrument pane; mobile central panel is full-bleed (no inner margin) so panes sit flush. - phone-ui-sketches.html: inst-bar moved to the top of the music surface.
This commit is contained in:
parent
77eb2c2d33
commit
b14972f657
|
|
@ -976,6 +976,14 @@ struct EditorApp {
|
|||
mobile_state: mobile::MobileState,
|
||||
/// Active mobile long-press context menu (set by a pane, rendered by the shell).
|
||||
mobile_context_menu: Option<panes::MobileContextMenu>,
|
||||
/// Shared keyboard octave offset (C4-relative) for the mobile Virtual Piano + Piano Roll.
|
||||
keyboard_octave: i8,
|
||||
/// Shared horizontal keyboard pan (px) for the mobile keyboard + roll.
|
||||
keyboard_pan_x: f32,
|
||||
/// Mobile: request to open the instrument Preset Browser (set by the music pane header).
|
||||
open_instrument_browser: bool,
|
||||
/// Mobile: request to toggle recording (set by the music pane REC button).
|
||||
pending_record_toggle: bool,
|
||||
icon_cache: IconCache,
|
||||
tool_icon_cache: ToolIconCache,
|
||||
focus_icon_cache: FocusIconCache, // Focus card icons (start screen)
|
||||
|
|
@ -1338,6 +1346,10 @@ impl EditorApp {
|
|||
mobile_ui_override: None,
|
||||
mobile_state: mobile::MobileState::default(),
|
||||
mobile_context_menu: None,
|
||||
keyboard_octave: 0,
|
||||
keyboard_pan_x: 0.0,
|
||||
open_instrument_browser: false,
|
||||
pending_record_toggle: false,
|
||||
icon_cache: IconCache::new(),
|
||||
tool_icon_cache: ToolIconCache::new(),
|
||||
focus_icon_cache: FocusIconCache::new(),
|
||||
|
|
@ -6608,7 +6620,16 @@ impl eframe::App for EditorApp {
|
|||
{
|
||||
scratch.synthetic_input = test_mode_replay.synthetic_input;
|
||||
}
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
// On mobile the shell is full-bleed to the window edges (it paints its own background), so
|
||||
// drop the central panel's default inner margin — otherwise every pane is inset by it while
|
||||
// the unclipped keyboard overdraws into it, leaving an inconsistent gutter.
|
||||
let central = egui::CentralPanel::default();
|
||||
let central = if self.mobile_active() {
|
||||
central.frame(egui::Frame::default())
|
||||
} else {
|
||||
central
|
||||
};
|
||||
central.show(ctx, |ui| {
|
||||
let available_rect = ui.available_rect_before_wrap();
|
||||
|
||||
// Reset hovered divider each frame
|
||||
|
|
@ -6668,6 +6689,27 @@ impl eframe::App for EditorApp {
|
|||
&mut bundle.rc,
|
||||
bundle.mobile_state,
|
||||
);
|
||||
|
||||
// Drive recording from the application, not the Timeline pane's render pass, so a
|
||||
// REC request (from the music pane header) and count-in progression work regardless
|
||||
// of whether the Timeline is currently visible. The Timeline pane still owns the
|
||||
// recording *state*; we just tick it here each frame.
|
||||
let rc = &mut bundle.rc;
|
||||
for pane in rc.pane_instances.values_mut() {
|
||||
if let PaneInstance::Timeline(t) = pane {
|
||||
t.check_pending_recording_start(&mut rc.shared);
|
||||
if *rc.shared.pending_record_toggle {
|
||||
*rc.shared.pending_record_toggle = false;
|
||||
t.toggle_recording(&mut rc.shared);
|
||||
}
|
||||
// Stopping playback stops recording (stop_recording clears is_recording,
|
||||
// so this fires once).
|
||||
if *rc.shared.is_recording && !*rc.shared.is_playing {
|
||||
t.stop_recording(&mut rc.shared);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
render_layout_node(
|
||||
ui,
|
||||
|
|
@ -7221,6 +7263,11 @@ impl EditorApp {
|
|||
rc: RenderContext {
|
||||
shared: panes::SharedPaneState {
|
||||
is_mobile,
|
||||
keyboard_octave: &mut self.keyboard_octave,
|
||||
keyboard_pan_x: &mut self.keyboard_pan_x,
|
||||
instrument_show_roll: false,
|
||||
open_instrument_browser: &mut self.open_instrument_browser,
|
||||
pending_record_toggle: &mut self.pending_record_toggle,
|
||||
mobile_context_menu: &mut self.mobile_context_menu,
|
||||
container_path: self.current_file_path.clone(),
|
||||
onion: {
|
||||
|
|
|
|||
|
|
@ -19,18 +19,24 @@ struct Intent {
|
|||
focus: usize,
|
||||
/// Initial mobile stack window: (window_top, window_count).
|
||||
window: (usize, usize),
|
||||
/// Initial pane weights (relative heights) for the window.
|
||||
weights: [f32; 3],
|
||||
}
|
||||
|
||||
fn intents(pal: &Palette) -> [Intent; 6] {
|
||||
let [coral, cyan, amber, pink, violet] = pal.accents;
|
||||
let even = [1.0, 1.0, 1.0];
|
||||
// Compose/Record: compressed Timeline ribbon on top; the tall Piano Roll (which now hosts the
|
||||
// instrument header + keyboard as one surface) fills the rest.
|
||||
let music = [0.4, 1.6, 1.0];
|
||||
[
|
||||
// Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5.
|
||||
Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1) },
|
||||
Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2) },
|
||||
Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 3) },
|
||||
Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 3) },
|
||||
Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2) },
|
||||
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2) },
|
||||
Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1), weights: even },
|
||||
Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2), weights: even },
|
||||
Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 2), weights: music },
|
||||
Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 2), weights: music },
|
||||
Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2), weights: even },
|
||||
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2), weights: even },
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +98,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
|
|||
app.create_new_project_with_focus(intent.focus);
|
||||
app.mobile_state.window_top = intent.window.0;
|
||||
app.mobile_state.window_count = intent.window.1;
|
||||
app.mobile_state.weights = [1.0, 1.0, 1.0];
|
||||
app.mobile_state.weights = intent.weights;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,21 +89,21 @@ pub enum StackPane {
|
|||
AssetLibrary,
|
||||
Stage,
|
||||
Timeline,
|
||||
/// The instrument surface: keyboard-primary on mobile, hosting the falling-notes roll above the
|
||||
/// keys (the standalone Virtual Piano is embedded here, so it isn't a separate stack slot).
|
||||
PianoRoll,
|
||||
VirtualPiano,
|
||||
/// Node editor, or (toggled) the instrument/preset browser.
|
||||
NodeInstrument,
|
||||
ScriptEditor,
|
||||
}
|
||||
|
||||
/// The stack order. Index into this is a pane's stable slot id.
|
||||
pub const STACK: [StackPane; 8] = [
|
||||
pub const STACK: [StackPane; 7] = [
|
||||
StackPane::Outliner,
|
||||
StackPane::AssetLibrary,
|
||||
StackPane::Stage,
|
||||
StackPane::Timeline,
|
||||
StackPane::PianoRoll,
|
||||
StackPane::VirtualPiano,
|
||||
StackPane::NodeInstrument,
|
||||
StackPane::ScriptEditor,
|
||||
];
|
||||
|
|
@ -118,7 +118,6 @@ impl StackPane {
|
|||
StackPane::Stage => PaneType::Stage,
|
||||
StackPane::Timeline => PaneType::Timeline,
|
||||
StackPane::PianoRoll => PaneType::PianoRoll,
|
||||
StackPane::VirtualPiano => PaneType::VirtualPiano,
|
||||
StackPane::NodeInstrument => {
|
||||
if show_instruments {
|
||||
PaneType::PresetBrowser
|
||||
|
|
@ -136,8 +135,7 @@ impl StackPane {
|
|||
StackPane::AssetLibrary => "Assets",
|
||||
StackPane::Stage => "Stage",
|
||||
StackPane::Timeline => "Timeline",
|
||||
StackPane::PianoRoll => "Piano Roll",
|
||||
StackPane::VirtualPiano => "Keys",
|
||||
StackPane::PianoRoll => "Instrument",
|
||||
StackPane::NodeInstrument => {
|
||||
if show_instruments {
|
||||
"Instruments"
|
||||
|
|
@ -332,6 +330,20 @@ pub fn render_mobile_shell(
|
|||
region
|
||||
};
|
||||
|
||||
// Decide whether the instrument pane (PianoRoll = STACK index 4) reveals the roll, from the
|
||||
// *committed* (snapped) window weights — so the keyboard↔roll transition lands on a stack snap
|
||||
// rather than at an arbitrary mid-drag height. Roll shows once the pane is past the smallest snap.
|
||||
rc.shared.instrument_show_roll = {
|
||||
let idx = 4i32 - state.window_top as i32;
|
||||
if idx >= 0 && (idx as usize) < state.window_count {
|
||||
let sum: f32 = state.weights[..state.window_count].iter().map(|w| w.max(0.0)).sum();
|
||||
let w = if sum > 0.0 { state.weights[idx as usize].max(0.0) / sum } else { 0.0 };
|
||||
w > 0.30 // 0.25 preset ⇒ keyboard only; 0.33/0.5/0.75 ⇒ keyboard + roll
|
||||
} else {
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
stack::render(ui, stack_rect, rc, state, &pal);
|
||||
|
||||
if inspector_shown {
|
||||
|
|
@ -345,6 +357,16 @@ pub fn render_mobile_shell(
|
|||
// Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else.
|
||||
omni::render(ui, region, rc, state, &pal);
|
||||
|
||||
// Instrument-browser request from the music pane's header → show the Preset Browser fullscreen.
|
||||
if *rc.shared.open_instrument_browser {
|
||||
*rc.shared.open_instrument_browser = false;
|
||||
state.show_instruments = true;
|
||||
state.window_top = 5; // Node/Instrument band (PresetBrowser when show_instruments)
|
||||
state.window_count = 1;
|
||||
state.weights = [1.0, 1.0, 1.0];
|
||||
state.anim = None;
|
||||
}
|
||||
|
||||
// Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last.
|
||||
topbar::render(ui, topbar_rect, available_rect, rc, state, &pal);
|
||||
|
||||
|
|
|
|||
|
|
@ -40,9 +40,24 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState,
|
|||
}
|
||||
}
|
||||
|
||||
// --- Timecode (MM:SS:FF) ---
|
||||
let fps = shared.action_executor.document().framerate.max(1.0);
|
||||
let tc = format_timecode(*shared.playback_time, fps);
|
||||
// --- Playhead readout: format follows the project's TimelineMode (set at creation by type). ---
|
||||
let t = *shared.playback_time;
|
||||
let tc = {
|
||||
use lightningbeam_core::document::TimelineMode;
|
||||
let doc = shared.action_executor.document();
|
||||
match doc.timeline_mode {
|
||||
TimelineMode::Measures => {
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(t, doc.tempo_map(), &doc.time_signature);
|
||||
format!("{}.{}.{:02}", pos.measure, pos.beat, pos.tick / 10)
|
||||
}
|
||||
TimelineMode::Frames => format_timecode(t, doc.framerate.max(1.0)),
|
||||
TimelineMode::Seconds => {
|
||||
let total = t.max(0.0);
|
||||
let m = (total / 60.0).floor() as u32;
|
||||
format!("{:02}:{:06.3}", m, total % 60.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
let tc_left = btn_rect.right() + 12.0;
|
||||
painter.text(
|
||||
egui::pos2(tc_left, cy),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
//! Shared horizontal keyboard geometry (pitch → x), used by both the Virtual Piano and the mobile
|
||||
//! portrait "Synthesia" Piano Roll so their columns line up with the keys. It is **width-driven**
|
||||
//! (key width from the pane width, not its height) and supports a smooth horizontal **pan** (pixels)
|
||||
//! so the roll and keyboard scroll together and stay aligned.
|
||||
|
||||
/// Number of white keys strictly before each pitch-class within its octave.
|
||||
const WHITES_BEFORE: [i32; 12] = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6];
|
||||
/// White pitch-classes, indexed by white-key-within-octave.
|
||||
const WHITE_PC: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
|
||||
/// Target on-screen white-key width (px); the visible white-key count approximates it.
|
||||
const TARGET_WHITE_KEY_W: f32 = 40.0;
|
||||
|
||||
/// Absolute white-key index of a note, counting white keys from MIDI 0.
|
||||
fn awi(note: u8) -> i32 {
|
||||
(note as i32 / 12) * 7 + WHITES_BEFORE[(note % 12) as usize]
|
||||
}
|
||||
|
||||
/// The white note at a given absolute white-key index.
|
||||
fn white_note_from_awi(a: i32) -> u8 {
|
||||
let oct = a.div_euclid(7);
|
||||
let rem = a.rem_euclid(7) as usize;
|
||||
(oct * 12 + WHITE_PC[rem]).clamp(0, 127) as u8
|
||||
}
|
||||
|
||||
/// A horizontal piano key layout for a pane width, octave center, and horizontal pan.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct KeyboardLayout {
|
||||
pub white_key_width: f32,
|
||||
pub black_key_width: f32,
|
||||
/// `note_x(white n) == base_x + awi(n) * white_key_width`.
|
||||
base_x: f32,
|
||||
rect_left: f32,
|
||||
rect_width: f32,
|
||||
}
|
||||
|
||||
impl KeyboardLayout {
|
||||
pub fn is_black_key(note: u8) -> bool {
|
||||
matches!(note % 12, 1 | 3 | 6 | 8 | 10)
|
||||
}
|
||||
|
||||
/// Build a layout. `pan_x` shifts everything right (drag-right reveals lower keys); at `pan_x == 0`
|
||||
/// the octave-center key sits in the middle of the pane.
|
||||
pub fn from_width(origin_x: f32, width: f32, octave: i8, pan_x: f32) -> Self {
|
||||
let vis = ((width / TARGET_WHITE_KEY_W).round() as u32).clamp(7, 24) as f32;
|
||||
let white_key_width = width / vis;
|
||||
let black_key_width = white_key_width * 0.6;
|
||||
let center = (60 + octave as i32 * 12).clamp(0, 127) as u8;
|
||||
let base_x = origin_x + width / 2.0 - (awi(center) as f32 + 0.5) * white_key_width + pan_x;
|
||||
Self { white_key_width, black_key_width, base_x, rect_left: origin_x, rect_width: width }
|
||||
}
|
||||
|
||||
/// Snap a pan offset to the nearest whole key (for release-snap).
|
||||
pub fn snap_pan(&self, pan_x: f32) -> f32 {
|
||||
(pan_x / self.white_key_width).round() * self.white_key_width
|
||||
}
|
||||
|
||||
/// Left x of a key's rect (black keys straddle the white boundary).
|
||||
pub fn note_x(&self, note: u8) -> f32 {
|
||||
let x = self.base_x + awi(note) as f32 * self.white_key_width;
|
||||
if Self::is_black_key(note) {
|
||||
x - self.black_key_width / 2.0
|
||||
} else {
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_width(&self, note: u8) -> f32 {
|
||||
if Self::is_black_key(note) {
|
||||
self.black_key_width
|
||||
} else {
|
||||
self.white_key_width
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_center_x(&self, note: u8) -> f32 {
|
||||
self.note_x(note) + self.note_width(note) / 2.0
|
||||
}
|
||||
|
||||
/// Leftmost visible white key (with one key of margin).
|
||||
pub fn first_visible_white(&self) -> u8 {
|
||||
let a = ((self.rect_left - self.base_x) / self.white_key_width).floor() as i32 - 1;
|
||||
white_note_from_awi(a.max(0))
|
||||
}
|
||||
|
||||
/// Rightmost visible note (with one key of margin).
|
||||
pub fn last_visible_note(&self) -> u8 {
|
||||
let a = ((self.rect_left + self.rect_width - self.base_x) / self.white_key_width).ceil() as i32 + 1;
|
||||
white_note_from_awi(a).min(127)
|
||||
}
|
||||
|
||||
pub fn visible_notes(&self) -> std::ops::RangeInclusive<u8> {
|
||||
self.first_visible_white()..=self.last_visible_note()
|
||||
}
|
||||
|
||||
/// Which key is at screen x (black keys, drawn on top, take precedence).
|
||||
pub fn x_to_note(&self, x: f32) -> u8 {
|
||||
for note in self.visible_notes() {
|
||||
if Self::is_black_key(note) {
|
||||
let c = self.note_center_x(note);
|
||||
if (x - c).abs() <= self.black_key_width / 2.0 {
|
||||
return note;
|
||||
}
|
||||
}
|
||||
}
|
||||
let a = ((x - self.base_x) / self.white_key_width).floor() as i32;
|
||||
white_note_from_awi(a.max(0)).min(127)
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ pub mod gradient_editor;
|
|||
pub mod timeline;
|
||||
pub mod infopanel;
|
||||
pub mod outliner;
|
||||
pub mod keyboard_layout;
|
||||
pub mod piano_roll;
|
||||
pub mod virtual_piano;
|
||||
pub mod node_editor;
|
||||
|
|
@ -363,6 +364,19 @@ pub struct SharedPaneState<'a> {
|
|||
pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
|
||||
/// True when rendering the phone/mobile shell (panes can render more compactly).
|
||||
pub is_mobile: bool,
|
||||
/// Shared keyboard octave offset (C4-relative), so the mobile Virtual Piano and the portrait
|
||||
/// Piano Roll agree on which keys are visible and stay column-aligned.
|
||||
pub keyboard_octave: &'a mut i8,
|
||||
/// Shared horizontal keyboard pan (px) for smooth left/right scroll of the mobile keyboard+roll.
|
||||
pub keyboard_pan_x: &'a mut f32,
|
||||
/// Whether the mobile instrument pane should show the falling-notes roll above the keys. Driven
|
||||
/// by the shell from the *snapped* pane size-class so the reveal happens at a stack snap point.
|
||||
pub instrument_show_roll: bool,
|
||||
/// Set by the mobile instrument header's "Presets" button; the shell opens the Preset Browser.
|
||||
pub open_instrument_browser: &'a mut bool,
|
||||
/// Set by the mobile instrument header's REC button; the Timeline pane picks it up and toggles
|
||||
/// recording (reusing its full count-in / clip-creation flow).
|
||||
pub pending_record_toggle: &'a mut bool,
|
||||
/// Mobile long-press context menu request. A pane sets this on `response.secondary_clicked()`
|
||||
/// (which fires on long-press) with the items relevant to what was pressed; the mobile shell
|
||||
/// renders one persistent popup and dispatches the chosen `MenuAction`. `None` = no menu.
|
||||
|
|
|
|||
|
|
@ -169,6 +169,18 @@ pub struct PianoRollPane {
|
|||
snap_value: SnapValue,
|
||||
last_snap_selection: HashSet<usize>,
|
||||
snap_user_changed: bool, // set in render_header, consumed before handle_input
|
||||
|
||||
// Mobile portrait "Synthesia" mode: the playable keyboard is drawn as a strip at the bottom of
|
||||
// this pane (one unified surface), reusing the Virtual Piano's rendering + MIDI logic.
|
||||
keyboard: super::virtual_piano::VirtualPianoPane,
|
||||
// Manual long-press detection for the vertical roll (works with mouse-hold and touch): time the
|
||||
// press started, and where. `None` = disarmed (no fresh press, or the press became a pan).
|
||||
lp_press_time: Option<f64>,
|
||||
lp_press_pos: egui::Pos2,
|
||||
// When the long-press landed on an existing note, we resize it instead of creating: its index in
|
||||
// the resolved-note list and its original duration (to compute the resize delta on commit).
|
||||
editing_index: Option<usize>,
|
||||
editing_orig_dur: f64,
|
||||
}
|
||||
|
||||
impl PianoRollPane {
|
||||
|
|
@ -205,6 +217,11 @@ impl PianoRollPane {
|
|||
snap_value: SnapValue::None,
|
||||
last_snap_selection: HashSet::new(),
|
||||
snap_user_changed: false,
|
||||
keyboard: super::virtual_piano::VirtualPianoPane::new(),
|
||||
lp_press_time: None,
|
||||
lp_press_pos: egui::Pos2::ZERO,
|
||||
editing_index: None,
|
||||
editing_orig_dur: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -444,6 +461,13 @@ impl PianoRollPane {
|
|||
}
|
||||
}
|
||||
|
||||
// Mobile: keyboard-primary "Synthesia" instrument surface (keys + falling-notes roll). Always
|
||||
// used on mobile (device is portrait; the landscape/conventional flip is P7).
|
||||
if shared.is_mobile {
|
||||
self.render_vertical_mode(ui, rect, shared, &clip_data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply quantize if the user changed the snap dropdown (must happen before handle_input
|
||||
// which may clear the selection when the ComboBox click propagates to the grid).
|
||||
if self.snap_user_changed {
|
||||
|
|
@ -556,6 +580,336 @@ impl PianoRollPane {
|
|||
self.render_keyboard(&painter, keyboard_rect);
|
||||
}
|
||||
|
||||
/// Portrait mobile "Synthesia" view — ONE unified instrument surface: an instrument header on
|
||||
/// top, then falling notes as vertical columns (pitch on X, aligned to the keys via the shared
|
||||
/// `KeyboardLayout`, time falling toward the "now" line), then the playable keyboard strip at the
|
||||
/// bottom (reusing the Virtual Piano). Tapping a column adds a note.
|
||||
fn render_vertical_mode(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
rect: Rect,
|
||||
shared: &mut SharedPaneState,
|
||||
clip_data: &[(u32, f64, f64, f64, Uuid)],
|
||||
) {
|
||||
use super::keyboard_layout::KeyboardLayout;
|
||||
|
||||
// Keyboard-primary layout (portrait, per wireframe Plate 08): the playable keyboard is the
|
||||
// base surface, capped at an ergonomic height. Whether the falling-notes roll appears above
|
||||
// is decided by the shell from the *snapped* pane size-class, so the reveal lands on a stack
|
||||
// snap point (not mid-drag). Short/smallest snap ⇒ keyboard only.
|
||||
const KEY_CAP: f32 = 170.0;
|
||||
let header_h = 32.0;
|
||||
let header_rect = Rect::from_min_max(rect.min, pos2(rect.right(), rect.top() + header_h));
|
||||
let content_top = header_rect.bottom();
|
||||
let content_h = (rect.bottom() - content_top).max(0.0);
|
||||
let show_roll = shared.instrument_show_roll && content_h > KEY_CAP + 40.0;
|
||||
let kb_h = if show_roll { content_h.min(KEY_CAP) } else { content_h };
|
||||
let kb_rect = Rect::from_min_max(pos2(rect.left(), rect.bottom() - kb_h), rect.max);
|
||||
let roll_rect = Rect::from_min_max(pos2(rect.left(), content_top), pos2(rect.right(), kb_rect.top()));
|
||||
|
||||
let pps = self.pixels_per_second; // pixels per second (vertical waterfall)
|
||||
let now_y = roll_rect.bottom(); // a note reaches the keys at its onset time
|
||||
let now = ui.input(|i| i.time);
|
||||
|
||||
// Auto-release a long-press-added preview note once its short audition elapses (the normal
|
||||
// release check lives in handle_input, which the vertical mode bypasses).
|
||||
if let Some(dur) = self.preview_duration {
|
||||
if self.preview_note_sounding && now - self.preview_start_time >= dur {
|
||||
self.preview_note_off(shared);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle roll gestures BEFORE building the layout / drawing, so the roll and the keyboard both
|
||||
// use the updated pan + playhead this same frame (no 1-frame follow lag between them).
|
||||
let resp = if show_roll {
|
||||
let r = ui.interact(roll_rect, ui.id().with("pr_vertical"), egui::Sense::click_and_drag());
|
||||
if self.creating_note.is_none() && r.dragged() {
|
||||
let d = r.drag_delta();
|
||||
if d.y.abs() > 0.0 {
|
||||
// Drag down ⇒ advance time (content follows the finger).
|
||||
let nt = (*shared.playback_time + (d.y / pps) as f64).max(0.0);
|
||||
*shared.playback_time = nt;
|
||||
if let Some(ctrl) = shared.audio_controller.as_ref() {
|
||||
if let Ok(mut c) = ctrl.lock() {
|
||||
c.seek(nt);
|
||||
}
|
||||
}
|
||||
}
|
||||
*shared.keyboard_pan_x += d.x;
|
||||
}
|
||||
Some(r)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let layout = KeyboardLayout::from_width(rect.min.x, rect.width(), *shared.keyboard_octave, *shared.keyboard_pan_x);
|
||||
// Note times are in beats; the playhead is in seconds — convert via the tempo map so the
|
||||
// waterfall lines up with real playback (onset crosses the now line exactly when it sounds).
|
||||
let tempo_map = shared.action_executor.document().tempo_map().clone();
|
||||
let playhead = *shared.playback_time; // seconds
|
||||
|
||||
if let Some(resp) = resp {
|
||||
let painter = ui.painter_at(roll_rect);
|
||||
let bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(30, 30, 35));
|
||||
painter.rect_filled(roll_rect, 0.0, bg);
|
||||
|
||||
// Column guides aligned to the keys: black-key columns tinted, white-key boundaries lined.
|
||||
for note in layout.visible_notes() {
|
||||
let x = layout.note_x(note);
|
||||
if KeyboardLayout::is_black_key(note) {
|
||||
let w = layout.note_width(note);
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(pos2(x, roll_rect.min.y), vec2(w, roll_rect.height())),
|
||||
0.0,
|
||||
Color32::from_rgba_unmultiplied(0, 0, 0, 40),
|
||||
);
|
||||
} else {
|
||||
painter.line_segment([pos2(x, roll_rect.min.y), pos2(x, roll_rect.max.y)], Stroke::new(1.0, Color32::from_gray(55)));
|
||||
}
|
||||
}
|
||||
|
||||
// Notes of the selected clip, falling toward the now line.
|
||||
if let Some(clip_id) = self.selected_clip_id {
|
||||
if let Some(&(_, timeline_start, trim_start, duration, _)) =
|
||||
clip_data.iter().find(|c| c.0 == clip_id)
|
||||
{
|
||||
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
|
||||
let resolved = Self::resolve_notes(events);
|
||||
for (ni, note) in resolved.iter().enumerate() {
|
||||
// The note being resized is drawn separately (live) as the temp note.
|
||||
if Some(ni) == self.editing_index {
|
||||
continue;
|
||||
}
|
||||
if note.start_time + note.duration <= trim_start
|
||||
|| note.start_time >= trim_start + duration
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let global = timeline_start + (note.start_time - trim_start);
|
||||
let y_on = now_y - ((tempo_map.transform(global) - playhead) as f32) * pps;
|
||||
let y_off = now_y - ((tempo_map.transform(global + note.duration) - playhead) as f32) * pps;
|
||||
let (top, bot) = (y_on.min(y_off), y_on.max(y_off));
|
||||
if bot < roll_rect.min.y || top > roll_rect.max.y {
|
||||
continue;
|
||||
}
|
||||
let x = layout.note_x(note.note);
|
||||
let w = layout.note_width(note.note).max(3.0);
|
||||
let bright = 0.35 + (note.velocity as f32 / 127.0) * 0.65;
|
||||
let col = Color32::from_rgb(
|
||||
(111.0 * bright) as u8,
|
||||
(220.0 * bright) as u8,
|
||||
(111.0 * bright) as u8,
|
||||
);
|
||||
painter.rect_filled(
|
||||
Rect::from_min_max(
|
||||
pos2(x + 1.0, top.max(roll_rect.min.y)),
|
||||
pos2(x + w - 1.0, bot.min(roll_rect.max.y)),
|
||||
),
|
||||
2.0,
|
||||
col,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The note currently being created (long-press to start, drag along Y to size).
|
||||
if let Some(temp) = self.creating_note.as_ref() {
|
||||
if let Some(&(_, ts, trim, _d, _)) =
|
||||
clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id)
|
||||
{
|
||||
let g = ts + (temp.start_time - trim);
|
||||
let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps;
|
||||
let y_off = now_y - ((tempo_map.transform(g + temp.duration) - playhead) as f32) * pps;
|
||||
let x = layout.note_x(temp.note);
|
||||
let w = layout.note_width(temp.note).max(3.0);
|
||||
painter.rect_filled(
|
||||
Rect::from_min_max(pos2(x + 1.0, y_on.min(y_off)), pos2(x + w - 1.0, y_on.max(y_off))),
|
||||
2.0,
|
||||
Color32::from_rgb(150, 255, 150),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The amber "now" line at the boundary with the keyboard.
|
||||
painter.line_segment(
|
||||
[pos2(roll_rect.min.x, now_y), pos2(roll_rect.max.x, now_y)],
|
||||
Stroke::new(2.0, Color32::from_rgb(0xf4, 0xa3, 0x40)),
|
||||
);
|
||||
|
||||
const LONG_PRESS_SECS: f64 = 0.4;
|
||||
let pressed = ui.input(|i| i.pointer.primary_pressed());
|
||||
let down = ui.input(|i| i.pointer.any_down());
|
||||
let ptr = ui.input(|i| i.pointer.latest_pos());
|
||||
|
||||
if self.creating_note.is_some() {
|
||||
// Sizing the note (new or existing): drag along Y sets its duration; release commits.
|
||||
if down {
|
||||
if let (Some(p), Some(&(_, ts, trim, _d, _))) = (
|
||||
ptr,
|
||||
clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id),
|
||||
) {
|
||||
let end_sec = playhead + ((now_y - p.y) / pps) as f64;
|
||||
let end_beats = tempo_map.inverse_transform(end_sec).max(0.0);
|
||||
let end_local = trim + (end_beats - ts);
|
||||
if let Some(t) = self.creating_note.as_mut() {
|
||||
t.duration = (end_local - t.start_time).max(0.25);
|
||||
}
|
||||
}
|
||||
} else if let (Some(temp), Some(clip_id)) =
|
||||
(self.creating_note.take(), self.selected_clip_id)
|
||||
{
|
||||
if let Some(idx) = self.editing_index.take() {
|
||||
let dt = temp.duration - self.editing_orig_dur;
|
||||
self.commit_resize_note(clip_id, idx, dt, shared, clip_data);
|
||||
} else {
|
||||
self.commit_create_note(clip_id, temp, shared, clip_data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Snap the pan to the nearest key on release.
|
||||
if resp.drag_stopped() {
|
||||
*shared.keyboard_pan_x = layout.snap_pan(*shared.keyboard_pan_x);
|
||||
}
|
||||
// Manual long-press (mouse-hold or touch): armed only on a *fresh* press, and cancelled
|
||||
// for the rest of that press once it moves (→ pan), so pausing mid-pan won't fire.
|
||||
if pressed {
|
||||
match ptr {
|
||||
Some(p) if roll_rect.contains(p) => {
|
||||
self.lp_press_time = Some(now);
|
||||
self.lp_press_pos = p;
|
||||
}
|
||||
_ => self.lp_press_time = None,
|
||||
}
|
||||
}
|
||||
if !down {
|
||||
self.lp_press_time = None;
|
||||
}
|
||||
if let Some(t0) = self.lp_press_time {
|
||||
let moved = ptr.map_or(0.0, |p| (p - self.lp_press_pos).length());
|
||||
if moved > 8.0 {
|
||||
self.lp_press_time = None; // became a pan → suppress for this press
|
||||
} else if now - t0 >= LONG_PRESS_SECS {
|
||||
self.lp_press_time = None;
|
||||
let pos = self.lp_press_pos;
|
||||
if let Some(clip_id) = self.selected_clip_id {
|
||||
if let Some(&(_, timeline_start, trim_start, clip_dur, _)) =
|
||||
clip_data.iter().find(|c| c.0 == clip_id)
|
||||
{
|
||||
let pitch = layout.x_to_note(pos.x);
|
||||
// Hit-test an existing note in this column → resize it; else create.
|
||||
let mut hit = None;
|
||||
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
|
||||
for (i, n) in Self::resolve_notes(events).iter().enumerate() {
|
||||
if n.note != pitch
|
||||
|| n.start_time < trim_start
|
||||
|| n.start_time >= trim_start + clip_dur
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let g = timeline_start + (n.start_time - trim_start);
|
||||
let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps;
|
||||
let y_off = now_y - ((tempo_map.transform(g + n.duration) - playhead) as f32) * pps;
|
||||
if pos.y >= y_on.min(y_off) && pos.y <= y_on.max(y_off) {
|
||||
hit = Some((i, n.start_time, n.duration, n.velocity));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((idx, onset, dur, vel)) = hit {
|
||||
self.editing_index = Some(idx);
|
||||
self.editing_orig_dur = dur;
|
||||
self.creating_note = Some(TempNote { note: pitch, start_time: onset, duration: dur, velocity: vel });
|
||||
} else {
|
||||
let onset_sec = playhead + ((now_y - pos.y) / pps) as f64;
|
||||
let onset_beats = tempo_map.inverse_transform(onset_sec).max(0.0);
|
||||
let onset_local = snap_to_value((trim_start + (onset_beats - timeline_start)).max(0.0), self.snap_value, &tempo_map);
|
||||
self.editing_index = None;
|
||||
self.creating_note = Some(TempNote { note: pitch, start_time: onset_local, duration: 0.5, velocity: DEFAULT_VELOCITY });
|
||||
}
|
||||
self.preview_note_on(pitch, DEFAULT_VELOCITY, Some(0.4), now, shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes sounding at the playhead → drive the keyboard's highlights so it colors them exactly
|
||||
// like pressed keys (full key, correct white/black layering) rather than an after-overlay.
|
||||
let mut sounding = std::collections::HashSet::new();
|
||||
if let Some(clip_id) = self.selected_clip_id {
|
||||
if let Some(&(_, timeline_start, trim_start, duration, _)) =
|
||||
clip_data.iter().find(|c| c.0 == clip_id)
|
||||
{
|
||||
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
|
||||
for note in Self::resolve_notes(events) {
|
||||
if note.start_time < trim_start || note.start_time >= trim_start + duration {
|
||||
continue;
|
||||
}
|
||||
let global = timeline_start + (note.start_time - trim_start);
|
||||
let s = tempo_map.transform(global);
|
||||
let e = tempo_map.transform(global + note.duration);
|
||||
if playhead >= s && playhead < e {
|
||||
sounding.insert(note.note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.keyboard.playback_notes = sounding;
|
||||
|
||||
// Instrument header (in this pane's own header) + the always-present playable keyboard strip.
|
||||
self.render_instrument_header(ui, header_rect, shared);
|
||||
let kb_path: NodePath = Vec::new();
|
||||
self.keyboard.render_content(ui, kb_rect, &kb_path, shared);
|
||||
}
|
||||
|
||||
/// The in-pane instrument header: active track name + Presets + REC.
|
||||
fn render_instrument_header(&mut self, ui: &mut egui::Ui, rect: Rect, shared: &mut SharedPaneState) {
|
||||
let painter = ui.painter_at(rect);
|
||||
let hdr = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28));
|
||||
painter.rect_filled(rect, 0.0, hdr);
|
||||
painter.line_segment(
|
||||
[pos2(rect.left(), rect.bottom()), pos2(rect.right(), rect.bottom())],
|
||||
Stroke::new(1.0, Color32::from_gray(60)),
|
||||
);
|
||||
|
||||
let name = shared
|
||||
.active_layer_id
|
||||
.and_then(|id| shared.action_executor.document().get_layer(&id))
|
||||
.map(|l| l.name().to_string())
|
||||
.unwrap_or_else(|| "No instrument".to_string());
|
||||
painter.text(
|
||||
pos2(rect.left() + 12.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
name,
|
||||
egui::FontId::proportional(14.0),
|
||||
Color32::from_gray(220),
|
||||
);
|
||||
|
||||
// REC (rightmost).
|
||||
let rec = Rect::from_min_max(pos2(rect.right() - 64.0, rect.top() + 4.0), pos2(rect.right() - 8.0, rect.bottom() - 4.0));
|
||||
let rec_resp = ui.interact(rec, ui.id().with("pr_rec"), egui::Sense::click());
|
||||
let recording = *shared.is_recording;
|
||||
let rec_bg = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) } else { Color32::from_gray(66) };
|
||||
painter.rect_filled(rec, 6.0, rec_bg);
|
||||
painter.circle_filled(pos2(rec.left() + 13.0, rec.center().y), 4.0, Color32::from_rgb(255, 96, 96));
|
||||
painter.text(pos2(rec.left() + 23.0, rec.center().y), egui::Align2::LEFT_CENTER, "REC", egui::FontId::proportional(12.0), Color32::WHITE);
|
||||
if rec_resp.clicked() {
|
||||
*shared.pending_record_toggle = true;
|
||||
}
|
||||
|
||||
// Presets (left of REC).
|
||||
let pre = Rect::from_min_max(pos2(rec.left() - 84.0, rect.top() + 4.0), pos2(rec.left() - 8.0, rect.bottom() - 4.0));
|
||||
let pre_resp = ui.interact(pre, ui.id().with("pr_presets"), egui::Sense::click());
|
||||
painter.rect_filled(pre, 6.0, if pre_resp.hovered() { Color32::from_gray(56) } else { Color32::from_gray(42) });
|
||||
painter.text(pre.center(), egui::Align2::CENTER_CENTER, "Presets", egui::FontId::proportional(12.0), Color32::from_gray(220));
|
||||
if pre_resp.clicked() {
|
||||
*shared.open_instrument_browser = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) {
|
||||
// Background
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45));
|
||||
|
|
|
|||
|
|
@ -951,7 +951,7 @@ impl TimelinePane {
|
|||
|
||||
/// Toggle recording on/off
|
||||
/// In Auto mode, records to the active layer (audio or video with camera)
|
||||
fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
|
||||
pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
|
||||
if *shared.is_recording {
|
||||
// Stop recording
|
||||
self.stop_recording(shared);
|
||||
|
|
@ -1212,7 +1212,7 @@ impl TimelinePane {
|
|||
|
||||
/// Stop all active recordings
|
||||
/// Called every frame; fires deferred recording commands once the count-in pre-roll ends.
|
||||
fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
|
||||
pub(crate) fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
|
||||
let Some(ref pending) = self.pending_recording_start else { return };
|
||||
if !*shared.is_playing || *shared.playback_time < pending.trigger_time {
|
||||
return;
|
||||
|
|
@ -1232,7 +1232,7 @@ impl TimelinePane {
|
|||
*shared.count_in_enabled = saved_count_in;
|
||||
}
|
||||
|
||||
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
||||
pub(crate) fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
||||
// Cancel any in-progress count-in
|
||||
self.pending_recording_start = None;
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ pub struct VirtualPianoPane {
|
|||
sustain_active: bool,
|
||||
/// Notes being held by sustain pedal (not by active key/mouse press)
|
||||
sustained_notes: HashSet<u8>,
|
||||
/// Externally-driven highlights (e.g. notes sounding during playback), colored like pressed keys.
|
||||
pub playback_notes: HashSet<u8>,
|
||||
}
|
||||
|
||||
impl Default for VirtualPianoPane {
|
||||
|
|
@ -83,6 +85,7 @@ impl VirtualPianoPane {
|
|||
note_to_key_map,
|
||||
sustain_active: false,
|
||||
sustained_notes: HashSet::new(),
|
||||
playback_notes: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,9 +219,17 @@ impl VirtualPianoPane {
|
|||
|
||||
/// Render the piano keyboard
|
||||
fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
|
||||
// Calculate visible range and key dimensions based on pane size
|
||||
let (visible_start, visible_end, white_key_width, offset_x) =
|
||||
self.calculate_visible_range(rect.width(), rect.height());
|
||||
// Calculate visible range and key dimensions based on pane size. On mobile use the shared
|
||||
// width-driven layout (so the portrait Piano Roll's columns line up with these keys).
|
||||
let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile {
|
||||
let layout = super::keyboard_layout::KeyboardLayout::from_width(
|
||||
rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x,
|
||||
);
|
||||
let vs = layout.first_visible_white();
|
||||
(vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x)
|
||||
} else {
|
||||
self.calculate_visible_range(rect.width(), rect.height())
|
||||
};
|
||||
|
||||
let white_key_height = rect.height();
|
||||
let black_key_width = white_key_width * self.black_key_width_ratio;
|
||||
|
|
@ -238,6 +249,11 @@ impl VirtualPianoPane {
|
|||
return;
|
||||
}
|
||||
|
||||
// Only play notes when the press STARTED on the keyboard, so dragging in from the roll above
|
||||
// (mobile) doesn't trigger notes. On desktop the whole pane is the keyboard, so a click here
|
||||
// always qualifies.
|
||||
let started_here = ui.input(|i| i.pointer.press_origin().map_or(false, |p| rect.contains(p)));
|
||||
|
||||
// Count white keys before each note for positioning
|
||||
let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new();
|
||||
let mut white_count = 0;
|
||||
|
|
@ -303,7 +319,8 @@ impl VirtualPianoPane {
|
|||
});
|
||||
let pointer_down = ui.input(|i| i.pointer.primary_down());
|
||||
let is_pressed = self.pressed_notes.contains(¬e) ||
|
||||
(!black_key_interacted && pointer_over_key && pointer_down);
|
||||
self.playback_notes.contains(¬e) ||
|
||||
(started_here && !black_key_interacted && pointer_over_key && pointer_down);
|
||||
let color = if is_pressed {
|
||||
shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))
|
||||
} else {
|
||||
|
|
@ -320,8 +337,9 @@ impl VirtualPianoPane {
|
|||
);
|
||||
|
||||
if !black_key_interacted {
|
||||
// Mouse down starts note (detect primary button pressed on this key)
|
||||
if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
// Mouse down starts note (detect primary button pressed on this key). Gated on
|
||||
// `started_here` so a drag that began on the roll above doesn't start notes.
|
||||
if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
// Calculate velocity based on mouse Y position
|
||||
let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y;
|
||||
let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect);
|
||||
|
|
@ -330,7 +348,8 @@ impl VirtualPianoPane {
|
|||
self.dragging_note = Some(note);
|
||||
}
|
||||
|
||||
// Mouse up stops note (detect primary button released)
|
||||
// Mouse up stops note (detect primary button released). NEVER gated — a held note
|
||||
// must always release.
|
||||
if ui.input(|i| i.pointer.primary_released()) {
|
||||
if self.dragging_note == Some(note) {
|
||||
self.send_note_off(note, shared);
|
||||
|
|
@ -339,7 +358,7 @@ impl VirtualPianoPane {
|
|||
}
|
||||
|
||||
// Dragging over a new key (pointer is down and over a different key)
|
||||
if pointer_over_key && pointer_down {
|
||||
if started_here && pointer_over_key && pointer_down {
|
||||
if self.dragging_note != Some(note) {
|
||||
// Stop previous note
|
||||
if let Some(prev_note) = self.dragging_note {
|
||||
|
|
@ -387,7 +406,8 @@ impl VirtualPianoPane {
|
|||
});
|
||||
let pointer_down = ui.input(|i| i.pointer.primary_down());
|
||||
let is_pressed = self.pressed_notes.contains(¬e) ||
|
||||
(pointer_over_key && pointer_down);
|
||||
self.playback_notes.contains(¬e) ||
|
||||
(started_here && pointer_over_key && pointer_down);
|
||||
let color = if is_pressed {
|
||||
shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200))
|
||||
} else {
|
||||
|
|
@ -397,7 +417,7 @@ impl VirtualPianoPane {
|
|||
ui.painter().rect_filled(key_rect, 2.0, color);
|
||||
|
||||
// Mouse down starts note
|
||||
if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
// Calculate velocity based on mouse Y position
|
||||
let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y;
|
||||
let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect);
|
||||
|
|
@ -415,7 +435,7 @@ impl VirtualPianoPane {
|
|||
}
|
||||
|
||||
// Dragging over a new key
|
||||
if pointer_over_key && pointer_down {
|
||||
if started_here && pointer_over_key && pointer_down {
|
||||
if self.dragging_note != Some(note) {
|
||||
if let Some(prev_note) = self.dragging_note {
|
||||
self.send_note_off(prev_note, shared);
|
||||
|
|
@ -824,6 +844,11 @@ impl PaneRenderer for VirtualPianoPane {
|
|||
_path: &NodePath,
|
||||
shared: &mut SharedPaneState,
|
||||
) {
|
||||
// On mobile, the octave is shared with the Piano Roll so they stay aligned.
|
||||
if shared.is_mobile {
|
||||
self.octave_offset = *shared.keyboard_octave;
|
||||
}
|
||||
|
||||
// Check if there's an active MIDI layer
|
||||
let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id {
|
||||
shared.layer_to_track_map.contains_key(&active_layer_id)
|
||||
|
|
@ -869,16 +894,30 @@ impl PaneRenderer for VirtualPianoPane {
|
|||
}
|
||||
|
||||
// Calculate visible range (needed for both rendering and labels)
|
||||
let (visible_start, visible_end, white_key_width, offset_x) =
|
||||
self.calculate_visible_range(rect.width(), rect.height());
|
||||
let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile {
|
||||
let layout = super::keyboard_layout::KeyboardLayout::from_width(
|
||||
rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x,
|
||||
);
|
||||
let vs = layout.first_visible_white();
|
||||
(vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x)
|
||||
} else {
|
||||
self.calculate_visible_range(rect.width(), rect.height())
|
||||
};
|
||||
|
||||
// Render the keyboard
|
||||
self.render_keyboard(ui, rect, shared);
|
||||
|
||||
// Render keyboard labels on top
|
||||
// Render keyboard labels on top — but not on mobile (no physical keyboard to hint at).
|
||||
if !shared.is_mobile {
|
||||
self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x);
|
||||
}
|
||||
|
||||
// Publish the octave so the portrait Piano Roll aligns to these keys.
|
||||
if shared.is_mobile {
|
||||
*shared.keyboard_octave = self.octave_offset;
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"Virtual Piano"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,842 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Phone UI — Wireframe Plates</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0E1014;
|
||||
--ink:#D7DDE4;
|
||||
--ink-soft:#8B95A1;
|
||||
--rule:#23282F;
|
||||
--frame:#3A414D;
|
||||
--device:#16191F;
|
||||
--panel:#1F242C;
|
||||
--panel-2:#272D37;
|
||||
--line:#363D49;
|
||||
--dim:#7C8693;
|
||||
--bright:#EAEEF3;
|
||||
--amber:#F4A340; /* time / playhead / transport — the spine */
|
||||
--cyan:#54C3E8; /* selection */
|
||||
--coral:#E8826B; /* annotation */
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html{-webkit-text-size-adjust:100%;}
|
||||
body{
|
||||
margin:0; font-family:var(--sans); color:var(--ink);
|
||||
background:
|
||||
radial-gradient(circle, rgba(255,255,255,.022) 1px, transparent 1.4px) 0 0/26px 26px,
|
||||
var(--bg);
|
||||
line-height:1.5;
|
||||
}
|
||||
|
||||
/* ---- title block ---- */
|
||||
.sheet-head{ max-width:1120px; margin:0 auto; padding:48px 24px 8px;}
|
||||
.titleblock{ border:1.5px solid var(--frame); display:grid; grid-template-columns:1fr auto; background:rgba(255,255,255,.012);}
|
||||
.titleblock .tl-main{ padding:20px 22px;}
|
||||
.titleblock .tl-meta{ border-left:1.5px solid var(--frame); display:grid; grid-template-rows:repeat(3,1fr); font-family:var(--mono); font-size:11px; min-width:200px;}
|
||||
.tl-meta div{ padding:8px 14px; display:flex; flex-direction:column; justify-content:center;}
|
||||
.tl-meta div + div{ border-top:1px solid var(--rule);}
|
||||
.tl-meta span{ color:var(--ink-soft); letter-spacing:.04em;}
|
||||
.tl-meta strong{ font-weight:600; font-size:12px; color:var(--bright);}
|
||||
.eyebrow{ font-family:var(--mono); font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--coral); margin:0 0 8px;}
|
||||
.titleblock h1{ margin:0; font-size:clamp(22px,3.6vw,34px); letter-spacing:-0.01em; line-height:1.05; color:var(--bright);}
|
||||
.titleblock p{ margin:10px 0 0; max-width:60ch; color:var(--ink-soft); font-size:14px;}
|
||||
|
||||
.legend-key{ max-width:1120px; margin:14px auto 0; padding:0 24px; display:flex; flex-wrap:wrap; gap:18px; font-family:var(--mono); font-size:11px; color:var(--ink-soft);}
|
||||
.legend-key b{ display:inline-flex; align-items:center; gap:6px; font-weight:500; color:var(--ink);}
|
||||
.swatch{ width:11px; height:11px; border-radius:2px; display:inline-block;}
|
||||
|
||||
/* ---- plates ---- */
|
||||
.plate{ max-width:1120px; margin:0 auto; padding:40px 24px; border-top:1px solid var(--rule);}
|
||||
.plate:first-of-type{ border-top:0;}
|
||||
.plate-head{ display:flex; align-items:baseline; gap:16px; margin-bottom:8px; flex-wrap:wrap;}
|
||||
.plate-num{ font-family:var(--mono); font-size:13px; font-weight:600; color:var(--coral); letter-spacing:.04em;}
|
||||
.plate-head h2{ margin:0; font-size:clamp(18px,2.6vw,24px); letter-spacing:-0.01em; color:var(--bright);}
|
||||
.plate-head .tag{ font-family:var(--mono); font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--ink-soft); border:1px solid var(--line); padding:3px 8px; border-radius:3px;}
|
||||
.plate > p.lede{ margin:0 0 26px; max-width:74ch; color:var(--ink-soft); font-size:14px;}
|
||||
|
||||
.stage-row{ display:flex; gap:34px; align-items:flex-start; flex-wrap:wrap;}
|
||||
.figcol{ display:flex; flex-direction:column; gap:10px; align-items:center;}
|
||||
.figcap{ font-family:var(--mono); font-size:11px; color:var(--ink-soft); text-align:center; max-width:300px; letter-spacing:.02em;}
|
||||
.figcap b{ color:var(--bright); font-weight:600;}
|
||||
|
||||
/* ---- annotation callouts ---- */
|
||||
.notes{ flex:1; min-width:240px; display:flex; flex-direction:column; gap:16px; padding-top:6px;}
|
||||
.note{ display:grid; grid-template-columns:24px 1fr; gap:12px;}
|
||||
.note .dot{ width:22px; height:22px; border-radius:50%; border:1.5px solid var(--frame); font-family:var(--mono); font-size:11px; font-weight:600; display:flex; align-items:center; justify-content:center; background:var(--panel); color:var(--ink);}
|
||||
.note h4{ margin:0 0 3px; font-size:14px; letter-spacing:-0.01em; color:var(--bright);}
|
||||
.note p{ margin:0; font-size:13px; color:var(--ink-soft);}
|
||||
.note code{ font-family:var(--mono); font-size:12px; background:var(--panel); border:1px solid var(--line); padding:1px 5px; border-radius:3px; color:var(--bright);}
|
||||
|
||||
/* ================= PHONE FRAME ================= */
|
||||
.phone{ width:266px; flex:0 0 auto; background:#04060A; border-radius:34px; padding:7px; box-shadow:0 0 0 1px rgba(255,255,255,.04), 0 22px 46px -22px rgba(0,0,0,.85); position:relative;}
|
||||
.phone.lg{ width:300px;}
|
||||
.phone .screen{ position:relative; border-radius:27px; overflow:hidden; background:var(--device); height:540px; display:flex; flex-direction:column; color:var(--bright); font-family:var(--mono);}
|
||||
.phone.lg .screen{ height:610px;}
|
||||
.phone.land{ width:520px;} .phone.land .screen{ height:266px;}
|
||||
|
||||
.hs{ position:absolute; width:20px; height:20px; border-radius:50%; background:var(--coral); color:#1a0f0a; font-family:var(--mono); font-size:11px; font-weight:700; display:flex; align-items:center; justify-content:center; z-index:9; box-shadow:0 0 0 3px rgba(232,130,107,.22);}
|
||||
|
||||
.statusbar{ height:20px; display:flex; align-items:center; justify-content:space-between; padding:0 14px; font-size:9px; color:var(--dim); flex:0 0 auto;}
|
||||
|
||||
/* surface tabs — now at TOP */
|
||||
.tabs{ flex:0 0 auto; height:30px; background:var(--device); border-bottom:1px solid var(--line); display:flex;}
|
||||
.tabs .tab{ flex:1; display:flex; align-items:center; justify-content:center; gap:5px; font-size:9px; color:var(--dim); border-right:1px solid #20242c; letter-spacing:.02em;}
|
||||
.tabs .tab:last-child{ border-right:0;}
|
||||
.tabs .tab .g{ width:10px; height:10px; border-radius:2.5px; border:1.4px solid var(--dim);}
|
||||
.tabs .tab.on{ color:var(--amber); box-shadow:inset 0 -2px 0 var(--amber);}
|
||||
.tabs .tab.on .g{ border-color:var(--amber); background:rgba(244,163,64,.16);}
|
||||
|
||||
/* transport — now at BOTTOM, with the timeline */
|
||||
.transport{ flex:0 0 auto; height:34px; background:var(--panel); border-top:1px solid var(--line); display:flex; align-items:center; gap:8px; padding:0 10px;}
|
||||
.transport .play{ width:20px; height:20px; border-radius:50%; background:var(--amber); display:flex; align-items:center; justify-content:center; flex:0 0 auto;}
|
||||
.transport .play::after{ content:""; border-left:7px solid #1b130a; border-top:5px solid transparent; border-bottom:5px solid transparent; margin-left:2px;}
|
||||
.transport .tc{ font-size:11px; color:var(--bright); letter-spacing:.02em;}
|
||||
.transport .scrub{ flex:1; height:4px; border-radius:2px; background:var(--line); position:relative;}
|
||||
.transport .scrub::before{ content:""; position:absolute; left:0; top:0; bottom:0; width:38%; background:var(--amber); border-radius:2px; opacity:.45;}
|
||||
.transport .scrub::after{ content:""; position:absolute; left:38%; top:-3px; width:2px; height:10px; background:var(--amber);}
|
||||
.transport .mini{ font-size:9px; color:var(--dim);}
|
||||
|
||||
.hero{ flex:1; position:relative; overflow:hidden; display:flex; align-items:center; justify-content:center; background:radial-gradient(120% 120% at 50% 0%, #1b1f27 0%, #14161B 70%);}
|
||||
.hero .canvas-frame{ position:absolute; border:1px dashed var(--line);}
|
||||
.obj{ position:absolute; border-radius:6px;}
|
||||
.obj.sel{ box-shadow:0 0 0 1.5px var(--cyan), 0 0 0 4px rgba(84,195,232,.18);}
|
||||
.handle{ position:absolute; width:7px; height:7px; background:var(--cyan); border:1px solid #0c2a33; border-radius:2px; z-index:3;}
|
||||
|
||||
/* timeline ribbon — sits above the transport */
|
||||
.ribbon{ flex:0 0 auto; background:var(--panel); display:flex; flex-direction:column; overflow:hidden; position:relative;}
|
||||
.ribbon .grab{ height:14px; display:flex; align-items:center; justify-content:center; flex:0 0 auto;}
|
||||
.ribbon .grab::before{ content:""; width:34px; height:4px; border-radius:2px; background:var(--line);}
|
||||
.ribbon .ruler{ height:14px; border-bottom:1px solid var(--line); border-top:1px solid var(--line); position:relative; background:repeating-linear-gradient(90deg,transparent 0 27px,var(--line) 27px 28px); flex:0 0 auto;}
|
||||
.ribbon .ruler::after{ content:""; position:absolute; left:38%; top:0; height:600px; width:1.5px; background:var(--amber); z-index:4;}
|
||||
.lane{ height:22px; display:flex; align-items:center; border-bottom:1px solid #20242c; padding-left:42px; position:relative; flex:0 0 auto;}
|
||||
.lane .gut{ position:absolute; left:0; top:0; bottom:0; width:42px; border-right:1px solid var(--line); display:flex; align-items:center; padding-left:7px; font-size:8.5px; color:var(--dim); background:var(--panel-2);}
|
||||
.clip{ position:absolute; height:13px; border-radius:3px; top:50%; transform:translateY(-50%);}
|
||||
.clip.a{ background:linear-gradient(180deg,#3a6ea8,#2c5482);}
|
||||
.clip.v{ background:linear-gradient(180deg,#8a5fb0,#6a4690);}
|
||||
.clip.raster{ background:linear-gradient(180deg,#3f9d8f,#2c7568);}
|
||||
.clip.sel{ box-shadow:0 0 0 1.5px var(--cyan);}
|
||||
.clip .wav{ position:absolute; inset:2px 3px; opacity:.5; background:repeating-linear-gradient(90deg,#cfe3ff 0 1px, transparent 1px 3px);}
|
||||
|
||||
/* bottom sheet (inspector) — rises above the transport */
|
||||
.sheet-ui{ position:absolute; left:0; right:0; bottom:34px; background:var(--panel-2); border-top:1px solid var(--line); border-radius:14px 14px 0 0; z-index:6; box-shadow:0 -10px 30px -12px rgba(0,0,0,.7);}
|
||||
.sheet-ui .grab{ height:16px; display:flex; align-items:center; justify-content:center;}
|
||||
.sheet-ui .grab::before{ content:""; width:34px; height:4px; border-radius:2px; background:var(--line);}
|
||||
.sheet-ui .sh-head{ display:flex; align-items:center; gap:8px; padding:2px 12px 8px;}
|
||||
.sheet-ui .sh-head .ico{ width:18px; height:18px; border-radius:4px; background:var(--cyan);}
|
||||
.sheet-ui .sh-head .ttl{ font-size:11px; color:var(--bright);}
|
||||
.sheet-ui .sh-head .ovf{ margin-left:auto; color:var(--dim); font-size:14px; letter-spacing:1px;}
|
||||
.chips{ display:flex; gap:6px; padding:0 12px 10px; flex-wrap:wrap;}
|
||||
.chip{ font-size:9.5px; padding:4px 8px; border-radius:11px; border:1px solid var(--line); color:var(--dim);}
|
||||
.chip.on{ background:var(--cyan); color:#06222b; border-color:var(--cyan);}
|
||||
.prop{ display:flex; align-items:center; gap:8px; padding:7px 12px; border-top:1px solid #20242c;}
|
||||
.prop .k{ font-size:10px; color:var(--dim); width:62px;}
|
||||
.prop .field{ flex:1; height:18px; border-radius:4px; background:var(--panel); border:1px solid var(--line); display:flex; align-items:center; padding:0 7px; font-size:10px; color:var(--bright);}
|
||||
.prop .field.slider{ position:relative; background:var(--line);}
|
||||
.prop .field.slider::after{ content:""; position:absolute; left:0;top:0;bottom:0;width:55%; background:var(--cyan); opacity:.55; border-radius:4px 0 0 4px;}
|
||||
|
||||
.omni{ position:absolute; right:14px; bottom:120px; width:46px; height:46px; border-radius:50%; background:var(--amber); display:flex; align-items:center; justify-content:center; z-index:7; box-shadow:0 6px 16px -4px rgba(244,163,64,.55);}
|
||||
.omni::before,.omni::after{ content:""; position:absolute; background:#1b130a;}
|
||||
.omni::before{ width:16px; height:2.5px; border-radius:2px;}
|
||||
.omni::after{ width:2.5px; height:16px; border-radius:2px;}
|
||||
|
||||
.radial-wrap{ position:absolute; inset:0; z-index:8;}
|
||||
.radial-wrap .scrim{ position:absolute; inset:0; background:rgba(8,10,14,.55);}
|
||||
.rad-btn{ position:absolute; width:42px; height:42px; border-radius:50%; background:var(--panel-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size:9px; color:var(--bright); text-align:center; line-height:1.1;}
|
||||
.rad-btn .g{ width:16px;height:16px;border-radius:4px;}
|
||||
|
||||
/* intent grid */
|
||||
.intent-screen{ flex:1; display:flex; flex-direction:column; padding:18px 16px; gap:14px; background:var(--device);}
|
||||
.intent-screen .new-h{ font-size:13px; color:var(--bright); letter-spacing:.02em;}
|
||||
.intent-screen .new-sub{ font-size:10px; color:var(--dim); margin-top:-8px;}
|
||||
.intent-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:4px;}
|
||||
.intent{ aspect-ratio:1/.92; border:1px solid var(--line); border-radius:12px; background:var(--panel); display:flex; flex-direction:column; justify-content:flex-end; padding:10px; gap:4px; position:relative; overflow:hidden;}
|
||||
.intent .gico{ position:absolute; top:10px; left:10px; width:22px; height:22px; border-radius:6px;}
|
||||
.intent .nm{ font-size:11px; color:var(--bright);}
|
||||
.intent .de{ font-size:8.5px; color:var(--dim); line-height:1.25;}
|
||||
.intent.draw .gico{ background:var(--coral);}
|
||||
.intent.animate .gico{ background:var(--cyan);}
|
||||
.intent.compose .gico{ background:var(--amber);}
|
||||
.intent.record .gico{ background:#c75b8a;}
|
||||
.intent.video .gico{ background:#6a4690;}
|
||||
.intent.open{ grid-column:1/3; aspect-ratio:auto; flex-direction:row; align-items:center; gap:10px; padding:12px;}
|
||||
.intent.open .gico{ position:static; background:var(--line);}
|
||||
|
||||
.keys{ flex:1; background:#0c0e12; display:flex; position:relative; padding:6px 6px 8px;}
|
||||
.wkey{ flex:1; background:linear-gradient(180deg,#f3f4f6,#d9dde2); border-radius:0 0 4px 4px; margin:0 1px; box-shadow:inset 0 -6px 8px -6px rgba(0,0,0,.3);}
|
||||
.bkey{ position:absolute; top:6px; width:5.4%; height:54%; background:#1b1e24; border-radius:0 0 3px 3px; z-index:2; box-shadow:0 2px 3px rgba(0,0,0,.5);}
|
||||
.inst-bar{ flex:0 0 auto; height:26px; background:var(--panel); border-top:1px solid var(--line); border-bottom:1px solid var(--line); display:flex; align-items:center; gap:8px; padding:0 10px; font-size:9.5px; color:var(--dim);}
|
||||
.inst-bar .pill{ padding:3px 7px; border:1px solid var(--line); border-radius:10px; color:var(--bright);}
|
||||
|
||||
.gmap{ display:flex; gap:30px; flex-wrap:wrap; align-items:flex-start;}
|
||||
.gtable{ flex:1; min-width:280px; border:1px solid var(--frame); border-radius:8px; overflow:hidden; background:var(--device);}
|
||||
.grow{ display:grid; grid-template-columns:150px 1fr; border-top:1px solid var(--rule);}
|
||||
.grow:first-child{ border-top:0;}
|
||||
.grow .gg{ font-family:var(--mono); font-size:12px; padding:9px 12px; background:var(--panel); color:var(--bright); border-right:1px solid var(--rule); display:flex; align-items:center;}
|
||||
.grow .gm{ padding:9px 12px; font-size:13px; color:var(--ink-soft); display:flex; align-items:center;}
|
||||
.grow .gm b{ color:var(--bright); font-weight:600;}
|
||||
.grow.free .gg{ color:var(--coral);}
|
||||
.grow.free .gm b{ color:var(--coral);}
|
||||
|
||||
.foot{ max-width:1120px; margin:0 auto; padding:30px 24px 70px; border-top:1.5px solid var(--frame); font-family:var(--mono); font-size:11px; color:var(--ink-soft); display:flex; justify-content:space-between; flex-wrap:wrap; gap:12px;}
|
||||
|
||||
@media (max-width:560px){
|
||||
.phone{ width:240px;} .phone .screen{ height:500px;}
|
||||
.phone.land{ width:300px;} .phone.land .screen{ height:200px;}
|
||||
.titleblock{ grid-template-columns:1fr;} .titleblock .tl-meta{ border-left:0; border-top:1.5px solid var(--frame); grid-template-rows:none; grid-template-columns:repeat(3,1fr);}
|
||||
.tl-meta div + div{ border-top:0; border-left:1px solid var(--rule);}
|
||||
}
|
||||
/* ---- node focus mode ---- */
|
||||
.nodewrap{ position:absolute; inset:0;}
|
||||
.minimap{ position:absolute; top:8px; right:8px; width:80px; height:54px; background:rgba(8,10,14,.72); border:1px solid var(--line); border-radius:6px; z-index:5;}
|
||||
.minimap .mn{ position:absolute; width:11px; height:8px; border-radius:2px; background:var(--line);}
|
||||
.minimap .mn.on{ background:var(--cyan); box-shadow:0 0 0 2px rgba(84,195,232,.3);}
|
||||
.minimap .mw{ position:absolute; height:1.5px; background:var(--line);}
|
||||
.node-card{ position:absolute; left:50%; top:48%; transform:translate(-50%,-50%); width:158px; background:var(--panel-2); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 30px -16px rgba(0,0,0,.85); overflow:hidden; z-index:3;}
|
||||
.node-card .nh{ padding:8px 10px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:7px;}
|
||||
.node-card .nh .ndot{ width:8px; height:8px; border-radius:2px; background:#9a6bc0;}
|
||||
.node-card .nh .nt{ font-size:11px; color:var(--bright);}
|
||||
.node-card .nh .ny{ margin-left:auto; font-size:7.5px; color:var(--dim); border:1px solid var(--line); border-radius:8px; padding:2px 6px;}
|
||||
.node-card .nprev{ height:42px; background:linear-gradient(135deg,#3a2c52,#243042);}
|
||||
.node-card .np{ display:flex; align-items:center; gap:7px; padding:6px 10px; border-top:1px solid #20242c;}
|
||||
.node-card .np .k{ font-size:9px; color:var(--dim); width:42px;}
|
||||
.node-card .np .field{ flex:1; height:15px; border-radius:4px; background:var(--panel); border:1px solid var(--line); font-size:9px; color:var(--bright); display:flex; align-items:center; padding:0 6px;}
|
||||
.node-card .np .field.slider{ background:var(--line); position:relative;}
|
||||
.node-card .np .field.slider::after{ content:""; position:absolute; left:0;top:0;bottom:0; width:48%; background:var(--cyan); opacity:.5; border-radius:4px;}
|
||||
.wire-line{ position:absolute; top:48%; height:1.5px; background:var(--line); z-index:1;}
|
||||
.wire-line.l{ left:0; width:50px;} .wire-line.r{ right:0; width:50px; background:var(--cyan); opacity:.6;}
|
||||
.port-col{ position:absolute; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:9px; z-index:4;}
|
||||
.port-col.in{ left:8px;} .port-col.out{ right:8px;}
|
||||
.pchip{ width:54px; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:5px 6px; font-size:8px; color:var(--bright); line-height:1.25;}
|
||||
.pchip .pl{ color:var(--dim); font-size:7px; display:block; letter-spacing:.04em;}
|
||||
.pchip.wire{ border-color:var(--cyan); box-shadow:0 0 0 2px rgba(84,195,232,.18);}
|
||||
.swipehint{ position:absolute; top:50%; transform:translateY(-50%); font-size:8px; color:var(--dim); z-index:4;}
|
||||
.addnode{ position:absolute; left:50%; bottom:8px; transform:translateX(-50%); background:var(--panel-2); border:1px solid var(--line); border-radius:14px; padding:5px 12px; font-size:9px; color:var(--bright); z-index:6; white-space:nowrap;}
|
||||
.wirebanner{ position:absolute; top:8px; left:8px; right:96px; background:rgba(84,195,232,.14); border:1px solid var(--cyan); border-radius:8px; padding:5px 8px; font-size:8px; color:#bfe9f7; z-index:6; line-height:1.25;}
|
||||
|
||||
/* ---- patch (pair) view ---- */
|
||||
.patch{ position:absolute; left:12px; right:12px; top:50%; transform:translateY(-50%); background:var(--panel-2); border:1px solid var(--line); border-radius:12px; overflow:hidden; z-index:2; box-shadow:0 14px 30px -16px rgba(0,0,0,.85);}
|
||||
.patch .pthead{ display:flex; align-items:center; gap:6px; padding:8px 10px; border-bottom:1px solid var(--line); font-size:10px; color:var(--bright);}
|
||||
.patch .pthead .ty{ margin-left:auto; font-size:7.5px; color:var(--dim); border:1px solid var(--line); border-radius:8px; padding:2px 6px;}
|
||||
.ptrow{ display:grid; grid-template-columns:1fr 64px 1fr; align-items:center; padding:7px 10px; border-top:1px solid #20242c;}
|
||||
.ptrow .sp{ display:flex; align-items:center; gap:6px; justify-content:flex-end; font-size:9px; color:var(--bright);}
|
||||
.ptrow .dp{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--bright);}
|
||||
.ptrow .dp.muted, .ptrow .sp.muted{ color:var(--dim);}
|
||||
.ptrow .cable{ height:12px; position:relative;}
|
||||
.ptrow .cable.on::before{ content:""; position:absolute; left:0; right:0; top:50%; height:2px; background:var(--cyan); transform:translateY(-50%);}
|
||||
.dotport{ width:8px; height:8px; border-radius:50%; background:var(--cyan); border:1px solid #0c2a33; flex:0 0 auto;}
|
||||
.dotport.empty{ background:var(--line); border-color:var(--dim);}
|
||||
.patch .pthint{ padding:7px 10px; border-top:1px solid var(--line); font-size:8px; color:var(--dim);}
|
||||
|
||||
/* ---- conventional piano roll (landscape) ---- */
|
||||
.proll{ flex:1; position:relative; overflow:hidden; border-bottom:1px solid var(--line); background:repeating-linear-gradient(0deg,#13161c 0 13px,#181c23 13px 26px);}
|
||||
.proll .glab{ position:absolute; top:5px; left:8px; font-size:8px; color:var(--dim); z-index:2;}
|
||||
.proll .pn{ position:absolute; height:10px; border-radius:2px; background:linear-gradient(180deg,#caa15a,#a07c32);}
|
||||
.proll .ph{ position:absolute; top:0; bottom:0; width:1.5px; background:var(--amber); opacity:.7; z-index:2;}
|
||||
|
||||
/* ---- instrument workspace ---- */
|
||||
.workspace{ flex:1; position:relative; overflow:hidden; border-bottom:1px solid var(--line);
|
||||
background:repeating-linear-gradient(90deg,#13161c 0 7.14%, #181c23 7.14% 14.28%);}
|
||||
.workspace .now{ position:absolute; left:0; right:0; bottom:0; height:1.5px; background:var(--amber); opacity:.7; z-index:3;}
|
||||
.workspace .glab{ position:absolute; top:6px; left:8px; font-size:8px; color:var(--dim);}
|
||||
.nbar{ position:absolute; width:5.2%; border-radius:2px; background:linear-gradient(180deg,#caa15a,#a07c32); z-index:2;}
|
||||
.ws-toggle{ display:flex; gap:5px;}
|
||||
.ws-toggle .seg{ font-size:8.5px; padding:3px 8px; border:1px solid var(--line); border-radius:9px; color:var(--dim);}
|
||||
.ws-toggle .seg.on{ background:var(--cyan); color:#06222b; border-color:var(--cyan);}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){ *{ transition:none!important; animation:none!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sheet-head">
|
||||
<p class="eyebrow">Mobile port · concept plates</p>
|
||||
<div class="titleblock">
|
||||
<div class="tl-main">
|
||||
<h1>Unified-timeline studio — phone layout</h1>
|
||||
<p>One hero surface at a time, surface tabs along the top, and time controls grouped at the bottom: a fixed transport bar with the resizable timeline ribbon riding above it. Every desktop control stays reachable; almost none stay visible.</p>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
<div><span>CONSTRAINT</span><strong>Phone · single-pane</strong></div>
|
||||
<div><span>TABS</span><strong>Top · surfaces</strong></div>
|
||||
<div><span>SPINE</span><strong>Bottom · time</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-key">
|
||||
<b><span class="swatch" style="background:var(--amber)"></span>Time / playhead / transport</b>
|
||||
<b><span class="swatch" style="background:var(--cyan)"></span>Selection</b>
|
||||
<b><span class="swatch" style="background:var(--coral)"></span>Annotation</b>
|
||||
</div>
|
||||
|
||||
<!-- ============ PLATE 01 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 01</span>
|
||||
<h2>The spine & the resizable ribbon</h2>
|
||||
<span class="tag">Tabs top · time bottom · 3 snaps</span>
|
||||
</div>
|
||||
<p class="lede">Surface tabs sit along the top. At the bottom, the transport bar is fixed — the irreducible spine — and the timeline ribbon expands upward above it through three snaps, trading stage for tracks by degrees. It never hits zero on its own; the transport is always the floor.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="canvas-frame" style="inset:24px 30px;"></div>
|
||||
<div class="obj sel" style="left:96px; top:150px; width:74px; height:54px; background:#2c5482;"></div>
|
||||
</div>
|
||||
<div class="ribbon"><div class="grab"></div><div class="ruler"></div><div class="lane"><span class="gut">V1</span></div></div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:120px; top:42px;">1</span>
|
||||
<span class="hs" style="left:6px; bottom:48px;">2</span>
|
||||
<span class="hs" style="left:6px; bottom:8px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Peek</b> — ribbon collapsed to a sliver; transport still anchors the bottom.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero" style="flex:0 0 168px;">
|
||||
<div class="canvas-frame" style="inset:22px;"></div>
|
||||
<div class="obj sel" style="left:80px; top:78px; width:66px; height:46px; background:#2c5482;"></div>
|
||||
</div>
|
||||
<div class="ribbon" style="flex:1;">
|
||||
<div class="grab"></div><div class="ruler"></div>
|
||||
<div class="lane"><span class="gut">RASTER</span><div class="clip raster" style="left:50px; width:80px;"></div></div>
|
||||
<div class="lane"><span class="gut">VEC</span><div class="clip v sel" style="left:96px; width:64px;"></div></div>
|
||||
<div class="lane"><span class="gut">AUD</span><div class="clip a" style="left:60px; width:120px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">AUD</span><div class="clip a" style="left:130px; width:70px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Half</b> — arrange & trim across tracks while the stage stays in view.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Tabs own the top</h4>
|
||||
<p>Stage / Time / Nodes / Mixer / Tree. The active surface is home; the rest are one tap away. Moving them up clears the bottom for the controls your thumb actually lives on.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Ribbon expands upward</h4>
|
||||
<p>Drag the grabber up for more tracks, down for more stage. A continuous reveal, not a modal toggle — there's always a sliver left.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Transport = the fixed floor</h4>
|
||||
<p>Play, playhead time and a zoomed-out project scrub never move and never disappear. It's the irreducible spine; the ribbon is detail layered on top of it.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 02 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 02</span>
|
||||
<h2>Tweak mode — the pull model</h2>
|
||||
<span class="tag">Selection → inspector</span>
|
||||
</div>
|
||||
<p class="lede">You arrive with a full project and a vague target, so controls come <em>to</em> what you point at. Tap anything and its properties rise in a sheet above the transport. The header carries jump-to chips that reframe to another surface with the same object still selected.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="canvas-frame" style="inset:18px 22px 0;"></div>
|
||||
<div class="obj sel" style="left:108px; top:54px; width:78px; height:58px; background:#7a4f9c;"></div>
|
||||
<div class="handle" style="left:104px; top:50px;"></div>
|
||||
<div class="handle" style="left:182px; top:50px;"></div>
|
||||
<div class="handle" style="left:104px; top:108px;"></div>
|
||||
<div class="handle" style="left:182px; top:108px;"></div>
|
||||
</div>
|
||||
<div class="sheet-ui" style="height:236px;">
|
||||
<div class="grab"></div>
|
||||
<div class="sh-head"><span class="ico" style="background:#9a6bc0;"></span><span class="ttl">Ellipse · Vector layer</span><span class="ovf">⋯</span></div>
|
||||
<div class="chips"><span class="chip on">Properties</span><span class="chip">Timeline</span><span class="chip">Nodes</span><span class="chip">Automation</span></div>
|
||||
<div class="prop"><span class="k">Fill</span><div class="field"><span style="width:9px;height:9px;background:#9a6bc0;border-radius:2px;margin-right:6px;"></span>#9A6BC0</div></div>
|
||||
<div class="prop"><span class="k">Opacity</span><div class="field slider"></div></div>
|
||||
<div class="prop"><span class="k">Position</span><div class="field">x 108</div><div class="field">y 54</div></div>
|
||||
<div class="prop"><span class="k">Z-order</span><div class="field">Front ▾</div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:170px; top:74px;">1</span>
|
||||
<span class="hs" style="left:120px; bottom:250px;">2</span>
|
||||
<span class="hs" style="left:6px; bottom:8px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Half tier</b> shown — drag up for every advanced parameter, down to a 3-control peek. Transport stays pinned below.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Selection-driven</h4>
|
||||
<p>Tap selects and summons the sheet. Once selected, dragging the object <em>moves</em> it; dragging empty space pans. One chain: tap → inspect → move → long-press.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Jump-to chips = parity move</h4>
|
||||
<p><code>Timeline</code> · <code>Nodes</code> · <code>Automation</code> reframe to that surface with the object still held — replacing the desktop trick of seeing panes side-by-side.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Sheet floats over the floor</h4>
|
||||
<p>The inspector rises above the transport, never under it — so even mid-tweak you can scrub and see the playhead. Object actions like <code>send to back</code> live on the <code>⋯</code>.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot" style="border-style:dashed;">+</div><div>
|
||||
<h4>Two ways to find the thing</h4>
|
||||
<p>For "I don't know what I want to tweak": the <code>⌕</code> command palette and an outliner surface (the <code>Tree</code> tab) — a browsable map of the whole project.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 03 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 03</span>
|
||||
<h2>Sketch mode — the push model</h2>
|
||||
<span class="tag">New file · intent seeds</span>
|
||||
</div>
|
||||
<p class="lede">An empty project with a clearish intent, so a tool defines the surface and gets out of the way. "New" offers intents — the same non-binding menu as desktop. Picking one sets the hero surface and a minimal toolset; switching intent mid-session just swaps the hero while a real project quietly accretes underneath.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="intent-screen">
|
||||
<div class="new-h">Start something</div>
|
||||
<div class="new-sub">Sets your first surface — nothing is locked.</div>
|
||||
<div class="intent-grid">
|
||||
<div class="intent draw"><span class="gico"></span><span class="nm">Draw</span><span class="de">Full canvas · stylus · radial brushes</span></div>
|
||||
<div class="intent animate"><span class="gico"></span><span class="nm">Animate</span><span class="de">Stage + keyframe scrub strip</span></div>
|
||||
<div class="intent compose"><span class="gico"></span><span class="nm">Compose</span><span class="de">Instrument + arrangement ribbon</span></div>
|
||||
<div class="intent record"><span class="gico"></span><span class="nm">Record</span><span class="de">Big button · waveform fills screen</span></div>
|
||||
<div class="intent video"><span class="gico"></span><span class="nm">Edit video</span><span class="de">Clip bin + trim timeline</span></div>
|
||||
<div class="intent draw" style="opacity:.5"><span class="gico" style="background:var(--line)"></span><span class="nm">Blank</span><span class="de">Empty unified timeline</span></div>
|
||||
<div class="intent open"><span class="gico"></span><span class="nm">Open recent…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hs" style="left:120px; top:160px;">1</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Intent picker</b> — mirrors the desktop new-file flow; the seed steers nothing once content exists.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Intent seeds, state steers</h4>
|
||||
<p>At creation there's nothing else to go on, so intent is the right call. The moment content exists, the original type stops driving anything.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Home-on-open ≠ birth type</h4>
|
||||
<p>Re-opening lands on the file's <b>last-focused surface</b> + last selection — not the type it was born as. A music file that grew an animation layer shouldn't open music-shaped.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Lossless underneath</h4>
|
||||
<p>Every intent writes to the same document model. Whatever you rough out on the train reconstitutes as a full pane layout back on desktop.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 04 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 04</span>
|
||||
<h2>Music surface — the GarageBand fix</h2>
|
||||
<span class="tag">Expression + context, together</span>
|
||||
</div>
|
||||
<p class="lede">GarageBand denies you the <em>when</em> while you play. Here the instrument lives in the thumb zone with a compressed arrangement ribbon pinned above it — loop boundaries, the playhead sweeping toward the wrap, and squashed lanes of what you're playing against. The transport anchors the bottom as everywhere else.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:0 0 auto;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 64px,#2a8a5a 64px 65px,transparent 65px 128px,var(--line) 128px 129px);"></div>
|
||||
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:64px;"><div class="wav"></div></div><div class="clip a" style="left:106px; width:64px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">BASS</span><div class="clip a" style="left:42px; width:128px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut" style="color:var(--amber);">KEYS ●</span><div class="clip a sel" style="left:42px; width:42px; background:linear-gradient(180deg,#caa15a,#a07c32);"></div></div>
|
||||
</div>
|
||||
<div class="inst-bar"><span class="pill">Grand Piano ▾</span><span>Loop ⟳ 2 bars</span><span style="margin-left:auto;color:var(--coral);">● REC</span></div>
|
||||
<div class="workspace">
|
||||
<span class="glab">LIVE TAKE · KEYS</span>
|
||||
<div class="nbar" style="left:7.1%; bottom:0; height:30px;"></div>
|
||||
<div class="nbar" style="left:21.4%; bottom:16px; height:24px;"></div>
|
||||
<div class="nbar" style="left:35.7%; bottom:4px; height:40px;"></div>
|
||||
<div class="now"></div>
|
||||
</div>
|
||||
<div class="keys" style="flex:0 0 150px;">
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="bkey" style="left:9.5%"></div><div class="bkey" style="left:22%"></div>
|
||||
<div class="bkey" style="left:47%"></div><div class="bkey" style="left:59.5%"></div><div class="bkey" style="left:72%"></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
|
||||
<span class="hs" style="left:120px; top:52px;">1</span>
|
||||
<span class="hs" style="left:200px; top:96px;">2</span>
|
||||
<span class="hs" style="left:6px; bottom:8px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Record-against-context</b> — see the loop wrap and the other tracks while your thumbs are on the keys.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>The loop boundary is visible</h4>
|
||||
<p>Green markers show where the 2-bar loop wraps; the amber playhead sweeps toward it — the exact <em>when</em> GarageBand's modal switch hides.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Playing against the mix</h4>
|
||||
<p>Squashed drum/bass lanes stay on screen so you hear <em>and see</em> what your take lands over. The live KEYS lane fills as you record.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Same spine, same floor</h4>
|
||||
<p>Transport with bars-beats + tempo anchors the bottom. Drag the ribbon down for full arrangement; the Mixer is a portrait-native surface one tab away.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 05 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 05</span>
|
||||
<h2>Orientation is a per-surface reflow</h2>
|
||||
<span class="tag">Not an app-global mode</span>
|
||||
</div>
|
||||
<p class="lede">No surface forces a rotation — that's the user's wrist, not the app's call. But the same timeline data emphasises a different axis per orientation: landscape spends pixels on time resolution, portrait spends them on track count. Rotation preserves selection, playhead and zoom-center; it only reflows.</p>
|
||||
|
||||
<div class="stage-row" style="align-items:flex-start;">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:1;">
|
||||
<div class="ruler"></div>
|
||||
<div class="lane"><span class="gut">V1</span><div class="clip v" style="left:50px;width:60px;"></div></div>
|
||||
<div class="lane"><span class="gut">V2</span><div class="clip raster" style="left:70px;width:90px;"></div></div>
|
||||
<div class="lane"><span class="gut">RAS</span><div class="clip raster" style="left:50px;width:50px;"></div></div>
|
||||
<div class="lane"><span class="gut">A1</span><div class="clip a" style="left:48px;width:130px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A2</span><div class="clip a" style="left:90px;width:80px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A3</span><div class="clip a" style="left:60px;width:60px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A4</span><div class="clip a" style="left:120px;width:70px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">FX</span><div class="clip v" style="left:42px;width:40px;"></div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Portrait</b> — vertical room buys <b>more tracks</b>. Best for arranging, reordering, seeing structure.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone land"><div class="screen">
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:1;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 48px,var(--line) 48px 49px);"></div>
|
||||
<div class="lane"><span class="gut">V1</span><div class="clip v" style="left:60px;width:140px;"></div><div class="clip raster" style="left:210px;width:90px;"></div></div>
|
||||
<div class="lane"><span class="gut">A1</span><div class="clip a" style="left:50px;width:300px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A2</span><div class="clip a" style="left:120px;width:200px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div><span class="mini">⌕</span><span class="mini">⋯</span></div>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Landscape</b> — wide axis buys <b>time resolution</b>. Best for scrubbing, precise trims.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="min-width:200px;">
|
||||
<div class="note"><div class="dot">↻</div><div>
|
||||
<h4>Reflow, don't reset</h4>
|
||||
<p>Same selection, playhead and zoom-center — just re-emphasised. A rotate that throws away where you were is its own GarageBand-tier annoyance.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">⊞</div><div>
|
||||
<h4>Per-surface character</h4>
|
||||
<p>Stage follows the <em>project's</em> aspect; instrument prefers landscape for key width; mixer is portrait-native. The ribbon absorbs whatever the hero leaves over.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">🔒</div><div>
|
||||
<h4>Manual lock</h4>
|
||||
<p>People work lying down and propped at angles. The device <em>suggests</em>; the user can <em>pin</em>. Auto-rotate never fights a drawing gesture.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 06 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 06</span>
|
||||
<h2>Omnibutton & the gesture map</h2>
|
||||
<span class="tag">Tools vs commands · 7 + 1 meanings</span>
|
||||
</div>
|
||||
<p class="lede">The omnibutton produces <em>tools/objects</em>; the global menu holds <em>commands/destinations</em> — a clean tool-vs-command line. Below, the resolved stage gestures: seven meanings, no collisions, each reinforcing a desktop idiom rather than inventing a phone-only convention.</p>
|
||||
|
||||
<div class="stage-row" style="margin-bottom:30px;">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="canvas-frame" style="inset:20px;"></div>
|
||||
<div class="radial-wrap">
|
||||
<div class="scrim"></div>
|
||||
<div class="rad-btn" style="right:18px; bottom:190px;"><span class="g" style="background:var(--coral);"></span></div>
|
||||
<div class="rad-btn" style="right:64px; bottom:174px;"><span class="g" style="background:var(--cyan);"></span></div>
|
||||
<div class="rad-btn" style="right:96px; bottom:140px;"><span class="g" style="background:var(--amber);"></span></div>
|
||||
<div class="rad-btn" style="right:110px; bottom:98px;">Shape</div>
|
||||
<div class="omni"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ribbon"><div class="grab"></div><div class="ruler"></div><div class="lane"><span class="gut">V1</span></div></div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="right:6px; bottom:150px;">1</span>
|
||||
<span class="hs" style="right:30px; top:42px;">2</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Radial fans away from the finger</b> to dodge occlusion. Global commands live in the top <code>⋯</code>.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Omnibutton = tools/objects</h4>
|
||||
<p>Brushes, shapes, instruments, nodes — things you place or use. Long-press on empty space can summon this same create menu <em>at the point you pressed</em>.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Global menu = commands</h4>
|
||||
<p>Export, render, project settings, and the command palette itself live in the top <code>⋯</code>/<code>⌕</code>. If an item feels like either, it's usually a selection action in disguise → inspector.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot" style="border-style:dashed;">▤</div><div>
|
||||
<h4>Palette is the safety net</h4>
|
||||
<p>Every menu-bar verb has one contextual home <em>plus</em> the palette. That's what lets contextual homes hide rare commands without breaking parity.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gmap">
|
||||
<div class="gtable">
|
||||
<div class="grow"><div class="gg">tap object</div><div class="gm"><b>select</b> + summon inspector</div></div>
|
||||
<div class="grow"><div class="gg">tap empty</div><div class="gm">deselect</div></div>
|
||||
<div class="grow"><div class="gg">drag selected</div><div class="gm"><b>move</b> the object / clip</div></div>
|
||||
<div class="grow"><div class="gg">drag empty</div><div class="gm"><b>pan</b> the view (matches timeline scroll)</div></div>
|
||||
<div class="grow"><div class="gg">two-finger drag</div><div class="gm"><b>pan</b> — always, everywhere (escape valve)</div></div>
|
||||
<div class="grow"><div class="gg">pinch</div><div class="gm"><b>zoom</b> — time-zoom on timeline, spatial on stage</div></div>
|
||||
<div class="grow"><div class="gg">long-press object</div><div class="gm"><b>actions menu</b> for that object</div></div>
|
||||
<div class="grow"><div class="gg">long-press empty</div><div class="gm">create-here radial <span style="color:var(--coral)">(proposed)</span></div></div>
|
||||
<div class="grow"><div class="gg">double-tap object</div><div class="gm"><b>enter / edit</b> — go a level deeper</div></div>
|
||||
<div class="grow"><div class="gg">double-tap empty</div><div class="gm">zoom-to-fit</div></div>
|
||||
<div class="grow free"><div class="gg">double-tap-drag empty</div><div class="gm"><b>marquee select</b> — fast transient path</div></div>
|
||||
</div>
|
||||
<div class="notes" style="min-width:240px;">
|
||||
<div class="note"><div class="dot">▦</div><div>
|
||||
<h4>Two tiers for marquee</h4>
|
||||
<p>The <b>double-tap-drag</b> gesture is the fast, transient path. A sustained <b>marquee mode</b> covers heavy vector multi-select — and with the gesture in hand, it may not even need a dedicated button.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">◎</div><div>
|
||||
<h4>Origin disambiguates</h4>
|
||||
<p>Marquee is strictly empty-space-initiated. Double-tap-drag starting <em>on</em> an object never marquees — same spatial-zones logic as the ruler vs lanes.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">⊙</div><div>
|
||||
<h4>Crisp double-tap window</h4>
|
||||
<p>Empty space is where both <code>zoom-to-fit</code> and <code>marquee</code> branch off the same tap-tap, so tighten the detection window there specifically.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 07 — NODE EDITOR: FOCUS & PATCH ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 07</span>
|
||||
<h2>Node editor — focus & patch</h2>
|
||||
<span class="tag">Audio synthesis · port-to-port</span>
|
||||
</div>
|
||||
<p class="lede">For synthesis the graph is patch-heavy: several cables of one type running between modules. So ports are identified by <em>name</em> — gate, velocity, pitch — not just type; type only decides what <em>may</em> connect, the name decides which is which. Connections are wire-level, parallel cables between a single pair are normal, and one output can fan out. Focus mode tunes a module's parameters; patch mode runs the cables.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab on"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="nodewrap">
|
||||
<div class="minimap">
|
||||
<span class="mw" style="left:14px; top:16px; width:18px; transform:rotate(20deg);"></span>
|
||||
<span class="mw" style="left:40px; top:26px; width:20px;"></span>
|
||||
<span class="mn" style="left:8px; top:10px;"></span>
|
||||
<span class="mn on" style="left:30px; top:22px;"></span>
|
||||
<span class="mn" style="left:56px; top:30px;"></span>
|
||||
<span class="mn" style="left:34px; top:40px;"></span>
|
||||
</div>
|
||||
<div class="wire-line l"></div>
|
||||
<div class="wire-line r"></div>
|
||||
<div class="port-col in">
|
||||
<div class="pchip"><span class="pl">◂ gate</span>MIDI→CV.gate</div>
|
||||
<div class="pchip"><span class="pl">◂ velocity</span>MIDI→CV.vel</div>
|
||||
</div>
|
||||
<div class="node-card">
|
||||
<div class="nh"><span class="ndot" style="background:#5aa0c2;"></span><span class="nt">ADSR</span><span class="ny">ENV · CV</span></div>
|
||||
<div class="nprev" style="background:linear-gradient(135deg,#243042,#2c3a26);"></div>
|
||||
<div class="np"><span class="k">Attack</span><div class="field slider"></div></div>
|
||||
<div class="np"><span class="k">Decay</span><div class="field">120 ms</div></div>
|
||||
<div class="np"><span class="k">Sustain</span><div class="field slider"></div></div>
|
||||
</div>
|
||||
<div class="port-col out">
|
||||
<div class="pchip"><span class="pl">env ▸</span>VCA.in</div>
|
||||
</div>
|
||||
<div class="addnode">+ Add node · ⌕ Search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:6px; top:232px;">1</span>
|
||||
<span class="hs" style="right:34px; top:54px;">2</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Focus</b> — one module's parameters; its named ports are travel chips. Input chips name the <em>remote port</em>, so two cables from the same node never collide.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab on"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="patch">
|
||||
<div class="pthead"><span>Patch</span><span style="color:var(--dim);">MIDI→CV ▸ ADSR</span><span class="ty">CV</span></div>
|
||||
<div class="ptrow"><div class="sp">pitch <span class="dotport"></span></div><div class="cable"></div><div class="dp muted"><span class="dotport empty"></span>→ Osc 1</div></div>
|
||||
<div class="ptrow"><div class="sp">velocity <span class="dotport"></span></div><div class="cable on"></div><div class="dp"><span class="dotport"></span>velocity</div></div>
|
||||
<div class="ptrow"><div class="sp">gate <span class="dotport"></span></div><div class="cable on"></div><div class="dp"><span class="dotport"></span>gate</div></div>
|
||||
<div class="pthint">tap a source port, then a target — both are CV, so the <b>name</b> decides the match</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:122px; top:250px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Patch</b> — two modules as port lists, each cable a row. The CV pair (gate, velocity) is just two rows; pitch fans off to the oscillator.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Ports are named, not just typed</h4>
|
||||
<p>Input chips read the remote endpoint as <code>node.port</code> — <code>MIDI→CV.gate</code>, <code>MIDI→CV.vel</code> — so two cables from one node stay distinct even though both are CV. Type gates compatibility; the name carries identity.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Focus still travels & renders live</h4>
|
||||
<p>Tap a chip to jump to that endpoint; the minimap keeps you placed. The audio graph evaluates at the playhead, so transport stays — scrub and the envelope previews where you are.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Patch mode for cabling</h4>
|
||||
<p>A pair (or small cluster) shown as two port lists; each cable is a row. Parallel cables between the same pair are normal, and an output fans out (pitch → Osc). Tap a source port then a target — compatible ports light up, incompatible dim. This is the modular workflow, not one-node-at-a-time.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot" style="border-style:dashed;">!</div><div>
|
||||
<h4>Honest scope</h4>
|
||||
<p>Phone is tune-a-module and patch-a-few; large graph restructuring stays a desktop job. Reachable for parity via the palette and these two views — without faking a tiny pannable canvas of boxes.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 08 — KEYBOARD CAP & RECLAIMED SPACE ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 08</span>
|
||||
<h2>Keyboard cap & the reclaimed space</h2>
|
||||
<span class="tag">Portrait · keys ≤ ⅓</span>
|
||||
</div>
|
||||
<p class="lede">Key <em>width</em> governs playability, and width is set by the screen's short axis — so past about a third of a portrait screen, taller keys add nothing. The resize catches at that cap; pulling further reveals an instrument workspace above the keys instead. By default it's a note view of the current part, pitch-aligned to the keys; toggle it to performance controllers. Landscape has no room for this, so it's a portrait-only affordance.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<!-- macro context -->
|
||||
<div class="ribbon" style="flex:0 0 auto;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 64px,#2a8a5a 64px 65px,transparent 65px 128px,var(--line) 128px 129px);"></div>
|
||||
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:64px;"><div class="wav"></div></div><div class="clip a" style="left:106px; width:64px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">BASS</span><div class="clip a" style="left:42px; width:128px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<!-- instrument bar at the top of the surface (above the workspace, not between notes/keys) -->
|
||||
<div class="inst-bar">
|
||||
<span class="pill">Grand Piano ▾</span>
|
||||
<div class="ws-toggle"><span class="seg on">Notes</span><span class="seg">Controllers</span></div>
|
||||
<span style="margin-left:auto;color:var(--coral);">● REC</span>
|
||||
</div>
|
||||
<!-- reclaimed workspace: note view, aligned to keys -->
|
||||
<div class="workspace">
|
||||
<span class="glab">CURRENT PART · KEYS</span>
|
||||
<div class="nbar" style="left:7.1%; bottom:0; height:34px;"></div>
|
||||
<div class="nbar" style="left:21.4%; bottom:18px; height:26px;"></div>
|
||||
<div class="nbar" style="left:35.7%; bottom:6px; height:48px;"></div>
|
||||
<div class="nbar" style="left:50%; bottom:28px; height:22px;"></div>
|
||||
<div class="nbar" style="left:64.2%; bottom:0; height:40px;"></div>
|
||||
<div class="nbar" style="left:78.5%; bottom:14px; height:30px;"></div>
|
||||
<div class="now"></div>
|
||||
</div>
|
||||
<!-- keys capped ~1/3 -->
|
||||
<div class="keys" style="flex:0 0 150px;">
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="bkey" style="left:9.5%"></div><div class="bkey" style="left:22%"></div>
|
||||
<div class="bkey" style="left:47%"></div><div class="bkey" style="left:59.5%"></div><div class="bkey" style="left:72%"></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
|
||||
<span class="hs" style="left:120px; top:150px;">1</span>
|
||||
<span class="hs" style="left:120px; bottom:188px;">2</span>
|
||||
<span class="hs" style="left:96px; bottom:158px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Macro → micro → input</b>: arrangement context up top, note workspace in the middle, capped keys in the thumb zone.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone land"><div class="screen">
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:0 0 auto;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 80px,#2a8a5a 80px 81px,transparent 81px 160px,var(--line) 160px 161px);"></div>
|
||||
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:130px;"><div class="wav"></div></div><div class="clip a" style="left:180px; width:130px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<div class="inst-bar"><span class="pill">Grand Piano ▾</span><div class="ws-toggle"><span class="seg">Keys</span><span class="seg on">Notes</span></div><span style="margin-left:auto;color:var(--coral);">● REC</span></div>
|
||||
<div class="proll">
|
||||
<span class="glab">PIANO ROLL · time ▸</span>
|
||||
<div class="pn" style="left:12%; top:30px; width:14%;"></div>
|
||||
<div class="pn" style="left:30%; top:56px; width:10%;"></div>
|
||||
<div class="pn" style="left:44%; top:17px; width:18%;"></div>
|
||||
<div class="pn" style="left:46%; top:82px; width:8%;"></div>
|
||||
<div class="pn" style="left:66%; top:43px; width:12%;"></div>
|
||||
<div class="pn" style="left:80%; top:69px; width:10%;"></div>
|
||||
<div class="ph" style="left:44%;"></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
|
||||
<span class="hs" style="left:200px; top:42px;">4</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Landscape · Notes</b> — standalone, the editor flips to a conventional piano roll (pitch ↕, time ▸) to spend the wide axis on time. The context ribbon still rides on top.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>The cap, and what fills it</h4>
|
||||
<p>Keys grow to ~⅓ then stop; continued upward drag reveals the workspace. The keyboard holds its ergonomic height — the new room opens <em>above</em> it. Same continuous-reveal as the timeline ribbon.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Default: see what you play</h4>
|
||||
<p>A note view of the current part, each column sitting over its key below (a waterfall toward the amber <em>now</em> line). The see-what-you're-doing principle the GarageBand port breaks — and editable in place.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Or perform: Controllers</h4>
|
||||
<p>Toggle the zone to pitch/mod strips, an XY pad, an arpeggiator — the other natural use of space above keys. The same drag, a different occupant.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">4</div><div>
|
||||
<h4>Landscape splits them — and reflows</h4>
|
||||
<p>No room to stack, so keys and notes become a <b>Keys / Notes</b> toggle, with the context ribbon persisting in both — you lose seeing keys-and-notes together, never the <em>when</em>. And the note view itself reflows: Synthesia-style (pitch ↔, aligned to keys) when it sits above the keyboard in portrait; a conventional piano roll (pitch ↕, time ▸) when it's standalone in landscape and the wide axis is better spent on time. Rotate back and your part, playhead and selection carry across.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="foot">
|
||||
<span>UNIFIED-TIMELINE STUDIO · PHONE CONCEPT PLATES 01–08</span>
|
||||
<span>WIREFRAME — LAYOUT & GESTURE INTENT, NOT FINAL VISUAL</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue