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.

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. Zip the contents of the package folder, not the parent folder.

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.

cd builds/reactiveTheme/src_react
npm ci
npm run build
cd ../src
zip -r ../reactiveTheme.zip .

After this, reactiveTheme.zip contains only the offline package files that oPlayer imports.

Old Android WebView compatibility

oPlayer can run on devices whose system WebView is as old as Chrome 87. Modern Tailwind output may use cascade layers and color functions that those WebViews drop. Run the repository compatibility pass after building CSS and before creating the ZIP:

node tools/compat-css.mjs builds/<theme>/src/style.css

The React themes in this repository already chain this command into npm run build. Keep bundled JavaScript at ES2020 compatibility 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, 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, and clear cached lyrics.
content.podcastsAdd or refresh feeds, manage downloads, set played state, and remove podcasts.
content.radioAdd, rename, 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 and should also request the fullscreen shell after the DOM is ready.

{
  "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));
}
function requestFullscreenShell() {
  try {
    var settings = Bridge.getSettings ? Bridge.getSettings() : {};
    if (settings.deviceModel !== "FULLSCREEN") {
      Bridge.setSetting("deviceModel", "FULLSCREEN");
    }
    if (settings.uiOrientationLock !== "PORTRAIT") {
      Bridge.setSetting("uiOrientationLock", "PORTRAIT");
    }
    if (settings.themeDarkMode !== true) {
      Bridge.setSetting("themeDarkMode", "true");
    }
    if (settings.systemBarsEnabled !== true) {
      Bridge.setSetting("systemBarsEnabled", "true");
    }
  } catch (e) {}
}

document.addEventListener("DOMContentLoaded", requestFullscreenShell);

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
  • Fullscreen startup settings 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
  • Broad app settings outside the fullscreen exceptions

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)
fetchLyricsAsync(title, artist)onLyricsLoaded(lyricsText)

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 limitedOnly documented fullscreen startup keys: deviceModel, uiOrientationLock, themeDarkMode, and systemBarsEnabled.
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 shell except documented fullscreen setup.
  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 can load a theme ZIP, switch between retro/fullscreen shells, simulate hardware controls, show Bridge calls, and maintain mock playback state. The mock bridge does not enforce the complete native policy and uses simplified data, so device testing remains required.

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, rebuild the ZIP first and then generate registry metadata instead of hand-editing hashes, versions, URLs, or capability arrays:

npm run sync-registry
npm run check-registry

Commit the theme source, built package, ZIP, data/themes.json, and index.json together.