Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7caeca8
Add persistent audio player and play button blocks
juanmaguitar Mar 19, 2026
2aafff4
Enhance InteractiveRouter to support navigation directives on header …
juanmaguitar Mar 19, 2026
1eae80f
Fix navigation link handling in audio player to ensure correct href r…
juanmaguitar Mar 19, 2026
6d62d5f
Update action attributes in InteractiveRouter to use namespaced ident…
juanmaguitar Mar 19, 2026
b318048
Add support for client-side navigation modules in InteractiveRouter
juanmaguitar Mar 19, 2026
db1b224
Update README.md to enhance documentation for Bifrost Player architec…
juanmaguitar Mar 19, 2026
5899df4
Enhance BlockServiceProvider to boot RenderPlaylistTrack and update R…
juanmaguitar Mar 19, 2026
4202d3b
Add WaveformPlayer integration and enhance audio player functionality
juanmaguitar Mar 20, 2026
5bc1fb4
Add documentation for Bifrost Player architecture, blocks, and build …
juanmaguitar Mar 20, 2026
c14fbe7
Merge branch 'trunk' into feature/player-persistent
juanmaguitar Mar 20, 2026
eab3b18
Refactor Bifrost Player to utilize bifrost-framework for dependency i…
juanmaguitar Mar 20, 2026
26635e4
Add production site link to Bifrost Player development notes
juanmaguitar Mar 20, 2026
a864d81
Remove outdated documentation for audio player block, play button blo…
juanmaguitar Mar 20, 2026
2d1d9b8
Refactor play button implementation: remove old block files and integ…
juanmaguitar Mar 21, 2026
8820546
Update services-pattern image for Bifrost Player
juanmaguitar Mar 21, 2026
adad32c
Refactor code style in Plugin.php and plugin.php for consistency: adj…
juanmaguitar Mar 21, 2026
efa34f0
Update Excalidraw diagram link in README.md for Bifrost Player
juanmaguitar Mar 21, 2026
97ecb04
Update development notes in CLAUDE.md: refine build toolchain and int…
juanmaguitar Mar 21, 2026
3a58b53
Update package-lock.json: add peer dependencies for various packages
juanmaguitar Mar 21, 2026
56da484
Refactor code style in BlockServiceProvider, RenderPlayButton, Render…
juanmaguitar Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions plugins/bifrost-player/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Bifrost Player — Development Notes

Production site: https://developershowcase3.wpcomstaging.com/

Resources to take into account:
- https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/core-concepts/client-side-navigation/
- https://make.wordpress.org/core/2026/02/23/changes-to-the-interactivity-api-in-wordpress-7-0/
- https://make.wordpress.org/core/2025/11/12/changes-to-the-interactivity-api-in-wordpress-6-9/
- https://make.wordpress.org/core/2025/11/12/interactivity-apis-client-navigation-improvements-in-wordpress-6-9/
- https://make.wordpress.org/core/2025/03/24/interactivity-api-best-practices-in-6-8/


## Build toolchain

- When `--experimental-modules` is active, `@wordpress/scripts/config/webpack.config` returns an **array** of configs (scripts + modules). Do NOT spread it into a single object (`{ ...defaultConfig }`) — export the array directly: `module.exports = defaultConfig`.
- To get `style-index.css` output for a block, the block **must** have an `editorScript` entry in `block.json` (even if minimal). Without it, wp-scripts' scripts webpack doesn't create an entry and CSS imported in `index.js` is never extracted. A minimal `index.js` that just does `import './style.css'` is enough.

## Interactivity API

- **Generator actions that call `e.preventDefault()`** must be wrapped with `withSyncEvent()`. Without it, the browser processes the default action before the generator resumes from its first `yield`.
- **`getContext()`** returns the context of the element where the directive fires, not the element where the store is defined. This is what makes cross-block communication work (e.g., play-button sets context on its element, audio-player reads it in `playTrack`).
- **`data-wp-interactive` namespace conflicts**: Setting `data-wp-interactive="bifrost-player"` on an element inside a `data-wp-interactive="core/playlist"` region overrides the namespace for that subtree. This breaks core directives. Use `namespace::` prefixes instead (e.g., `data-wp-on--click="bifrost-player::actions.foo"`).
- **Cross-namespace directives**: Use the `namespace::` prefix to call actions from a different store without changing the element's namespace (e.g., `data-wp-init="bifrost-player::callbacks.initPlaylistBridge"` on a `core/playlist` element).

## Interactivity Router (client-side navigation)

- **Import map timing**: Blocks rendered during `wp_footer` (like our audio-player) have their `viewScriptModule` processed AFTER the import map is printed. This means `yield import('@wordpress/interactivity-router')` fails with `Failed to resolve module specifier`. Fix: enqueue the block's view script module early via `wp_enqueue_script_module('bifrost-player-audio-player-view-script-module')` in `wp_enqueue_scripts`. This forces WordPress to process the asset file (including dynamic dependencies) before the import map is printed.
- **Script module handle naming**: WordPress auto-generates handles from `block.json`. For non-core blocks: `str_replace('/', '-', blockName) . '-view-script-module'`. Example: `bifrost-player/audio-player` → `bifrost-player-audio-player-view-script-module`.
- **Do NOT use `wp_enqueue_script_module('@wordpress/interactivity-router')` directly** — enqueue the block's own view module instead, which declares the router as a dynamic dependency in its asset file. This matches how blocks normally work (the reference example at `block-development-examples/interactivity-router-2f43f8` doesn't enqueue the router directly either — it works because the block renders in page content, before the import map).
- **Navigation directives go on each `<a>` tag, NOT on a parent container**. Event delegation from a parent breaks because `e.target` is whatever nested element was clicked (image, span), not the link. Use `getElement().ref.href` instead of `e.target.href`, which always returns the element the directive is on.
- **Block style variation suffix mismatch**: WordPress generates unique numbered class suffixes per page render (e.g., `is-style-site-footer--50` on page A, `--8` on page B). During client-side navigation, the router swaps stylesheets (new CSS targets `--8`) but elements outside router regions keep their old HTML (`--50`). Fix: add router regions to `core/template-part` blocks (header/footer) so their HTML is also updated, keeping suffixes in sync with the new CSS. The persistent audio player (injected via `wp_footer`, outside all regions) is unaffected because it uses custom classes, not block style variations.

## Core/playlist integration

- `core/playlist` uses a **locked** store (`{ lock: true }`). External stores cannot call its actions directly.
- Setting `data-wp-interactive="bifrost-player"` on playlist track `<li>` elements overrides the namespace for that subtree, so core's `actions.changeTrack` silently no-ops — which is the desired behavior since the in-page waveform is hidden.

## Persistent WaveformPlayer (`@arraypress/waveform-player`)

The persistent audio player embeds its own `WaveformPlayer` instance for seekable playback with waveform visualization. The `<audio>` element in `render.php` is kept as a fallback — `syncPlayback` skips when the waveform is active.

### Initialization

- **Initialize via JS constructor** (`new WaveformPlayer(div, options)`), NOT via `data-waveform-player` attribute. The library's auto-init scans the DOM for `[data-waveform-player]` elements — after CSR navigation, this can re-initialize and corrupt an existing instance.
- **First-time init must be deferred** via `requestAnimationFrame`. The persistent player starts with `hidden` attribute (`display: none`). The Interactivity API removes `hidden` and fires `data-wp-watch` in the same reactive cycle, but the browser hasn't painted yet — the canvas would get 0 dimensions.
- **`syncPlayback` must also check `initPending`**. During the `requestAnimationFrame` delay, the fallback `<audio>` would otherwise start playing, causing dual audio.

### Colors and styling

- **Pass all colors explicitly** in the options object. Do NOT use `colorPreset: "dark"` — it reads computed styles which change when the router swaps stylesheets during navigation.
- **Set `singlePlay: false`** to prevent the library from pausing our instance when other WaveformPlayer instances exist on the page.

### Surviving client-side navigation

The core/playlist stylesheet (which provides ALL `.waveform-*` layout styles) is removed when navigating away from album pages. Without those styles, the waveform canvas collapses and the layout breaks.

- **All `.waveform-*` layout styles must be duplicated** in our own `style.css`, scoped under `.bifrost-audio-player__waveform`. Copy them from `plugins/gutenberg/build/styles/block-library/playlist/style.css`.
- **The `navigate` action must NOT touch the waveform** — don't destroy, recreate, or pause it.
- **Interactivity state is NOT reset during navigation.** The router calls `populateServerData` with the new page's `wp_interactivity_state` values, but uses `deepMerge(state, newState, false)` — the third arg `false` means it only sets NEW keys, never overwrites existing ones.

### Code style

- **Do NOT remove existing comments** in `view.js` when making changes.
146 changes: 146 additions & 0 deletions plugins/bifrost-player/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Bifrost Player

A persistent audio player for the Developer Showcase. Music keeps playing as users navigate between pages using the WordPress Interactivity Router for SPA-like client-side navigation.


## Architecture

### Plugin lifecycle

```
bifrost-framework (plugins_loaded @ 999)
→ do_action('bifrost/framework/register/plugin', $app)
→ Plugin::register($app)
→ registers BlockServiceProvider
→ registers RouterServiceProvider
→ $app->boot()
→ boots all providers that implement Bootable
```

<img src="./assets/services-pattern.png" width="75%"/>

[See diagram on Excalidraw](https://excalidraw.com/#json=Fe_KgC1pYL6HhLlTetBc4,UV7E_u_8MX0dPxMY-4G48g)


The plugin uses **bifrost-framework** for its service container and provider lifecycle (same as `bifrost-music`). The `Plugin` class registers two providers into the framework's shared `Application`:

| Provider | Responsibility |
|---|---|
| `BlockServiceProvider` | Registers blocks, renders the player in `wp_footer`, boots playlist bridge filters |
| `RouterServiceProvider` | Boots `InteractiveRouter` for client-side navigation |

### Blocks

#### `audio-player` — Persistent player bar

- **Rendered in `wp_footer`** by `BlockServiceProvider` (not placed in templates).
- Hidden by default; becomes visible when `state.currentTrackUrl` is set.
- Contains artwork, track info, play/pause, close button, and a `<audio>` element.
- All UI bound reactively via `data-wp-*` directives to the `bifrost-player` store.

#### `play-button` — "Listen Now" button

- Server-rendered with automatic track resolution: falls back to the current post's first audio attachment, parent album artist, and featured image.
- Sets Interactivity API context (`trackUrl`, `trackTitle`, `trackArtist`, `trackImage`) and triggers `actions.playTrack` on click.
- Uses standard `wp-block-button` markup for theme style inheritance.

### Interactivity store (`view.js`)

A single `bifrost-player` store manages all state and actions:

```
state
├── currentTrackUrl / Title / Artist / Image
├── isAudioPlaying
├── isPlayerVisible (derived: trackUrl !== '')
└── playPauseLabel (derived: ⏸ / ▶)

actions
├── playTrack() — reads context, sets track state, starts playback
├── togglePlay() — toggles isAudioPlaying
├── closePlayer() — stops playback, clears track
├── onTrackEnded() — resets playing state
├── navigate() — client-side navigation via interactivity-router
└── prefetch() — prefetches link on hover

callbacks
├── syncPlayback() — watches state, controls <audio> play/pause
└── initPlaylistBridge() — bridges core/playlist waveform events
```

### Client-side navigation

`InteractiveRouter` enables SPA-like page transitions so the player (rendered in `wp_footer`, outside all regions) persists across navigations.

**How it works:**

1. A `render_block` filter wraps `<main>`, `<header>`, and `<footer>` in **router regions** (`data-wp-router-region`). Only content inside these regions is swapped during navigation.
2. Every `<a>` tag inside router regions gets `data-wp-on--click="actions.navigate"` and `data-wp-on--mouseenter="actions.prefetch"` injected via `WP_HTML_Tag_Processor`.
3. The `navigate` action dynamically imports `@wordpress/interactivity-router` and delegates to its `actions.navigate()`.

**Workarounds in place:**

- The audio-player's `viewScriptModule` is enqueued early via `wp_enqueue_scripts` to ensure its dynamic dependencies are in the import map before it's printed (blocks in `wp_footer` would otherwise miss the import map window).
- Script modules like `@wordpress/block-library/tabs/view` are explicitly marked for client navigation via `add_client_navigation_support_to_script_module()`, working around a missing `build/modules/index.php` in the current Gutenberg build.

### `core/playlist` integration

The `core/playlist` block uses a locked store, so direct cross-store action calls are not possible. Integration works through event bridging:

```
core/playlist (waveform player)
→ dispatches 'waveformplayer:play' event
→ RenderPlaylist injects data-wp-init="bifrost-player::callbacks.initPlaylistBridge"
on the playlist <figure>
→ initPlaylistBridge() listens for the event, pauses the waveform audio,
and transfers playback data to the bifrost-player store
```

`RenderPlaylistTrack` adds `data-wp-context` and `data-wp-on--click="actions.playTrack"` to each `core/playlist-track`, enabling direct track selection.

### File structure

```
bifrost-player/
├── plugin.php # Entry point, constants, framework hook
├── inc/
│ ├── Plugin.php # Static registrar (registers providers with framework)
│ ├── Block/
│ │ ├── BlockServiceProvider.php # Registers blocks, renders player in footer
│ │ ├── RenderPlaylist.php # Injects bridge init on core/playlist
│ │ └── RenderPlaylistTrack.php # Injects context + click on playlist tracks
│ └── Router/
│ ├── RouterServiceProvider.php # Boots InteractiveRouter
│ └── InteractiveRouter.php # Router regions + link directives
└── src/blocks/
├── audio-player/
│ ├── block.json # Block metadata
│ ├── index.js # Editor entry (imports style.css)
│ ├── render.php # Server render + initial iAPI state
│ ├── style.css # Player bar styles
│ └── view.js # Interactivity store (state, actions, callbacks)
└── play-button/
├── block.json # Block metadata + attributes
└── render.php # Server render with track resolution
```

Container, Application, ServiceProvider, and Bootable are provided by `bifrost-framework` — no local copies.

### Data flow

```
User clicks play-button
→ actions.playTrack() reads context → sets state.currentTrack* + isAudioPlaying
→ audio-player becomes visible (isPlayerVisible derived)
→ callbacks.syncPlayback() calls audio.play()

User clicks link
→ actions.navigate() prevents default → imports interactivity-router
→ router swaps <main>, <header>, <footer> regions
→ audio-player in wp_footer is outside regions → stays mounted, keeps playing

User clicks track in core/playlist
→ waveformplayer:play event fires on <figure>
→ initPlaylistBridge() pauses waveform audio
→ transfers track data to bifrost-player state → persistent player takes over
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions plugins/bifrost-player/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "bifrost/player",
"type": "wordpress-plugin",
"license": "GPL-3.0-or-later",
"autoload": {
"psr-4": { "Bifrost\\Player\\": "inc/" }
},
"require": { "php": ">=8.1" },
"require-dev": {
"wp-coding-standards/wpcs": "^3.0",
"phpcompatibility/phpcompatibility-wp": "*",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0"
},
"scripts": {
"build": "composer update --no-dev && composer dump-autoload -o --no-dev",
"dev": "composer update && composer dump-autoload"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}
54 changes: 54 additions & 0 deletions plugins/bifrost-player/inc/Block/BlockServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/**
* Block service provider.
*
* @author Bifrost
* @copyright Copyright (c) 2026
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL-3.0-or-later
* @link https://github.com/wptrainingteam/developer-showcase
*/

declare(strict_types=1);

namespace Bifrost\Player\Block;

use Bifrost\Framework\Contracts\Bootable;
use Bifrost\Framework\Core\ServiceProvider;

use const Bifrost\Player\PLUGIN_DIR;

/**
* Registers interactive blocks and the playlist track render filter.
*/
final class BlockServiceProvider extends ServiceProvider implements Bootable
{
/**
* Boots the block service provider.
*/
public function boot(): void
{
add_action('init', $this->registerBlocks(...));
add_action('wp_footer', $this->renderPlayer(...));

$this->container->get(RenderPlayButton::class)->boot();
$this->container->get(RenderPlaylist::class)->boot();
$this->container->get(RenderPlaylistTrack::class)->boot();
}

/**
* Registers the plugin's interactive blocks.
*/
private function registerBlocks(): void
{
register_block_type(PLUGIN_DIR . '/build/blocks/audio-player');
}

/**
* Renders the persistent audio player in the footer.
*/
private function renderPlayer(): void
{
echo do_blocks('<!-- wp:bifrost-player/audio-player /-->');
}
}
Loading
Loading