Webview and JavaScript SDK

Last validated: 2026-05-04

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

  1. Enable the WebView global plugin in OdenVR / Player.

  2. Open the WebView plugin panel.

  3. Set Address to your web app, for example http://localhost:3000.

  4. Enable Accept Input when the page should receive mouse and keyboard input.

  5. Use Fill View when 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 View is 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.0 to 1.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

fleet_list_vehicles

{}

Oden to webview

fleet_online_vehicles

Array of { id, name, availability }, where availability is Available, Monitored, or Controlled.

Webview to Oden

fleet_connect

{ "vehicle_name": "vehicle-12" }

Webview to Oden

fleet_disconnect

{ "vehicle_name": "vehicle-12" }

Webview to Oden

fleet_monitor

{ "vehicle_name": "vehicle-12" }

Webview to Oden

fleet_monitor_disconnect

{ "vehicle_name": "vehicle-12" }

Webview to Oden

configure_player

Optional player configuration such as packer_enabled, packer_auto_crop, background_color, or hide_connection_ui.

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

ocp_vehicle_user_data

VehicleOcpShared: { vehicle_feedback, last_input }.

Webview to Oden

ocp_client_user_data

ClientOcpShared: { active_vehicle, client_user_data }.

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

fleet_list_vehicles

Send

Request the current Fleet vehicle list.

fleet_online_vehicles

Receive

Receive Fleet vehicle availability.

fleet_connect

Send

Request control connection by vehicle name.

fleet_disconnect

Send

Disconnect a Fleet vehicle by vehicle name.

fleet_monitor

Send

Request a view-only monitor session by vehicle name.

fleet_monitor_disconnect

Send

End a monitor session by vehicle name.

configure_player

Send

Apply selected player-side configuration.

ocp_vehicle_user_data

Receive

Receive OCP vehicle feedback and input state.

ocp_client_user_data

Send

Send operator-side user data and active vehicle selection to OCP.

connect

Send

Low-level connection request with { "roverId": "…​" }. Fleet integrations should prefer fleet_connect.

disconnect

Send

Low-level disconnect request with { "roverId": "…​" }. Fleet integrations should prefer fleet_disconnect.

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 videos in the WebView plugin panel, or use registerCallback() 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 z attribute. For overlays and picture-in-picture views, use an explicit positive z.

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.