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. AllLayoutService operations run on the Bukkit main thread.
Get the layout service
Register a simple action
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 underplaying.items in layout.yml:
Improve an action with parameters
Suppose the owner wants to choose a speed in each binding. Register aLayoutAction with validation:
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 aLayoutItem:
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.
Per-viewer item state is not a separate playback. Seeking, pause, and speed still affect every viewer sharing that session.

