WP Manifestindependent plugin directory
manifest / media / callboard

Callboard

Rehearsal tracks for a cast 🎭

by Joe Fusco Β· github.com/promptcorner/callboard Β· website

β˜… 1stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/promptcorner/callboard/archive/refs/heads/main.zip

Callboard: two phones showing the posted call and a set with the player

Callboard

An open-source music player for WordPress. Publish an owned, app-like listening experience with persistent playback, offline sets, waveform seeking, lyrics, and lock-screen controls. Rehearsal workflows for casts and choirs are built in, not bolted on.

Landing page and live demo Β· Open in WordPress Playground Β· Latest release

CI Latest release WordPress 6.5+ PHP 8.1+ GPL-2.0-or-later

The home screen with the next call, the sets, and the player
The board
A set: numbered tracks, the waveform, and the transport
A set
The call editor in WordPress
Posting a call

What it does

  • Owned music player. WordPress manages the library while Callboard replaces the theme on the front end with a focused listening app. Share one URL; listeners do not need an account by default.
  • Board. The home page shows the next call: time, place, note, and the numbers being worked. Each number is a tap that starts the track. A call is a post under Sets. Publishing one sends a push notification.
  • Sets. A set is a post; its tracks are audio attachments. Add audio through the Media Library, fetch a playlist with WP-CLI, or import a folder.
  • Player. A bar at the foot of every page with the artwork, what is playing, and previous, play, next. Tap it and Now Playing fills the screen: the cover, the waveform, elapsed and remaining, and what the copy actually is. Waveform scrubbing, an A/B loop (two fingers on the wave, or the bracket keys), count-in, lyrics and director's notes in time with the track, AirPlay, and lock-screen controls. The tab title carries the track as well, for whoever has the board open behind a rehearsal PDF.
  • Offline. Save a set once. It plays from the phone with no connection, or load it from a file when there is no signal to save it with.
  • Settings. Site name, accent colour, badge, confetti behind a triple tap on the title. Hooks and template overrides for developers.

Getting started

  1. Install callboard.zip from the latest release. WordPress 6.5+, PHP 8.1+.
  2. Add a set. With yt-dlp and ffmpeg installed where WP-CLI runs: wp callboard fetch '<playlist url>' --name="Spring Show". Otherwise put audio files and a manifest.json in wp-content/uploads/callboard/<slug>/ and use Sets β†’ Import. wp callboard doctor reports what is available.
  3. Post a call under Sets β†’ Calls. Publish it or schedule it.
  4. Share the home page URL. On iPhone, the cast adds it to the Home Screen. The bell turns on notifications where the host supports Web Push (PHP with OpenSSL and GMP or BCMath).
  5. Adjust Sets β†’ Settings.

Contents

How it works

Rendering and navigation

Router handles two routes: the home page and /<set-slug>/. Unknown paths show the home page with a note. Frontend renders from templates/ on template_redirect and loads only the plugin's stylesheet and script, core's wp-hooks underneath it, and the assets of registered extensions. The theme is not used.

Navigation is client-side. The script fetches the target URL with ?fragment=1, which returns the view without the page shell, and swaps it into <main> inside a view transition. PHP is the only renderer. Fetching starts on the first touch of a link. The home fragment is prefetched when idle.

Elements that change state after load (save marks, header buttons, the AirPlay picker) are rendered in place and hidden, so nothing shifts when the script runs.

No build step

assets/app.js is one IIFE, assets/app.css is one file. No bundler, framework, or transpiler. The plugin has to keep working on sites nobody maintains, and a build chain is the first thing to break. Every browser feature is behind a check; CSS degrades through @supports and media queries. Pwa::write_files() writes the service worker and manifest to the site root, since a worker only controls the scope it is served from. On a multisite network the sites share that folder, so each site saves both in its own options and serves them from its home URL (/?callboard_file=sw.js).

Design rules: animate only transform and opacity, never use font weight for state, prefer native controls, one set of colour tokens for both schemes.

Data model
Where What
callboard_call post Title and body. _callboard_when (site time zone, empty for a notice with no time), _callboard_where, _callboard_numbers (attachment ids). Leaves the board six hours after its time
callboard_set post Name, slug, order, credits, source link, share image
Audio attachment A track. menu_order is its position. Quality shown to listeners β€” bit depth and sample rate for lossless, bitrate and sample rate for lossy β€” reads WordPress's own attachment metadata, not a callboard field; lossy formats show no bit depth, since MP3 does not have one
_callboard_duration Seconds
_callboard_levels Loudness envelope, digits 0–9, ten per second. Drives the waveform and the filament, which auto-ranges to its own recent peak β€” rising at once, forgotten over ~6s β€” so a quiet reading lights it as fully as a loud mix. iPhones cannot analyse audio live
_callboard_lyrics, _callboard_lyrics_approved Timed lines from captions, shown after approval
callboard_note comments on the track Director's notes. Text is the comment body, the time into the track is _callboard_at comment meta, and author and date come from the comment. Legacy _callboard_notes post meta is still read as a fallback; wp callboard migrate-notes turns those arrays into comments
_callboard_bpm Tempo, for the count-in
_callboard_practice Anonymous practice counts per hour, written only while Count practice is on
_callboard_video_id, _callboard_source_url, _callboard_uploader Source. The uploader is also sent per track as artist β€” right for one playlist by one uploader, wrong for a set where every track differs
_callboard_codec, _callboard_reencoded What a fetch actually got (e.g. aac) and whether ffmpeg had to re-encode to get it β€” provenance, not a measurement of the file itself

Sets builds the data the front end renders, cached in a transient for a day and cleared on save.

Import
  1. Fetch. Fetcher runs yt-dlp (and ffmpeg when it needs to) on a YouTube URL, playlist, or search and writes wp-content/uploads/callboard/<slug>/: the audio, a manifest.json, and sidecars keyed by video id (levels.json, lyrics.json, notes.json, tempo.json). It keeps the native m4a/AAC stream YouTube already serves rather than re-encoding it β€” re-encoding a lossy source is a pure loss, and can even inflate the bitrate while making it sound worse β€” and only asks ffmpeg to convert when a video truly offers no AAC audio, preferring m4a there too and falling back to mp3 only if the ffmpeg build cannot encode AAC at all. A search (yt-dlp's ytsearch: syntax) is narrowed to one candidate first: an auto-generated "Topic" upload, then a channel YouTube has verified, skipping obvious live versions, covers and remixes where another copy exists. Binary paths are filterable.
  2. Import. Importer reads that folder into posts. The folder is only an input; posts are the source of truth. Re-importing refreshes order, credits, and lyrics but keeps titles edited in the admin.

Hosts that cannot run binaries: build the folder on a laptop with the same command, upload it, import. Requests is an admin queue of URLs; wp callboard run drains it where the tools exist. Because Requests only accepts a real YouTube URL, a search query is a WP-CLI-only way in for now.

  1. The file. Exporter writes that same folder as a single .callboard file, and reads one back. The manifest carries a version; a reader refuses a file newer than it understands. Unzipped it is an import folder, so Importer reads it without knowing it was ever a file, and somebody with no Callboard still has playable audio in the right order with a cover. Entries are extracted by name, never with extractTo(), since a set is a flat folder and anything carrying a path separator is not ours to write.

  2. The stick. --format=car writes a plain folder instead: 01 Title.mp3, in order, each tagged so it names itself on a dash, with the cover embedded and beside them as folder.jpg. Id3 writes ID3v2.3 by hand β€” WordPress bundles getID3's reading modules but not its writing ones, and five frames are not worth a dependency. Latin-1 where the text fits it, UTF-16 where it does not, and JPEG artwork because head units read it far more reliably than PNG.

Offline

The service worker precaches the shell and the home fragment. Saving a set streams each track into the Cache API and fetches the set's fragment. The worker serves cached audio and answers Range requests, so seeking works offline. Saved sets are re-fetched on the home screen after an update because the shell cache is versioned. The Cache API is used instead of IndexedDB or OPFS because it serves whole files with Range support, which is what the media element needs.

Load from files fills the same cache without the network, for a phone on bad signal standing next to somebody who has the audio on a stick. Files are matched to tracks by the track's own file name, then by a leading number, which is how a car-format export is named, then by title; anything unmatched is reported rather than guessed at. A sideloaded copy carries a marker, because it can be a different size from the server's β€” a car export has ID3 tags the original does not β€” and without it the next check would call it stale and download it again.

Save set file appears on a set once every track is saved offline. It writes a .callboard file in the browser from those saved copies, in the same format wp-admin exports, and hands it to the share sheet, where Save to Files can put it on a USB drive. Where the share sheet won't take a zip, as on Android, the file downloads instead. Open a set file on home, or a .callboard file picked in Load from files, reads one back into the offline copy of the set it describes, with no network. That's how a set gets from one phone to another on a stick.

Push

Push stores subscriptions through a REST route and sends with web-push-php. Payloads use declarative Web Push so Safari shows them without waking the worker. A pushsubscriptionchange handler re-subscribes when a browser rotates an endpoint. VAPID keys are generated with OpenSSL and stored in an option.

Privacy

Privacy adds noindex via wp_robots and X-Robots-Tag, disallows everything in robots.txt, sets Referrer-Policy: no-referrer, requires authentication for REST except the push routes, hides the users endpoint, disables XML-RPC and feeds, and redirects author archives and search to home.

Gate is the one decision about who may see the front end, and it is off by default: a link in a group chat is the whole setup, and that is the point. Turn on Require a WordPress sign-in and the answer becomes is_user_logged_in() and nothing else, so whatever sign-in the site already has guards the app too β€” Apple, Google, a membership plugin, passkeys through Two Factor and its WebAuthn provider. Core still ships no passkeys of its own. Also turn on Only let in users with access to Callboard and a signed-in user needs the view_callboard capability too. callboard_can_view overrides the settings; return null and the filter never has to know what they say.

Roles adds two roles. Director can post calls, edit playlists and their track notes, and send notifications from Notices. Cast member can only open the front end. Each post type has its own capabilities (edit_callboard_calls, edit_callboard_playlists, and so on), and on activation or update every other role gets the ones that match the post capabilities it already has. Administrators, editors, authors and contributors keep what they could do before. Deleting the plugin removes the roles and capabilities.

Count practice is off by default. When it is on, the page counts how many times each track is opened, how many loops are set on it and how many seconds it plays, and sends those totals to the site. They are stored on the track, grouped by hour, and shown on each call in the editor. No user id, name, IP address or cookie is stored with them.

A gated request answers 403 with templates/gate.php rather than redirecting to wp-login.php, which would drop the cast out of an installed app and into WordPress branding mid-session. Nothing about the sets escapes it: the script and its data are not enqueued, link previews are suppressed, and the document title falls back to the site name. Audio files keep their own upload addresses either way, so the gate guards the app, not the media.

Artwork

Art draws covers and native wide share cards with GD from the set's name, track count, and available curator or performer credit. A deterministic palette and modular layout keep generated art distinct without network assets or a build step.

Repository map
Path Purpose
callboard.php Plugin header, constants, autoload
uninstall.php Removes the roles and capabilities when the plugin is deleted
includes/ One class per concern: Plugin, Router, Frontend, Sets, Calls, Post_Types, Admin, Settings, Importer, Exporter, Id3, Fetcher, Requests, Push, Pwa, Privacy, Gate, Roles, Art, Cli. helpers.php has icons and formatting
templates/ index.php (shell), fragment.php, home.php, board.php, set.php, deck.php (player), gate.php, footer.php
assets/ app.js, app.css
pwa/sw.js Service worker source
tests/e2e/ Playwright suites
tests/fixtures/ demo-set and empty-set, and jazz.js, which builds the public-domain Airmen of Note demo. local-set.js builds a set from your own audio into a gitignored folder, for local listening only
blueprint.json Playground demo. The Pages workflow publishes it with the plugin zip and demo audio, stamped by commit
site/ Landing page (GitHub Pages)
languages/callboard.pot Translation template; npm run pot regenerates it
scripts/sync-versions.sh Writes the release version into the plugin files
scripts/screenshots.js Retakes the landing-page screenshots from a running wp-env site, at the exact sizes the page expects. node scripts/screenshots.js demo retakes only the live demo previews
scripts/wporg-screenshots.js Regenerates the wordpress.org screenshots in .wordpress-org/ from a running wp-env site, using scripts/wporg/template.html. .wordpress-org/screenshots.json sets each screenshot's page, headline, and readme.txt caption. tests/e2e/wporg-screenshots.spec.js checks the captions and image sizes match
.github/ Workflows, Dependabot, PR template, CONTRIBUTING
AGENTS.md Notes for coding agents

Developer API

Features are extensions: an id, a version, and named contribution points shared between PHP and the page. Callboard's own count-in, quality readout and Home Screen badge are built that way, so a plugin can switch one off, replace it, or add its own beside them. Extending Callboard is the contract: the points, the lifecycle, events, state and commands, escaping, and what version 1 promises.

PHP
Function or hook Kind What it does
callboard_register_extension( $id, $args ) function Register an extension on callboard_register_extensions. callboard/* and some other namespaces are reserved
callboard_unregister_extension( $id ) function Remove one, Callboard's own included
callboard_get_extension( $id ), callboard_get_extensions() functions Read the registry
callboard_slot( $slot, ...$context ), callboard_get_slot() functions Print or return a slot, for a replacement template
callboard_rest_can_view() function Whether this REST request may see what the front end shows
callboard_get_setting( $key ) function One of Callboard's settings
CALLBOARD_API_VERSION constant The extension contract's version: 1
callboard_register_extensions action Register, unregister or replace extensions here
callboard_extension_enabled filter Whether an extension runs on this request
callboard_slot_allowed_html filter The markup an HTML slot accepts
callboard_can_view filter Whether this visitor may see the front end. Return null for the default, true or false to decide
callboard_head action Output in the <head> of every front-end page
callboard_template_path filter Replace any template with your own file
callboard_app_data filter Data the script receives on load. Extension app_data runs inside it at priority 5
callboard_set_data filter One set's data. Extension track_data and set_data run inside it at priority 5
callboard_board filter The calls shown on the board
callboard_push_message filter Title, body, and URL of a notification. Return an empty array to cancel
callboard_call_published action A call was published
callboard_imported action A set folder was imported
callboard_import_page action Add your own controls to the bottom of the admin import screen
callboard_import_dir, callboard_ytdlp_path, callboard_ffmpeg_path, callboard_max_subscribers filters Import folder, tool paths, subscriber limit
JavaScript
Name Kind What it does
callboard.apiVersion number The extension contract's version: 1
callboard.registerExtension( id, args ) function Register the page half of an extension PHP registered
callboard.unregisterExtension( id ) function Remove its client contributions
callboard.state object The view, the playlist and track in the player bar, position, duration, paused, loop, online
callboard.commands object play, pause, seek, next, prev, goTo, display
callboard.registerCommand( name, fn ) function Add a command named namespace/name/command for a registered extension
callboard.run( name, ...args ) function Run a command with arguments and return what it returns
callboard.data( id ), callboard.emit( name, detail ), callboard.invalidate( ...points ) functions An extension's app data, its own events, re-rendering
callboard.deprecated( name, { since, alternative, hint } ) function Log a deprecation warning once per name, in the shape of @wordpress/deprecated
callboard.ready, .view, .viewTeardown, .track, .play, .pause, .ended, .seek, .loop, .save, .unsave, .online, .offline wp.hooks actions Also fired as callboard:<event> on document, which is how callboard:track and callboard:view have always arrived
callboard.slot.trackBadges, .slot.trackMeta, .slot.nowPlayingMeta, callboard.badge, callboard.beforePlay wp.hooks filters The contribution points underneath the registry
Abilities

On WordPress 6.9 and later, Callboard registers three abilities with the Abilities API, in the callboard category. Other plugins and AI tools can run them with wp_get_ability( $name )->execute( $input ) or through the wp-abilities/v1 REST routes. Each one checks the same capability as the matching wp-admin screen. On older WordPress versions nothing is registered.

Ability Capability What it does
callboard/list-playlists edit_callboard_playlists Lists published playlists with each track's id, position and title
callboard/list-upcoming-calls edit_callboard_calls Lists the calls shown on the board: upcoming calls soonest first, then notices without a time
callboard/post-call publish_callboard_calls Publishes a call from a title and optional note, when (YYYY-MM-DDTHH:MM in the site's time zone), where and numbers (track ids). The cast gets a notification if call notifications are on

WP-CLI

Command Does
wp callboard fetch <url> --name=<name> [--slug=<slug>] Fetch a YouTube video, playlist, or ytsearch: query into a set
wp callboard run [--interval=<seconds>] Process the admin's fetch queue
wp callboard import [--file=<path>] Import every folder under uploads/callboard/, or one .callboard file
wp callboard export <slug> [--format=<file\|car>] [--out=<path>] Write a set out as a .callboard file, or as a folder of tagged mp3s for a car
wp callboard levels <slug> Measure loudness for a set that has none
wp callboard notify <message> [--title=<title>] [--url=<url>] Send a push notification
wp callboard doctor Check for yt-dlp, ffmpeg, and PHP extensions

Web APIs used

Links go to the specifications.

Web platform CSS WordPress and PHP

Watchlist

Browser and WordPress features considered for this plugin, with a verdict, so nobody has to evaluate them twice.

Would add
  • WebAuthn passkeys to gate the front end. Today anyone with the URL can open the site. Passkeys give one-tap sign-in with no third party. OAuth and Sign in with Apple were considered and set aside; the Presence API answers who is present, not who may enter.
  • WebRTC data channels for live cues from the director to every phone, with WordPress only as the signaling server.
  • AudioWorklet for transposition without tempo change. The most requested feature and the largest.
  • Media Capabilities to choose between mp3 and m4a per device.
  • Navigation API to replace pushState routing. Safari 18.4+.
  • light-dark(), scroll snap, contrast-color().
Waiting on browsers WordPress
  • Interactivity API: only if the admin grows beyond forms.
  • Script Modules: once the service worker precache can handle module URLs.
  • HTML API: if the theme is ever allowed to add markup.
  • Abilities API: to expose import and notifications to other plugins.
  • Presence API: for a sign-in sheet. Needs the gated front end first.
Ruled out
  • Popover API for the lyrics sheet: the top layer would cover the player.
  • Vibration API on iPhone: not implemented; the switch control is used instead.
  • wavesurfer.js and peaks.js: they decode audio in the browser, which iPhone Safari cannot do for long files. Levels are measured at import instead.
  • IndexedDB or OPFS for audio: the Cache API already serves files with Range support.
  • Web Bluetooth and Web MIDI for foot pedals: Chromium only, and Bluetooth pedals already arrive as media keys.
  • Declarative push app_badge as an unread count: would need per-subscriber state on the server.

Tests

Two suites, both against wp-env. PHPUnit in tests/php/ covers the PHP, leading with the two boundaries that matter: what a .callboard file is allowed to unpack, and who the gate lets through. After those come the data model, settings sanitizing, and the helpers behind every line of text a cast reads. Playwright in tests/e2e/ covers the browser. The pre-commit hook that npm install sets up runs a fast smoke pass, and GitHub Actions runs the full suite on every pull request. One test saves a set, takes the browser offline, and opens and plays it. Headless Chromium cannot decode mp3, so player tests assert on state, not audio.

The demo fixture is Compositions (2012), ten jazz recordings performed by the Airmen of Note, United States Air Force Band. tests/fixtures/jazz.js downloads the source recordings from Wikimedia Commons, cuts compact 40-second excerpts, encodes them, and writes manifest.json, levels.json, notes.json, and tempo.json (it needs ffmpeg, ffprobe, and a network connection). The Air Force and Commons identify each composition, performance, and recording as a public-domain U.S. Government work; the manifest retains the composer, performer, source page, and rights evidence. The source files are public domain in the United States rather than GPL-licensed; status outside the United States may vary. Two fixture sets remain: demo-set and empty-set. Covers are drawn by the plugin's Art class.

Source and maintainer

Source, issues, and releases live on GitHub. Made by Joe Fusco. Security reports go through private vulnerability reporting.

Releases

Pull request titles follow Conventional Commits. release-please creates the version, changelog, and GitHub Release with callboard.zip attached. See CONTRIBUTING.md for the rest of the automation.

GPL-2.0-or-later.