# Webview and JavaScript SDK

Build custom Oden operator interfaces with the webview plugin 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

```none
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](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.

```typescript
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.

```javascript
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:

```javascript
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()`.

```javascript
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:

```json
{
  "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

```javascript
const client = getOrCreateOdenLayoutClient();

client?.sendNamedUserMessage("drive_commands", {
  throttle: 0.15,
  brake: 0.0,
  steering: -0.2,
});
```

### Oden to JavaScript

```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 send messages to webviews through the named MPMC channel `user_message_oden_to_webview`. Current inbound webview messages are received with `webview_shared::create_receiver()` and a set of wanted message names.

```rust
use std::collections::HashSet;

use oden_plugin_rs::{
    named_mpmc_channel::{NamedMpmcChannelExt, Sender},
    webview_user_message::WebviewUserMessage,
    InitParams, OdenPlugin, UpdateParams,
};
use serde_json::json;

struct State {
    to_webview: Sender<WebviewUserMessage>,
    from_webview: webview_shared::Receiver,
}

impl OdenPlugin for State {
    fn init(api: &InitParams) -> Self {
        let wanted_messages = HashSet::from(["drive_commands".to_string()]);

        Self {
            to_webview: api.named_mpmc_channel_tx("user_message_oden_to_webview", 128),
            from_webview: webview_shared::create_receiver(api, wanted_messages),
        }
    }

    fn update(&mut self, _api: &UpdateParams) {
        self.to_webview
            .try_send(WebviewUserMessage {
                name: "vehicle_state".to_string(),
                payload: Some(json!({
                    "speed": 4.2,
                    "battery": 0.81
                })),
            })
            .ok();

        while let Ok(message) = self.from_webview.try_recv() {
            if message.name == "drive_commands" {
                // Deserialize message.payload into your command type.
            }
        }
    }
}
```

`webview_shared::create_receiver()` filters by message name. If a message name is not in `wanted_messages`, that plugin instance will not receive it.

## 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 | `configure_player` | Optional player configuration such as `packer_enabled`, `packer_auto_crop`, `background_color`, or `hide_connection_ui`. |

Example:

```javascript
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",
});
```

## 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](ocp-overview.md) 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`.

```javascript
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. |
| `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 plugin registered the message name in `webview_shared::create_receiver(api, wanted_messages)`.

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.

## More examples

-   [Player layout](../configure/player-layout.md)
    
-   [Fleet management](../operate/fleet-management.md)
    
-   [Multiple vehicles](../operate/multiple-vehicles.md)
    
-   [Operator-side control](operator-side-control.md)
