# @scarlett-player/ui

UI controls plugin for Scarlett Player. Provides a modern, customizable player interface.

> **Docs:** [scarlettplayer.com/documentation](https://scarlettplayer.com/documentation/) · **For AI coding agents:** [llms.txt](https://scarlettplayer.com/llms.txt) (index) and [llms-full.txt](https://scarlettplayer.com/llms-full.txt) (every guide as one Markdown file)

## Installation

```bash
npm install @scarlett-player/core @scarlett-player/ui
```

## Usage

```typescript
import { createPlayer } from '@scarlett-player/core';
import { createHLSPlugin } from '@scarlett-player/hls';
import { uiPlugin } from '@scarlett-player/ui';

const player = await createPlayer({
  container: document.getElementById('player'),
  src: 'https://example.com/video.m3u8',
  plugins: [
    createHLSPlugin(),
    uiPlugin({
      theme: { accentColor: '#e50914' },
    }),
  ],
});
```

## Features

- Responsive control bar: measures itself and moves low-priority controls into
  an overflow tray rather than rendering them past a narrow player's edge
- Play/pause, seek, volume controls
- Big play button over the poster, shown until playback starts, again whenever
  a new source is loaded and sits paused, and as Replay when it ends
  (`bigPlayButton: false` turns it off)
- Fullscreen toggle
- Picture-in-Picture toggle (disabled until media metadata is loaded; hidden
  when the browser has no PiP support; Safari webkit presentation mode
  supported)
- Quality selector
- Progress bar with buffering indicator
- Time display: current / duration on VOD; on live, blank at the live edge
  (the LIVE indicator says it) and `-m:ss` behind live after a DVR scrub back
- Error overlay with viewer-friendly copy per error code, a Try Again action,
  and a reconnecting state while the player self-heals
- Keyboard shortcuts, with a `?` help dialog that lists the ones the player
  actually implements (`keyboard: false` turns the global shortcuts off)
- Customizable theming
- Auto-hide controls

## Responsive control bar

The bar is a single non-wrapping row of fixed-width controls, and both known
hosts clip what does not fit. On a 390px phone with a 17-slot layout that meant
the whole right-hand group (settings, captions, cast, PiP, fullscreen) rendered
off canvas: captions and playback speed were not broken, they were unreachable.

The bar now measures itself and moves the least important controls into a tray
behind a "More controls" button. It runs at first paint, whenever the player is
resized, and whenever a control shows or hides itself.

```typescript
uiPlugin({
  responsive: true,                 // Default: true
  priority: { share: 'never' },     // Pin a control, or re-rank one
});
```

`responsive: false` restores the pre-1.8 behaviour exactly: no measuring, no
observer, no tray button, no extra DOM.

### Priority

Lower ranks leave first. Ties go to the control that is later in the layout.

| Slot | Rank | Leaves to |
|---|---|---|
| `bandwidth-indicator` | 0 | hidden (a status glyph, not an action) |
| `skip-backward`, `skip-forward` | 1 | tray (gestures cover the same seek on touch) |
| `pip` | 2 | tray |
| any registered control (`share`, `chapters`, the playlist buttons, ...) and the optional `keyboard-help` button | 3 | tray |
| `chromecast`, `airplay` | 4 | tray (AirPlay is how an iPhone reaches a television, so it is never hidden) |
| `volume` | 5 | tray (iOS `video.volume` is read only) |
| `captions` | 6 | tray (also lives inside the settings menu) |
| `quality` | 6 | hidden (its menu is anchored in the bar, and the settings menu carries a Quality row, so `quality` needs `settings` in the layout) |
| `time` | 7 | hidden (a readout in a tray says nothing; the scrub tooltip still shows position) |
| `play`, `live-indicator`, `settings`, `fullscreen`, `spacer` | never | stays |

Settings never moves, so playback speed and captions are at most two taps away
at every width.

Speed is always offered on VOD. On a live stream it is offered once the DVR
window (`seekableRange`) reaches 90 seconds on the current source, and stays
until the window drops below 60 seconds or `seekableRange` goes null; after
that it needs 90 seconds again. A window between 60 and 90 seconds that never
reached 90 on this source does not offer it, so a sliding window of a few
segments, or one hovering near a threshold, neither shows Speed nor makes it
flicker. WHEP (no seekable range) never offers it. While Speed is withdrawn the
row is left out and any rate other than 1 is reset to 1, since there is too
little behind the edge to play through; when Speed comes back on the same
source (native HLS reports `live` just before its seekable range, and a live
window can grow past 90 seconds), the rate that was reset is put back. A new
source starts over. The menu follows the stream as live and DVR state arrive
after the manifest, and the settings button hides while the menu has no rows
at all. The skip buttons, progress bar and arrow keys keep the simpler rule:
on live they work with a `seekableRange` of any length.

Because `quality` hides, a layout with `quality` and no `settings` would lose
quality selection entirely below the width where the bar fits. `uiPlugin()`
throws on that layout unless `quality` is pinned
(`priority: { quality: 'never' }`) or `responsive` is off.

### The floor

The controls that never move (play, settings, the tray button and fullscreen)
need 188px of inner width, which is about 212px of player width, or about 280px
on a live stream where the live indicator also stays. Below that the bar clips
again, exactly as it did before.

### Menus

The settings and quality menus are bounded to the player's height
(`--sp-menu-max-height`, written by the plugin) and scroll inside it. Without
that, the Speed sub-panel is 253px tall against a 211px portrait phone player
and loses its Back header and its first three speeds to the host's
`overflow: hidden`.

## Layout

`controls` is the order of slots in the bar. The default is:

```typescript
uiPlugin({
  controls: [
    'play', 'skip-backward', 'skip-forward', 'volume', 'time',
    'live-indicator', 'bandwidth-indicator', 'spacer', 'settings',
    'captions', 'chromecast', 'airplay', 'pip', 'fullscreen',
  ],
});
```

Any id a plugin registers through the control registry (`share`, `chapters`,
`playlist-previous`, `playlist-next`, ...) can be placed in the same list. A
control whose plugin is not loaded is skipped.

The optional `keyboard-help` slot is a visible entry point into the shortcut
dialog for hosts that want one:

```typescript
uiPlugin({
  controls: ['play', 'keyboard-help', 'spacer', 'fullscreen'],
});
```

It is deliberately not in the default layout: `?` already opens the same
dialog, and the default bar keeps its exact widths. With `keyboard: false` the
slot renders nothing, because the dialog would list shortcuts that no longer
work. It fits like a registered control (rank 3, into the tray when the bar
runs short).

## Timeline extensions

The control registry lets a plugin contribute a *button*. `registerTimelineExtension` lets one contribute an editing layer over the **playback timeline** - the clip plugin's in/out handles are the first, and the reason it exists: an editor with its own miniature track cannot express a 30-second selection on a two-hour source, and makes the viewer pick points on a rail that is not the one they were just scrubbing.

```typescript
import { registerTimelineExtension } from '@scarlett-player/ui';

const release = registerTimelineExtension(api.container, (surface) => {
  surface.element.appendChild(myHandles);

  return {
    update: () => reposition(surface.getRailRect()),
    onSeekStart: () => suspendMyPreview(),
    onSeekEnd: () => {},
    destroy: () => myHandles.remove(),
  };
});
```

`surface` gives an extension four things and nothing else:

| Member | What it is |
|---|---|
| `element` | The layer to paint into: a sibling of the `role="slider"` seek element (never a child of it), positioned with exactly the rail's horizontal geometry and centred on it. `pointer-events: none`, so plain presses on the rail keep seeking; give your own hit targets `pointer-events: auto` |
| `getRailRect()` | The rail's box, in client coordinates - the same box the slider maps a press against, so a `clientX`-to-time mapping agrees with the player's own seeking to the pixel |
| `setEditing(active)` | Holds the control bar and progress bar visible and reserves the vertical room an editor needs around the rail. Independent of any control's `isMenuOpen()`, so an extension opened from a host's own button still holds the bar |
| `setDragging(active)` | Suppresses ordinary seeking while the extension owns the pointer, and gets the hover tooltip out of the way |

The extension gives back `update()` (called with the progress bar's own update, so it never has to observe the rail itself), `onSeekStart()` / `onSeekEnd()` for ordinary seeks it does **not** own - mouse, touch, keyboard and cancellation alike - and `destroy()`.

There is one active extension per player, keyed by container. Registering again replaces and destroys the previous one; the returned disposer only ever removes the registration it made, so a late cleanup cannot unmount its successor. Registration works before or after the UI plugin initialises. `unregisterTimelineExtension(container)` drops a player's registration wholesale, and `hasTimelineExtension(container)` reports whether one is present.

The UI package reads no plugin-specific state and gains no `IPluginAPI` surface for this: it offers a positioned layer and four lifecycle calls, and an extension paints into it.

## Keyboard Shortcuts

| Key | Action |
|-----|--------|
| Space / K | Play/Pause |
| M | Toggle mute |
| F | Toggle fullscreen |
| ? | Open the keyboard shortcut dialog |
| C | Toggle captions (off, or the first available track; needs caption tracks) |
| 0-9 | Seek to 0-90% of the video |
| Left Arrow | Seek -5s |
| Right Arrow | Seek +5s |
| Up Arrow | Volume +10% |
| Down Arrow | Volume -10% |

Pass `keyboard: false` to turn all of them off: no document keydown listener is
attached, so none of the keys above does anything. The controls' own keyboard
handling (Enter/Space on buttons, arrow keys in the settings menu, the help
dialog's own keys) is unchanged and the player stays focusable. The
`keyboard-help` control is omitted too (see above).

```typescript
uiPlugin({
  keyboard: false,  // Default: true
});
```

`?` opens a dialog inside the player that lists these shortcuts and notes the
conditional ones, so a viewer can discover them without leaving the video.
Escape (or the close button) dismisses it and returns focus to whatever opened
it. In fullscreen the browser takes the first Escape to exit fullscreen, so
the close button or a second Escape is needed to dismiss the dialog. While it is open, the dialog owns Tab and the media shortcuts above stand
down. The list lives in one place in the package (`src/shortcuts.ts`), so the
dialog cannot drift from the handler.

Digits seek to a share of the media. On VOD that is a share of the duration.
On a live stream with a DVR window it is the same share of the seekable
window, clamped inside it; on live without DVR the digits do nothing at all.
An infinite or missing duration never becomes a seek. Home/End are not
player-wide shortcuts: they belong to the focused progress bar, and the
dialog says so.

Every shortcut only takes a key it actually acts on: modifier chords
(Cmd/Ctrl/Alt, Shift or AltGraph with a digit), keys a focused widget handles
itself (Space/Enter on buttons, the arrows and Home/End on the slider), keys
already consumed by a nearer handler, and typing in an editable field are all
left alone.

### Seek events

Progress-bar mouse/touch presses and releases, its focused Arrow/Home/End
keys, the player-wide arrow shortcuts, the digit percentage seeks, the
skip-backward/forward buttons and the control-bar Replay button emit
`playback:seeking { time }` immediately after writing the media element's
`currentTime`. The payload carries the clamped target (zero for a VOD replay,
the playlist window start for a replay of a finished live stream).
Home/End on the focused bar seek to the VOD endpoints or live DVR boundaries.

A drag emits on press and release, not on each throttled mid-drag write.
With the analytics plugin these requests produce `seeking` beacons with
`seekSource: 'player'`; recent element echoes are deduplicated. Native-control
seeks are tracked separately through provider state as `'element'` seeks;
they do not emit this bus command.

On live the emit is what keeps a seek playable: the HLS provider holds every
`playback:seeking` request at the live sync position, so a forward skip or a
drag to the end lands at live instead of on the end of the seekable range,
where nothing is buffered yet. While `liveEdge` is true the skip-forward
button does nothing at all, since there is nothing ahead to skip to.

Big-overlay replay still seeks by writing `currentTime` without a bus request. Analytics observes these through the
element path (`seekSource: 'element'` when not consumed as a recent bus echo),
so the source field describes the path rather than proving native-control use.

Replay (the big overlay and the control-bar button) picks its target from the
media element's duration, not from `live`. On a finite duration (VOD, or a live
stream that ended with a playlist end marker) it restarts at the start of the
playlist window, so a sliding window replays from its first segment. On hls.js
that start comes from the HLS provider's `getPlaylistStart()`, because the MSE
element's finite seekable range always starts at zero; on native HLS and other
providers it is the start of the element's seekable window, or zero when there
is none. On an infinite duration (the element ended without an end
marker, for example because the playlist stopped updating) it does not seek. It
emits `media:load-request` for the current source with `autoplay: true`, so the
fresh load rejoins the live edge.

Analytics only counts seeks while a view is open: a replay's pre-play seek
after `viewEnd` is ignored on both paths, without changing the finalized view
or creating another view. The play request starts the replay view as usual.

## Theming

```typescript
uiPlugin({
  theme: {
    accentColor: '#e50914',      // Progress bar, highlights
    primaryColor: '#ffffff',      // Text, icons
    backgroundColor: 'rgba(0, 0, 0, 0.8)',
    controlBarHeight: 48,
    iconSize: 24,
  },
  hideDelay: 3000,  // Auto-hide delay in ms
});
```

`accentColor` writes `--sp-accent`, which colours the progress fill, the big
play button and focus rings - all non-text, where WCAG asks for 3:1.

Accent **text** (the LIVE label, the active row in the settings and quality
menus, the active captions and cast glyphs) is a separate token, because the
same colour has to clear 4.5:1 there. The brand red is 4.38:1 on black and
3.84:1 on the menus' own background: fine as a progress bar, unreadable as an
11px label. `accentTextColor` writes it:

```typescript
import { uiPlugin, accentTextTone } from '@scarlett-player/ui';

uiPlugin({
  theme: {
    accentColor: brand,
    // Returns `brand` as `#rrggbb` when it already clears AA, otherwise the
    // nearest lighter tone of the same hue that does
    accentTextColor: accentTextTone(brand),
  },
});
```

`accentTextTone()` reads hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`),
`rgb()`/`rgba()` in the comma or space syntax (channels and alpha as numbers or
percentages) and the CSS named colours, ignoring case and surrounding space. A
translucent accent is composited over the menus first, since that is what the
viewer sees, and the result is always an opaque `#rrggbb`: `white` comes back as
`#ffffff`. Anything else - `hsl()`, `var()`, a gradient, `transparent` - comes
back unchanged, so set `accentTextColor` yourself for those.

Or in CSS, which is the same token:

```css
.my-player {
  --sp-accent: #e50914;       /* fills, rings */
  --sp-accent-text: #ff4d57;  /* labels and active-state glyphs */
}
```

`--sp-accent-text` falls back to `--sp-accent`, so theming through
`accentColor` alone behaves as it always did - set one of the two forms above
when your accent is dark enough to fail as text.

## Big Play Button

```typescript
uiPlugin({
  bigPlayButton: false,  // Default: true
});
```

On by default because it is the only play affordance on the picture itself: a
mouse click on the video surface only reveals the control bar, and touch taps
belong to `@scarlett-player/gestures`, so a poster with the button off leaves
the viewer hunting for the small button in the bar. It is a real `<button>`
with an `aria-label`, sized past the 44 px minimum target, coloured with
`--sp-accent`, and it sits above the gestures surface so a tap starts playback
instead of toggling the controls.

Turn it off when the host page draws its own play affordance over the player.

## License

MIT
