VPinFE uses an embedded Chromium frontend with a WebSocket bridge to communicate between the browser and Python backend.
Themes interact with the backend through vpinfe-core.js, so theme code calls vpin.call(...) without handling transport details directly.
VPinFE runs up to 3 browser windows, one per monitor:
table— The main screen. Controller for all other screens and input. Handles gamepad/keyboard input and hosts the in-theme menu overlays.bg— Backglass screen. Receives events from the table window.dmd— DMD screen (not a "real DMD" like ZeDMD). Receives events from the table window.
Each window has its own webpage but shares an instance of the VPinFE API (frontend/api.py), accessed via vpinfe-core.js.
Themes are installed in the user config directory: ~/.config/vpinfe/themes/<THEME NAME>/ (Linux) or the equivalent platformdirs location on other platforms.
<THEME NAME>
├── manifest.json
├── theme.json (optional - schema plus saved Manager UI theme options)
├── preview.png (optional - shown in manager UI, can be .png or .gif)
├── index_table.html
├── index_bg.html
├── index_dmd.html
├── style.css
├── theme.js
└── fonts/ (optional - custom font files)
└── MyFont.otf
Every theme must include a manifest.json:
{
"name": "My Theme",
"version": "1.0",
"author": "Your Name",
"description": "A brief description of the theme.",
"preview_image": "preview.png",
"supported_screens": 3,
"type": "desktop",
"change_log": "Initial release."
}| Field | Description |
|---|---|
name |
Display name shown in the manager UI. |
version |
Version string for tracking updates. |
author |
Theme author name. |
description |
Brief description shown in the manager UI. |
preview_image |
Filename of the preview image (.png or .gif). |
supported_screens |
Number of screens the theme supports (typically 3). |
type |
Theme type: "desktop" for desktop/flat-screen setups, "cab" for cabinet setups, or "both" for themes that adapt to either. |
change_log |
Description of changes in this version. |
Each screen has its own HTML file. These must be named exactly as listed:
| File | Description |
|---|---|
index_table.html |
The main screen. Controller for all other screens and input. |
index_bg.html |
Backglass screen. |
index_dmd.html |
DMD screen. |
This is the main HTML file. It controls input, displays the primary UI, and hosts the in-theme menu overlays. Below is the minimum required structure:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>VPinFE - My Theme</title>
<link rel="stylesheet" href="/web/common/vpinfe-style.css">
<link rel="stylesheet" href="style.css">
<script src="/web/common/vpinfe-core.js"></script>
<script src="theme.js"></script>
</head>
<body>
<!-- Your theme content goes here -->
<div id="fadeContainer">
<!-- Wrap your content in a container for fade transitions -->
</div>
<!-- Required: Menu overlay container. VPinFECore injects the main menu
and collection menu iframes into this div. -->
<div id="overlay-root"></div>
<!-- Optional: Remote launch overlay shown when manager UI triggers a launch -->
<div id="remote-launch-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 9999; justify-content: center; align-items: center; flex-direction: column;">
<div style="color: white; font-size: 3em; font-family: Arial, sans-serif; text-align: center;">
<div style="margin-bottom: 20px;">Remote Launching...</div>
<div id="remote-launch-table-name" style="font-size: 1.5em; color: #4CAF50;"></div>
</div>
</div>
</body>
</html><link rel="stylesheet" href="/web/common/vpinfe-style.css">
<script src="/web/common/vpinfe-core.js"></script>These are served by VPinFE's HTTP server on port 8000. vpinfe-core.js provides all API calls, media URL helpers, gamepad/keyboard input, and event handling. vpinfe-style.css is required for the in-theme menu system styling.
Your theme's own style.css and theme.js can be named whatever you want.
| Element | Purpose |
|---|---|
<div id="overlay-root"> |
Required on all windows. VPinFECore injects the main menu and collection menu iframes here. Without this, menus won't appear. |
| Element | Purpose |
|---|---|
<div id="fadeContainer"> |
Wrap your content for fade-to-black transitions on table launch/return. Style with transition: opacity in CSS. |
<div id="fadeOverlay"> |
Alternative fade pattern: a fixed full-screen black overlay that fades in/out via a CSS class (e.g., .show { opacity: 1 }). |
<div id="remote-launch-overlay"> |
Overlay shown when the manager UI triggers a remote table launch. Include <div id="remote-launch-table-name"> inside for the table name. |
If your theme supports cabinets or portrait-style table layouts, build that into the table window deliberately. In practice, the table window is usually the only screen that needs rotation-aware layout changes. bg and dmd often stay unrotated.
There are two different rotation concepts to keep separate:
- OS monitor orientation: If the user sets the playfield monitor to Portrait in the operating system, Chromium receives a portrait-shaped window. For example, CSS
100vwis the narrow edge and100vhis the long edge. - VPinFE table rotation:
[Displays] tablerotationis exposed to themes asvpin.tableRotationandget_table_rotation. This tells the theme how to rotate its playfield UI inside that Chromium window.
VPinFE does not automatically rotate arbitrary theme markup. The backend launches Chromium on the configured monitor and vpinfe-core.js loads display values during vpin.ready; the theme decides how to use those values.
These calls are especially useful:
const cabMode = await vpin.call("get_cab_mode");
const rotationDegree = await vpin.call("get_table_rotation");After await vpin.ready, the same values are also available as:
vpin.tableOrientation; // "landscape" or "portrait"
vpin.tableRotation; // degrees, default 0Do not infer cabinet Portrait mode from window.innerWidth and window.innerHeight. VPinFE can run through the bundled embedded Chromium build or through a user-installed Chrome, and desktop window bounds can be affected by OS display orientation, monitor placement, DPI behavior, and theme transforms. Treat viewport dimensions as layout measurements only. Use VPinFE's display config as the source of truth:
const tableOrientation = String(await vpin.call("get_table_orientation") || "").toLowerCase();
const tableRotation = Number(await vpin.call("get_table_rotation")) || 0;
const tableDisplayPortrait = tableOrientation === "portrait";
const normalizedRotation = ((tableRotation % 360) + 360) % 360;When adapting an existing landscape theme to OS-level Portrait mode, decide separately how each layer should behave:
- The page/layout surface may need to rotate as a whole, like Basic Cab.
- A portrait-aware layout may stay upright while only table media is corrected.
- Table media (
table.png/table.mp4) may need its own per-theme correction even when the surrounding page is right. Do this in the table media element only, not inbgordmd. - Avoid guessing from screenshots alone whether the media needs a mirror. If table text is backwards, that is a flip/mirror problem. If the apron/top are on the wrong end but text is still readable, that is a rotation problem.
For themes that correct table media separately, keep the media transform isolated and size rotated media from the untransformed layout box, not from getBoundingClientRect() after parent transforms:
function sizeRotatedTableMedia(mediaEl) {
const frame = mediaEl.closest(".hero-media-frame") || mediaEl.parentElement;
const frameWidth = frame?.clientWidth || frame?.offsetWidth || 0;
const frameHeight = frame?.clientHeight || frame?.offsetHeight || 0;
if (frameWidth > 0 && frameHeight > 0) {
mediaEl.style.width = `${frameHeight}px`;
mediaEl.style.height = `${frameWidth}px`;
}
}getBoundingClientRect() includes CSS transforms from rotated parents. That makes it easy to feed already-rotated visual dimensions back into your media sizing and produce narrow, clipped, or badly scaled table images.
Good questions to answer up front when starting a new theme:
- Should the theme declare
type: "cab"ortype: "both"? - Should portrait mode use a different layout, or just rotate the landscape one?
- Should only the main table UI rotate, or should table-only overlays rotate too?
- Is the table media orientation tied to the whole page surface, or does it need a theme-specific correction?
The Basic Cab theme works on an OS-level Portrait playfield by treating the page as layers:
#fadeContainercontains the table UI and media.#remote-launch-overlayis rotated with the table UI so launch feedback appears in the same orientation.#overlay-rootstays as the injected menu host, but a child wrapper (#menu-overlay-container) catches the menu iframes and applies menu-specific rotation.
The key trick is that a 90-degree or 270-degree rotated surface must swap its CSS dimensions before rotation:
const rotation = Number(vpin.tableRotation) || 0;
const swapAxes = Math.abs(rotation) === 90 || Math.abs(rotation) === 270;
const rotatedWidth = swapAxes ? "100vh" : "100vw";
const rotatedHeight = swapAxes ? "100vw" : "100vh";
[document.getElementById("fadeContainer"), document.getElementById("remote-launch-overlay")]
.filter(Boolean)
.forEach((element) => {
element.style.position = "absolute";
element.style.top = "50%";
element.style.left = "50%";
element.style.width = rotatedWidth;
element.style.height = rotatedHeight;
element.style.transformOrigin = "center center";
element.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`;
});Without the width/height swap, the rotated landscape surface is clipped inside the portrait browser window. With the swap, the theme gets a full-size virtual playfield surface and then rotates it into the monitor.
One easy thing to miss: the built-in menus are injected into #overlay-root, not inside your main theme container. If you rotate only your main table wrapper, the menus will still appear unrotated.
In other words:
- Rotating your table wrapper rotates your theme content
- Rotating
#overlay-rootrotatesmainmenu.htmlandcollectionmenu.html - If you only do the first one, rotated table themes will have mismatched menus
Basic Cab handles this by keeping #overlay-root aligned to the same virtual surface and moving injected children into a stable wrapper:
<div id="overlay-root">
<div id="menu-overlay-container"></div>
</div>function ensureMenuOverlayContainer() {
const overlayRoot = document.getElementById("overlay-root");
if (!overlayRoot) return null;
let container = document.getElementById("menu-overlay-container");
if (!container) {
container = document.createElement("div");
container.id = "menu-overlay-container";
overlayRoot.appendChild(container);
}
Array.from(overlayRoot.children).forEach((child) => {
if (child !== container) container.appendChild(child);
});
if (!overlayRoot._menuObserver) {
const observer = new MutationObserver(() => {
Array.from(overlayRoot.children).forEach((child) => {
if (child !== container) container.appendChild(child);
});
});
observer.observe(overlayRoot, { childList: true });
overlayRoot._menuObserver = observer;
}
return container;
}Then size and center the root surface, and rotate the inner menu wrapper as needed for that theme:
const overlayRoot = document.getElementById("overlay-root");
if (overlayRoot) {
overlayRoot.style.position = "absolute";
overlayRoot.style.top = "50%";
overlayRoot.style.left = "50%";
overlayRoot.style.width = rotatedWidth;
overlayRoot.style.height = rotatedHeight;
overlayRoot.style.transformOrigin = "center center";
overlayRoot.style.transform = "translate(-50%, -50%)";
}
const menuOverlay = ensureMenuOverlayContainer();
if (menuOverlay) {
menuOverlay.style.transformOrigin = "center center";
menuOverlay.style.transform = `rotate(${menuRotation}deg)`;
}menuRotation is theme-specific. Basic Cab uses a separate menu rotation because its table UI, wheel art, and metadata panel are already designed for cabinet viewing, while the injected menus have their own landscape assumptions. When extending this to another theme, copy the layer structure and dimension swap first, then tune menuRotation until the main and collection menus read correctly on the cabinet.
For more advanced themes, it helps to think in layers:
#tableViewport: fullscreen viewport wrapper#tableScreen: your actual table UI surface that may be rotated and scaled#overlay-root: injected menu host that may need the same transform as#tableScreen
That wrapper approach is much easier to maintain than rotating individual components one by one.
Recommended CSS baseline:
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: black;
}
#fadeContainer {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
transform-origin: center center;
}
#overlay-root {
position: absolute;
inset: 0;
pointer-events: none;
}
#menu-overlay-container {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
pointer-events: none;
}
#menu-overlay-container > iframe,
#menu-overlay-container > * {
pointer-events: auto;
}Same structure as above but with simpler content. These windows only display media and respond to events — they don't handle input.
Important: theme code for these windows should support both static images and videos. In practice that means:
bgwindows should preferbg.mp4and fall back tobg.pngdmdwindows should preferdmd.mp4and fall back todmd.png
Do not hardcode these windows to image-only rendering with getImageURL() alone, or bg.mp4 / dmd.mp4 will never appear even when the files exist.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>VPinFE - BG</title>
<link rel="stylesheet" href="/web/common/vpinfe-style.css">
<link rel="stylesheet" href="style.css">
<script src="/web/common/vpinfe-core.js"></script>
<script src="theme.js"></script>
</head>
<body>
<div id="fadeContainer">
<div id="bgImageContainer">
<!-- BG or DMD image inserted here by theme.js -->
</div>
</div>
<div id="overlay-root"></div>
</body>
</html>Typical JS pattern for these windows:
function hasUsableMedia(url) {
return Boolean(url) && !String(url).includes('file_missing');
}
function renderWindowMedia(container, imageUrl, videoUrl, altText) {
const existingMedia = container.querySelector('video, img');
const wantsVideo = hasUsableMedia(videoUrl);
if (existingMedia) {
if (existingMedia.tagName === 'VIDEO') {
existingMedia.pause();
existingMedia.removeAttribute('src');
existingMedia.load();
}
existingMedia.remove();
}
if (wantsVideo) {
const video = document.createElement('video');
video.src = videoUrl;
video.poster = hasUsableMedia(imageUrl) ? imageUrl : '';
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.style.cssText = 'width: 100%; height: 100%; object-fit: cover;';
video.onerror = () => {
if (!hasUsableMedia(imageUrl)) return;
const fallback = document.createElement('img');
fallback.src = imageUrl;
fallback.alt = altText;
fallback.style.cssText = 'width: 100%; height: 100%; object-fit: cover;';
video.replaceWith(fallback);
};
container.appendChild(video);
return;
}
const img = document.createElement('img');
img.src = hasUsableMedia(imageUrl) ? imageUrl : '';
img.alt = altText;
img.style.cssText = 'width: 100%; height: 100%; object-fit: cover;';
container.appendChild(img);
}
function updateBGWindow() {
const container = document.getElementById('rootContainer');
const bgUrl = vpin.getImageURL(currentTableIndex, 'bg');
const bgVideoUrl = vpin.getVideoURL(currentTableIndex, 'bg');
renderWindowMedia(container, bgUrl, bgVideoUrl, 'Backglass');
}
function updateDMDWindow() {
const container = document.getElementById('rootContainer');
const dmdUrl = vpin.getImageURL(currentTableIndex, 'dmd');
const dmdVideoUrl = vpin.getVideoURL(currentTableIndex, 'dmd');
renderWindowMedia(container, dmdUrl, dmdVideoUrl, 'DMD');
}You can bundle custom fonts with your theme. Place font files in the theme directory (or a fonts/ subfolder) and reference them with @font-face in your CSS:
@font-face {
font-family: 'MyFont';
src: url('fonts/MyFont.otf') format('opentype');
}You can also load web fonts (e.g., Google Fonts) via <link> in your HTML:
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500&display=swap" rel="stylesheet">The user selects a theme by setting this in vpinfe.ini:
[Settings]
theme = <THEME NAME>The main JS file for interacting with VPinFE and controlling the theme UI. All three windows (table, bg, dmd) load the same theme.js, so use windowName to branch logic per window.
VPinFE also passes the current window identity in the page URL as ?window=table, ?window=bg, or ?window=dmd. For high-DPI backglass and DMD setups, VPinFE may also include an optional override query parameter in the form x,y,width,height. Theme authors can read that value when they need to use the configured bounds instead of the auto-detected browser window size.
/*
Bare minimum theme example.
*/
// Globals
windowName = ""
currentTableIndex = 0;
// init the core interface to VPinFE
const vpin = new VPinFECore();
vpin.init();
window.vpin = vpin // main menu needs this to call back in.
// Register receiveEvent globally BEFORE vpin.ready to avoid timing issues
window.receiveEvent = receiveEvent;
// wait for VPinFECore to be ready
vpin.ready.then(async () => {
await vpin.call("get_my_window_name")
.then(result => {
windowName = result;
});
// register your input handler
vpin.registerInputHandler(handleInput);
// optional: load values from theme.json in your theme dir
config = await vpin.call("get_theme_config");
// Initialize the display
updateScreen();
});
// listener for window events
async function receiveEvent(message) {
// Let VPinFECore handle the data refresh logic (TableDataChange, filters, sorts)
await vpin.handleEvent(message);
if (message.type == "TableIndexUpdate") {
currentTableIndex = message.index;
updateScreen();
}
else if (message.type == "TableLaunching") {
await fadeOut();
}
else if (message.type == "TableRunning") {
// Table has finished loading and is now running
}
else if (message.type == "TableLaunchComplete") {
fadeIn();
}
else if (message.type == "RemoteLaunching") {
// Remote launch from manager UI - message.table_name has the table name
showRemoteLaunchOverlay(message.table_name);
await fadeOut();
}
else if (message.type == "RemoteLaunchComplete") {
hideRemoteLaunchOverlay();
fadeIn();
}
else if (message.type == "TableDataChange") {
currentTableIndex = message.index;
updateScreen();
}
}
// input handler - only called on the "table" window
/* joyleft, joyright, joyup, joydown,
joyselect, joymenu, joyback, joycollectionmenu */
async function handleInput(input) {
switch (input) {
case "joyleft":
currentTableIndex = wrapIndex(currentTableIndex - 1, vpin.tableData.length);
updateScreen();
vpin.sendMessageToAllWindows({
type: 'TableIndexUpdate',
index: currentTableIndex
});
break;
case "joyright":
currentTableIndex = wrapIndex(currentTableIndex + 1, vpin.tableData.length);
updateScreen();
vpin.sendMessageToAllWindows({
type: 'TableIndexUpdate',
index: currentTableIndex
});
break;
case "joyselect":
vpin.sendMessageToAllWindows({ type: "TableLaunching" });
await fadeOut();
await vpin.launchTable(currentTableIndex);
break;
case "joyback":
break;
}
}
function updateScreen() {
if (windowName === "table") {
// Update table window: images, carousel, info, audio
vpin.playTableAudio(currentTableIndex);
} else if (windowName === "bg") {
// Update backglass image
} else if (windowName === "dmd") {
// Update DMD image
}
}
// circular table index helper
function wrapIndex(index, length) {
return (index + length) % length;
}
// Fade transition helpers
async function fadeOut() {
const el = document.getElementById('fadeContainer');
return new Promise(resolve => {
el.addEventListener('transitionend', e => {
if (e.propertyName === 'opacity') resolve();
}, { once: true });
el.style.opacity = '0';
});
}
function fadeIn() {
document.getElementById('fadeContainer').style.opacity = '1';
}
// Remote launch overlay
function showRemoteLaunchOverlay(tableName) {
const overlay = document.getElementById('remote-launch-overlay');
const nameEl = document.getElementById('remote-launch-table-name');
if (overlay && nameEl) {
nameEl.textContent = tableName || 'Unknown Table';
overlay.style.display = 'flex';
}
}
function hideRemoteLaunchOverlay() {
const overlay = document.getElementById('remote-launch-overlay');
if (overlay) overlay.style.display = 'none';
}Important: Call
await vpin.handleEvent(message)at the top of yourreceiveEventfunction. This lets VPinFECore handleTableDataChangeevents automatically (collection changes, filter/sort updates) so you don't have to manage that logic yourself.
Important: Set
window.vpin = vpinso the in-theme menu system can call back into your VPinFECore instance.
For anything beyond a very simple theme, especially carousel-style table screens, avoid rebuilding the entire table window DOM on every table change.
A much smoother pattern is:
- Create the table view scaffold once
- Keep references to the important nodes
- Update wheel art, title text, media, and tags in place
- Only swap the specific media layer or text nodes that actually changed
This matters a lot for:
- smoother wheel navigation
- less layout jitter while images load
- cleaner image/video fades
- reduced browser work in Chromium
If a theme feels choppy, a full-screen rebuild on every selection change is one of the first things to remove.
The fastest-looking theme is usually the one doing the least work during browsing.
Things that helped in practice:
- preload nearby media such as the current, previous, and next table images
- prefer updating existing
<img>/<video>nodes or swapping a small media layer instead of rerendering the whole screen - keep fades simple; a plain crossfade is usually smoother than blur-heavy "dissolve" effects
- be careful with simultaneous animation systems; CSS transitions plus a JS animation library or canvas effects can stack up quickly
- if wheel browsing feels sluggish, test without heavy motion libraries first
For table video specifically, image-first browsing with delayed video start is often smoother than immediately starting video while the user is rapidly scrolling.
If you want a wheel carousel to feel smooth instead of "slotty":
- keep a persistent wheel strip instead of recreating wheel nodes every move
- use a buffered strip with offscreen items if you want real scrolling motion
- anchor any selection halo or highlight to the selected position, not to the moving wheel artwork
- keep the selected/non-selected size difference moderate during motion so the eye follows the scroll instead of the scale jump
- tune motion duration generously; motion that is technically correct but too fast still reads like hopping
Events are sent between windows via receiveEvent(). These are the built-in event types:
| Event Type | Properties | Description |
|---|---|---|
TableIndexUpdate |
index |
User navigated to a different table. Sent by the table window to all others. |
TableLaunching |
— | A table is about to launch. Frontend keyboard/gamepad routing is suspended until TableLaunchComplete; use this to fade out, stop audio, etc. |
TableRunning |
— | The launched table has finished loading and is now running. Sent when the table process outputs "Startup done". |
TableLaunchComplete |
— | The launched table has exited and frontend input routing is restored. Use this to fade back in, resume audio. |
RemoteLaunching |
table_name |
The manager UI triggered a remote table launch. Frontend keyboard/gamepad routing is suspended until RemoteLaunchComplete; show an overlay. |
RemoteLaunchComplete |
— | The remote-launched table has exited and frontend input routing is restored. Hide the overlay. |
TableDataChange |
index, collection?, filters?, sort? |
Table data changed (collection switch, filter/sort update). Handled automatically by vpin.handleEvent(). |
You can also define custom event types and send them with vpin.sendMessageToAllWindows().
Themes can show a loading image or animation while VPX is starting. Use the built-in launch lifecycle instead of guessing with timers:
- show the overlay on
TableLaunching - hide it on
TableRunning - also hide it on
TableLaunchCompleteas a cleanup fallback
Add the overlay markup to every theme page that should show it (index_table.html, index_bg.html, and/or index_dmd.html):
<div id="table-loading-overlay" aria-hidden="true">
<img src="img/loading.gif" alt="" class="table-loading-spinner">
</div>Keep the overlay transparent if you want the normal screen fade to remain visible underneath:
#table-loading-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
#table-loading-overlay.is-visible {
opacity: 1;
}
.table-loading-spinner {
width: min(34vw, 34vh, 500px);
height: min(34vw, 34vh, 500px);
object-fit: contain;
}Then drive it from receiveEvent(message):
function showTableLoadingOverlay() {
const overlay = document.getElementById("table-loading-overlay");
if (!overlay) return;
overlay.classList.add("is-visible");
overlay.setAttribute("aria-hidden", "false");
}
function hideTableLoadingOverlay() {
const overlay = document.getElementById("table-loading-overlay");
if (!overlay) return;
overlay.classList.remove("is-visible");
overlay.setAttribute("aria-hidden", "true");
}
async function receiveEvent(message) {
await vpin.handleEvent(message);
if (message.type === "TableLaunching") {
showTableLoadingOverlay();
await fadeOut();
} else if (message.type === "TableRunning") {
hideTableLoadingOverlay();
} else if (message.type === "TableLaunchComplete") {
hideTableLoadingOverlay();
fadeIn();
}
}If the table window launches the table from local input, remember that vpin.sendMessageToAllWindows(...) excludes the sender. Call showTableLoadingOverlay() directly in the local joyselect path before await vpin.launchTable(...), or send the event with vpin.sendMessageToAllWindowsIncSelf(...).
If your theme implements attract mode, treat table launch as a hard suspension boundary. Clearing the current timer is not enough, because user-activity listeners, menu events, or TableRunning handling can accidentally schedule a new idle timer while VPX is still open.
Use a separate launch/remote-launch suspension flag:
- set
attractSuspended = trueand clear both idle and advance timers onTableLaunchingandRemoteLaunching - keep
shouldPauseAttractMode()returningtruewhileattractSuspendedis set - make user-activity handlers clear timers and return without scheduling a new idle timer while suspended
- clear
attractSuspendedonly onTableLaunchCompleteorRemoteLaunchComplete - after launch completion, restart the idle countdown instead of immediately starting attract mode
- in a local
joyselectlaunch path, suspend attract mode before callingvpin.launchTable(...)so the sender is protected before backend events arrive
Example pattern:
let attractIdleTimer = null;
let attractAdvanceTimer = null;
let attractSuspended = false;
function clearAttractTimers() {
clearTimeout(attractIdleTimer);
clearTimeout(attractAdvanceTimer);
attractIdleTimer = null;
attractAdvanceTimer = null;
}
function suspendAttractMode() {
attractSuspended = true;
clearAttractTimers();
}
function markUserActivity() {
if (attractSuspended) {
clearAttractTimers();
return;
}
clearAttractTimers();
attractIdleTimer = setTimeout(startAttractMode, ATTRACT_IDLE_MS);
}
async function receiveEvent(message) {
await vpin.handleEvent(message);
if (message.type === "TableLaunching" || message.type === "RemoteLaunching") {
suspendAttractMode();
} else if (message.type === "TableLaunchComplete" || message.type === "RemoteLaunchComplete") {
attractSuspended = false;
markUserActivity();
}
}The following input actions are passed to your handleInput function (table window only):
| Action | Gamepad | Keyboard |
|---|---|---|
joyleft |
Mapped button | [Input] keyleft (default ArrowLeft,ShiftLeft) |
joyright |
Mapped button | [Input] keyright (default ArrowRight,ShiftRight) |
joyup |
Mapped button | [Input] keyup (default ArrowUp) |
joydown |
Mapped button | [Input] keydown (default ArrowDown) |
joyselect |
Mapped button | [Input] keyselect (default Enter) |
joyback |
Mapped button | [Input] keyback |
joytutorial |
Mapped button | [Input] keytutorial when routed to handlers |
The following actions are handled internally by VPinFECore and do not reach your handler:
| Action | Gamepad | Keyboard | Effect |
|---|---|---|---|
joymenu |
Mapped button | [Input] keymenu (default m) |
Toggles the main menu overlay |
joycollectionmenu |
Mapped button | [Input] keycollectionmenu (default c) |
Toggles the collection menu overlay |
joytutorial |
Mapped button | [Input] keytutorial (default t) |
Toggles the Pinball Primer tutorial overlay |
joyexit |
Mapped button | [Input] keyexit (default Escape,q) |
Closes the application |
joypageup / joypagedown |
Mapped button | [Input] keypageup/keypagedown (defaults PageUp/PageDown) |
Pages the table wheel (see below) |
By default the core handles joypageup/joypagedown itself: it asks the backend
where the press should land (get_page_index) and broadcasts a TableIndexUpdate
to every window. Your theme moves its wheel through the same receiveEvent path it
already uses for external index updates, so paging works with no theme changes.
The user controls the behavior with two [Input] settings in vpinfe.ini:
pagingtype—alpha(default) jumps to the next/previous letter of the current Alpha sort (numbers and symbols share one#group);numericjumps by a fixed number of tables. Alpha paging falls back to numeric when the active sort isn'tAlphaor the list is all one letter.pagingsize— how many tables a numeric jump moves (default10). All paging wraps around.
A theme that wants its own paging behavior calls vpin.enableCorePaging(false);
the actions are then routed to handleInput like any other, and
vpin.getPageIndex(direction) is available if you want the config-aware target
index while animating the move yourself. While a core overlay (menu, collection
menu, tutorial) is up, these actions bypass core paging and go to the overlay's
handler regardless.
The JavaScript interface to the VPinFE API. Must be loaded in your theme:
<script src="/web/common/vpinfe-core.js"></script>These properties are available on the vpin instance after vpin.ready resolves:
| Property | Type | Description |
|---|---|---|
vpin.tableData |
array |
The current (possibly filtered) table list. Each element is a table object (see Table Data Object). |
vpin.monitors |
array |
List of monitor objects with name, x, y, width, height. Loaded during init. |
vpin.tableOrientation |
string |
Table playfield orientation from config: "landscape" or "portrait". |
vpin.tableRotation |
number |
Table playfield rotation in degrees from config (default 0). |
vpin.themeAssetsPort |
number |
HTTP server port (default 8000). |
vpin.menuUP |
boolean |
Whether the main menu overlay is currently visible. |
vpin.collectionMenuUP |
boolean |
Whether the collection menu overlay is currently visible. |
Sets up keyboard event listener and connects to the backend over the WebSocket bridge.
Registers an input handler for the table screen. Only works when the current window name is "table". The handler receives a single string argument (the action name).
Registers an input handler for the main menu overlay.
Registers an input handler for the collection menu overlay.
Programmatically toggles the main menu overlay open/closed.
Programmatically toggles the collection menu overlay open/closed.
Invokes a backend API method over the WebSocket bridge. Returns a Promise.
The following methods are available via vpin.call():
| Method | Args | Returns | Description |
|---|---|---|---|
get_my_window_name |
— | string |
Returns the window name for this instance ("table", "bg", or "dmd"). |
close_app |
— | — | Shuts down all browser windows and exits the application. |
get_monitors |
— | array |
Returns list of monitor objects with name, x, y, width, height. |
console_out |
output |
string |
Prints a message to the Python CLI console. Useful for debugging. Returns the same string. |
| Method | Args | Returns | Description |
|---|---|---|---|
get_tables |
reset=false |
string (JSON) |
Returns JSON string of the current (filtered) table list. Pass true to reset to the full unfiltered list. Each table object includes paths, media paths, addon flags, and metadata. |
launch_table |
index |
— | Launches the VPX table at the given index. Blocks until the table exits. Automatically tracks play in the "Last Played" collection. Sends TableLaunching before launch, TableRunning when the table finishes loading, and TableLaunchComplete when it exits. |
build_metadata |
download_media=true, update_all=false |
object |
Triggers a background metadata build/refresh. Sends progress events (buildmeta_progress, buildmeta_log, buildmeta_complete, buildmeta_error) to all windows. Returns {success, message}. |
| Method | Args | Returns | Description |
|---|---|---|---|
get_collections |
— | array |
Returns list of collection names from collections.ini. |
get_collections_metadata |
— | array |
Returns collection objects with name, type, is_filter, image, image_url, and table_count. image_url is a theme-server URL such as /collection_icons/favorites.png, or an empty string when no image is set. |
get_collection_image_url |
collection |
string |
Returns the image URL for one collection, or an empty string when no image is set. |
set_tables_by_collection |
collection |
— | Filters the table list by the named collection. Supports both VPS ID-based and filter-based collections. |
save_filter_collection |
name, letter, theme, table_type, manufacturer, year, sort_by, rating, rating_or_higher, order_by |
object |
Saves the current filter settings as a named collection. order_by is "Descending" or "Ascending" and defaults to "Descending". Returns {success, message}. |
get_current_collection |
— | string |
Returns the name of the currently active collection, or "None". |
| Method | Args | Returns | Description |
|---|---|---|---|
apply_filters |
letter, theme, table_type, manufacturer, year, rating, rating_or_higher |
number |
Applies VPSdb filters to the full table list. Each arg is optional (pass null to keep current). Returns the filtered count. |
reset_filters |
— | — | Resets all filters back to the full table list. |
apply_sort |
sort_type, order_by |
number |
Sorts the current filtered tables. sort_type is "Alpha", "Newest", "LastRun", "Highest StartCount", or "RunTime"; order_by is "Descending" or "Ascending". Returns the count. |
get_current_filter_state |
— | object |
Returns the current filter state: {letter, theme, type, manufacturer, year, rating, rating_or_higher}. |
get_current_sort_state |
— | string |
Returns the current sort type. |
get_current_order_state |
— | string |
Returns the current sort order ("Descending" or "Ascending"). |
get_filter_letters |
— | array |
Returns available starting letters from all tables (for filter UI). |
get_filter_themes |
— | array |
Returns available themes/categories from all tables. |
get_filter_types |
— | array |
Returns available table types (SS, EM, PM, etc.) from all tables. |
get_filter_manufacturers |
— | array |
Returns available manufacturers from all tables. |
get_filter_years |
— | array |
Returns available years from all tables. |
| Method | Args | Returns | Description |
|---|---|---|---|
send_event_all_windows |
message |
— | Sends an event to all windows except the caller. |
send_event_all_windows_incself |
message |
— | Sends an event to all windows including the caller and iframes. |
send_event |
window_name, message |
— | Sends an event to a specific window by name ("table", "bg", or "dmd"). |
| Method | Args | Returns | Description |
|---|---|---|---|
get_joymaping |
— | object |
Returns the gamepad button mapping from vpinfe.ini. Keys: joyleft, joyright, joyup, joydown, joypageup, joypagedown, joyselect, joymenu, joyback, joytutorial, joyexit, joycollectionmenu. Values are button index strings. |
get_keymapping |
— | object |
Returns the keyboard mapping from vpinfe.ini. Keys: keyleft, keyright, keyup, keydown, keypageup, keypagedown, keyselect, keymenu, keyback, keytutorial, keyexit, keycollectionmenu. Values are comma-separated browser key names or key codes. |
set_button_mapping |
button_name, button_index |
object |
Sets a gamepad button mapping and saves to config. Returns {success, message}. |
get_page_index |
index, direction |
number |
Returns the wheel index a page press should land on, from index in the given direction ("next" or "prev"). Honors [Input] pagingtype/pagingsize and the current sort. See Wheel Paging. |
| Method | Args | Returns | Description |
|---|---|---|---|
get_theme_name |
— | string |
Returns the active theme name from vpinfe.ini. |
get_theme_config |
— | object|null |
Loads and returns the theme's current configuration values. When a theme provides theme.json, VPinFE flattens the option value fields into the object returned to theme code. |
get_theme_assets_port |
— | number |
Returns the HTTP server port (default 8000). |
get_theme_index_page |
— | string |
Returns the full URL for this window's theme index page. |
get_table_orientation |
— | string |
Returns the table orientation from config ("landscape" or "portrait"). |
get_table_rotation |
— | number |
Returns the table rotation angle in degrees from config (default 0). |
Theme pages receive the current window name in the window query parameter:
?window=table?window=bg?window=dmd
For bg and dmd, VPinFE can also pass an optional high-DPI display override:
?override=x,y,width,height
This is intended for setups where the detected Chromium window bounds are not the values the theme should use, usually on high-DPI screens. The override value is a comma-separated string containing:
x: left positiony: top positionwidth: window widthheight: window height
Example:
const params = new URLSearchParams(window.location.search);
const windowName = params.get('window') || 'unknown';
const override = params.get('override');
let overrideBounds = null;
if (override) {
const [x, y, width, height] = override.split(',').map(Number);
overrideBounds = { x, y, width, height };
}If override is present, themes that position or scale BG/DMD content based on window bounds should prefer those values over window.innerWidth, window.innerHeight, or other automatically detected measurements.
| Method | Args | Returns | Description |
|---|---|---|---|
playTableAudio |
indexOrUrl, retries=3 |
— | Plays table audio using VPinFECore's centralized audio manager. Pass a table index (recommended) or URL string. |
stopTableAudio |
options={} |
— | Stops audio via centralized manager. Supports fade-out; pass { immediate: true } for an immediate stop. |
enableCoreAudio |
enabled=true |
— | Enables or disables centralized audio handling for the current window. Core audio is opt-in by default unless enabled in theme config. |
isCoreAudioEnabled |
— | boolean |
Returns whether centralized audio handling is currently enabled. |
setAudioOptions |
options |
— | Sets runtime audio options. Supported keys: maxVolume/max_volume/volume, fadeDuration/fade_duration_ms/fadeMs, loop. |
Returns an HTTP URL for a table's image. type can be "table", "bg", "dmd", "wheel", or "cab". Returns a fallback /web/images/file_missing.png URL if the file doesn't exist.
Returns an HTTP URL for a table's video. type can be "table", "bg", or "dmd". Returns a fallback /web/images/file_missing.png URL if no video exists. See Video Support.
Returns an HTTP URL using the user's configured media priority from Manager UI > Configuration > Media > Media Priorities. For "table", "bg", and "dmd", VPinFE chooses image or video first based on the setting and falls back to the alternate when the preferred file is missing. For "realdmd", VPinFE chooses realdmd-color.png or realdmd.png first based on the setting and falls back to the other frame.
Returns the same priority-aware selection with metadata: { url, kind, priority, path }. Real DMD selections also include variant with "color" or "standard".
Returns an HTTP URL for a table's audio file, or null if no audio exists. See Audio Support.
Plays table audio via VPinFECore's centralized audio manager. Normally you pass currentTableIndex; passing a URL string is also supported.
Stops centralized audio playback. Default behavior is fade-out, or pass { immediate: true } for an immediate stop.
Turns centralized core audio handling on or off for the current window.
Returns true when centralized core audio handling is enabled.
Updates centralized audio options at runtime: volume (maxVolume, max_volume, or volume), fade duration (fadeDuration, fade_duration_ms, or fadeMs), and loop.
Turns core-handled wheel paging (joypageup/joypagedown) on or off. Disable it if your theme does its own paging; the actions then arrive in handleInput. See Wheel Paging.
Returns true when core-handled wheel paging is enabled.
Asks the backend where a page press should land and returns the target index. Convenience wrapper around the get_page_index API method for themes doing their own paging animation.
Returns the full table object for a given table index. This is the same object as vpin.tableData[index]. See Table Data Object.
Returns the number of tables in the current (possibly filtered) table list.
Sends an event to all windows except the current one. Convenience wrapper around vpin.call("send_event_all_windows", message).
Sends an event to all windows including the current one and forwarding to iframes.
Suspends frontend keyboard/gamepad routing, calls backend to launch the selected table, then restores input after the launch lifecycle completes. The launch lifecycle is TableLaunching before the process starts, TableRunning when the table finishes loading, and TableLaunchComplete when it exits.
Loads table data from the backend into vpin.tableData. Pass reset=true to reload from the full unfiltered table list.
Handles incoming events with built-in logic for:
TableDataChange(collection/filter/sort changes)- centralized audio transitions on
TableIndexUpdate,TableLaunching,RemoteLaunching,TableLaunchComplete, andRemoteLaunchComplete
Call this at the top of your receiveEvent function to get automatic data refresh and default audio behavior.
Registers a custom event handler for a specific event type. The handler is called whenever that event type is received via handleEvent().
Each element in vpin.tableData (and the return of vpin.getTableMeta(index)) is an object with the following structure:
| Property | Type | Description |
|---|---|---|
tableDirName |
string |
The table's directory name. |
TableImagePath |
string|null |
Local path to the table playfield image (table.png or fss.png). |
BGImagePath |
string|null |
Local path to the backglass image (bg.png). |
DMDImagePath |
string|null |
Local path to the DMD image (dmd.png). |
WheelImagePath |
string|null |
Local path to the wheel/logo image (wheel.png). |
CabImagePath |
string|null |
Local path to the cabinet image (cab.png). |
TableVideoPath |
string|null |
Local path to the table playfield video (table.mp4 or fss.mp4). |
BGVideoPath |
string|null |
Local path to the backglass video (bg.mp4). |
DMDVideoPath |
string|null |
Local path to the DMD video (dmd.mp4). |
AudioPath |
string|null |
Local path to the audio file (audio.mp3). |
meta |
object |
Nested metadata object (see below). |
vpinplay |
object|null |
Cached VPinPlay cumulative rating payload for the table, or null until fetched/unavailable. |
Note: You typically don't use the path properties directly. Use
vpin.getImageURL(),vpin.getVideoURL(), andvpin.getAudioURL()which convert these paths to HTTP URLs. Direct access to path properties is useful for checking existence (e.g.,if (table.TableVideoPath)to decide whether to show video or image).
VPSdb and user-edited metadata:
| Property | Type | Description |
|---|---|---|
Title |
string |
Table display name. |
Manufacturer |
string |
Table manufacturer (e.g., "Williams", "Bally"). |
Year |
string |
Year of manufacture. |
Type |
string |
Table type code: "SS" (Solid State), "EM" (Electro Mechanical), "PM" (Pure Mechanical). |
Authors |
array |
List of VPX table author names. |
Theme |
string |
Table theme/category. |
Per-user stats and preferences stored in each table's .info file:
| Property | Type | Description |
|---|---|---|
Rating |
number |
User rating from 0 to 5. |
Favorite |
number |
Favorite flag (0 or 1). |
LastRun |
number|null |
Unix timestamp (seconds) of the last launch, or null if never played. |
StartCount |
number |
Number of times the table has been launched. |
RunTime |
number |
Total accumulated play time in minutes. |
Tags |
array |
User-defined tags (string list). |
Data extracted from the .vpx file itself:
| Property | Type | Description |
|---|---|---|
filename |
string |
VPX filename. |
manufacturer |
string |
Manufacturer from VPX metadata. |
year |
string |
Year from VPX metadata. |
type |
string |
Table type from VPX metadata. |
Boolean flags indicating detected features/addons in the VPX table:
| Property | Type | Description |
|---|---|---|
detectnfozzy |
boolean |
Nfozzy physics detected. |
detectfleep |
boolean |
Fleep sound pack detected. |
detectssf |
boolean |
SSF (Surround Sound Feedback) detected. |
detectfastflips |
boolean |
FastFlips detected. |
detectlut |
boolean |
LUT (color correction) detected. |
detectscorebit |
boolean |
ScoreBit integration detected. |
detectflex |
boolean |
FlexDMD detected. |
altSoundExists |
boolean |
AltSound pack exists for this table. |
altColorExists |
boolean |
AltColor pack exists for this table. |
pupPackExists |
boolean |
PuP-Pack exists for this table. |
Example usage (feature detection lights):
const meta = vpin.getTableMeta(currentTableIndex);
const vpx = meta.meta.VPXFile || {};
const features = [
{ key: "detectnfozzy", label: "Nfozzy" },
{ key: "detectfleep", label: "Fleep" },
{ key: "detectssf", label: "SSF" },
{ key: "detectfastflips", label: "FastFlips" },
{ key: "detectlut", label: "LUT" },
{ key: "detectscorebit", label: "ScoreBit" },
{ key: "detectflex", label: "FlexDMD" },
{ key: "altSoundExists", label: "AltSound" },
{ key: "altColorExists", label: "AltColor" },
{ key: "pupPackExists", label: "PuP-Pack" },
];
features.forEach(({ key, label }) => {
const isOn = vpx[key] === true || vpx[key] === "true" || vpx[key] === 1;
// Create a green/red indicator light based on isOn
});Common pattern for getting display-ready table information:
const table = vpin.getTableMeta(currentTableIndex);
const info = table.meta.Info || {};
const user = table.meta.User || {};
const vpx = table.meta.VPXFile || {};
const title = info.Title || vpx.filename || table.tableDirName || 'Unknown Table';
const manufacturer = info.Manufacturer || vpx.manufacturer || 'Unknown';
const year = info.Year || vpx.year || '';
const authors = Array.isArray(info.Authors) ? info.Authors.join(', ') : 'Unknown';
const rating = Number(user.Rating || 0);
const plays = Number(user.StartCount || 0);
const vpinplay = await vpin.getVPinPlayRating(currentTableIndex);
const cumulativeRating = vpinplay?.cumulativeRating ?? null;
const ratingCount = vpinplay?.ratingCount ?? 0;vpinfe-core.js can fetch the selected table's VPinPlay cumulative rating from the configured vpinplay.apiendpoint.
| Method | Returns | Description |
|---|---|---|
await vpin.getVPinPlayRating(index?) |
object|null |
Returns the cached rating for the table or fetches it from VPinPlay. |
await vpin.refreshVPinPlayRating(index?) |
object|null |
Forces a fresh fetch from VPinPlay. |
vpin.getCachedVPinPlayRating(index?) |
object|null |
Returns only the cached value already attached to the table. |
The returned object matches the API payload shape and is also stored on the table entry as table.vpinplay:
const table = vpin.getTableMeta(currentTableIndex);
const rating = table.vpinplay?.cumulativeRating ?? null;
const votes = table.vpinplay?.ratingCount ?? 0;All media files are stored per-table in either the medias/ subfolder or the table's root folder. The medias/ subfolder is checked first.
<Table Folder>
├── medias/
│ ├── table.png (or fss.png)
│ ├── bg.png
│ ├── dmd.png
│ ├── wheel.png
│ ├── cab.png
│ ├── table.mp4 (or fss.mp4)
│ ├── bg.mp4
│ ├── dmd.mp4
│ └── audio.mp3
└── <tablename>.vpx
| File | API Type | Description |
|---|---|---|
table.png / fss.png |
"table" |
Table playfield image |
bg.png |
"bg" |
Backglass image |
dmd.png |
"dmd" |
DMD image |
wheel.png |
"wheel" |
Wheel/logo image |
cab.png |
"cab" |
Cabinet image |
Use vpin.getImageURL(index, type) to get the URL.
| File | API Type | Description |
|---|---|---|
table.mp4 / fss.mp4 |
"table" |
Table playfield video |
bg.mp4 |
"bg" |
Backglass video |
dmd.mp4 |
"dmd" |
DMD video |
Use vpin.getVideoURL(index, type) to get the URL.
| File | Description |
|---|---|
audio.mp3 |
Per-table audio (music, callouts, etc.) |
Use vpin.getAudioURL(index) to get the URL. Returns null if no audio file exists.
Themes can display looping videos for table, backglass, and DMD screens in addition to (or instead of) static images.
For new themes, prefer vpin.getMedia(index, type) or vpin.getMediaURL(index, type) when you want to honor the user's Manager UI media priority. The default priority is video for table, backglass, and DMD media, and colorized for Real DMD frames. If the preferred file is missing, VPinFE automatically falls back to the available alternate.
Priority-aware example:
const media = vpin.getMedia(currentTableIndex, 'bg');
const preview = document.createElement(media.kind === 'video' ? 'video' : 'img');
preview.className = 'preview';
preview.src = media.url;
if (media.kind === 'video') {
preview.autoplay = true;
preview.loop = true;
preview.muted = true;
preview.playsInline = true;
}
container.appendChild(preview);Use vpin.getVideoURL(index, type) to get the video URL. The method returns a fallback file_missing URL if no video file exists, so check for this before creating a <video> element.
Example with image fallback:
const videoUrl = vpin.getVideoURL(currentTableIndex, 'table');
const imageUrl = vpin.getImageURL(currentTableIndex, 'table');
if (videoUrl && !videoUrl.includes('file_missing')) {
const preview = document.createElement('video');
preview.className = 'preview';
preview.poster = imageUrl; // stable dimensions while video loads
preview.src = videoUrl;
preview.autoplay = true;
preview.loop = true;
preview.muted = true;
preview.playsInline = true;
// Fall back to image if video fails to load
preview.onerror = () => {
const fallback = document.createElement('img');
fallback.className = 'preview';
fallback.src = imageUrl;
preview.replaceWith(fallback);
};
container.appendChild(preview);
} else {
const preview = document.createElement('img');
preview.className = 'preview';
preview.src = imageUrl;
container.appendChild(preview);
}For bg and dmd windows, use the same pattern with:
vpin.getVideoURL(currentTableIndex, 'bg')plusvpin.getImageURL(currentTableIndex, 'bg')vpin.getVideoURL(currentTableIndex, 'dmd')plusvpin.getImageURL(currentTableIndex, 'dmd')
Recommended rule for theme authors:
- Table window: optionally prefer
table.mp4overtable.png/fss.png - BG window: prefer
bg.mp4, fall back tobg.png - DMD window: prefer
dmd.mp4, fall back todmd.png
If you only use getImageURL() in BG or DMD renderers, those windows will remain image-only even when the matching video files exist.
Key points:
- Set
muted = true— browsers require this for autoplay to work without user gesture. - Set
poster = imageUrl— gives the video element proper dimensions before metadata loads, preventing layout shifts. - The
onerrorhandler provides a graceful fallback to the static image. - You can check
vpin.tableData[index].TableVideoPathdirectly to decide whether to create a video or image element.
VPinFECore now includes a centralized per-table audio manager. Theme code can use it directly and no longer needs to implement its own Audio/fade/retry logic.
Place an audio.mp3 file in the table's medias/ folder (or root folder). vpin.getAudioURL(index) returns the URL, or null if no audio file exists.
On the table window, await vpin.handleEvent(message) automatically manages audio transitions when core audio is enabled.
Core audio is opt-in by default. If your theme does not explicitly enable it (or call vpin.enableCoreAudio(true) at runtime), no automatic table audio playback will occur.
When enabled, these transitions are handled automatically:
TableIndexUpdate-> play selected table audioTableLaunchingandRemoteLaunching-> fade/stop audioTableLaunchCompleteandRemoteLaunchComplete-> resume audio for current selectionTableDataChange(withindex) -> play audio for that index
api.py knows when table launch starts/completes and emits lifecycle events, but it does not own the browser Audio object. Actual playback, fading, retries, and autoplay-policy handling must run in frontend JavaScript (VPinFECore/theme code), which owns the in-memory audio state.
vpin.sendMessageToAllWindows(...) excludes the sender. If your table window sends TableLaunching, it might not receive that same event back, so backend-emitted lifecycle events are the reliable source of truth for launch state.
For robust behavior, it is valid to also call:
vpin.stopTableAudio()directly in your localjoyselect/launch pathvpin.playTableAudio(currentTableIndex)directly on local launch-complete handling
This explicit local stop/resume acts as a safety net while still using centralized core audio.
Defaults:
- volume:
0.8 - fade duration:
500ms - loop:
true
function updateScreen() {
// ... update images, carousel, etc ...
if (windowName === "table") {
vpin.playTableAudio(currentTableIndex);
}
}
async function receiveEvent(message) {
// Keep this call at the top for built-in table-data refresh and core audio handling
await vpin.handleEvent(message);
// ... theme-specific event handling ...
}
// Optional immediate stop:
// vpin.stopTableAudio({ immediate: true });If your theme wants Manager UI-editable options, add a theme.json file to the theme root.
theme.json now serves as both the option schema and the saved value store. VPinFE Manager UI reads this file to build the configuration dialog and writes the selected values back into each option's value field.
Example:
{
"title": "Carousel Desktop Options",
"description": "These options control layout and audio behavior.",
"options": [
{
"key": "wheel.scale",
"name": "Wheel Scale",
"description": "Controls the wheel image scale multiplier.",
"type": "number",
"value": 1,
"min": 0.5,
"max": 2,
"step": 0.1
},
{
"key": "showClock",
"name": "Show Clock",
"description": "Show the clock overlay in the table window.",
"type": "boolean",
"value": true
},
{
"key": "audio.mode",
"name": "Audio Mode",
"description": "Select how the theme should handle table audio.",
"type": "select",
"value": "core",
"options": ["off", "core", "theme"]
}
]
}Full sample theme.json for quick testing:
{
"title": "Sample Theme Options",
"description": "Example configurable options exposed through the VPinFE Themes page.",
"options": [
{
"key": "showClock",
"name": "Show Clock",
"description": "Show a clock overlay on the table screen.",
"type": "boolean",
"value": true
},
{
"key": "headerTitle",
"name": "Header Title",
"description": "Text displayed in the theme header.",
"type": "text",
"value": "My Custom Theme"
},
{
"key": "footerMessage",
"name": "Footer Message",
"description": "Multi-line text shown at the bottom of the screen.",
"type": "textarea",
"value": "Welcome to VPinFE\nPress Start to Play"
},
{
"key": "wheel.scale",
"name": "Wheel Scale",
"description": "Scale multiplier for wheel art.",
"type": "number",
"value": 1,
"min": 0.5,
"max": 2,
"step": 0.1
},
{
"key": "themeMode",
"name": "Theme Mode",
"description": "Choose the overall layout style.",
"type": "select",
"value": "arcade",
"options": [
"minimal",
"arcade",
"modern"
]
},
{
"key": "accentColor",
"name": "Accent Color",
"description": "Hex color used for highlights.",
"type": "text",
"value": "#ffd84d"
},
{
"key": "audio.enabled",
"name": "Enable Audio",
"description": "Turn theme-controlled audio behavior on or off.",
"type": "boolean",
"value": true
},
{
"key": "audio.maxVolume",
"name": "Audio Max Volume",
"description": "Maximum playback volume for theme audio.",
"type": "number",
"value": 0.8,
"min": 0,
"max": 1,
"step": 0.05
},
{
"key": "advancedRules",
"name": "Advanced Rules JSON",
"description": "Raw JSON for advanced theme behavior.",
"type": "json",
"value": {
"showTop10": true,
"animateWheel": false,
"videoFadeMs": 750
}
}
]
}Supported field types in theme.json:
texttextareanumberbooleanselectjson
Notes:
keyis required and identifies the value returned throughget_theme_config(). Dot notation such asaudio.enabledcreates nested objects in the returned config.nameis the display label shown in Manager UI. If omitted, thekeyis shown.descriptionis shown as help text in the dialog.valueis the current saved value edited by the user.defaultis optional and is used as a fallback ifvalueis omitted.selectoptions may be simple scalar values or{label, value}objects.
Themes should continue reading user configuration through get_theme_config(), which returns a plain values object derived from theme.json.
For compatibility, if theme.json is missing, VPinFE still falls back to a legacy config.json file when present.
For the sample schema above, get_theme_config() would return an object like this:
{
"showClock": true,
"headerTitle": "My Custom Theme",
"footerMessage": "Welcome to VPinFE\nPress Start to Play",
"wheel": {
"scale": 1
},
"themeMode": "arcade",
"accentColor": "#ffd84d",
"audio": {
"enabled": true,
"maxVolume": 0.8
},
"advancedRules": {
"showTop10": true,
"animateWheel": false,
"videoFadeMs": 750
}
}Core audio can be configured from theme config:
{
"use_core_audio": true,
"audio": {
"enabled": true,
"maxVolume": 0.8,
"fadeDuration": 500,
"loop": true
}
}If omitted, core audio remains disabled by default.
Also accepted for compatibility:
useCoreAudio(camelCase)audio.max_volumeoraudio.volumeaudio.fade_duration_msoraudio.fadeMs
Two common patterns for fade-to-black transitions during table launch:
Pattern 1: Fade container (used by Carousel Desktop) — wrap all content in a container with opacity transition:
#fadeContainer {
position: fixed;
top: 0; left: 0;
width: 100vw; height: 100vh;
transition: opacity 4.5s ease-in-out;
opacity: 1;
}Pattern 2: Fade overlay (used by Slider Video) — a fixed black overlay toggled via CSS class:
#fadeOverlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: black;
opacity: 0;
transition: opacity 0.8s ease-in-out;
pointer-events: none;
z-index: 9999;
}
#fadeOverlay.show {
opacity: 1;
}Recommended base styles to prevent white flash and scrollbars:
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: black;
color: white;
}For bg and dmd windows that show a single fullscreen image:
.fullscreen-image-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.fullscreen-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.5s ease-in-out;
}