# Player kit

`@vabu/streams-web` is a small JavaScript package for the page that plays your stream. You keep your own player and your own buttons. The kit opens the viewer's session, keeps it alive, moves to the next source when one stops working, shows moments when the video reaches them, and sends quality data. It has no UI and no framework. It plays through the hls.js you already use.

If you do not have a player, use the [Vabu player element](https://docs.vabu.live/guides/streams/player-element.md). It is built on this kit.

## Before you start

You need:

- a [publishable key](https://docs.vabu.live/concepts/keys-and-scopes.md), `vpk_test_...` while you build and `vpk_live_...` when you go live;
- the id of a live stream (`ls_...`) or a video (`ast_...`), and an entitlement for your viewer, as in [Access](https://docs.vabu.live/api.md);
- a route on your server that signs a short-lived [customer token](https://docs.vabu.live/api.md) for the signed-in viewer.

```sh
npm install @vabu/streams-web hls.js
```

hls.js 1.7 or later is a peer dependency. The kit attaches to your own instance, so you choose the version and the build.

## Play a stream

```ts
import Hls from 'hls.js'
import { attachHls, createStreamsSession } from '@vabu/streams-web'

const video = document.querySelector('video')!

const session = await createStreamsSession({
  apiBase: 'https://api.vabu.live',
  publishableKey: 'vpk_live_...',
  resource: { type: 'live_stream', id: 'ls_...' }, // or { type: 'asset', id: 'ast_...' }
  customerToken: async () => {
    const res = await fetch('/api/vabu-token')

    if (!res.ok) {
      throw new Error(`Token request failed: ${res.status}`) // never hand an error page to the kit as a token
    }

    return res.text()
  },
  collectorBase: 'https://collect.vabu.live', // optional: leave it out to send no quality data
})

const hls = new Hls()
const playback = attachHls({ session, hls, video })

await video.play()
```

`createStreamsSession` opens the viewer's session. It checks their entitlement and the limits on it, so a viewer who is already watching on another device can be refused here. `session.grant` has the playback sources in order. `attachHls` plays the first one.

From then on the kit:

- sends a heartbeat every `session.grant.heartbeat_interval` seconds, with a fresh token each time;
- asks your `customerToken` function for a new token a minute before the old one expires, and once more if the API refuses a token. After that it stops and tells you;
- ends the session when you call `session.end()` and when the page is closed.

`deviceId` is `'auto'` by default: a random id kept in `localStorage`, or a new one for each page load when storage is blocked. Pass your own string if you have one.

### The token route on your server

The browser never sees your signing secret. It asks your own server, which already knows who is signed in.

```ts
// Node, Express style
import jwt from 'jsonwebtoken'

app.get('/api/vabu-token', requireLogin, (req, res) => {
  const token = jwt.sign({ sub: req.user.id, aud: 'vabu' }, process.env.VABU_CUSTOMER_TOKEN_SECRET!, {
    algorithm: 'HS256',
    keyid: process.env.VABU_CUSTOMER_TOKEN_KID,
    expiresIn: '10m',
  })

  res.type('text/plain').send(token)
})
```

`sub` is your own reference for the viewer, the same one you used when you granted the entitlement.

## When something goes wrong

Opening can fail, and a session can end later. Both give you a `StreamsError` with a `code` you can show or log.

```ts
import { StreamsError, type StreamsSession } from '@vabu/streams-web'

let session: StreamsSession

try {
  session = await createStreamsSession(options)
} catch (error) {
  if (error instanceof StreamsError) showProblem(error.code, error.reason)
  throw error
}

session.on('ended', (error) => showEnded(error.reason))
session.on('error', (error) => console.warn(error.code, error.fatal))
```

| `code` | What happened | What to say |
|---|---|---|
| `concurrency_limit_reached` | The viewer is already watching and the entitlement allows one stream at a time. | Playing on another device. |
| `device_limit_reached` | The entitlement allows fewer devices than this viewer has used. | They reached their device limit. |
| `view_limit_reached` | They used all the views the entitlement allows. | They have no views left. |
| `access_denied` | No entitlement, or it expired, was revoked or paused. `reason` says which. | Their access has ended. |
| `invalid_customer_token` | Your token was refused, even after the kit asked for a new one. `reason` says why. | Check your signing key and clock. |
| `token_unavailable` | Your `customerToken` function failed. The reason is in `error.cause`. | Try again. |
| `network_error` | The API could not be reached. | Check the connection. |
| `no_playback_source` | The stream or video has no playable source. | Not available right now. |

When a session ends after it opened, `ended` carries `error.reason`:

| `reason` | Meaning |
|---|---|
| `replaced_by_newer_session` | The viewer started watching on another device and the entitlement allows one. |
| `entitlement_inactive` | The entitlement was revoked, paused or expired. |
| `missed_heartbeats` | The session lapsed, usually because the connection was down. |
| `token_rejected` | The customer token was refused twice. |
| `client` | Your code, or the closing page, ended it. |

When a session ends the kit stops loading and pauses the video, so access stops with the session. A failed heartbeat on a bad connection is reported through `error` with `fatal: false` and tried again soon, inside the time the server waits before it drops the session.

## Sources and failover

A grant lists sources in order. The kit plays the first and watches for trouble. It moves to the next source:

- on a fatal network error;
- on a second fatal media error within a minute of a recovery attempt (the first one gets `recoverMediaError()`);
- after `failover.max_errors` source errors inside `failover.window_seconds`.

By default hls.js waits more than a minute before it gives up on a dead source. The kit sets `manifestLoadPolicy`, `playlistLoadPolicy` and `fragLoadPolicy` on your instance so that a dead source becomes fatal in about 10 seconds. A live stream starts again at the live edge on the next source. A video starts again where it was. If you already set those policies, the kit replaces them.

When there is no other source to move to, the kit loads the same one again, with the newest token, after 2, 4, 8 and 16 seconds, and then every 16 seconds, for about a minute. While the browser is offline it waits for the network and does not use up retries. Only after that does it report `playback_failed`. Soft errors on the only source never end playback. A source is healthy again after 5 seconds of playing.

With native HLS (Safari without hls.js) the kit cannot swap tokens in requests. About a minute before the token the player loaded expires, it loads the newest URL from the last heartbeat and carries on from the live edge. A native player that waits for 10 seconds without moving is treated as a source error.

```ts
playback.on('source', ({ from, to, reason }) => {
  console.log(`${from.provider} to ${to.provider}: ${reason}`)
})

playback.on('error', (error) => {
  // Every source failed. Show a message, then try again from the first one:
  playback.retry()
})
```

Each heartbeat returns a fresh grant. When only the token in a URL changed, nothing reloads and the new token goes out with the requests that follow. When Vabu reorders the sources and sets `switch` to `"now"`, the kit moves to the new first source. With `"on_error"` it remembers the order for the next failure. For this to work, keep hls.js's default loader: the kit swaps tokens in `xhrSetup` and `fetchSetup`, and calls yours after it.

To treat a frozen live playlist as an error as well, set `liveMaxUnchangedPlaylistRefresh` in your hls.js config. Those errors count towards `max_errors`.

## Moments in step with the video

A moment is something your server puts on the stream, such as a paid shoutout. The kit shows it when the viewer's video reaches it, not when it arrives.

```ts
playback.moments.followFeed({ intervalMs: 2000 }) // polls the viewer feed with your publishable key
playback.moments.add({ id, occurred_at, label, type, payload, display_ms }) // or hand one over from your own realtime channel

playback.on('moment', (moment) => overlay.show(moment.label, moment.payload))
playback.on('momentend', (moment) => overlay.hide(moment.id))
```

How it works:

- The playhead's clock is `hls.playingDate`, from the `EXT-X-PROGRAM-DATE-TIME` tags in the stream. On a browser that plays HLS natively, it is `video.getStartDate()` plus `video.currentTime`.
- Without those tags the kit estimates the clock as the current time minus `hls.latency`, the distance from the live edge.
- A moment shows 150 ms early, so that drawing it is not late. It shows once, stays for `display_ms`, and is dropped if it is more than 2 minutes behind the playhead.
- Every pipeline stamps the video with its own clock. If one source runs early or late, set `playback.source.pdt_offset_ms`, the milliseconds to add to that source's clock. It is 0 by default.

Draw `label` and `payload` as text, never as HTML. The feed is public to anyone who knows the stream id.

## Data saver and audio only

```ts
playback.setDataSaver(true) // renditions go no higher than 360p, or the closest lower height
playback.setAudioOnly(true) // returns false when the source has no audio-only rendition
```

hls.js hides audio-only variants from its own level list, so the kit reads them from the manifest. Turning audio only on or off loads the source again, and playback stops for a moment.

## iPhone and native HLS

When hls.js cannot run but the browser plays HLS itself (iPhone Safari before 17.1), the kit sets `video.src`. Sessions, moments and quality data work the same way. Failover works on the video element's `error` event. Data saver and audio only need hls.js. Leave `hls` out to use native playback only:

```ts
attachHls({ session, video })
```

## Quality data

With a `collectorBase`, the kit sends beacons to `/v1/playback` on the collector: `start` with the startup time, `heartbeat` every 30 seconds while the video plays, `rebuffer`, `error` and `end`. Counters (`watch_ms`, `rebuffer_count`, `rebuffer_ms`, `errors`) add up over the session. Each beacon also has the current and average bitrate, the provider of the source in use, the session id, the playback id, a coarse device type (`mobile`, `desktop` or `tv`) and the connection type when the browser offers one. It never sends the viewer's name, contact details or the customer token.

## Clean up

```ts
playback.destroy() // detaches from the video and puts your hls.js config back
await session.end() // frees the device slot
hls.destroy() // yours to destroy
```
