msx-knowledge-base

Cookbook — Real-World MSX Recipes

A curated, opinionated tour of the most practically-relevant real examples in this KB, grouped by what you actually want to build. This is not the full catalog — for every example with searchable metadata, see Examples Index. Here the focus is “which real file do I start from for THIS task, and why does it matter in practice.”

Test any page instantly via the web version.

The tour has two parts:

For practical recommendations and rendering quirks learned from building real pages — item-type/"list"-vs-"pages" choices, transparent/background chains, image sizing math, imageOverlay/icon-contrast pitfalls, background video/audio persistence, player UI timing, and more — see the companion page Best Practices & Good to Know.

URLs: all example URLs use http:// on purpose (MSX auto-upgrades to https:// in a secure context via the secure parameter) — see Testing Your JSON. Features are 0.1.0 unless a minimum version is noted.


Part A — Practical, usable pages

Media lists

The four github/base/ examples are the cleanest starting points — a templated grid whose items each trigger one media action.

Reference: Actions, Content Root Object

M3U/PLS vs. MRSS: two different playlist-import mechanisms

Both exist for the same reason — turning a playlist/feed format you already have into MSX content without hand-writing JSON — but they get there in fundamentally different ways:

  M3U/PLS MRSS
Mechanism Backend conversion service (m3u.php/pls.php, closed-source, runs on msx.benzac.de) Interaction plugin (mrss.html, open source, runs client-side)
Where conversion happens Server-side, on the conversion service Client-side, inside the app itself
CORS requirement The playlist file itself does not need CORS enabled — MSX only ever talks to the already-CORS-enabled conversion service, never to the original playlist URL The MRSS feed URL itself does need CORS enabled, since MRSS is an XML format and the plugin fetches and parses that XML client-side via XHR (see the general rule below). An optional dictionary URL needs it for the same reason (it’s fetched as JSON). banner/background do not need CORS — they end up rendered as a plain <img> tag. A plugin URL does not need CORS either — it is loaded as an iframe, not fetched
Metadata richness Low: M3U/PLS are simple formats, usually just title/URL pairs. Metadata extensions exist (e.g. M3U’s #EXTINF comment line) but are only partially standardized and sparsely documented Higher: MRSS is a standardized RSS extension with a defined, richer set of fields (title, description, thumbnail, media info, categories, …), so the resulting content page can carry more detail
Launcher service m3u.html (M3U only — there is no equivalent launcher for PLS, which has little remaining real-world relevance; the pls.php conversion service itself is unaffected and still works) mrss.html
Min. version (launcher) 0.1.145 — newer than the base m3u.php/pls.php conversion service itself (0.1.30) 0.1.146, matching the plugin’s own minimum version

Both launcher services set their parameters as a base64-encoded JSON start parameter (see M3U/PLS Files and MRSS Feeds for the exact action syntax) and can be registered with a URL Shortener Service for the app’s Setup Start Parameter input. Neither stores submitted URLs/files persistently: MRSS processing happens entirely in-plugin with no server-side storage at all; the M3U/PLS conversion service uses only session-based storage (deleted once the session closes, files above 25 MB truncated), plus a separate long-lived cookie for the favorites feature (expires after a year of non-use). The M3U launcher additionally restricts usage to legal content and may auto-block suspicious URLs.

General rule of thumb for when CORS actually matters: it only applies to a URL that gets fetched as data — JSON, XML, or any other payload read via XHR (XMLHttpRequest/fetch, the browser mechanism for requesting a URL’s raw response in JavaScript instead of just letting the browser display it — used for a menu/content data URL, an execute:/reference endpoint, or the MRSS feed URL above). A URL that instead becomes the src of an <img> (image, background, logo, a banner, …), an <iframe> (any plugin: URL — video/audio/interaction), or native <video>/<audio> playback is loaded as a browser resource, not fetched as data, and does not need CORS enabled for basic display/playback. (Reading pixel data back out of a cross-origin <video>/<img>/<canvas> — e.g. a plugin’s own custom effects — is the one case where CORS can still matter even for those tags, but that’s an implementation detail of the specific plugin, not something MSX itself requires. The Backdrop Plugin is a concrete real example: its url (backdrop image) property needs CORS precisely because the plugin reads the image’s pixels to generate the atmospheric background effect — an ordinary image/background property on an ordinary item never needs this, since nothing reads its pixels back.)

Deep dive — Node Browser MSX (browsing a plain HTTP directory listing)

Node Browser MSX turns the plain directory-listing output of an HTTP server into a browsable MSX menu/content structure, purely by file/folder naming convention — no custom backend code needed beyond serving files with directory listing enabled. The showcase names Node.js/http-server (the same package used in the Quick-Start Guide) as its primary/default option, but per Tips & Tricks → Node Browser MSX it works with any HTTP server that has directory listing and CORS enabled — that page gives the concrete Apache .htaccess recipe (Options +Indexes + the CORS headers), plus how to point the browser at a non-root folder via a <meta name="node-browser-folder" content="path/to/folder"/> tag in the loaded index file. It’s a fast way to browse and test-drive a local or online file server directly in MSX: images become a slideshow, video/audio files are recognized and played directly with the standard player, and it also doubles as a way to host and open your own hand-written MSX JSON pages from a plain static file server — no purpose-built/MSX-aware backend needed, just directory listing and CORS turned on.

Embedding it directly, instead of going through the standalone showcase launcher: content:request:interaction:access:{SERVER}@http://nb.msx.benzac.de/interaction, with {SERVER} replaced by the target server’s IP+port or hostname (e.g. 192.168.0.10:8080) — see Tips & Tricks → Node Browser MSX.

File/folder naming conventions it recognizes:

Tabs within a folder: since index.json is just the default entry point, a folder can swap it for another JSON file (e.g. section2.json) at runtime via the interaction:commit:message:index:{FILE} action — the standard way to build a tabbed layout across the whole cloud-storage family (Google Drive/OneDrive/Dropbox/Node Browser MSX alike). See Tips & Tricks → Cloud Storage Service Tabs for the full worked example.

Player choice for video items: by default, Node Browser MSX (like Google Drive/OneDrive/Dropbox MSX) plays video through the Tizen Player where it’s available, not the HTML5X Plugin — so a plain video:{asset:video:{FILE}} action may not give you the HTML5X-specific features (subtitle/audio track selection, etc.). To force the HTML5X Plugin instead, use video:plugin:http://msx.benzac.de/plugins/html5x.html?id={asset:id:{FILE}} — see Tips & Tricks → External Subtitles for a worked example (built for Google Drive MSX, but explicitly stated to work the same way across all four cloud-storage services).

Combining the index.json/menu/content/panel conventions with the assets folder means you can hand-write and host a full MSX app on any plain static file server with directory listing on — no MSX-aware backend required at all, with server-side assets wired in cleanly via {asset:...} instead of hardcoded absolute URLs.

See Node Browser MSX for the setup start parameter and demo link.

Adaptive & dynamic playback

Live data (EPG, progress, countdowns)

The live object (since 0.1.70) re-evaluates every second while an item is visible, updates its visual properties, and fires a one-shot action on each state transition. Live inline expressions ({now:…}, {from:…}, {progress:…}, {countdown:…}) render the values — see Live Object and Live Inline Expressions.

A Live Inline Expression only works inside a live object’s own properties, or in the extension property — never in a plain item’s headline/title/text directly. Putting {now:date:...} straight into an item’s headline (with no live object) leaves it unresolved on screen. See AGENTS.md → Inline Expressions and the live clock example just below.

Separately, most (but not all) live object properties also require the item’s own version of that property to already be set to a non-null value before live can update it at all. Per Live Object, this applies to title, titleHeader, titleFooter, label, icon, headline, text, tag, badge, stamp, progress, image, extensionIcon, and extensionLabel — MSX only renders these as their own element when the item’s own value is present, so there’s nothing for live to update otherwise. The exception: color-type properties (color, tagColor, badgeColor, stampColor, progressColor, progressBackColor, wrapperColor) recolor the item directly instead of needing a dedicated element, so they can always be live-updated regardless of the item’s own initial value.

Deep dive — a minimal “resume playback” item (live_test_2 pattern)

Leaving a video and picking it up later at the same position is one of the most commonly wanted features, and needs only a few additions layered on top of a normal video item — the pattern behind Example 2 (Playback & Resume) (xp/live_test_2.json):

{
    "template": {
        "type": "separate",
        "layout": "0,0,2,4",
        "icon": "msx-white-soft:movie",
        "color": "msx-glass",
        "titleFooter": "",
        "progress": -1,
        "live": {
            "type": "playback",
            "titleFooter": "",
            "running": {
                "titleFooter": "{progress:time:hh:mm:ss}"
            },
            "action": "player:show"
        },
        "properties": {
            "resume:key": "url"
        }
    }
}

What each addition does, and why it’s there:

Setting Why
titleFooter: "" (template level) The item’s titleFooter must be a non-null value for the live object to be allowed to update it at all (see Live Object) — an empty string is the smallest value that qualifies.
progress: -1 A progress that is entirely unset can never be shown or updated; -1 makes it updatable while keeping the bar hidden until there’s something to show (see Content Item Object).
live.type: "playback" (since 0.1.70) Binds the live object to the player’s actual state instead of a fixed schedule/lifetime timer. Normally only "init"/"running"/"over" — but since 0.1.74, combined with resume:key below, it can also enter "coming": idle but resumable, not just actively playing (see Live Object → type).
live.titleFooter: "" The base value — applies in every state that has no more specific override, including "coming" (since this example sets no coming.titleFooter) — so the footer stays empty both before the item has ever been resumable and while it’s idle-but-resumable, and only fills in once "running" takes over below. A deliberate UX choice, not a technical limitation — a coming.titleFooter override would work just as well.
live.running.titleFooter: "{progress:time:hh:mm:ss}" While this item is the one playing, shows the live playback position as hh:mm:ss, a Live Inline Expression.
live.action: "player:show" While the live object is in state "running", executing the item runs this action instead of the item’s normal action — reopening the player instead of restarting/re-resolving the video from the top.
properties["resume:key"]: "url" The Extended Property (since 0.1.111) that actually makes MSX remember and restore the playback position, keyed here by the video’s own URL.

How the pieces actually relate — the part that isn’t obvious from reading each property in isolation:

See Live Object, Extended Properties, and Example 2 (Playback & Resume) for the complete worked example.

Deep dive — the live channels / EPG backend (live_test_4)

live_test_4.json is only the shell; the interesting part is the server that generates the channel list and keeps every tile live. Here is the reusable pattern (the reference implementation is a single PHP endpoint).

One endpoint, two response shapes. The same URL returns either the channel content or a per-tile live update, chosen by its query parameters:

if ($channel != -2 && $program != -2) {
    echoUpdateAction(...);   // -> a response object that updates ONE channel tile
} else {
    echoChannelList(...);    // -> the content root (type "list") with ALL channels
}

1. Each channel tile ships a setup live object. A type: "setup" live object is never “live” itself — it runs its action once, when the tile becomes visible (see Live Object). Here that action calls the service back for this channel:

{
    "id": "channel0",
    "titleHeader": "{txt:msx-white:Channel 1}",
    "playerLabel": "Channel 1",
    "action": "video:plugin:http://msx.benzac.de/plugins/live.html?channel=0&program=-1",
    "live": {
        "type": "setup",
        "action": "execute:service:http://example.com/live.php?type=default&channel=0&program=-1"
    }
}

execute:service: (since 0.1.90) runs in parallel, so all visible channels resolve their current program at once (up to 128 concurrent).

2. The service answers with an update + a schedule live object. The response replaces the tile’s live object with the real current program and — crucially — arms an over action that will fetch the next program when this one ends:

{
    "response": {
        "status": 200,
        "text": "OK",
        "data": {
            "action": "update:content:channel0",
            "data": {
                "live": {
                    "type": "schedule",
                    "from": 1730000000000,
                    "to": 1730000600000,
                    "titleHeader": "Program 1.1",
                    "titleFooter": "{from:time:hh:mm} - {to:time:hh:mm}",
                    "running": { "titleFooter": "{from:time:hh:mm} - {to:time:hh:mm} | {countdown:text:hm} left" },
                    "over": {
                        "action": "execute:service:http://example.com/live.php?type=default&channel=0&program=1"
                    }
                }
            }
        }
    }
}

3. The over action closes the loop. When the program ends, its one-shot over.action calls the service again with the next program index; the service returns a fresh schedule whose own over points to the one after that, and so on. The result is a self-sustaining, server-driven EPG: the client hardcodes nothing, and each tile keeps advancing as long as it stays visible. (This is the concrete form of the polling loop noted in Live Object.) The from/to values are epoch-millisecond timestamps the server computes per program.

4. Focus preview (optional). For the preview layout, each tile also carries a selection object whose action is update:content:overlay:preview with a schedule live object in its data; a space item id: "preview" on an overlay page (plus navigationSpan) renders the highlighted program’s description as you move focus — a classic EPG side panel.

5. Player controls. The template’s properties give it a broadcast feel — e.g. "progress:type": "fix:Live", "label:extension": "{ico:live-tv}", the transport buttons disabled ("button:play_pause:enable": "false", …), and "button:content:action": "[release:panel|panel:request:player:program]" to open program info.

One service, many presentations: the same endpoint also serves context=panel and the type variants list / grid / compact / preview / overview from the same logic.

Deep dive — the TV Guide (EPG grid) backend (guide.php)

The TV Guide menu item ("data": "user:http://…/guide.php") points to a second service that renders a full EPG grid. It reuses the live-object idea but adds several patterns worth stealing.

Stateful + credentialed. Unlike live.php, the guide is stateful — it uses server sessions to remember which channel window and start time you are viewing. That is exactly why the menu data carries the user: prefix (Main Action variant, since 0.1.30): it enables withCredentials so the session cookie is sent. Every action back to the service is credentialed the same way (execute:user:…, panel:user:…). See Actions.

Grid via compress + time-positioned control items. The guide is type: "pages", compress: true (16×8 grid): 2 columns hold the channel names (on an overlay page), and 4 half-hour slots span the remaining 14 columns. Each program is a control item whose layout column encodes its start time and whose width encodes its duration, with a fractional offset for sub-cell precision:

{
    "id": "program0_3_0",
    "type": "control",
    "layout": "2,1,3,1",
    "offset": "0.000,-0.5,0.500,0",
    "label": "{col:msx-white-soft}...",
    "action": "panel:user:http://example.com/guide.php?type=info&channel=0&program=3",
    "live": {
        "type": "schedule",
        "from": 1730000000000,
        "to": 1730001800000,
        "label": "Program 1.4",
        "over": { "label": "{col:msx-white-soft}Program 1.4", "action": "[invalidate:content|reload:content]" }
    },
    "selection": {
        "important": true,
        "action": "data",
        "data": {
            "actions": [
                { "action": "update:content:description_0", "data": { "headline": "Program 1.4", "text": "..." } },
                { "action": "update:content:image_0", "data": { "live": { "type": "schedule", "from": 1730000000000, "to": 1730001800000 } } },
                { "action": "update:content:event_0", "data": { "live": { "type": "schedule", "from": 1730000000000, "to": 1730001800000, "running": { "text": "..." } } } }
            ]
        }
    }
}

Three reusable patterns in that one item:

Time headers via number expressions. The slot labels are fixed timestamps formatted inline — "{num:1730000000000:date:D, dd.mm.}" and "{num:1730000000000:time:hh:mm}". See Number Inline Expressions.

Channel paging via POST → session → reload. The up/down buttons do not navigate — they POST the new window to the service, which stores it in the session and answers reload:content:

{
    "id": "channels_down_0",
    "type": "button",
    "enable": true,
    "layout": "14,7,1,1",
    "icon": "keyboard-arrow-down",
    "action": "[invalidate:content|execute:user:http://example.com/guide.php]",
    "data": { "channelOffset": 5 }
}

Cross-link into the channels view. The info panel’s “watch” button jumps straight to the live.php channels content and starts the chosen channel using the focus separator (>): "content:http://example.com/live.php>channel0>execute" — load the content, focus item channel0, then execute it. See Focus Separator.

The service also returns a few non-MSX bookkeeping keys at the top level (sessionId, sessionTimestamp, channelCount, channelOffset, startTimestamp, executionTime); MSX ignores unknown top-level properties.

Interaction & UX

Plugins (media, immersive, platform, ads)

Plugins are HTML pages MSX loads to extend playback or interaction; JavaScript is optional. All are invoked from JSON via video:plugin:{URL} or the interaction actions — the JSON side is shown here, the plugin page itself is external.

A video/audio plugin doesn’t have to contain an actual <video>/<audio> element at all — per Video/Audio Plugin, it is “nothing more than a simple HTML page that is loaded into an iframe,” and “any HTML page can be loaded as a video/audio plugin.” The benchmark tests above are a good real-world illustration of just how far that goes: none of them touches a real video element, yet each one loads and behaves exactly like any other video:plugin:{URL} item (same player controls, same extended properties, same lifecycle). The same freedom applies to real content, not just benchmarks — e.g. an animated slideshow/presentation driven entirely by an iframe.

Whether a given plugin is worth loading via video:plugin:{URL} or audio:plugin:{URL} depends on whether its visual output is something you actually want to show. Technically the two load the exact same plugin iframe — the only difference is that an audio:plugin:{URL} iframe is kept hidden, so all you see is the background (the same as a plain audio:{URL} item), while video:plugin:{URL} shows whatever the iframe renders. Which one fits depends on the specific plugin and use case, not a fixed rule — e.g. the SoundCloud plugin (listed above) also renders a waveform visualization and cover artwork, so video:plugin: is the better fit to actually show that; conversely, a YouTube video that’s really just music with a static thumbnail could just as reasonably be loaded via audio:plugin: instead, with your own custom background shown in its place. A dedicated audio-visualizer plugin is the clearest case for video:plugin:, but it isn’t the only pattern where the choice matters.

Deep dive — building an interaction plugin (plugin_test_2)

An interaction plugin is a headless HTML page MSX loads into a background iframe. It has no UI of its own — it only handles events, data, and requests, and in return hands MSX content to render or actions to run (see Interaction Plugin). plugin_test_2.json wires up four of them:

{ "title": "Template", "action": "interaction:load:http://msx.benzac.de/interaction/template.html" },
{ "title": "Settings Example", "action": "content:request:interaction:init@http://msx.benzac.de/interaction/settings.html" }

The handler contract. You register one handler object, implement the callbacks you need, then setupHandler + init:

function MyHandler() {
    this.init = function() { /* ... */ };
    this.ready = function() { TVXInteractionPlugin.success("Ready."); };
    this.handleEvent = function(data) { /* app/video/slider/settings events */ };
    this.handleData = function(data) { /* data committed back from MSX */ };
    this.handleRequest = function(dataId, data, callback) { callback(/* content or data */); };
    this.onError = function(message, error) { /* ... */ };
}
TVXPluginTools.onReady(function() {
    TVXInteractionPlugin.setupHandler(new MyHandler());
    TVXInteractionPlugin.init();
});

See Plugin API Reference for the full method list, and Plugin Events Reference for the handleEvent event types. The bare template.html handler just logs each callback; the settings handler shows the useful pattern below.

1. The plugin serves the UI (handleRequest). content:request:interaction:init@…/settings.html makes MSX load the plugin and call handleRequest("init", …); whatever the handler passes to callback(...) becomes the rendered content — here a normal Content Root Object built in JavaScript (pages of control items):

this.handleRequest = function(dataId, data, callback) {
    if (dataId == "init") {
        callback(settings);            // a full MSX content root (pages of controls)
    } else if (dataId == "special") {
        callback(specialControlPanel); // returned for panel:request:interaction:special
    } else {
        callback();
    }
};

handleRequest may answer asynchronously — just call callback(result) later (after a fetch, setTimeout, etc.).

2. MSX commits user input back (interaction:commit → handleData). Each control carries action: "interaction:commit" with a data payload; selecting it sends that payload to the plugin’s handleData:

{
    "id": "check_box_0-0",
    "type": "control",
    "label": "Check Box 0-0",
    "extensionIcon": "msx-white-soft:check-box-outline-blank",
    "action": "interaction:commit",
    "data": { "action": "settings:item:check_box_0-0" }
}

3. The plugin updates its model and re-renders (executeAction). handleData mutates the in-memory model (flip checked, swap extensionIcon) and then tells MSX to re-fetch:

this.handleData = function(data) {
    var action = data.data.action;                         // "settings:item:check_box_0-0"
    if (action.indexOf("settings:item:") == 0) {
        updateSettingsItem(action.substr(14), data.data);  // flip state in the model
        TVXInteractionPlugin.executeAction("reload:content"); // re-render (or "reload:panel")
    }
};

That round-trip — request → render → commit → update model → reload — is how you build fully stateful, server-less UIs (settings screens, forms, multi-select panels, star ratings) entirely inside a plugin. Nested panels work the same way: a control can panel:data into a sub-panel whose items commit [back|interaction:commit], or panel:request:interaction:special to have the handler serve the panel too. (search.html and observer.html are two more handlers wired up by the same JSON.)

Immersive Mode — aligning a plugin that does show content. Most interaction plugins are headless, but if yours renders visible content (the template.html handler shows a debug log), it should align with Immersive Mode — a display setting, default-on in recent MSX versions, that centers and scales the main frame — otherwise those elements can land outside the 16:9 frame. (It is a choice, not a rule: full-bleed visuals such as backgrounds should fill the whole screen anyway, while logos, text and boxes want to stay inside the frame. The Backdrop plugin does exactly this: its atmospheric background is drawn fullscreen, while the backdrop image sits inside the content wrapper and so stays aligned to the 16:9 frame.) The shared content-controller.js (referenced by template.html, delegated to from the handler’s init/ready/handleEvent) reads the current mode and repositions your wrapper:

var setupWrapper = function() {
    if (immersiveMode == 1) {                     // center + scale to the main frame
        contentWrapper.css({
            left: "50%", top: "50%",
            width: TVXSettings.SCREEN_WIDTH + "px",
            height: TVXSettings.SCREEN_HEIGHT + "px",
            marginLeft: -Math.floor(TVXSettings.SCREEN_WIDTH / 2) + "px",
            marginTop:  -Math.floor(TVXSettings.SCREEN_HEIGHT / 2) + "px",
            transform: TVXSettings.ZOOM_FACTOR != 1 ? "scale(" + TVXSettings.ZOOM_FACTOR + ")" : ""
        });
    } else if (immersiveMode == 0) {              // reset
        contentWrapper.css({ left: "", top: "", width: "", height: "", marginLeft: "", marginTop: "", transform: "" });
    }
};
this.validate = function() {
    TVXInteractionPlugin.onValidatedSettings(function(data) {
        immersiveMode = TVXTools.strToNum(data.info.application.settings.immersiveMode, -1);
        setupWrapper();
    });
};
this.handleEvent = function(data) {
    if (data.event == "app:resize") {                     // zoom may have changed
        TVXInteractionPlugin.validateSettings(setupWrapper);
    } else if (data.event == "settings:immersive_mode") { // user toggled it
        immersiveMode = TVXTools.strToNum(data.value, -1);
        setupWrapper();
    }
};

Read the current mode via onValidatedSettings/validateSettings (data.info.application.settings.immersiveMode), size to TVXSettings.SCREEN_WIDTH/SCREEN_HEIGHT, scale by TVXSettings.ZOOM_FACTOR, and re-run on the app:resize and settings:immersive_mode events. See Plugin API Reference and Plugin Events Reference.

Deep dive — building a video plugin (plugin_test_4, HTML5X)

A video plugin is an HTML page with a media element (here <video id="player">) that MSX loads via video:plugin:{URL}. You implement a player object; MSX calls its methods to drive playback and polls it for the progress bar. The anchor item plays a URL and configures subtitles through html5x:* item properties, wiring its content button to a player panel:

{
    "playerLabel": "Sintel © copyright Blender Foundation",
    "action": "video:plugin:http://msx.benzac.de/plugins/html5x.html?url=http://msx.benzac.de/media/sintel/sintel.mp4",
    "properties": {
        "resume:position": "102",
        "button:content:icon": "settings",
        "button:content:action": "panel:request:player:options",
        "html5x:subtitle:en:English": "http://msx.benzac.de/media/sintel/en.vtt",
        "html5x:subtitle:de:Deutsch": "http://msx.benzac.de/media/sintel/de.vtt"
    }
}

Video sizing & fullscreen. Since a plugin is just an HTML page, you can style it however you want; by convention these plugins size the media element to fill the whole frame (video { width: 100%; height: 100%; }). On a TV that frame is 16:9, but MSX also runs on mobile and desktop, where the frame matches the actual display — so the video fills the real screen for true fullscreen (source videos are not always 16:9).

The player contract. Register a player object; MSX calls its lifecycle, transport and state methods:

function MyPlayer() {
    var player = null;
    this.init = function() { player = document.getElementById("player"); /* add media listeners */ };
    this.ready = function() {
        TVXVideoPlugin.startLoading();
        TVXVideoPlugin.requestData("info:base", function(data) {
            // resolve the source from the plugin URL params:
            setupVideoWithId(TVXServices.urlParams.get("id")) ||
            setupVideoWithUrl(TVXServices.urlParams.get("url"));
        });
    };
    this.play  = function() { player.play(); };
    this.pause = function() { player.pause(); };
    this.stop  = function() { player.pause(); };   // HTML5 has no real stop
    this.getDuration = function() { return player.duration; };
    this.getPosition = function() { return player.currentTime; };
    this.setPosition = function(p) { player.currentTime = p; };
    this.getUpdateData = function() {              // polled for the progress bar
        return { position: this.getPosition(), duration: this.getDuration(), speed: this.getSpeed() };
    };
    this.handleData = function(data) { handleMessage(data.message); };
    this.handleRequest = function(dataId, data, callback) { callback(createResponseData(dataId)); };
}
TVXPluginTools.onReady(function() {
    TVXVideoPlugin.setupPlayer(new MyPlayer());
    TVXVideoPlugin.init();
});

Driving MSX from media events. Map the <video> element’s events onto MSX calls so the app UI mirrors real playback state:

player.addEventListener("canplay", function() { TVXVideoPlugin.startPlayback(true); }); // accelerated start
player.addEventListener("playing", function() { TVXVideoPlugin.stopLoading(); TVXVideoPlugin.setState(TVXVideoState.PLAYING); });
player.addEventListener("pause",   function() { TVXVideoPlugin.setState(TVXVideoState.PAUSED); });
player.addEventListener("waiting", function() { TVXVideoPlugin.startLoading(); });
player.addEventListener("ended",   function() { TVXVideoPlugin.stopPlayback(); });
player.addEventListener("error",   function() { TVXVideoPlugin.error("Video error: ..."); });

requestData(dataId, callback) pulls data from MSX (e.g. "video:info" to read the item’s properties); requestInteractionResponse(id, callback) resolves an id-based source through the interaction/resolve mechanism (so the same plugin supports ?id=… as well as ?url=…).

The “extended” part — custom player panels (the reason to build your own). This is the same round-trip as an interaction plugin, but on the player side. The item’s content button runs panel:request:player:options → MSX calls the player’s handleRequest("options", …), which returns a Content Root Object built in JS (the audio/subtitle/fullscreen menu). Each panel item commits back with player:commit:message:{MESSAGE} → the player’s handleData receives data.message and acts:

{ "label": "English", "action": "player:commit:message:subtitle:0" }
this.handleData = function(data) {
    var message = data.message;                   // "subtitle:0"
    if (message.indexOf("subtitle:") == 0) {
        TVXVideoPlugin.executeAction("cleanup");   // close the panel
        selectSubtitleTrack(TVXTools.strToNum(message.substr(9), -1), true, true);
    }
};

Subtitles come from the item properties: the plugin reads every html5x:subtitle:{lang}:{Label} entry, builds <track> elements, and remembers the chosen language via TVXServices.storage for next time. On top of that this plugin adds CORS handling (html5x:cors → crossOrigin/proxy), a related-content option (html5x:content), iOS/Mac fullscreen, live-stream duration handling (infinite duration → track the max position), and audio/subtitle language indicators shown via setupExtensionLabel.

Drawing visual overlays in the 16:9 frame. A video plugin can align its own on-screen visuals with Immersive Mode the same way an interaction plugin does (via TVXVideoPlugin.onValidatedSettings/validateSettings and TVXSettings). This is rarely needed — most video plugins draw nothing of their own over the video — but the option exists if you render visual overlays (these are display-only; a plugin never receives key or mouse input).

See Video/Audio Plugin for the player skeleton and Plugin API Reference for every TVXVideoPlugin method.


Part B — Beautiful layouts

Once the data works, these show how to make it look like a polished TV UI. All live under github/extended/ and require MSX 0.1.112+, except list3_section0.json–list3_section3.json below, which require 0.1.144 (their tab-switching relies on replace:content:). They are filled with dummy images and built statically — in production you would generate them from a server or assemble them with a plugin, but the layout techniques are exactly the same.

They lean on the same toolbox: template + items, decorative space items, offset for pixel-nudging, imageFiller: "cover", imageOverlay, wrapperColor, enumerate numbering, id:-prefixed group values for independent per-page enumeration counters, teaser/control item types, selection previews, preload for smooth paging, and underlay/overlay decoration pages.

Reference: Content Root Object, Content Item Object, Extended Properties

Deep dive — layout drives navigation, offset doesn’t (list0.json)

MSX’s directional-navigation engine (up/down/left/right) decides what to focus next purely from each item’s layout (its grid position) — offset is invisible to it entirely; offset only nudges the rendered position/size, with zero effect on what gets focused next. That means you can deliberately make an item’s logical grid footprint narrower or wider than its visual footprint (layout + a compensating offset) to steer navigation without changing how anything looks.

The general shape of the algorithm: while still on the current page, moving in a direction tries to preserve the perpendicular axis (X when moving vertically, Y when moving horizontally) as much as possible — staying “in lane” so gaps get skipped over rather than causing a diagonal jump — until the outermost edge of the page is reached in that direction. Once the edge is reached, MSX either wraps around or moves to the next/previous page, again trying to preserve X/Y alignment as much as possible.

list0.json exploits exactly this to make a “Show All” button reachable at the end of a row of four selectable items, without it looking like a fifth grid cell:

{
    "layout": "9,1,2,3",
    "offset": "0,-0.5,1,0",
    "titleHeader": "{col:msx-white}Fourth Item"
},
{
    "type": "button",
    "layout": "11,1,1,1",
    "offset": "0,-1,0,-0.5",
    "icon": "more-horiz"
}

The button’s own offset ("0,-1,0,-0.5") shifts and enlarges it purely visually (so it reads as its own tile, overlapping the row above) — none of that affects any of the navigation behavior above, only its layout does.

What happens if you get this wrong: if the fourth item instead used "layout": "9,1,3,3" + "offset": "0,-0.5,0,0" — full width, no compensating offset — it would render visually identical to the version above, but its logical grid footprint would now reach all the way to column 11, exactly overlapping the button’s own layout. The button would still be visible on screen, but it would become unreachable via remote/keyboard navigation entirely: pressing right from the fourth item finds no further logical grid space to move into (the item’s own layout already reaches the page edge there), so navigation overflows immediately instead of ever landing on the button. Getting the layout/offset split right isn’t just a cosmetic nicety here — it’s the only thing that makes the button reachable at all.

See Content Item Object for layout/offset, and JSON Building Guide → Actions for the general focus-navigation note.


See Also