Media player
@nextgensoftwares/folio-plugin-player replaces the browser's native <video> / <audio> controls with Folio's own players: themed with the --folio-* tokens (light, dark, system and document colour themes), keyboard-first and accessible, and sized by layout only. working
import { composePlugins } from '@nextgensoftwares/folio-editor';
import { indexedDbUploader, mediaPlugin } from '@nextgensoftwares/folio-plugin-media';
import { playerPlugin } from '@nextgensoftwares/folio-plugin-player';
const media = mediaPlugin({ uploader: indexedDbUploader({ dbName: 'my-app-media' }) });
const composed = composePlugins(standardSchema, [
media,
playerPlugin({ media }), // AFTER media: its painters replace media:video / media:audio
]);Passing media lets the player reuse its resolveSrc (so idb: and other uploader srcs play) and hand upload placeholders back to plugin-media's painter (progress, cancel, retry keep working).
Bring your own player
Video and audio are drawn by painters keyed media:video and media:audio (see painter lookup). composePlugins merges painters by key and the last plugin wins, so any plugin (or the host, as the last plugin) listed after mediaPlugin / playerPlugin replaces them:
import type { FolioPlugin } from '@nextgensoftwares/folio-editor';
import { useResolvedSrc, type PainterProps } from '@nextgensoftwares/folio-plugin-media';
function MyVideo({ fragment }: PainterProps) {
const a = fragment.kind === 'media' ? fragment.attrs : {};
const src = useResolvedSrc(a.src, media.resolveSrc);
// Fill the laid-out box exactly; never change its size.
return (
<div style={{ position: 'absolute', left: fragment.x, top: fragment.y, width: fragment.width, height: fragment.height }}
onMouseDown={(e) => e.stopPropagation() /* keep presses away from the caret */}>
<my-player src={src} style={{ width: '100%', height: '100%' }} />
</div>
);
}
const myVideo: FolioPlugin = { name: 'my-video', painters: { 'media:video': MyVideo } };
composePlugins(schema, [media, myVideo]);Bring your own audio player
Same contract, different key. Players may stay narrower than the box but must never grow it; layout gives audio a 54 px bar unless the node has a height.
const myAudio: FolioPlugin = { name: 'my-audio', painters: { 'media:audio': MyAudioBar } };
composePlugins(schema, [media, playerPlugin({ media }), myAudio]); // Folio video, your audioplayerPlugin({ types: ['video'] }) paints only one type. Print/PDF/DOCX cards are overridable the same way (exporters.pdf['media:audio']).
Variants
Pick per node with the controls attr (or the Playback menu), or set the defaults with playerPlugin({ video, audio }).
| Video | Audio | |
|---|---|---|
minimal | play + an edge scrubber | play button + time (pill) |
standard (default) | floating bar: play, time, scrubber, volume, captions, fullscreen | compact bar: play, scrubber (waveform), time, speed, volume |
full | + skip ±10 s, speed (0.5–2×), loop, picture-in-picture | card: cover art, title/artist, waveform scrubber with chapter markers, skip −10/+30 s, speed, loop, volume, transcript |
Video also has a poster with a big centred play button (and duration), a loading spinner, an error state with Retry, hover time preview (with the chapter title) and buffered ranges on the scrubber, and controls that hide after autoHideMs (2.5 s) of inactivity while playing.
The full audio card needs a taller box: choosing Full controls sets the node's height (140 px, or 300 px with a transcript); other variants reset it to auto. Parts that don't fit the box (transcript, cover) are left out by container queries, so the laid-out size is never changed.
Keyboard and accessibility
Focus a player (click or Tab) and:
| Key | Action |
|---|---|
| Space, K | play / pause |
| J / L | −10 s / +10 s |
| ← / → | −5 s / +5 s (on the volume slider: volume) |
| ↑ / ↓ | volume (on the seek slider: ±5 s) |
| M | mute |
| F | fullscreen (video) |
| C | captions on/off (video), transcript (audio card) |
| 0–9 | seek to 0–90 % |
| Home / End | start / end |
| < / > | slower / faster |
| Esc | back to the document text |
| Backspace / Delete, Ctrl/Cmd+Z | handed to the editor (delete / undo the selected media) |
The player is a labelled region; the scrubber and volume are role="slider" with spoken aria-valuetext ("1 minute 5 seconds of 3 minutes"); menus are menu / menuitemradio; a polite live region announces seeks, volume, speed and caption changes. Focus rings use --folio-focus (:focus-visible only); prefers-reduced-motion turns transitions and the spinner animation off.
Editor behaviour
- In edit mode a press anywhere on a player selects the node (resize handles, bubble bar) and never reaches the page's caret/selection logic; the controls still work. Read-only views are fully interactive.
- Virtualized pages unmount off-screen players: they pause and release the decoder and network (
srcremoved). StrictMode-safe. exclusive(default true): starting one player pauses the other, video and audio alike.rememberPosition: resume each media (bymediaId, elsesrc) where it was left, insessionStorage.autoplaystarts muted only, when at least half visible, until the user takes over.
Attributes
Added to resizableMedia through the plugin's schema extension (the core schema is unchanged; poster, duration, name come from plugin-media):
| Attr | Type | |
|---|---|---|
controls | 'minimal' | 'standard' | 'full' | null | variant (null = plugin default) |
tracks | MediaTrack[] | null | WebVTT captions/subtitles: { src, label?, srclang?, kind?, default? }, ≤ 16 |
chapters | string | null | WebVTT chapters file: scrubber markers and titles |
autoplay | boolean | muted autoplay |
loop | boolean | |
startAt | number | null | seconds |
artist | string | null | audio card |
Edit them from the context menu or bubble bar: one Playback item (group media.playback, defaultSurfaces: ['contextMenu', 'bubble']; route it elsewhere with ui.route: { 'media.playback': [...] }) with controls, loop, autoplay, start time, poster/cover, captions, chapters and title/artist.
Theming
Only --folio-* tokens are used: the accent (--folio-accent) draws progress, the video bar and menus use the UI surface tokens (--folio-bg, --folio-border, --folio-pop-shadow, --folio-radius) so they match the toolbar and popovers, and the audio bar/card takes the page ink (currentColor), so document colour themes recolour it. Video and cover art go through --folio-media-filter. Corners follow the node's borderRadius.
Player-specific overrides: --folio-player-accent, --folio-player-surface, --folio-player-backdrop (video letterbox), --folio-player-caption-bg, --folio-player-caption-fg. Icons: playerPlugin({ icons: { play: <MyIcon/>, … } }) (see PlayerIconName). The stylesheet is injected once (<style id="folio-player-css">); hosts with a strict CSP can ship playerCss themselves.
Security
- Media, poster and caption srcs pass plugin-media's safe-URL rules (http(s), blob,
idb:viaresolveSrc, relative); captions may also be inlinedata:text/vtt. Nothing renders in an iframe. - Captions/chapters are fetched only from safe resolved URLs, same-origin credentials only, capped at 1 MB, and parsed as text (tags stripped, entities decoded, never injected as HTML).
- Waveforms decode at most 16 MB of audio, lazily (when near the viewport); failures fall back to a plain bar.
Export
PDF and DOCX keep plugin-media's card (PrintFallback); the player's playerPrintFallback adds the poster (video) or cover art (audio) as the card image and the artist on the detail line.