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
- plugins/riff/templates/player.php, line 160
<audio class="riff-audio" preload="metadata"></audio> - plugins/riff/assets/player/media.js, line 61
const el = ( t = P.track() ) => - plugins/riff/assets/player/playback.js, line 167
m.play().catch( P.paint );
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
- plugins/riff/assets/player/paint.js, line 170
function buffered( m, duration ) { - plugins/riff/assets/player/paint.js, line 171
const ranges = m.buffered;
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
- plugins/riff/assets/player/wake-lock.js, line 18
lock = await navigator.wakeLock.request( 'screen' );
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
- plugins/riff/assets/player/playback.js, line 87
navigator.vibrate?.( 8 );
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
- plugins/riff/assets/player/state.js, line 25
if ( navigator.connection?.saveData ) {
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
- plugins/riff/assets/player/session.js, line 9
navigator.mediaSession.metadata = new window.MediaMetadata( { - plugins/riff/assets/player/session.js, line 75
navigator.mediaSession.setActionHandler( action, handler ); - plugins/riff/assets/player/session.js, line 35
navigator.mediaSession.setPositionState( {
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
- plugins/riff/assets/player/cast.js, line 19
audio.remote.watchAvailability( available ).catch( () => {} ); - plugins/riff/assets/player/cast.js, line 26
m.remote.prompt().catch( () => {} );
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
- plugins/riff/assets/player/meter.js, line 44
const ctx = new AudioContext(); - plugins/riff/assets/player/meter.js, line 45
const src = ctx.createMediaElementSource( audio );
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
- plugins/riff/assets/player/meter.js, line 46
analyser = ctx.createAnalyser(); - plugins/riff/assets/player/meter.js, line 86
analyser.getByteFrequencyData( bins );
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
- plugins/riff/assets/player/playback.js, line 175
document.addEventListener( 'visibilitychange', () => {
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
- plugins/riff/assets/player/helpers.js, line 165
? `<img src="${ esc( art ) }" alt="" loading="lazy" decoding="async">` - plugins/riff/features/listen/class-ui.php, line 193
$load = ' loading="lazy"';
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
- plugins/riff/templates/player.php, line 82
<img class="riff-cover" data-riff-art data-riff-size="large" alt="" decoding="async" draggable="false" hidden> - plugins/riff/assets/player/helpers.js, line 62
img.decoding = 'async';
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
- plugins/riff/assets/player/gestures.js, line 45
function drag( zone, { move, end } ) { - plugins/riff/assets/player/gestures.js, line 47
zone.addEventListener( 'pointerdown', ( e ) => {
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
- plugins/riff/assets/player/media.js, line 119
glides.push( node.animate( frames, { ...timing, ...extra } ) );
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
- plugins/riff/assets/player/meter.js, line 139
meterRaf = requestAnimationFrame( tick );
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
- plugins/riff/assets/player/share.js, line 23
await navigator.share( {
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
- plugins/riff/assets/player/share.js, line 36
await navigator.clipboard.writeText( url );
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
- plugins/riff/assets/player/now-playing.js, line 191
node.inert = true; - plugins/riff/assets/player/now-playing.js, line 171
node.inert = false;
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
- plugins/riff/assets/player/state.js, line 46
window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches, - plugins/riff/assets/player/playback.js, line 86
if ( window.matchMedia( '(hover: none)' ).matches ) {
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
- plugins/riff/assets/player/registry.js, line 631
?.scrollIntoView( { block: 'nearest' } ), - plugins/riff/features/listen/app/views.js, line 131
app.scrollIntoView( { block: 'start' } );
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
- plugins/riff/src/class-player.php, line 250
wp_enqueue_script_module( - plugins/riff/features/listen/app.js, line 33
export function start( api ) {
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
- plugins/riff/assets/riff.js, line 711
import( data.assets.app )
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
- plugins/riff/assets/player/now-playing.js, line 114
history.pushState( { ...history.state, riff: 'open' }, '' ); - plugins/riff/assets/player/now-playing.js, line 252
window.addEventListener( 'popstate', () => { - plugins/riff/features/listen/app/views.js, line 117
{ ...history.state, riffApp: id },
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
- plugins/riff/assets/player/resume.js, line 84
const saved = parse( read( sessionStorage, P.STATE ) ); - plugins/riff/assets/player/seek.js, line 119
write( localStorage, P.VOLUME, String( audio.volume ) );
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
- plugins/riff/features/listen/app/fetch.js, line 84
fetch( href, { headers: { Accept: 'text/html' } } ) - plugins/riff/features/listen/app/views.js, line 45
to.browse ? to.href : ctx.addressOf( id, upto )
Specification: Fetch Standard. Reference: Fetch on MDN.
Navigation Timing
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
- plugins/riff/assets/player/resume.js, line 110
const arrival = performance.getEntriesByType( 'navigation' )[ 0 ]?.type;
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
- plugins/riff/assets/player/resume.js, line 80
window.addEventListener( 'pagehide', save );
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
- plugins/riff/assets/player/media.js, line 365
window.addEventListener( 'pageshow', ( e ) => {
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
- plugins/riff/templates/player.php, line 62
<div class="riff-np" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e( 'Now Playing', 'riff' ); ?>" hidden> - plugins/riff/assets/player/now-playing.js, line 96
active && active !== document.body - plugins/riff/assets/player/keyboard.js, line 64
if ( e.key === 'Escape' && ! np.hidden ) {
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
- plugins/riff/assets/riff.css, line 307
@property --riff-quality-ink { - plugins/riff/assets/riff.css, line 344
--riff-quality-ink 0.4s ease,
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
- plugins/riff/assets/riff.css, line 119
background: color-mix(in srgb, currentcolor 8%, transparent); - plugins/riff/assets/riff.css, line 1065
background: linear-gradient(var(--riff-tint), color-mix(in srgb, var(--riff-tint) 70%, #000));
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
- plugins/riff/assets/riff.css, line 1232
container: riff-stage / size; - plugins/riff/assets/riff.css, line 1243
width: min(100cqw, 100cqh);
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
- plugins/riff/assets/riff.css, line 580
.riff-bar-seek:has(> .riff-seek:focus-visible), - plugins/riff/assets/riff.css, line 605
html:has(#riff[data-live]) [data-playing="true"] .riff-row-state i { - plugins/riff/features/listen/app.css, line 701
.riff-app .riff-card:has(> .riff-card-link:focus-visible) {
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
- plugins/riff/assets/riff.css, line 38
--riff-safe-b: env(safe-area-inset-bottom, 0px); - plugins/riff/assets/riff.css, line 620
height: calc(var(--riff-bar-h) + var(--riff-safe-b));
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
- plugins/riff/assets/riff.css, line 3502
@media (horizontal-viewport-segments: 2) { - plugins/riff/assets/riff.css, line 3510
grid-template-columns: env(viewport-segment-width 0 0) env(viewport-segment-width 1 0);
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
- plugins/riff/features/listen/app.css, line 1390
text-wrap: balance;
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
- plugins/riff/assets/riff.css, line 16
@font-face { - plugins/riff/assets/riff.css, line 19
font-weight: 100 900;
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
- plugins/riff/assets/riff.css, line 352
@media (prefers-reduced-motion: reduce) { - plugins/riff/assets/player/state.js, line 45
reduce: () => - plugins/riff/features/listen/app/views.js, line 27
window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
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
- plugins/riff/assets/riff.css, line 1176
clip-path: inset(50%); - plugins/riff/assets/riff.css, line 2767
clip-path: none;
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
- plugins/riff/assets/riff.css, line 1919
aspect-ratio: 16 / 9; - plugins/riff/features/listen/app.css, line 636
aspect-ratio: 1;
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
- plugins/riff/assets/riff.css, line 673
touch-action: pan-y; - plugins/riff/assets/riff.css, line 1130
touch-action: none; - plugins/riff/assets/player/gestures.js, line 47
zone.addEventListener( 'pointerdown', ( e ) => {
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
- plugins/riff/assets/riff.css, line 2137
overscroll-behavior: contain;
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
- plugins/riff/assets/riff.css, line 440
#riff button:focus-visible, - plugins/riff/assets/riff.css, line 122
.riff-row:focus-visible { - plugins/riff/features/listen/app.css, line 701
.riff-app .riff-card:has(> .riff-card-link:focus-visible) {
Specification: Selectors Level 4. Reference: :focus-visible on MDN.