diff --git a/Cargo.lock b/Cargo.lock index bdd24e0..784b336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,11 +730,12 @@ dependencies = [ [[package]] name = "hyperdeck-plugin" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "base64 0.22.1", "hyperdeck-pool", + "hyperdeck-tokio", "serde", "serde_json", "streamdeck-plugin", @@ -746,7 +747,7 @@ dependencies = [ [[package]] name = "hyperdeck-pool" -version = "0.1.0" +version = "0.2.0" dependencies = [ "hyperdeck-tokio", "serde", diff --git a/Cargo.toml b/Cargo.toml index b9acaba..dc116c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/hyperdeck-pool", "crates/hyperdeck-plugin"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.99" license = "MIT OR Apache-2.0" diff --git a/crates/hyperdeck-plugin/Cargo.toml b/crates/hyperdeck-plugin/Cargo.toml index 52b0a21..aa1ce6d 100644 --- a/crates/hyperdeck-plugin/Cargo.toml +++ b/crates/hyperdeck-plugin/Cargo.toml @@ -23,6 +23,7 @@ path = "src/bin/typegen.rs" anyhow = "1" base64 = "0.22" hyperdeck-pool = { path = "../hyperdeck-pool" } +hyperdeck-tokio.workspace = true serde.workspace = true serde_json.workspace = true streamdeck-plugin = { version = "0.1", features = ["macros", "typegen"] } diff --git a/crates/hyperdeck-plugin/src/actions.rs b/crates/hyperdeck-plugin/src/actions.rs index a5c4e2f..d0daa0a 100644 --- a/crates/hyperdeck-plugin/src/actions.rs +++ b/crates/hyperdeck-plugin/src/actions.rs @@ -1,7 +1,7 @@ use serde_json::Value; use streamdeck_plugin::{ ActionContext, ActionPayload, DialRotatePayload, EncoderAction, KeypadAction, Result, - TouchTapPayload, streamdeck_action, + streamdeck_action, }; use crate::contracts::ActionSettings; @@ -145,15 +145,6 @@ macro_rules! hyperdeck_dial { Ok(()) } - async fn on_touch_tap( - &mut self, - _payload: &TouchTapPayload, - ctx: &ActionContext<'_, Self::Settings, Self::State>, - ) -> Result<()> { - ctx.state().dial_touch(&ctx.identity().context); - Ok(()) - } - async fn on_property_inspector_did_appear( &mut self, ctx: &ActionContext<'_, Self::Settings, Self::State>, @@ -218,16 +209,6 @@ hyperdeck_key!( "dev.mikanseilaboratory.bmd.hyperdeck.preview", ActionKind::Preview ); -hyperdeck_key!( - LoopAction, - "dev.mikanseilaboratory.bmd.hyperdeck.loop", - ActionKind::Loop -); -hyperdeck_key!( - SingleClipAction, - "dev.mikanseilaboratory.bmd.hyperdeck.singleclip", - ActionKind::SingleClip -); hyperdeck_key!( SlotAction, "dev.mikanseilaboratory.bmd.hyperdeck.slot", @@ -461,8 +442,6 @@ pub fn force_link() { std::any::TypeId::of::(), std::any::TypeId::of::(), std::any::TypeId::of::(), - std::any::TypeId::of::(), - std::any::TypeId::of::(), std::any::TypeId::of::(), std::any::TypeId::of::(), std::any::TypeId::of::(), diff --git a/crates/hyperdeck-plugin/src/contracts.rs b/crates/hyperdeck-plugin/src/contracts.rs index b1a4c77..d52a549 100644 --- a/crates/hyperdeck-plugin/src/contracts.rs +++ b/crates/hyperdeck-plugin/src/contracts.rs @@ -25,6 +25,8 @@ pub struct InstanceConfig { pub username: String, #[serde(default)] pub password: String, + #[serde(default = "default_update_interval_ms")] + pub update_interval_ms: u32, } fn default_host() -> String { @@ -43,6 +45,10 @@ fn default_true() -> bool { true } +fn default_update_interval_ms() -> u32 { + hyperdeck_pool::DEFAULT_UPDATE_INTERVAL_MS +} + impl From for HyperDeckInstanceConfig { fn from(value: InstanceConfig) -> Self { Self { @@ -54,6 +60,7 @@ impl From for HyperDeckInstanceConfig { enabled: value.enabled, username: value.username, password: value.password, + update_interval_ms: value.update_interval_ms, } } } @@ -115,6 +122,7 @@ pub fn localhost_instance() -> InstanceConfig { enabled: true, username: String::new(), password: String::new(), + update_interval_ms: default_update_interval_ms(), } } @@ -225,6 +233,8 @@ pub struct ActionParams { pub threshold_seconds: u32, #[serde(default = "default_status_field")] pub status_field: String, + #[serde(default = "default_time_basis")] + pub time_basis: String, #[serde(default)] pub raw: String, #[serde(default)] @@ -234,8 +244,6 @@ pub struct ActionParams { #[serde(default)] pub raw_push: String, #[serde(default)] - pub raw_touch: String, - #[serde(default)] pub step: i32, #[serde(default = "default_direction")] pub direction: String, @@ -273,6 +281,10 @@ fn default_status_field() -> String { "status".into() } +fn default_time_basis() -> String { + "clip".into() +} + fn default_goto() -> String { "timeline-start".into() } @@ -305,11 +317,11 @@ impl Default for ActionParams { value: String::new(), threshold_seconds: default_threshold(), status_field: default_status_field(), + time_basis: default_time_basis(), raw: String::new(), raw_rotate_up: String::new(), raw_rotate_down: String::new(), raw_push: String::new(), - raw_touch: String::new(), step: 0, direction: default_direction(), active_color: String::new(), diff --git a/crates/hyperdeck-plugin/src/kind.rs b/crates/hyperdeck-plugin/src/kind.rs index 81d7052..1367fd0 100644 --- a/crates/hyperdeck-plugin/src/kind.rs +++ b/crates/hyperdeck-plugin/src/kind.rs @@ -5,8 +5,6 @@ pub enum ActionKind { Record, Stop, Preview, - Loop, - SingleClip, Slot, Clip, PlayRange, @@ -62,8 +60,6 @@ impl ActionKind { Self::Record => "dev.mikanseilaboratory.bmd.hyperdeck.record", Self::Stop => "dev.mikanseilaboratory.bmd.hyperdeck.stop", Self::Preview => "dev.mikanseilaboratory.bmd.hyperdeck.preview", - Self::Loop => "dev.mikanseilaboratory.bmd.hyperdeck.loop", - Self::SingleClip => "dev.mikanseilaboratory.bmd.hyperdeck.singleclip", Self::Slot => "dev.mikanseilaboratory.bmd.hyperdeck.slot", Self::Clip => "dev.mikanseilaboratory.bmd.hyperdeck.clip", Self::PlayRange => "dev.mikanseilaboratory.bmd.hyperdeck.playrange", diff --git a/crates/hyperdeck-plugin/src/ops.rs b/crates/hyperdeck-plugin/src/ops.rs index 573ad92..f5a5087 100644 --- a/crates/hyperdeck-plugin/src/ops.rs +++ b/crates/hyperdeck-plugin/src/ops.rs @@ -1,4 +1,5 @@ use hyperdeck_pool::DeckSnapshot; +use hyperdeck_tokio::core::types::{Timecode, VideoFormat}; use crate::contracts::ActionParams; use crate::kind::{ActionKind, PressKind}; @@ -10,8 +11,6 @@ pub enum Gesture { Press(PressKind), /// Encoder rotation. Positive ticks are clockwise. Rotate(i32), - /// Encoder touch screen tap. - Touch, } /// Text and light derived from one deck snapshot. @@ -34,7 +33,6 @@ pub fn commands( ) -> Vec { match gesture { Gesture::Rotate(ticks) => rotate_commands(kind, params, snap, ticks), - Gesture::Touch => touch_commands(kind, params, snap), Gesture::Press(press) => press_commands(kind, params, snap, press), } } @@ -64,8 +62,6 @@ pub fn face(kind: ActionKind, params: &ActionParams, snap: &DeckSnapshot) -> Fac title: status.to_string(), indicator: 0, }, - ActionKind::Loop => flag_face(snap.transport_value("loop"), "Loop"), - ActionKind::SingleClip => flag_face(snap.transport_value("single clip"), "Single"), ActionKind::Slot => { let current = snap.transport_value("slot id"); let wanted = if params.slot_id.trim().is_empty() { @@ -91,11 +87,14 @@ pub fn face(kind: ActionKind, params: &ActionParams, snap: &DeckSnapshot) -> Fac title: clip_title(snap), indicator: 0, }, - ActionKind::ClipList => Face { - active: !current_clip_id(snap).is_empty(), - title: clip_list_caption(snap), - indicator: clip_indicator(snap), - }, + ActionKind::ClipList => { + let clock = playback_clock(snap, params); + Face { + active: !current_clip_id(snap).is_empty(), + title: time_caption(ActionKind::ClipList, snap, params, &clock), + indicator: clock.progress, + } + } ActionKind::PlayRange => Face { active: snap.playrange_active(), title: playrange_title(snap), @@ -154,11 +153,12 @@ pub fn face(kind: ActionKind, params: &ActionParams, snap: &DeckSnapshot) -> Fac indicator: 0, }, ActionKind::Remaining => { - let seconds = recording_seconds(snap); + let raw = snap.slot_value("recording time"); + let seconds = recording_seconds(raw); let threshold = params.threshold_seconds; Face { active: seconds > 0 && seconds <= u64::from(threshold), - title: format!("{seconds}s {}", snap.slot_value("remaining size")), + title: remaining_title(raw, snap.slot_value("remaining size")), indicator: 0, } } @@ -201,13 +201,16 @@ pub fn face(kind: ActionKind, params: &ActionParams, snap: &DeckSnapshot) -> Fac indicator: percent_of(speed.unsigned_abs(), 5000), } } - ActionKind::Jog => Face { - active: status == "jog", - title: timecode, - indicator: 0, - }, + ActionKind::Jog => { + let clock = playback_clock(snap, params); + Face { + active: status == "jog", + title: time_caption(ActionKind::Jog, snap, params, &clock), + indicator: clock.progress, + } + } ActionKind::PlaySpeed => { - let speed = play_speed_percent(snap); + let speed = snap.speed(); Face { active: playing(status), title: format_play_speed(speed), @@ -220,16 +223,15 @@ pub fn face(kind: ActionKind, params: &ActionParams, snap: &DeckSnapshot) -> Fac indicator: position_indicator(snap), }, ActionKind::AudioChannels => { - let count = snap.config_value("audio input channels"); - let parsed = audio_channel_base(snap); + let count = snap.config_value("audio input channels").trim(); + let indicator = count + .parse::() + .map(|value| percent_of(value.min(64), 64)) + .unwrap_or(0); Face { active: false, - title: if count.is_empty() { - "2".into() - } else { - count.to_string() - }, - indicator: percent_of(parsed.unsigned_abs().min(64), 64), + title: count.to_string(), + indicator, } } ActionKind::RawDial => Face { @@ -294,14 +296,6 @@ fn press_commands( }, )], )], - ActionKind::Loop => vec![line( - "play", - &[("loop", flip(snap.transport_value("loop")))], - )], - ActionKind::SingleClip => vec![line( - "play", - &[("single clip", flip(snap.transport_value("single clip")))], - )], ActionKind::Slot => slot_select(params), ActionKind::Clip => { let id = params.clip_id.trim(); @@ -453,7 +447,9 @@ fn rotate_commands( } ActionKind::Jog => { let frames = delta.unsigned_abs().max(1); - let tc = frames_to_timecode(frames); + let Some(tc) = jog_timecode(frames, reported_fps(snap)) else { + return Vec::new(); + }; let signed = if delta < 0 { format!("-{tc}") } else { @@ -462,7 +458,7 @@ fn rotate_commands( vec![line("jog", &[("timecode", &signed)])] } ActionKind::PlaySpeed => { - let speed = (i64::from(play_speed_percent(snap)) + i64::from(delta)).clamp(-5000, 5000); + let speed = (i64::from(snap.speed()) + i64::from(delta)).clamp(-5000, 5000); vec![line("play", &[("speed", &speed.to_string())])] } ActionKind::ClipList => { @@ -478,8 +474,10 @@ fn rotate_commands( vec![line("goto", &[("timeline", &target)])] } ActionKind::AudioChannels => { - let current = audio_channel_base(snap); - let next = (current + delta).clamp(0, 64); + let Some(current) = reported_audio_channels(snap) else { + return Vec::new(); + }; + let next = (i64::from(current) + i64::from(delta)).clamp(0, 64); vec![line( "configuration", &[("audio input channels", &next.to_string())], @@ -497,17 +495,6 @@ fn rotate_commands( } } -fn touch_commands(kind: ActionKind, params: &ActionParams, snap: &DeckSnapshot) -> Vec { - match kind { - ActionKind::RawDial => raw_line(¶ms.raw_touch).into_iter().collect(), - ActionKind::Shuttle | ActionKind::Jog | ActionKind::Position | ActionKind::ClipList => { - press_commands(ActionKind::Stop, params, snap, PressKind::Single) - } - ActionKind::PlaySpeed => press_commands(kind, params, snap, PressKind::Single), - _ => Vec::new(), - } -} - fn play_line(params: &ActionParams) -> String { let speed = params.speed.clamp(-5000, 5000).to_string(); line( @@ -638,24 +625,18 @@ fn bool_config(key: &str, snap: &DeckSnapshot) -> Vec { )] } -/// Stopped decks report speed 0. The play-speed dial treats that as 1.00x. -fn play_speed_percent(snap: &DeckSnapshot) -> i32 { - let speed = snap.speed(); - if speed == 0 { 100 } else { speed } -} - fn format_play_speed(percent: i32) -> String { let sign = if percent < 0 { "-" } else { "" }; let abs = percent.unsigned_abs(); format!("{sign}{}.{:02}x", abs / 100, abs % 100) } -fn audio_channel_base(snap: &DeckSnapshot) -> i32 { +fn reported_audio_channels(snap: &DeckSnapshot) -> Option { let count = snap.config_value("audio input channels").trim(); if count.is_empty() { - 2 + None } else { - count.parse().unwrap_or(0) + count.parse().ok() } } @@ -677,31 +658,277 @@ fn clip_title(snap: &DeckSnapshot) -> String { } } -/// Dial caption: timeline number, then the clip name, split by a record separator. -fn clip_list_caption(snap: &DeckSnapshot) -> String { - format!("{}\u{1e}{}", current_clip_id(snap), snap.clip_name()) +struct PlaybackClock { + current: String, + remaining: String, + total: String, + progress: u8, +} + +fn empty_clock() -> PlaybackClock { + PlaybackClock { + current: String::new(), + remaining: String::new(), + total: String::new(), + progress: 0, + } +} + +fn uses_timeline(params: &ActionParams) -> bool { + params.time_basis.eq_ignore_ascii_case("timeline") +} + +/// Current, remaining, and total time. +/// +/// Clip time stays inside the current clip. The deck's numeric timeline +/// position is only the frame counter used to find that clip offset. The +/// whole-timeline clock is drawn when the inspector selects Timeline. +fn playback_clock(snap: &DeckSnapshot, params: &ActionParams) -> PlaybackClock { + if snap.status() == "record" { + return empty_clock(); + } + if uses_timeline(params) { + timeline_clock(snap) + } else { + clip_clock(snap) + } +} + +fn time_caption( + kind: ActionKind, + snap: &DeckSnapshot, + params: &ActionParams, + clock: &PlaybackClock, +) -> String { + let basis = if uses_timeline(params) { "TL" } else { "CLIP" }; + format!( + "{}\u{1e}{}\u{1e}{}\u{1e}{}\u{1e}{basis}", + time_name(kind, snap), + clock.current, + clock.remaining, + clock.total + ) +} + +fn time_name(kind: ActionKind, snap: &DeckSnapshot) -> String { + let name = snap.clip_name(); + if !name.is_empty() { + return name; + } + let id = current_clip_id(snap); + if !id.is_empty() { + return id.to_string(); + } + kind.dial_heading().to_string() +} + +fn clip_clock(snap: &DeckSnapshot) -> PlaybackClock { + let id = current_clip_id(snap); + let Some((index, clip)) = snap + .clips + .iter() + .enumerate() + .find(|(_, clip)| clip.id == id) + else { + return empty_clock(); + }; + let Some(fps) = reported_fps(snap) else { + return empty_clock(); + }; + let Some(duration) = duration_frames(&clip.duration, fps) else { + return empty_clock(); + }; + clock_from_frames( + elapsed_in_clip(snap, clip, index, fps, duration), + duration, + fps, + ) +} + +/// Frames already played in this clip. +/// +/// The live value on most decks is the numeric timeline position. Subtracting +/// the durations of the clips before this one turns that counter into a clip +/// offset, which is still clip time. Timecode is the fallback when that +/// counter is missing or points at a different clip. +fn elapsed_in_clip( + snap: &DeckSnapshot, + clip: &hyperdeck_pool::ClipRow, + index: usize, + fps: u32, + duration: u32, +) -> Option { + elapsed_from_timeline_offset(snap, index, fps, duration) + .or_else(|| elapsed_from_reported_timecode(snap, clip, fps, duration)) +} + +fn elapsed_from_timeline_offset( + snap: &DeckSnapshot, + index: usize, + fps: u32, + duration: u32, +) -> Option { + let position = snap + .transport_value("timeline position") + .parse::() + .ok() + .or_else(|| snap.transport_value("timeline").parse::().ok())?; + let start = snap.clips[..index].iter().try_fold(0u32, |total, clip| { + Some(total.saturating_add(duration_frames(&clip.duration, fps)?)) + })?; + let end = start.saturating_add(duration); + if position < start || position > end { + None + } else { + Some(position.saturating_sub(start)) + } +} + +fn elapsed_from_reported_timecode( + snap: &DeckSnapshot, + clip: &hyperdeck_pool::ClipRow, + fps: u32, + duration: u32, +) -> Option { + ["timecode", "display timecode"] + .into_iter() + .find_map(|key| elapsed_against_clip_start(snap.transport_value(key), clip, fps, duration)) +} + +fn elapsed_against_clip_start( + raw: &str, + clip: &hyperdeck_pool::ClipRow, + fps: u32, + duration: u32, +) -> Option { + let position = parse_timecode(raw)?.to_frames(fps); + let start = if clip.start.trim().is_empty() { + 0 + } else { + parse_timecode(&clip.start)?.to_frames(fps) + }; + let end = start.saturating_add(duration); + if position < start || position > end { + None + } else { + Some(position.saturating_sub(start)) + } +} + +fn timeline_clock(snap: &DeckSnapshot) -> PlaybackClock { + let Some(fps) = reported_fps(snap) else { + return empty_clock(); + }; + let Some(total) = snap.clips.iter().try_fold(0u32, |sum, clip| { + Some(sum.saturating_add(duration_frames(&clip.duration, fps)?)) + }) else { + return empty_clock(); + }; + if total == 0 { + return empty_clock(); + } + clock_from_frames(timeline_frames(snap, fps, total), total, fps) +} + +fn timeline_frames(snap: &DeckSnapshot, fps: u32, total: u32) -> Option { + if let Some(position) = snap + .transport_value("timeline position") + .parse::() + .ok() + .or_else(|| snap.transport_value("timeline").parse::().ok()) + { + return Some(position); + } + let origin = snap + .clips + .iter() + .find_map(|clip| parse_timecode(&clip.timeline_in).map(|time| time.to_frames(fps)))?; + let position = parse_timecode(snap.transport_value("timecode"))?.to_frames(fps); + let end = origin.saturating_add(total); + if position < origin || position > end { + None + } else { + Some(position.saturating_sub(origin)) + } } -fn clip_indicator(snap: &DeckSnapshot) -> u8 { - let len = u32::try_from(snap.clips.len()).unwrap_or(0); - if len == 0 { - return 0; +fn clock_from_frames(elapsed: Option, total: u32, fps: u32) -> PlaybackClock { + let total_text = frames_text(total, fps); + let Some(elapsed) = elapsed else { + return PlaybackClock { + current: String::new(), + remaining: String::new(), + total: total_text, + progress: 0, + }; + }; + let remain = total.saturating_sub(elapsed); + PlaybackClock { + current: frames_text(elapsed, fps), + remaining: format!("-{}", frames_text(remain, fps)), + total: total_text, + progress: percent_of(elapsed.min(total), total), + } +} + +fn duration_frames(raw: &str, fps: u32) -> Option { + parse_timecode(raw).map(|time| time.to_frames(fps)) +} + +fn frames_text(frames: u32, fps: u32) -> String { + short_timecode(&Timecode::from_frames(frames, fps).to_string()) +} + +fn parse_timecode(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; } - let id = snap.transport_value("clip id"); - let Some(index) = snap.clips.iter().position(|clip| clip.id == id) else { - return 0; + let normalized = trimmed.replace(';', ":"); + Timecode::parse(&normalized).ok() +} + +/// Integer frame count from a transport or slot `video format` token. +/// +/// Fractional rates use the timecode frame count (`29.97` is 30). `None` when +/// the deck has not reported a format this plugin can read. +fn reported_fps(snap: &DeckSnapshot) -> Option { + let transport = snap.transport_value("video format").trim(); + let token = if transport.is_empty() { + snap.slot_value("video format").trim() + } else { + transport }; - percent_of(u32::try_from(index).unwrap_or(0).saturating_add(1), len) + if token.is_empty() { + None + } else { + integer_fps(token) + } } -fn frames_to_timecode(frames: u32) -> String { - let fps = 30u32; - let seconds = frames / fps; - let ff = frames % fps; - let ss = seconds % 60; - let mm = (seconds / 60) % 60; - let hh = seconds / 3600; - format!("{hh:02}:{mm:02}:{ss:02}:{ff:02}") +fn integer_fps(token: &str) -> Option { + match token { + "NTSC" => Some(30), + "PAL" => Some(25), + "NTSCp" => Some(60), + "PALp" => Some(50), + _ => match VideoFormat::parse_ethernet(token).ok()? { + VideoFormat::Raster { rate, .. } => Some(rate.integer_fps()), + VideoFormat::Sd(_) => None, + }, + } +} + +/// `HH:MM:SS:FF` for a jog offset. +/// +/// One frame is `:01` in every format. A larger step needs the deck's frame +/// rate, because the frame field rolls into seconds at that rate. +fn jog_timecode(frames: u32, fps: Option) -> Option { + match fps { + Some(fps) if fps > 0 => Some(Timecode::from_frames(frames, fps).to_string()), + _ if (1..24).contains(&frames) => Some(format!("00:00:00:{frames:02}")), + _ => None, + } } fn step_of(kind: ActionKind, params: &ActionParams) -> i32 { @@ -820,7 +1047,10 @@ fn display_timecode(snap: &DeckSnapshot) -> String { short_timecode(raw) } -/// `HH:MM:SS:FF` shown as `mm:ss:ff`. Hours appear only when they are greater than zero. +/// `HH:MM:SS:FF` shown as `mm:ss`. Hours appear only when they are greater than zero. +/// +/// Frame counts stay off the key. The transport poll is slower than one frame, +/// so a frame field would usually be stale. fn short_timecode(raw: &str) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -843,17 +1073,11 @@ fn short_timecode(raw: &str) -> String { if !hours_ok || !rest_ok || !frames_ok { return trimmed.to_string(); } - let frames = if parts.len() == 4 { - let separator = if body.contains(';') { ';' } else { ':' }; - format!("{separator}{}", parts[3]) - } else { - String::new() - }; let hours: u32 = parts[0].parse().unwrap_or(0); let shown = if hours > 0 { - format!("{hours:02}:{}:{}{frames}", parts[1], parts[2]) + format!("{hours:02}:{}:{}", parts[1], parts[2]) } else { - format!("{}:{}{frames}", parts[1], parts[2]) + format!("{}:{}", parts[1], parts[2]) }; format!("{sign}{shown}") } @@ -867,15 +1091,31 @@ fn recording_time(snap: &DeckSnapshot) -> String { } } -fn recording_seconds(snap: &DeckSnapshot) -> u64 { - snap.slot_value("recording time") - .split_whitespace() +fn recording_seconds(raw: &str) -> u64 { + raw.split_whitespace() .next() .unwrap_or("") .parse() .unwrap_or(0) } +/// Recording time and remaining size, using only the strings the deck sent. +fn remaining_title(recording_time: &str, remaining_size: &str) -> String { + let time = if recording_time.is_empty() { + String::new() + } else if recording_time.bytes().all(|byte| byte.is_ascii_digit()) { + format!("{recording_time}s") + } else { + short_timecode(recording_time) + }; + match (time.is_empty(), remaining_size.is_empty()) { + (true, true) => String::new(), + (false, true) => time, + (true, false) => remaining_size.to_string(), + (false, false) => format!("{time} {remaining_size}"), + } +} + fn playrange_title(snap: &DeckSnapshot) -> String { let start = snap .playrange @@ -962,6 +1202,33 @@ mod tests { snap } + #[test] + fn remaining_title_uses_the_recording_time_the_deck_sent() { + let params = ActionParams::default(); + let mut snap = DeckSnapshot::default(); + snap.slot.insert("recording time".into(), "3600".into()); + snap.slot.insert("remaining size".into(), "1000".into()); + let shown = face(ActionKind::Remaining, ¶ms, &snap); + assert_eq!(shown.title, "3600s 1000"); + assert!(!shown.active); + + snap.slot.insert("recording time".into(), "30".into()); + let shown = face(ActionKind::Remaining, ¶ms, &snap); + assert_eq!(shown.title, "30s 1000"); + assert!(shown.active); + + snap.slot + .insert("recording time".into(), "00:01:02:03".into()); + let shown = face(ActionKind::Remaining, ¶ms, &snap); + assert_eq!(shown.title, "01:02 1000"); + assert!(!shown.active); + + snap.slot.remove("recording time"); + let shown = face(ActionKind::Remaining, ¶ms, &snap); + assert_eq!(shown.title, "1000"); + assert!(!shown.active); + } + #[test] fn play_toggles_and_record_hold_spills() { let stopped = snap_status("stopped", "0"); @@ -1008,45 +1275,105 @@ mod tests { } #[test] - fn displayed_timecode_keeps_frames_and_hides_zero_hours() { - assert_eq!(short_timecode("00:01:23:04"), "01:23:04"); - assert_eq!(short_timecode("00:00:05;12"), "00:05;12"); - assert_eq!(short_timecode("01:02:03:04"), "01:02:03:04"); - assert_eq!(short_timecode("10:00:00:00"), "10:00:00:00"); + fn displayed_timecode_hides_frames_and_zero_hours() { + assert_eq!(short_timecode("00:01:23:04"), "01:23"); + assert_eq!(short_timecode("00:00:05;12"), "00:05"); + assert_eq!(short_timecode("01:02:03:04"), "01:02:03"); + assert_eq!(short_timecode("10:00:00:00"), "10:00:00"); + assert_eq!(short_timecode("-00:00:06:00"), "-00:06"); assert_eq!(short_timecode(""), ""); assert_eq!(short_timecode("stopped"), "stopped"); let mut snap = snap_status("play", "100"); snap.transport .insert("display timecode".into(), "00:04:05:06".into()); let shown = face(ActionKind::Jog, &ActionParams::default(), &snap); - assert_eq!(shown.title, "04:05:06"); + assert_eq!(shown.title, "Jog\u{1e}\u{1e}\u{1e}\u{1e}CLIP"); } #[test] - fn stopped_play_speed_starts_at_one_x() { + fn play_speed_follows_the_reported_speed() { let snap = snap_status("stopped", "0"); let params = ActionParams::default(); let shown = face(ActionKind::PlaySpeed, ¶ms, &snap); - assert_eq!(shown.title, "1.00x"); + assert_eq!(shown.title, "0.00x"); + assert_eq!(shown.indicator, 0); let sent = commands(ActionKind::PlaySpeed, ¶ms, &snap, Gesture::Rotate(1)); - assert_eq!(sent, vec!["play: speed: 101\r\n".to_string()]); + assert_eq!(sent, vec!["play: speed: 1\r\n".to_string()]); let playing = snap_status("play", "200"); let shown = face(ActionKind::PlaySpeed, ¶ms, &playing); assert_eq!(shown.title, "2.00x"); + let sent = commands(ActionKind::PlaySpeed, ¶ms, &playing, Gesture::Rotate(1)); + assert_eq!(sent, vec!["play: speed: 201\r\n".to_string()]); + } + + #[test] + fn jog_uses_the_reported_frame_rate() { + let params = ActionParams { + step: 30, + ..ActionParams::default() + }; + let mut snap = snap_status("stopped", "0"); + snap.transport + .insert("video format".into(), "1080p2997".into()); + let sent = commands(ActionKind::Jog, ¶ms, &snap, Gesture::Rotate(1)); + assert_eq!(sent, vec!["jog: timecode: +00:00:01:00\r\n".to_string()]); + + snap.transport + .insert("video format".into(), "1080p24".into()); + let sent = commands(ActionKind::Jog, ¶ms, &snap, Gesture::Rotate(-1)); + assert_eq!(sent, vec!["jog: timecode: -00:00:01:06\r\n".to_string()]); + + snap.transport + .insert("video format".into(), "1080i5994".into()); + let minute = ActionParams { + step: 60, + ..ActionParams::default() + }; + let sent = commands(ActionKind::Jog, &minute, &snap, Gesture::Rotate(60)); + assert_eq!(sent, vec!["jog: timecode: +00:01:00:00\r\n".to_string()]); + + let unknown = snap_status("stopped", "0"); + let one = commands( + ActionKind::Jog, + &ActionParams::default(), + &unknown, + Gesture::Rotate(1), + ); + assert_eq!(one, vec!["jog: timecode: +00:00:00:01\r\n".to_string()]); + let guessed = commands(ActionKind::Jog, ¶ms, &unknown, Gesture::Rotate(1)); + assert!(guessed.is_empty()); + } + + #[test] + fn position_bar_uses_a_numeric_duration_only() { + let mut snap = snap_status("play", "100"); + snap.transport.insert("timeline".into(), "50".into()); + snap.playrange + .insert("timeline out".into(), "00:10:00:00".into()); + let params = ActionParams::default(); + let shown = face(ActionKind::Position, ¶ms, &snap); + assert_eq!(shown.indicator, 0); + snap.playrange.insert("timeline out".into(), "200".into()); + let shown = face(ActionKind::Position, ¶ms, &snap); + assert_eq!(shown.indicator, 25); } #[test] fn clip_list_steps_and_plays_the_current_clip() { let mut snap = DeckSnapshot::default(); snap.transport.insert("clip id".into(), "3".into()); + snap.transport + .insert("display timecode".into(), "01:02:03:04".into()); snap.clips = vec![ hyperdeck_pool::ClipRow { id: "1".into(), name: "slate".into(), + ..hyperdeck_pool::ClipRow::default() }, hyperdeck_pool::ClipRow { id: "3".into(), name: "interview".into(), + ..hyperdeck_pool::ClipRow::default() }, ]; let params = ActionParams::default(); @@ -1061,17 +1388,106 @@ mod tests { Gesture::Press(PressKind::Single), ); assert_eq!(play, vec!["play: clip id: 3\r\n".to_string()]); - let stop = commands(ActionKind::ClipList, ¶ms, &snap, Gesture::Touch); - assert_eq!(stop, vec!["stop\r\n".to_string()]); let shown = face(ActionKind::ClipList, ¶ms, &snap); - assert_eq!(shown.title, "3\u{1e}interview"); - assert_eq!(shown.indicator, 100); + assert_eq!(shown.title, "interview\u{1e}\u{1e}\u{1e}\u{1e}CLIP"); + assert_eq!(shown.indicator, 0); } #[test] - fn empty_audio_channels_start_at_two() { - let snap = DeckSnapshot::default(); + fn clip_countdown_uses_the_frame_offset_inside_the_clip() { + let params = ActionParams::default(); + let mut snap = DeckSnapshot::default(); + snap.transport.insert("status".into(), "play".into()); + snap.transport.insert("clip id".into(), "2".into()); + snap.transport + .insert("video format".into(), "1080p25".into()); + snap.transport + .insert("timecode".into(), "01:00:04:00".into()); + snap.transport.insert("timeline".into(), "100".into()); + snap.clips = vec![ + hyperdeck_pool::ClipRow { + id: "1".into(), + name: "slate".into(), + duration: "00:00:10:00".into(), + ..hyperdeck_pool::ClipRow::default() + }, + hyperdeck_pool::ClipRow { + id: "2".into(), + name: "interview".into(), + start: "01:00:00:00".into(), + duration: "00:00:10:00".into(), + timeline_in: "00:00:10:00".into(), + timeline_out: "00:00:20:00".into(), + }, + ]; + let shown = face(ActionKind::ClipList, ¶ms, &snap); + assert_eq!( + shown.title, + "interview\u{1e}00:04\u{1e}-00:06\u{1e}00:10\u{1e}CLIP" + ); + assert_eq!(shown.indicator, 40); + + snap.transport + .insert("timecode".into(), "10:00:00:00".into()); + snap.transport + .insert("timeline position".into(), "350".into()); + let shown = face(ActionKind::ClipList, ¶ms, &snap); + assert_eq!( + shown.title, + "interview\u{1e}00:04\u{1e}-00:06\u{1e}00:10\u{1e}CLIP" + ); + + snap.transport.remove("timeline"); + snap.transport.remove("timeline position"); + snap.transport + .insert("display timecode".into(), "01:00:04:00".into()); + let shown = face(ActionKind::Jog, ¶ms, &snap); + assert_eq!( + shown.title, + "interview\u{1e}00:04\u{1e}-00:06\u{1e}00:10\u{1e}CLIP" + ); + + snap.transport.insert("timeline".into(), "100".into()); + let timeline = ActionParams { + time_basis: "timeline".into(), + ..ActionParams::default() + }; + snap.transport + .insert("timeline position".into(), "350".into()); + let shown = face(ActionKind::Jog, &timeline, &snap); + assert_eq!( + shown.title, + "interview\u{1e}00:14\u{1e}-00:06\u{1e}00:20\u{1e}TL" + ); + assert_eq!(shown.indicator, 70); + + snap.transport.insert("status".into(), "record".into()); + let shown = face(ActionKind::ClipList, ¶ms, &snap); + assert_eq!(shown.title, "interview\u{1e}\u{1e}\u{1e}\u{1e}CLIP"); + snap.transport.insert("status".into(), "play".into()); + snap.transport.remove("video format"); + let shown = face(ActionKind::ClipList, ¶ms, &snap); + assert_eq!(shown.title, "interview\u{1e}\u{1e}\u{1e}\u{1e}CLIP"); + } + + #[test] + fn audio_channels_follow_the_reported_count() { let params = ActionParams::default(); + let snap = DeckSnapshot::default(); + let shown = face(ActionKind::AudioChannels, ¶ms, &snap); + assert_eq!(shown.title, ""); + assert_eq!(shown.indicator, 0); + let sent = commands( + ActionKind::AudioChannels, + ¶ms, + &snap, + Gesture::Rotate(1), + ); + assert!(sent.is_empty()); + + let mut snap = DeckSnapshot::default(); + snap.config + .insert("audio input channels".into(), "2".into()); let shown = face(ActionKind::AudioChannels, ¶ms, &snap); assert_eq!(shown.title, "2"); let sent = commands( @@ -1084,7 +1500,6 @@ mod tests { sent, vec!["configuration: audio input channels: 4\r\n".to_string()] ); - assert_eq!(ActionParams::default().range_count, "1"); } #[test] diff --git a/crates/hyperdeck-plugin/src/render.rs b/crates/hyperdeck-plugin/src/render.rs index f5b7339..7d6ae53 100644 --- a/crates/hyperdeck-plugin/src/render.rs +++ b/crates/hyperdeck-plugin/src/render.rs @@ -32,6 +32,10 @@ pub struct Segment { pub const LAYOUT_DIAL: &str = "layouts/dial.json"; /// Custom layout for two to four decks. pub const LAYOUT_MULTI: &str = "layouts/dial-multi.json"; +/// Current time, large countdown, and total for one deck. +pub const LAYOUT_TIMES: &str = "layouts/dial-countdown.json"; +/// The same readout for two to four decks. +pub const LAYOUT_TIMES_MULTI: &str = "layouts/dial-times-multi.json"; /// `0` when every band is active. Stream Deck state 0 is the lit state. pub fn hardware_state(states: impl IntoIterator) -> i32 { @@ -97,6 +101,9 @@ pub fn dial_feedback( levels: &[u8], foreground: &str, ) -> (String, Value) { + if kind == ActionKind::ClipList || kind == ActionKind::Jog { + return times_feedback(segments, captions, levels); + } let icon = dial_icon(kind, foreground); if segments.len() <= 1 { let segment = segments.first(); @@ -116,14 +123,8 @@ pub fn dial_feedback( let title = captions .iter() .take(4) - .map(|caption| { - if kind == ActionKind::ClipList { - dial_text(kind, caption).0 - } else { - caption.clone() - } - }) - .filter(|caption| !caption.is_empty() && caption != kind.dial_heading()) + .filter(|caption| !caption.is_empty() && caption.as_str() != kind.dial_heading()) + .cloned() .collect::>() .join(" · "); let mut payload = json!({ "title": title }); @@ -137,26 +138,65 @@ pub fn dial_feedback( } } -fn dial_text(kind: ActionKind, caption: &str) -> (String, String) { - if kind == ActionKind::ClipList { - let (id, name) = caption.split_once('\u{1e}').unwrap_or(("", caption)); - let title = if !name.is_empty() { - name.to_string() - } else if !id.is_empty() { - id.to_string() - } else { - kind.dial_heading().to_string() - }; - let value = if id.is_empty() { - String::new() - } else { - id.to_string() - }; - return (title, value); +fn times_feedback(segments: &[Segment], captions: &[String], levels: &[u8]) -> (String, Value) { + if segments.len() <= 1 { + let caption = captions.first().map(String::as_str).unwrap_or(""); + let (filename, now, left, total, basis) = time_fields(caption); + let level = levels.first().copied().unwrap_or(0); + return ( + LAYOUT_TIMES.to_string(), + json!({ + "filename": filename, + "basis": basis, + "nowLabel": "Now", + "totalLabel": "Total", + "now": now, + "left": left, + "total": total, + "indicator": indicator(segments.first(), level), + }), + ); } + let mut payload = serde_json::Map::new(); + for index in 0..4 { + let text = captions.get(index).map(|caption| { + let (filename, now, left, total, _) = time_fields(caption); + time_line(&filename, &now, &left, &total) + }); + payload.insert( + format!("line{}", index + 1), + json!(text.unwrap_or_default()), + ); + } + (LAYOUT_TIMES_MULTI.to_string(), Value::Object(payload)) +} + +fn dial_text(kind: ActionKind, caption: &str) -> (String, String) { (kind.dial_heading().to_string(), caption.to_string()) } +/// File name, current time, remaining time, total time, and basis label. +fn time_fields(caption: &str) -> (String, String, String, String, String) { + let mut parts = caption.split('\u{1e}'); + let name = parts.next().unwrap_or("").to_string(); + let now = parts.next().unwrap_or("").to_string(); + let left = parts.next().unwrap_or("").to_string(); + let total = parts.next().unwrap_or("").to_string(); + let basis = parts.next().unwrap_or("").to_string(); + (name, now, left, total, basis) +} + +fn time_line(name: &str, now: &str, left: &str, total: &str) -> String { + let slot = |value: &str| { + if value.is_empty() { + "–".to_string() + } else { + value.to_string() + } + }; + format!("{} {} {} {}", name, slot(now), slot(left), slot(total)) +} + fn indicator(segment: Option<&Segment>, level: u8) -> Value { let color = segment .map(|item| item.color.clone()) @@ -212,9 +252,7 @@ fn icon_path(kind: ActionKind) -> &'static str { } ActionKind::Stop => r#""#, ActionKind::Preview => r#""#, - ActionKind::Loop | ActionKind::SingleClip | ActionKind::PlayRange => { - r#""# - } + ActionKind::PlayRange => r#""#, ActionKind::Slot | ActionKind::SlotUnblock | ActionKind::ExternalDrive => { r#""# } @@ -321,12 +359,46 @@ mod tests { let (layout, payload) = dial_feedback( ActionKind::ClipList, std::slice::from_ref(&one), - &["3\u{1e}interview".into()], - &[100], + &["interview.mov\u{1e}01:23\u{1e}-00:06\u{1e}00:10\u{1e}CLIP".into()], + &[40], "#f4f7fb", ); - assert_eq!(layout, LAYOUT_DIAL); - assert_eq!(payload["title"], "interview"); - assert_eq!(payload["value"], "3"); + assert_eq!(layout, LAYOUT_TIMES); + assert_eq!(payload["filename"], "interview.mov"); + assert_eq!(payload["now"], "01:23"); + assert_eq!(payload["left"], "-00:06"); + assert_eq!(payload["total"], "00:10"); + assert_eq!(payload["basis"], "CLIP"); + assert_eq!(payload["nowLabel"], "Now"); + assert_eq!(payload["indicator"]["value"], 40); + assert!(payload.get("title").is_none()); + + let (layout, payload) = dial_feedback( + ActionKind::Jog, + std::slice::from_ref(&one), + &["interview.mov\u{1e}00:14\u{1e}-00:06\u{1e}00:20\u{1e}TL".into()], + &[70], + "#f4f7fb", + ); + assert_eq!(layout, LAYOUT_TIMES); + assert_eq!(payload["basis"], "TL"); + assert_eq!(payload["now"], "00:14"); + assert_eq!(payload["total"], "00:20"); + + let (layout, payload) = dial_feedback( + ActionKind::ClipList, + &two, + &[ + "interview.mov\u{1e}01:23\u{1e}-00:06\u{1e}00:10\u{1e}CLIP".into(), + "slate.mov\u{1e}\u{1e}\u{1e}00:10\u{1e}CLIP".into(), + ], + &[40, 0], + "#f4f7fb", + ); + assert_eq!(layout, LAYOUT_TIMES_MULTI); + assert_eq!(payload["line1"], "interview.mov 01:23 -00:06 00:10"); + assert_eq!(payload["line2"], "slate.mov – – 00:10"); + assert_eq!(payload["line3"], ""); + assert!(payload.get("title").is_none()); } } diff --git a/crates/hyperdeck-plugin/src/state.rs b/crates/hyperdeck-plugin/src/state.rs index 69aed20..8a4cdea 100644 --- a/crates/hyperdeck-plugin/src/state.rs +++ b/crates/hyperdeck-plugin/src/state.rs @@ -23,11 +23,20 @@ struct Press { struct LiveKey { kind: ActionKind, settings: ActionSettings, - segments: HashMap, - titles: HashMap, - indicators: HashMap, - title: String, multi: bool, + /// Layout last accepted by Stream Deck. Sent again only when it changes. + layout: String, + /// Picture last accepted by Stream Deck. Unchanged faces are not sent again. + sent: SentFace, +} + +/// Last `setImage` / `setTitle` / `setState` / `setFeedback` accepted for one key. +#[derive(Clone, Debug, Default)] +struct SentFace { + image: Option, + title: Option, + state: Option, + feedback: Option, } struct Runtime { @@ -35,7 +44,6 @@ struct Runtime { keys: Mutex>, presses: Mutex>, global: Mutex, - errors: Mutex>, dirty: Mutex>, dirty_notify: Notify, inspectors: Mutex>, @@ -74,7 +82,6 @@ impl AppState { keys: Mutex::new(HashMap::new()), presses: Mutex::new(HashMap::new()), global: Mutex::new(GlobalSettings::default()), - errors: Mutex::new(HashMap::new()), dirty: Mutex::new(HashSet::new()), dirty_notify: Notify::new(), inspectors: Mutex::new(HashSet::new()), @@ -131,25 +138,18 @@ impl AppState { settings: ActionSettings, multi: bool, ) { - let mut keys = self.runtime.keys.lock().await; - let previous = keys.remove(context); - let (segments, titles, indicators) = previous - .map(|key| (key.segments, key.titles, key.indicators)) - .unwrap_or_default(); - keys.insert( + self.runtime.keys.lock().await.insert( context.to_string(), LiveKey { kind, settings, - segments, - titles, - indicators, - title: String::new(), multi, + layout: String::new(), + sent: SentFace::default(), }, ); - drop(keys); self.refresh_key(context).await; + self.mark_catalog_open().await; } /// Forget a key that left the profile. @@ -242,11 +242,6 @@ impl AppState { self.spawn_gesture(context, Gesture::Press(PressKind::Single)); } - /// Dial touch. - pub fn dial_touch(&self, context: &str) { - self.spawn_gesture(context, Gesture::Touch); - } - /// Dial rotation. pub fn rotate(&self, context: &str, ticks: i32) { self.spawn_gesture(context, Gesture::Rotate(ticks)); @@ -292,45 +287,10 @@ impl AppState { }); } - /// Recompute bands and titles from the pool cache. + /// Schedule a repaint. The picture is read from the deck snapshots when it is drawn. pub async fn refresh_key(&self, context: &str) { - let Some((kind, settings)) = self.key_settings(context).await else { + if self.key_settings(context).await.is_none() { return; - }; - let targets = self.targets_for(&settings).await; - let failed = self.failed_ids(context).await; - let mut segments = HashMap::new(); - let mut titles = HashMap::new(); - let mut indicators = HashMap::new(); - for id in &targets { - let snapshot = self.pool.snapshot(id).await.unwrap_or_default(); - let connected = matches!( - self.pool.status(id).await, - Some(ConnectionStatus::Connected) - ) && snapshot.connected; - let params = settings.params_for(id); - let face = ops::face(kind, params, &snapshot); - let state = if !connected { - SegmentState::Disconnected - } else if failed.contains(id) { - SegmentState::Error - } else if face.active { - SegmentState::Active - } else { - SegmentState::Off - }; - segments.insert(id.clone(), state); - if !face.title.trim().is_empty() { - titles.insert(id.clone(), face.title); - } - indicators.insert(id.clone(), face.indicator); - } - let title = join_titles(&targets, &titles); - if let Some(key) = self.runtime.keys.lock().await.get_mut(context) { - key.segments = segments; - key.titles = titles; - key.indicators = indicators; - key.title = title; } self.mark_dirty(context).await; } @@ -348,19 +308,15 @@ impl AppState { if commands.is_empty() { continue; } - let mut failed = None; + let mut failed = false; for command in &commands { if let Err(error) = self.pool.call_raw(id, command.as_bytes()).await { - failed = Some(error.to_string()); + tracing::warn!(id, %error, "hyperdeck command failed"); + failed = true; break; } } - let mut errors = self.runtime.errors.lock().await; - if let Some(message) = failed { - tracing::warn!(id, %message, "hyperdeck command failed"); - errors.insert((context.to_string(), id.clone()), message); - } else { - errors.remove(&(context.to_string(), id.clone())); + if !failed { any_ok = true; } } @@ -400,7 +356,7 @@ impl AppState { let Some(sender) = self.runtime.sender.lock().await.clone() else { return; }; - let (kind, settings, segments, titles, indicators, title, multi) = { + let (kind, settings, multi, sent_layout, sent) = { let keys = self.runtime.keys.lock().await; let Some(key) = keys.get(context) else { return; @@ -408,62 +364,93 @@ impl AppState { ( key.kind, key.settings.clone(), - key.segments.clone(), - key.titles.clone(), - key.indicators.clone(), - key.title.clone(), key.multi, + key.layout.clone(), + key.sent.clone(), ) }; let global = self.runtime.global.lock().await.clone(); let targets = self.targets_for(&settings).await; - let visual: Vec = targets - .iter() - .map(|id| { - let instance = global.instances.iter().find(|item| item.id == *id); - let state = segments - .get(id) - .copied() - .unwrap_or(SegmentState::Disconnected); - let params = settings.params_for(id); - let mut color = instance - .map(|item| item.color.clone()) - .unwrap_or_else(|| "#4c8dff".into()); - if state == SegmentState::Active && !params.active_color.trim().is_empty() { - color = params.active_color.trim().to_string(); - } - Segment { - label: instance - .map(|item| item.name.clone()) - .unwrap_or_else(|| id.clone()), - color, - state, - } - }) - .collect(); + let mut visual = Vec::with_capacity(targets.len()); + let mut captions = Vec::with_capacity(targets.len()); + let mut levels = Vec::with_capacity(targets.len()); + let mut titles = HashMap::new(); + for id in &targets { + let appearance = self.appearance(kind, &settings, id).await; + let instance = global.instances.iter().find(|item| item.id == *id); + let params = settings.params_for(id); + let mut color = instance + .map(|item| item.color.clone()) + .unwrap_or_else(|| "#4c8dff".into()); + if appearance.state == SegmentState::Active && !params.active_color.trim().is_empty() { + color = params.active_color.trim().to_string(); + } + visual.push(Segment { + label: instance + .map(|item| item.name.clone()) + .unwrap_or_else(|| id.clone()), + color, + state: appearance.state, + }); + captions.push(appearance.title.clone()); + levels.push(appearance.indicator); + if !appearance.title.trim().is_empty() { + titles.insert(id.clone(), appearance.title); + } + } + let title = join_titles(&targets, &titles); if kind.is_dial() { - let captions: Vec = targets - .iter() - .map(|id| titles.get(id).cloned().unwrap_or_default()) - .collect(); - let levels: Vec = targets - .iter() - .map(|id| indicators.get(id).copied().unwrap_or(0)) - .collect(); let (layout, payload) = render::dial_feedback(kind, &visual, &captions, &levels, &global.fg_color); - let _ = sender.set_feedback_layout(context, &layout); - let _ = sender.set_feedback(context, &payload); + let mut previous_feedback = sent.feedback; + if sent_layout != layout + && sender.set_feedback_layout(context, &layout).is_ok() + && let Some(key) = self.runtime.keys.lock().await.get_mut(context) + { + key.layout = layout; + key.sent.feedback = None; + previous_feedback = None; + } + let delta = feedback_delta(previous_feedback.as_ref(), &payload); + if !feedback_is_blank(&delta) + && sender.set_feedback(context, &delta).is_ok() + && let Some(key) = self.runtime.keys.lock().await.get_mut(context) + { + key.sent.feedback = Some(payload); + } return; } if !multi { let image = render::key_image(kind, &visual, &global.fg_color); - let _ = sender.set_image(context, Some(&image), Target::HardwareAndSoftware, None); - let _ = sender.set_title(context, Some(&title), Target::HardwareAndSoftware, None); + let both_states = kind.has_toggle_state(); + let image_changed = sent.image.as_deref() != Some(image.as_str()); + let title_changed = sent.title.as_deref() != Some(title.as_str()); + if (image_changed || title_changed) + && publish_face( + &sender, + context, + both_states, + image_changed.then_some(image.as_str()), + title_changed.then_some(title.as_str()), + ) + && let Some(key) = self.runtime.keys.lock().await.get_mut(context) + { + if image_changed { + key.sent.image = Some(image); + } + if title_changed { + key.sent.title = Some(title); + } + } } if kind.has_toggle_state() { let active = render::hardware_state(visual.iter().map(|segment| segment.state)); - let _ = sender.set_state(context, active); + if sent.state != Some(active) + && sender.set_state(context, active).is_ok() + && let Some(key) = self.runtime.keys.lock().await.get_mut(context) + { + key.sent.state = Some(active); + } } } @@ -561,15 +548,30 @@ impl AppState { Some((key.kind, key.settings.clone())) } - async fn failed_ids(&self, context: &str) -> HashSet { - self.runtime - .errors - .lock() - .await - .iter() - .filter(|(key, _)| key.0 == context) - .map(|(key, _)| key.1.clone()) - .collect() + async fn appearance( + &self, + kind: ActionKind, + settings: &ActionSettings, + id: &str, + ) -> Appearance { + let snapshot = self.pool.snapshot(id).await.unwrap_or_default(); + let connected = matches!( + self.pool.status(id).await, + Some(ConnectionStatus::Connected) + ) && snapshot.connected; + let face = ops::face(kind, settings.params_for(id), &snapshot); + let state = if !connected { + SegmentState::Disconnected + } else if face.active { + SegmentState::Active + } else { + SegmentState::Off + }; + Appearance { + state, + title: face.title, + indicator: face.indicator, + } } async fn mark_dirty(&self, context: &str) { @@ -631,15 +633,85 @@ impl AppState { #[cfg(test)] async fn segment_state(&self, context: &str, id: &str) -> Option { - self.runtime - .keys - .lock() - .await - .get(context) - .and_then(|key| key.segments.get(id).copied()) + let (kind, settings) = self.key_settings(context).await?; + let targets = self.targets_for(&settings).await; + if !targets.iter().any(|target| target == id) { + return None; + } + Some(self.appearance(kind, &settings, id).await.state) } } +struct Appearance { + state: SegmentState, + title: String, + indicator: u8, +} + +/// Keys that differ from the feedback Stream Deck already has. +/// +/// A timecode tick must not resend the filename. Stream Deck redraws every +/// property in the payload, and repeating an unchanged title blanks it. +fn feedback_delta(previous: Option<&Value>, next: &Value) -> Value { + let Some(previous) = previous else { + return next.clone(); + }; + let (Some(prev), Some(next_map)) = (previous.as_object(), next.as_object()) else { + return if previous == next { + json!({}) + } else { + next.clone() + }; + }; + let mut delta = serde_json::Map::new(); + for (key, value) in next_map { + if prev.get(key) != Some(value) { + delta.insert(key.clone(), value.clone()); + } + } + Value::Object(delta) +} + +fn feedback_is_blank(value: &Value) -> bool { + value.as_object().is_some_and(|map| map.is_empty()) +} + +/// `setImage` / `setTitle` for the current face. +/// +/// Toggle actions have two hardware states. A title sent without a state index +/// lands on the state that is visible now, so the other state stays blank and +/// the text vanishes on the next `setState`. +fn publish_face( + sender: &CommandSender, + context: &str, + both_states: bool, + image: Option<&str>, + title: Option<&str>, +) -> bool { + let states: &[Option] = if both_states { + &[Some(0), Some(1)] + } else { + &[None] + }; + for state in states { + if let Some(image) = image + && sender + .set_image(context, Some(image), Target::HardwareAndSoftware, *state) + .is_err() + { + return false; + } + if let Some(title) = title + && sender + .set_title(context, Some(title), Target::HardwareAndSoftware, *state) + .is_err() + { + return false; + } + } + true +} + fn join_titles(targets: &[String], titles: &HashMap) -> String { let mut unique = Vec::new(); for id in targets { @@ -691,6 +763,7 @@ mod tests { enabled: true, username: String::new(), password: String::new(), + update_interval_ms: hyperdeck_pool::DEFAULT_UPDATE_INTERVAL_MS, } } @@ -712,6 +785,22 @@ mod tests { } } + #[test] + fn feedback_delta_leaves_an_unchanged_filename_out() { + let previous = json!({ + "filename": "clip.mov", + "timecode": "01:02:03:04", + }); + let next = json!({ + "filename": "clip.mov", + "timecode": "01:02:03:05", + }); + let delta = feedback_delta(Some(&previous), &next); + assert_eq!(delta, json!({ "timecode": "01:02:03:05" })); + assert!(feedback_is_blank(&feedback_delta(Some(&next), &next))); + assert_eq!(feedback_delta(None, &next), next); + } + #[tokio::test] async fn only_the_recording_deck_lights_its_band() { let first = listen().await; diff --git a/crates/hyperdeck-pool/src/config.rs b/crates/hyperdeck-pool/src/config.rs index a575ab7..ac9aa16 100644 --- a/crates/hyperdeck-pool/src/config.rs +++ b/crates/hyperdeck-pool/src/config.rs @@ -17,6 +17,27 @@ pub struct HyperDeckInstanceConfig { pub username: String, #[serde(default)] pub password: String, + /// How often this deck is polled for transport state, in milliseconds. + #[serde(default = "default_update_interval_ms")] + pub update_interval_ms: u32, +} + +/// Default transport poll, faster than the previous fixed 500 ms. +pub const DEFAULT_UPDATE_INTERVAL_MS: u32 = 200; + +/// Lowest poll a connection card can request. +pub const MIN_UPDATE_INTERVAL_MS: u32 = 50; + +/// Highest poll a connection card can request. +pub const MAX_UPDATE_INTERVAL_MS: u32 = 5_000; + +fn default_update_interval_ms() -> u32 { + DEFAULT_UPDATE_INTERVAL_MS +} + +/// Keep a requested interval inside the range the session will actually use. +pub fn clamp_update_interval_ms(value: u32) -> u32 { + value.clamp(MIN_UPDATE_INTERVAL_MS, MAX_UPDATE_INTERVAL_MS) } fn default_port() -> u16 { diff --git a/crates/hyperdeck-pool/src/lib.rs b/crates/hyperdeck-pool/src/lib.rs index 4402012..d13063a 100644 --- a/crates/hyperdeck-pool/src/lib.rs +++ b/crates/hyperdeck-pool/src/lib.rs @@ -10,7 +10,10 @@ mod pool; mod snapshot; mod status; -pub use config::{HyperDeckInstanceConfig, TargetGroup, TargetSelector, resolve_targets}; +pub use config::{ + DEFAULT_UPDATE_INTERVAL_MS, HyperDeckInstanceConfig, MAX_UPDATE_INTERVAL_MS, + MIN_UPDATE_INTERVAL_MS, TargetGroup, TargetSelector, clamp_update_interval_ms, resolve_targets, +}; pub use message::DeckMessage; pub use pool::{CallError, HyperDeckPool, PoolEvent, PoolOptions}; pub use snapshot::{ClipRow, DeckSnapshot}; diff --git a/crates/hyperdeck-pool/src/pool.rs b/crates/hyperdeck-pool/src/pool.rs index cbec34a..587c4c4 100644 --- a/crates/hyperdeck-pool/src/pool.rs +++ b/crates/hyperdeck-pool/src/pool.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; use hyperdeck_tokio::core::RawMessage; @@ -8,11 +9,11 @@ use hyperdeck_tokio::core::session::{ClientEvent, ClientSession}; use hyperdeck_tokio::core::types::CodeClass; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::sync::{Mutex, broadcast, mpsc, oneshot}; +use tokio::sync::{Mutex, Notify, broadcast, mpsc, oneshot}; use tokio::time::{Instant, sleep, timeout}; use tokio_util::sync::CancellationToken; -use crate::config::{HyperDeckInstanceConfig, TargetGroup}; +use crate::config::{HyperDeckInstanceConfig, TargetGroup, clamp_update_interval_ms}; use crate::message::DeckMessage; use crate::snapshot::DeckSnapshot; use crate::status::{ConnectionStatus, InstanceStatus}; @@ -27,6 +28,9 @@ pub struct PoolOptions { pub initial_backoff: Duration, pub max_backoff: Duration, pub timecode_interval: Duration, + /// Kept for existing callers. The session polls each deck at + /// `HyperDeckInstanceConfig::update_interval_ms` instead. + pub transport_poll_interval: Duration, } impl Default for PoolOptions { @@ -37,6 +41,7 @@ impl Default for PoolOptions { initial_backoff: Duration::from_secs(1), max_backoff: Duration::from_secs(30), timecode_interval: Duration::from_millis(100), + transport_poll_interval: Duration::from_millis(200), } } } @@ -50,6 +55,7 @@ impl PoolOptions { initial_backoff: Duration::from_millis(20), max_backoff: Duration::from_millis(50), timecode_interval: Duration::from_millis(20), + transport_poll_interval: Duration::from_millis(50), } } } @@ -89,6 +95,8 @@ struct Slot { commands: mpsc::Sender, cancel: CancellationToken, status: ConnectionStatus, + poll_ms: Arc, + poll_wake: Arc, } struct Inner { @@ -147,9 +155,16 @@ impl HyperDeckPool { for config in &configs { let restart = match inner.slots.get(&config.id) { None => true, - Some(slot) => slot.config != *config, + Some(slot) => !same_session(&slot.config, config), }; if !restart { + if let Some(slot) = inner.slots.get_mut(&config.id) { + let next = clamp_update_interval_ms(config.update_interval_ms); + if slot.poll_ms.swap(next, Ordering::Relaxed) != next { + slot.poll_wake.notify_one(); + } + slot.config = config.clone(); + } continue; } if let Some(slot) = inner.slots.remove(&config.id) { @@ -274,11 +289,17 @@ impl HyperDeckPool { let (commands, rx) = mpsc::channel(32); let snapshot = Arc::new(Mutex::new(DeckSnapshot::default())); let cancel = CancellationToken::new(); + let poll_ms = Arc::new(AtomicU32::new(clamp_update_interval_ms( + config.update_interval_ms, + ))); + let poll_wake = Arc::new(Notify::new()); let events = self.events.clone(); let options = self.options.clone(); let task_snapshot = Arc::clone(&snapshot); let task_cancel = cancel.clone(); let task_config = config.clone(); + let task_poll_ms = Arc::clone(&poll_ms); + let task_poll_wake = Arc::clone(&poll_wake); let pool = self.clone(); tokio::spawn(async move { supervise( @@ -289,6 +310,8 @@ impl HyperDeckPool { events, options, pool, + task_poll_ms, + task_poll_wake, ) .await; }); @@ -298,18 +321,38 @@ impl HyperDeckPool { commands, cancel, status: ConnectionStatus::Connecting, + poll_ms, + poll_wake, } } } +/// Host, credentials, and enablement identify the TCP session. +/// A poll-interval edit keeps that session and only wakes the pump. +fn same_session(current: &HyperDeckInstanceConfig, next: &HyperDeckInstanceConfig) -> bool { + current.id == next.id + && current.name == next.name + && current.host == next.host + && current.port == next.port + && current.color == next.color + && current.enabled == next.enabled + && current.username == next.username + && current.password == next.password +} + fn disabled_slot(config: HyperDeckInstanceConfig) -> Slot { let (commands, _rx) = mpsc::channel(1); + let poll_ms = Arc::new(AtomicU32::new(clamp_update_interval_ms( + config.update_interval_ms, + ))); Slot { config, snapshot: Arc::new(Mutex::new(DeckSnapshot::default())), commands, cancel: CancellationToken::new(), status: ConnectionStatus::Disabled, + poll_ms, + poll_wake: Arc::new(Notify::new()), } } @@ -330,6 +373,8 @@ async fn supervise( events: broadcast::Sender, options: PoolOptions, pool: HyperDeckPool, + poll_ms: Arc, + poll_wake: Arc, ) { let mut backoff = options.initial_backoff; loop { @@ -345,6 +390,8 @@ async fn supervise( &events, &options, &pool, + &poll_ms, + &poll_wake, ) .await { @@ -381,6 +428,8 @@ async fn run_session( events: &broadcast::Sender, options: &PoolOptions, pool: &HyperDeckPool, + poll_ms: &Arc, + poll_wake: &Arc, ) -> Result<(), CallError> { let stream = tokio::select! { _ = cancel.cancelled() => return Ok(()), @@ -398,8 +447,12 @@ async fn run_session( events: events.clone(), id: config.id.clone(), options: options.clone(), + poll_ms: Arc::clone(poll_ms), + poll_wake: Arc::clone(poll_wake), waiting: false, coalesce: false, + clips_version: 2, + refresh_clips: false, }; link.read_preamble().await?; link.handshake(config).await?; @@ -421,8 +474,17 @@ struct Link { events: broadcast::Sender, id: String, options: PoolOptions, + poll_ms: Arc, + poll_wake: Arc, waiting: bool, coalesce: bool, + clips_version: u8, + refresh_clips: bool, +} + +enum Pending { + Call(oneshot::Sender>), + BackgroundQuery, } impl Link { @@ -452,11 +514,25 @@ impl Link { self.roundtrip(b"remote: override: true\r\n".to_vec()) .await?; self.roundtrip(notify_command()).await?; + match self.roundtrip(b"clips get: version: 2\r\n".to_vec()).await { + Ok(_) => self.clips_version = 2, + Err(error @ (CallError::Closed | CallError::Timeout)) => return Err(error), + Err(error) => { + tracing::debug!(id = %self.id, %error, "clips get version 2 skipped"); + self.clips_version = 1; + match self.roundtrip(b"clips get\r\n".to_vec()).await { + Ok(_) => {} + Err(error @ (CallError::Closed | CallError::Timeout)) => return Err(error), + Err(error) => { + tracing::debug!(id = %self.id, %error, "clips get skipped"); + } + } + } + } for query in [ "device info\r\n", "transport info\r\n", "slot info\r\n", - "clips get\r\n", "disk list\r\n", "configuration\r\n", "playrange\r\n", @@ -469,7 +545,7 @@ impl Link { "external drive selected\r\n", ] { match self.roundtrip(query.as_bytes().to_vec()).await { - Ok(message) => self.apply_message(message).await, + Ok(_) => {} Err(error @ (CallError::Closed | CallError::Timeout)) => return Err(error), Err(error) => { tracing::debug!(id = %self.id, %error, query, "hyperdeck query skipped"); @@ -484,14 +560,17 @@ impl Link { commands: &mut mpsc::Receiver, cancel: &CancellationToken, ) -> Result<(), CallError> { - let mut pending: Option>> = None; + let mut pending: Option = None; let mut deadline: Option = None; let mut buf = [0u8; 2048]; + let mut next_poll = Instant::now() + self.poll_period(); + let poll_wake = Arc::clone(&self.poll_wake); + let mut poll_changed = std::pin::pin!(poll_wake.notified()); loop { let idle = timeout(self.options.timecode_interval, std::future::pending::<()>()); tokio::select! { _ = cancel.cancelled() => { - if let Some(reply) = pending.take() { + if let Some(Pending::Call(reply)) = pending.take() { let _ = reply.send(Err(CallError::Closed)); } return Ok(()); @@ -499,7 +578,7 @@ impl Link { read = self.stream.read(&mut buf) => { let count = read.map_err(|error| CallError::Protocol(error.to_string()))?; if count == 0 { - if let Some(reply) = pending.take() { + if let Some(Pending::Call(reply)) = pending.take() { let _ = reply.send(Err(CallError::Closed)); } return Err(CallError::Closed); @@ -508,10 +587,12 @@ impl Link { .push(&buf[..count]) .map_err(|error| CallError::Protocol(error.to_string()))?; if let Some(message) = self.drain().await? - && let Some(reply) = pending.take() + && let Some(waiter) = pending.take() { deadline = None; - let _ = reply.send(accept(message)); + if let Pending::Call(reply) = waiter { + let _ = reply.send(accept(message)); + } } } incoming = commands.recv(), if pending.is_none() => { @@ -529,14 +610,14 @@ impl Link { let _ = call.reply.send(Err(CallError::Protocol(error.to_string()))); return Err(CallError::Protocol(error.to_string())); } - pending = Some(call.reply); + pending = Some(Pending::Call(call.reply)); deadline = Some(Instant::now() + self.options.command_timeout); } _ = sleep_until(deadline), if deadline.is_some() => { self.session.abandon(); self.waiting = false; deadline = None; - if let Some(reply) = pending.take() { + if let Some(Pending::Call(reply)) = pending.take() { let _ = reply.send(Err(CallError::Timeout)); } } @@ -544,10 +625,54 @@ impl Link { self.coalesce = false; let _ = self.events.send(PoolEvent::Updated { id: self.id.clone() }); } + _ = &mut poll_changed => { + poll_changed.set(poll_wake.notified()); + next_poll = Instant::now() + self.poll_period(); + } + _ = sleep_until(Some(next_poll)), if pending.is_none() => { + next_poll = Instant::now() + self.poll_period(); + if self.refresh_clips { + let command = if self.clips_version >= 2 { + b"clips get: version: 2\r\n".as_slice() + } else { + b"clips get\r\n".as_slice() + }; + self.session + .begin_command() + .map_err(|error| CallError::Protocol(error.to_string()))?; + self.waiting = true; + if let Err(error) = self.stream.write_all(command).await { + self.session.abandon(); + self.waiting = false; + return Err(CallError::Protocol(error.to_string())); + } + self.refresh_clips = false; + pending = Some(Pending::BackgroundQuery); + deadline = Some(Instant::now() + self.options.command_timeout); + continue; + } + self.session + .begin_command() + .map_err(|error| CallError::Protocol(error.to_string()))?; + self.waiting = true; + if let Err(error) = self.stream.write_all(b"transport info\r\n").await { + self.session.abandon(); + self.waiting = false; + return Err(CallError::Protocol(error.to_string())); + } + pending = Some(Pending::BackgroundQuery); + deadline = Some(Instant::now() + self.options.command_timeout); + } } } } + fn poll_period(&self) -> Duration { + Duration::from_millis(u64::from(clamp_update_interval_ms( + self.poll_ms.load(Ordering::Relaxed), + ))) + } + async fn roundtrip(&mut self, bytes: Vec) -> Result { self.session .begin_command() @@ -600,7 +725,11 @@ impl Link { match event { ClientEvent::Response(raw) => { self.waiting = false; - response = Some(decode(&raw)?); + let message = decode(&raw)?; + if !message.is_failure() { + self.apply_message(message.clone()).await; + } + response = Some(message); } ClientEvent::Notification(raw) | ClientEvent::ConnectionInfo(raw) => { self.apply_notice(&raw).await?; @@ -617,10 +746,19 @@ impl Link { } async fn apply_message(&mut self, message: DeckMessage) { + // 519 is a partial timeline-change notice. Fetch a complete 205 block + // before replacing the cached clip list. + if message.code == 519 { + self.refresh_clips = true; + return; + } let coalesced = is_timecode(&message); - { + let changed = { let mut snapshot = self.snapshot.lock().await; - snapshot.apply(&message); + snapshot.apply(&message) + }; + if !changed { + return; } if coalesced { self.coalesce = true; @@ -716,6 +854,7 @@ mod tests { enabled: true, username: String::new(), password: String::new(), + update_interval_ms: 50, } } @@ -751,6 +890,55 @@ mod tests { ); } + #[tokio::test] + async fn update_interval_changes_without_reconnecting() { + let demo = listen().await; + let pool = HyperDeckPool::new(PoolOptions::for_tests()); + let mut deck = config(demo.addr); + pool.reconcile(vec![deck.clone()], Vec::new()).await; + wait_connected(&pool).await; + deck.update_interval_ms = 200; + pool.reconcile(vec![deck], Vec::new()).await; + assert!( + matches!(pool.status("deck").await, Some(ConnectionStatus::Connected)), + "changing the poll interval should keep the session" + ); + } + + #[tokio::test] + async fn transport_poll_updates_playback_when_notifications_are_disabled() { + let demo = listen().await; + let pool = HyperDeckPool::new(PoolOptions::for_tests()); + pool.reconcile(vec![config(demo.addr)], Vec::new()).await; + wait_connected(&pool).await; + pool.call( + "deck", + "notify", + &[ + ("transport", "false"), + ("display timecode", "false"), + ("timeline position", "false"), + ], + ) + .await + .unwrap(); + pool.call("deck", "play", &[]).await.unwrap(); + assert_eq!(pool.snapshot("deck").await.unwrap().status(), "stopped"); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let snapshot = pool.snapshot("deck").await.unwrap(); + if snapshot.status() == "play" { + break; + } + assert!( + Instant::now() < deadline, + "transport poll did not refresh playback: {snapshot:?}" + ); + sleep(Duration::from_millis(20)).await; + } + } + #[tokio::test] async fn unsupported_command_returns_the_device_code() { let demo = listen().await; @@ -775,4 +963,16 @@ mod tests { pool.call("deck", "record", &[]).await.unwrap(); assert_eq!(pool.snapshot("deck").await.unwrap().status(), "record"); } + + #[tokio::test] + async fn handshake_query_response_fills_the_snapshot() { + let demo = listen().await; + let pool = HyperDeckPool::new(PoolOptions::for_tests()); + pool.reconcile(vec![config(demo.addr)], Vec::new()).await; + wait_connected(&pool).await; + let snapshot = pool.snapshot("deck").await.unwrap(); + assert_eq!(snapshot.transport_value("video format"), "1080p2997"); + assert_eq!(snapshot.model, "HyperDeck Rust"); + assert!(!snapshot.config_value("audio input").is_empty()); + } } diff --git a/crates/hyperdeck-pool/src/snapshot.rs b/crates/hyperdeck-pool/src/snapshot.rs index 13643a3..0e1a052 100644 --- a/crates/hyperdeck-pool/src/snapshot.rs +++ b/crates/hyperdeck-pool/src/snapshot.rs @@ -5,14 +5,26 @@ use serde::{Deserialize, Serialize}; use crate::message::DeckMessage; /// One timeline or disk clip row. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClipRow { pub id: String, pub name: String, + /// Clip start timecode, when the row included one. + #[serde(default)] + pub start: String, + /// Duration timecode, when the row included one. + #[serde(default)] + pub duration: String, + /// Timeline in point from a version 2 or 3 row. + #[serde(default)] + pub timeline_in: String, + /// Timeline out point from a version 2 or 3 row. + #[serde(default)] + pub timeline_out: String, } -/// Cached deck state built from queries and notifications. +/// Fields received from the deck. Queries and notifications both land here. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeckSnapshot { @@ -84,8 +96,14 @@ impl DeckSnapshot { }) } - /// Fold one server message into the cache. - pub fn apply(&mut self, message: &DeckMessage) { + /// Fold one server message into the cache. `true` when a field changed. + pub fn apply(&mut self, message: &DeckMessage) -> bool { + let previous = self.clone(); + self.fold(message); + self != &previous + } + + fn fold(&mut self, message: &DeckMessage) { let text = message.text.to_ascii_lowercase(); if message.code == 500 || text.contains("connection info") { if let Some(model) = message.get("model") { @@ -96,13 +114,13 @@ impl DeckSnapshot { } } if message.code == 208 || message.code == 508 || text.contains("transport info") { - replace_map(&mut self.transport, message); + apply_transport(&mut self.transport, message); } if text.contains("display timecode") || text.contains("timeline position") { merge_map(&mut self.transport, message); } if message.code == 202 || message.code == 502 || text.contains("slot info") { - replace_map(&mut self.slot, message); + apply_slot(&mut self.slot, message); } if message.code == 211 || message.code == 511 @@ -126,13 +144,13 @@ impl DeckSnapshot { replace_map(&mut self.playrange, message); } } - if message.code == 205 || text.contains("clips") { + if message.code == 205 { let rows = parse_rows(&message.body); if !rows.is_empty() { self.clips = rows; } } - if message.code == 206 || text.contains("disk list") { + if message.code == 206 { let rows = parse_rows(&message.body); if !rows.is_empty() { self.disk_clips = rows; @@ -163,20 +181,142 @@ fn replace_map(map: &mut BTreeMap, message: &DeckMessage) { merge_map(map, message); } +/// `208 transport info` replaces the transport. A `508` notice replaces the +/// fields it carries, but an omitted playback timecode stays. That timecode +/// arrives on its own notice, and clearing it here blanks the key until then. +fn apply_transport(map: &mut BTreeMap, message: &DeckMessage) { + if message.params.is_empty() { + return; + } + if message.code == 208 { + replace_map(map, message); + return; + } + // Keep the timecode pair together. A notice that updates `timecode` and + // omits `timeline` must not reuse the old frame, or the delta is counted twice. + let timecode_omitted = message.get("timecode").is_none(); + let kept: Vec<(String, String)> = [ + "display timecode", + "timecode", + "timeline position", + "timeline", + ] + .into_iter() + .filter_map(|key| { + if message.get(key).is_some() { + return None; + } + if matches!(key, "timeline" | "timeline position") && !timecode_omitted { + return None; + } + map.get(key).map(|value| (key.to_string(), value.clone())) + }) + .collect(); + replace_map(map, message); + for (key, value) in kept { + map.insert(key, value); + } +} + +/// `202 slot info` replaces the slot. A `502` notice only carries the fields +/// the deck included, so an omitted recording time stays at the last value. +fn apply_slot(map: &mut BTreeMap, message: &DeckMessage) { + if message.params.is_empty() { + return; + } + if message.code == 202 { + replace_map(map, message); + return; + } + merge_map(map, message); + let status = message.get("status").unwrap_or(""); + if matches!(status, "empty" | "mounting" | "error") { + for key in [ + "recording time", + "remaining size", + "total size", + "volume name", + ] { + if message.get(key).is_none() { + map.remove(key); + } + } + } +} + fn merge_map(map: &mut BTreeMap, message: &DeckMessage) { for (key, value) in &message.params { map.insert(key.clone(), value.clone()); } } +struct DescribedClip { + name: String, + start: String, + duration: String, + timeline_in: String, + timeline_out: String, +} + +fn describe_clip_row(rest: &str) -> DescribedClip { + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let mut described = DescribedClip { + name: clip_name_from_row(rest), + start: String::new(), + duration: String::new(), + timeline_in: String::new(), + timeline_out: String::new(), + }; + if tokens.is_empty() { + return described; + } + let name_at = tokens + .iter() + .position(|token| !is_timecode(token)) + .unwrap_or(tokens.len()); + if name_at >= 4 { + described.start = tokens[0].to_string(); + described.duration = tokens[1].to_string(); + described.timeline_in = tokens[2].to_string(); + described.timeline_out = tokens[3].to_string(); + return described; + } + if name_at == 2 { + described.start = tokens[0].to_string(); + described.duration = tokens[1].to_string(); + return described; + } + let last = tokens.len() - 1; + if tokens.len() >= 3 && is_timecode(tokens[last]) && is_timecode(tokens[last - 1]) { + described.start = tokens[last - 1].to_string(); + described.duration = tokens[last].to_string(); + return described; + } + if is_timecode(tokens[last]) { + described.duration = tokens[last].to_string(); + } + described +} + fn clip_name_from_row(rest: &str) -> String { let tokens: Vec<&str> = rest.split_whitespace().collect(); + if tokens.is_empty() { + return String::new(); + } + // Version 1: `{name} {start} {duration}`. if tokens.len() >= 3 && is_timecode(tokens[tokens.len() - 1]) && is_timecode(tokens[tokens.len() - 2]) { return tokens[..tokens.len() - 2].join(" "); } + // Version 2 and 3: timecodes first, then the file name. + let name_at = tokens.iter().position(|token| !is_timecode(token)); + if let Some(index) = name_at + && index > 0 + { + return tokens[index..].join(" "); + } tokens.first().copied().unwrap_or("").to_string() } @@ -203,9 +343,14 @@ fn parse_rows(body: &str) -> Vec { if id.is_empty() || !id.chars().all(|ch| ch.is_ascii_digit()) { continue; } + let described = describe_clip_row(rest); rows.push(ClipRow { id: id.to_string(), - name: clip_name_from_row(rest), + name: described.name, + start: described.start, + duration: described.duration, + timeline_in: described.timeline_in, + timeline_out: described.timeline_out, }); } rows @@ -260,6 +405,58 @@ mod tests { assert_eq!(snapshot.transport_value("display timecode"), "01:02:03:04"); } + #[test] + fn transport_notice_keeps_the_playback_timecode_it_omits() { + let mut snapshot = DeckSnapshot::default(); + snapshot.apply(&DeckMessage { + code: 208, + text: "transport info".into(), + body: String::new(), + params: vec![ + ("status".into(), "play".into()), + ("speed".into(), "100".into()), + ("display timecode".into(), "00:01:02:03".into()), + ("timecode".into(), "00:01:02:03".into()), + ("timeline".into(), "1500".into()), + ("loop".into(), "true".into()), + ], + }); + snapshot.apply(&DeckMessage { + code: 508, + text: "transport info".into(), + body: String::new(), + params: vec![ + ("status".into(), "play".into()), + ("speed".into(), "200".into()), + ], + }); + assert_eq!(snapshot.speed(), 200); + assert_eq!(snapshot.transport_value("loop"), ""); + assert_eq!(snapshot.transport_value("display timecode"), "00:01:02:03"); + assert_eq!(snapshot.transport_value("timecode"), "00:01:02:03"); + assert_eq!(snapshot.transport_value("timeline"), "1500"); + snapshot.apply(&DeckMessage { + code: 513, + text: "display timecode".into(), + body: String::new(), + params: vec![("display timecode".into(), "00:01:02:04".into())], + }); + assert_eq!(snapshot.transport_value("display timecode"), "00:01:02:04"); + assert_eq!(snapshot.transport_value("timecode"), "00:01:02:03"); + snapshot.apply(&DeckMessage { + code: 208, + text: "transport info".into(), + body: String::new(), + params: vec![ + ("status".into(), "stopped".into()), + ("speed".into(), "0".into()), + ], + }); + assert_eq!(snapshot.transport_value("display timecode"), ""); + assert_eq!(snapshot.transport_value("timecode"), ""); + assert_eq!(snapshot.transport_value("timeline"), ""); + } + #[test] fn clip_rows_keep_the_full_name_before_the_timecodes() { let mut snapshot = DeckSnapshot::default(); @@ -270,6 +467,8 @@ mod tests { params: Vec::new(), }); assert_eq!(snapshot.clips[0].name, "My Clip"); + assert_eq!(snapshot.clips[0].start, "00:00:00:00"); + assert_eq!(snapshot.clips[0].duration, "00:00:10:00"); assert_eq!(snapshot.clips[1].name, "other.mov"); snapshot.apply(&DeckMessage { code: 206, @@ -278,5 +477,131 @@ mod tests { params: Vec::new(), }); assert_eq!(snapshot.disk_clips[0].name, "clip.mov"); + assert_eq!(snapshot.disk_clips[0].duration, "00:00:10:00"); + snapshot.apply(&DeckMessage { + code: 205, + text: "clips info".into(), + body: "1: 00:00:00:00 00:00:10:00 00:00:01:00 00:00:08:00 folder/My Clip.mov\r\n" + .into(), + params: Vec::new(), + }); + assert_eq!(snapshot.clips[0].name, "folder/My Clip.mov"); + assert_eq!(snapshot.clips[0].start, "00:00:00:00"); + assert_eq!(snapshot.clips[0].duration, "00:00:10:00"); + assert_eq!(snapshot.clips[0].timeline_in, "00:00:01:00"); + assert_eq!(snapshot.clips[0].timeline_out, "00:00:08:00"); + } + + #[test] + fn partial_clip_notice_does_not_replace_the_complete_clip_list() { + let mut snapshot = DeckSnapshot::default(); + snapshot.apply(&DeckMessage { + code: 205, + text: "clips info".into(), + body: "1: A.mov 00:00:00:00 00:00:10:00\r\n2: B.mov 00:00:10:00 00:00:05:00\r\n".into(), + params: Vec::new(), + }); + let changed = snapshot.apply(&DeckMessage { + code: 519, + text: "clips info".into(), + body: "type: update\r\n2: B.mov 00:00:10:00 00:00:06:00\r\n".into(), + params: vec![("type".into(), "update".into())], + }); + assert!(!changed); + assert_eq!(snapshot.clips.len(), 2); + assert_eq!(snapshot.clips[1].duration, "00:00:05:00"); + } + + #[test] + fn slot_notice_keeps_recording_time_the_deck_already_sent() { + let mut snapshot = DeckSnapshot::default(); + snapshot.apply(&DeckMessage { + code: 202, + text: "slot info".into(), + body: String::new(), + params: vec![ + ("slot id".into(), "1".into()), + ("status".into(), "mounted".into()), + ("recording time".into(), "3600".into()), + ("remaining size".into(), "1000".into()), + ], + }); + snapshot.apply(&DeckMessage { + code: 502, + text: "slot info".into(), + body: String::new(), + params: vec![ + ("slot id".into(), "1".into()), + ("status".into(), "mounted".into()), + ("volume name".into(), "Media".into()), + ], + }); + assert_eq!(snapshot.slot_value("recording time"), "3600"); + assert_eq!(snapshot.slot_value("remaining size"), "1000"); + assert_eq!(snapshot.slot_value("volume name"), "Media"); + snapshot.apply(&DeckMessage { + code: 502, + text: "slot info".into(), + body: String::new(), + params: vec![("recording time".into(), "3599".into())], + }); + assert_eq!(snapshot.slot_value("recording time"), "3599"); + assert_eq!(snapshot.slot_value("remaining size"), "1000"); + } + + #[test] + fn empty_slot_notice_drops_the_previous_recording_time() { + let mut snapshot = DeckSnapshot::default(); + snapshot.apply(&DeckMessage { + code: 202, + text: "slot info".into(), + body: String::new(), + params: vec![ + ("status".into(), "mounted".into()), + ("recording time".into(), "3600".into()), + ("remaining size".into(), "1000".into()), + ("volume name".into(), "Media".into()), + ], + }); + snapshot.apply(&DeckMessage { + code: 502, + text: "slot info".into(), + body: String::new(), + params: vec![ + ("slot id".into(), "1".into()), + ("status".into(), "empty".into()), + ], + }); + assert_eq!(snapshot.slot_value("recording time"), ""); + assert_eq!(snapshot.slot_value("remaining size"), ""); + assert_eq!(snapshot.slot_value("volume name"), ""); + assert_eq!(snapshot.slot_value("status"), "empty"); + } + + #[test] + fn slot_query_replaces_the_previous_slot() { + let mut snapshot = DeckSnapshot::default(); + snapshot.apply(&DeckMessage { + code: 202, + text: "slot info".into(), + body: String::new(), + params: vec![ + ("recording time".into(), "3600".into()), + ("remaining size".into(), "1000".into()), + ], + }); + snapshot.apply(&DeckMessage { + code: 202, + text: "slot info".into(), + body: String::new(), + params: vec![ + ("slot id".into(), "2".into()), + ("status".into(), "mounted".into()), + ("recording time".into(), "10".into()), + ], + }); + assert_eq!(snapshot.slot_value("recording time"), "10"); + assert_eq!(snapshot.slot_value("remaining size"), ""); + assert_eq!(snapshot.slot_value("slot id"), "2"); } } diff --git a/pi/package-lock.json b/pi/package-lock.json index 22ac855..f5976da 100644 --- a/pi/package-lock.json +++ b/pi/package-lock.json @@ -1,12 +1,12 @@ { "name": "hyperdeck-property-inspector", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hyperdeck-property-inspector", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@mikanseilaboratory/streamdeck-pi-client": "0.1.0", "react": "^18.3.1", diff --git a/pi/package.json b/pi/package.json index 980da66..e793397 100644 --- a/pi/package.json +++ b/pi/package.json @@ -1,7 +1,7 @@ { "name": "hyperdeck-property-inspector", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/pi/public/assets/caret.svg b/pi/public/assets/caret.svg new file mode 100644 index 0000000..932fff4 --- /dev/null +++ b/pi/public/assets/caret.svg @@ -0,0 +1,3 @@ + + + diff --git a/pi/public/assets/check.svg b/pi/public/assets/check.svg new file mode 100644 index 0000000..bbfec96 --- /dev/null +++ b/pi/public/assets/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/pi/public/assets/rcheck.svg b/pi/public/assets/rcheck.svg new file mode 100644 index 0000000..c01a443 --- /dev/null +++ b/pi/public/assets/rcheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/pi/src/App.tsx b/pi/src/App.tsx index fd703fb..f1ae9ec 100644 --- a/pi/src/App.tsx +++ b/pi/src/App.tsx @@ -39,11 +39,11 @@ const emptyParams = (): ActionParams => ({ value: "", thresholdSeconds: 60, statusField: "status", + timeBasis: "clip", raw: "", rawRotateUp: "", rawRotateDown: "", rawPush: "", - rawTouch: "", step: 0, direction: "next", activeColor: "", @@ -136,11 +136,14 @@ export function App() { useEffect(() => { for (const listener of listenersRef.current) listener(statuses); + }, [statuses]); + + useEffect(() => { const child = configWindowRef.current; if (child && !child.closed && child.opener === window) { - child.refreshHyperdeckConfig?.(settingsRef.current, statuses); + child.refreshHyperdeckConfig?.(settingsRef.current, statusRef.current); } - }, [statuses, global.settings]); + }, [global.settings]); useEffect(() => { send({ type: "ready" }); @@ -237,69 +240,78 @@ function TargetPicker({ value: TargetSelector; onChange: (value: TargetSelector) => void; }) { - const mode = value.kind === "group" ? "group" : value.kind === "instances" ? "instances" : "all"; + const [preferMultiple, setPreferMultiple] = useState(value.kind === "instances" && value.ids.length !== 1); + useEffect(() => { + if (value.kind !== "instances") setPreferMultiple(false); + }, [value]); + const mode = preferMultiple ? "multiple" : targetMode(value); + const selected = value.kind === "instances" ? value.ids : []; + const missingGroup = value.kind === "group" && !groups.some((group) => group.id === value.id); + const missingInstance = + value.kind === "instances" && value.ids.length === 1 && !instances.some((instance) => instance.id === value.ids[0]); return ( <> -
+
Send to
- {value.kind === "group" && ( -
-
Group
- -
- )} - {value.kind === "instances" && - instances.map((instance) => { - const status = statuses.find((item) => item.id === instance.id)?.status?.kind; - return ( -
-
{status ?? ""}
-
- + {mode === "multiple" && ( +
+
Decks
+
+ {instances.length === 0 && No decks yet} + {instances.map((instance) => { + const id = `target-${instance.id}`; + const on = selected.includes(instance.id); + const status = statuses.find((item) => item.id === instance.id)?.status?.kind; + return ( +
{ - const ids = event.target.checked - ? [...value.ids, instance.id] - : value.ids.filter((id) => id !== instance.id); + checked={on} + onChange={() => { + const ids = on ? selected.filter((item) => item !== instance.id) : [...selected, instance.id]; onChange({ kind: "instances", ids }); }} /> -
-
- ); - })} +
+ ); + })} +
+
+ )} ); } @@ -324,7 +336,8 @@ function Fields({ const shared = settings.common?.sharedParams !== false; const targeted = targetedIds(instances, groups, target); const [editor, setEditor] = useState(targeted[0] ?? ""); - const params = shared ? settings.shared : settings.params?.[editor] ?? settings.shared; + const activeEditor = targeted.includes(editor) ? editor : (targeted[0] ?? ""); + const params = shared ? settings.shared : settings.params?.[activeEditor] ?? settings.shared; const clips = uniqueClips(decks); const update = (patch: Partial) => { setSettings((previous) => { @@ -335,7 +348,7 @@ function Fields({ ...previous, params: { ...previous.params, - [editor]: { ...(previous.params?.[editor] ?? previous.shared), ...patch } + [activeEditor]: { ...(previous.params?.[activeEditor] ?? previous.shared), ...patch } } }; }); @@ -343,7 +356,7 @@ function Fields({ return ( <> {!shared && ( - + {targeted.map((id) => (
@@ -214,6 +247,24 @@ function InstanceCard({ /> Enabled +
@@ -278,8 +352,12 @@ function Groups({