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.
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.
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.
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.
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, 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, and clear cached lyrics. |
content.podcasts | Add or refresh feeds, manage downloads, set played state, and remove podcasts. |
content.radio | Add, rename, 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 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);
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) |
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.
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 limited | Only documented fullscreen startup keys: deviceModel, uiOrientationLock, themeDarkMode, and systemBarsEnabled. |
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 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.
chrome://inspect.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.