diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 3bb7c96..81d8b10 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use clap::Parser; use uuid::Uuid; +mod mobile; mod panes; use panes::{PaneInstance, PaneRenderer}; @@ -197,8 +198,17 @@ fn main() -> eframe::Result { } }; + // When developing the mobile UI on desktop (LB_MOBILE_UI), open a phone-aspect window so + // the shell can be exercised at a realistic size. Gating the shell itself is done at + // render time on the flag, not on window aspect. + let initial_size = if mobile::is_mobile_env() { + [402.0, 874.0] // iPhone-ish portrait + } else { + [1920.0, 1080.0] + }; + let mut viewport_builder = egui::ViewportBuilder::default() - .with_inner_size([1920.0, 1080.0]) + .with_inner_size(initial_size) .with_title("Lightningbeam Editor") .with_app_id("lightningbeam-editor"); // Set app_id for Wayland @@ -942,6 +952,13 @@ struct EditorApp { hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal) selected_pane: Option, // Currently selected pane for editing split_preview_mode: SplitPreviewMode, + // === Mobile / phone UI (developed on desktop behind LB_MOBILE_UI) === + /// True if the mobile shell is requested via the env var (read once at startup). + mobile_ui: bool, + /// Runtime override of the mobile flag (None = follow `mobile_ui`). + mobile_ui_override: Option, + /// Persistent state for the mobile shell (active surface, ribbon tier). + mobile_state: mobile::MobileState, icon_cache: IconCache, tool_icon_cache: ToolIconCache, focus_icon_cache: FocusIconCache, // Focus card icons (start screen) @@ -1297,6 +1314,9 @@ impl EditorApp { hovered_divider: None, selected_pane: None, split_preview_mode: SplitPreviewMode::default(), + mobile_ui: mobile::is_mobile_env(), + mobile_ui_override: None, + mobile_state: mobile::MobileState::default(), icon_cache: IconCache::new(), tool_icon_cache: ToolIconCache::new(), focus_icon_cache: FocusIconCache::new(), @@ -6584,22 +6604,34 @@ impl eframe::App for EditorApp { } // Build the per-frame context (single source of truth for SharedPaneState, - // shared with the mobile path). The bundle borrows `self` + `scratch`; it - // is dropped before the post-render drain below re-touches them. + // shared by the desktop and mobile paths). The bundle borrows `self` + `scratch`; + // it is dropped before the post-render drain below re-touches them. + let is_mobile = self.mobile_active(); let mut bundle = self.build_frame(&mut scratch); - render_layout_node( - ui, - bundle.current_layout, - available_rect, - bundle.drag_state, - bundle.hovered_divider, - bundle.selected_pane, - &mut layout_action, - bundle.split_preview_mode, - &Vec::new(), // Root path - &mut bundle.rc, - ); + if is_mobile { + // Phone shell: a single hero surface + top tabs + resizable timeline ribbon + // + a fixed transport floor, reusing the same panes the desktop layout uses. + mobile::render_mobile_shell( + ui, + available_rect, + &mut bundle.rc, + bundle.mobile_state, + ); + } else { + render_layout_node( + ui, + bundle.current_layout, + available_rect, + bundle.drag_state, + bundle.hovered_divider, + bundle.selected_pane, + &mut layout_action, + bundle.split_preview_mode, + &Vec::new(), // Root path + &mut bundle.rc, + ); + } drop(bundle); // Process collected effect thumbnail requests @@ -7111,9 +7143,15 @@ struct FrameBundle<'a> { hovered_divider: &'a mut Option<(NodePath, bool)>, selected_pane: &'a mut Option, split_preview_mode: &'a mut SplitPreviewMode, + mobile_state: &'a mut mobile::MobileState, } impl EditorApp { + /// Whether the mobile shell is active this frame (runtime override wins over the env flag). + fn mobile_active(&self) -> bool { + self.mobile_ui_override.unwrap_or(self.mobile_ui) + } + /// Build the per-frame [`FrameBundle`]. The returned bundle borrows `self` and /// `scratch` for `'a`; it must be dropped before the post-render drain touches /// `self`/`scratch` again. This is the single source of truth for `SharedPaneState`. @@ -7225,6 +7263,7 @@ impl EditorApp { hovered_divider: &mut self.hovered_divider, selected_pane: &mut self.selected_pane, split_preview_mode: &mut self.split_preview_mode, + mobile_state: &mut self.mobile_state, } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs new file mode 100644 index 0000000..56c76a0 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -0,0 +1,229 @@ +//! Mobile / phone UI shell. +//! +//! Developed on desktop behind the `LB_MOBILE_UI` env var (see [`is_mobile_env`]). +//! The shell reuses the existing egui panes (`PaneInstance` / `render_content`) for its +//! "hero" surfaces, composing them as a single surface at a time with a top tab bar, a +//! resizable timeline ribbon, and a fixed transport "floor" at the bottom — per the +//! `phone-ui-sketches.html` wireframe spec. +//! +//! Not every pane becomes a surface: per the spec the Toolbar becomes the omnibutton and +//! the Infopanel becomes the selection inspector (later phases). The tab list here mirrors +//! the *mobile-relevant* subset of `PaneType`. + +use eframe::egui; +use lightningbeam_core::pane::PaneType; + +use crate::panes::NodePath; +use crate::RenderContext; + +mod ribbon; +mod surface; +mod topbar; +mod transport; + +/// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are +/// built from small child indices, so prefixing with `usize::MAX` guarantees mobile slots +/// never alias a real layout path in the shared `pane_instances` map. +pub const MOBILE_NS: usize = usize::MAX; + +const TOPBAR_H: f32 = 50.0; +const TRANSPORT_H: f32 = 60.0; +const GRABBER_H: f32 = 16.0; + +/// Returns true if the mobile UI is requested via the `LB_MOBILE_UI` env var. +/// Any non-empty value other than "0" enables it. +pub fn is_mobile_env() -> bool { + std::env::var("LB_MOBILE_UI") + .map(|v| !v.is_empty() && v != "0") + .unwrap_or(false) +} + +/// The hero surfaces selectable from the top tab bar. These mirror the pane list minus the +/// panes that become other mobile affordances (Toolbar → omnibutton, Infopanel → inspector). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MobileSurface { + Stage, + Time, + Nodes, + Mixer, + Tree, +} + +impl MobileSurface { + /// All surfaces, in tab order. + pub const TABS: [MobileSurface; 5] = [ + MobileSurface::Stage, + MobileSurface::Time, + MobileSurface::Nodes, + MobileSurface::Mixer, + MobileSurface::Tree, + ]; + + /// The existing pane that backs this surface. Phase 1 reuses panes directly; the Nodes + /// surface is a placeholder for the focus/patch rework (it currently shows the desktop + /// node editor) and Mixer maps to the closest existing audio pane. + pub fn pane_type(self) -> PaneType { + match self { + MobileSurface::Stage => PaneType::Stage, + MobileSurface::Time => PaneType::Timeline, + MobileSurface::Nodes => PaneType::NodeEditor, + MobileSurface::Mixer => PaneType::VirtualPiano, + MobileSurface::Tree => PaneType::Outliner, + } + } + + pub fn label(self) -> &'static str { + match self { + MobileSurface::Stage => "Stage", + MobileSurface::Time => "Time", + MobileSurface::Nodes => "Nodes", + MobileSurface::Mixer => "Mixer", + MobileSurface::Tree => "Tree", + } + } + + fn index(self) -> usize { + match self { + MobileSurface::Stage => 0, + MobileSurface::Time => 1, + MobileSurface::Nodes => 2, + MobileSurface::Mixer => 3, + MobileSurface::Tree => 4, + } + } + + /// Stable `pane_instances` key for this surface's cached pane. + fn path(self) -> NodePath { + vec![MOBILE_NS, self.index()] + } +} + +/// How far the timeline ribbon is expanded. The transport floor is always present, so the +/// ribbon never collapses to zero — `Peek` is the floor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RibbonTier { + Peek, + Half, + Full, +} + +impl RibbonTier { + /// Ribbon body height (excluding the grabber) for a given available middle-region + /// height (the space between the top bar and the transport floor). + fn height(self, region_h: f32) -> f32 { + match self { + RibbonTier::Peek => 96.0_f32.min(region_h * 0.45), + RibbonTier::Half => region_h * 0.45, + RibbonTier::Full => region_h * 0.72, + } + } + + fn snap_from(region_h: f32, target_h: f32) -> RibbonTier { + // Snap to whichever tier height is closest to the dragged target. + let candidates = [RibbonTier::Peek, RibbonTier::Half, RibbonTier::Full]; + *candidates + .iter() + .min_by(|a, b| { + let da = (a.height(region_h) - target_h).abs(); + let db = (b.height(region_h) - target_h).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(&RibbonTier::Peek) + } +} + +/// Persistent mobile-shell UI state, cached on `EditorApp`. +pub struct MobileState { + pub active_surface: MobileSurface, + pub ribbon_tier: RibbonTier, + /// Transient live-drag offset (px) applied to the ribbon height while the grabber is + /// held; folded into `ribbon_tier` on release. + pub ribbon_drag: f32, +} + +impl Default for MobileState { + fn default() -> Self { + Self { + active_surface: MobileSurface::Stage, + ribbon_tier: RibbonTier::Peek, + ribbon_drag: 0.0, + } + } +} + +/// Render the whole mobile shell into `available_rect`. Reuses `rc` (the shared +/// `RenderContext`) for all pane content and `state` for the persistent shell state. +pub fn render_mobile_shell( + ui: &mut egui::Ui, + available_rect: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, +) { + // Background (wireframe device color; the bands below paint over most of it). + let bg = egui::Color32::from_rgb(0x14, 0x16, 0x1b); + ui.painter().rect_filled(available_rect, 0.0, bg); + + let top = available_rect.top(); + let bottom = available_rect.bottom(); + let left = available_rect.left(); + let right = available_rect.right(); + + // Fixed bands. + let topbar_rect = egui::Rect::from_min_max( + egui::pos2(left, top), + egui::pos2(right, top + TOPBAR_H), + ); + let transport_rect = egui::Rect::from_min_max( + egui::pos2(left, bottom - TRANSPORT_H), + egui::pos2(right, bottom), + ); + + // Middle region between the top bar and the transport floor. + let region_top = topbar_rect.bottom(); + let region_bottom = transport_rect.top(); + let region_h = (region_bottom - region_top).max(0.0); + + // The ribbon is hidden when the Time surface is the hero (it already *is* the timeline). + let show_ribbon = state.active_surface != MobileSurface::Time && region_h > GRABBER_H + 40.0; + + let (hero_rect, ribbon_rects) = if show_ribbon { + let base_h = state.ribbon_tier.height(region_h); + let ribbon_h = (base_h + state.ribbon_drag) + .clamp(40.0, region_h - GRABBER_H - 80.0); + let grabber_top = region_bottom - ribbon_h - GRABBER_H; + let grabber_rect = egui::Rect::from_min_max( + egui::pos2(left, grabber_top), + egui::pos2(right, grabber_top + GRABBER_H), + ); + let ribbon_body = egui::Rect::from_min_max( + egui::pos2(left, grabber_rect.bottom()), + egui::pos2(right, region_bottom), + ); + let hero = egui::Rect::from_min_max( + egui::pos2(left, region_top), + egui::pos2(right, grabber_top), + ); + (hero, Some((grabber_rect, ribbon_body))) + } else { + let hero = egui::Rect::from_min_max( + egui::pos2(left, region_top), + egui::pos2(right, region_bottom), + ); + (hero, None) + }; + + // Hero surface (reuses an existing pane full-bleed). + let surface = state.active_surface; + surface::render_surface_fullbleed(ui, hero_rect, &surface.path(), surface.pane_type(), rc); + + // Resizable timeline ribbon. + if let Some((grabber_rect, ribbon_body)) = ribbon_rects { + ribbon::render(ui, grabber_rect, ribbon_body, region_h, state, rc); + } + + // Top tab bar (drawn after the hero so its hit area wins along the top edge). + topbar::render(ui, topbar_rect, state, rc); + + // Transport floor (drawn last = always on top, the persistent spine). + transport::render(ui, transport_rect, &mut rc.shared); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/ribbon.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/ribbon.rs new file mode 100644 index 0000000..ee6a46a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/ribbon.rs @@ -0,0 +1,57 @@ +//! Resizable timeline ribbon. A grabber strip drags the ribbon through three snap tiers +//! (Peek / Half / Full); the ribbon body reuses the existing `TimelinePane` full-bleed. +//! The transport floor below it is always present, so the ribbon never collapses to zero. + +use eframe::egui; +use lightningbeam_core::pane::PaneType; + +use super::{surface, MobileState, RibbonTier, MOBILE_NS}; +use crate::panes::NodePath; +use crate::RenderContext; + +const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); +const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); + +/// Stable `pane_instances` key for the ribbon's timeline — distinct from the Time surface so +/// their zoom/scroll state don't fight. +fn ribbon_path() -> NodePath { + vec![MOBILE_NS, 100] +} + +pub fn render( + ui: &mut egui::Ui, + grabber_rect: egui::Rect, + ribbon_body: egui::Rect, + region_h: f32, + state: &mut MobileState, + rc: &mut RenderContext, +) { + // Grabber strip. + let painter = ui.painter_at(grabber_rect); + painter.rect_filled(grabber_rect, 0.0, C_PANEL); + painter.hline(grabber_rect.x_range(), grabber_rect.top(), egui::Stroke::new(1.0, C_LINE)); + // Handle pill. + let pill = egui::Rect::from_center_size(grabber_rect.center(), egui::vec2(34.0, 4.0)); + painter.rect_filled(pill, 2.0, C_LINE); + + let resp = ui.interact( + grabber_rect, + ui.id().with("mobile_ribbon_grabber"), + egui::Sense::drag(), + ); + if resp.dragged() { + // Dragging up (negative y) expands the ribbon. + state.ribbon_drag -= resp.drag_delta().y; + } + if resp.drag_stopped() { + let target_h = state.ribbon_tier.height(region_h) + state.ribbon_drag; + state.ribbon_tier = RibbonTier::snap_from(region_h, target_h); + state.ribbon_drag = 0.0; + } + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + + // Ribbon body = the timeline, full-bleed. + surface::render_surface_fullbleed(ui, ribbon_body, &ribbon_path(), PaneType::Timeline, rc); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/surface.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/surface.rs new file mode 100644 index 0000000..7f2fbba --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/surface.rs @@ -0,0 +1,33 @@ +//! Full-bleed hero surface rendering: maps a mobile surface to an existing pane and renders +//! its content into a rect with no header/divider chrome. This is the de-chromed core of +//! `render_pane` (main.rs) — see the get-or-create + `render_content` pattern there. + +use eframe::egui; +use lightningbeam_core::pane::PaneType; + +use crate::panes::{NodePath, PaneInstance, PaneRenderer}; +use crate::RenderContext; + +/// Render `pane_type` full-bleed into `rect`, reusing (or creating) the cached pane instance +/// keyed by `path`. The pane clips its own drawing to `rect`, so no extra clipping is needed. +pub fn render_surface_fullbleed( + ui: &mut egui::Ui, + rect: egui::Rect, + path: &NodePath, + pane_type: PaneType, + rc: &mut RenderContext, +) { + // Get-or-create the instance for this slot (recreate if the type changed). + let needs_new = rc + .pane_instances + .get(path) + .map(|inst| inst.pane_type() != pane_type) + .unwrap_or(true); + if needs_new { + rc.pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); + } + + if let Some(inst) = rc.pane_instances.get_mut(path) { + inst.render_content(ui, rect, path, &mut rc.shared); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs new file mode 100644 index 0000000..d71b1b2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs @@ -0,0 +1,70 @@ +//! Top surface-tab bar. One tap switches the hero surface. Mirrors the wireframe's top tabs +//! (Stage / Time / Nodes / Mixer / Tree); the active tab is marked with an amber underline. + +use eframe::egui; + +use super::{MobileState, MobileSurface}; +use crate::RenderContext; + +// Wireframe palette. +const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); +const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); +const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); +const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x7c, 0x86, 0x93); +const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); + +pub fn render( + ui: &mut egui::Ui, + rect: egui::Rect, + state: &mut MobileState, + _rc: &mut RenderContext, +) { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, C_PANEL); + // Bottom rule. + painter.hline(rect.x_range(), rect.bottom(), egui::Stroke::new(1.0, C_LINE)); + + let tabs = MobileSurface::TABS; + let tab_w = rect.width() / tabs.len() as f32; + + for (i, surface) in tabs.iter().enumerate() { + let tab_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + i as f32 * tab_w, rect.top()), + egui::pos2(rect.left() + (i as f32 + 1.0) * tab_w, rect.bottom()), + ); + let id = ui.id().with(("mobile_tab", i)); + let resp = ui.interact(tab_rect, id, egui::Sense::click()); + if resp.clicked() { + state.active_surface = *surface; + } + + let active = state.active_surface == *surface; + let color = if active { + C_AMBER + } else if resp.hovered() { + C_BRIGHT + } else { + C_DIM + }; + painter.text( + tab_rect.center(), + egui::Align2::CENTER_CENTER, + surface.label(), + egui::FontId::proportional(13.0), + color, + ); + if active { + // Underline marking the active surface. + let y = tab_rect.bottom() - 1.5; + painter.hline( + (tab_rect.left() + 10.0)..=(tab_rect.right() - 10.0), + y, + egui::Stroke::new(2.0, C_AMBER), + ); + } + // Vertical separators between tabs. + if i > 0 { + painter.vline(tab_rect.left(), rect.y_range(), egui::Stroke::new(1.0, C_LINE)); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs new file mode 100644 index 0000000..1623b72 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs @@ -0,0 +1,106 @@ +//! The transport "floor": the always-present bottom bar with play/pause, the timecode, and a +//! project scrub. Wired to the audio controller exactly like `TimelinePane`'s header. + +use eframe::egui; + +use crate::panes::SharedPaneState; + +const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); +const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); +const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); +const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); +const C_DARK: egui::Color32 = egui::Color32::from_rgb(0x1b, 0x13, 0x0a); + +pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, C_PANEL); + painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE)); + + let cy = rect.center().y; + + // --- Play / pause button (circle on the left) --- + let btn_r = 18.0; + let btn_center = egui::pos2(rect.left() + 20.0 + btn_r, cy); + let btn_rect = egui::Rect::from_center_size(btn_center, egui::vec2(btn_r * 2.0, btn_r * 2.0)); + let btn_resp = ui.interact(btn_rect, ui.id().with("mobile_transport_play"), egui::Sense::click()); + painter.circle_filled(btn_center, btn_r, C_AMBER); + let glyph = if *shared.is_playing { "⏸" } else { "▶" }; + painter.text( + btn_center, + egui::Align2::CENTER_CENTER, + glyph, + egui::FontId::proportional(16.0), + C_DARK, + ); + if btn_resp.clicked() { + *shared.is_playing = !*shared.is_playing; + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + if *shared.is_playing { + controller.seek(*shared.playback_time); + controller.play(); + } else { + controller.pause(); + } + } + } + + // --- Timecode (MM:SS:FF) --- + let fps = shared.action_executor.document().framerate.max(1.0); + let tc = format_timecode(*shared.playback_time, fps); + let tc_left = btn_rect.right() + 12.0; + painter.text( + egui::pos2(tc_left, cy), + egui::Align2::LEFT_CENTER, + &tc, + egui::FontId::monospace(13.0), + C_BRIGHT, + ); + let tc_width = 78.0; + + // --- Project scrub (fills the remaining width) --- + let duration = shared.action_executor.document().duration.max(1.0); + let scrub_left = tc_left + tc_width; + let scrub_rect = egui::Rect::from_min_max( + egui::pos2(scrub_left, cy - 3.0), + egui::pos2(rect.right() - 14.0, cy + 3.0), + ); + let scrub_resp = ui.interact( + scrub_rect.expand2(egui::vec2(0.0, 12.0)), + ui.id().with("mobile_transport_scrub"), + egui::Sense::click_and_drag(), + ); + painter.rect_filled(scrub_rect, 3.0, C_LINE); + let frac = (*shared.playback_time / duration).clamp(0.0, 1.0) as f32; + let filled = egui::Rect::from_min_max( + scrub_rect.min, + egui::pos2(scrub_rect.left() + scrub_rect.width() * frac, scrub_rect.bottom()), + ); + painter.rect_filled(filled, 3.0, C_AMBER.gamma_multiply(0.5)); + let head_x = scrub_rect.left() + scrub_rect.width() * frac; + painter.vline( + head_x, + (scrub_rect.top() - 4.0)..=(scrub_rect.bottom() + 4.0), + egui::Stroke::new(2.0, C_AMBER), + ); + + if (scrub_resp.dragged() || scrub_resp.clicked()) && scrub_rect.width() > 0.0 { + if let Some(pos) = scrub_resp.interact_pointer_pos() { + let f = ((pos.x - scrub_rect.left()) / scrub_rect.width()).clamp(0.0, 1.0) as f64; + let new_time = f * duration; + *shared.playback_time = new_time; + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.seek(new_time); + } + } + } +} + +fn format_timecode(seconds: f64, fps: f64) -> String { + let total = seconds.max(0.0); + let minutes = (total / 60.0).floor() as u32; + let secs = (total % 60.0).floor() as u32; + let frames = ((total - total.floor()) * fps).floor() as u32; + format!("{:02}:{:02}:{:02}", minutes, secs, frames) +}