Webview and JavaScript SDK
Oden’s WebView plugin lets you render a browser-based operator interface inside OdenVR / Player. The page can place Oden video streams into DOM-defined regions, exchange JSON messages with Oden plugins, and participate in Fleet and OCP operator workflows.
Use the webview path when the operator UI is naturally a web app: dashboards, multi-camera layouts, fleet selectors, maps, mission controls, or application-specific controls. The web page does not render Oden video pixels itself. Instead, it tells Oden where video should appear, and Oden draws the video over the browser surface.
How it fits together
Web app in Oden WebView
-> injected OdenLayoutClient
-> local WebSocket layout server
-> WebView plugin
-> Oden video layout, Fleet, OCP, or custom plugins
The WebView plugin injects a global JavaScript class named OdenLayoutClient into each page.
The client opens a WebSocket to a localhost port chosen by Oden and uses the oden_layout WebSocket subprotocol.
Applications should create one shared instance and reuse it for the lifetime of the page.
The injected script also exposes window.oden_version.
Enable the webview
-
Enable the
WebViewglobal plugin in OdenVR / Player. -
Open the WebView plugin panel.
-
Set
Addressto your web app, for examplehttp://localhost:3000. -
Enable
Accept Inputwhen the page should receive mouse and keyboard input. -
Use
Fill Viewwhen the web UI should cover the whole viewport.
The most important WebView settings are:
Address-
The URL loaded by the embedded browser.
Width/Height-
The browser render size when
Fill Viewis disabled. Accept Input-
Forwards mouse and keyboard input to the page.
Inhibit Mouse Look-
Prevents Oden’s 3D mouse-look behavior while interacting with the page.
Fill View-
Uses the current viewport size as the browser size.
Ignore Url Updates-
Keeps the currently rendered page instead of reloading when the configured URL value changes. Use this when an operator web app manages its own route state and should not be reset by project-side URL updates.
Mute log-
Stops browser console logs from being forwarded into the Oden log.
Clear Session Cookies-
Clears browser session cookies at startup.
Create the client
Guard the client creation so the app can still render during server-side rendering, tests, or ordinary browser development.
declare global {
interface Window {
OdenLayoutClient?: new () => any;
odenLayoutClient?: any;
oden_version?: string;
}
}
export function getOrCreateOdenLayoutClient() {
if (typeof window === "undefined" || !window.OdenLayoutClient) {
return null;
}
if (!window.odenLayoutClient) {
window.odenLayoutClient = new window.OdenLayoutClient();
}
return window.odenLayoutClient;
}
The client queues a small number of outgoing layout and user messages until its WebSocket opens. Still, your app should treat the client as a singleton. Creating multiple instances creates multiple WebSocket connections and duplicate callbacks.
JavaScript API
registerVideo(name, element)-
Registers a DOM element as the placement rectangle for an Oden video stream. The client tracks
getBoundingClientRect()and sends updates when the element moves, resizes, or when video attributes change. unregisterVideo(name)-
Removes a video registration. Call this when a component unmounts or a route changes.
sendPositionUpdate()-
Sends the current placement for all registered video elements. This is normally called automatically.
registerCallback(callback)-
Registers a callback for available-video updates. The callback receives an object keyed by video name, for example
{ "Front": { width: 1920, height: 1080 } }. unregisterCallback(callback)-
Removes an available-video callback.
registerUserMessageCallback(name, callback)-
Registers a callback for named user messages from Oden. The callback receives the message
payload. unregisterUserMessageCallback(name, callback)-
Removes a named user-message callback.
sendNamedUserMessage(name, payload)-
Sends a named JSON message to Oden. Use this for plugin messages, Fleet messages, and OCP messages.
sendMessage(layout)/sendBoxData(layout)-
Sends a raw video layout message. Use this only when you need manual video placement.
close()-
Closes the WebSocket, disconnects observers, and stops the position update loop.
Video layout
The recommended path is to create transparent DOM elements where video should appear, then register those elements with registerVideo().
Oden draws the corresponding stream into each rectangle.
import { useEffect, useRef } from "react";
import { getOrCreateOdenLayoutClient } from "./oden";
export function OdenVideo({
name,
scaleX = "1.0",
scaleY = "1.0",
rotation = "0.0",
cropLeft = "0.0",
cropRight = "0.0",
cropTop = "0.0",
cropBottom = "0.0",
z = "-1",
}) {
const ref = useRef(null);
useEffect(() => {
const client = getOrCreateOdenLayoutClient();
if (!client || !ref.current) return;
client.registerVideo(name, ref.current);
return () => {
client.unregisterVideo(name);
};
}, [name]);
return (
<div
ref={ref}
style={{ width: "100%", height: "100%", background: "transparent" }}
scale-x={scaleX}
scale-y={scaleY}
rotation={rotation}
crop-left={cropLeft}
crop-right={cropRight}
crop-top={cropTop}
crop-bottom={cropBottom}
z={z}
/>
);
}
A stream name is usually the camera or video entity name, such as Front.
When Fleet or another multi-streamer setup is active, names can include a vehicle prefix, such as vehicle-12:Front.
Listen for available videos instead of hard-coding names whenever possible:
const client = getOrCreateOdenLayoutClient();
function onVideos(videos) {
// { "Front": { width: 1920, height: 1080 }, ... }
console.log("Available Oden videos", videos);
}
client?.registerCallback(onVideos);
// Later:
client?.unregisterCallback(onVideos);
Layout attributes
The automatic layout client reads these attributes from the registered DOM element:
scale-x,scale-y-
Scale multipliers for the video placement. Use
scale-x="-1.0"to mirror a rear camera. rotation-
Rotation in degrees.
crop-left,crop-right,crop-top,crop-bottom-
Normalized crop values from
0.0to1.0. z-
View-space z order used by Oden’s immediate drawing path.
data-camera-id-
Optional camera name override. Use this if the DOM registration key must be unique but the video stream name should be shared.
Manual placement
Manual placement sends the raw layout shape expected by the WebView layout server.
Do not wrap video placement in sendNamedUserMessage().
const client = getOrCreateOdenLayoutClient();
client?.sendMessage({
videos: [
{
name: "Front",
position: { x: 100, y: 80 },
size: { width: 1280, height: 720 },
scale: { x: 1.0, y: 1.0 },
rotation: 0.0,
crop: { left: 0.0, right: 0.0, top: 0.0, bottom: 0.0 },
z: 1,
},
],
});
Coordinates and sizes are browser CSS pixels in the web page coordinate system. Oden applies the current webview scale internally.
Named user messages
Named user messages are JSON messages with this shape:
{
"name": "vehicle_state",
"payload": {
"speed": 4.2,
"battery": 0.81
}
}
Use named messages for application data. Use raw layout messages for video placement.
JavaScript to Oden
const client = getOrCreateOdenLayoutClient();
client?.sendNamedUserMessage("drive_commands", {
throttle: 0.15,
brake: 0.0,
steering: -0.2,
});
Oden to JavaScript
const client = getOrCreateOdenLayoutClient();
function onVehicleState(payload) {
console.log("Vehicle state", payload);
}
client?.registerUserMessageCallback("vehicle_state", onVehicleState);
// Later:
client?.unregisterUserMessageCallback("vehicle_state", onVehicleState);
Plugin side
Rust plugins exchange named user messages with the webview through the message bus in oden_plugin_rs::message_bus.
The WebView plugin owns the bus and publishes it under the owner UUID f6599e51-0ea3-46c1-9907-0e7d0334a807.
Plugins built inside the Oden source tree can use the webview_shared::WEBVIEW_UUID string constant instead of the literal.
Uuid::parse is a const fn, so a mistyped UUID literal in a const fails at compile time instead of at runtime.
BusClient::open() creates the bus if the WebView plugin has not published it yet, so you can open and subscribe directly in init() without waiting for the WebView plugin to start.
A broadcast that arrives before your subscription exists is parked and delivered once you subscribe, so no retry or reconnect loop is needed.
Subscriptions match message names exactly: subscribe to the names the page sends with sendNamedUserMessage().
Messages sent with BusClient::send() are forwarded to JavaScript callbacks registered for that name with registerUserMessageCallback().
The bus carries opaque payload bytes, but messages sent toward the webview must be valid JSON (or empty) — the WebView plugin discards anything else with a host-side error log.
use oden_plugin_rs::{
log, math::Uuid, BusClient, GuiParams, InitParams, OdenPlugin, ShutdownParams, Subscription,
UpdateParams,
};
use serde_json::json;
const WEBVIEW_UUID: Uuid = Uuid::parse("f6599e51-0ea3-46c1-9907-0e7d0334a807");
struct State {
bus_client: BusClient,
subscription: Subscription,
}
impl OdenPlugin for State {
fn init(api: &InitParams) -> Self {
let bus_client = BusClient::open(api, WEBVIEW_UUID);
let subscription = bus_client.subscribe(&["drive_commands"]);
State {
bus_client,
subscription,
}
}
fn shutdown(self, _api: &ShutdownParams) {}
fn update(&mut self, _api: &UpdateParams) {
while let Some(msg) = self.subscription.try_recv() {
log::info!("{}: {}", msg.name, String::from_utf8_lossy(&msg.payload));
}
let payload = serde_json::to_vec(&json!({
"speed": 4.2,
"battery": 0.81
}))
.unwrap_or_default();
self.bus_client.send("vehicle_state", &payload).ok();
}
fn gui(&mut self, _api: &GuiParams) {}
}
Deserialize incoming payloads with serde_json::from_slice() into your own message types.
subscribe() queues up to 128 messages per subscription; use subscribe_with_capacity() for a different depth.
Outside the update tick, Subscription also offers recv_timeout(Duration) and recv_async() for dedicated threads or async tasks.
The bus lives in the host executable, so it survives WebView hot-reloads and restarts.
Your BusClient and Subscription stay valid across them: while no owner is running you simply receive nothing, and upstream sends wait in the bounded queue for the next owner to drain them.
There is no liveness to poll and no reconnect to handle.
send() returns Result<(), SendError>; the only error is SendError::QueueFull when the bounded upstream queue is full.
A complete round-trip example lives in the Oden source tree under plugins/example_plugin_webview_messaging together with the React page in plugins/webview/example_react.
Fleet messages
When the Fleet plugin is active, webviews can discover and connect vehicles with named user messages. These are the message names used by the current Fleet player plugin.
| Direction | Name | Payload |
|---|---|---|
Webview to Oden |
|
|
Oden to webview |
|
Array of |
Webview to Oden |
|
|
Webview to Oden |
|
|
Webview to Oden |
|
|
Webview to Oden |
|
|
Webview to Oden |
|
Optional player configuration such as |
fleet_connect requests an exclusive control connection, so the vehicle must not be Controlled by another operator.
Monitor sessions do not block taking control: existing monitoring operators are switched over to receive the new operator’s stream.
End the control session with fleet_disconnect.
Example:
const client = getOrCreateOdenLayoutClient();
function onVehicles(vehicles) {
// [{ id, name, availability }, ...]
console.log(vehicles);
}
client?.registerUserMessageCallback("fleet_online_vehicles", onVehicles);
client?.sendNamedUserMessage("fleet_list_vehicles", {});
client?.sendNamedUserMessage("fleet_connect", {
vehicle_name: "vehicle-12",
});
fleet_monitor requests a view-only monitor session. It receives the vehicle’s video without taking control, and works while another operator controls or monitors the same vehicle.
A fleet_monitor for a vehicle this client controls ends the control session first, mirroring how fleet_connect ends an existing monitor session.
End the monitor session with fleet_monitor_disconnect.
Closing the webview page automatically disconnects any control and monitor sessions it requested.
client?.sendNamedUserMessage("fleet_monitor", {
vehicle_name: "vehicle-12",
});
// Later:
client?.sendNamedUserMessage("fleet_monitor_disconnect", {
vehicle_name: "vehicle-12",
});
OCP operator messages
OCP publishes vehicle feedback to the webview and accepts optional operator-side user data back from the page. See Oden Control Pipeline overview for the full control-path model.
| Direction | Name | Payload |
|---|---|---|
Oden to webview |
|
|
Webview to Oden |
|
|
vehicle_feedback is keyed by vehicle name.
Each value includes OCP latency fields, sender and receiver faults, optional vehicle data, the last remote input, and timestamp fields used by the OCP latency loop.
When JavaScript sends ocp_client_user_data, the injected client automatically echoes the latest ack_time and ack_time_mac for each vehicle entry it has seen.
Your web app normally only supplies active_vehicle, user_data, and optionally ocp_disable_gamepad.
const client = getOrCreateOdenLayoutClient();
function onOcpData(payload) {
const entries = Object.entries(payload.vehicle_feedback ?? {});
if (entries.length === 0) return;
const [vehicleName] = entries[0];
client?.sendNamedUserMessage("ocp_client_user_data", {
active_vehicle: vehicleName,
client_user_data: {
[vehicleName]: {
user_data: {
selected_tool: "bucket",
requested_mode: "work",
},
ocp_disable_gamepad: false,
},
},
});
}
client?.registerUserMessageCallback("ocp_vehicle_user_data", onOcpData);
Only one operator-side client data source should send OCP client data in a session.
Do not send ocp_client_user_data from both a webview and another operator-side TCP or plugin client at the same time.
Message names reference
| Name | Direction from web page | Use |
|---|---|---|
|
Send |
Request the current Fleet vehicle list. |
|
Receive |
Receive Fleet vehicle availability. |
|
Send |
Request control connection by vehicle name. |
|
Send |
Disconnect a Fleet vehicle by vehicle name. |
|
Send |
Request a view-only monitor session by vehicle name. |
|
Send |
End a monitor session by vehicle name. |
|
Send |
Apply selected player-side configuration. |
|
Receive |
Receive OCP vehicle feedback and input state. |
|
Send |
Send operator-side user data and active vehicle selection to OCP. |
|
Send |
Low-level connection request with |
|
Send |
Low-level disconnect request with |
Custom plugins can define any additional message names.
Use stable, namespaced names such as vehicle_state, drive_commands, or my_plugin:set_mode.
Troubleshooting
- No
OdenLayoutClient -
The page is not running inside Oden’s WebView, or the injected script did not load. Keep the singleton helper nullable so local browser development still works.
- No video appears
-
Check
Layout Server > Available videosin the WebView plugin panel, or useregisterCallback()to log available names. Make sure the registered element has non-zero size and the name matches the stream name Oden reports. - Video is behind the web UI
-
Adjust the element’s
zattribute. For overlays and picture-in-picture views, use an explicit positivez. - Plugin does not receive webview messages
-
Make sure the subscription includes the exact message name the page sends with
sendNamedUserMessage()— subscriptions match names exactly. - OCP client data stops working
-
Only one OCP client data source can own operator-side user data in a session. Restart Oden after a source conflict, then send from only one of webview, plugin shared data, or player-side TCP.