# mHub Browser API

When a web page is opened inside an mHub-compatible browser, the browser
exposes the **mHub Browser API**. `window.mhub` is simply the namespace it
lives in, the way `navigator` is for the web platform. It lets a page do
things a normal browser cannot:

1. **`mhub.fetch`**: fetch any URL without CORS, with request headers the
   browser would never let a page set.
2. **`mhub.storage`**: key/value storage that follows the *site*, not the
   domain it was written on.
3. **`mhub.openStream`**: play media that needs mandatory request headers.
4. **`mhub.setLinks`**: add entries ("addon links") to the browser home screen.
   **`mhub.setRecent`**: put the site's "continue watching" there too.
5. **`mhub.setBackHandler`**: own the back press while the page runs its own
   navigation.
6. **`mhub.setSearch`** / **`mhub.openSearch`**: receive searches the user
   types into the host's own search UI, so your search page needs no input
   field, and open that UI from the page.
7. **`mhub.setImmersive`** / **`mhub.exit`**: run as an app, not a
   document: hide the host's chrome for a player, and leave for the host's
   home.
8. **`mhub.device`** / **`mhub.capabilities`**: read what the page can't
   detect on its own.

Two more things are not calls. **The meta tag** declares in the HTML what a
host cannot see from the outside: that this is an mhub page, and that a D-pad
reaches everything on it. **Mirrors** are one small file on each of your
domains, and your site survives a dead one; see
[Mirrors](#mirrors-survive-a-dead-domain).

Everything here is **progressive enhancement**: outside an mHub-compatible
browser the API simply doesn't exist, so guard every use with feature detection.
A page that ignores the API keeps working as a plain website.

## The core guarantee

If `window.mhub` exists, **every member documented in the Core API below MUST
exist and work**. A conforming host has no partial surface and no members that
silently do nothing. What legitimately varies between hosts are the *optional
powers* (a loopback stream proxy, the mirror system, the task protocol, the
signed identity), and each of those is announced in
[`mhub.capabilities`](#windowmhubcapabilities). Check `window.mhub` to know the
API is there; check a capability to know an optional power is there. So
`if (window.mhub)` is the whole check. Probing a core member
(`window.mhub?.fetch`) says the opposite of this guarantee, and once a page
does it for one member it does it for all of them.

The API is the same on **every platform** the host apps run on: mobile, TV,
desktop, web. A platform difference is always expressed through
`capabilities` (or `device`), never through a missing member.

The API is only present when the app has the page-API feature enabled, and only
in the **top document**, never inside embedded (third-party) iframes.

---

## Readiness: the boot script

`window.mhub` is injected **synchronously at document start**, before your
first script. Two things still make raw feature-detection awkward: on some
hosts the API attaches a few milliseconds late, and a page often wants to
*tell the user* when it is not running in an mHub browser at all. The **boot
script** handles both; load it as the first script in `<head>`:

```html
<script src="/mhub.js" data-require></script>
```

It gives the page four things:

1. **The queuing stub, installed synchronously.** `mhub.fetch`,
   `mhub.setLinks`, `mhub.setRecent`, `mhub.setSearch`, `mhub.setBackHandler`,
   `mhub.setImmersive`, `mhub.openSearch` and
   `mhub.exit` are callable from the very first line; on a late-attaching host the calls queue and
   replay in order. (`mhub.storage`, `mhub.openStream`,
   `mhub.requestPlayback`, `mhub.capabilities` and `mhub.device` cannot be
   queued meaningfully; while the stub is active they are `undefined`/`null`,
   so touch them after `mhub.ready`.)
2. **Detection.** `mhub.ready` is a `Promise<boolean>`: `true` once a real
   host has attached, `false` when this is a plain browser (decided shortly
   after the document is complete). After it settles, `mhub.hosted` carries
   the same answer synchronously.
3. **A browser fallback** when there is no host: `mhub.fetch` becomes the
   browser's own `fetch` and the queued calls go through it (the other queued
   calls had no one to reach and are dropped). It carries less, and says so:
   see [below](#without-a-host-the-browser-fallback).
4. **A warning banner** when there is no host, only with the `data-require`
   attribute (override the text with `data-message`). Leave it off and handle
   `mhub.ready === false` yourself; a page built as progressive enhancement
   needs neither.

```js
const hosted = await mhub.ready;
if (!hosted) showDownloadHint();       // or data-require does it for you
```

With the boot script loaded, `window.mhub` is **always** there, so
`if (window.mhub)` detects nothing and `mhub.ready` is the answer. Without the
boot script, `if (window.mhub)` is the whole detection.

`mhub.ready` and `mhub.hosted` are provided by the **boot script, not by
hosts**; never feature-detect the host through them.

### Tell the host what you are: the meta tag

Two things a host cannot see from the outside, declared in the HTML so they
are known before any script runs, and before the page is even opened by a
host that reads its HTML ahead of a visit:

```html
<meta name="mhub" content="app dpad">
```

- `app`: this document is an mhub page. On a TV the host clears its chrome
  and treats the page as the app on screen; it never turns it away as a
  plain website.
- `dpad`: the page is **fully operable with a D-pad**: the directional keys
  move a visible focus to every interactive element, OK/Enter activates it,
  nothing needs a pointer. A TV host then hands the remote's keys straight to
  the page instead of putting a pointer aid (a virtual cursor driven by the
  remote) in front of it. A page that declares only `dpad` is a website that
  works with the remote: a TV host will not turn it away, but it is not an
  mhub page.

Put it in `<head>`, above your scripts. Tokens are separated by spaces or
commas; unknown tokens are ignored, so the list can grow. Per document, like
everything a page declares: the next document starts from nothing.

The tag is the **only** way a page becomes an mhub page: it has to be in the
HTML as served, a tag added later by script is not read. Without it the API
still works (`mhub.fetch`, `mhub.storage`, `mhub.openStream`, …), but the page
stays a website: no app chrome, and a TV host turns it away. Neither a read
nor a call of `window.mhub` counts.

### Without a host: the browser fallback

In a plain browser the page keeps calling `mhub.fetch`, and the boot script
answers with the browser's `fetch`: CORS applies, there is no proxy and no
signature, credentials are omitted, and a request is aborted after 20 s where
the browser can (`AbortController`). `mhub.storage`, `mhub.openStream`,
`mhub.capabilities` and `mhub.device` stay `undefined`/`null`; check
`mhub.hosted` before reaching for them.

What a browser cannot do is **refused with a `code`** instead of failing
obscurely, so the page can tell the user that an mhub-compatible app would
help (see [Errors](#errors-codes-and-what-to-tell-the-user)):

- `identity: "required"` is refused up front (`identity_required`); nothing is
  sent.
- A header a browser reserves for itself (`Referer`, `Origin`, `Cookie`, ...)
  is refused up front (`header_blocked`). `User-Agent` is dropped quietly, the
  server sees the browser's own.
- A cross-origin request that died without a status is either CORS or the
  network, and the browser words both the same. The fallback asks once more in
  `no-cors` mode: an opaque answer means the server is reachable and the first
  attempt was refused by CORS (`cors_blocked`); no answer means the network,
  and the original error is rethrown without a code.

Self-hosting or inlining the script is fine and recommended for
availability-critical pages; it has no server-side coupling. Pages that cannot
load it can still hand-roll the stub (see the script source; the stub shape
is the compatibility contract and stays stable).

---

## Errors: codes and what to tell the user

A rejected `mhub.fetch` carries a stable **`code`** on the error, so a page
can tell "a host would have helped" from an ordinary network failure without
matching message text. The `message` stays English and one line, for the
console; the `code` is what page code branches on. Whoever answers
`mhub.fetch` sets it: the host, or the boot script's
[browser fallback](#without-a-host-the-browser-fallback).

| `code` | Set by | When |
|---|---|---|
| `identity_required` | browser fallback | The request asked for `identity: "required"` and there is no host to sign it. Nothing was sent. |
| `header_blocked` | browser fallback | The request sets a header a browser refuses to send (`Referer`, `Origin`, `Cookie`, ...). Nothing was sent. |
| `cors_blocked` | browser fallback | The request went out and the browser refused to show the answer; the server is reachable. |
| `permission_denied` | host | The user blocked web access for this site; nothing was sent. |

The first three also carry **`hint`**, a URL that explains where an
mhub-compatible app comes from (`https://mhub.mx/browser`). Everything else a
rejection can be (network, timeout, an HTTP status the page treats as an
error) has no `code`; it is the ordinary failure it looks like.

`mhub.hosted === false` is a state, not an error: a feature that cannot start
without a host should stay quiet or sleep, and only a call that actually
fails should show a message.

### Wording

The user cannot fix any of the three "no host" causes, so they share one
sentence, with `hint` as the link behind it: *This needs an mhub-compatible
app.* Only `permission_denied` gets its own: *Web access for this site is
blocked. Allow it in the address bar.* Say it in the page's own words, at the
point of failure (the empty row, the tile, the player's error state), once
per session. The boot script ships no translations.

```js
try {
  const res = await mhub.fetch(url, { identity: "required" });
} catch (err) {
  if (err.code === "permission_denied") showBlocked();
  else if (err.hint) showNeedsApp(err.hint);   // identity_required, header_blocked, cors_blocked
  else showFailed(err);                         // an ordinary network failure
}
```

---

## Events

Live host-state changes arrive through a single **`mhubupdate`** event on
`window`, discriminated by `detail.kind`:

```js
addEventListener("mhubupdate", (e) => {
  switch (e.detail.kind) {
    case "device":     applyLayout(e.detail.device); break;
    case "permission": onPermission(e.detail.granted); break;
    case "identity":   refetchUserState(); break;
    case "lock":       setFrozen(e.detail.locked); break;
  }
});
```

| `detail.kind` | Fired when | `detail` |
|---|---|---|
| `device`     | on load (device known), and whenever `insets` change: the host's bar tucks away or returns, an immersive flip | `{ device }`, the same object as `mhub.device` |
| `permission` | on load, and when a permission changes        | `{ name: "fetch", state: "allow"/"deny"/"ask", granted }` |
| `identity`   | when the signed identity / entitlement changes (only on hosts with the `"identity"` capability, never elsewhere) | `{}` (a trigger only) |
| `lock`       | when the host freezes the page under a dialog of its own or lets go again, and when it switches the page's playback off or on | `{ locked: boolean, playback?: boolean }` |
| `cast`       | on load and whenever the host's Cast sender changes state (only on hosts with the `"cast"` capability) | `{ available, state, device, error }`, see [`mhub.cast`](#windowmhubcastop) |
| `downloads`  | when a title the host keeps for this site changes, is removed, or needs a fresh link (only on hosts with the `"downloads"` capability) | `{ ev: "change", items }`, `{ ev: "remove", id }`, `{ ev: "link", id, key, ref }`, see [`mhub.downloads`](#windowmhubdownloads) |

- **`permission`** reports the effective per-site decision, so a page can show a
  "grant access" affordance instead of a dead button. `granted` is
  `state === "allow"`; `ask` means the next `mhub.fetch` will prompt. `name`
  identifies which permission it is, today only `"fetch"` (CORS-free web
  access, also covering proxied `openStream`); branch on it so your code keeps
  working if more permissions are added later.
- **`identity`** carries no data on purpose: the signed identity is never exposed
  to page JavaScript. Read it as *"your entitlement may have changed, re-fetch
  your own endpoint"*; the browser attaches the fresh signature to that request.
- **`lock`**: while `locked` is true the host's dialog stands over the page,
  the page is inert and playing media is paused; `false` restores both. The
  host never touches the page's DOM, so hide your own chrome (player bar,
  menus) yourself. A host that never freezes a page never fires it.
  `playback: false` says the page is the user's but **playback is off** (the
  free quota is spent; typically the host's dialog was just closed without an
  upgrade): close an open player, and when the user asks to play, call
  [`requestPlayback`](#windowmhubrequestplayback) instead of starting one. A
  missing `playback` field means playback is allowed — read absence as `true`,
  never as off. A frozen page (`locked: true`) always counts as playback-off
  too.

A second event, **`mhubback`**, is not a state change but a command: the host
handing the back press to the page. It is documented with
[`mhub.setBackHandler`](#windowmhubsetbackhandlerdepth).

---

## Frame hosts (the TV apps)

The TV apps on Samsung Tizen and LG webOS have no WebView of their own: they
host a page in an `<iframe>` of their document and reach it by `postMessage`.
`mhub.js` refuses embedding by default (an embed on a third party's site must
not be scripted by its embedder); a page that wants those hosts opts in on the
script tag:

```html
<script src="/mhub.js" data-frame-host></script>
```

With the attribute, a page inside a frame announces itself to the parent
(`{mhub: 1, type: "frameReady", href}`) and runs the scripts the parent answers
with (`{mhub: 1, type: "eval", js}`, from `window.parent` only) — the same
bridge a WebView host injects, so the whole API above works as it does there:
`window.mhub`, the events, the capabilities the host announces. Without a
frame host answering, the page settles as a plain browser after the usual
grace.

**What the opt-in means:** the page trusts ANY parent that answers (the parent
runs scripts in the page). Opt in only for a page whose data is public and
whose identity the host signs — never a page that keeps a secret of its own in
its origin. Vuma opts in.

**What a frame host cannot do:** script a page that did not opt in (a plain
website in the frame stays mute; the TV product turns it away), and read the
page's URL or title on its own — the bridge reports both.

---

## `window.mhub.capabilities`

A **synchronous** array of strings naming the optional host powers. Core
members are never listed; they are always present (see the core guarantee).

```js
if (window.mhub?.capabilities.includes("streamProxy")) {
  // openStream can inject mandatory headers here
}
```

| Capability | Meaning |
|---|---|
| `"streamProxy"` | `openStream` can proxy streams with mandatory request headers |
| `"mirrors"`     | the host runs the [mirror system](#mirrors-survive-a-dead-domain): site-file discovery, page-load failover, `fetch` failover, adaptive order |
| `"clientFetch"` | `fetch` transparently resolves the addon's client-fetch requests (a request made from the client, with the user's own IP) |
| `"identity"`    | `fetch` attaches the signed client identity (`identity: true`, or by default to confirmed site endpoints) |
| `"search"`      | the host surfaces a site-search entry point (e.g. the address bar) and delivers queries to [`setSearch`](#windowmhubsetsearchconfig) |
| `"video"`       | the host plays the page's stream in its own media player, **behind the page**: [`mhub.video`](#windowmhubvideo) |
| `"player"`      | the host has a media player of its own and takes a stream through [`openPlayer`](#windowmhubopenplayerurl-headers-name-logo) |
| `"cast"`        | the host's Cast sender takes the stream it plays for the page (`mhub.video`) to a receiver: [`mhub.cast`](#windowmhubcastop) |
| `"downloads"`   | the host keeps a title for watching without a network, in a library of its own: [`mhub.downloads`](#windowmhubdownloads) |
| `"remote"`      | a TV host that pairs a phone as its remote control (touchpad and keyboard): [`mhub.remote`](#windowmhubremote) |
| `"handover"`    | the host carries a page's sign-in from the phone to the TV that phone steers: [`mhub.handover`](#windowmhubhandover) |

Unknown strings may appear as the API grows; ignore what you don't know.
The list is **fixed per document load**: a capability never appears or
disappears while your page is running.

---

## `window.mhub.fetch(url, options?)`

A CORS-free, `fetch`-compatible request that runs on the native side.

```js
const res = await window.mhub.fetch("https://example.com/data.json");
const data = await res.json();
```

### Parameters

| Argument  | Type                | Notes                                              |
| --------- | ------------------- | -------------------------------------------------- |
| `url`     | `string`            | Resolved relative to the current page.             |
| `options` | `object` (optional) | A subset of `fetch`'s `RequestInit`, see below.    |

Supported `options`:

- `method`: e.g. `"GET"`, `"POST"`.
- `headers`: a plain object or a `Headers` instance. Because the request is
  built **natively**, this includes headers a browser reserves for itself
  (`Referer`, `Origin`, `User-Agent`, …); set them like any other.
- `body`: **string only** (JSON, form-encoded, …). Blobs/FormData are not
  transferred.
- `redirect`: best-effort. `"follow"` (the default) works everywhere.
  `"manual"` hands back the 3xx itself with a readable `Location`, `"error"`
  rejects; a host whose native stack cannot stop a redirect follows anyway.
  Without a host the browser answers `"manual"` with an opaque status 0.
- `identity`: whether the signed client identity rides along, **whatever
  origin the request goes to**. `true` = attach it if this host can (a host
  without the `"identity"` capability sends the request unsigned);
  `"required"` = the request only makes sense signed, a host that cannot sign
  rejects the promise instead of sending it; `false` = never. Left out, the
  host signs requests to the site's own confirmed endpoints only. See the
  [binding](#signed-identity-capability-identity).

Credentials are always omitted; the host never attaches its own cookies.

### Return value

A Promise resolving to a genuine **`Response`** on every host: phone, desktop and
TV alike. `headers.get()`, `text()`, `json()`, `arrayBuffer()` and `blob()` all
work as in the browser, and `url` carries the final URL after redirects.

Binary responses are transferred transparently (base64 under the hood) and
reconstructed for you.

The promise **rejects** on network failure, on denied permission, and on
timeout: a request that neither answers nor fails is aborted by the host
(currently after 20 s), so a hanging upstream can never leave your page waiting
forever.

### What it does beyond a plain fetch

- **No CORS.** The request runs natively, so cross-origin responses are readable
  regardless of the target's CORS headers.
- **Forbidden headers.** A browser silently strips `Referer`, `Origin`,
  `User-Agent` and friends from page-initiated requests; here they go through.
  Many stream and API endpoints are unusable without exactly this.
- With the **`"mirrors"`** capability, requests to the site's own endpoints fail
  over to the next mirror on a network error, a timeout or an HTTP status
  `>= 500`; see [Mirrors](#mirrors-survive-a-dead-domain).
- With the **`"clientFetch"`** capability, client-fetch requests in the response
  are resolved transparently; see the [binding](#mediahubmx-binding).
- With the **`"identity"`** capability, requests carry the signed client
  identity: on request (`identity: true`) to any origin, by default to the
  site's own confirmed endpoints; see the [binding](#mediahubmx-binding).

### Permission

The **first** `mhub.fetch` for a given site shows a one-time dialog
("Allow web access?") naming the host the page is served from, with a
*Remember for this site* option.

- **Allow** + remember → persisted; no further prompts.
- **Allow** without remember → granted for the session.
- **Block** → the call rejects with `permission_denied`; a red indicator
  appears in the address bar and tapping it re-opens the dialog. A block is
  session-only and never persisted.

Several `mhub.fetch` calls made before the user answers share the one dialog and
are all resolved by the single decision. The same permission covers proxied
[`openStream`](#windowmhubopenstreamurl-headers-cors) calls; it does **not**
gate `mhub.storage`.

---

## `window.mhub.storage`

Key/value storage that follows your **site**, not the domain it was written on.
All methods return promises; values are strings, so you decide the format.

```js
await window.mhub.storage.set("watchlist", JSON.stringify(items));
const raw = await window.mhub.storage.get("watchlist");   // string | null
await window.mhub.storage.remove("watchlist");
const keys = await window.mhub.storage.keys();            // string[]

const all  = await window.mhub.storage.entries();         // { key: value }
const seen = await window.mhub.storage.entries("watched/");// only that prefix
const q    = await window.mhub.storage.quota();           // see Limits
```

Storage needs **no permission** and never prompts: nothing leaves the device,
exactly like `localStorage`.

### `entries([prefix])`

Every value in one call, as an object, optionally narrowed to the keys that
start with `prefix`. One round trip instead of one `get` per key at startup;
prefer it over `keys()` when you want the values anyway, and name a prefix
so you read the one store you need.

### Why not `localStorage`

Browsers partition `localStorage` per origin. A site reachable under several
mirror domains therefore gets a separate, empty store on each one, and that
hits exactly when a mirror is used, i.e. when the usual domain is unreachable.
The host is the only side that knows which domains are the same site, so it is
the only side that can bridge this.

### Scope

The site part of the key comes from the **host**, derived from the same verified
identity that governs permission and the signature (see *Site identity*). You
cannot choose it, and no other site can name it to reach your data. Inside your
own scope the keys are yours; namespace them per addon if one origin serves
several (`"arte/watchlist"`, `"nasa/watchlist"`).

A site that has not published a site file still gets storage, keyed by its
origin: same isolation, it just cannot span mirrors, because as far as the host
knows there are none.

### Limits

They differ per host, so ask rather than assume:

```js
const { total, used, perValue, keys, keysUsed } = await window.mhub.storage.quota();
```

All five are numbers; `used` counts keys and values together, the same way the
host counts them against `total`; sizes are in characters. Current hosts
offer **20 MB** per site, **512 KB** per value and **50 000** keys. The
**minimum** any host offers is **512 KB** in total, **256 KB** per value and
**200** keys; `quota` is how you tell.

`set` rejects when a limit is hit rather than dropping data silently. `entries`
crosses to your page in one piece, so a host refuses an answer of several
megabytes; narrow it with a prefix.

The store lives on the device; several tabs of the same site share it, writes
are last-write-wins. There is no cross-device sync; it follows the site's
*identity*, not the user.

### Availability

Outside a host there is no `mhub.storage` (the boot script's stub does not
carry it), so fall back to `localStorage`; the data then stays on the current
domain:

```js
const store = window.mhub?.storage;
const raw = store
  ? await store.get("watchlist")
  : localStorage.getItem("watchlist");
```

---

## `window.mhub.openStream({url, headers?, cors?})`

**The one way to start playback of an external stream.** Media that needs
mandatory request headers (a `Referer`, a token) cannot be played by handing
the URL to a `<video>` tag or a native player: those fetch without your
headers. `openStream` hands the URL to the host, and the host decides what
comes back: the original URL (direct playback) or a loopback-proxied one that
injects the headers natively. The page always plays whatever it gets and never
needs to know the difference.

```js
const s = await window.mhub.openStream({
  url: "https://cdn.example/master.m3u8",
  headers: { Referer: "https://site.example/" },
});
video.src = s.url;         // hls.js / <video>
// nativePlayer.load(s.entry) for native HLS players (path form)
// … playback …
window.mhub.closeStream(s.url);
```

### Parameters

| Field | Type | Notes |
|---|---|---|
| `url` | `string` | Resolved relative to the current page. |
| `headers` | `object?` | Mandatory request headers the stream needs (`Referer`, tokens, …). |
| `cors` | `boolean?` | Set `true` when the page will **read** the stream with XHR (hls.js and every MSE player do); cross-origin CDNs reject those reads, so the host must proxy even though there are no headers. Leave it off for a plain `<video src>`, which is not a CORS request. |

### Return value

`{ url, entry, base?, proxied }`:

| Field | Meaning |
|---|---|
| `url` | What to play: hls.js, `<video>`, a native player. For a proxied stream it is the path form, so relative playlist entries resolve against it and stay on the proxy. |
| `entry` | The same path-form URL as `url`. |
| `base` | Proxied streams only. For a player with a URL hook (an hls.js loader): send every request as `${base}?u=${encodeURIComponent(absoluteUrl)}`. That also keeps root-relative entries (`/path/seg.ts`) and segments on other hosts on the proxy, which the path form loses. |
| `proxied` | `true` when the host routed the stream through its loopback proxy. Diagnostic only. |

For a direct stream, `url === entry === ` the input URL, there is no `base`
and `proxied` is `false`.

### Rules

The host proxies when the page **needs** it: `headers`, `cors: true`, or
cleartext `http://` media (the loopback clears mixed content). Everything else
comes back direct: no permission prompt, no detour.

- **Every proxied stream** is gated by the same per-site permission as
  `mhub.fetch`: a loopback URL makes the bytes page-readable, which is exactly
  the power that dialog is about. Direct streams never prompt.
- **`headers` given, host has no `"streamProxy"`** → the stream comes back
  direct with the headers dropped (`proxied: false`), exactly what a plain
  browser would do. Whether it plays is up to the source; check
  `capabilities` for `"streamProxy"` before you pick a header-bound source
  when a header-free one exists.
- **`cors`/`http://` without a proxy** degrade to direct instead, the same
  behavior the page would get in a plain browser.
- **Playback off** (`lock` update with `playback: false`) → the call rejects
  and the host raises its premium dialog itself, the same ask as
  [`requestPlayback`](#windowmhubrequestplayback). Don't route around it: a
  stream started any other way is stopped the moment it audibly plays.

Header hygiene is enforced by the host: a blocklist (including
`mediahubmx-signature`; a page can never make the host send its identity), an
SSRF guard, and headers that only ever go to the stream's own origin. A
playlist may still point to segments on other hosts: those are fetched
without the headers.

### `window.mhub.closeStream(urlOrEntry)`

Release a proxied stream when playback ends. Accepts the `url`, the `entry` or
the bare token; a no-op for direct URLs. There is no permission gate; a page
can only close a stream whose unguessable token it holds.

The host also releases all of a page's streams **itself** when the document
unloads or the tab closes, so `closeStream` is for ending playback early. A
page that forgets it leaks nothing past its own lifetime.

---

## `window.mhub.requestPlayback()`

The host can switch **playback off while leaving the page alone**: a `lock`
update with `playback: false` — the free quota is spent, and the host's
premium dialog was closed without an upgrade. The page stays the user's:
browse, search, everything but play. When the user then asks for playback,
call `requestPlayback()` **instead of opening your player**: the host raises
its own dialog (subscribe, sign in) over the page as it stands. No stream is
touched and no player flashes up — the point of asking is that the dialog
comes before the first frame, not after it.

Fire-and-forget: returns `true`, carries no answer. The outcome arrives as
the next `lock` update — `playback` restored means play; closing the dialog
without an upgrade repeats `playback: false`.

```js
let playbackOff = false;
addEventListener("mhubupdate", (e) => {
  if (e.detail.kind === "lock")
    playbackOff = e.detail.locked || e.detail.playback === false;
});

function play(item) {
  if (playbackOff && typeof mhub.requestPlayback === "function") {
    mhub.requestPlayback(); // the host's dialog comes up
    return;
  }
  openPlayer(item);
}
```

The member arrived together with the `playback` field, so any host that has
switched playback off has it; a host too old to send the field never gives
you a reason to call it. That is what the `typeof` probe above covers — the
one member where probing is right, precisely because such hosts exist. Either
way a page that plays anyway is caught: the host meters actual playback,
pauses it and raises the dialog itself. `requestPlayback` only makes that
moment graceful.

---

## `window.mhub.setLinks(links)`

Declare the site's entries on the browser home screen (mHub calls these *addon
links*). Returns `true` when accepted.

```js
window.mhub.setLinks([
  { id: "tmdb", name: "TMDB", icon: "/icons/tmdb.png", url: "/tmdb" },
  {
    id: "live",
    name: "Live TV",
    endpoints: ["https://a.mx/live", "https://b.mx/live"],
  },
]);
```

**The list you pass is the site's complete set.** Entries the site declared on
an earlier visit and no longer lists are removed; `setLinks([])` removes them
all. Call it with the full list on every load; it is a declaration, not an
append.

### Entry fields

| Field       | Type       | Notes                                                          |
| ----------- | ---------- | -------------------------------------------------------------- |
| `id`        | `string`   | Stable id for the entry (namespaced per site internally).      |
| `name`      | `string?`  | Label shown on the tile. Falls back to `id`.                   |
| `icon`      | `string?`  | Image URL, resolved relative to the page. Falls back to a monogram. |
| `url`       | `string?`  | The single target the tile opens.                              |
| `endpoints` | `string[]?`| Mirror list for the target; use instead of `url` for HA.       |

Give either `url` **or** `endpoints`. Both are resolved relative to the page, so
relative paths work.

### Behaviour

- Tiles open the target as a normal web page; if the target turns out to be an
  mHub addon, the app switches to addon mode automatically.
- Entries persist between visits (a tile is the way *back* to the site) until
  the site itself replaces them or the user removes the tile. They stay
  attributed to the declaring site.
- A site's set holds at most **8** entries; excess entries are dropped.
- The declared `endpoints` do **not** seed the target site's mirror set: that
  would be one site speaking for another. The target declares its own mirrors
  when it is opened; until then the tile uses `endpoints[0]`, and the remaining
  entries serve as load fallbacks for the tile itself.

---

## `window.mhub.setRecent(entries)`

Declare the site's "continue watching" for the browser home screen. The host
lists these next to what its own addons were playing, newest first, each card
opening the entry's `url` inside the site. Returns `true` when accepted.

```js
window.mhub.setRecent([
  {
    id: "tt0903747",
    name: "Breaking Bad",
    type: "series",
    year: 2008,
    image: "/img/bb.jpg",
    url: "#/item/tmdb/series/tt0903747",
    sub: "S2 E4 · 31 min left",
    progress: 0.42,
    updated: 1757462400000,
  },
]);
```

**Like `setLinks`, the list you pass is the site's complete set.** Call it with
the whole list whenever it changes (a title finished, a new one started, the
position moved); `setRecent([])` removes everything. Sending the list every
few seconds during playback is fine, the host coalesces.

### Entry fields

| Field      | Type      | Notes                                                                 |
| ---------- | --------- | --------------------------------------------------------------------- |
| `id`       | `string`  | Stable id of the entry (namespaced per site internally).              |
| `name`     | `string`  | Title on the card.                                                    |
| `url`      | `string`  | Where the card opens, resolved relative to the page (a hash route works). |
| `image`    | `string?` | Poster URL, resolved relative to the page. Falls back to a monogram.  |
| `type`     | `string?` | `movie`, `series`, `episode`, …; shown as a badge where the host has words for it. |
| `year`     | `string\|number?` | Shown under the title.                                        |
| `sub`      | `string?` | A line in the site's own words ("S2 E4 · 31 min left"); replaces `year` when given. |
| `progress` | `number?` | 0..1, how far in. A thin bar on the poster.                           |
| `updated`  | `number?` | When it was watched, ms since the epoch. Orders the row; defaults to now. |

### Behaviour

- Entries persist between visits and stay attributed to the declaring site;
  the site replaces them with its next declaration, and what it no longer
  lists is removed.
- The user can remove a card on the home screen. A removed entry stays away
  until the site declares it again with a **newer** `updated`.
- A site's set holds at most **12** entries; excess entries are dropped.

---

## `window.mhub.setBackHandler(depth)`

Claim the hardware/browser back press while the page is deeper than its own
entry point. Returns `true` when the call was accepted.

The host's back button walks the **tab history**, a list of URLs. A page that
navigates inside itself is invisible to it, so back would jump straight out of
the site. Instead, the page reports how many
levels deep it is in its **own** navigation:

```js
mhub.setBackHandler(2);                 // I am 2 levels deep
addEventListener("mhubback", () => {
  popOneLevel();
  mhub.setBackHandler(currentDepth);    // report the new depth
});
```

While the reported depth is `> 0`, a back press is dispatched to the page as a
**`mhubback`** event instead of touching the tab history. The page pops one
level and reports its new depth; once that reaches `0`, back presses fall
through to the host again (tab history → start page → out of the browser).

- There is no acknowledgement round-trip: **the depth is the claim.**
- The depth resets to `0` on every document load; a claim never survives a
  navigation, so a page cannot trap the user in a tab.
- **Host fallback:** if the page does not call `setBackHandler` within a short
  timeout (~300 ms) after `mhubback`, the host assumes the page stopped
  listening, clears the claim and handles back presses itself again. **Any**
  `setBackHandler` call counts as the sign of life, also one reporting the
  same depth (a press may legitimately leave the depth unchanged, e.g. closing
  a modal that replaced a level). Always re-report after handling `mhubback`,
  and keep the listener alive as long as you claim a depth.
- A page that drives real browser history (`pushState` + `popstate`) may not
  need this on hosts whose back maps to the WebView's own navigation, but
  claiming the depth is the only behaviour that works on **every** host.

---

## `window.mhub.setSearch(config)`

Let the user search **your site** through the host's own search UI; on mobile
and desktop that is the address bar. The page declares that it handles search
and what to do with a query; your search page then needs no input field of its
own. Returns `true` when accepted.

```js
window.mhub.setSearch({
  placeholder: "Search movies & shows",
  onQuery: (query) => {
    location.href = "/search?q=" + encodeURIComponent(query);
  },
  onSuggest: async (query) => {
    const res = await window.mhub.fetch(
      "/api/suggest?q=" + encodeURIComponent(query)
    );
    return (await res.json()).map((s) => ({ text: s.title, url: s.href }));
  },
});
```

### Config fields

| Field | Type | Notes |
|---|---|---|
| `onQuery` | `(query: string) => void` | Required. The user submitted a search scoped to your site. What happens next is yours: navigate to your results page, filter in place. |
| `onSuggest` | `(query: string) => Suggestion[] \| Promise<Suggestion[]>` | Optional. Called while the user types (debounced by the host). |
| `placeholder` | `string?` | Hint the host may show in its search field. |
| `query` | `string?` | The term your page is currently showing results for, `""` (or absent) elsewhere. The host names it in its search entry point and pre-fills its field with it when the user comes back to edit it. Re-declare whenever it changes (your results route is the natural place). |

A `Suggestion` is `{ text, url? }`. Picking one **with** `url` opens that page
directly (resolved relative to the current page); one **without** is submitted
as a query via `onQuery(text)`.

### Behaviour

- **Capability `"search"`** announces that the host actually surfaces an entry
  point. `setSearch` is core and accepted everywhere, but on a host without
  the capability nothing will ever call your handlers; check it at render
  time to decide whether the page shows its own search box.
- **The declaration is dynamic.** Each call replaces the previous one, and
  `setSearch(null)` withdraws it entirely: the host stops offering the site
  search. Register and withdraw freely as your UI state changes, e.g. offer
  search only in sections that have one.
- The declaration is also **per document**: your callbacks live in the page's
  JS and die with it, so register on every load. How and where the host
  surfaces the search (an in-site mode of the address bar, a search affordance
  on TV) is host UX; the contract is only *query in, handlers called*.
- **Suggestion budget:** the host debounces while the user types, shows at
  most **8** suggestions, and stops waiting after roughly a second; a slow or
  throwing `onSuggest` is dropped silently and never blocks the host UI.
- **Clearing:** an empty submission from the host's field calls
  `onQuery("")`: that is how the user clears a search from the host, so treat
  it as "back to no term", not as an error.
- **Privacy:** input reaches your page only while the host's search UI is
  visibly in its site-search state (an input labeled with your site, or an
  explicit mode the user entered), and a URL is never forwarded. Input typed
  anywhere else never reaches you.

### `window.mhub.openSearch()`

Put the host's search UI in front of the user, in its site-search state, from
the page: your search screen shows a button (or focuses on open) instead of an
input field of its own. Only honoured on the page currently on screen and only
after a `setSearch` declaration; without one there is nowhere to send the
query. Hosts without the `"search"` capability accept the call and do nothing,
so keep your own input as the fallback there. Returns `true` when accepted.

---

## `window.mhub.setImmersive(on)`

The page is showing a full-screen surface, typically its video player. The
host slides its chrome away and **keeps it away**: scrolling inside the player
must not summon the address bar, and on phones the system bars may go too.
`setImmersive(false)` ends it. Per document, like the other declarations; a
document load always starts non-immersive. Returns `true` when accepted.

```js
player.addEventListener("open",  () => mhub.setImmersive(true));
player.addEventListener("close", () => mhub.setImmersive(false));
```

---

## `window.mhub.video`

Capability `"video"`. The member exists only where the capability is announced.

The page keeps its whole player (bar, menus, overlays, gestures) and hands only
the **picture and the engine** to the host: the stream plays in the host's
media player as a surface *behind* the page, and the page is transparent where
its `<video>` would be. A native player starts faster than MSE, plays what MSE
cannot (H.264 without IDR frames, hosts with broken certificates) and needs no
stream proxy for request headers.

```js
const sid = "v" + Date.now();
mhub.video.open({ sid, url, headers, isLive: true, startAt: 0, volume: 1, muted: false, rate: 1,
                  title, subtitle, poster,     // the three for a Cast receiver's screen
                  download });                 // a finished download's id: the host plays its copy
addEventListener("mhubupdate", (e) => {
  const d = e.detail;
  if (d.kind !== "video" || d.sid !== sid) return;
  // d.ev: "waiting" | "canplay" | "frame" | "playing" | "pause" | "time" | "meta" | "ended" | "error"
  //       | "fullscreen" {on} | "subtitles" {list}
});
mhub.video.send(sid, "pause");      // play | pause | seek {t} | volume {v} | muted {m}
                                    // rate {r} | audio {idx} | quality {idx} | fullscreen {on}
                                    // zoom {s, x, y}
mhub.video.close(sid);
```

- **One session per document.** `open` replaces whatever the host was playing;
  ops and events carry the page's `sid`, and a stale one is ignored.
  `close("*")` closes whatever session the host holds — the page's belt and
  braces when its player goes, whether or not its own bookkeeping still knows
  the sid.
- **`meta`** carries `duration` (0 = live), `width`, `height`, `audio[]`
  (`title`, `language`), `activeAudio`, `quality[]` (`width`, `height`,
  `bitrate`, highest first; `quality {idx:-1}` = automatic). **`time`** carries
  `t`, `duration`, `buffered`. **`error`** carries `message`.
- **`frame`** says the host has drawn its first picture. Stand aside then, not
  at `open`: until it arrives the page's own ground is what the viewer sees.
- **Transparency is the page's half.** While its player shows the host's
  picture, everything between the player's chrome and the document root must be
  transparent (`html`, `body`, the app's root, the player's ground), and what
  lies under the player must not paint.
- **`zoom {s, x, y}`** is the page's pinch: the host scales its surface by
  `s` about the picture's centre and pans it by `x`/`y` (CSS px). `1, 0, 0`
  is the picture as it was; a new session starts unzoomed.
- **No browser full screen.** `requestFullscreen` puts an opaque layer between
  page and picture; ask the host with `fullscreen {on}` (a phone turns to
  landscape, a desktop window goes full screen). The host turns back when the
  session ends. When the screen turns by the host's own hands — a desktop's
  title bar, a shortcut — the event **`fullscreen`** `{on}` says so.
- Subtitles stay the page's: the host renders none; draw them from `time`.
- **`download`** names a finished title of [`mhub.downloads`](#windowmhubdownloads):
  the host plays its own copy from the disk and `url` stays what it is for
  everything else — the stream a Cast receiver would get, the source the page
  shows as playing. A copy that is not there, or not finished, is no reason to
  refuse: the stream plays as asked. The copy's subtitle files lie where the
  page cannot fetch them, so the host sends them as text, once, as the event
  **`subtitles`** `{ list: [{ name, language, text }] }`.
- The host pauses the picture by itself when it locks the page
  ([`lock`](#events)) or the tab goes to the background, and meters it like the
  page's own playback.

---

## `window.mhub.cast(op?)`

Capability `"cast"`. The member exists only where the capability is announced.

The host's own Cast sender (Google Cast) takes **the stream the host plays for
the page** — the [`mhub.video`](#windowmhubvideo) session — to a receiver: a
Chromecast, a TV with Cast built in. The page never sees the receiver: the
host's sheet chooses it, the host's engine feeds it (content type, the phone
relay for header-bound and bad-certificate sources), and the page's player
keeps steering it through the very session it opened.

```js
mhub.cast();        // or mhub.cast("open"): the host's device sheet —
                    // the chooser, or the controller while connected
mhub.cast("stop");  // end the session; playback returns to this device

addEventListener("mhubupdate", (e) => {
  const d = e.detail;
  if (d.kind !== "cast") return;
  // d.available: a receiver is on the network (or casting was used before)
  //              — show your Cast button
  // d.state:     "idle" | "connecting" | "connected"
  // d.device:    the receiver's name while connecting/connected, else null
  // d.error:     null | "connect" | "load" (the receiver could not play the
  //              stream) | "local" (the stream only exists on this device)
});
```

- **The Cast button is the page's.** Draw it where your player's controls
  are, in the icon's three states (plain, pulsing while `connecting`, filled
  while `connected` — the Cast design checklist), and only while `available`
  or a session runs. A press calls `mhub.cast()`; the host's sheet does the
  rest.
- **While casting, the session speaks for the receiver.** The host unmounts
  its picture the moment casting begins (an IPTV source allows one
  connection, the receiver needs it), the page's stage shows its own ground —
  say where the picture went (`device`) and offer the way back
  (`mhub.cast("stop")`). The session's events keep coming as if the picture
  were here: `waiting` until the receiver plays, then `playing`, `pause`,
  `time` (position, duration), `ended`; `meta` once, with no tracks. The ops
  keep working: `play`, `pause`, `seek` go to the receiver, `volume` and
  `muted` set the receiver's volume; `rate`, `audio`, `quality`, `zoom` and
  `fullscreen` do nothing there.
- **A new `open` while connected loads the next stream on the receiver** (a
  zap); the phone never plays it. Do not open sessions the user did not ask
  for while casting (a warm start at a press would zap the TV).
- **When the session ends** the host's picture comes back where the receiver
  was (VOD) or at the live edge, with a `frame` as at any open.
- **Metadata.** Give `open` a `title`, `subtitle` and `poster` (an absolute
  image URL): the receiver's screen shows them. The picture itself needs none.

---

## `window.mhub.remote`

Capability `"remote"`. The member exists only where the capability is
announced: a TV host that can pair a phone as its remote control. The phone's
screen becomes the touchpad, its keyboard types on the TV.

A page is built for the TV's own remote (see the `dpad` token of the meta
tag), and most of it should stay that way. This member is for the one screen
the remote is no tool for, such as a messenger, where every line is typed: the
page asks whether a phone steers, and where none does, shows why it wants one
and offers the host's pairing dialog.

```js
const { connected } = await mhub.remote.state();  // a phone steers this TV
mhub.remote.pair();                               // the host's pairing dialog
mhub.remote.prefer("pointer");                    // this screen: touchpad
mhub.remote.prefer(null);                         // back to the D-pad
mhub.remote.companion({ url: "/#/chat", label: "Open the chat on the phone" });
mhub.remote.companion(null);                      // take the button back
mhub.remote.composer({ label: "Deniz", placeholder: "Message to Deniz", quick: ["🍿", "😂"] });
mhub.remote.composer(null);                       // take the field away

addEventListener("mhubupdate", (e) => {
  const d = e.detail;
  if (d.kind !== "remote") return;
  // d.connected: a phone took the TV (true) or let go of it (false)
});
```

- **`state()`** resolves `{ connected }`. Ask once when the screen in question
  opens; every change after that arrives as `mhubupdate` kind `"remote"`.
- **`pair()`** puts the host's own pairing dialog (QR code, short code) over
  the page and returns `true`. Call it from a press, on the page that is on
  screen. With a phone already connected it does nothing. The dialog closes
  by itself when a phone takes over; the page learns it from the event, not
  from this call.
- **`prefer("pointer")`** says the screen on show is better steered with the
  phone's touchpad than with its D-pad (small targets, a text field): a
  connected phone then shows the touchpad by itself. `prefer(null)` hands
  the choice back, and so does leaving the document. Declare it only for the
  screen that needs it and withdraw it when that screen is left: everything
  else on a `dpad` page belongs to the keys. The TV's own remote keeps its
  keys either way.
- **`companion({ url, label })`** says that what is on the TV goes on better
  on the phone itself. A messenger is the case: the TV shows the chat, the
  phone writes it. The phone's remote then carries one button with your
  `label` (at most 60 characters) that opens `url` in the phone's own
  browser; the connection to the TV stays. Only an address of your own site;
  `null`, or leaving the document, takes the button back.
  With `open: true` (call it from a press on the TV) the phone opens the
  address at once; with no phone connected the host's pairing dialog comes
  up first and the address goes to the phone that pairs.
- **`composer({ label, placeholder, quick })`** says the page has a line to
  write that the phone should write: a watch party's chat. The phone's
  remote then carries, beside its keys, a text field (`placeholder`) and one
  button per entry of `quick` (at most eight short strings: reactions). What
  the person sends comes back to the page as an event, and the page sends it
  as whoever is signed in on the TV; nothing of the chat lives on the phone:

  ```js
  addEventListener("mhubupdate", (e) => {
    const d = e.detail;
    if (d.kind !== "line") return;
    if (d.quick) react(d.quick);        // one of your quick strings
    else if (d.text) send(d.text);      // a typed line, at most 2000 characters
  });
  ```

  Two more fields make the phone the whole of the conversation. `lines`:
  the last few lines of what is being answered, `[{ from, mine, text }]` (at
  most six, your page's own reading of them; a page that decrypts its chat
  is the only one who can show it). The phone shows the last three above the
  field; offer again whenever a line comes in. `tag`: the notification tag
  of that chat (the `tag` its notifications carry). While the field
  stands on the phone, the phone does not ring for that tag: it is showing
  it.

  Offer it only while there is somewhere for the line to go, and take it
  back (`null`) when there is not. Leaving the document takes it back too.
- **The pairing never crosses to the page.** No code, no QR content, no phone
  name: a page that held them could hand the TV to anyone.
- **A phone can drop for a moment** (its network changes hands). Do not tear
  your screen down on the first `connected: false`; wait a few seconds for it
  to come back.
- **Only the phone counts.** A mouse on the TV does not make `connected`
  true: it types nothing.

---

## `window.mhub.handover`

Capability `"handover"`. The member exists only where the capability is
announced: on a phone that can steer a TV, and on that TV.

Nobody wants to type an account into a TV. A phone that steers a TV brings
the sign-in along instead: the page on the phone **offers** what the same
page on the TV needs to go on as the same person, the host carries it over
the remote's sealed channel, and the page on the TV **takes** it. What is in
it is the page's own business; the host reads only the label.

```js
// On the phone, whenever the sign-in changes:
mhub.handover.offer({
  id: "user-42:keys-7",   // what is offered; the same id is not sent twice
  label: "@deniz",        // shown in the host's question on the phone
  data: { /* what your page on the TV needs, JSON, at most 32 KB */ },
});
mhub.handover.offer(null); // signed out: withdraw it

// On the TV:
const got = await mhub.handover.take({ signIn: "/#/account" }); // { label, data } or null
addEventListener("mhubupdate", (e) => {
  if (e.detail.kind !== "handover") return;
  if (e.detail.declined) showThatThePhoneSaidNo();
  else if (e.detail.noSignIn) showThatThePhoneHasNoSignIn();
  else mhub.handover.take().then(use);
});
```

- **`offer(offer | null)`** is a declaration, like `setLinks`: it replaces
  what the page offered before, `null` withdraws it. The host keeps it per
  site, so it is there when the phone connects to a TV while the page is not
  open. Returns `true`.
- **The host asks the person on the phone** before anything goes, naming the
  site, the label and the TV; "always for this TV" skips the question from
  then on. A page never learns whether it was asked.
- **Only to the same site.** The TV hands the object to the page of the site
  that offered it on the phone, and to no other page.
- **`take()`** resolves what the phone brought for this page, once, or
  `null`. With nothing there the TV asks the steering phone for it (which
  asks its person); what arrives then is announced with the event. Call it
  when the event comes, and when your page stands signed out while a phone
  steers ([`mhub.remote`](#windowmhubremote)).
- **`take({ signIn })`** names where a person signs in on your site (an
  address of the same site, relative ones resolve against the page). A phone
  that holds no offer for the site then asks its person whether to sign in
  THERE first, on the phone, opens that address in its browser, and the
  moment your page offers its sign-in it goes to the TV that waits. Without
  `signIn` a phone that holds nothing stays silent.
- **`take({ local: true })`** is the same device instead of a TV: a person
  signed in on one site in the app is signed in on the next that asks. The
  host hands over the newest sign-in ANOTHER site in this app offered: at
  once on the host's own sites, after one question to its person on any
  other ("sign in as @deniz?", naming your site; a yes holds for your site
  from then on), since what goes is the whole of what that site offered.
  What your site was given once it is not given again by itself (the person
  signed out there): then the question comes. `quiet: true` asks nothing and
  resolves `null` where a question would be needed — for the moment a page
  starts; ask without it where the person stands at your sign-in. Check what
  you take: it is another site's object, and only yours to use when it names
  the service you talk to.
- **A no is told.** When the person on the phone declines, the page on the
  TV gets the event with `declined: true` and nothing to take. The phone then
  stays quiet for that TV and site: further `take()` calls are answered with
  the same event, not with a new question.
- **A phone without a sign-in says so.** Asked by a TV while it holds
  nothing for the site, the phone answers the page with `noSignIn: true`
  (and, given `signIn`, asks its person whether to sign in there first). The
  moment the phone has a sign-in for the site it asks its person for the TV
  by itself, so the page need not ask again.
- **`take({ again: true })`** is for a press on the TV ("ask my phone
  again"): the phone puts the question once more, also after a no; a phone
  without a sign-in opens the `signIn` address right away. Pass it only from
  something the person did, never from a timer.
- **Sent once.** A TV that got an offer does not get the same `id` again by
  itself, so the same connect every evening asks nobody anything. A `take()`
  from the TV's page is how it goes again.
- **Put in it what makes the TV a device of its own**, not a copy of the
  phone: a way to get the TV its own session, so each can be signed out by
  itself. And decide on the TV what to do when someone else is signed in
  there: ask, do not overwrite.

---

## `window.mhub.openPlayer({url, headers?, name?, logo?})`

Capability `"player"`. The member exists only where the capability is
announced; check both.

Hands **one resolved stream** to the host's own media player, for a stream
the page's `<video>` cannot play. The case it was made for: a broadcast feed
whose H.264 carries no IDR frames. A browser's MSE accepts nothing else as the
way into a picture, so the sound buffers and the picture never starts, while a
native player shows it. The host's player opens over the page; the page stays
loaded and is what the user comes back to.

```js
if (mhub.capabilities.includes("player") && noPictureAfterAFragment) {
  if (mhub.openPlayer({ url, headers, name: channel.name, logo: channel.logo })) closeMyPlayer();
}
```

| Field | Meaning |
|---|---|
| `url`     | the playable `http(s)` address, already resolved |
| `headers` | request headers the stream needs, if any |
| `name`    | what the player shows as its title |
| `logo`    | an image address for the title |

Returns `true` when the host took it, `false` otherwise. Playback there counts
against the host's usage rules like the page's own. It is a fallback: play in
the page wherever the page can.

---

## `window.mhub.downloads`

Capability `"downloads"`. The member exists only where the capability is
announced; check both.

The host **keeps a title for watching without a network**. The page hands
over a *ticket*: what the title is, where it sits in its series, where its
stream is. The host downloads the stream, keeps it with its pictures and
subtitles in a library of its own (it works with the page gone and the
network off) and plays it in its own player. The page's part is the ticket,
a badge for where the download stands, and an answer when the host asks for
a fresh link.

```js
const dl = mhub.capabilities.includes("downloads") && mhub.downloads;

const record = await dl.add({
  key: "tmdb/tt0903747:1:1",          // your stable name for this title
  kind: "episode",                    // "movie" | "episode"
  item:   { id, name, year, runtime, description, genres, images: { poster, backdrop, logo, thumbnail } },
  series: { id, name, year, description, images: { poster, backdrop, logo } },   // episodes only
  season: 1, episode: 1,
  source: { name: "Hoster", quality: "1080p", languages: ["de"], size },         // what the user picked, for display
  stream: { url, headers, format },   // the playable address, resolved — or null (see "link")
  subtitles: [{ url, language: "de", name, format: "vtt" }],
  ref: { ... },                       // your own note, handed back with a "link" request
  progress: { t: 754, d: 2640 },      // where your own history stands, seconds
});

addEventListener("mhubupdate", (e) => {
  const d = e.detail;
  if (d.kind !== "downloads") return;
  if (d.ev === "change") for (const r of d.items) show(r);
  if (d.ev === "remove") forget(d.id);
  if (d.ev === "link") resolveAgain(d.ref).then(
    (stream) => dl.link(d.id, stream),              // { url, headers, format }
    (err) => dl.link(d.id, { error: String(err) }));
});
```

| Call | Does | Resolves with |
|---|---|---|
| `add(ticket)`       | queues the title; the same `key` again is the same download | its record |
| `list()`            | every title the host keeps for this site | records, each with its `ref` |
| `pause(id)` / `resume(id)` | stops a download where it is / lets it carry on (also: tries a failed one again) | `true` |
| `remove(id)`        | deletes the title and its files from the device | `true` |
| `link(id, stream)`  | the answer to a `link` request: `{ url, headers?, format? }`, or `{ error }` when the stream cannot be found again | `true` |
| `play(id)`          | plays a finished title in the host's player | `true` when it opened |
| `open()`            | brings the host's own library of downloads to the front | `true` |

A record, as `add`, `list` and the `change` event carry it:

| Field | Meaning |
|---|---|
| `id`, `key`, `kind` | the host's id, and your key and kind from the ticket |
| `status` | `"queued"`, `"running"`, `"paused"`, `"waiting"`, `"failed"`, `"complete"` |
| `wait`   | why a `"waiting"` one stands still: `"link"` (needs a fresh link from the page), `"network"`, `"space"`, `"locked"` |
| `error`  | a short reason on `"failed"` |
| `bytes`, `total` | what is on the device and what it will be; `total` is `0` while unknown and an estimate for HLS until the last segment |
| `speed`, `eta` | bytes per second and seconds left while it runs |
| `added`, `finished` | timestamps (ms) |
| `position`, `watched` | where the host's player left the title: `{ t, d, updated }` in seconds and ms |

Rules:

- **A site's titles are its own.** A page sees and steers only the downloads
  made by pages of its site (the mirror system's notion of a site); the user
  sees all of them in the host's library.
- **The ticket is kept as told.** The library is drawn from it long after the
  page is gone, so say what you know: names, year, runtime, season and episode
  numbers, pictures. Pictures and subtitles are fetched once and kept with the
  title. Strings are bounded, addresses must be `http(s)`, `ref` is JSON of at
  most 4 KB.
- **`stream` may be `null`.** Then the host asks for it (`link`) the moment
  the title's turn comes. That is the way to queue a whole season: links
  resolved all at once would be stale before the last episode starts.
- **`link` requests** also come when a link ran out half way: the host keeps
  what it has and carries on at the same byte with the fresh one. A request
  goes to the pages of the site that are open; with none open the title waits
  (`wait: "link"`) and lines up again when a page calls `list()`. Answer every
  request, with `{ error }` when there is no way.
- **What the host takes:** a file (`mp4`, `mkv`, …) over one connection or
  several, as the server allows, and HLS (the best rendition, or the best one
  that does not exceed `source.quality`, with its audio renditions and keys).
  A live stream and DASH are refused (`"failed"`, `error: "unsupported:…"`).
- **Playback on the page is the page's.** A finished title plays in the
  page's own player like any source: open the source's url through
  [`mhub.video.open`](#windowmhubvideo) with `download: id`, and the host
  takes the picture from its copy — the page's list, history, party and
  menus know nothing of the disk. `play(id)` opens the host's own player over
  the page instead (for a page that has no player of its own); either counts
  against the host's usage rules like any playback, and under a playback lock
  `add` is refused with `err.code === "locked"` (the host's own dialog comes
  up) and downloads stand still (`wait: "locked"`).
- **Errors** reject with a stable `err.code`: `"unavailable"`, `"invalid"`
  (not a ticket), `"not_found"` (no such id for this site), `"locked"`.

---

## `window.mhub.exit()`

Leave the page for the **host's own home**: the app's start page with its
tiles. Meant for the one place a page has room for a door, such as the bottom
entry of a TV rail. One press, and the user is out of the site; the page is
not consulted again. Only the page on screen may call it. Returns `true`.

---

## `window.mhub.device`

A small, **synchronous** object describing the host; read it directly, no
`await`, so you can branch on it at first render:

```js
if (window.mhub?.device.isTV) renderTvLayout();
```

| Field      | Type      | Notes                                        |
|------------|-----------|----------------------------------------------|
| `isTV`     | `boolean` | `true` on a TV / remote-driven device.       |
| `platform` | `string`  | `"android"`, `"ios"`, `"electron"` or `"web"` (`"web"` = the host itself renders as a web app, e.g. a TV web runtime). |
| `canPlay`  | `object?` | Optional: `{ hls?, dash?, drm? }` booleans. An **absent field means unknown**, not unsupported; probe with a source trial then. |
| `insets`   | `object?` | `{ top, bottom }` in CSS px the host's chrome covers. **The page starts below `top` as a whole**: a spacer at the top of its scroller, not a stepped header. `bottom` is how far the page hangs past the screen's foot; `0` once the bar has tucked away. Both `0` without a bar (TV, desktop). **Immersive**: `top` is the display cutout the full-screen page runs under; keep controls out of it, the video fills the screen. |
| `chrome`   | `string?` | Where the host's bar stands: `"none"` (no bar), `"inset"` (the band is in `insets.top` and the page steps below it), `"framed"` (the host places the page below its bar, `insets.top` is `0`). Absent on older hosts: read `insets.top > 0` as `"inset"`. Sent even when the number is `0`, because `0` alone cannot tell "framed" from "no bar". |
| `theme`    | `string?` | The look the host wears: `"light"`, `"dark"` or `"oled"` (paint true black, let accents glow). Absent means the host has no say; follow `prefers-color-scheme`. |

`isTV` and `platform` are independent axes: an Android TV reports
`{ isTV: true, platform: "android" }`, a webOS/Tizen TV
`{ isTV: true, platform: "web" }`. Branch layout on `isTV`, never on
`platform`.

It deliberately carries **only what a page cannot derive from standard web
APIs**. For screen size and pixel density use `window.screen` and
`window.devicePixelRatio`; to check whether an app capability exists, use
[`mhub.capabilities`](#windowmhubcapabilities) rather than branching on a
version number.

---

## Mirrors: survive a dead domain

*Capability: `"mirrors"`.*

Media sites lose domains. On a host with the `"mirrors"` capability your site
doesn't go down with one, and the whole integration is **one small file, no
API call**:

Serve `/mhub-site.json` from **every** domain of your site:

```json
{ "id": "your-site-id", "endpoints": ["https://a.example", "https://b.example"] }
```

- **`id`** names the site. Free-form, no relation to any domain; pick any
  stable string. It is not a global namespace either: two unrelated sites may
  use the same id without ever being mixed up.
- **`endpoints`** lists all your domains, including the one serving the file.
  Sub-path sites (`https://host.example/mysite`) are allowed; serve the file
  under that base.

That's it. There is nothing to call: the **host discovers the file by itself**
the first time your page uses any `mhub.*` member (which is what marks it as
an mHub-aware site; ordinary pages never cost a request). It looks in the
**page's directory** first and at the **origin root**, so sub-path sites work
the same way. From then on:

- **Page load:** if loading the current page fails (network error or HTTP
  `>= 500`), the browser reloads it from the next mirror, keeping the path and
  query. The address bar shows whichever mirror actually served the page.
- **`mhub.fetch`:** requests to your endpoints fail over the same way.
- **Adaptive order:** a mirror that works is remembered and tried first next
  time (persisted per site), so a dead primary is skipped on later visits.
- **Permission, storage, signature follow the site**, not the domain; a user
  who granted access once is not asked again when a mirror takes over.

Publish a fresh file any time; new endpoints are picked up and merged on the
next visit.

### How verification works (you don't need this to use it)

A file is a claim, not proof: a page could otherwise name any origin as its
mirror and hand it the site's permission and signature. So the host asks every
listed endpoint itself, over HTTP, and only groups two of them when **each one
names the other** in its own file. Nobody can arrange that for domains they do
not run.

- **An endpoint that does not answer is not rejected: it is kept** and checked
  again on the next occasion. Only an endpoint that answers with a
  *different* `id` is dropped. Rolling a new mirror out one server at a time
  is therefore safe: it joins once both ends list it.
- Until an endpoint is confirmed it may still serve a page (loading transfers
  no trust), but it does not share the site's permission and receives no
  signature.
- Verification re-runs whenever a page of the site is online, so your other
  endpoints are checked without the user ever visiting them.

> **Caveat: origins vs. sub-paths.** Declaring mirrors as bare **origins**
> (`https://a.mx`, `https://b.mx`) is always safe: on failover the path resolves
> identically on every mirror. Mirrors that host the same site under *different*
> sub-paths work for `mhub.fetch`, but a page loaded from them may break on
> root-relative assets; that is the site's responsibility (use `<base>` or
> relative URLs).

---

## Site identity

A site is the **set of its confirmed endpoints**, grouped under the `id` they
publish in [their site file](#mirrors-survive-a-dead-domain). Permission,
storage scope and cache follow that set, not the endpoint that happens to serve
right now, so a user who grants access once is not asked again when a mirror
takes over.

- A site that publishes nothing is simply its own single endpoint, keyed by the
  page's normalised host (`www.` stripped, lowercased).
- Serve several logical sites from one host by giving each its own `id` and its
  own sub-path endpoints, e.g. `https://mhub.mx/tmdb` vs. `https://mhub.mx/live`.

The permission dialog always shows the **host** the page is served from, never
the `id`: an id is free-form, so it is not something a user could rely on.

---

## Permission & privacy

- The user grants web access **per site**, once, via the permission dialog. One
  grant covers the site across all its mirrors, for `mhub.fetch` and for
  proxied `openStream`. `mhub.storage` needs no permission (nothing leaves the
  device).
- The signed `mediahubmx-signature` header is a bearer credential (a pseudonymous
  user id, subscription status, ~15 min validity). It is attached by the app
  and is **never exposed to page JavaScript**: a page can neither read it nor
  forward it, and the `openStream` header blocklist keeps a page from making
  the host send it. Where it goes is the page's call: by default only to the
  site's own confirmed endpoints, and to any origin the page asks for with
  `identity: true` on `mhub.fetch` (an addon page talking to third-party
  addons needs exactly that, the same way the native addon client signs every
  addon it talks to).
- Requests carry no cookies (`credentials` are omitted).

---

## MediaHubMX binding

Everything above is protocol-agnostic: it works for any page in any
conforming browser. The powers in this section tie a host to the **mHub Addon
Protocol** (v1, "MediaHubMX"); each is announced by a capability, and the
coming **Addon Protocol v2** binding will dock here the same way without
touching the core API.

### Site file fallback: `mediahubmx.json`

For [the mirror system](#mirrors-survive-a-dead-domain), a **MediaHubMX addon
needs no extra file**: the host also accepts the `id` + `endpoints` fields in
the `mediahubmx.json` every addon endpoint already serves. `/mhub-site.json`
wins when both exist; a plain website only ever needs `/mhub-site.json`.

### Client-fetch: capability `"clientFetch"`

If a `mhub.fetch` response is a client-fetch request (the MediaHubMX v1
`taskRequest` of kind **fetch**, which is what a v1 client and every v2 addon
behind the v1 bridge speak), the host runs that request from the client itself
(with the user's own IP, which is the point: geo checks, IP-bound tokens, rate
limits), POSTs the `taskResponse` back to the mirror that actually answered, and resolves the
page's promise with the final result. Any other task kind is answered with an
error response.

Without the capability, the page receives the raw client-fetch request and can
fall back to handling it itself.

### Signed identity: capability `"identity"`

The host attaches the signed `mediahubmx-signature` header (the same client
identity the mHub addon client sends), so an addon server can authorize the
user. Without an `identity` option it goes to the site's **own confirmed
endpoints** only. The page decides per request:

| `identity` | With the capability | Without it |
|---|---|---|
| *(absent)* | signed on own confirmed endpoints | unsigned |
| `true` | signed, any origin | unsigned |
| `"required"` | signed, any origin | the promise rejects with [`code: "identity_required"`](#errors-codes-and-what-to-tell-the-user), nothing is sent |
| `false` | unsigned | unsigned |

`true` is for requests that work either way and are merely better signed (a
public addon that personalises for known users); `"required"` for requests
whose answer is worthless unsigned (a paid catalog, an account endpoint), so
the failure is immediate and named rather than a 403 from far away. A page
speaking to third-party addons (mhub.mx and friends, v1 and v2 alike) uses
`true`. A client-fetch round trip keeps the identity setting of the request
that started it. See [Permission & privacy](#permission--privacy). Which
addons get it is not the page's guess: a v2 addon declares `identity` in its
manifest (`none`, `optional`, `required`) and the v2 client maps that onto
this option.

---

## Planned: the phone as the TV's remote

> **Planned, not implemented anywhere. Do not build against this section;
> everything in it may change.**

Casting to a Cast receiver exists today ([`mhub.cast`](#windowmhubcastop)).
The idea here is different: a phone controls the user's **own TV app** in the
same LAN, YouTube-style. The page on the phone discovers the TV, hands playback
over and becomes the remote. Video control (play/pause/seek) is mandatory; the
phone never tunnels the TV's traffic; the TV plays the stream itself.

---

## Platform notes

- The API exists on **every platform**: mobile (Android, iOS), desktop
  (Electron), and TV (Android TV, webOS, Tizen). What differs is how a page
  gets there: mobile and desktop have a free in-app browser; on TV there is no
  free browsing, pages arrive as the packaged runtime or through their home
  tiles; the API they see is the same.
- `mhub.fetch` resolves to a genuine `Response` on every host.
- The whole API is gated by a server-side feature flag; treat its presence as
  optional and always feature-detect `window.mhub`.
- A conforming host implements the **full core** (the guarantee above) and
  announces its optional powers in `mhub.capabilities`.

---

## Full example

```html
<script>
// No boot script here, so window.mhub exists only where a host injected it
// (with /mhub.js loaded the check is `await mhub.ready` instead).
// Mirrors need no code at all: serving /mhub-site.json on every domain is
// the whole integration; the host discovers it on our first mhub.* call.
if (window.mhub) (async () => {
  // 1) Declare our home-screen entries (the full set, every load).
  window.mhub.setLinks([
    { id: "tmdb", name: "TMDB", icon: "/icons/tmdb.png", url: "/tmdb" },
    { id: "live", name: "Live TV",
      endpoints: ["https://a.mx/live", "https://b.mx/live"] },
  ]);

  // 2) A CORS-free request to our own backend. With "identity", the app attaches
  //    the client identity header; our backend can trust it.
  const res = await window.mhub.fetch("https://mhub.mx/api/catalog");
  if (res.ok) render(await res.json());

  // 2b) A third-party addon that wants the same identity: say so. `true` still
  //     sends on a host that cannot sign; "required" would reject instead.
  const other = await window.mhub.fetch("https://tv.mhub.mx/mediahubmx.json",
    { method: "POST", body: "{}", identity: true });

  // 3) Play a stream that needs a Referer; one code path for every host.
  const s = await window.mhub.openStream({
    url: item.streamUrl,
    headers: { Referer: "https://mhub.mx/" },
  });
  video.src = s.url;

  // 4) Own the address-bar search: our search page needs no input field.
  window.mhub.setSearch({
    placeholder: "Search movies & shows",
    onQuery: (q) => (location.href = "/search?q=" + encodeURIComponent(q)),
  });
})();

// React to live host-state changes (device known, permission, identity).
addEventListener("mhubupdate", (e) => { /* … */ });
</script>
```
