diff --git a/crates/hypercolor-ui/src/app.rs b/crates/hypercolor-ui/src/app.rs index 982a984a6..e9acea577 100644 --- a/crates/hypercolor-ui/src/app.rs +++ b/crates/hypercolor-ui/src/app.rs @@ -38,7 +38,7 @@ use crate::preferences::PreferencesStore; use crate::preview_telemetry::{PreviewPresenterTelemetry, PreviewTelemetryContext}; use crate::thumbnails::{self, ThumbnailStore}; use crate::toasts; -use crate::ws::messages::scene_event_affects_active_effect; +use crate::ws::messages::scene_event_requires_effect_refresh; use crate::ws::{ AudioLevel, BackpressureNotice, CanvasFrame, ControlSurfaceEventHint, DeviceEventHint, EffectErrorHint, ExtensionEventHint, InputInjectEdge, InputSourceStatusEventHint, @@ -934,10 +934,14 @@ pub fn app_view(ext: UiExtensions) -> impl IntoView { .set_active_scene_mutation_mode .set(Some(scene_mutation_mode)); } - if current_scene_event - .as_ref() - .is_none_or(scene_event_affects_active_effect) - { + let target = effects_ctx.active_effect_target.get_untracked(); + if current_scene_event.as_ref().is_none_or(|current| { + scene_event_requires_effect_refresh( + previous_scene_event.as_ref().and_then(Option::as_ref), + current, + target.as_ref().map(|target| target.zone_id.as_str()), + ) + }) { effects_ctx.refresh_active_effect(); } diff --git a/crates/hypercolor-ui/src/components/layer_panel/controls.rs b/crates/hypercolor-ui/src/components/layer_panel/controls.rs index 2948a03d0..1ba61d1c0 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/controls.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/controls.rs @@ -37,35 +37,57 @@ const LAYER_CONTROLS_DEBOUNCE_MS: f64 = 120.0; #[component] pub fn EffectControlsSection( zone_id: String, - layer: SceneLayer, + layer: Signal, + effect_cache: super::LayerEffectCache, on_layers_mutated: Callback<()>, ) -> impl IntoView { let LayerSource::Effect { effect_id, controls, .. - } = layer.source.clone() + } = layer.get_untracked().source else { return ().into_any(); }; let effect_id_str = effect_id.to_string(); + let ws = expect_context::(); let detail = api::daemon_resource({ let effect_id_str = effect_id_str.clone(); move || { let effect_id_str = effect_id_str.clone(); - async move { api::fetch_effect_detail(&effect_id_str).await } + let generation = ws.connection_generation.get(); + async move { + if let Some((epoch, detail)) = + effect_cache.with_value(|cache| cache.get(&effect_id_str).cloned()) + && epoch == generation + { + return Ok(detail); + } + let detail = api::fetch_effect_detail(&effect_id_str).await?; + effect_cache.try_update_value(|cache| { + cache.insert(effect_id_str, (generation, detail.clone())); + }); + Ok::<_, api::ApiError>(detail) + } } }); + // A same-effect layer replacement retires its write session, but its + // controls need not collapse to a loading placeholder while it mounts. + let detail_value = Signal::derive(move || { + detail.get().and_then(Result::ok).or_else(|| { + effect_cache + .with_value(|cache| cache.get(&effect_id_str).map(|(_, detail)| detail.clone())) + }) + }); let defs = Signal::derive(move || { - detail + detail_value .get() - .and_then(Result::ok) .map(|detail| detail.controls) .unwrap_or_default() }); let screen_reactive = Signal::derive(move || { - detail.get().and_then(Result::ok).is_some_and(|detail| { + detail_value.get().is_some_and(|detail| { detail .tags .iter() @@ -76,7 +98,7 @@ pub fn EffectControlsSection( // Optimistic local control values. Layer identity fences stale patches, // so the canonical control route carries no revision token. let (values, set_values) = signal(controls); - let layer_id = layer.id.to_string(); + let layer_id = layer.get_untracked().id.to_string(); let session_target = Signal::stored(Some(format!("{zone_id}:{layer_id}"))); let patch: ControlPatchFn = Arc::new({ @@ -103,39 +125,44 @@ pub fn EffectControlsSection( on_error: Callback::new(|error: String| { toasts::toast_error(&format!("Effect controls failed: {error}")); }), - recover: on_layers_mutated, + recover: Callback::new(move |()| { + // A rejected edit can leave the server snapshot unchanged, so + // memo equality will not trigger the normal reconciliation effect. + if let LayerSource::Effect { controls, .. } = layer.get_untracked().source { + set_values.set(controls); + } + on_layers_mutated.run(()); + }), on_committed: None, flush_guard: None, }); + Effect::new(move |_| { + if let LayerSource::Effect { controls, .. } = layer.get().source { + session.reconcile_values.run(controls); + } + }); let on_change = session.on_change; view! {
"Effect controls" - {move || { - if detail.get().is_none() { - view! { -
- "Loading controls…" -
- } - .into_any() - } else { - view! { - - - } - .into_any() + "Loading controls…"
} - }} + > + + + } .into_any() @@ -147,26 +174,31 @@ pub fn EffectControlsSection( #[component] pub fn MediaPlaybackSection( zone_id: String, - layer: SceneLayer, - revision: u64, + layer: Signal, + revision: Signal, on_layers_mutated: Callback<()>, ) -> impl IntoView { - let LayerSource::Media { playback, .. } = layer.source.clone() else { + let LayerSource::Media { playback, .. } = layer.get_untracked().source else { return ().into_any(); }; - let speed = playback.speed; - let loop_mode = playback.loop_mode; - let auto_play = playback.auto_play; + let playback = Memo::new(move |_| match layer.get().source { + LayerSource::Media { playback, .. } => playback, + _ => playback.clone(), + }); // Rebuild the layer with a mutated `MediaPlayback` and push it. let push = { - let layer = layer.clone(); move |mutate: &dyn Fn(&mut hypercolor_types::layer::MediaPlayback)| { - let mut next = layer.clone(); + let mut next = layer.get_untracked(); if let LayerSource::Media { playback, .. } = &mut next.source { mutate(playback); } - update_layer(zone_id.clone(), next, revision, on_layers_mutated); + update_layer( + zone_id.clone(), + next, + revision.get_untracked(), + on_layers_mutated, + ); } }; let push_speed = push.clone(); @@ -177,12 +209,14 @@ pub fn MediaPlaybackSection( ("ping_pong".to_owned(), "Ping-pong".to_owned()), ("none".to_owned(), "Play once".to_owned()), ]; - let loop_value = match loop_mode { - LoopMode::Loop => "loop", - LoopMode::PingPong => "ping_pong", - LoopMode::None => "none", - } - .to_owned(); + let loop_value = move || { + match playback.get().loop_mode { + LoopMode::Loop => "loop", + LoopMode::PingPong => "ping_pong", + LoopMode::None => "none", + } + .to_owned() + }; view! {
@@ -195,17 +229,17 @@ pub fn MediaPlaybackSection( max="4" step="0.05" class="w-full accent-accent" - prop:value=format!("{speed:.2}") + prop:value=move || format!("{:.2}", playback.get().speed) on:change=move |event| { if let Some(value) = Change::from_event(event).value::() { push_speed(&|playback| playback.speed = value.clamp(0.1, 4.0)); } } /> - {format!("{speed:.2}×")} + {move || format!("{:.2}×", playback.get().speed)} "Auto-play" - +
} .into_any() } -/// A compact Luminary toggle track — the switch visual without its own -/// label row, for embedding in a layer card. The card rebuilds on every -/// layer change, so a plain `bool` tracks state without a signal. +/// A compact toggle track that follows the current layer playback state. #[component] -pub fn LayerToggleTrack(on: bool) -> impl IntoView { +pub fn LayerToggleTrack(#[prop(into)] on: Signal) -> impl IntoView { view! { } diff --git a/crates/hypercolor-ui/src/components/layer_panel/mod.rs b/crates/hypercolor-ui/src/components/layer_panel/mod.rs index fcd374125..8fda324d2 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/mod.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/mod.rs @@ -51,6 +51,9 @@ use source::{ resolve_add_layer_targets, }; +/// Effect schemas survive layer replacements; a reconnect invalidates their epoch. +pub type LayerEffectCache = StoredValue>; + /// Layer-stack editor for one zone. See the module docs for the /// mount contract. #[component] @@ -66,6 +69,10 @@ pub fn LayerPanel( layers_resource: LocalResource>, on_layers_mutated: Callback<()>, ) -> impl IntoView { + let effect_cache = LayerEffectCache::new(HashMap::new()); + // Disclosure preference belongs to a visible stack slot, independently of + // the fresh layer authority minted by a whole-layer replacement. + let disclosures = StoredValue::new(HashMap::<(String, String, usize), bool>::new()); // Content selection is owned here, not driven by the host page — the // asset list backs both media-name resolution and the picker's Media tab. let assets_resource = api::daemon_resource(|| async { api::list_assets().await }); @@ -105,6 +112,31 @@ pub fn LayerPanel( .map(|stack| stack.revision) }); + let layers = Memo::new(move |_| { + layers_resource + .get() + .and_then(Result::ok) + .map(|stack| stack.items) + .unwrap_or_default() + }); + let scene_id = Memo::new(move |_| { + active_scene + .get() + .map(|scene| scene.id.to_string()) + .unwrap_or_default() + }); + let revision = Signal::derive(move || scene_revision.get().unwrap_or_default()); + let row_keys = Memo::new(move |_| { + let scene = scene_id.get(); + let zone = selected_zone_id.get().unwrap_or_default(); + layers + .get() + .into_iter() + .rev() + .map(|layer| (scene.clone(), zone.clone(), layer_mount_key(&layer))) + .collect::>() + }); + // Per-layer runtime health streams in over the WebSocket, independent // of the layer stack itself; an absent context means no health yet. let ws = use_context::(); @@ -251,98 +283,62 @@ pub fn LayerPanel( "Add layer" - }> - {move || match layers_resource.get() { - None => view! { }.into_any(), - Some(Err(error)) => view! { -
- {error.to_string()} -
- }.into_any(), - Some(Ok(stack)) if stack.items.is_empty() => { - // A screen may still be painting its stored - // default face; only the scene's own stack is - // empty, and the card above the panel says so. - let copy = if selected_zone_role.get() == Some(ZoneRole::Display) { - "No scene layers on this screen" - } else { - "No layers in this zone" - }; - view! { -
- {copy} -
- }.into_any() - } - Some(Ok(stack)) => { - // `try_get`: Suspense can re-poll this closure after - // the panel instance owning these memos is disposed - // (host swapped the mounted view mid-fetch). A plain - // `get` panics the whole reactive runtime there. - let (Some(names), Some(effect_name_map)) = - (media_names.try_get(), effect_names.try_get()) - else { - return view! { }.into_any(); - }; - let scene_id = active_scene - .get() - .map(|scene| scene.id.to_string()) - .unwrap_or_default(); - let zone_id = selected_zone_id.get().unwrap_or_default(); - let revision = stack.revision; - let total = stack.items.len(); - let mut rows = stack - .items - .iter() - .cloned() - .enumerate() - .collect::>(); - rows.reverse(); - // The Top/Bottom stack markers orient a real - // stack; with a single layer there is no - // ordering to convey, so they stay hidden. - let show_stack_markers = total > 1; + + + + {move || layers_resource.get().and_then(Result::err).map(|error| view! { +
{error.to_string()}
+ })} + +
+ {move || if selected_zone_role.get() == Some(ZoneRole::Display) { "No scene layers on this screen" } else { "No layers in this zone" }} +
+
+
+ 1)> +
"Top"
+
+ - {show_stack_markers.then(|| view! { -
- "Top" -
- })} - {rows.into_iter().map(|(stack_index, layer)| { - let row_health_key = layer_health_key( - &scene_id, - &zone_id, - &layer.id.to_string(), - ); - let row_health = Signal::derive(move || { - layer_health.with(|map| map.get(&row_health_key).cloned()) - }); - view! { - - } - }).collect_view()} - {show_stack_markers.then(|| view! { -
- "Bottom" -
- })} -
- }.into_any() + + } } - }} -
+ /> + 1)> +
"Bottom"
+
+ @@ -436,3 +432,16 @@ fn reorder_layer( } }); } + +/// Keep editors mounted across value/revision changes, but retire a session +/// when its layer or source identity changes. +#[must_use] +pub fn layer_mount_key(layer: &SceneLayer) -> (String, String) { + use hypercolor_types::layer::LayerSource; + let source = match &layer.source { + LayerSource::Effect { effect_id, .. } => format!("effect:{effect_id}"), + LayerSource::Media { asset_id, .. } => format!("media:{asset_id}"), + other => row::source_meta(other).2.to_owned(), + }; + (layer.id.to_string(), source) +} diff --git a/crates/hypercolor-ui/src/components/layer_panel/row.rs b/crates/hypercolor-ui/src/components/layer_panel/row.rs index 41085e276..f2619bc84 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/row.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/row.rs @@ -64,46 +64,34 @@ pub fn layer_title( #[component] pub fn LayerRow( zone_id: String, - layer: SceneLayer, - stack_index: usize, - total_layers: usize, - stack: Vec, - revision: u64, - media_names: HashMap, - effect_names: HashMap, + layer: Signal, + stack_index: Signal, + stack: Signal>, + revision: Signal, + media_names: Memo>, + effect_names: Memo>, + effect_cache: super::LayerEffectCache, + expanded: bool, + on_disclosure: Callback, #[prop(into)] health: Signal>, on_layers_mutated: Callback<()>, ) -> impl IntoView { - let (icon, accent_rgb, kind_word) = source_meta(&layer.source); - let title = layer_title(&layer, &media_names, &effect_names, kind_word); - let layer_id = layer.id.to_string(); - let is_effect = matches!(layer.source, LayerSource::Effect { .. }); - let is_media = matches!(layer.source, LayerSource::Media { .. }); - // A lone layer has nowhere to move; reorder appears only in a stack. - let show_reorder = total_layers > 1; - let can_move_up = stack_index + 1 < total_layers; - let can_move_down = stack_index > 0; - let opacity = layer.opacity; - let blend = layer.blend; - let fit = layer.transform.fit; - let brightness = layer.adjust.brightness; - let saturation = layer.adjust.saturation; - let tint_strength = layer.adjust.tint_strength; - let scale_x = layer.transform.scale[0]; - let scale_y = layer.transform.scale[1]; - - let blend_layer = layer.clone(); - let opacity_layer = layer.clone(); - let fit_layer = layer.clone(); - let brightness_layer = layer.clone(); - let saturation_layer = layer.clone(); - let tint_layer = layer.clone(); - let scale_x_layer = layer.clone(); - let scale_y_layer = layer.clone(); - let effect_layer = layer.clone(); - let media_layer = layer.clone(); - let move_up_stack = stack.clone(); - let move_down_stack = stack; + let initial = layer.get_untracked(); + let (icon, accent_rgb, kind_word) = source_meta(&initial.source); + let title = move || { + layer_title( + &layer.get(), + &media_names.get(), + &effect_names.get(), + kind_word, + ) + }; + let layer_id = initial.id.to_string(); + let is_effect = matches!(initial.source, LayerSource::Effect { .. }); + let is_media = matches!(initial.source, LayerSource::Media { .. }); + let show_reorder = move || stack.with(|layers| layers.len() > 1); + let can_move_up = move || stack_index.get() + 1 < stack.with(Vec::len); + let can_move_down = move || stack_index.get() > 0; let chip_style = format!("background: rgba({accent_rgb}, 0.14)"); let icon_style = format!("color: rgb({accent_rgb})"); @@ -130,24 +118,24 @@ pub fn LayerRow(
- {show_reorder + {let zone_id = zone_id.clone(); move || show_reorder() .then(|| { let zone_up = zone_id.clone(); let zone_down = zone_id.clone(); - let up_stack = move_up_stack.clone(); - let down_stack = move_down_stack.clone(); + let up_stack = stack; + let down_stack = stack; view! {