# Your own player

> Play the signed HLS URL in hls.js, Safari or a native app, and give each viewer a short-lived link for members-only streams.

`playback_url` is a standard HLS address. Anything that plays HLS plays it: hls.js or Video.js on the web, Safari and iOS natively, AVPlayer, ExoPlayer/Media3, VLC.

## On a web page

```html
<video id="v" controls playsinline muted style="width:100%;aspect-ratio:16/9;background:#000"></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
  const url = 'https://live.joacdn.com/bcdn_token=.../str_e1bzk3dxw9z9allei6n7/master.m3u8';
  const video = document.getElementById('v');
  if (Hls.isSupported()) {
    const hls = new Hls({ liveSyncDurationCount: 3 });
    hls.loadSource(url);
    hls.attachMedia(video);
  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = url; // Safari and iOS play HLS natively
  }
  video.play().catch(() => {}); // muted autoplay is allowed; with sound it needs a click
</script>
```

The CDN sends CORS headers, so this works from any site.

## Signed links

Every `playback_url` carries a signature and an expiry, checked by the CDN on every request. An unsigned, altered or expired link gets `403`. The signature covers the whole stream (playlists and every video piece), so players need no special handling.

The `playback_url` in a stream object is valid until the stream could no longer be live, plus an hour. That suits a public event. It is re-signed each time you read the stream.

## Members-only streams

For viewing that should stay behind your login, don't put the stream's own `playback_url` in the page. Have your backend sign a short one for each viewer after checking their membership:

```bash
curl https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/playback-token \
  -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \
  -d '{"expires_in": 900}'
```

```json
{
  "object": "playback_token",
  "stream_id": "str_e1bzk3dxw9z9allei6n7",
  "playback_url": "https://live.joacdn.com/bcdn_token=.../str_e1bzk3dxw9z9allei6n7/master.m3u8",
  "expires_at": "2026-09-29T11:21:04.000Z"
}
```

`expires_in` is 60 seconds to 7 days. A link is checked on every request, so a viewer who starts watching at the last second of a 15-minute link is cut off when it expires. Mint links that outlast your longest session, or fetch a new one and reload the source before expiry.

What a signature can't do: it can't limit *how many* people use a link while it is valid. If one member shares theirs, it works for others until it expires. Short links keep that window small.

## Count viewers from your own player

Viewers of your own player are counted exactly from the CDN logs, about an hour after the fact. To see them live too, have your player report the way the hosted player does:

```js
const id = 'str_e1bzk3dxw9z9allei6n7';
const u = Math.random();                                       // once per page load
const sid = crypto.randomUUID().replace(/-/g, '').slice(0, 24); // once per page load
let beacon = { rate: 1, interval_seconds: 30 };

async function refresh() {                                      // every minute is enough
  const e = await (await fetch(`https://play.joacdn.com/api/embed/${id}`)).json();
  beacon = e.beacon ?? { rate: 0, interval_seconds: 30 };
}
function report() {
  const st = video.paused ? 'z' : video.readyState < 3 ? 'b' : 'p'; // w = waiting, p, z = paused, b = buffering
  if (u < beacon.rate) navigator.sendBeacon(`https://api.justonair.com/v1/embed/${id}/beat?s=${sid}&st=${st}&u=${u}`);
  setTimeout(report, beacon.interval_seconds * 1000 * (0.9 + Math.random() * 0.2));
}
refresh().then(report);
setInterval(refresh, 60_000);
```

Only players whose `u` is below `rate` report, and the rate falls as the audience grows. Keep that rule: it is what keeps a large event's reports small.

## After the stream ends

There is no replay. Once a stream ends, `playback_url` becomes `null` and new links are refused with `stream_ended`. The recording is an MP4 you download and host where you like; see [Recordings](/recordings).
