Built on the open web

Every browser API and CSS feature the player uses, how and why it uses each, and what happens without it. The lines quoted open at the commit this page was published from.

Media

HTMLMediaElement

One audio and one video element for the whole page, which every control drives.

How Riff uses it

The template prints one <audio> and one <video> for the page, and the script picks which one a track plays through. Loading a track sets src and currentTime on that element and calls play(). Switching between Song and Video hands the current time and paused state from one element to the other. The core blocks' own players are paused and hidden underneath.

Why

The whole player is a UI over these two elements. Sharing one pair across the page is what lets a track keep playing while rows, views and Now Playing change around it.

Without it

Each block keeps its native player underneath, so a page where the script never runs still plays.

Where

Specification: HTML Standard. Reference: HTMLMediaElement on MDN.

TimeRanges

Shows how much of the file is loaded past the playhead.

How Riff uses it

On every time update the script reads the live element's buffered ranges, finds the one that contains the playhead, and turns its end into a 0 to 1 ratio. That ratio goes into a custom property on both seek bars, and the stylesheet scales a lighter band under the played part of the line to match.

Why

The lighter band tells the listener a seek into loaded territory will be instant.

Without it

With no matching range the function returns 0 and the band is empty.

Where

Specification: HTML Standard. Reference: TimeRanges on MDN.

Screen Wake Lock

Keeps the screen on while a video plays in Now Playing, and only then.

How Riff uses it

One function computes a single condition: a video is playing, Now Playing is open, and the page is visible. When that becomes true it requests a screen lock. When it becomes false it releases it. It runs from the video's play, pause and ended events, the player's open and close events, and visibility changes.

Why

A phone would otherwise dim and lock partway through a video.

Without it

The call is guarded by navigator.wakeLock and a try/catch, so the screen follows the system timeout.

Where

Specification: Screen Wake Lock API. Reference: Screen Wake Lock on MDN.

Vibration

A short tick under the thumb on play and pause.

How Riff uses it

Every play or pause, from a tap, a key or a row, first checks the (hover: none) media query to tell a touch screen from a mouse. On a touch screen it fires an 8ms vibration.

Why

A haptic tick confirms the tap landed. Without it the only confirmation is the icon change.

Without it

The call is optional, so a browser without it skips the tick.

Where

Specification: Vibration API. Reference: Vibration on MDN.

Network Information

Respects Data Saver by fetching nothing before a press.

How Riff uses it

At startup the script reads navigator.connection.saveData. When Data Saver is on, it sets preload to none on both media elements, overriding the template's metadata.

Why

On a metered connection the player should not download even a track's metadata before the listener presses play.

Without it

The property is optionally chained, so browsers without it keep the template's preload.

Where

Specification: Network Information API (WICG draft). Reference: Network Information on MDN.

Media Session

Gives the lock screen and headphones the track's title, artwork and controls.

How Riff uses it

Each new track builds a MediaMetadata with title, artist, album and every artwork size. Eight action handlers map the system's play, pause, previous, next, seek and stop onto the player's own functions. The playing state and position are mirrored, position at most once a second.

Why

The lock screen, headphones and the system media widget would otherwise show a blank page title and could not skip tracks.

Without it

Guarded by feature checks and a try/catch per action, so the on-page controls are the only controls.

Where

Specification: Media Session. Reference: Media Session on MDN.

Remote Playback

Shows the cast button only when a Chromecast or AirPlay target is in reach.

How Riff uses it

The script watches availability once, and the callback shows or hides every cast button as a device comes in and out of range. Safari takes the equivalent WebKit path for AirPlay. Pressing the button prompts the browser's device picker on whichever element is live.

Why

There is no other standard way to know a device is near, so the buttons would either always show and do nothing, or never show.

Without it

Both calls are optionally chained, so a browser with neither keeps the buttons hidden.

Where

Specification: Remote Playback API. Reference: Remote Playback on MDN.

Web Audio

Reads the signal that moves the bars on the playing row.

How Riff uses it

The first time something plays through the audio element on a non-iOS browser, the script creates an AudioContext. It wraps the element in a media source and connects it through an analyser and back to the destination, so the sound still comes out. The context suspends on pause and resumes on play.

Why

This is what moves the three bars on the playing row, and what window.riff.analyser() hands a plugin such as Riff Meters. On iOS the graph breaks lock-screen and background playback, so iPhones are left out.

Without it

Where there is no analyser the bars keep a fixed keyframe loop instead.

Where

Specification: Web Audio API. Reference: Web Audio on MDN.

AnalyserNode

Turns the signal into three band levels, every frame.

How Riff uses it

The analyser uses a 1024-sample window with 0.6 smoothing. Each frame its frequency bins are summed into three bands, bass, voice and air, and each band is read against its own recent range. Those three values scale the three bars on the playing row.

Why

Without it there is no signal to read, so the bars could not follow the music.

Without it

Same as Web Audio: the bars keep a fixed loop.

Where

Specification: Web Audio API. Reference: AnalyserNode on MDN.

Page Visibility

Moves a playing video to audio when the page goes to the background.

How Riff uses it

When the page is hidden while a video is playing, the handler flips the track to the audio element and remembers it did. When the page is visible again it flips back. The same event restarts the per-frame seek follower, and the loops check document.hidden so they stop in the background.

Why

A phone pauses video in the background but keeps audio going, so backgrounding a tab during a video would otherwise stop the music.

Where

Specification: HTML Standard. Reference: Page Visibility on MDN.

Lazy loading

Fetches list thumbnails only as they scroll into view.

How Riff uses it

Every thumbnail in a list is marked loading="lazy": the art on each track row, each item in Up next, and each record cover in the library grid. The cover of the current track is not lazy, and neither are the first row of covers on Home and the cover at the head of an album, which the page is painted around; the first of them is fetched before anything else.

Why

A post or library can have dozens of covers, and the browser should not fetch them all before the listener scrolls.

Without it

A browser without the attribute loads the images eagerly.

Where

Specification: HTML Standard. Reference: Lazy loading on MDN.

Async decoding

Decodes cover art off the main thread so the seek line does not stutter.

How Riff uses it

Every image the player prints carries decoding="async": the large cover and the two thumbnails in the template, each row and queue item, and each library card. The off-screen image loaded to sample a cover's colour sets it too.

Why

The cover swaps on every track change while the seek line is animating each frame, and a synchronous decode would drop a frame.

Without it

Browsers without the hint decode as usual.

Where

Specification: HTML Standard. Reference: Async decoding on MDN.

Interface

Pointer Events

One gesture helper for swiping the bar and dragging the sheets.

How Riff uses it

A single drag() helper records the pointer's start, timestamp and id on pointerdown. It reports deltas on pointermove for that pointer, and calls an end handler on pointerup or pointercancel with distance and velocity. It skips mouse pointers. It drives swiping the bar to skip or dismiss, dragging Now Playing down to close, and dragging the Up next handle.

Why

Pointer events give one code path for touch and pen with a pointerType to exclude the mouse.

Where

Specification: Pointer Events. Reference: Pointer Events on MDN.

Web Animations

Glides the seek line on the GPU.

How Riff uses it

While a track plays, the seek line's played part and its knob each run one linear element.animate() from where the time is to the end of the track, at the playback rate. They are aimed again when the time jumps (a seek, a pause, a stall, a new rate or track, or drift past 150 ms).

Why

Transform animations run on the compositor, so the line glides even while the page's own script is busy, and nothing is written each frame.

Without it

Without the API the line rests where timeupdate puts it, four times a second.

Where

Specification: Web Animations. Reference: Web Animations on MDN.

requestAnimationFrame

Moves the playing row's bars with the music every frame.

How Riff uses it

While music plays and the playing row is in sight, one frame loop reads the analyser's three bands with the frame timestamp and scales the bars, writing each only when it moves by a step the eye could see. Opening Now Playing also waits one frame before adding its class, so the slide-up transition actually runs.

Why

The bars have to follow the music as it happens; the seek line itself moves by Web Animations instead.

Where

Specification: HTML Standard. Reference: requestAnimationFrame on MDN.

Web Share

Opens the system share sheet with the track's title and the post link.

How Riff uses it

The Share chip in Now Playing calls navigator.share() with the track title and the post's URL. A rejected promise from dismissing the sheet is swallowed.

Why

On a phone the system sheet is how people send a link to Messages or another app.

Without it

Falls through to the clipboard and a Copied toast.

Where

Specification: Web Share API. Reference: Web Share on MDN.

Clipboard

Copies the link where there is no share sheet.

How Riff uses it

When the browser has no share sheet, the Share chip writes the post's URL to the clipboard and shows a Copied message in the status toast for 2.5 seconds. Any error is caught and nothing is reported.

Why

Desktop browsers mostly lack navigator.share, so copying is the only thing the chip can do there.

Without it

A failing write is caught and the toast does not appear.

Where

Specification: Clipboard API and events. Reference: Clipboard on MDN.

inert

Makes Now Playing a real modal: nothing behind it can take focus.

How Riff uses it

Opening Now Playing sets inert on every direct child of <body> except the player, and on a phone also on the bar and the toast, which the dialog covers. Each node touched is marked so closing restores only those. On a wide screen the bar stays live because the dialog opens above it.

Why

Without it the page behind a role="dialog" would still take keyboard and screen reader focus.

Without it

The page stays focusable behind the dialog.

Where

Specification: HTML Standard. Reference: inert on MDN.

matchMedia

Reads the same breakpoint and motion preference the stylesheet uses.

How Riff uses it

Two queries are read from script. prefers-reduced-motion skips the frame loops and the close transition. (hover: none) stands in for a touch screen before vibrating. Where the layout's own line falls (whether the bar stays live beside Now Playing, and whether a panel such as Up next stands beside the page, covers it or opens in a turntable's strip) is not asked again in script: the stylesheet's media queries set a custom property, --riff-np-covers and --riff-side-place, which the script reads, so the line is written once.

Why

The phone and desktop layouts differ in ways the script has to know about, and reading the stylesheet's answer keeps the two from drifting apart.

Where

Specification: CSSOM View Module. Reference: matchMedia on MDN.

scrollIntoView

Brings the playing track into view when Up next opens.

How Riff uses it

When the Up next panel opens, the current track's item in the list is scrolled into view with block: nearest. In the Listen app, after a view swap, the app scrolls to its top only if it had scrolled off above the viewport.

Why

A long queue would otherwise open at its top with the playing track somewhere below.

Where

Specification: CSSOM View Module. Reference: scrollIntoView on MDN.

Platform

ES modules

The shipped file is the source, loaded as a module with no build step.

How Riff uses it

The player is enqueued through WordPress's wp_enqueue_script_module(), so it loads as <script type="module">, deferred by default. The Listen app is a separate module with a named export that the player imports on demand. There is no bundler.

Why

Modules give deferred loading, strict mode and a real import for the optional app without a build step.

Without it

A browser without modules skips the script, and the native audio blocks still play.

Where

Specification: ECMAScript Language Specification. Reference: ES modules on MDN.

Dynamic import

Loads the Listen app only on the page that holds the block.

How Riff uses it

After the player is ready it checks for the Listen block and a module URL the feature put into the page data. Only then does it import() the app and call its start function with the public API. Posts without the block never download it.

Why

A static import would load the app on every post with audio.

Without it

A failed import leaves the server-rendered links as real links, so the library works as plain page loads.

Where

Specification: ECMAScript Language Specification. Reference: Dynamic import on MDN.

History

Back closes Now Playing, and the Listen app navigates without a page load.

How Riff uses it

Opening Now Playing pushes a history entry without changing the address, so Back closes the dialog. Closing it any other way pops that entry. The Listen app pushes an entry with the new address for each view it swaps in and re-renders on popstate, spreading the existing state so the Interactivity API's session id survives.

Why

A lost session id would make the Interactivity runtime reload the page on Back and end the music.

Where

Specification: HTML Standard. Reference: History on MDN.

Web Storage

Carries the queue and position to the next page, and remembers the volume.

How Riff uses it

The queue, order, position, mode, repeat, shuffle, current time and playing flag are written to sessionStorage on every track load, every five seconds, and on pagehide. The next page in the same tab reads it back and reloads the same track at the same time. The volume goes to localStorage so it survives across tabs.

Why

This is what lets a link to another page keep playing and a reload pick up paused where it left off.

Without it

Every read and write is wrapped in try/catch, so in private mode or over quota the player works without remembering.

Where

Specification: HTML Standard. Reference: Web Storage on MDN.

Fetch

Swaps library views in from the REST route without a page load.

How Riff uses it

The Listen app fetches the library and each post from the plugin's REST route with a JSON Accept header, and memoises each promise so going back is instant. A non-2xx response throws and the entry is dropped from the cache.

Why

Swapping views without a page load is what keeps the music playing, and the cache is why Back feels instant.

Without it

A failed fetch sends the browser to the real URL with a full navigation.

Where

Specification: Fetch Standard. Reference: Fetch on MDN.

Tells a reload from a link, so a reload never autoplays.

How Riff uses it

When restoring the saved state, the script reads the type of the navigation entry. After a reload the saved track loads at its position but stays paused. After a link, a track that was playing resumes.

Why

The click that follows a link is the gesture the new page needs to play with sound. A reload has no such gesture and would be blocked or surprise the listener.

Without it

The entry is optionally chained, so without one the saved playing flag decides alone.

Where

Specification: Navigation Timing Level 2. Reference: Navigation Timing on MDN.

pagehide

Captures the exact position when the listener leaves the page.

How Riff uses it

The save function that writes the queue and position to sessionStorage is bound to pagehide. So the last currentTime is captured when the listener follows a link, closes the tab, or the page enters the back-forward cache.

Why

Without it the restored position could be up to five seconds stale. pagehide fires reliably where unload does not, including on mobile.

Where

Specification: HTML Standard. Reference: pagehide on MDN.

pageshow

Restarts the frame loop after a Back from the back-forward cache.

How Riff uses it

When a page is restored from the back-forward cache, pageshow fires with persisted set. The handler repaints the seek line and, if the media element is still playing, restarts the per-frame loop the browser stopped when it froze the page.

Why

The audio element kept its state in the cache but the animation loop did not, so the music would be playing while the seek line sat still.

Without it

persisted is checked, so a normal load does nothing.

Where

Specification: HTML Standard. Reference: pageshow on MDN.

ARIA dialog

Now Playing is a named modal dialog with Escape and focus return.

How Riff uses it

The Now Playing container is printed as role="dialog" with aria-modal and a translated label. The buttons that open it declare aria-haspopup="dialog". The script completes the pattern. Opening remembers the focused element and moves focus to Close. Escape closes from anywhere, the rest of the page goes inert, and closing returns focus to the opener.

Why

Screen readers announce Now Playing as a modal dialog. Without the role the full-screen view would read as part of the post.

Where

Specification: WAI-ARIA 1.2. Reference: ARIA dialog on MDN.

Styling

@property

Registers the quality pill's colour as a colour, so a change of tier fades to the new one.

How Riff uses it

--riff-quality-ink is registered as a <color> and --riff-quality-lift as a <percentage>, so the browser can interpolate them: when the tier changes, the words and the light under them move to the new colour over 0.4 seconds, and the light under a pointer rises, rather than switching at once.

Why

An unregistered custom property is a string to the browser, which can't transition it.

Without it

Unregistered, the colour and the light change at once.

Where

Specification: CSS Properties and Values API Level 1. Reference: @property on MDN.

color-mix()

Tints rows, pills and the seek line from the theme's own text colour.

How Riff uses it

Track rows in a post have no colours of their own. The hover and current-row tint, the artwork placeholder, the buffered band and the quality pill are all currentcolor mixed with transparent at a low percentage. They sit right on a light or a dark theme. Now Playing darkens the cover's tint by mixing in black for the bottom of its gradient.

Why

A fixed rgba() tint would vanish on one theme and look heavy on another, and the Now Playing tint is only known at runtime from the artwork.

Without it

Rows get no hover tint, and Now Playing shows its plain background.

Where

Specification: CSS Color Module Level 5. Reference: color-mix() on MDN.

Container queries

Sizes the cover from the stage it sits in, not the viewport.

How Riff uses it

The stage in Now Playing, between the header and the title, is a size container named riff-stage. The cover is the smaller of the stage's width and height in cqw and cqh, so it is always a square that fits whatever room is left. The video fills the stage's width and caps at its height. On a stage wider than 3:2, an @container rule sizes the sleeve and the record to sit side by side, as large as the stage allows.

Why

The stage's height changes with the phone, the safe areas and the Up next sheet, and the cover has to fit without the script measuring anything.

Without it

The cover renders at the image's own size, clipped by the stage.

Where

Specification: CSS Conditional Rules Module Level 5. Reference: Container queries on MDN.

:has()

Styles a parent from a child's state, without the script toggling classes.

How Riff uses it

The seek control is an invisible range input over the seek line, so its keyboard focus ring is drawn on the parent bar instead. When the script has a live audio reading it sets data-live on the player, and the equalizer bars on the playing row drop their fallback keyframes so only the live movement shows. In the Listen app, a record card draws its focus ring around the whole card when the link inside it is focused.

Why

Each case would otherwise mean the script toggling classes on every focus change and every analyser start and stop.

Without it

The seek bar has no visible keyboard focus ring, and the equalizer bars run their keyframes over the live reading.

Where

Specification: Selectors Level 4. Reference: :has() on MDN.

env() safe areas

Keeps the bar and Now Playing clear of the notch and the home indicator.

How Riff uses it

The two insets are read once into custom properties. The fixed bar grows by the bottom inset and pads its controls above it. Now Playing, which covers the whole screen, pads its header below the notch and its controls above the indicator. The toast and the spacer at the end of the page use the same value.

Why

A fixed bar on a phone with a home indicator would otherwise put the play button under the indicator.

Without it

The second argument gives 0px, so the bar is exactly its base height.

Where

Specification: CSS Environment Variables Module Level 1. Reference: env() safe areas on MDN.

Viewport segments

Keeps the turntable and the controls out of a folding phone's crease.

How Riff uses it

Where the browser reports a fold, the horizontal-viewport-segments and vertical-viewport-segments media queries say whether it runs down the screen or across it, and the viewport-segment-width, viewport-segment-height and edge variables give each segment's size. Folded like a book, Now Playing's grid takes its two columns from the two segments, the deck on the left page and the controls on the right, with the crease as the gap between them; stood like a tent, it takes two rows, the sleeve and the turntable above and the controls below.

Why

An iPhone Duo can be half open, and anything centred on the whole screen lands in the fold. The browser knows where the fold is; the layout follows it rather than guessing from the screen's size.

Without it

Without it, as in Safari until its flag is on, the unfolded layout holds: the screen is one turntable, crease and all.

Where

Specification: CSS Environment Variables Module Level 1. Reference: Viewport segments on MDN.

text-wrap

Balances a long title in the Listen app so no word is left alone.

How Riff uses it

When the Listen app opens a post, its title is set large beside the Play and Shuffle pills. If it wraps, balance splits it into lines of roughly equal length instead of a long first line and one word on the second.

Why

A heading that size orphans a single word easily, and the wrap point moves with the viewport.

Without it

The title wraps normally.

Where

Specification: CSS Text Module Level 4. Reference: text-wrap on MDN.

Variable fonts

Ships each font as one file covering every weight the player asks for.

How Riff uses it

Each bundled font is a single variable woff2 with a weight range in its @font-face. Roboto covers 100 to 900 for the player, Inter and EB Garamond 400 to 700 for the Listen app. The stylesheet asks for four weights, and all of them come from the one file.

Why

The fonts ship with the plugin rather than from Google, so a listener's address never leaves the site, and one file per family keeps that to three requests.

Without it

The stacks name the system fonts next, and font-display: swap shows them until the file arrives.

Where

Specification: CSS Fonts Module Level 4. Reference: Variable fonts on MDN.

Behaviour

prefers-reduced-motion

Stops the equalizer and the sliding sheets when the system asks.

How Riff uses it

In CSS the query removes every transition on the player and stops the equalizer bars and the buffering pulse. In the script, the audio meter does not run. Now Playing closes without waiting for its transition, and the Listen app swaps views without a view transition.

Why

A music player is continuous motion by nature: bars, a pulsing button and sliding sheets, which is what the setting asks to stop.

Where

Specification: Media Queries Level 5. Reference: prefers-reduced-motion on MDN.

clip-path

Hides the Song and Video labels on a phone, but not from screen readers.

How Riff uses it

The Song and Video switch shows two icons on a phone. Its text labels stay in the markup for screen readers but are clipped to nothing with inset(50%). At 900px and up the clip is lifted, the labels show as words and the icons are hidden.

Why

Two icons on a phone and two words on a wide screen, from one set of markup, with an accessible name in both.

Without it

The 1px size and overflow: hidden in the same rule hide the text anyway.

Where

Specification: CSS Masking Module Level 1. Reference: clip-path on MDN.

aspect-ratio

Reserves the video's box and each record cover's square before anything loads.

How Riff uses it

The single video element in Now Playing reserves a 16:9 black box at the stage's width before any frame has loaded, so switching to Video does not resize the stage. In the library each record cover is a square box with a placeholder background, so the grid lays out at its final size before the images arrive.

Why

Without a declared ratio the video would sit at the browser's 300 by 150 default until metadata loads, and the grid would reflow as each cover came in.

Without it

The video takes its default box until it has dimensions, and the cards take the height of their image.

Where

Specification: CSS Box Sizing Module Level 4. Reference: aspect-ratio on MDN.

touch-action

Claims the swipes and drags so the browser does not scroll instead.

How Riff uses it

The bar's info area allows vertical page scrolling but claims horizontal movement, so the swipe-to-skip gesture gets its pointer events. Now Playing's header and stage take all touches for drag-down-to-close, and the Up next handle takes all touches for the sheet drag. On a wide screen there is no drag-to-close, so those go back to auto.

Why

The browser would otherwise scroll or zoom during the gesture and cancel the pointer events.

Without it

The gestures compete with page scrolling.

Where

Specification: Pointer Events. Reference: touch-action on MDN.

overscroll-behavior

Keeps the Up next list's scroll from moving the post behind it.

How Riff uses it

The Up next list scrolls inside its sheet. contain stops the scroll chaining to the page when the list hits its end, and stops a downward pull at the top from triggering pull-to-refresh while the overlay is open.

Why

Now Playing is a fixed overlay over a post that can itself scroll. A chained scroll would move the post underneath, or reload the page mid-track.

Without it

The scroll chains to the page behind as normal.

Where

Specification: CSS Overscroll Behavior Module Level 1. Reference: overscroll-behavior on MDN.

:focus-visible

Shows a focus ring for the keyboard and not after a tap.

How Riff uses it

Every button in the player is reset with all: unset, which removes the default outline. :focus-visible puts a 2px ring back only for keyboard focus. Track rows use currentcolor so the ring matches the theme, and the controls use the player's white. The Listen app uses the blue accent, with the ring drawn around the whole record card.

Why

A player is driven mostly by taps, and a ring left on the play button after each one would look like a bug.

Without it

Because buttons are reset, a browser without it shows no keyboard focus ring at all.

Where

Specification: Selectors Level 4. Reference: :focus-visible on MDN.