Appearance
Custom Web Interfaces
Exaplay serves its own user interface over HTTP, and it will serve your pages from the same web server. If you write HTML and CSS, you can build a control surface that looks exactly like your project — your typography, your colours, your client's logo, your wording — and have it drive the engine directly.
No build tools, no framework, no Exaplay plug-in to compile. A page is one .html file in a folder.
Which route should you take?
| You want | Use | Code needed |
|---|---|---|
| A tidy operator page with cue buttons, faders and projector controls | User Panel Builder | None — click it together in the Control View |
| Your own layout, branding, wording, kiosk graphics — including projector control and any engine parameter | This page — a custom HTML page, generated for you from the loaded project | HTML + CSS |
| The same, but described in words and written by an AI | This page → Building the page with Claude or ChatGPT | None — you describe it, the AI writes it |
| Integration into an existing system, a native app, a show controller | HTTP REST API, TCP, UDP, OSC | Real programming |
The three mix freely: a custom page can sit next to the built-in panels, and it uses the same API a show controller would.
Quick start — let Exaplay write the first version
You do not have to start from an empty file, you do not have to look up a single name, and you do not have to copy anything by hand.
Open the Control View (
http://<engine-ip>:8123/control) → User Panels tab → Custom HTML Pages, and press New Page. The builder opens as a dialog over the list.Give the page a title and press Save to this engine.
The page is written to the engine, and it appears in the list behind the dialog as a card carrying its URL. It is built for this project, and it keeps up with it: the cards are drawn from the show itself (see Repeating a card for every composition or cue), so a playlist or a cue added next week appears on the page by itself, and a rename never breaks a button. Nothing has to be generated again.
It always contains the lot — transport, cues, faders, parameters, projectors and devices, the clock. There is nothing to tick: this is a starting point, and deleting a section you do not want is one keystroke in the editor.
Open the URL, edit the page, reload. There is nothing to rebuild.
Your first edit is a styling edit: there is no name to look up, and none to keep up to date afterwards.
Download page does the same thing but hands you the file instead, for when you build the page on your own machine and copy it over later.
The pages this engine serves
Every .html in the engine's custom\ folder is a card in Custom HTML Pages, whether this builder wrote it or you copied it there by hand:
| Button | What it does |
|---|---|
| Open | Opens the page in a new tab, at the URL the card shows. |
| Edit | Reopens the builder on that file, with the editor already showing what the engine is serving. |
| Delete | Asks first, then removes it — the engine stops serving it at once. Windows keeps the file in the Recycle Bin, so it can be put back by hand. |
The refresh button re-reads the folder, for a page you dropped in over the network while the tab was open.
Editing the page without leaving the browser
Edit page, next to Save to this engine in the builder dialog (and the Edit button on a page's card), opens the page in an editor right there — so a rack PC in a locked room needs no keyboard, no remote desktop and no file share to change a headline or a colour.
It always opens what the engine is actually serving under that file name, so you edit the live page rather than a fresh copy of it. Type the name of any page in custom\ into the File name field first and the editor loads that one — including pages this builder never wrote.
Once you have edited the markup by hand, Exaplay stops regenerating the page: your version is what gets saved, downloaded and previewed, and it survives project changes arriving in the background. Two ways back, both in the editor's footer:
| Button | What it does |
|---|---|
| Reload from engine | Throws your edits away and re-reads the page the engine is serving. |
| Start over | Throws your edits away and generates the page again, with the project's current names. |
Nothing is written until you press Save to this engine — an unsaved dot in the editor's header tells you when there is something to write.
Got a page from somewhere else — Claude, ChatGPT, a colleague, another editor? Paste the page brings it in from the clipboard; see Building the page with Claude or ChatGPT below for where to find it and what it checks before it lets you save.
Building the page with Claude or ChatGPT
An AI will happily write you a control page — and unless you tell it how Exaplay works, it invents an API. The page looks right, and every single button is dead.
Press New Page, then fold open Prefer to describe it and let an AI write it? underneath the generator's own controls — the other way to the same page, described in words. It writes a briefing document containing:
- the complete
data-exa-*reference — every attribute the toolkit really implements, and nothing it doesn't; - your show's actual names — compositions and their type, every cue under the composition it belongs to, clips, projectors and TCP devices — as orientation, so the AI knows what kind of show it is laying out;
- the instruction to build the page from repeating templates, the same way New Page does, so what it writes keeps up with the show instead of freezing it. It names a composition only where you asked for one specific one — a kiosk for a single loop, say;
- the rules that break panels: don't copy
exaplay.js, don't write your ownfetch(), scope your cue buttons,on/offis required on projector buttons, no assets from the internet; - Exaplay's stylesheet and CSS variables, if you want the panel to match the product — or an instruction to design it from scratch, if you don't;
- a complete worked example page — repeating, and ending on the one script tag that makes every attribute work — so the model has a finished, correct page to copy the shape of, not a bare fragment missing the part that matters most. (The pinned single-composition shape is in there too, as a short snippet before it, for when that is what you asked for.)
The panel walks you through it in three numbered steps. One of them is highlighted and says do this now — that is a pointer, not a lock: every button on the panel works at every moment, so coming back to it later never leaves you with nothing to press.
Describe the page — type what you want, or press one of the starting points (FOH tablet, Kiosk, Lobby display, Show-caller) and edit from there. Optional: leave it empty and you get a sensible general panel.
Your description is not the prompt
What the AI needs is the whole document, and your description is one part of it. Selecting your text and pressing Ctrl+C copies only that — the panel notices and tells you so — and an assistant given only those lines invents an API and writes a page whose buttons do nothing.
Send everything to the AI — the box shows what one press copies: your description, how an Exaplay page works (every attribute the toolkit understands, and the rules that break pages), this show's real names, and a complete working example. Show the whole document opens it read-only with your own words highlighted inside it. Press Copy everything for the AI — the size on the button is the whole thing, around 11 kB — then paste it into ChatGPT or Claude with Ctrl+V and send. The two buttons next to it open a fresh chat in a new tab; already have one open? Paste there instead.
The panel remembers that you copied, even if you close the builder or reload the page while you are away in the chat. Editing the description afterwards makes the copy stale: the button changes to Copy everything again and the pointer moves back to it.
Bring the page back — in the chat, copy the assistant's whole answer, then press Paste the page and look it over before saving. If you closed the builder to go to the chat, open New Page again: the AI fold is already open on this step.
Paste the page picks the HTML out of whatever you copied: the code block is lifted out of the surrounding chat prose, and the markdown fence around it is stripped. It opens the editor rather than publishing straight away, so you read the page before an audience does. If the clipboard holds no HTML at all it says so and changes nothing — and if it still holds the briefing (the last thing you copied was Copy everything), it says that too, rather than pasting the briefing's own example page as if the assistant had written it.
Some browsers will not hand the clipboard to a page (Firefox never does, Safari asks). Then a paste box appears — press Ctrl+V into it and the page takes exactly the same route; Clipboard blocked? Paste it here instead opens that box on demand.
It also checks for the one line every dead page turns out to be missing: if the pasted page has no
<script src="/custom-ui/exaplay.js">, it warns you right there instead of quietly accepting a page whose buttons will never do anything. Add the line yourself in the editor that opens — immediately before</body>is the usual place — or go back to the assistant, ask it to add the line, and paste the answer again.
Then press Save to this engine, up in the generator's own row.
The preview in the chat shows the design, not your show
A page written this way names nothing from your show — each card is written once and Exaplay stamps out a copy per playlist and per cue, which is what keeps it current as the show changes. The catch is that there is nothing to draw until an engine answers, and the chat window has no route to one, so the preview beside the answer would be blank.
The briefing therefore asks for a small preview shim: a few lines at the bottom of the page that fill it with obviously-fake demo content ("Main Show", "Intro", grey placeholder pictures) when no engine can be reached. That is what lets you look at the layout, colours and type in the chat and ask for changes there, instead of pasting into Exaplay after every round.
On the engine it does nothing at all — the real names, thumbnails and states take over the moment the page is served. It is safe to delete if you would rather not have it; only the chat preview goes blank again.
The briefing is generated from the project that is loaded right now, and shares its page title and file name with the generator above it, so the two are always describing the same page. Open the real show before you copy the briefing: with the names in, the AI's first attempt usually runs; without them, you get placeholders to find and replace by hand.
Keep it in the conversation
The briefing is the whole contract, so paste it once and keep asking for changes in the same conversation — "make the cue buttons bigger", "add the foyer composition". Starting a fresh chat means pasting it again.
Where the files live
Custom pages are stored in the engine's work folder, not in Program Files — so saving one needs no administrator rights and no Exaplay update can ever overwrite it:
C:\Users\<you>\Documents\Exaplay3\html\custom\Anything in there is served straight away:
…\Documents\Exaplay3\html\custom\stage.html
→ http://<engine-ip>:8123/custom/stage.htmlThe toolkit your page links to is part of the application and lives with it:
| Folder | Contents | Updates |
|---|---|---|
…\Documents\Exaplay3\html\custom\ | Your pages. | Never touched |
…\Program Files\Exaplay 3\html\custom-ui\ | exaplay.js, exaplay-panel.css, starter.html | Replaced every update — never edit these |
…\Program Files\Exaplay 3\html\custom\ | Also served, for pages deployed with the application | Left alone by the installer |
If the same file name exists in both custom folders, the one under Program Files wins — the application always takes precedence over a page that happens to share its name.
starter.html in custom-ui\ is a hand-written panel with placeholder names in it — the shape a page has when every card is typed out. Copy it into your custom folder if you would rather start by hand. New Page writes a different shape: the same controls, but as repeating templates that draw the show themselves.
How a page is put together
Three lines of boilerplate, then plain HTML:
html
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- optional starter theme — delete it and write your own CSS -->
<link rel="stylesheet" href="/custom-ui/exaplay-panel.css" />
<!-- the toolkit: this is what makes the attributes below work -->
<script type="module" src="/custom-ui/exaplay.js"></script>
</head>
<body>
<button data-exa-play="Main Show">Play</button>
<button data-exa-cue="Blackout">Blackout</button>
<span data-exa-text="status" data-exa-comp="Main Show"></span>
</body>
</html>exaplay.js does three things:
- Reads the attributes. Any element carrying a
data-exa-…attribute becomes a control. Nothing has to be registered, and elements added to the page later work too. - Translates names into the engine's API. You write the composition and cue names shown in the Exaplay editor; the toolkit looks up the UIDs the API needs and refreshes them whenever the project changes.
- Keeps the page live. It listens on the engine's status websocket and writes the current state back into your markup as text and as CSS classes.
Always link /custom-ui/exaplay.js rather than copying it into your own folder, so your panel picks up fixes when Exaplay is updated.
Attribute reference
Buttons and actions
Put the attribute on any clickable element. The value is the composition name as shown in the editor.
| Attribute | Does |
|---|---|
data-exa-play="Main Show" | Start playback |
data-exa-pause="Main Show" | Pause |
data-exa-stop="Main Show" | Stop |
data-exa-toggle="Main Show" | Play when stopped or paused, pause when playing |
data-exa-next="Main Show" | Next item / cue |
data-exa-prev="Main Show" | Previous item / cue |
data-exa-cue="Blackout" | Fire the cue with that name |
data-exa-seek="30" | Jump the composition to 30 seconds |
data-exa-show | Toggle show mode (the engine's "outputs live" switch). data-exa-show="on" / ="off" force it |
data-exa-mute | Toggle mute — the engine master, or one composition when scoped |
data-exa-send='{"req":"cue.next","comp-uid":123}' | Send any request from the REST reference verbatim |
Scoping — write the name once
data-exa-comp on any container sets the composition for everything inside it, so the individual controls need no value at all:
html
<section data-exa-comp="Main Show">
<button data-exa-play>Play</button>
<button data-exa-stop>Stop</button>
<span data-exa-text="status"></span>
</section>Scoping matters for cues too. An unscoped data-exa-cue fires the first cue of that name in the project; inside a data-exa-comp it can only ever fire that composition's cue. If two compositions have a cue called Blackout, scope it.
When something gets renamed
A show gets tidied up. Someone renames Main Show to Evening Show, or Blackout to Full Black, and the panel on the wall was written weeks ago.
The page does not have to break, and it does not have to be regenerated. Two things to know:
What the page SHOWS follows a rename by itself. Whenever the engine reports a rename, the panel re-reads the project and every data-exa-text display catches up within a second — no reload, nobody touching the tablet. So put the name in a data-exa-text rather than typing it into the markup:
html
<section data-exa-comp="comp1">
<h2 data-exa-text="name">Main Show</h2>
<button data-exa-cue="cue2" data-exa-text="cue-name">Blackout</button>
</section>The words inside those elements are only what stands there until the engine answers, so still write the real ones — the page then reads correctly even offline.
What the page TARGETS is your choice, and one of the two options survives. Every scope — data-exa-comp, data-exa-cue, data-exa-media — accepts either the name or the object's variable name, the short comp1 / cue3 shown beside the name in the Exaplay editor. A variable name is given out once when the object is created and no rename ever touches it.
- A page an operator maintains by hand reads better with names. It stops working at a rename, and the button says so (it flashes red and the browser console names the composition it could not find).
- A page meant to be left alone should use variable names.
- A page that names nothing at all cannot go stale in the first place — that is what repeating templates are for, and what pages generated by Exaplay use. The project's names are still in the file, in the header comment, so you can read what it was built against and pin a card to one composition if you want to.
Adding a composition or a cue is the other half, and the answer is the same: a page written out card by card has no element for something that did not exist yet, so it has to be generated again; a page built from repeating templates grows on its own.
Faders
html
<input type="range" min="0" max="100" data-exa-fader="master" />
<div data-exa-comp="Main Show">
<input type="range" min="0" max="100" data-exa-fader="volume" />
<input type="range" min="0" max="100" data-exa-fader="alpha" />
</div>| Value | Controls | Range |
|---|---|---|
master | Engine master volume | 0–100 |
volume | Volume of the scoped composition (the master if unscoped) | 0–100 |
alpha | Opacity of the scoped composition | 0–100 |
Faders follow the engine, so a change made in the Exaplay editor or by another panel shows up here — except while you are actually working one, which is never overwritten under your hand.
Any parameter — the equivalent of pinned custom controls
data-exa-set writes any engine parameter by its dotted path — the same paths the Inspector shows and the User Panel's pinned Custom Controls write. On an input it writes as you drag; on a button it writes a fixed value.
html
<section data-exa-comp="Main Show">
<!-- follows the slider -->
<input type="range" min="0" max="100" data-exa-set="alpha" />
<!-- fixed values -->
<button data-exa-set="alpha" data-exa-value="100">Full</button>
<button data-exa-set="alpha" data-exa-value="0">Hide</button>
</section>
<!-- a parameter on ONE clip rather than the whole composition -->
<div data-exa-comp="Main Show" data-exa-media="Intro Clip">
<input type="range" min="0.5" max="5" step="0.1" data-exa-set="effect.blur_amount" />
</div>| Attribute | Meaning |
|---|---|
data-exa-set="<path>" | The parameter to write, e.g. alpha, audio.volume, effect.blur_amount, realtime.params.u_speed. Effect parameter names are listed in the Effects Reference |
data-exa-value="<value>" | The value a button writes. Numbers and true/false are sent as numbers and booleans, not strings |
data-exa-media="<clip name>" | Target one clip instead of the composition. Inherited by children, like data-exa-comp |
Without a data-exa-comp or data-exa-media scope the write goes to the engine's global object — where show-mode and audio-volume live.
Parameter inputs are seeded with the parameter's current value when the page connects, so a panel does not open with every slider at zero and jump the show the moment one is touched.
Finding the path
Open the Inspector on the clip or composition, hover the parameter — the dotted path is what the Custom Controls pinning uses, and the same string works here.
Projectors and TCP devices
Devices are addressed by the name they have in the editor's Devices tab. The page never holds an IP address or a PJLink password — the engine does the talking.
html
<div data-exa-projector="Main Projector">
<button data-exa-power="on">Power on</button>
<button data-exa-power="off">Power off</button>
<button data-exa-shutter="on">Shutter</button>
<button data-exa-shutter="off">Open</button>
<button data-exa-pjlink="INPT=31">HDMI 1</button>
</div>
<button data-exa-projectors="on">All projectors on</button>
<button data-exa-projectors="off">All projectors off</button>
<button data-exa-tcp="House Lights">House lights</button>| Attribute | Does |
|---|---|
data-exa-projector="<name>" | Names the projector for the buttons inside it (or on the same element) |
data-exa-power="on" / ="off" | PJLink POWR |
data-exa-shutter="on" / ="off" | PJLink AVMT — blank / unblank picture and sound |
data-exa-pjlink="CMD=PARAM" | Any other PJLink command, e.g. INPT=31 for an input select |
data-exa-projectors="on" / ="off" | Power every configured projector — one engine call, not a loop in the browser, so it works from a page that never read the device list |
data-exa-tcp="<name>" | Send that TCP device's stored command. Add data-exa-command="…" to send a different string |
on / off must be written out on these — a projector reports no state the page could toggle against, so an empty value is refused rather than guessed.
Live text
data-exa-text fills an element with live state. Combine it with a scope.
| Value | Shows |
|---|---|
status | play, pause or stop |
time | Playhead position of the composition (m:ss) |
clip-time | Position within the clip currently playing |
clip-duration | Length of that clip |
clip-remaining | Time left on that clip — the classic countdown |
volume | Volume 0–100 (master when unscoped) |
name | The composition's name, as it is right now |
cue-name | The name of the cue this element's own (or an enclosing) data-exa-cue points at |
cue-index | That cue's position, counting from 1 |
media-name | The name of a clip — the one data-exa-media names, or the one playing when it names none |
project | The loaded project's file name |
clock | The viewing device's wall clock |
The three name keys are how a page keeps up with the show — see When something gets renamed.
Thumbnails
data-exa-thumb on an <img> fills in a clip's thumbnail picture. The engine publishes one per clip when the clip opens, so the page never has to know where the media lives on that machine:
html
<!-- What is on air in this composition, picture and name -->
<div data-exa-comp="comp1">
<img data-exa-thumb alt="" />
<span data-exa-text="media-name"></span>
</div>
<!-- Or one particular clip, always -->
<img data-exa-thumb="Intro Loop" alt="" />Leave the value empty and it takes its subject from where it sits: inside a cue button it shows that cue's clip, otherwise the clip currently playing in the scoped composition. Give it a clip name (or the clip's variable name) and it stays on that clip.
A coverflow: one tile per cue
In a playlist, a cue is a clip — cue 2 is simply the second clip — so every cue has a picture, and a row of tiles needs nothing typed per tile but its number:
html
<div data-exa-comp="Main Show">
<button data-exa-cue="1">
<img data-exa-thumb alt="" />
<span data-exa-text="cue-index"></span>
<span data-exa-text="cue-name"></span>
</button>
<button data-exa-cue="2"> … </button>
</div>The picture and both labels read the cue from the button around them. Nothing here has to be kept in step with the show: rename a clip and the tile follows, because the name comes from the engine.
The same clip can be reached three ways in a playlist, and they are one object, not three features — by position (data-exa-cue="2", what the engine counts in), by name (data-exa-cue="Act 1"), or directly as a clip (data-exa-thumb="Act 1", data-exa-media="Act 1" for its parameters).
On a timeline this does not apply: a cue there is a point in time, with no clip of its own and therefore no thumbnail. Give those cues text buttons. Generated pages already do exactly this — tiles for a playlist, plain buttons for a timeline.
Three things worth knowing:
- Size the
<img>yourself. It has nosrcuntil there is something to show, and an<img>without one takes no space — so a card with no fixed height jumps when a cue starts. The generated page setswidth/heighton its thumbnail for exactly this reason. - A placeholder survives. If you give the
<img>asrcof your own, it comes back whenever there is nothing to show, instead of the picture sticking on the clip that just ended. - A clip the engine has never opened has no thumbnail, and neither do live inputs. Nothing is shown for those; it is not an error.
Repeating a card for every composition or cue
The coverflow above lists its tiles one by one, so it shows the cues that existed when you wrote it. Add a playlist to the show next week and the page does not know about it.
Put the markup in a <template data-exa-repeat="…"> instead. Exaplay stamps out one copy per item and keeps the list in step with the show — new playlists and new cues appear on their own, with nothing to regenerate:
html
<template data-exa-repeat="playlists">
<section class="show" data-exa-comp>
<h2 data-exa-text="name"></h2>
<div class="flow">
<template data-exa-repeat="cues">
<button data-exa-cue>
<img data-exa-thumb alt="" />
<span data-exa-text="cue-index"></span>
<span data-exa-text="cue-name"></span>
</button>
</template>
</div>
</section>
</template>That is a complete panel for a show of any size, and it names nothing. It is also what New Page writes: every card in a generated page — transport, cues, faders, parameters — is a template like this one. What stays written out are the things the project does not list for us: the projectors and TCP devices, the master fader, the clock.
| Value | One copy per |
|---|---|
playlists | playlist composition |
timelines | timeline composition |
comps | composition of either kind |
cues | cue of the composition it sits in |
clips | clip of the composition it sits in |
- Leave the scope attribute empty.
data-exa-comp,data-exa-cueordata-exa-mediawritten with no value is filled in per copy with the item it belongs to. That is what lets a copy carry no names — and it is the one thing to get right: a copy that names a real cue would repeat that same cue. - Nest them. A
cuesrepeat inside aplaylistscopy is that playlist's cues. Acuesorclipsrepeat needs a composition around it — an enclosing copy, or an ordinarydata-exa-compwrapper. - Everything inside a copy behaves normally. Text keys, thumbnails, transport, faders: they all read the scope that was filled in.
- Copies are only rebuilt when the show's shape changes — not on every update — so a press or a half-loaded thumbnail is never thrown away.
How a new cue is noticed
A page with repeats re-reads the project every 15 seconds. It has to: adding a cue to a playlist adds a clip, which changes neither the project name nor the number of compositions, so nothing on the live connection announces it. A new composition is noticed immediately; a new cue takes up to that long.
data-exa-repeat-poll="30" on any element changes the interval, and "0" switches it off for a panel that is generated once and never edited during a show.
Live state as CSS classes
This is the part that makes a custom page worth building: the toolkit only sets classes, and what they look like is entirely yours.
data-exa-state="Main Show" puts one of these on the element and keeps it current:
| Class | When |
|---|---|
exa-playing | That composition is playing |
exa-paused | Paused |
exa-stopped | Stopped |
exa-unknown | The engine has not reported it (yet) |
css
.cue-card.exa-playing {
border-color: #35c46a;
box-shadow: 0 0 24px rgba(53, 196, 106, 0.4);
}On the <html> element the toolkit maintains three more, so a whole page can react without any per-element markup:
| Class | When |
|---|---|
exa-online / exa-offline | The engine connection is up / down |
exa-live | Show mode is on — outputs are live |
css
.exa-offline body { filter: grayscale(1); }
.exa-live .tally { background: red; }Buttons also get a brief exa-ok after the engine accepted a command, or exa-error when it did not — a wrong name or an unreachable engine is visible on the panel instead of hidden in the browser console.
Progress
data-exa-progress writes the current clip's progress into the CSS custom property --exa-progress (0%–100%), so the bar is yours to draw:
html
<div class="bar" data-exa-progress data-exa-comp="Main Show"></div>css
.bar::after {
content: "";
display: block;
height: 100%;
width: var(--exa-progress, 0%);
background: currentColor;
}A complete example
A two-composition wall for a museum foyer — no JavaScript:
html
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script type="module" src="/custom-ui/exaplay.js"></script>
<style>
body { margin: 0; font: 16px/1.4 system-ui; background: #111; color: #eee;
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px; }
.room { border: 2px solid #333; border-radius: 16px; padding: 20px; }
.room.exa-playing { border-color: #35c46a; }
button { width: 100%; min-height: 72px; font-size: 1.2rem; border-radius: 12px;
border: 0; background: #2b2f36; color: inherit; }
.exa-offline button { opacity: 0.4; }
</style>
</head>
<body>
<section class="room" data-exa-comp="Room A" data-exa-state="Room A">
<h2><span data-exa-text="name">Room A</span> — <span data-exa-text="status"></span></h2>
<p>Ends in <span data-exa-text="clip-remaining"></span></p>
<button data-exa-play>Start tour</button>
<button data-exa-stop>Stop</button>
</section>
<section class="room" data-exa-comp="Room B" data-exa-state="Room B">
<h2><span data-exa-text="name">Room B</span> — <span data-exa-text="status"></span></h2>
<p>Ends in <span data-exa-text="clip-remaining"></span></p>
<button data-exa-play>Start tour</button>
<button data-exa-stop>Stop</button>
</section>
</body>
</html>If you do want JavaScript
The same object the attributes use is available as Exaplay:
html
<script type="module">
import Exaplay from "/custom-ui/exaplay.js";
await Exaplay.play("Main Show");
await Exaplay.cue("Act 2", "Main Show"); // scoped to one composition
await Exaplay.volume(60, "Main Show"); // 0–100
await Exaplay.showMode(true);
// any parameter, on a composition, a clip, or the engine
await Exaplay.set("alpha", 50, "Main Show");
await Exaplay.set("effect.blur_amount", 3.5, { comp: "Main Show", media: "Intro Clip" });
await Exaplay.values("Main Show"); // that object's current values
// projectors and devices, by their editor names
await Exaplay.power("Main Projector", true);
await Exaplay.shutter("Main Projector", false);
await Exaplay.pjlink("Main Projector", "INPT", "31");
await Exaplay.projectors(false); // all of them
await Exaplay.tcp("House Lights"); // the device's stored command
Exaplay.status("Main Show"); // "play" | "pause" | "stop" | null
Exaplay.clip("Main Show"); // { uid, playtime, duration }
Exaplay.compositions(); // every composition, with its cues
Exaplay.clips(); // every clip, with the composition it is in
Exaplay.deviceList(); // { projectors, tcp }
Exaplay.on("update", () => { /* every status frame, ~20×/s */ });
Exaplay.on("project", (comps) => { /* the show was loaded or re-saved */ });
// anything this toolkit does not wrap: Exaplay.http is the engine's base
// URL (scheme + host + port), so a plain fetch reaches any endpoint
await fetch(Exaplay.http + "/project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ req: "cue.next", "comp-uid": 12345678 }),
});
</script>Everything returns a promise that rejects when the engine refuses the command, so try / catch reports real failures.
Hosting the page somewhere else
The page does not have to live on the engine. The engine answers cross-origin requests, so a panel served by your own web server, an intranet CMS or even a local file works — it just has to be told which engine to talk to:
http://my-intranet/panels/stage.html?host=192.168.1.5:8123or, before the script tag:
html
<script>window.EXAPLAY_HOST = "192.168.1.5:8123";</script>Without either, the page talks to whatever served it — which is what you want when the engine serves it.
Turning a machine into a panel
Any tablet, phone or touch screen with a browser can open the page. For a dedicated box, Exaplay can boot straight into your panel instead of the editor:
Settings → Interface
| Setting | Set it to |
|---|---|
| Startup mode | app (chromeless window) or mini (small window) |
Connect to another machine (ui.startup-url) | The full URL of your page, e.g. http://localhost:8123/custom/stage.html |
The machine then starts up showing your interface, with no browser chrome and no Exaplay editor. This setting is per machine and is never pushed to clients by a multi-client sync, so each box can boot into a different panel.
Security — read this before a public installation
The engine's HTTP API is not authenticated. Anyone who can reach port 8123 can control the show, whether through your page, the built-in interface or a curl command. A PIN on a built-in user panel hides controls from a passer-by; it is not a security boundary.
For anything reachable beyond a trusted network:
- Keep the engine on a separate control VLAN, or firewall port 8123 down to the devices that need it.
- Put a reverse proxy with authentication in front of the panel if it must be reachable from a general-purpose network.
- Never expose the engine directly to the internet.
Troubleshooting
| Symptom | Cause |
|---|---|
| A button flashes red | The engine refused the command. Open the browser console (F12) — the toolkit logs the exact name it could not resolve, and the bottom of starter.html lists the names that do exist. |
| Nothing on the page updates, everything is greyed out | The /info connection is down: <html> carries exa-offline. Check the engine is running and the address is right. |
| The page loads but no control does anything | The script tag is missing or its path is wrong. It must be <script type="module" src="/custom-ui/exaplay.js"></script>. |
| A card still shows the old name after a rename | The name is typed into the markup. Put it in data-exa-text="name" / "cue-name" instead and it follows the editor by itself — see When something gets renamed. |
| A button stopped working after a rename | It addresses the composition or cue by its old name. Use the new name, or address it by its variable name (comp1, cue3), which a rename never changes. |
| A cue added in the editor is missing from the page | If the page lists its cues one by one, it has no element for a new one — reload after regenerating it. A page built from repeating templates, which is what New Page writes, picks a new cue up within 15 seconds by itself. |
| A thumbnail never appears | That clip has no thumbnail yet — the engine makes one when the clip first opens, and live inputs have none at all. Check the same clip in the Media Browser. |
| A cue tile stays empty on a timeline | A timeline cue is a point in time, not a clip, so there is no picture to show. Coverflows are a playlist thing. |
| The thumbnail box collapses when nothing plays | Give the <img> a fixed width/height in CSS; it has no src until there is something to show. |
| I cannot find the generator again | Control View → User Panels tab → Custom HTML Pages → New Page (or Edit on an existing card). |
| The AI wrote a page and nothing on it works | It invented an API instead of using the attributes. Check the page links /custom-ui/exaplay.js and has no fetch() of its own — and that you pasted the whole briefing, not a summary of it. |
| Paste the page warned about a missing script tag | The AI's answer left out <script src="/custom-ui/exaplay.js"> — the single most common reason a generated page does nothing. It still lands in the editor: add the line yourself (just before </body>), or ask the assistant to add it and paste the answer again. |
| The preview in the chat is empty | The page draws nothing until an engine answers, and the chat cannot reach one. The briefing asks for a preview shim that fills it with demo content — if the answer left it out, ask the assistant to add it. The page itself is fine; it will work once saved. |
| The page shows "Main Show" and "Intro", which are not in my show | That is the preview shim's demo data, so the page is not reaching the engine. Open it at the engine's own address (http://<engine-ip>:8123/custom/...), not from a file on disk, and check /custom-ui/exaplay.js loads — a hard refresh clears a stale cached copy. |
| The AI used names that are not in my show | The briefing was copied with no project loaded — the panel says so in an orange note. Load the show, copy it again. |
| The page stopped following the project | It has been edited by hand — a notice in the card says so. That version is what gets saved; Start over in the editor goes back to a generated one. (Only the header comment's list of names goes stale; the cards themselves are drawn from the show either way.) |
| Edit page opened a generated page, not mine | Nothing is saved under that file name yet, or this engine does not serve the custom\ folder (see the row below). The header of the editor says which of the two you are looking at. |
| The panel came back empty after an Exaplay update | The page was saved in html\custom-ui\ (replaced on update) instead of a custom\ folder. |
| "Save to this engine" warns that the folder is not served | That engine predates custom-page hosting. Update Exaplay, or put the file in html\custom\ next to exaplay3.exe. |
| A cue fires in the wrong composition | Two cues share a name. Scope the button with data-exa-comp. |
| A projector button flashes red | The device name does not match the editor's Devices tab. The console message lists the names that do exist. |
| A parameter slider does nothing | The dotted path is wrong for that object, or the scope points at the composition when the parameter lives on a clip (data-exa-media). |
See also
- User Panel Builder — the no-code panel builder
- Custom Controls — pinning inspector parameters
- HTTP REST API — the full request reference
- External Show Control — driving Exaplay from other systems
