> ## Documentation Index
> Fetch the complete documentation index at: https://docs.marioded.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom hotbar controls

> Add a playback action, validate its parameters, and bind it to a hotbar item.

# Custom hotbar controls

Let's add a control that rewinds the replay. Then we will improve it with a parameterized action and a rendered item. All `LayoutService` operations run on the Bukkit main thread.

## Get the layout service

```java theme={null}
ReversePlayer player = ReversePlayer.get().orElseThrow();
LayoutService layout = player.getLayoutService();
```

Use the real player service. It already manages the temporary viewer inventory, so our integration only needs to add an action or item.

## Register a simple action

```java theme={null}
layout.registerAction("example:rewind_round", (context, parameters) -> {
    context.playbackService().seekTo(context.session().getSessionId(), 0);
});
```

`context.session()` is the current playback session. `seekTo` rebuilds/resynchronizes the scene; setting the cursor field directly would not do that work.

This action preserves the API seek's pause behavior. Add `context.playbackService().resume(context.session().getSessionId())` if your control should explicitly resume after seeking.

## Bind it from configuration

Merge an item under `playing.items` in `layout.yml`:

```yaml theme={null}
playing:
  items:
    rewind_round:
      enabled: true
      slot: 8
      appearance:
        material: ARROW
        data: 0
        amount: 1
        name: '<yellow>Rewind round'
        lore: ['<gray>Return to the beginning.']
      states: {}
      bindings:
        LEFT:
          action: example:rewind_round
          parameters: {}
```

Reload the player configuration and test the item while watching. Hotbar slots are **1–9**. Slot 8 is free in the shipped defaults, but check owner customizations before using it.

## Improve an action with parameters

Suppose the owner wants to choose a speed in each binding. Register a `LayoutAction` with validation:

```java theme={null}
layout.registerAction("example:set_speed", new LayoutAction() {
    @Override
    public void validate(Map<String, String> parameters) {
        if (!parameters.keySet().equals(Set.of("speed"))) {
            throw new IllegalArgumentException("Expected only a speed parameter");
        }
        double speed = Double.parseDouble(parameters.get("speed"));
        if (!Double.isFinite(speed) || speed <= 0) {
            throw new IllegalArgumentException("Speed must be finite and positive");
        }
    }

    @Override
    public void execute(LayoutItem.Context context, Map<String, String> parameters) {
        context.playbackService().setSpeed(
            context.session().getSessionId(), Double.parseDouble(parameters.get("speed")));
    }
});
```

`validate` runs without changing state. It rejects missing/unknown keys or unusable values before an edit is published and before execution. `execute` performs the actual change. Do not mutate a session or register another item from validation.

A YAML binding can now use `action: example:set_speed` with `parameters: {speed: '0.5'}`. Unknown action IDs may be configured before an integration registers them; registration later validates existing bindings.

## Or install an item in Java

For a control with dynamic rendering, create a `LayoutItem`:

```java theme={null}
layout.setItem(new LayoutItem(
    "example:speed_item",
    8,
    context -> new ItemStack(Material.FEATHER),
    Map.of(LayoutItem.Click.LEFT,
        new LayoutItem.Binding("example:set_speed", Map.of("speed", "0.5")))));
```

Use this **instead of** the previous slot-8 YAML item, or choose another free slot. The service rejects a collision without publishing a partial layout.

The renderer receives a viewer/session context and returns an `ItemStack`. Reverse handles placement and stack cloning. Supported bindings are `LEFT`, `RIGHT`, `SHIFT_LEFT`, and `SHIFT_RIGHT`.

## Remember a viewer's choice

`context.state()` belongs to one viewer and one item. Use it for choices such as a selected interval, and do not retain the context beyond the callback.

`addDefaultState(viewerId, key, value)` provides viewer defaults that can survive a configuration reload. Pass the viewer UUID (`viewer.getUniqueId()`). Passing null removes an existing default; removing one that does not exist can throw. `clearViewerState(viewerId)` clears all state for that viewer.

The renderer should use context data. A paused session may skip redraws while nothing relevant changes, so call `layout.refresh(viewer)` when your external application state changes. Refresh still accepts an actual `Player` or `PlaybackSession`, rather than a UUID.

> [!NOTE]
>
> Per-viewer item state is not a separate playback. Seeking, pause, and speed still affect every viewer sharing that session.

## Reload and disable

Programmatic items survive layout reloads and can shadow configured keys. Remove your item explicitly:

```java theme={null}
layout.removeItem("example:speed_item");
```

Unregister your actions when disabling:

```java theme={null}
layout.unregisterAction("example:set_speed");
layout.unregisterAction("example:rewind_round");
```

Do this only for your registrations. Intentionally replacing a shared ID requires coordination with other integrations. See the [complete UI example](https://docs.marioded.com/examples/ui-extensions.md) for setup and teardown in a JavaPlugin.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.