Appearance
Core player
approvedThe core is the baseline every player instance ships, regardless of configuration. Core elements are not capabilities: they cannot be absent from the build, only tuned per instance.
Scope of the core
Playback machinery, the cover, basic controls, gestures, keyboard shortcuts, and the state surfaces (buffering, errors). Everything else is a capability.
Rules
- Visible by default. Every core control defaults to on; configuration can switch each off per instance under the
controlsnamespace. - Off removes the function everywhere. Disabling a control removes its UI and all its input paths: buttons, hotkeys, gestures. A player configured without fullscreen cannot enter fullscreen by any means.
- Boolean or object. Every control key accepts
false/trueor an object: visibility sub-switches (the cover is the compound example) or documented behavior options (the timeline'sseekStepis the first). Sub-keys exist only where a real need is named in this document; adding one later is backward compatible. - Reveal, never invent. Controls only show or hide what
contentprovides. Absent content renders nothing, independent of control switches. - Chromeless is legal. Turning every control off yields a bare video surface driven by the page API and is a supported end state. There is no shorthand for it; each element is switched explicitly.
Control inventory
| Config key | Element | false removes |
|---|---|---|
cover | Pre-playback surface (compound, see below) | The whole cover; playback starts on the first frame |
playPause | Play and pause control | Button, space and k hotkeys, tap-to-toggle |
timeline | Seek slider | Slider, keyboard seeking, scrubbing, double-seek |
time | Time display (current and duration) | The display only |
volume | Volume and mute control | Control, m hotkey, volume keys |
fullscreen | Fullscreen control | Button, f hotkey, double-tap |
pip | Picture-in-picture control | Button, i hotkey, and auto-PiP eligibility |
buffering | Buffering indicator | Fluid 3rem to 5rem spinner |
inputFeedback | Visual confirmation of actions | Yellow play/pause feedback plus secondary indicators |
logo | LT branding element | The logo |
gestures | Master switch for the gesture layer | All gestures, including those of enabled functions |
hotkeys | Master switch for the keyboard layer | All shortcuts, including those of enabled functions |
Two inventory notes:
- Play/pause input feedback uses the native status-indicator lifecycle with yellow play and pause icons in place of its text labels.
- Double-click and double-tap seeking use the left and right thirds in both layouts. The center is neutral in portrait and on landscape touch, and remains reserved for landscape mouse fullscreen.
volumeadapts to the device, not the layout: a full volume control on desktop; on mobile devices it collapses to a mute toggle and volume belongs to the hardware buttons.fullscreenresolves from the same enabled default in both layouts. Portrait omits its fullscreen surface and silently ignores configuration that enables it (see the layouts document).pipfollows platform availability: where the browser offers no picture-in-picture, the control is absent regardless of configuration (see Picture-in-picture).
The cover
The cover is the pre-playback surface and the only compound control today.
| Sub-key | Element | Notes |
|---|---|---|
image | Cover image | URL from content.poster; absent, the first video frame stands in |
title | Title overlay | Controls the cover title; portrait playback identity uses content directly |
author | Author overlay | Same |
button | Play affordance | Plain icon by default; { "label": "..." } renders a labeled button |
Clicking anywhere on the cover starts playback; the button is presentation, not the only hit target. Identity on the Latest Thinking website typically runs title: false, author: true because the page owns the title.
Strings
The core owns these catalog entries. Content values such as the video title and author do not enter the string catalog.
| Key | English default | Placeholders | Appears in |
|---|---|---|---|
playerLabel | Media player | none | Player-root aria label |
logoLabel | Latest Thinking | none | Branding accessible label |
coverButton | Play | none | Cover affordance label |
play | Play | none | Play control aria label and tooltip |
pause | Pause | none | Pause control aria label and tooltip |
replay | Replay | none | Replay control aria label and tooltip |
mute | Mute | none | Mute control aria label and tooltip |
unmute | Unmute | none | Unmute control aria label and tooltip |
seek | Seek | none | Timeline aria label |
seekForward | Seek forward {seconds}s | seconds | Seek-forward label |
seekBackward | Seek backward {seconds}s | seconds | Seek-backward label |
enterFullscreen | Enter fullscreen | none | Fullscreen control label |
exitFullscreen | Exit fullscreen | none | Fullscreen control label |
enterPictureInPicture | Enter picture-in-picture | none | Picture-in-picture control label |
exitPictureInPicture | Exit picture-in-picture | none | Picture-in-picture control label |
currentTime | Current time | none | Current-time aria label |
duration | Duration | none | Duration aria label |
remainingTime | Remaining | none | Remaining-time aria label |
timePosition | {current} of {duration} | current, duration | Timeline value text |
volume | Volume | none | Volume-slider aria label |
volumeMutedValue | {percent}, muted | percent | Muted slider value text |
volumeFeedback | Volume {value} | value | Volume action announcement |
muted | Muted | none | Mute action announcement |
paused | Paused | none | Pause action announcement |
playing | Playing | none | Play action announcement |
fullscreenEntered | Fullscreen | none | Fullscreen announcement |
pictureInPictureEntered | Picture in picture | none | Picture-in-picture entry announcement |
pictureInPictureExited | Exited picture in picture | none | Picture-in-picture exit announcement |
seekedTo | Seeked to {time} | time | Seek announcement |
buffering | Buffering | none | Buffering status |
mediaErrorMessage | This video cannot be played right now. | none | Media-error surface |
retry | Retry | none | Media-error action |
configErrorMessage | This player cannot be loaded. | none | Configuration-error surface |
States
A player instance is always in exactly one of six states: idle, buffering, playing, paused, ended, error. The set is deliberately minimal: every state earns its place in all three lenses at once: it looks different (design), it is derived differently (implementation), and a hosting page can act on it (public contract). What has no distinct presentation and no distinct meaning for a page is not a state.
getState().playback reports the current state, and the page API events announce the changes. Engine flags never appear in the public contract.
The state set
| State | On screen (design) | A page reads it as (contract) |
|---|---|---|
idle | The cover element: cover image, identity, play affordance | Nothing consumed yet |
buffering | The buffering indicator, after 300 milliseconds | Playback is stalled or starting |
playing | The video, under the control visibility rules | The user is watching |
paused | The video paused, controls pinned visible | The user paused |
ended | The frozen last frame, play control becomes replay | The video completed |
error | The calm dark error surface | Playback or configuration failed |
Mapping from the engine
The media engine has no single state value: its store models playback as independent flags (paused, ended, started, waiting, canPlay, error). The LT states are named, derived views over those flags, and implementers map them exactly like this:
| LT state | Engine condition |
|---|---|
idle | !started: no playback has begun on this video |
buffering | waiting, including the span between the play gesture and the first frame; surfaced only after the 300 millisecond delay |
playing | started && !paused && !waiting && !ended |
paused | started && paused && !ended |
ended | ended (standalone only; the feed loops instead) |
error | error != null for media failures; the config error state is LT's own and has no engine flag |
There is deliberately no loading state. Initialization happens inside idle and is invisible by design (see Idle); the moment a page can act on, "the instance can play", is the ready event, not a state.
Transitions
The complete transition graph; nothing else is legal:
idletobuffering: the user starts playback (cover click, play control, page API).bufferingtoplaying: frames flow.playingtopausedand back: pause and resume, from any input.playingtobufferingand back: a mid-playback stall and its recovery.playingtoended: the video completes (standalone; the feed loops and never entersended).endedtobuffering: the replay affordance restarts from zero.- Any state to
error: a media failure, or a hard-invalid configuration. errortobuffering: Retry, on media failures only; config errors have no Retry.update()with a new video returns a standalone instance toidle(new cover); the shorts feed goes straight tobuffering, since it autoplays (and skips the intro, see the chapters and shorts documents).
Idle
The pre-playback state; what it shows is the cover element (see The cover). Without content.poster, the first video frame stands in as the cover image; identity and the play affordance render regardless.
Initialization is invisible inside idle, by design: from the first paint the player surface is a themed dark background, so nothing shifts when assets arrive. Identity and the cover button render immediately (they need no network), and the cover image streams in progressively over the dark background. There is no spinner and no layout jump, and this is why no loading state exists.
Buffering
One indicator serves both startup (centered over the still-visible cover, between the play gesture and the first frame) and mid-playback stalls (centered over the video). It appears only after 300 milliseconds of actual buffering, so brief stalls never flicker the indicator. During feed transitions the incoming video's poster covers this span (see the shorts document).
Playing and paused
Normal playback under the control visibility rules (see Control visibility). Paused pins the controls visible.
Ended
Standalone: the last frame stays on screen and the play control becomes a replay affordance; one activation restarts from the beginning. The shorts feed overrides this state entirely: feed videos loop (see the shorts document).
Error
- Unplayable at start (unreachable source, unsupported format): the surface shows a calm dark state with a short message and a Retry control. No technical details face the user.
- Mid-playback failure (network loss, stalled source): a stall becomes a failure after 10 continuous seconds of waiting without readiness or playback-position progress. Any readiness or position progress resets that timer; leaving waiting cancels it. An engine media error starts recovery immediately without waiting for stall promotion. The player presents only the buffering indicator while recovery runs. Automatic resume attempt one starts after 250 milliseconds; if it fails, attempt two starts after a further 500 milliseconds, and there is no third automatic attempt. An attempt that does not restore playback within 10 seconds counts as failed. Only when both fail does the message-and-Retry state appear. Retry resumes from the position where playback stalled, never from zero, and re-arms a fresh two-attempt automatic cycle.
- The message and the Retry label are string keys in the language catalogs.
- Every error fires a page API event so hosting pages can log or react.
- Configuration errors are a distinct case within the error state: a hard-invalid configuration at mount or update (defined in the config contract's validation section) renders the config error state: a black surface with a short, calm, generic message and no Retry control, since retrying cannot fix a configuration. The exact violation goes to the console for the developer. A rejected update keeps the instance's layout and replaces playback with this state; a mount whose
layoutis itself invalid renders the shell in landscape. The message is a string key in the language catalogs.
Control visibility
- Landscape hide delay: while playing, after 3 seconds without interaction, the control bar, the top actions, and (in fullscreen) the cursor fade out together. The configured logo remains visible.
- Revealing: on desktop, any mouse movement over the player; on touch, a tap on the video surface. The revealing tap only reveals, it never toggles playback; with controls visible, taps and clicks act normally.
- Paused: controls stay pinned visible.
- Hover and open menus: while the pointer hovers the control bar, or any player menu or dialog is open, the hide timer is suspended.
- Keyboard focus: while any control has keyboard focus, controls never hide. This is an accessibility requirement, not a preference.
- Portrait: never auto-hides anything: its persistent overlay (captions, identity, trigger rail, bottom bar) is the design.
Autoplay
Standalone instances have no autoplay: playback starts from a user gesture on the cover or the controls, and no configuration key exists to change that. The shorts feed's muted autostart is defined in the shorts document and is not per-instance configurable either.
Background playback and lock screen
The player integrates with the operating system's media controls through the Media Session API, so playing content appears as "now playing" on the iOS lock screen and control center, in the Android notification shade, and on desktop through hardware media keys and the system's now-playing surfaces.
Media Session is document-global, so player instances coordinate one owner:
The first live player provisionally owns the session. The most recent player to transition to playing takes ownership, and pausing retains ownership so the operating system's play action resumes the same video.
Only the owner publishes metadata, actions, playback state, and position state. An owner update refreshes that data immediately.
Destroying the owner transfers ownership to the most recently active remaining player, or to the first remaining provisional player. Cleanup from the old owner never clears data published by its successor.
Metadata mapping:
content.titlebecomes the title,content.authorthe artist line, andcontent.posterthe artwork.Actions: play, pause, seek backward, seek forward, and absolute seeking where the platform offers a scrubber. The seek step is 10 seconds by default, configurable:
json
{
"controls": {
"timeline": { "seekStep": 10 }
}
}The same step drives every seek input: the widget buttons, keyboard arrows, and seek gestures.
Control switches gate only Media Session action handlers:
controls.playPausegates the play and pause handlers.controls.timelinegates the seek-backward, seek-forward, and absolute-seek handlers.- Control switches never suppress metadata, playback-state, or position-state publication. A playing chromeless instance still publishes its title, artist, artwork, playback state, and position; it simply installs no LT action handlers.
- Page API commands remain available for chromeless integrations.
On a final media error or a configuration error, the owner removes every LT action handler, clears position state, and sets playbackState to none. Ownership transfers to the most recently active eligible player when one exists. Otherwise the failed video's metadata remains published without presenting it as playing or seekable.
Media Retry makes that participant eligible again. If no other player owns the session, it immediately republishes metadata and applicable handlers; position remains cleared until the engine reports a valid duration and position. If another player owns the session, Retry does not steal ownership: resumed playback does. A valid configuration update recovering from a configuration error follows the same eligibility rule.
- Metadata follows the instance: a configuration update (the shorts feed) refreshes the lock screen metadata to the new video.
- Platform honesty: background continuation is platform-dependent and not fully ours to promise. Android generally keeps audio playing with the media notification. iOS pauses web playback when the screen locks; the widget then shows with our metadata, and one tap on its play control resumes audio while the screen stays locked. The player guarantees correct metadata and working controls on the widget; the exact continuation behavior belongs to the platform.
- The shorts feed additionally maps the widget's previous and next track controls to feed navigation (see the shorts document); standalone instances do not register those actions, so the platform shows no such buttons for them. A standalone owner explicitly clears stale previous- and next-track handlers, but never installs non-null handlers for them.
- Unsupported platform actions are silently omitted. Browser-owned fallback UI remains platform-dependent.
Picture-in-picture
Delivery priority: last. Picture-in-picture is specified here and binding, but it is implemented only after everything else on the player is finished.
The player supports element picture-in-picture: the video frames lift into a floating window owned by the operating system, and playback continues there while the user scrolls, switches tabs, or leaves the browser. The behavior follows the reference experience of Chromium news sites: minimal, browser-native, no custom window.
What the player owns, and all it owns:
- The
pipcontrol in the bar, theIhotkey, and the engine wiring. - Auto-PiP eligibility: the player registers the Media Session enter-picture-in-picture handler and leaves picture-in-picture enabled on the media element unless
controls.pip: falseremoves the function. Firefox is excluded so its browser-owned PiP overlay does not cover the LT controls. Eligibility is what lets Chromium float the video automatically.
Everything about the window itself belongs to the platform, and differs by design:
| Behavior | Chromium (Chrome, Brave, Edge) | Safari macOS | iOS |
|---|---|---|---|
| Automatic entry | On scroll away and tab switch, while playing | None, manual only | On app switch, per system setting |
| Window look | Minimal: bare video frames, controls on hover | Native framed window with system controls | Native iOS PiP |
| Positioning | Free, any position, any screen | Snaps to the four screen corners | Corners, collapsible to the screen edge |
| Return | Scrolling back or returning to the tab restores inline playback | Manual | Manual or app return |
The window's appearance is never ours, on any platform. Every browser renders its own picture-in-picture design, and the player neither styles nor fights it.
One limitation, inherent to the API: LT-rendered surfaces do not travel into the PiP window. Captions (the styled chip), chapters, and all controls exist in the page; the window carries video frames only, with the platform's own transport controls.
Gestures and keyboard shortcuts
Default bindings ship with the player. They are implemented through the media engine's hotkey and gesture primitives and their action vocabulary (composition, not reinvented input handling), and the bindings deliberately follow the engine's reference set plus established player conventions. The two master switches (controls.hotkeys, controls.gestures) disable whole input layers; per-function inputs already die with their function (rule 2): a removed fullscreen ignores F. All shortcut-related accessibility texts come from the strings namespace.
Every seek input below uses the one shared seek step, controls.timeline.seekStep, 10 seconds by default (see the lock screen section).
Keyboard (while the player has focus)
| Keys | Action |
|---|---|
Space, K | Play, pause |
ArrowLeft, ArrowRight | Seek backward, forward by the seek step |
J, L | Seek backward, forward by the seek step |
ArrowUp, ArrowDown | Volume up, down in small increments |
M | Mute toggle |
F | Fullscreen toggle, where the layout has the slot |
C | Captions toggle |
I | Picture-in-picture toggle, where the platform supports it |
<, > | Previous, next offered playback rate, wrapping at the list boundaries |
0 to 9 | Jump to 0 through 90 percent of the video |
Home, End | Jump to start, end |
Escape | Close the open menu, dialog, or panel; exit fullscreen |
Gestures and pointer
| Input | Layout | Action |
|---|---|---|
| Touch tap on the surface | Landscape | Reveal controls only, never toggles playback (see control visibility) |
| Touch tap on the surface | Portrait | Play and pause toggle: portrait never hides controls, so no reveal exists |
| Double-tap left or right zone | Landscape only | Seek backward or forward by the seek step, with the seek indicator |
| Mouse click on the surface | Both | Play and pause toggle |
| Mouse double-click on the surface | Landscape | Fullscreen toggle |
| Vertical swipe | Portrait feed | Owned by the shorts feed (advance), never a player gesture |
No pinch and no long-press gestures exist. Double-tap seeking is deliberately absent in portrait: it would race the feed's fast swiping.