Native app
Playback, queue, library database, scanning, settings, imports, permissions, haptics, and system UI.
Create offline HTML, CSS, and JavaScript interfaces for oPlayer's Android WebView. Read library data, control playback and queues, and request capability-gated features while the native app protects permissions, confirmations, storage, and the media database.
Contract baseline: external-theme contract 1.0.0, oPlayer 2.7.1 (build 372), source commit 6f51059, audited 2026-08-24. Maintainers validate the machine-readable snapshot at contracts/oplayer-theme-api.json before publication.
Imported themes are served from https://app-theme.local/user_themes/<theme-id>/index.html. That domain is local to the app; oPlayer intercepts it and routes requests to bundled assets, imported theme files, cached artwork, media content proxies, or local JSON endpoints.
Playback, queue, library database, scanning, settings, imports, permissions, haptics, and system UI.
Your index.html, style.css, theme.js, and local assets render the interface.
oplayer-bridge.js exposes window.Bridge for allowed reads, playback controls, events, and fullscreen setup.
Your distributable ZIP must contain these files at the archive root. Do not zip a parent folder.
manifest.json
index.html
style.css
theme.js
You may include local images, fonts, icons, and data files. The importer accepts html, css, js, json, png, jpg, jpeg, svg, gif, woff, woff2, ttf, and otf.
{
"id": "my_theme",
"name": "My Theme",
"title": "My Theme",
"version": "1.0.0",
"author": "Your Name"
}
Theme IDs may contain only letters, numbers, and underscores. Do not use built-in IDs such as ipod, classic, dark, glideform_touch, or glideform_retro.
<script src="https://app-theme.local/assets/core/oplayer-bridge.js"></script>
<script src="theme.js" defer></script>
React, Vite, and other bundlers are fine for authoring themes, but the installed ZIP must still be static files at the theme root. Include oplayer-bridge.js before your bundle, and call the bridge as window.Bridge from module code.
The installed theme must run fully offline through app-theme.local. Do not ship CDN React scripts, remote fonts, remote images, or code that assumes a local dev server is running. Bundle framework code, components, CSS, and small required assets into the static package files, and reference larger assets as local files inside the ZIP.
<script src="https://app-theme.local/assets/core/oplayer-bridge.js"></script>
<script type="module" src="theme.js"></script>
src_react/.src/.theme.js and the bundled stylesheet style.css.manifest.json, index.html, style.css, and theme.js at the package root.ReactiveTheme in this repository uses that split: builds/reactiveTheme/src_react/ is the React authoring project, while builds/reactiveTheme/src/ is the importable static theme folder. Install the authoring dependencies once, then package from the repository root:
cd builds/reactiveTheme/src_react
npm ci
cd ../../..
npm run package-theme -- reactiveTheme
The packaging command runs Vite for themes with src_react/, targets Chrome 87 JavaScript, downlevels the built CSS, and creates the importable ZIP.
The Android app supports devices down to API 24 and transpiles bundled themes for Chrome/WebView 58. Imported themes do not pass through that pipeline. This repository currently publishes community themes against Chrome 87, so Chrome 87 compatibility does not guarantee support on every device that can run oPlayer. Authors seeking full device coverage should downlevel and test against Chrome 58.
Modern Tailwind output may use cascade layers and color functions that older WebViews drop. Run the repository's current Chrome 87 compatibility pass after building CSS and before creating the ZIP:
node tools/compat-css.mjs builds/<theme>/src/style.css
The deterministic package-theme command always runs this CSS pass; the React themes also chain it into their standalone npm run build. Keep bundled JavaScript at the explicit Chrome 87 target and avoid newer runtime APIs such as Object.hasOwn, Array.prototype.at, structuredClone, and crypto.randomUUID unless you provide a polyfill.
Imported themes can request narrowly scoped permissions for persistent features. Declare only the capabilities your visible UI actually uses. oPlayer validates every ID, shows one native consent sheet on first activation, and lets users revoke access later from Theme Permissions.
{
"id": "my_theme",
"name": "My Theme",
"version": "1.0.0",
"capabilities": {
"request": [
"library.favorites",
"library.playlists",
"content.lyrics"
],
"rationale": {
"library.favorites": "Favorite songs from the theme UI.",
"library.playlists": "Create and edit playlists from library views.",
"content.lyrics": "Load and refresh lyrics for the current song."
}
}
}
| Capability | What it enables |
|---|---|
library.favorites | Toggle the current favorite state and add or remove multiple favorite songs. |
library.playlists | Create, rename, reorder, import/export, delete, and add or remove one or many playlist songs. |
library.mixes | Save custom mixes and delete custom or smart mixes. |
library.eq-presets | Save, rename, and delete persistent custom EQ presets. |
content.lyrics | Fetch/refetch/search lyrics, import LRC files, save/delete manual lyrics, and clear cached lyrics. |
content.podcasts | Add, refresh, or favorite feeds; manage saved/downloaded/played episodes and podlists; and remove podcasts. |
content.radio | Add, rename, favorite, and remove saved radio stations. |
library.sources | Add, enable, sync, refresh, and remove library sources or include/exclude folders. |
Classic themes render inside the compact display area above oPlayer's native wheel or button controls. Calculate visible rows from the actual list height, support wheel navigation, and avoid assumptions based on desktop-sized previews.
Fullscreen themes must set all fullscreen manifest fields consistently. The manifest is the native source of truth and is read before theme JavaScript runs.
{
"id": "my_fullscreen_theme",
"name": "My Fullscreen Theme",
"version": "1.0.0",
"author": "Your Name",
"deviceModel": "FULLSCREEN",
"layout": "fullscreen",
"fullscreen": true,
"themeDarkMode": true,
"systemBarStyle": "dark"
}
Use themeDarkMode and systemBarStyle for dark fullscreen themes so Android can set readable status/navigation icons before theme JavaScript runs. Include viewport-fit=cover and account for both CSS and native safe-area values:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
.app-shell {
min-height: 100dvh;
padding-top: max(env(safe-area-inset-top, 0px), var(--native-safe-area-top, 0px));
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--native-safe-area-bottom, 0px));
}
Bridge.setSetting() is intentionally a no-op for imported themes in oPlayer 2.7.1. It cannot change the native shell, orientation, dark mode, or system bars. Older built-in examples may contain runtime setting calls because bundled themes use a broader native bridge.Built-in bundled themes can use the full native bridge. Imported themes get an allowlisted facade, and Bridge.call() passes through the same native trust gate. A native method is not available to imported themes merely because it exists.
escapeExternalTheme()Keep all state-changing operations behind clear user actions. Fullscreen themes should expose a visible exit action, and all themes should use library metadata only for the on-screen oPlayer experience.
Named methods are preferred. Direct results are normalized by oplayer-bridge.js: JSON objects and arrays become JavaScript values, numeric strings become numbers, and "true"/"false" become booleans. Parse only after checking whether a result is still a string.
function asArray(value) {
if (Array.isArray(value)) return value;
if (typeof value === "string") {
try { return JSON.parse(value); } catch (e) {}
}
return [];
}
Use Bridge.*Async() methods for library screens and render the first page before loading more:
async function loadSongs(offset) {
var rows = await Bridge.getSongsPaginatedAsync(offset, 100);
renderSongs(Array.isArray(rows) ? rows : []);
}
Searches and several capability mutations complete through global callbacks. Define the callback before starting the operation, and parse payloads whose argument is named jsonStr.
| Call | Completion callback |
|---|---|
searchSongsAsync(query) | onMusicSearchResults(jsonStr) |
toggleFavoriteAsync(id) | onFavoriteToggled(jsonStr) |
createPlaylistAsync(name) | onPlaylistCreated(jsonStr) |
renamePlaylistAsync(id, name) | onPlaylistRenamed(jsonStr) |
addPodcastAsync(url) | onPodcastAdded(jsonStr) |
addRadioStationAsync(url) | onRadioAdded(jsonStr) |
fetchLyricsAsyncRequest(title, artist, requestId) | onLyricsResult(payload) and oplayer:lyrics |
fetchLyricsAsync(title, artist) | Structured result plus legacy onLyricsLoaded(lyricsText, payload) |
Use Bridge.call(method, ...args) only for methods explicitly documented as native-only and externally allowed. It is an escape hatch for wrapper coverage, not for security policy.
This page lists the APIs most theme UIs need. The raw guide remains the complete signature and data-shape reference.
| Read API | Result | Guidance |
|---|---|---|
getSongsPaginatedAsync(offset, limit) |
Promise<song[]> |
Primary song-list API. Paginate large libraries. |
getAlbumsPaginatedAsync(offset, limit, artist)getArtistsPaginatedAsync(offset, limit) |
Promises of arrays | Pass null for all album artists. |
getPlaylistsAsync()getPlaylistSongsAsync(id) |
Promises of arrays | Reading playlists never requires a capability. |
getFolderListingAsync(path) |
{ currentPath, items } |
Use "ROOT" at the top level and read rows from items. |
getPodcastsAsync()getPodcastEpisodesAsync(id)getRadioStationsAsync() |
Promises of arrays | Synchronous fallbacks also exist; prefer async for screen loads. |
getPlaybackState()getCurrentTrack() |
Objects | Use onPlaybackUpdate instead of aggressive polling. |
| Purpose | Methods | Availability |
|---|---|---|
| Transport | play(), pause(), togglePlayPause(), next(), previous(), seekTo(ms) | External allowed |
| Play library content | playSong(id), playAlbum(...), playArtistAt(...), playPlaylist(...), playGenre(...), playEpisode(id), playRadio(id) | External allowed |
| Playback modes | toggleShuffle(), isShuffle(), toggleRepeat(), getRepeatMode(), sortSongs(criteria) | External allowed |
| Queue reads and edits | getQueueItems(offset, limit), playQueueIndex(index), moveQueueItem(from, to), removeQueueItem(index) | External allowed |
| Add next / append | addSongIdsNext(ids), addSongIdsToQueue(ids), plus episode and radio equivalents | External allowed |
| Session controls | getVolume(), setVolume(level), setSleepTimer(minutes), getSleepTimerMinutes() | External allowed |
Queue edits are transient and apply to the current media type, so they do not require a capability. Re-read getQueueItems() after a fire-and-forget mutation.
| Capability | Representative methods |
|---|---|
library.favorites | toggleFavoriteAsync(id), addFavoriteSongs(ids), removeFavoriteSongs(ids) |
library.playlists | createPlaylistAsync(name), renamePlaylistAsync(id, name), addSongsToPlaylist(pid, ids), removeSongsFromPlaylist(pid, ids), deletePlaylist(id) |
library.mixes | saveCustomMix({ name: "My Mix" }), deleteCustomMix(id), deleteSmartMix(id) |
library.eq-presets | saveEqCustom(name), renameEqCustom(oldName, newName), deleteEqCustom(name) |
content.lyrics | fetchLyricsAsync(title, artist), refetchLyrics(...), searchLyrics(...), clearLyricsCache() |
content.podcasts | addPodcastAsync(url), refreshPodcast(id), downloadEpisode(id), markEpisodePlayed(id, played), removePodcast(id) |
content.radio | addRadioStationAsync(url), renameRadioStation(id, name), removeRadioStation(id) |
library.sources | setLibrarySourceEnabled(id, enabled), syncLibrarySource(id), requestAddSafFolderSource(), removeLibrarySource(id) |
Reading presets/bands and changing the current EQ session are externally allowed through getEqPresets(), useEqPreset(index), getEqBands(), setEqBand(index, level), setEqPreamp(levelDb), setEqLimiterEnabled(enabled), and resetEqBands(). Only saving, renaming, or deleting persistent custom presets requires library.eq-presets.
| API group | Imported themes | Notes |
|---|---|---|
setSetting() | External no-op | Imported themes cannot change native settings. Use manifest fields for startup layout and system-bar appearance. |
stop() | External no-op | Use pause() for an imported-theme transport control. |
escapeExternalTheme() | External allowed | Visible safety action that returns to oPlayer's built-in fallback theme. |
| Theme management, import, backup/restore, permissions, review/store prompts, native browser helpers | Built-in only | Expect failure or a no-op from imported themes. |
Define hardware handlers as globals even when a handler is intentionally empty. Module/IIFE code must attach them to window.
window.handleScroll = function(delta) {};
window.handleSelect = function() {};
window.handleLongSelect = function() {};
window.handleBack = function() {};
window.handlePlayPause = function() { setTimeout(refreshNowPlaying, 150); };
window.handleNext = function() { setTimeout(refreshNowPlaying, 250); };
window.handlePrevious = function() { setTimeout(refreshNowPlaying, 250); };
window.handleUp = function() {};
window.handleDown = function() {};
window.handleLeft = function() {};
window.handleRight = function() {};
window.onPlaybackUpdate = function(playback, track) {};
window.onDataChanged = function(dataType) {};
window.onBatteryUpdate = function(level, isCharging) {};
window.refreshCurrentScreen = function() {};
window.handleMetadata = function(title, artist, artUrl) {};
onPlaybackUpdate receives parsed objects and is the preferred way to keep Now Playing synchronized. Search, add, favorite, playlist, and lyrics result callbacks receive strings as documented in Bridge Basics and should be parsed defensively.
Render the first 100–200 songs, albums, or artists immediately. Fetch the next page when the user approaches the end instead of waiting for the complete library.
For classic layouts, derive visible rows from the list container height. Keep the focused row above mini-player overlays and scroll it into view without delayed follower animation.
Capability mutations may complete asynchronously. Update obvious UI state immediately, then refresh from callbacks or short delayed reads rather than assuming a synchronous return.
async function readRows(loader, fallback) {
try {
var rows = await loader();
if (Array.isArray(rows)) return rows;
if (typeof rows === "string") return JSON.parse(rows || "[]");
} catch (e) {}
return fallback || [];
}
getAllSongs() during startup, animation, or typing.title/name, missing artwork, boolean-like strings, and song/podcast/radio media types.Use capabilities as a user-facing contract, not merely an API switch. Request the smallest set, explain each rationale, and expose persistent changes only through clear controls. oPlayer owns destructive confirmations; themes must not imitate a successful deletion before native confirmation settles.
| Risk | Guidance |
|---|---|
| Destructive data/file changes | Invoke only from explicit delete/remove actions. Let oPlayer render the native confirmation. |
| Network-backed additions | Podcast feed and radio stream URLs must be public http/https. Rate-limit refresh and clearly show what is being added. |
| Playback, sleep timer, volume, keyboard, haptics | Keep changes tied to direct interaction; never run them from passive render loops. |
| Library metadata | Use it only for the visible theme experience. Do not track, upload, or store personal library data remotely. |
| Large synchronous reads | Prefer Promise-based pagination, debounce search, reuse DOM nodes, and avoid Bridge calls in animation frames. |
The official theme listing is curated. Review reduces risk for users who install from Glideform Studio, while sideloaded ZIPs from other sources remain outside the official review process.
Bridge.call() as a policy bypass.Package from inside the importable source folder so required files stay at the ZIP root:
zip -r my_theme.zip manifest.json index.html style.css theme.js default_art.png icons fonts
unzip -t my_theme.zip
unzip -l my_theme.zip
Use the browser testbench for interaction and layout checks. It loads a theme ZIP, validates classic/fullscreen manifest fields, simulates hardware and native data events, offers granted/denied/revoked capability modes, and can generate a 5,000-song stress library. Its Bridge log warns on built-in-only, unknown, and denied capability calls.
The testbench cannot reproduce real audio or MediaSession behavior, Android system bars and safe areas, native confirmations and permissions, SAF selection, downloads and network failures, haptics, keyboard/IME behavior, native Cover Flow, process restart, or actual WebView performance. This includes destructive confirmation methods such as deletePlaylist, deleteCustomMix, removePodcast, removeRadioStation, and removeLibrarySource; SAF/import/export methods; native Cover Flow; and real episode downloads. Those checks still require an emulator or physical Android device.
chrome://inspect.For themes published from this repository, maintain curated metadata only in data/themes.json, then build, package, hash, and register from the repository root. Never hand-edit index.json, hashes, versions, URLs, or capability arrays:
npm ci
npm run package-theme -- hifiDeckTheme
# Add an explicit date for a published release:
npm run package-theme -- hifiDeckTheme --release-date 2026-08-24
npm run check-repository
The command builds Vite sources when present, applies Chrome 87 compatibility processing, validates root layout and safe paths, excludes non-distributable files, enforces the 20 MiB app limit, and creates a stable ordered ZIP. The complete repository check inspects every published archive, screenshot, internal site link, and documentation contract. CI rebuilds every React-authored theme and supplies the required validation status for the Cloudflare-hosted production site. Declare every build directory in data/build-status.json and commit the theme source, ZIP, canonical catalog, and generated app index together.