# oPlayer Theme Development Guide

This guide explains how to build, test, package, and submit custom visual themes for oPlayer.

External themes are ordinary HTML, CSS, and JavaScript files rendered inside oPlayer's Android WebView. The native app owns playback, scanning, storage, permissions, system controls, and the media database. Your theme owns the user interface and talks to the native app through the injected `Bridge` object.

> [!WARNING]
> External themes execute JavaScript and can read the user's oPlayer library metadata through the Bridge. Imported themes run with a restricted Bridge policy: normal rendering, library reads, playback controls, input helpers, and fullscreen shell requests are allowed, while app-management and destructive library actions are blocked. Build themes as if users are trusting your code with their oPlayer experience and visible library metadata.

## Contents

- [1. Runtime Model](#1-runtime-model)
- [2. Theme Package Structure](#2-theme-package-structure)
- [3. Layout Modes](#3-layout-modes)
- [4. Bridge Basics](#4-bridge-basics)
- [5. Bridge API Reference](#5-bridge-api-reference)
- [6. Incoming Native Events](#6-incoming-native-events)
- [7. Data Shapes](#7-data-shapes)
- [8. Recommended Patterns](#8-recommended-patterns)
- [9. Starter Theme](#9-starter-theme)
- [10. Packaging and Testing](#10-packaging-and-testing)
- [11. Security and Performance](#11-security-and-performance)

---

## 1. Runtime Model

An imported theme is loaded from:

```text
https://app-theme.local/user_themes/<theme-id>/index.html
```

Built-in app assets are loaded from:

```text
https://app-theme.local/assets/
```

The `app-theme.local` domain is not a public website. It is intercepted by oPlayer's WebView and routed to either bundled app assets, imported user theme files, album-art cache files, content URI proxies, or local JSON API endpoints.

The important pieces are:

| Layer | Responsibility |
| :--- | :--- |
| Android/Kotlin | Playback, queue, library database, MediaStore scan, podcasts, radio, settings, file permissions, haptics, system UI, theme import. |
| WebView | Renders your `index.html`, `style.css`, and `theme.js`. |
| `oplayer-bridge.js` | Public JavaScript wrapper that exposes `window.Bridge`. |
| Virtual local API | High-volume JSON reads such as songs, albums, artists, playlists, podcasts, radio, and folders. These are called by Promise-returning `Bridge.*Async()` methods. |
| Native callbacks | Native pushes events and selected async results into global functions such as `onPlaybackUpdate`, `onMusicSearchResults`, and `onLyricsLoaded`. |

### Include The Bridge

Every theme should include the core bridge before `theme.js`:

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

Because `theme.js` is loaded with `defer`, it can safely define global handlers before `DOMContentLoaded`, and it can query DOM elements after `DOMContentLoaded`.

### Bundled JavaScript and React Builds

Themes may be authored with tools such as React and Vite as long as the shipped ZIP is still a static WebView theme with root `index.html`, `manifest.json`, `style.css`, and `theme.js`.

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 any larger assets as local files inside the ZIP.

For bundled or module-based JavaScript, the bridge is available on `window.Bridge` after `oplayer-bridge.js` loads:

```html
<script src="https://app-theme.local/assets/core/oplayer-bridge.js"></script>
<script type="module" src="theme.js"></script>
```

Inside React, Vite, or other module code, read the bridge from `window` when you call it:

```javascript
function getBridge() {
  return window.Bridge || null;
}

function playFirstSong(songs) {
  var bridge = getBridge();
  if (!bridge || !songs.length) return;
  bridge.playSong(songs[0].id);
}
```

Do not rely on a bare `Bridge` variable inside ES modules. Use `window.Bridge` so the code works consistently after bundling and minification.

Recommended React/Vite workflow:

1. Author the theme in a source folder such as `src_react/`.
2. Configure the bundler to emit static files into the theme 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. Include `oplayer-bridge.js` before your bundle in `index.html`.
6. Rebuild the ZIP from the contents of the package folder, not from 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. A typical rebuild is:

```bash
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.

Example Vite output settings:

```javascript
// vite.config.js
export default {
  base: "./",
  build: {
    outDir: "../src",
    emptyOutDir: true,
    cssCodeSplit: false,
    rollupOptions: {
      output: {
        entryFileNames: "theme.js",
        assetFileNames: function(assetInfo) {
          return assetInfo.name && assetInfo.name.endsWith(".css") ? "style.css" : "assets/[name][extname]";
        }
      }
    }
  }
};
```

---

## 2. Theme Package Structure

Your distributable ZIP must contain these files at the ZIP root:

```text
manifest.json
index.html
style.css
theme.js
```

You may include additional local assets such as images, fonts, or data files:

```text
manifest.json
index.html
style.css
theme.js
default_art.png
icons/
fonts/
```

Reference local assets with relative URLs:

```html
<img src="default_art.png" alt="">
```

Do not put the files inside an extra top-level folder in the ZIP. oPlayer expects `manifest.json` at the root of the archive.

The importer rejects packages that are missing any required root file, exceed the app size limit, use unsupported file extensions, use a built-in theme ID, or contain paths that escape the ZIP root. Allowed asset extensions are `html`, `css`, `js`, `json`, `png`, `jpg`, `jpeg`, `svg`, `gif`, `woff`, `woff2`, `ttf`, and `otf`.

### Manifest

Minimum manifest:

```json
{
  "id": "my_theme",
  "name": "My Theme",
  "title": "My Theme",
  "version": "1.0.0",
  "author": "Your Name"
}
```

Recommended manifest:

```json
{
  "id": "my_theme",
  "name": "My Theme",
  "title": "My Theme",
  "version": "1.0.0",
  "author": "Your Name",
  "description": "A concise sentence describing the theme."
}
```

Use a stable `id`. Changing the ID makes oPlayer treat the package as a different theme.

Theme IDs may contain only letters, numbers, and underscores. Do not use a built-in ID such as `ipod`, `ipod_dark`, `classic`, `dark`, `vinyl`, `midnight`, `aurora`, `truedark`, `glideform_touch`, or `glideform_retro`.

### Fullscreen Manifest

For a fullscreen theme:

```json
{
  "id": "my_fullscreen_theme",
  "name": "My Fullscreen Theme",
  "title": "My Fullscreen Theme",
  "version": "1.0.0",
  "author": "Your Name",
  "description": "A fullscreen touch-first oPlayer theme.",
  "deviceModel": "FULLSCREEN",
  "layout": "fullscreen",
  "fullscreen": true,
  "themeDarkMode": true,
  "systemBarStyle": "dark"
}
```

The manifest tells oPlayer the intended shell before the WebView code runs. Fullscreen manifest fields must be consistent: set `deviceModel` to `"FULLSCREEN"`, `layout` to `"fullscreen"`, and `fullscreen` to `true`. Dark fullscreen themes should also set `themeDarkMode` to `true` and `systemBarStyle` to `"dark"` so Android status/navigation icons are legible immediately during theme switching. Fullscreen themes should still request the shell at runtime as a backup. See [Fullscreen Startup](#fullscreen-startup).

---

## 3. Layout Modes

oPlayer supports classic framed layouts and fullscreen layouts.

### Classic Layout

Classic themes render inside the upper display area of an iPod-style native shell. The bottom portion of the app contains native wheel or button controls.

Design expectations:

- Treat the WebView as a compact, nearly square viewport.
- Keep navigation efficient with the scroll wheel and center/menu buttons.
- Do not hardcode the visible list count. Calculate it from the list container height.
- Keep touch controls optional unless your theme is intentionally hybrid.

### Fullscreen Layout

Fullscreen themes occupy the full screen and usually provide their own touch navigation and transport controls.

Design expectations:

- Use `100dvh`/`100vh` and responsive layout.
- Include safe-area padding for notches and system bars.
- Provide touch controls for primary navigation and transport.
- Still define hardware handlers. Android may continue to dispatch wheel/button events depending on settings and device shell state.

### Safe Areas

Use `viewport-fit=cover`:

```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
```

Use CSS safe-area variables:

```css
#app {
  min-height: 100dvh;
  padding-top: env(safe-area-inset-top, 0px);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
```

### Fullscreen Startup

Fullscreen themes should request the native fullscreen shell after the DOM is ready:

```javascript
function requestFullscreenShell() {
  try {
    var settings = Bridge.getSettings ? Bridge.getSettings() : {};

    if (settings && settings.deviceModel !== "FULLSCREEN" && Bridge.setSetting) {
      Bridge.setSetting("deviceModel", "FULLSCREEN");
    }

    if (settings && settings.uiOrientationLock !== "PORTRAIT" && Bridge.setSetting) {
      Bridge.setSetting("uiOrientationLock", "PORTRAIT");
    }

    if (settings && settings.themeDarkMode !== true && Bridge.setSetting) {
      Bridge.setSetting("themeDarkMode", "true");
    }

    if (settings && settings.systemBarsEnabled !== true && Bridge.setSetting) {
      Bridge.setSetting("systemBarsEnabled", "true");
    }
  } catch (e) {}
}

document.addEventListener("DOMContentLoaded", requestFullscreenShell);
```

Avoid caching `window.Bridge` at script parse time for startup logic. Resolve the bridge when you call it:

```javascript
function getBridge() {
  if (window.Bridge) return window.Bridge;
  if (typeof Bridge !== "undefined") return Bridge;
  return null;
}
```

---

## 4. Bridge Basics

`Bridge` is the public JavaScript API exposed by:

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

Most theme code should call named methods such as:

```javascript
var state = Bridge.getPlaybackState();
var songs = await Bridge.getSongsPaginatedAsync(0, 100);
Bridge.playSong(songs[0].id);
```

### Return Value Parsing

The bridge parses direct native return values for you:

| Native return string | JavaScript result |
| :--- | :--- |
| `"true"` / `"false"` | `true` / `false` |
| `"42"` / `"3.14"` | `42` / `3.14` |
| `"{...}"` | object |
| `"[...]"` | array |
| plain text | string |

Do not `JSON.parse()` direct `Bridge.*` results unless you first check that the value is a string:

```javascript
function asArray(value) {
  if (Array.isArray(value)) return value;
  if (typeof value === "string") {
    try { return JSON.parse(value); } catch (e) {}
  }
  return [];
}
```

### Promise-Based Data Reads

Most `Bridge.*Async()` data-read methods are Promise-based:

```javascript
async function loadAlbums() {
  var albums = await Bridge.getAlbumsPaginatedAsync(0, 200, null);
  renderAlbums(Array.isArray(albums) ? albums : []);
}
```

These methods use the virtual local API when available and fall back to synchronous bridge calls if needed.

### Callback-Based Native Results

Some methods do not return data directly. They trigger a native operation that later calls a global callback function. Callback arguments are usually raw strings, so parse JSON callback payloads yourself.

Common callback-based methods:

| Method | Callback |
| :--- | :--- |
| `Bridge.searchSongsAsync(query)` | `window.onMusicSearchResults(jsonStr)` |
| `Bridge.searchRadioAsync(query)` | `window.onRadioSearchResults(jsonStr)` |
| `Bridge.searchPodcastAsync(query)` | `window.onPodcastSearchResults(jsonStr)` |
| `Bridge.addPodcastAsync(url)` | `window.onPodcastAdded(jsonStr)` |
| `Bridge.addRadioStationAsync(url)` | `window.onRadioAdded(jsonStr)` |
| `Bridge.toggleFavoriteAsync(id)` | `window.onFavoriteToggled(jsonStr)` |
| `Bridge.createPlaylistAsync(name)` | `window.onPlaylistCreated(jsonStr)` |
| `Bridge.renamePlaylistAsync(id, name)` | `window.onPlaylistRenamed(jsonStr)` |
| `Bridge.fetchLyricsAsync(title, artist)` | `window.onLyricsLoaded(lyricsText)` |
| `Bridge.refetchLyrics(title, artist)` | `window.onLyricsLoaded(lyricsText)` |

Example:

```javascript
function searchMusic(query) {
  var q = (query || "").trim();
  if (!q) {
    renderSongs([]);
    return;
  }

  window.onMusicSearchResults = function(jsonStr) {
    var rows = [];
    try { rows = JSON.parse(jsonStr || "[]"); } catch (e) {}
    renderSongs(rows);
  };

  if (Bridge.searchSongsAsync) {
    Bridge.searchSongsAsync(q);
  } else {
    renderSongs(Bridge.searchSongs(q) || []);
  }
}
```

### `Bridge.call()` Escape Hatch

The `Bridge` wrapper does not expose every native method as a named method. When a native method exists but has no wrapper, you can use:

```javascript
var text = Bridge.call("formatDuration", 94000);
```

Prefer named wrapper methods when available. Use `Bridge.call()` only when the guide explicitly marks a method as native-only and allowed for imported themes. `Bridge.call()` is not a way around native policy.

---

## 5. Bridge API Reference

The following tables describe the public `Bridge` wrapper. Availability labels:

| Label | Meaning |
| :--- | :--- |
| External allowed | Available to imported themes. Use normal UX care. |
| External limited | Available to imported themes only for the documented narrow case. |
| Built-in only | Blocked for imported themes. Only bundled app themes should use it. |

Current external-theme bridge allowlist includes library reads, metadata/status reads, normal playback controls, volume/haptics/input helpers, `Bridge.setSetting("deviceModel", "FULLSCREEN")`, `Bridge.setSetting("uiOrientationLock", "PORTRAIT")`, `Bridge.setSetting("themeDarkMode", "true")`, `Bridge.setSetting("systemBarsEnabled", "true")` for dark fullscreen system-bar visibility and contrast, and `Bridge.escapeExternalTheme()`. App-management, data mutation, permission/import flows, external-browser helpers, review/store prompts, backup/restore, EQ mutation, podcast/radio additions or removals, playlist/favorite mutation, folder include/exclude, library refresh, lyrics fetch/refetch, and theme switching/deletion are built-in only.

### System and Settings

| Method | Availability | Returns | Notes |
| :--- | :--- | :--- | :--- |
| `Bridge.getAppVersion()` | External allowed | string | Installed app version. |
| `Bridge.getSettings()` | External allowed | object | All app settings. |
| `Bridge.getSetting(key)` | External allowed | string/boolean/number | One setting value. |
| `Bridge.setSetting(key, value)` | External limited | void/null | Imported themes may set `deviceModel`, `uiOrientationLock`, `themeDarkMode`, and `systemBarsEnabled` for fullscreen startup/system-bar visibility and contrast. |
| `Bridge.resetSettings()` | Built-in only | void/null | Resets app settings. |
| `Bridge.getDeviceModels()` | External allowed | array | Available native shell/device models. |
| `Bridge.getFrameColors()` | External allowed | array | Available frame colors. |
| `Bridge.getSongCount()` | External allowed | number | Total song count. |
| `Bridge.getBatteryLevel()` | External allowed | number | Battery percentage. |
| `Bridge.isCharging()` | External allowed | boolean | Charging state. |
| `Bridge.triggerClick()` | External allowed | void/null | Native click feedback. |
| `Bridge.triggerHaptic(style)` | External allowed | void/null | Common styles include `"tick"` and `"heavy"`. |
| `Bridge.setInputMode(enabled)` | External allowed | void/null | Current wrapper passes a boolean to native. Use `true` for visible input/search flows, `false` to restore normal mode. |
| `Bridge.requestLibraryBackup()` | Built-in only | void/null | Opens native backup flow. |
| `Bridge.requestLibraryRestore()` | Built-in only | void/null | Opens native restore flow. |
| `Bridge.requestAppReview()` | Built-in only | void/null | Native review prompt. |
| `Bridge.openPlayStoreListing()` | Built-in only | void/null | Opens app listing. |
| `Bridge.contactDeveloper()` | Built-in only | void/null | Opens email intent. |
| `Bridge.showToast(message)` | Built-in only | void/null | Native toast helper is not exposed to imported themes. |
| `Bridge.openBrowser(url)` | Built-in only | void/null | External browser helper is not exposed to imported themes. User-clicked normal links may open through the system browser. |

Native-only methods available through `Bridge.call()`:

| Native method | Availability | Example |
| :--- | :--- | :--- |
| `getDeviceModel` | External allowed | `Bridge.call("getDeviceModel")` |
| `getTime` | External allowed | `Bridge.call("getTime")` |
| `getTime12` | External allowed | `Bridge.call("getTime12")` |
| `getDate` | External allowed | `Bridge.call("getDate")` |
| `getArtistCount` | External allowed | `Bridge.call("getArtistCount")` |
| `getAlbumCount` | External allowed | `Bridge.call("getAlbumCount")` |
| `getGenreCount` | External allowed | `Bridge.call("getGenreCount")` |
| `getVideoCount` | External allowed | `Bridge.call("getVideoCount")` |
| `triggerScrollFeedback` | External allowed | `Bridge.call("triggerScrollFeedback")` |
| `formatDuration` | External allowed | `Bridge.call("formatDuration", ms)` |
| `log` | External allowed | `Bridge.call("log", "message")` |
| `showNativeSplash` / `hideNativeSplash` | Built-in only | `Bridge.call("hideNativeSplash")` |
| `requestPermissionsAndSync` | Built-in only | `Bridge.call("requestPermissionsAndSync")` |

### Settings Keys

These keys are commonly useful to themes:

| Key | Values |
| :--- | :--- |
| `deviceModel` | `"RETRO_FRAME"`, `"FRAMELESS"`, `"FULLSCREEN"` |
| `uiOrientationLock` | `"AUTO"`, `"PORTRAIT"`, `"LANDSCAPE_LEFT"`, `"LANDSCAPE_RIGHT"`, `"REVERSE_PORTRAIT"` |
| `frameColor` / `wheelColor` | App-defined color IDs. |
| `wheelTextureEnabled` / `frameTextureEnabled` | boolean-like value. |
| `systemBarsEnabled` | boolean-like value. |
| `textScalingEnabled` | boolean-like value. |
| `touchControlsEnabled` | boolean-like value. |
| `crossFadeEnabled` | boolean-like value. |
| `resumeOnStartEnabled` | boolean-like value. |
| `currentTheme` | Active theme ID. |

Settings may be returned as booleans, strings, or numbers depending on the native value and bridge parsing. Compare defensively:

```javascript
function isOn(value) {
  return value === true || value === "true" || value === 1 || value === "1";
}
```

### Volume

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.getVolume()` | number | Current media volume, 0-100. |
| `Bridge.setVolume(level)` | void/null | Absolute volume, 0-100. |
| `Bridge.adjustVolume(delta)` | void/null | Relative change, e.g. `5` or `-5`. |

Android hardware volume buttons are handled by the app. Themes generally display volume changes rather than mapping wheel input to volume.

The native app may push volume changes to:

```javascript
window.Core.onVolumeChange(percent)
```

If you do not use a `Core` object, polling `Bridge.getVolume()` while the theme is visible is acceptable.

### Music Data

Promise-based methods:

| Method | Resolves to | Notes |
| :--- | :--- | :--- |
| `Bridge.getSongsPaginatedAsync(offset, limit)` | song[] | Use pagination for large libraries. |
| `Bridge.getAlbumsPaginatedAsync(offset, limit, artist)` | album[] | Pass `null` for all artists. |
| `Bridge.getArtistsPaginatedAsync(offset, limit)` | artist[] | Artist rows usually include `name`. |
| `Bridge.getGenresAsync()` | genre[] | Genre rows usually include `name`. |
| `Bridge.getYearsAsync()` | year[] | Year rows vary by app version. |
| `Bridge.getPlaylistsAsync()` | playlist[] | User playlists. |
| `Bridge.getPlaylistSongsAsync(id)` | song[] | Songs in playlist. |
| `Bridge.getRecentSongsAsync()` | song[] | Recently played. |
| `Bridge.getMostPlayedAsync()` | song[] | Most played. |
| `Bridge.getFavoritesAsync()` | song[] | Favorite songs. |
| `Bridge.getFolderListingAsync(path)` | folder item[] | Use `"ROOT"` for root folder listing. |
| `Bridge.getSongsByArtistAsync(artist)` | song[] | Songs by artist. |
| `Bridge.getSongsByAlbumAsync(albumOrId)` | song[] | Accepts album object, ID, title, or name depending on source. |
| `Bridge.getSongsByGenreAsync(genre)` | song[] | Songs in genre. |
| `Bridge.getExcludedFoldersAsync()` | folder[] | Excluded folders. |

Synchronous fallback methods:

| Method | Returns |
| :--- | :--- |
| `Bridge.getAllSongs()` | song[] |
| `Bridge.getSongsPaginated(offset, limit)` | song[] |
| `Bridge.getAlbumsPaginated(offset, limit, artist)` | album[] |
| `Bridge.getArtistsPaginated(offset, limit)` | artist[] |
| `Bridge.getArtists()` | artist[] |
| `Bridge.getAlbums()` | album[] |
| `Bridge.getGenres()` | genre[] |
| `Bridge.getYears()` | year[] |
| `Bridge.getPlaylists()` | playlist[] |
| `Bridge.getPlaylistSongs(id)` | song[] |
| `Bridge.getRecentSongs()` | song[] |
| `Bridge.getMostPlayed()` | song[] |
| `Bridge.getFavorites()` | song[] |
| `Bridge.getFolderListing(path)` | folder item[] |
| `Bridge.getSongsByArtist(artist)` | song[] |
| `Bridge.getAlbumsByArtist(artist)` | album[] |
| `Bridge.getSongsByAlbum(albumOrId)` | song[] |
| `Bridge.getSongsByGenre(genre)` | song[] |
| `Bridge.getSongsByYear(year)` | song[] |
| `Bridge.getCoverFlowData()` | array |

Avoid synchronous reads for large lists during animation, scrolling, or text input.

### Search

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.searchSongsAsync(query)` | void/null | Calls `onMusicSearchResults(jsonStr)`. |
| `Bridge.searchSongs(query)` | song[] | Synchronous fallback, capped by native implementation. |
| `Bridge.call("searchAll", query)` | object | Native-only global search fallback. Avoid during live text input on large libraries. |
| `Bridge.searchRadioAsync(query)` | void/null | Calls `onRadioSearchResults(jsonStr)`. |
| `Bridge.searchPodcastAsync(query)` | void/null | Calls `onPodcastSearchResults(jsonStr)`. |
| `Bridge.searchRadioOnline(query)` | native result/callback/null | Compatibility wrapper. Prefer `Bridge.searchRadioAsync(query)` for current themes. |

Expose music search as a normal navigable menu item unless the theme has a very clear search affordance.

### Playback Controls

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.playSong(id)` | void/null | Play one song by ID. |
| `Bridge.playAlbum(albumOrId, index, shuffle)` | void/null | Accepts album object or ID/title. |
| `Bridge.playArtist(artist, index, shuffle)` | void/null | With numeric `index > 0`, wrapper routes to `playArtistAt`. |
| `Bridge.playArtistAt(artist, index)` | void/null | Start artist discography at index. |
| `Bridge.playPlaylist(id, index, shuffle)` | void/null | Start playlist at index. |
| `Bridge.playGenre(genre, index)` | void/null | Start genre at index. |
| `Bridge.playYear(year, index)` | void/null | Start year at index. |
| `Bridge.playRecent(index)` | void/null | Start recent songs at index. |
| `Bridge.playFavorites(index)` | void/null | Start favorites at index. |
| `Bridge.playMostPlayed(index)` | void/null | Start most-played list at index. |
| `Bridge.playFolder(path, index)` | void/null | Start folder listing at index. |
| `Bridge.playEpisode(id)` | void/null | Play podcast episode. |
| `Bridge.playRadio(id)` | void/null | Play radio station. |
| `Bridge.playVideo(id)` | void/null | Open native video playback. |
| `Bridge.play()` | void/null | Explicit play. |
| `Bridge.pause()` | void/null | Explicit pause. |
| `Bridge.togglePlayPause()` | void/null | Toggle playback. |
| `Bridge.next()` | void/null | Skip forward. |
| `Bridge.previous()` | void/null | Skip backward. |
| `Bridge.seekTo(ms)` | void/null | Seek absolute position. |
| `Bridge.stop()` | void/null | Stop playback service. |
| `Bridge.shuffleAll()` | void/null | Start shuffled library playback. |
| `Bridge.toggleShuffle()` | boolean/null | Toggle shuffle. |
| `Bridge.setShuffle(enabled)` | void/null | Set shuffle state. |
| `Bridge.isShuffle()` | boolean | Current shuffle state. |
| `Bridge.toggleRepeat()` | string/null | Cycle repeat mode. |
| `Bridge.setRepeatMode(mode)` | void/null | `"OFF"`, `"ALL"`, or `"ONE"`. |
| `Bridge.getRepeatMode()` | string | `"OFF"`, `"ALL"`, or `"ONE"`. |
| `Bridge.sortSongs(criteria)` | void/null | Native sorting pipeline. |

Important: do not call `Bridge.togglePlayPause()`, `Bridge.next()`, or `Bridge.previous()` inside the hardware handlers `handlePlayPause`, `handleNext`, or `handlePrevious`. Native already performed those actions before invoking your handler. See [Native-First Playback Buttons](#native-first-playback-buttons).

### Playback State

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.getPlaybackState()` | playback object | Synchronous current state. Prefer `onPlaybackUpdate` when possible. |
| `Bridge.getCurrentTrack()` | track object/null | Synchronous current track. Prefer `onPlaybackUpdate` when possible. |
| `Bridge.getQueueInfo()` | object | `{ position, length }`. |

Native-only state helpers:

| Native method | Example |
| :--- | :--- |
| `isPlaying` | `Bridge.call("isPlaying")` |
| `isBuffering` | `Bridge.call("isBuffering")` |
| `getPosition` | `Bridge.call("getPosition")` |
| `getDuration` | `Bridge.call("getDuration")` |

### Podcasts and Radio

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.getPodcastsAsync()` | `Promise<podcast[]>` | Subscribed podcasts. |
| `Bridge.getPodcastEpisodesAsync(id)` | `Promise<episode[]>` | Episodes for a podcast. |
| `Bridge.getPodcasts()` | podcast[] | Synchronous fallback. |
| `Bridge.getPodcastEpisodes(id)` | episode[] | Synchronous fallback. |
| `Bridge.getRecentEpisodes()` | episode[] | Synchronous recent episodes. |
| `Bridge.addPodcastAsync(url)` | void/null | Calls `onPodcastAdded(jsonStr)`. |
| `Bridge.refreshPodcast(id)` | void/null | Refresh one feed. |
| `Bridge.removePodcast(id)` | void/null | Delete subscription. |
| `Bridge.downloadEpisode(id)` | void/null | Start download. |
| `Bridge.deleteEpisodeDownload(id)` | void/null | Remove downloaded episode file. |
| `Bridge.markEpisodePlayed(id, played)` | void/null | Set played flag. |
| `Bridge.getRadioStationsAsync()` | `Promise<station[]>` | Saved stations. |
| `Bridge.getRadioStations()` | station[] | Synchronous fallback. |
| `Bridge.addRadioStationAsync(url)` | void/null | Calls `onRadioAdded(jsonStr)`. |
| `Bridge.removeRadioStation(id)` | void/null | Delete station. |
| `Bridge.renameRadioStation(id, name)` | void/null | Rename station. |

Native-only podcast/radio helpers:

| Native method | Example |
| :--- | :--- |
| `getResumeEpisodeForPodcast` | `Bridge.call("getResumeEpisodeForPodcast", podcastId)` |
| `getPodcastArt` | `Bridge.call("getPodcastArt", title)` |
| `refreshPodcasts` | `Bridge.call("refreshPodcasts")` |
| `addRadioStation` | `Bridge.call("addRadioStation", name, url, genre)` |

### Videos

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.getVideosAsync()` | `Promise<video[]>` | Video library. |
| `Bridge.refreshVideoLibrary()` | void/null | Trigger video scan. |
| `Bridge.requestVideoPermissions()` | void/null | Request OS permissions. |
| `Bridge.playVideo(id)` | void/null | Invoke native video player. |

### Favorites and Playlists

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.toggleFavoriteAsync(id)` | void/null | Calls `onFavoriteToggled(jsonStr)`. |
| `Bridge.isFavorite(id)` | boolean | Current favorite state. |
| `Bridge.addCurrentToFavorites()` | boolean/null | Favorite current track. |
| `Bridge.addToPlaylist(playlistId, songId)` | void/null | Add song to playlist. |
| `Bridge.removeSongFromPlaylist(playlistId, songId)` | void/null | Remove song from playlist. |
| `Bridge.createPlaylistAsync(name)` | void/null | Calls `onPlaylistCreated(jsonStr)`. |
| `Bridge.renamePlaylist(id, name)` | void/null | Synchronous rename wrapper. |
| `Bridge.renamePlaylistAsync(id, name)` | void/null | Calls `onPlaylistRenamed(jsonStr)`. |
| `Bridge.deletePlaylist(id)` | void/null | Delete playlist. |

### Lyrics

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.fetchLyricsAsync(title, artist)` | void/null | Built-in only. Calls `onLyricsLoaded(lyricsText)`. |
| `Bridge.refetchLyrics(title, artist)` | void/null | Built-in only. Cache-busting lyrics fetch, calls `onLyricsLoaded`. |
| `Bridge.getLyrics(title, artist)` | string | External allowed. Synchronous fetch is intentionally blocked and usually returns `"Loading..."`. Use async. |

Native-only lyrics helpers:

| Native method | Example |
| :--- | :--- |
| `searchLyrics` | `Bridge.call("searchLyrics", title, artist)` |
| `clearLyricsCache` | `Bridge.call("clearLyricsCache")` |

### Themes and Content

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.getAvailableThemes()` | array | External allowed. Installed theme IDs/names. |
| `Bridge.setTheme(id)` | void/null | Built-in only. Switch active theme. |
| `Bridge.escapeExternalTheme()` | object/null | External allowed. Leave an imported external theme and switch to oPlayer's built-in fallback theme. |
| `Bridge.deleteTheme(id)` | object/null | Built-in only. Delete imported theme. |
| `Bridge.refreshLibrary()` | void/null | Built-in only. Wrapper for native music library refresh. |
| `Bridge.requestImportTheme()` | void/null | Built-in only. Open ZIP picker. |
| `Bridge.excludeFolder(path)` | void/null | Built-in only. Exclude folder from library. |
| `Bridge.includeFolder(path)` | void/null | Built-in only. Re-include folder. |
| `Bridge.getExcludedFoldersAsync()` | `Promise<folder[]>` | External allowed. Excluded folder list. |

Native-only theme helper:

```javascript
var info = Bridge.call("getThemeInfo");
```

### Equalizer

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.getEqPresets()` | array | External allowed. Synchronous presets. |
| `Bridge.getEqPresetsAsync()` | `Promise<array>` | External allowed. Promise-based presets. |
| `Bridge.useEqPreset(index)` | void/null | Built-in only. Apply preset. |
| `Bridge.getEqBands()` | array | External allowed. Synchronous bands. |
| `Bridge.getEqBandsAsync()` | `Promise<array>` | External allowed. Promise-based bands. |
| `Bridge.setEqBand(index, level)` | void/null | Built-in only. Set one EQ band. |
| `Bridge.resetEqBands()` | void/null | Built-in only. Reset bands. |
| `Bridge.saveEqCustom(name)` | void/null | Built-in only. Save custom preset. |
| `Bridge.applyEqCustom(name)` | void/null | Built-in only. Apply custom preset. |
| `Bridge.getSavedEqCustom()` | array | External allowed. Saved custom presets. |
| `Bridge.deleteEqCustom(name)` | void/null | Built-in only. Delete custom preset. |

### Sleep Timer and Keyboard

| Method | Returns | Notes |
| :--- | :--- | :--- |
| `Bridge.setSleepTimer(minutes)` | void/null | External allowed. Use `0` to cancel. |
| `Bridge.getSleepTimerMinutes()` | number | External allowed. Remaining sleep timer minutes. |
| `Bridge.showKeyboard()` | void/null | External allowed. Request soft keyboard from visible input flows. |
| `Bridge.hideKeyboard()` | void/null | External allowed. Hide soft keyboard. |

---

## 6. Incoming Native Events

Define global functions in `theme.js`. oPlayer calls these from native code.

### Required Hardware Handlers

```javascript
function handleScroll(delta) {}
function handleSelect() {}
function handleLongSelect() {}
function handleBack() {}
function handlePlayPause() {}
function handleNext() {}
function handlePrevious() {}
function handleUp() {}
function handleDown() {}
function handleLeft() {}
function handleRight() {}
```

If a handler does nothing, still define it. Missing handlers can cause native JavaScript evaluation errors.

If you define handlers inside a module, closure, or IIFE, attach them to `window`:

```javascript
window.handleScroll = handleScroll;
window.handleSelect = handleSelect;
```

### Hardware Inputs

| Native input | Function called | Notes |
| :--- | :--- | :--- |
| Scroll wheel clockwise | `handleScroll(1)` | Theme decides meaning. |
| Scroll wheel counter-clockwise | `handleScroll(-1)` | Theme decides meaning. |
| Center button | `handleSelect()` | Theme decides action. |
| Center long press | `handleLongSelect()` | Commonly opens now playing. |
| Menu/back | `handleBack()` | Theme navigation. |
| Previous | `handlePrevious()` | Native already skipped first. |
| Next | `handleNext()` | Native already skipped first. |
| Play/pause | `handlePlayPause()` | Native already toggled first. |
| DPAD/surface up/down/left/right | `handleUp/Down/Left/Right()` | Availability depends on native input mode. |

### Native-First Playback Buttons

For the physical/native playback buttons, oPlayer executes playback first, then calls your handler as a UI notification.

Do this:

```javascript
function handlePlayPause() {
  setTimeout(refreshNowPlaying, 150);
}

function handleNext() {
  setTimeout(refreshNowPlaying, 250);
}

function handlePrevious() {
  setTimeout(refreshNowPlaying, 250);
}
```

Do not do this:

```javascript
function handleNext() {
  Bridge.next(); // Wrong for native hardware event: this skips twice.
}
```

Touch buttons inside your own HTML are different. For theme-rendered touch buttons, you should call playback commands directly:

```javascript
document.getElementById("btn-next").addEventListener("click", function() {
  Bridge.next();
  setTimeout(refreshNowPlaying, 250);
});
```

### Playback and Data Push Events

```javascript
function onPlaybackUpdate(playback, track) {}
function onDataChanged(dataType) {}
function onBatteryUpdate(level, isCharging) {}
function refreshCurrentScreen() {}
function handleMetadata(title, artist, artUrl) {}
```

`onPlaybackUpdate(playback, track)` is the most important push event. It is called with parsed JavaScript objects, not raw JSON strings.

```javascript
function onPlaybackUpdate(playback, track) {
  currentPlayback = playback || currentPlayback;
  currentTrack = track || currentTrack;
  renderNowPlaying();
}
```

### Callback Result Events

Define these only if your theme calls the associated methods:

```javascript
function onMusicSearchResults(jsonStr) {}
function onRadioSearchResults(jsonStr) {}
function onPodcastSearchResults(jsonStr) {}
function onPodcastAdded(jsonStr) {}
function onRadioAdded(jsonStr) {}
function onFavoriteToggled(jsonStr) {}
function onPlaylistCreated(jsonStr) {}
function onPlaylistRenamed(jsonStr) {}
function onLyricsLoaded(lyricsText) {}
function onExcludedFoldersLoaded(jsonStr) {}
function onVideosLoaded(jsonStr) {}
```

Payloads named `jsonStr` are raw strings. Parse them defensively.

### Layout Change Hooks

Some built-in theme flows call these names during display-model changes:

```javascript
function onDisplaySettingChanged(model) {
  document.body.classList.toggle("is-fullscreen", model === "FULLSCREEN");
}

function applyDeviceSkin(model) {
  onDisplaySettingChanged(model);
}
```

They are safe to define even if your theme does not use multiple layouts.

---

## 7. Data Shapes

Native data can evolve. Treat these shapes as common fields, not the only possible fields.

### Song

| Field | Type | Notes |
| :--- | :--- | :--- |
| `type` | string | Usually `"song"` when present. |
| `id` | number | Use this for `Bridge.playSong(id)`. |
| `title` | string | Track title. |
| `artist` | string | Artist name. |
| `album` | string | Album name. |
| `art` | string/null | Web-safe art URL. |
| `duration` | number | Milliseconds. |
| `folderPath` | string | Source folder path. |
| `format` | string | Example: `"MP3"`, `"FLAC"`. |
| `bitrate` | string | Example: `"320 kbps"`. |
| `sampleRate` | string | Example: `"44.1 kHz"`. |
| `trackNumber` | number | `0` if unknown. |
| `discNumber` | number | `0` if unknown. |
| `genre` | string | Genre. |
| `year` | number/string | May be `0` or empty if unknown. |

### Album

| Field | Type | Notes |
| :--- | :--- | :--- |
| `id` | number/string | Prefer this for album playback/listing. |
| `title` / `name` | string | Existing themes handle both. |
| `artist` | string | Album artist. |
| `art` | string/null | Album art URL. |
| `songCount` | number | May be absent. |

### Artist, Genre, Year

Rows are usually simple objects:

```javascript
{ "name": "Queen" }
```

Some values may arrive as strings or numbers depending on endpoint and app version. Normalize before rendering:

```javascript
function displayName(row) {
  if (row == null) return "";
  if (typeof row === "string" || typeof row === "number") return String(row);
  return row.name || row.title || row.artist || "";
}
```

### Playlist

| Field | Type | Notes |
| :--- | :--- | :--- |
| `id` | number | Use with `getPlaylistSongsAsync` and `playPlaylist`. |
| `name` / `title` | string | Existing data may use either. |
| `songCount` | number | May be absent. |

### Podcast

| Field | Type | Notes |
| :--- | :--- | :--- |
| `id` | number/string | Podcast ID. |
| `title` / `name` | string | Podcast title. |
| `author` / `artist` | string | Publisher. |
| `art` | string/null | Artwork URL. |

### Podcast Episode

| Field | Type | Notes |
| :--- | :--- | :--- |
| `id` | number/string | Use with `Bridge.playEpisode(id)`. |
| `title` | string | Episode title. |
| `artist` | string | Podcast title. |
| `description` | string | Episode description. |
| `duration` | number | Milliseconds when known. |
| `podcastId` | number/string | Parent podcast ID. |
| `art` | string/null | Artwork URL. |

### Radio Station

| Field | Type | Notes |
| :--- | :--- | :--- |
| `id` | number/string | Use with `Bridge.playRadio(id)`. |
| `name` / `title` | string | Station name. |
| `genre` | string | Station genre. |
| `art` | string/null | Station logo if available. |

### Playback State

| Field | Type | Notes |
| :--- | :--- | :--- |
| `isPlaying` | boolean | Playing state. |
| `position` | number | Current position in ms. |
| `duration` | number | Total duration in ms. |
| `isBuffering` | boolean | Buffering state. |
| `shuffle` | boolean | Shuffle enabled. |
| `repeat` | string | `"OFF"`, `"ALL"`, or `"ONE"`. |
| `mediaType` | string | Common values: `"MUSIC"`, `"PODCAST"`, `"RADIO"`. |

### Queue Info

```javascript
{
  "position": 0,
  "length": 12
}
```

`position` is zero-based.

### Album Art URLs

Art URLs are web-safe local URLs such as:

```text
https://app-theme.local/cache/<filename>
https://app-theme.local/content/<encoded-path>
```

Use them directly in image tags:

```javascript
function setArt(img, artUrl) {
  if (!artUrl || artUrl === "null") {
    img.removeAttribute("src");
    img.classList.add("missing-art");
    return;
  }
  img.src = artUrl;
  img.classList.remove("missing-art");
}
```

---

## 8. Recommended Patterns

### Normalize Async Rows

```javascript
async function readRows(loader, fallback) {
  try {
    var rows = await loader();
    if (Array.isArray(rows)) return rows;
    if (typeof rows === "string") {
      try { return JSON.parse(rows); } catch (e) {}
    }
  } catch (e) {}
  return fallback || [];
}
```

Usage:

```javascript
var albums = await readRows(function() {
  return Bridge.getAlbumsPaginatedAsync(0, 200, null);
});
```

### Avoid Blocking UI During Large Reads

Good:

```javascript
var songs = await Bridge.getSongsPaginatedAsync(0, 100);
```

Risky:

```javascript
var songs = Bridge.getAllSongs();
```

Use synchronous fallbacks only for small lists, one-time startup values, or fallback paths.

### List Windowing

Classic themes should calculate visible list rows:

```javascript
var ITEM_HEIGHT = 32;

function getVisibleCount() {
  var el = document.getElementById("list");
  return Math.max(1, Math.floor(el.clientHeight / ITEM_HEIGHT));
}

function clampIndex(index, rows) {
  return Math.max(0, Math.min(index, Math.max(0, rows.length - 1)));
}
```

### Shared Display Helpers

```javascript
function titleOf(item) {
  return (item && (item.title || item.name || item.artist)) || "Unknown";
}

function subtitleOf(item) {
  return (item && (item.artist || item.album || item.genre || item.author)) || "";
}

function idOf(item) {
  return item && item.id != null ? item.id : item;
}
```

### Touch Controls vs Hardware Handlers

Call playback commands from touch controls:

```javascript
button.addEventListener("click", function() {
  Bridge.togglePlayPause();
  setTimeout(refreshNowPlaying, 150);
});
```

Refresh only from native hardware playback handlers:

```javascript
function handlePlayPause() {
  setTimeout(refreshNowPlaying, 150);
}
```

### Debug Logging

Use browser console during development:

```javascript
console.log("Loaded rows", rows.length);
```

For Android Logcat, use native-only logging:

```javascript
Bridge.call("log", "Theme loaded");
```

---

## 9. Starter Theme

This is intentionally small but complete. It supports a classic shell, Promise-based song loading, current playback display, and native hardware handlers.

### `manifest.json`

```json
{
  "id": "starter_theme",
  "name": "Starter Theme",
  "title": "Starter Theme",
  "version": "1.0.0",
  "author": "Your Name",
  "description": "A minimal oPlayer external theme."
}
```

### `index.html`

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
  <title>Starter Theme</title>
  <link rel="stylesheet" href="style.css">
  <script src="https://app-theme.local/assets/core/oplayer-bridge.js"></script>
  <script src="theme.js" defer></script>
</head>
<body>
  <div id="app">
    <header>
      <div id="status">oPlayer</div>
      <strong id="title">Starter Theme</strong>
    </header>

    <main>
      <section id="now">
        <img id="art" alt="">
        <div>
          <h1 id="track-title">Not Playing</h1>
          <p id="track-artist"></p>
        </div>
      </section>

      <ul id="list" aria-label="Songs"></ul>
    </main>
  </div>
</body>
</html>
```

### `style.css`

```css
* {
  box-sizing: border-box;
}

html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  overflow: hidden;
  background: #111;
  color: #f5f5f5;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

#app {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  padding: 10px;
  gap: 10px;
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 28px;
  font-size: 12px;
}

#now {
  display: grid;
  grid-template-columns: 56px 1fr;
  gap: 10px;
  align-items: center;
  min-height: 70px;
}

#art {
  width: 56px;
  height: 56px;
  object-fit: cover;
  border-radius: 6px;
  background: #333;
}

#track-title,
#track-artist {
  margin: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#track-title {
  font-size: 16px;
}

#track-artist {
  margin-top: 4px;
  font-size: 12px;
  color: #aaa;
}

#list {
  flex: 1;
  min-height: 0;
  margin: 0;
  padding: 0;
  overflow: hidden;
  list-style: none;
}

#list li {
  height: 32px;
  display: flex;
  align-items: center;
  padding: 0 8px;
  border-radius: 6px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#list li.selected {
  background: #f5f5f5;
  color: #111;
}
```

### `theme.js`

```javascript
var state = {
  songs: [],
  index: 0,
  playback: null,
  track: null
};

document.addEventListener("DOMContentLoaded", function() {
  loadInitialState();
});

async function loadInitialState() {
  refreshNowPlaying();
  await loadSongs();
}

async function loadSongs() {
  try {
    var rows = await Bridge.getSongsPaginatedAsync(0, 100);
    state.songs = Array.isArray(rows) ? rows : [];
  } catch (e) {
    state.songs = [];
  }
  state.index = 0;
  renderList();
}

function refreshNowPlaying() {
  try {
    state.playback = Bridge.getPlaybackState ? Bridge.getPlaybackState() : state.playback;
    state.track = Bridge.getCurrentTrack ? Bridge.getCurrentTrack() : state.track;
  } catch (e) {}
  renderNowPlaying();
}

function renderNowPlaying() {
  var track = state.track || {};
  document.getElementById("track-title").textContent = track.title || "Not Playing";
  document.getElementById("track-artist").textContent = track.artist || "";

  var art = document.getElementById("art");
  if (track.art && track.art !== "null") {
    art.src = track.art;
  } else {
    art.removeAttribute("src");
  }
}

function renderList() {
  var list = document.getElementById("list");
  list.innerHTML = "";

  state.songs.forEach(function(song, i) {
    var li = document.createElement("li");
    li.className = i === state.index ? "selected" : "";
    li.textContent = (song.title || "Untitled") + (song.artist ? " - " + song.artist : "");
    li.addEventListener("click", function() {
      state.index = i;
      Bridge.playSong(song.id);
      setTimeout(refreshNowPlaying, 250);
      renderList();
    });
    list.appendChild(li);
  });
}

function move(delta) {
  if (!state.songs.length) return;
  state.index = Math.max(0, Math.min(state.index + delta, state.songs.length - 1));
  renderList();
}

function handleScroll(delta) {
  move(delta);
}

function handleSelect() {
  var song = state.songs[state.index];
  if (!song) return;
  Bridge.playSong(song.id);
  setTimeout(refreshNowPlaying, 250);
}

function handleLongSelect() {
  refreshNowPlaying();
}

function handleBack() {
  refreshNowPlaying();
}

function handlePlayPause() {
  setTimeout(refreshNowPlaying, 150);
}

function handleNext() {
  setTimeout(refreshNowPlaying, 250);
}

function handlePrevious() {
  setTimeout(refreshNowPlaying, 250);
}

function handleUp() { handleScroll(-1); }
function handleDown() { handleScroll(1); }
function handleLeft() { handleBack(); }
function handleRight() { handleSelect(); }

function onPlaybackUpdate(playback, track) {
  state.playback = playback || state.playback;
  state.track = track || state.track;
  renderNowPlaying();
}

window.handleScroll = handleScroll;
window.handleSelect = handleSelect;
window.handleLongSelect = handleLongSelect;
window.handleBack = handleBack;
window.handlePlayPause = handlePlayPause;
window.handleNext = handleNext;
window.handlePrevious = handlePrevious;
window.handleUp = handleUp;
window.handleDown = handleDown;
window.handleLeft = handleLeft;
window.handleRight = handleRight;
window.onPlaybackUpdate = onPlaybackUpdate;
```

---

## 10. Packaging and Testing

### Package A Theme

From inside your theme source folder:

```bash
zip -r my_theme.zip manifest.json index.html style.css theme.js default_art.png icons fonts
```

Only include paths that exist in your theme.

### Install In oPlayer

1. Copy the ZIP to your Android device.
2. Open oPlayer.
3. Go to Settings > Themes.
4. Enable external visual themes.
5. Tap Import New Theme.
6. Select the ZIP.
7. Apply the imported theme.

### Debug With Chrome

1. Enable Android developer options and USB debugging.
2. Connect the device by USB.
3. Open Chrome on your desktop.
4. Visit `chrome://inspect`.
5. Inspect the oPlayer WebView.

Use the console to check errors, inspect DOM, test Bridge calls, and watch network requests to `https://app-theme.local/api/...`.

### Browser Preview

This repository includes a `preview/` folder and `preview/mock-bridge.js` for rough browser screenshots and layout checks. The mock bridge is not the real backend. It may return strings for some values and Promises for others. Always test imported themes inside oPlayer before shipping.

### Glideform Studio Store Review

Themes submitted to the official Glideform Studio theme listing are reviewed before publishing. Review does not make third-party code risk-free, but it is the trust boundary for themes distributed through `glideformstudio.com`. Themes installed from any other ZIP, fork, link, or modified package are outside that review process.

Before submission, verify:

- The ZIP imports cleanly and contains root `manifest.json`, `index.html`, `style.css`, and `theme.js`.
- Manifest ID, name/title, version, author, description, layout, and fullscreen fields are accurate.
- No code attempts to call built-in-only Bridge APIs from an imported theme.
- Library metadata is used only for the visible theme experience.
- No hidden tracking, surprise navigation, or deceptive external links are present.
- No startup code changes playback, volume, sleep timer, haptics, keyboard state, or native shell except documented fullscreen startup settings.
- Playlist, favorite, podcast, radio, EQ, folder, import, backup, restore, permission, review, and theme-management flows are absent from imported themes.
- Large lists use pagination or async reads, search is debounced, and the theme stays responsive on large libraries.
- Hardware handlers exist and do not duplicate native play/pause/next/previous actions.
- Classic themes are tested in the retro shell; fullscreen themes are tested on fresh import, after app restart, and after exiting back to the built-in fallback.

---

## 11. Security and Performance

### Risky and Privileged APIs

Imported external themes run with a restricted bridge policy. They can read library data, render custom UI, receive native events, and control normal playback, but they cannot call privileged app-management APIs directly. Built-in themes have broader access because they ship with the app.

Fullscreen external themes may still call:

```javascript
Bridge.setSetting("deviceModel", "FULLSCREEN");
Bridge.setSetting("uiOrientationLock", "PORTRAIT");
Bridge.setSetting("themeDarkMode", "true");
Bridge.setSetting("systemBarsEnabled", "true");
```

That exception exists so imported fullscreen themes can request the fullscreen shell during startup and keep Android status/navigation bars visible and legible over dark fullscreen UI. Other app-wide setting changes are blocked for imported themes.

External themes may also call `Bridge.escapeExternalTheme()` from a visible user action such as an "Exit Theme" or "Use Built-in Theme" button. This is an intentionally narrow safety hatch: it only switches to oPlayer's built-in fallback theme and does not grant arbitrary theme switching.

Treat the following APIs as privileged. In current app builds, imported external themes should expect these calls to be blocked or return a failure/no-op result. If you are maintaining a built-in theme, use them only from clear, user-initiated actions and avoid calling them during startup, timers, passive rendering, or hidden background flows.

| Risk | APIs | Why it matters | Theme guidance |
| :--- | :--- | :--- | :--- |
| Destructive user data changes | `Bridge.deletePlaylist(id)`, `Bridge.removeSongFromPlaylist(pid, sid)`, `Bridge.removePodcast(id)`, `Bridge.removeRadioStation(id)`, `Bridge.deleteTheme(id)`, `Bridge.deleteEqCustom(name)`, `Bridge.call("clearLyricsCache")` | Removes user-created or cached app data. | Put behind an explicit delete action and a confirmation UI. Never call automatically. |
| Physical/downloaded file changes | `Bridge.deleteEpisodeDownload(id)` | Deletes a downloaded podcast episode file and updates episode state. | Require direct user intent and clearly label that the downloaded file will be removed. |
| Library visibility changes | `Bridge.excludeFolder(path)`, `Bridge.includeFolder(path)` | Changes which folders appear in the user's library. | Confirm the folder path and provide a way back to the excluded-folders list. |
| App-wide settings changes | `Bridge.setSetting(key, value)`, `Bridge.resetSettings()`, `Bridge.setTheme(id)`, `Bridge.setInputMode(enabled)` | Changes the native shell, orientation, controls, theme, or broad app preferences. | Use sparingly. Fullscreen themes may set `deviceModel`/orientation during startup; other settings should be user-triggered. |
| Permission and import flows | `Bridge.requestImportTheme()`, `Bridge.requestLibraryBackup()`, `Bridge.requestLibraryRestore()`, `Bridge.requestVideoPermissions()`, `Bridge.call("requestPermissionsAndSync")` | Opens native pickers, permission flows, backup/restore flows, or sync flows. | Trigger only from visible controls. Do not loop, spam, or run on page load. |
| External apps and web | `Bridge.openBrowser(url)`, `Bridge.call("searchLyrics", title, artist)`, `Bridge.openPlayStoreListing()`, `Bridge.contactDeveloper()`, `Bridge.requestAppReview()` | Leaves oPlayer or opens system UI. `openBrowser(url)` accepts arbitrary URLs. | Use only for clear actions. Prefer fixed, trustworthy URLs. Do not use for tracking or surprise navigation. |
| Network-backed additions | `Bridge.addPodcastAsync(url)`, `Bridge.addRadioStationAsync(url)`, `Bridge.call("addRadioStation", name, url, genre)`, `Bridge.refreshPodcast(id)`, `Bridge.call("refreshPodcasts")`, `Bridge.fetchLyricsAsync(title, artist)`, `Bridge.refetchLyrics(title, artist)` | Can trigger network requests and modify podcast/radio data. | Debounce, rate-limit, and disclose what is being added/refreshed. |
| Playback/service disruption | `Bridge.stop()`, `Bridge.play*()`, `Bridge.pause()`, `Bridge.next()`, `Bridge.previous()`, `Bridge.shuffleAll()`, `Bridge.setSleepTimer(minutes)` | Changes playback or can stop playback later. | Safe for transport controls, but never call from passive render loops. |
| Device feedback / nuisance | `Bridge.setVolume(level)`, `Bridge.adjustVolume(delta)`, `Bridge.triggerHaptic(style)`, `Bridge.triggerClick()`, `Bridge.showKeyboard()`, `Bridge.hideKeyboard()`, `Bridge.call("showNativeSplash")`, `Bridge.call("hideNativeSplash")` | Can annoy users, alter media volume, or interfere with input. | Keep tied to direct interaction and avoid repeated calls. `showKeyboard`, `hideKeyboard`, and `setInputMode` are allowed for imported themes when used for visible search/input flows. |
| Performance-heavy reads | `Bridge.getAllSongs()`, synchronous list methods, broad searches, very large `limit` values | Can block the WebView or create jank on large libraries. | Prefer Promise-based paginated reads. Debounce searches. |

Important implementation detail: `Bridge.call(method, ...args)` routes to Android methods exposed with `@JavascriptInterface`, even if that method is not wrapped by a friendly `Bridge.methodName` helper. The native trust gate still applies, so imported themes should not use `Bridge.call()` as a way around the documented policy.

### Safety Guidelines

- Do not delete playlists, podcasts, radio stations, folders, or themes unless the user clearly requested it.
- Do not call library refresh, permission, import, backup, restore, or review flows unexpectedly.
- Do not make network requests to third-party services without a clear user-facing reason.
- Do not store personal data in theme files or remote services.
- Treat all library metadata as user data.

### Performance Guidelines

- Prefer Promise-based `Bridge.*Async()` list reads.
- Paginate large song and album lists.
- Avoid synchronous `Bridge.getAllSongs()` on startup.
- Avoid calling synchronous Bridge methods during animation frames.
- Debounce search input.
- Reuse DOM nodes for long lists or render only visible rows.
- Use `onPlaybackUpdate` instead of polling playback state aggressively.
- Clear timers when views are hidden or no longer needed.

### Compatibility Guidelines

- Check that a Bridge method exists before calling newer APIs.
- Normalize arrays and strings defensively.
- Handle missing album art.
- Handle empty libraries.
- Handle radio and podcast tracks differently from songs.
- Keep hardware handlers defined even for touch-first fullscreen themes.
- Test both classic and fullscreen shells when your theme changes layout settings.
