diff --git a/src/PepperDash.Essentials.Core/Routing/IRoutingSinkWithLayoutState.cs b/src/PepperDash.Essentials.Core/Routing/IRoutingSinkWithLayoutState.cs
new file mode 100644
index 00000000..a0ac9bec
--- /dev/null
+++ b/src/PepperDash.Essentials.Core/Routing/IRoutingSinkWithLayoutState.cs
@@ -0,0 +1,46 @@
+using System;
+
+namespace PepperDash.Essentials.Core;
+
+///
+/// Extends for devices that can report the current shape of
+/// their multiview canvas, along with the position/size, stacking order and routed source of every
+/// tile, in a generic, product-agnostic form. This is intentionally decoupled from the routing
+/// graph (nodes/edges/tie-lines) built from -
+/// it exists to support a separate visualization concern: a mock-up of what is actually displayed
+/// on the monitor fed by the decoder, suitable for a React UI or the developer tools Routing page.
+///
+public interface IRoutingSinkWithLayoutState : IRoutingSinkWithLayouts
+{
+ ///
+ /// Gets the current multiview canvas/tile layout, or null if no layout is currently active or
+ /// applicable (e.g. the device is not currently in a multiview mode).
+ ///
+ MultiviewLayoutState CurrentLayout { get; }
+
+ ///
+ /// Raised whenever the canvas shape, a tile's geometry/stacking order, or a tile's routed source
+ /// changes.
+ ///
+ event EventHandler LayoutChanged;
+}
+
+///
+/// Event arguments for .
+///
+public class MultiviewLayoutStateEventArgs : EventArgs
+{
+ ///
+ /// The current multiview layout state at the time this event was raised, or null if no layout
+ /// is currently active.
+ ///
+ public MultiviewLayoutState CurrentLayout { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MultiviewLayoutStateEventArgs(MultiviewLayoutState currentLayout)
+ {
+ CurrentLayout = currentLayout;
+ }
+}
diff --git a/src/PepperDash.Essentials.Core/Routing/MultiviewLayoutState.cs b/src/PepperDash.Essentials.Core/Routing/MultiviewLayoutState.cs
new file mode 100644
index 00000000..382adebe
--- /dev/null
+++ b/src/PepperDash.Essentials.Core/Routing/MultiviewLayoutState.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace PepperDash.Essentials.Core;
+
+///
+/// Describes the current shape of a multiview canvas and the position, size, stacking order and
+/// routed source of every visible tile within it. Fully product-agnostic (no dependency on any
+/// particular decoder/hardware type) and JSON-serializable so it can be sent as-is over the routing
+/// feedback WebSocket () and the routing devices/tie-lines
+/// HTTP snapshot (), for
+/// rendering a visual mock-up of what is actually displayed on the fed monitor.
+///
+public class MultiviewLayoutState
+{
+ ///
+ /// Width, in pixels, of the canvas that positions/sizes are expressed
+ /// against (typically the decoder's current output resolution width).
+ ///
+ [JsonProperty("canvasWidth")]
+ public int CanvasWidth { get; set; }
+
+ ///
+ /// Height, in pixels, of the canvas that positions/sizes are expressed
+ /// against (typically the decoder's current output resolution height).
+ ///
+ [JsonProperty("canvasHeight")]
+ public int CanvasHeight { get; set; }
+
+ ///
+ /// Position, size, stacking order and routed source for every visible tile in the layout.
+ ///
+ [JsonProperty("tiles")]
+ public List Tiles { get; set; } = new List();
+}
+
+///
+/// Describes a single tile/window within a .
+///
+public class MultiviewTileState
+{
+ ///
+ /// 1-based tile/window number, matching the key in
+ /// .
+ ///
+ [JsonProperty("tileNumber")]
+ public int TileNumber { get; set; }
+
+ ///
+ /// Device key of this tile's child sink, so a client can
+ /// cross-reference existing routing-feedback data (e.g. sink current-source state) for this tile
+ /// without re-deriving it.
+ ///
+ [JsonProperty("tileSinkKey")]
+ public string TileSinkKey { get; set; }
+
+ /// Left edge of the tile, in pixels, within the canvas.
+ [JsonProperty("x")]
+ public int X { get; set; }
+
+ /// Top edge of the tile, in pixels, within the canvas.
+ [JsonProperty("y")]
+ public int Y { get; set; }
+
+ /// Width of the tile, in pixels.
+ [JsonProperty("width")]
+ public int Width { get; set; }
+
+ /// Height of the tile, in pixels.
+ [JsonProperty("height")]
+ public int Height { get; set; }
+
+ ///
+ /// Stacking order for overlapping tiles (e.g. picture-in-picture/overlay layouts). Tiles with
+ /// higher values are drawn on top of tiles with lower values.
+ ///
+ [JsonProperty("zOrder")]
+ public int ZOrder { get; set; }
+
+ ///
+ /// Device key of the source currently routed to this tile, or null if the tile is empty.
+ ///
+ [JsonProperty("sourceDeviceKey")]
+ public string SourceDeviceKey { get; set; }
+}
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs
index 0c5a3fae..0c3e17e6 100644
--- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs
@@ -180,7 +180,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Devices = devices,
TieLines = tielines,
CurrentRoutes = currentRoutes,
- SinkCurrentSources = BuildSinkCurrentSources(tileChildren)
+ SinkCurrentSources = BuildSinkCurrentSources(tileChildren),
+ MultiviewLayouts = RoutingGraphHelpers.BuildMultiviewLayoutSnapshot()
};
var jsonResponse = JsonConvert.SerializeObject(response, Formatting.Indented);
@@ -270,6 +271,16 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
///
[JsonProperty("sinkCurrentSources")]
public List SinkCurrentSources { get; set; }
+
+ ///
+ /// Gets or sets the current multiview canvas/tile layout for every device implementing
+ /// , keyed by device key. Devices with no currently
+ /// active layout are omitted. Lets a client render an initial visual mock-up of each
+ /// multiview decoder's monitor output without waiting for a routing feedback WebSocket
+ /// connection - see .
+ ///
+ [JsonProperty("multiviewLayouts")]
+ public Dictionary MultiviewLayouts { get; set; }
}
///
diff --git a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs
index 4e53e261..6cdf1e8e 100644
--- a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs
+++ b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs
@@ -224,7 +224,8 @@ public class RoutingFeedbackWebsocket : IKeyed
{
Type = "snapshot",
MidpointRoutes = midpointRoutes,
- SinkRoutes = sinkRoutes
+ SinkRoutes = sinkRoutes,
+ Layouts = RoutingGraphHelpers.BuildMultiviewLayoutSnapshot()
};
return JsonConvert.SerializeObject(snapshot, JsonSettings);
@@ -245,6 +246,12 @@ public class RoutingFeedbackWebsocket : IKeyed
{
device.InputChanged += HandleSinkInputChanged;
}
+
+ var layoutDevices = DeviceManager.AllDevices.OfType();
+ foreach (var device in layoutDevices)
+ {
+ device.LayoutChanged += HandleLayoutChanged;
+ }
}
private void UnsubscribeFromRoutingEvents()
@@ -260,6 +267,12 @@ public class RoutingFeedbackWebsocket : IKeyed
{
device.InputChanged -= HandleSinkInputChanged;
}
+
+ var layoutDevices = DeviceManager.AllDevices.OfType();
+ foreach (var device in layoutDevices)
+ {
+ device.LayoutChanged -= HandleLayoutChanged;
+ }
}
private void HandleMidpointRouteChanged(IRoutingMidpointWithFeedback midpoint, RouteSwitchDescriptor newRoute)
@@ -287,6 +300,24 @@ public class RoutingFeedbackWebsocket : IKeyed
});
}
+ private void HandleLayoutChanged(object sender, MultiviewLayoutStateEventArgs e)
+ {
+ if (sender is not IKeyed device)
+ return;
+
+ DebounceBroadcast($"layout-{device.Key}", () =>
+ {
+ var msg = new LayoutChangedDto
+ {
+ Type = "layoutChanged",
+ DeviceKey = device.Key,
+ Layout = e.CurrentLayout
+ };
+
+ Broadcast(JsonConvert.SerializeObject(msg, JsonSettings));
+ });
+ }
+
private void HandleSinkInputChanged(IRoutingSinkWithFeedback sender, RoutingInputPort currentInputPort)
{
// Tile-sink children are reported under their IRoutingSinkWithLayouts parent's key, with a
@@ -392,6 +423,14 @@ public class RoutingFeedbackWebsocket : IKeyed
public string Type { get; set; }
public Dictionary> MidpointRoutes { get; set; }
public Dictionary> SinkRoutes { get; set; }
+ public Dictionary Layouts { get; set; }
+ }
+
+ private class LayoutChangedDto
+ {
+ public string Type { get; set; }
+ public string DeviceKey { get; set; }
+ public MultiviewLayoutState Layout { get; set; }
}
private class MidpointRouteChangedDto
diff --git a/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs b/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs
index 714823ec..387c8824 100644
--- a/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs
+++ b/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs
@@ -86,4 +86,27 @@ public static class RoutingGraphHelpers
return device.CurrentSourceKeys.TryGetValue(eRoutingSignalType.Audio, out var audioKey) ? audioKey : null;
}
+
+ ///
+ /// Builds a snapshot of the current for every device in
+ /// that implements , keyed
+ /// by device key. Devices with no currently active layout (CurrentLayout == null) are
+ /// omitted. Shared by (initial
+ /// HTTP snapshot) and (WebSocket snapshot on connect).
+ ///
+ public static Dictionary BuildMultiviewLayoutSnapshot()
+ {
+ var result = new Dictionary();
+
+ foreach (var device in DeviceManager.AllDevices.OfType())
+ {
+ var layout = device.CurrentLayout;
+ if (layout == null)
+ continue;
+
+ result[device.Key] = layout;
+ }
+
+ return result;
+ }
}