oPlayer Theme Development

Build custom themes for oPlayer.

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.

Runtime Model

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.

External themes execute JavaScript and can read visible oPlayer library metadata. Persistent mutations require declared capabilities and user consent. Privileged app-management APIs remain blocked, and destructive capability actions remain native-confirmed.

Native app

Playback, queue, library database, scanning, settings, imports, permissions, haptics, and system UI.

WebView theme

Your index.html, style.css, theme.js, and local assets render the interface.

Bridge

oplayer-bridge.js exposes window.Bridge for allowed reads, playback controls, events, and fullscreen setup.

Package Structure

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.

Minimum manifest

{
  "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.

Bridge include

<script src="https://app-theme.local/assets/core/oplayer-bridge.js"></script>
<script src="theme.js" defer></script>

React and bundled JavaScript

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>

React authoring workflow

  1. Author the theme in a source folder such as src_react/.
  2. Configure the bundler to emit static files into the package folder, such as src/.
  3. Name the bundled JavaScript theme.js and the bundled stylesheet style.css.
  4. Keep manifest.json, index.html, style.css, and theme.js at the package root.
  5. Package from the repository root with the deterministic release command.

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.

Old Android WebView compatibility

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.

Capability Requests

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."
    }
  }
}
CapabilityWhat it enables
library.favoritesToggle the current favorite state and add or remove multiple favorite songs.
library.playlistsCreate, rename, reorder, import/export, delete, and add or remove one or many playlist songs.
library.mixesSave custom mixes and delete custom or smart mixes.
library.eq-presetsSave, rename, and delete persistent custom EQ presets.
content.lyricsFetch/refetch/search lyrics, import LRC files, save/delete manual lyrics, and clear cached lyrics.
content.podcastsAdd, refresh, or favorite feeds; manage saved/downloaded/played episodes and podlists; and remove podcasts.
content.radioAdd, rename, favorite, and remove saved radio stations.
library.sourcesAdd, enable, sync, refresh, and remove library sources or include/exclude folders.
Consent and destructive confirmation are different layers. Capability consent is granted when the theme is activated; playlist/mix/podcast/radio/source removal and downloaded-file deletion still show oPlayer's native confirmation at action time.

Layouts and Safe Areas

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.

Bridge Policy

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.

Always allowed

  • Library, metadata, and status reads
  • Playback, queue edits, volume, sleep timer, and native sort
  • Transient EQ controls, input helpers, and haptics
  • Manifest-driven fullscreen startup and dark system-bar contrast
  • escapeExternalTheme()

Capability-gated

  • Favorites, playlists, and mixes
  • Persistent custom EQ presets and lyrics network/cache actions
  • Podcast subscriptions, episode downloads, and played state
  • Saved radio stations and library sources

Built-in only

  • Arbitrary theme switching or deletion
  • Theme import, backup, restore, and permission flows
  • Review/store prompts and native external-browser helpers
  • Native app settings, including runtime fullscreen setting changes

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.

Bridge Basics

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 [];
}

Promise-based reads

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 : []);
}

Callback-based operations

Searches and several capability mutations complete through global callbacks. Define the callback before starting the operation, and parse payloads whose argument is named jsonStr.

CallCompletion 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.

Bridge API Reference

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.

Playback and queue

PurposeMethodsAvailability
Transportplay(), pause(), togglePlayPause(), next(), previous(), seekTo(ms)External allowed
Play library contentplaySong(id), playAlbum(...), playArtistAt(...), playPlaylist(...), playGenre(...), playEpisode(id), playRadio(id)External allowed
Playback modestoggleShuffle(), isShuffle(), toggleRepeat(), getRepeatMode(), sortSongs(criteria)External allowed
Queue reads and editsgetQueueItems(offset, limit), playQueueIndex(index), moveQueueItem(from, to), removeQueueItem(index)External allowed
Add next / appendaddSongIdsNext(ids), addSongIdsToQueue(ids), plus episode and radio equivalentsExternal allowed
Session controlsgetVolume(), 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-gated mutations

CapabilityRepresentative methods
library.favoritestoggleFavoriteAsync(id), addFavoriteSongs(ids), removeFavoriteSongs(ids)
library.playlistscreatePlaylistAsync(name), renamePlaylistAsync(id, name), addSongsToPlaylist(pid, ids), removeSongsFromPlaylist(pid, ids), deletePlaylist(id)
library.mixessaveCustomMix({ name: "My Mix" }), deleteCustomMix(id), deleteSmartMix(id)
library.eq-presetssaveEqCustom(name), renameEqCustom(oldName, newName), deleteEqCustom(name)
content.lyricsfetchLyricsAsync(title, artist), refetchLyrics(...), searchLyrics(...), clearLyricsCache()
content.podcastsaddPodcastAsync(url), refreshPodcast(id), downloadEpisode(id), markEpisodePlayed(id, played), removePodcast(id)
content.radioaddRadioStationAsync(url), renameRadioStation(id, name), removeRadioStation(id)
library.sourcessetLibrarySourceEnabled(id, enabled), syncLibrarySource(id), requestAddSafFolderSource(), removeLibrarySource(id)

Equalizer

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.

Limited and blocked APIs

API groupImported themesNotes
setSetting()External no-opImported themes cannot change native settings. Use manifest fields for startup layout and system-bar appearance.
stop()External no-opUse pause() for an imported-theme transport control.
escapeExternalTheme()External allowedVisible safety action that returns to oPlayer's built-in fallback theme.
Theme management, import, backup/restore, permissions, review/store prompts, native browser helpersBuilt-in onlyExpect failure or a no-op from imported themes.

Incoming Native Events

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() {};
Native play/pause, next, and previous buttons perform playback before calling your handler. Refresh your UI in those handlers; do not call the transport method again or the action will happen twice. Theme-rendered touch buttons should call Bridge transport methods directly.

State and data pushes

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.

Recommended Patterns

Paginate and stream

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.

Window compact lists

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.

Reconcile mutations

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 || [];
}
  • Debounce search input and avoid synchronous getAllSongs() during startup, animation, or typing.
  • Check that newer Bridge methods exist and keep documented synchronous fallbacks where appropriate.
  • Normalize title/name, missing artwork, boolean-like strings, and song/podcast/radio media types.
  • Use song IDs for folder playback when visible sorting can differ from backend order.
  • Clear timers and callback assignments when views unmount or stop being visible.

Security and Performance

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.

RiskGuidance
Destructive data/file changesInvoke only from explicit delete/remove actions. Let oPlayer render the native confirmation.
Network-backed additionsPodcast 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, hapticsKeep changes tied to direct interaction; never run them from passive render loops.
Library metadataUse it only for the visible theme experience. Do not track, upload, or store personal library data remotely.
Large synchronous readsPrefer Promise-based pagination, debounce search, reuse DOM nodes, and avoid Bridge calls in animation frames.

Glideform Studio Store Review

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.

  1. The ZIP imports cleanly and includes all required root files.
  2. Manifest fields are accurate and fullscreen fields are consistent.
  3. Every persistent mutation has the matching declared capability and a clear user-facing control.
  4. No imported-theme code calls built-in-only Bridge APIs or treats Bridge.call() as a policy bypass.
  5. No hidden tracking, surprise navigation, deceptive links, or off-purpose metadata use.
  6. No startup code changes playback, volume, sleep timer, haptics, keyboard state, or native settings. Fullscreen startup state comes from the manifest.
  7. Destructive actions rely on oPlayer's native confirmation and reconcile correctly after completion or cancellation.
  8. Large lists are paginated, search is debounced, and the UI stays responsive on large libraries.
  9. Hardware handlers exist and do not duplicate native play/pause/next/previous actions.
  10. Classic themes are tested in the retro shell; fullscreen themes are tested on fresh import, restart, and exit to the built-in fallback.

Testing and Submission

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.

Install test

  1. Copy the ZIP to your Android device.
  2. Open oPlayer Settings, then Themes.
  3. Enable external visual themes and import the ZIP.
  4. Apply the theme and inspect it through Chrome at chrome://inspect.

Repository releases

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.