SDK installation & configuration
The complete setup and configuration reference for building a viewer on the Aliran SDK. The Player SDK overview explains what the packages are. New to the SDK? Start with Build a player — a complete walkthrough from panel key to playing video, with runnable examples. This page is the working manual: every install path, every option, every event, and the runtime controls. For how operator actions flow into what your app sees, read Operator APIs & the SDK.
All packages are live on npm under the @aliran scope (MIT): @aliran/core,
@aliran/player-sdk, @aliran/react-native.
1. Installation
Node (headless host)
npm install @aliran/player-sdk
- Node ≥ 20. The package is ESM (
"type": "module"). - Host platforms with prebuilt natives: Linux (x64, arm64), Windows 10+
(x64, arm64), macOS 13+ (Apple silicon + Intel).
npm installnever compiles. The native stack (libsodium, the UDP transport, …) lands as prebuilds. If your platform/arch isn't in that list, there is no prebuild, and the engine won't load. For example, a Raspberry Pi 4/5 on a 64-bit OS islinux-arm64and works; the same board on a 32-bit OS does not. - TypeScript definitions ship in the package (
index.d.ts) — no@typesneeded. - Nothing else is required to serve video. To watch it, point any HLS-capable player — ffplay, VLC, mpv, hls.js, ExoPlayer — at the localhost URL the SDK returns.
Smoke test (prints usage, proves the install resolves):
node -e "import('@aliran/player-sdk').then(m => console.log(Object.keys(m)))"
A complete runnable starting point is
examples/headless-player.mjs.
React Native (phone + TV apps)
npm install @aliran/react-native react-native-video react-native-bare-kit b4a
Peer requirements and platform notes:
| Peer | Range | Notes |
|---|---|---|
react |
≥ 18 | |
react-native |
* |
Deliberately unpinned. TV apps install it as an npm alias of react-native-tvos, whose prerelease versions fail any strict semver range. With those, install with --legacy-peer-deps. |
react-native-video |
^6 | Renders the HLS. |
react-native-bare-kit |
≥ 0.13.3 | Hosts the engine worklet. Stock package requires minSdkVersion 29. The lazy-load patch lowers it to 24 for single-APK builds. |
b4a |
^1.6.6 | Buffer shim shared with the engine. |
Engine floor: Android 10 (API level 29), 64-bit. react-native-bare-kit
sets minSdkVersion 29, and the engine worklet simply cannot load on
Android 9 or older. This is a hard floor of the native P2P stack, not a
preference — libbare-kit.so needs ELF TLS, a libc feature added in
Android 10 (the full forensics are in the
Android build KB). It applies identically to
phones, tablets, Android TV, and Fire TV: Fire OS 8 devices (Android 11
base) work, and Fire OS 7 sticks (Android 9 base) do not. The shipped
prebuilds cover arm64-v8a and x86_64 (emulators) plus 32-bit ABIs for
custom builds, but the public APKs are 64-bit. The binding is exercised
on Android (phone + TV); iOS is not currently a supported target of the
shipped stack. The SDK itself runs below that floor — silently
inactive — see the next section.
Older Android (7–9): one APK, the engine gates itself at runtime
Fleets still run Android 7–9 set-top boxes. On those devices no P2P data is reachable at all: swarm, catalog, and login all live inside the native runtime that cannot load there (its ELF-TLS floor). So the SDK's contract is silent inactivity, and your app supplies its own content path — its "legacy mode", for example plain CDN/HLS URLs you deliver outside this SDK.
A single APK covers Android 7 → current. Out of the box that is
impossible. react-native-bare-kit is a C++ TurboModule statically
linked into your app's libappmodules.so, so libbare-kit.so is
resolved at React init on every device. An APK that merely lowers
minSdkVersion crashes on Android 9 and older before any JS runs. Two
pieces fix it:
-
Apply the bare-kit lazy-load patch (patch-package):
client/patches/react-native-bare-kit+0.13.3.patch— copy it into your app'spatches/and add the standardpatch-packagepostinstall. It rewrites the module's 22bare_*calls to go through adlopen("libbare-kit.so")/dlsymtable, resolved lazily and only on API 29+, so noDT_NEEDEDsurvives intolibappmodules.so, and it drops the package'sminSdkto 24. The engine still ships in the APK (jniLibsare untouched); below Android 10 it is simply never loaded, and a strayinitthrows a clean JS error instead of a native crash. Set your app'sminSdkVersionto 24. (Until this behavior lands upstream inreact-native-bare-kit, the patch is version-pinned — regenerate it when you bump the package.) -
Gate on
AliranBackend.isSupported(). On any Android below 10 it returnsfalseby OS version alone. The SDK never consults, constructs, or loads the native module there, and the whole backend is inert:start()and every other method are safe no-ops, nothing throws, nothing queues, and noonMessagelistener ever fires. Branch there, and use the SDK's ready-made<EngineNotice>screen in the unsupported branch. It gives you brandable copy and colors, plus an optional action button that is your seam for offering the viewer an alternative method — your own CDN/HLS playback, a help page. The SDK ships the notice and the switch, never the delivery:
Complete working example. This exact pattern — notice, button, plain-HLS fallback via ExoPlayer — is verified on a Fire OS 7 stick (Android 9):
import React, { useState } from 'react'
import { AliranBackend, EngineNotice } from '@aliran/react-native'
import Video from 'react-native-video'
export default function App () {
const [fallback, setFallback] = useState(false)
if (!AliranBackend.isSupported()) {
// Engine can't run here (Android 7-9): offer YOUR delivery instead —
// e.g. plain HLS from your CDN. The SDK never provides the content.
if (fallback) {
return <Video source={{ uri: 'https://cdn.example.com/live/main.m3u8' }} style={{ flex: 1 }} />
}
return (
<EngineNotice
title="Acme TV" // your brand
colors={{ background: '#0B1220', accent: '#0EA5E9' }}
actionLabel="Watch over the internet"
onAction={() => setFallback(true)} // the seam
/>
)
}
// Full P2P path (Android 10+): backend.start(bundle, { panelPubKey }), etc.
return <YourNormalP2PApp />
}
Omit actionLabel/onAction and it's a plain informational screen —
that's what the shipped app does, since it has no non-P2P delivery.
The action Pressable is D-pad focusable for TV, and
message/children let you replace or extend the copy per brand.
The shipped app is the working reference: client/android/build.gradle
(minSdk 24), the patch in client/patches/, and the isSupported() +
<EngineNotice> branch in client/src/App.tsx. Verified with one APK on
an Android 7 emulator (installs, runs, engine silent) and on a modern one
(engine boots to ready through the dlopen path).
Optional lean flavor. If you want a smaller APK for old-device-only
fleets — the engine libraries are ~55 MB per ABI — excluding
react-native-bare-kit from autolinking builds an engine-less APK at the
same floor. client/react-native.config.js (ALIRAN_LEGACY=1) shows
how, and client/android/settings.gradle dirties the autolinking cache
when the flavor flips (the cache keys on lock files, not env).
Practical floor: Android 7 (API 24) — that one is React Native's, not
ours. RN 0.76+ prebuilds, including the 0.83 the shipped app uses, are
built for API 24, and the build system rejects a lower minSdkVersion
outright (prefab: "User has minSdkVersion 23 but library was built for
24"). So Android 6 devices cannot run any app on a current RN
generation, engine or no engine — for those fleets, use the native
Kotlin SDK below, whose floor is Android 5.0. The only thing that could
ever bring P2P itself below Android 10 is Holepunch shipping
pre-API-29 bare-kit prebuilds — an upstream ask; this patch is the
app-side half of exactly that design.
Native Android (Kotlin) — aliran-kit, one APK from Android 5.0
Step-by-step version: the Kotlin SDK walkthrough builds the whole host app: the incompatibility hook, the notice, the dev-side CDN switch with complete code, and the P2P path.
For apps that don't use React Native — or for fleets below RN's own
Android 7 floor — sdk/android/ in the repo is a native Kotlin SDK
with the same engine and the same contracts, in one APK,
minSdk 21 (Android 5.0):
- On Android 10+ it hosts the full P2P engine via Holepunch's
plain-Java BareKit API (
to.holepunch.bare.kit.Worklet/IPC— no RN anywhere). It runs the same bare-pack engine bundle, and speaks the same line-JSON IPC protocol as the RN binding. - Below Android 10 the engine never loads. BareKit's
System.loadLibrarysits in the Worklet class's static initializer, and the SDK simply never touches that class below API 29, so no native patch is needed at all.AliranBackend.isSupported()isfalse, with every call a silent no-op.
The pieces mirror the RN surface: AliranBackend (worklet host +
protocol), AliranPlayerView (Media3/ExoPlayer with the <AliranVideo>
contracts — ~1 s zap buffer, engine-driven tune lifecycle,
frozen-live-edge resync ladder with reconnect() escalation,
feed-rotation rebuild, vod transport), and EngineNotice (the fallback
seam). Usage:
if (AliranBackend.isSupported()) {
backend.start(context, StartOptions().apply { panelPubKey = SERVICE_KEY })
backend.onMessage { m -> when (m) {
is BackendMessage.Ready -> backend.login(user, pass) // retry on "not connected" — ready can precede the panel link
is BackendMessage.Streams -> showChannels(m.streams)
else -> {}
} }
// then: playerView.attach(backend, streamId) — video renders via ExoPlayer
} else {
// Android 5-9: your own delivery (plain HLS plays on ExoPlayer down to 5.0)
setContentView(EngineNotice(context, title = "Acme TV",
actionLabel = "Watch over the internet", onAction = { mountYourFallback() }))
}
Build notes: the library vendors the engine from the RN package's
checkout (client/node_modules/react-native-bare-kit — run
npm install in client/ first), plus libc++_shared.so from the NDK,
and packages the engine bundle from client/backend/app.bundle.js.
sdk/android/demo/ is the working reference host (copy
service.example.json → src/main/assets/service.json). Verified with
one demo APK: an Android 5.1 emulator (installs, notice + plain-HLS
fallback plays) and a modern emulator (full P2P: OPRF login over the
DHT against a production panel, catalog, live channel playing).
Old-device TLS caveat for your fallback CDN: Android < 7.1.1 doesn't
trust Let's Encrypt's root — use a classic certificate chain there.
Three things the host app must provide:
- The worklet bundle. The binding has no build-time coupling to an
engine build. You supply the engine as a bare-pack
bundle — a base64 string or raw bytes — to
AliranBackend.start(). The reference recipe (packing@aliran/player-sdk+bare-fs/bare-http1wiring intoapp.bundle) is the client build guide; the shipped app'sclient/backend/is the working example. - Cleartext to loopback (Android release builds). The engine serves
HLS on
http://127.0.0.1:<port>, so release builds need cleartext permitted for loopback in the network-security-config. That is all the engine itself needs; the shipped app permits more, for a different reason (http://provider streams). A network security config governs the app's outbound requests only, so it has no effect on the inbound LAN server a cast session stands up. Details are in the client build guide. - Metro visibility, when the package lives outside your app root
(monorepo /
file:install). Add its path tometro.config.jswatchFolders, and map the peers intsconfig.jsonpaths. The shipped app'sclient/metro.config.js+client/tsconfig.jsonare working references. The package ships TypeScript source, so Metro consumes.ts/.tsxdirectly — no build step.
Codec reality check. The SDK passes streams through untouched
(copy end to end), so the device must decode whatever the operator
broadcasts. A lineup with HEVC/1080p channels needs HEVC-capable
hardware — see source compatibility.
Bare / custom runtimes
The engine core is runtime-agnostic. player.js takes injected
{ http, fs } modules and never imports Node builtins:
import { AliranPlayer } from '@aliran/player-sdk/player.js'
import http from 'bare-http1'
import fs from 'bare-fs'
const player = new AliranPlayer({ panelPubKey, storeDir, http, fs })
index.js (createPlayer) is exactly this with node:http/node:fs wired in.
The Android app's worklet (client/backend/backend.mjs) is the Bare reference.
2. What you need from your operator
The SDK talks to a deployment, so three artifacts come from whoever runs the panel:
| Artifact | Where it comes from | What the SDK does with it |
|---|---|---|
| Panel public key (hex) | Printed at panel init; also in the panel's keys/ |
connect() derives the DHT topic and verifies every catalog read. The entire control plane is signed by this key. |
| An account (username/password) | admin-cli add-user or POST /api/users |
login() runs the OPRF protocol against it. The password never leaves your process in plaintext. |
| Grants | admin-cli grant or POST /api/users/:u/grants |
Decide which streams appear in the display list and which sealed keys the login can unseal. |
No URLs, hostnames, or ports are needed: discovery is the DHT, and
identity is the key. A viewer app config is typically just
{ panelPubKey } plus your own branding.
3. createPlayer(opts) — full configuration reference
Every option is optional except that a panel key must arrive either here or in
connect(panelPubKey).
panelPubKey: string
Hex panel public key (§2).
storeDir: string — default './aliran-store'
The on-disk replica cache. Treat it as disposable. Corruption from
unclean exits is detected (EPARTIALREAD, OPLOG_CORRUPT, …); the store
is purged, and the operation retries once. Everything re-replicates from
peers, and in-memory entitlements survive (the recovered event fires).
Place it in platform cache storage — for the RN worklet, the app files
dir; for Node, any writable path. Deleting it while stopped is always
safe; you lose only warm replicas.
reclaimBudgetBytes: number — default 512 MiB
The ceiling on the watched feed's replica on disk. The engine
measures real allocated size, not logical length. When the feed being
served passes the budget, the engine rotates it: the replica is
purged and the feed is opened again. Deleting the files is the one
reclaim that needs no hole punching, so it frees bytes on every platform
— including the 32-bit Android ABIs, where the addon that punches the
holes is not shipped (it aborts the engine at startup there) and
clear() therefore reports success and frees nothing.
Three things this option does not do, each of which has surprised someone already:
- Only the served feed is measured against it. An idle cached
replica is never rotated, however large it is. Idle feeds are bounded
separately, and by two other things: the store cap below, and
metaBudgetBytes / 4— past a quarter of the metadata budget an idle feed is evicted outright, whatever its blob size and whatever this option says. - It silently moves a second bound. The warm-cache cap is four times
this value (2 GiB by default). Lowering
reclaimBudgetBytesfor a small device also lowers the cap, and idle feeds start being deleted sooner.0disables this bound and leaves the cap at its default. That cap is not a hard ceiling on the store: it is applied to the bytes this pass can actually evict, after the protected feeds (active, cast-pinned, VOD) have taken their share, and the warm cache is never trimmed below one feed's budget. One held feed alone can therefore hold the store above the cap for as long as the pin lasts, by design — the alternative purges the whole warm cache every minute and still never gets under. Size the device for the cap plus the largest feed it may hold. - The Android app now forwards it; the desktop engine does not.
client/backend/backend.mjspasses 128 MiB (deriving a 512 MiB store cap), unconditionally and on every platform — it needs no ABI test because the hole-punch probe already switches the budget off where punching works, so on a 64-bit phone the value is unreachable rather than merely unused. The reason is the TV boxes: a 32-bit ABI cannot punch, so nothing shrinks a replica in place, and those are the same devices that ship 4 GiB of flash in total. The desktop engine still omits it and uses the 512 MiB default.
Values are checked, and the check throws rather than clamps: a
non-number throws, and so does any value above 0 but below 64 MiB. A
32 MiB budget is refused at construction, not raised to 64 MiB — the
misconfiguration has to surface where you can see it, because a tiny
budget rotates the served feed on almost every reclaim pass, which looks
exactly like a broken stream. 0 is always accepted.
0 here is no longer "do not rotate". It switches off the blob
bound, and metaBudgetBytes below rotates the same feed on a trigger of
its own — deliberately gated on neither this option nor the hole-punch
probe. Opting out of rotation entirely takes both
reclaimBudgetBytes: 0 and metaBudgetBytes: 0. This is a change in
what 0 means for an option that already existed; if you set it to opt
out of rotation, set the second zero too.
Requests park during the swap instead of failing, so a rotation is designed to be invisible — but it is not guaranteed, and it can cost the viewer a gap in three ways:
- The park expires. It is bounded at 2.5 s; past that a parked request falls back to a 404. That is the same black gap as before this bound existed, delayed by up to the length of the park — any park that can end in a failure shifts the player's retry ladder by its own length, and only not parking would avoid it.
- The rotation reports success and the viewer still ate a remount.
The delete draws on the park's budget first, and the re-open then keeps
a 1 s floor whatever is left of it. So once the delete passes 1.5 s the
re-open runs past the end of the park: the parked requests wake to a
feed that is not open yet, take the 404, and the player remounts — and
then the re-open succeeds. Nothing throws, no recovery arms, and the
feed:rotateevent that follows reports a normal rotation. It was normal, for the disk. On the hardware this budget exists for (32-bit Android on low-end flash, unlinking a several-hundred-MB replica) this is uncommon but routine over a multi-hour session, not a corner case. - The refill after a clean swap. The re-opened replica is empty, so
the live window re-replicates at about 1x real time while the player
drains its ~10 s buffer — a thin race even when the swap itself was
fast. Measured on a 32-bit TCL box (2026-08-14, swaps 150-662 ms, park
never threatened): two of six rotations froze the picture for ~2.5 s
with no error and no remount, four were invisible.
durationMson thefeed:rotateevent cannot see this case — it measures the swap, not the refill.
This value is a floor, not the ceiling. The ceiling actually applied
is max(reclaimBudgetBytes, 3 x observed live window), where the window
is measured from the playlist the reclaim pass already parsed. A flat
ceiling was wrong: hls_list_size x hls_time reaches 1920 s, where one
healthy live window at 2 Mbit/s is 458 MiB — 90% of the 512 MiB
default — and any channel above ~2.24 Mbit/s passes that default
outright. A flat budget therefore rotated healthy replicas in a loop.
Rotation is also rate-limited to one per five minutes per engine.
A viewer whose filesystem can punch holes never rotates on this bound, enforced rather than assumed. The guarantee is about that capability, not about the ABI. Before the budget is applied the engine probes the store: it writes a scratch file, punches its middle, and re-measures allocated size. If the punch frees bytes this budget is switched off for the life of the handler.
It does not make rotation unreachable, and the older wording here
that said so is corrected rather than dropped: metaBudgetBytes rotates
the same feed past a metadata ceiling the punch cannot help with, and it
is gated on neither this probe nor reclaimBudgetBytes. On hardware that
punches — 64-bit Android, desktop — that is now the only rotation trigger
there is, which is exactly what it was added for.
So a 64-bit viewer is not exempt for being 64-bit. On exFAT, FAT32 or a
network mount the punch does not silently no-op — it rejects
(EOPNOTSUPP / ENOTSUP), the reclaim pass catches and frees nothing,
and the probe measures that. The budget stays armed there and the device
rotates, which is correct: a clear frees no bytes on those filesystems
either. Silent failure is the other case — the 32-bit Android
build, where the missing addon makes the delete report success and free
zero bytes. The engine has to treat the two differently: a rejecting
punch is proof the filesystem cannot reclaim, so the budget is checked
even when the pass did not complete; where nothing is proved either way,
it withholds and retries. An inconclusive probe (a transient I/O error,
or an allocation that has not settled) leaves the budget armed and is
retried — only a measured verdict is permanent.
Measured disk behavior: viewer bandwidth.
metaBudgetBytes: number — default 64 MiB
The ceiling on the watched feed's metadata store — the database half of a replica, the index that maps paths to media blocks. It is the one part of a replica the punch guarantee above does not cover, and it grows on every platform: a followed live channel writes to that database about 1.5 times per second (segment put, expired-segment delete, playlist rewrite), the viewer's replica follows it for as long as the feed stays cached, and a hole punch cannot free any of it — the database's current keys reference interior nodes that live in old blocks, so the engine never clears the metadata store in place. The only reset is the same purge-and-reopen the rotation already does.
Measured on an always-on TV with a working hole punch (10 h soak, 2026-08-15): ~2.7 MB/h on the watched channel's metadata, ~1.1-1.2 MB/h per warm idle feed, +12-17 MB/h store-wide — about 0.3 GB/day, filling a box with 4 GB free in roughly two weeks, while the blob bound held the active feed flat at ~128 MB. That is why this bound exists and why the capability probe does not gate it: the device that punches perfectly is exactly the device where metadata is the growth that remains.
Two thresholds come from the one option:
- The watched feed rotates through the same path as
reclaimBudgetByteswhen its metadata passes the full value — at the measured rate, roughly once per day of continuous same-channel watching. Any natural teardown (app restart, a zap away and back, a catalog re-key) resets the metadata for free and pushes that rotation out; the threshold exists for the always-on session that never tears down. Thefeed:rotateevent names which bound asked (trigger: 'budget' | 'meta'). - Idle cached feeds are evicted outright at a quarter of the
value (16 MiB at the default), during the same maintenance pass as the
store cap. Nobody is watching an idle feed, so the eviction costs the
viewer nothing at the time and one fresh dial on the next tune of that
channel; the engine records a
meta-evictbreadcrumb naming the feed. At most one feed per pass, and the pass runs every 60 s: the session this half is for warms the same prewarm lineup all session, so every idle feed crosses the threshold within minutes of every other, and uncapped, one pass would purge the entire warm cache in a single tick on a box with free disk. Capped, a full 12-feed cache drains over ~12 minutes instead. Disk pressure is the store cap's job, and that one is deliberately not capped.
Values are checked the way reclaimBudgetBytes is checked: a non-number
throws, anything above 0 but below 8 MiB throws (a smaller number
schedules rotation churn without saving meaningful disk), and 0 is the
documented off switch — it disables both halves. It disables the
metadata bound, not rotation: the blob budget still rotates wherever
the probe leaves it armed.
Nobody forwards this option, and that is the decision rather than an
omission. client/backend/backend.mjs passes reclaimBudgetBytes (128
MiB) and deliberately does not pass this one, so every host — Android,
Android TV, desktop — takes the 64 MiB default. It is the right number on
the 32-bit televisions too: a rotation there resets the metadata as a
side effect of the blob bound it already hits, and 64 MiB is proportionate
to the 128 MiB blob budget those boxes run. Forward it only if you have
measured a device that needs something else.
The bound switches itself off if a store cannot free the metadata.
The purge behind a rotation degrades to a plain close when the
filesystem refuses it, and a degraded purge frees nothing — the replica
re-opens over the same metadata store, so the ceiling is still crossed
and the next verdict lands five minutes later, forever, each one costing
a re-dial and possibly a ~2.5 s freeze. So after a 'meta' rotation the
engine re-measures the replica, and it disarms only on both signals
together: the purge itself rejected, and the metadata core is still
over the budget. Both, because a number on its own cannot tell a refused
purge from a purge that worked and a reading that is high for some other
reason.
And on two consecutive rotations, not one. A refusal is usually a
property of the filesystem and occasionally a moment — an EBUSY
unlink, a namespace another session still holds open — and the two leave
identical evidence, so the only thing that separates them is whether it
happens again. The second ask costs one more rotation five minutes later
on a store that would otherwise have rotated for the whole session.
When it does disarm it leaves a meta-rotate-off breadcrumb naming the
numbers, and it disarms both halves: the idle eviction purges
through the same fallback and frees the same nothing, so on a
purge-refusing store it would otherwise re-evict every warm channel once
per warm cycle for ever, paying a hang-up and a cold dial each time and
freeing nothing. A rotation that worked never disarms the bound, an
accepted purge resets the count, and a replica that cannot be measured
leaves it armed. The blob bound is untouched either way.
feedLimit: number — default 12
How many feeds may stay open at once. This bounds handles — open
drives and swarm topics — so browsing a 300-channel catalogue cannot
leave hundreds of both open. It is not a disk bound, and reading it
as one is the common mistake: prewarm opens connections, and it is
playback that fills a replica, so a prewarmed channel nobody watched
holds essentially nothing.
Lower it only where a cached replica is not nearly free. Where the filesystem can hole-punch, an idle feed settles at about one live window and 12 of them cost almost nothing. On the 32-bit Android ABIs nothing shrinks a replica in place, so every byte a zapped-through feed replicated survives until eviction unlinks it — and there, fewer slots genuinely means less disk.
The cost is paid on zap-back. Eviction purges, and a purged replica does not re-attach to an already-established protomux: it needs a full hang-up and re-dial, not just a re-download. A small limit turns "flip between two channels" into that round trip every time.
Values below 2 are refused at construction rather than clamped. Two
slots can never be evicted — the active feed and a cast-pinned one — so
1 cannot hold both, and 0 would leave a tune's in-flight open
protected only by its cache-slot claim. 3 is the smallest value with
any room to spare, and is what client/backend/backend.mjs passes on a
32-bit ABI (where it also caps prewarm to 3).
prewarm: boolean | number — default false
Open entitled feeds' DHT topics right after login, so the first zap
to a channel skips the cold lookup. true warms all entitled feeds; an
integer warms that many, lowest curated order first. This is
bandwidth-cheap, since it warms connections, not downloads. Also
callable later as player.prewarm().
On a 32-bit ABI the Android app caps this to 3 (see feedLimit
above). Warming more than the cache can hold is self-defeating anyway:
the opens past the limit are trimmed straight back out.
tune: { timeoutMs?, relookupMinMs?, relookupMaxMs?, rescanMs? } — defaults 30 000 / 5 000 / (backoff) / 10 000
The tune self-heal ladder's knobs. One tune attempt is bounded by
timeoutMs. The first expiry evicts the cached feed open and retries
once. The second tears down wedged peer connections (transport-alive but
replication-dead) and dials fresh. Only then does a friendly error
surface (≤ ~90 s with defaults). While a tune is incomplete, forced DHT
re-lookups are paced between relookupMinMs and relookupMaxMs. Raise
timeoutMs only for genuinely slow networks — the ladder usually beats
waiting. The engine also emits feed:reconnect outside this ladder. It does
so when you tune a channel whose data it deleted from disk earlier: that feed
only replicates again over a fresh connection, so the engine drops the old
one first (see docs/kb/playback.md).
rescanMs guards the play after a successful tune. A viewer can tune
off relay peers while its dials to the origin fail — the swarm then
forgets the origin, and if the relays later disappear, nothing would look
for a source again for ~10 minutes. When the active live feed holds zero
peers for rescanMs, the engine emits status feed:rescan, forces a
fresh DHT lookup and re-arms the tune ladder. Set 0 to disable.
zapPrefetch: boolean | object — default off ("Smooth zapping")
While a stream plays, keep the newest segment of the adjacent channels (curated zap order) replicated locally, so CH+/CH− starts from warm bytes. This costs standing bandwidth — about each warmed neighbor's bitrate. That's why it's off by default, and why it's designed to be a user-facing choice, not a silent default.
true enables the adaptive defaults; an object tunes them:
| Key | Default | Meaning |
|---|---|---|
neighbors |
1 | How many channels on each side to warm. |
intervalMs |
4000 | Warm-loop tick. |
directional |
true |
Once the surf direction is known (an adjacent-channel move), warm only that side. This halves the standing cost for CH+/CH+/CH+ patterns. A menu jump resets to both sides. |
stallMs |
12 000 | Suspend when the active playlist stops advancing this long (your own stream is starving). |
resumeMs |
60 000 | Clean-advance run required before a stall/thin suspension lifts. |
minHeadroom |
3 | Neighbor segments must download ≥ this × realtime, else the pipe has no room and prefetch suspends. |
The engine suspends itself — dropping the standing downloads, but
keeping the tick alive to observe recovery — on: a metered network
(setNetworkProfile), an active stream stall, or a thin pipe. It reports
every transition as a zap-prefetch event
(reason: 'metered' | 'stall' | 'thin'). Runtime-switchable with
setZapPrefetch().
uploadPolicy: 'reseed' | 'client-only' — default 'reseed'
'reseed' joins feed/assets topics announced: blocks this viewer already
replicated are served back to other viewers on request. This is the
opportunistic upload that makes the P2P model work. 'client-only'
joins unannounced: the peer is undiscoverable on those topics, so
other viewers can never dial it. That gives practically zero
viewer-to-viewer upload by construction, at the swarm-wide cost of one
fewer re-seeder. The viewer's own playback is unaffected. Switchable
live with setUploadPolicy() — the standard pattern is wiring it to the
platform's metered-network signal. Measured numbers:
viewer bandwidth.
remote: { sendToTv?, control?, keepSignIn? } — default all off
The three "send to TV" features. Every one is off unless you name it, so a build that omits this object joins no rendezvous, holds no account keys and keeps nothing on disk.
| Flag | What it turns on | Cost while on |
|---|---|---|
sendToTv |
sendSignIn() — the phone half of a phone→TV sign-in. |
Every login keeps the account's two private keys in memory for the session. sendSignIn() cannot recover them later without it. |
control |
startRemote() and the rest of "play on my TV". |
The login keeps the account rendezvous secret in memory. With the flag off, no rendezvous topic is ever joined. |
keepSignIn |
The receiving half: a signin-keys event, so a television can persist the handover and come back through signInWithKeys(). |
The host is given account private keys to write to a key store. This is a property of the build, not of a session. |
remote: true is shorthand for sendToTv and control only — the two
that are about memory. keepSignIn is about a disk and has to be asked
for by name, because a host that wanted sendSignIn() on a phone should
not silently get account keys at rest on a device that has a keyboard and
a password.
On a television the first two are usually the wrong way round from a
phone's: sendToTv off (a set never holds an account it could pass
on) and keepSignIn on. Read
Account keys at rest
before you turn keepSignIn on.
Casting needs no flag. It needs an injected os module (createPlayer()
wires node:os; a Bare worklet passes bare-os), or an advertiseHost
on every startCast() call.
swarm: { maxPeers?, bootstrap? }
Tuning for the engine's single Hyperswarm. Ordinary viewers omit the whole object.
maxPeers— total-connection budget (hyperswarm default 64, plenty for a viewer). SDK-based seed nodes and repeater-style hosts raise it into the hundreds, to hold big fan-out while re-seeding (scaling).bootstrap: [{ host, port }, …]— custom DHT bootstrap nodes, for local DHT testnets or private-DHT deployments. Omit for the public DHT.
hybrid — leave unset
A config-driven CDN↔P2P failover engine that predates
redirect channels. It survives as e2e-harness
infrastructure and is not a product path. The default, p2p-only,
is the shipped behavior. The product CDN mechanism is the redirect
channel class, which needs no client config at all.
4. Runtime control surface
| Method | What it does |
|---|---|
connect(panelPubKey?) |
Join the panel topic, replicate the signed DB. Emits ready. |
login(username, password) |
OPRF login → display list. Throws not connected to panel while the swarm is still dialing. Retry on that message (see the pattern below). |
listStreams() |
Last display list (also re-delivered via the streams event). |
resolve(streamId) |
Serve an entitled stream; see §5 for the contract. |
source() |
{ streamId, source, url } of the active stream, or null. |
serveFeed(feedKey, encKey) |
Low-level direct-play from raw keys, no login (dev/diagnostics). Returns the port. |
assetUrl(path) |
Catalog art path → localhost URL (absolute http(s) URLs pass through). |
prewarm() |
Warm entitled feeds' topics now. |
setZapPrefetch(v) |
Runtime Smooth-zapping switch; applies mid-play, echoed as zap-prefetch {enabled}. |
setNetworkProfile({ expensive }) |
Host network hint: expensive: true suspends zap-prefetch until the network is cheap again. Wire it to NetInfo (isConnectionExpensive / cellular). |
setUploadPolicy(policy) |
Live upload-policy flip. Re-joins active topics with the new announce flag, and tears down standing reseed connections without blipping playback. Resolves { policy, changed, rejoined }, echoed as an upload-policy event. |
reconnectActiveFeed() |
Tear down the active feed's peer connections and dial fresh — the wedged-transport escalation; the tune ladder calls it for you. |
reclaimStatus() |
Diagnostics: what the serving core decided about this device's disk bounds — the budgets it measures against, whether each is armed, and the hole-punch capability verdict (punch: { ok, canPunch, reason, freed, wideFreed }) that decides whether the blob budget applies here at all. Synchronous, no I/O. null before the loopback server exists and after stop(). punch is null until a reclaim tick has probed — which needs a feed being served and the blob budget enabled, since the probe runs only from that budget's gate: with reclaimBudgetBytes: 0 there is never a verdict. See §3 reclaimBudgetBytes. |
checkUpdate({ appId, platform, versionCode }) |
OTA: look the running build up in the operator's update manifest → { status, entry?, mandatory? } (unknown while the drive is still cold). |
downloadUpdate() |
OTA: fetch + sha256-verify the update the last available check found — throttled update-progress events, then update-ready { path, entry }. Operator side: App updates. |
stop() |
Full teardown. Ends a cast session and leaves the rendezvous first. |
Sign a television in from a phone (§9). All of it reports through the
signin-pair event; three of those states are questions that block
until the host answers.
| Method | Role | What it does |
|---|---|---|
startSignInPairing({ ttlMs?, pinMs?, payloadMs? }) |
TV | Mint a code, announce its rendezvous, and resolve at once with { code, expiresAt, done } to put on screen. Needs no panel connection first — the handover carries the operator key. |
submitSignInPin(pin) |
TV | The four digits the viewer typed on the remote. One attempt; a well-formed wrong answer ends the sign-in with the code spent. |
confirmSignInService(ok) |
TV | Answer the confirm-service question: sign in as that account, and (when adopting) take that operator key. |
cancelSignInPairing() |
TV | Abandon the code on screen. It is spent either way. |
sendSignIn(code, opts?) |
Phone | Sign a TV in with the code it shows. Resolves { done } once the rendezvous is joined. Needs a live session and remote: { sendToTv: true }. |
confirmSignInMatch(ok) |
Phone | Answer the match question: do the four digits on this phone appear on the TV? This is the check that sees a relay — never default it, and never let a dismissed dialog answer it. |
cancelSendSignIn() |
Phone | Abandon an in-flight send. |
signInWithKeys(username, { priv, authPriv }) |
TV | The same session entered with the account keys instead of a password — the door a television comes back through after a restart. Read the rejection rules below before you call it. |
"Play on my TV" (§10). Needs a live session and remote: { control: true }.
| Method | What it does |
|---|---|
startRemote({ role?, label?, acceptPlay? }) |
Join the rendezvous the account's own devices meet on. 'tv' announces and accepts commands; 'controller' looks up, never announces, and sends them. |
listRemotes() |
Devices of this account that have proved themselves. Build a picker from the ones whose role is 'tv'. |
remotePlay(deviceId, streamId) |
Ask a television to play a channel. Resolves on acceptance — what happened arrives as a status push. |
remoteStop(deviceId) |
Ask that television to stop. |
setRemoteAccept(ok) |
TV: the take-over switch, for the RUNNING session. Off refuses play and stop. Persisting it is the host's job — pass it as startRemote({ acceptPlay }) too, or it is on again at the next boot. |
updateRemoteStatus({ state?, position? }) |
TV: the two things only a host knows. The engine already publishes the channel and whether it plays. |
stopRemote() |
Leave the rendezvous. Idempotent. |
Cast to a television on the LAN (§11).
| Method | What it does |
|---|---|
startCast(streamId, { advertiseHost?, receiverHost?, readIdleMs?, reclaim? }) |
Stand up a second, LAN-scoped media server for one channel and resolve a CastSession. Rejects when this device has no private IPv4. |
stopCast() |
Close the socket, kill the token, unpin the feed, run one reclaim pass. On a 32-bit Android build that pass frees no bytes — the store shrinks when the replica rotates or is evicted (reclaimBudgetBytes, §3). |
castSession() |
The live session, or null. |
The login retry pattern every host should use:
let streams
for (let i = 0; ; i++) {
try { streams = await player.login(user, pass); break }
catch (err) {
if (i < 30 && /not connected to panel/.test(String(err.message))) {
await new Promise(r => setTimeout(r, 1000)); continue
}
throw err
}
}
5. The resolve() contract
const r = await player.resolve(streamId)
// r = { url, source: 'p2p' | 'cdn', localUrl?, port?, feedKey, headers?, type: 'live' | 'vod', durationSec? }
- P2P stream →
source: 'p2p',url=localUrl=http://127.0.0.1:<port>/index.m3u8. The feed replicates and is served progressively: bytes reach the player as they arrive, playlist requests are held briefly instead of 404ing, and the live edge is read ahead. - VOD title (a library title,
type:'vod'in the catalog) → same localhost serving, but the playlist is a finished VOD rendition (#EXT-X-PLAYLIST-TYPE:VOD, every segment listed,#EXT-X-ENDLIST). You can seek freely — any byte of any segment is Range-served and demand-paged over P2P — and pause indefinitely.typeis'vod', anddurationSeccarries the runtime (nullif the catalog lacks it). None of the live machinery arms: no tune watchdog, no zap prefetch, and nofeed-changedfollow (a re-ingest applies on the nextresolve()), and nostatus/errorself-heal events for it. A stalled download is the host player's to surface, withreconnectActiveFeed()as the manual redial. Build seek/pause UI offtype === 'vod', never off a URL shape. - Redirect channel →
source: 'cdn',urlis the operator's remote URL verbatim,localUrl/portareundefined,feedKeyisnull. There is no feed, no swarm join, and no watchdogs — remote-URL errors belong to the host player. A hotlink-protected channel also returnsheaders(a lower-cased subset ofreferer/origin/user-agent) that the host player must send with every request forurl, or the provider answers403.headersisundefinedon every other branch and on redirect channels that need none. The value is live: a source refresh that rotates the URL and its headers reaches the viewer on the next tune, with no re-login. The RN player, the desktop app and thealiran-kitKotlin binding do this for you; any other custom host must forward them (see the SDK reference). - Not entitled → throws
not entitled to <id>. - Entitled but no broadcaster feeding it (
feedKeynull in the catalog) → throwschannel is not broadcasting right now(for vod:title is not available right now). Show it as a friendly state, not a crash.
One localhost URL serves whatever feed is active: zapping re-uses the same server/port. That's why the RN binding identifies the playing channel by the engine's confirmation, never by URL. Do the same in custom hosts.
6. Events reference
player.on(name, fn). The emitter never throws on unhandled error.
| Event | Payload | Host action |
|---|---|---|
ready |
— | connect() finished; safe to login(). |
streams |
Stream[] |
Render the lineup. Fires at login and live on any panel catalog edit (title/art/isLive/order/categories) — no polling, no re-login. A newly granted stream still needs the next login. |
status |
{ state: 'feed:open' \| 'feed:ready' \| 'feed:retune' \| 'feed:reconnect' \| 'feed:rescan' } |
Drive a tuning indicator: open means a cold tune started, ready means playable, and retune/reconnect/rescan mean self-heal in progress. Say "reconnecting…" — don't freeze a spinner at a fake percentage. |
status |
{ state: 'feed:rotate', streamId, message, trigger?, bytes?, meta?, durationMs?, skipped?, failed? } |
The viewer-disk rotation (§3, reclaimBudgetBytes / metaBudgetBytes): the engine purged and re-opened the active replica to free disk. Three shapes, told apart by durationMs / skipped / failed — not by bytes. Success: durationMs set, and bytes set but possibly null. Refused: skipped: 'cast-pinned', no rotation happened. Failed: failed: true, the re-open died; the engine retries immediately and falls back to the tune ladder only if that also fails, so it is recoverable but worth logging. trigger names which bound asked and is set on all three shapes, the failed one included — that is the shape you investigate, so it is the one that has to be attributable: 'budget' is the blob bound (fires only where the filesystem cannot punch), 'meta' is the metadata bound (fires on any platform — a 'meta' rotation roughly daily on an always-on device is the design working, while frequent 'budget' rotations on hardware that should punch are worth investigating). On success do not show a spinner — requests parked across the swap and it is emitted after the swap anyway, so it is telemetry, not a cue. durationMs times the whole rotation, and the drain (≤6 s) and the measurement (≤5 s) both run before the park is armed, so a value above 2500 is necessary but not sufficient evidence that the park expired: a 3 s drain plus a 200 ms swap reports ~3200 ms with nothing ever parked. Treat it as a reason to investigate, not as a count of viewer-visible gaps — and note that a rotation can cost the viewer a remount while still reporting success (§3). bytes is not bytes freed: it is the replica's measured size before the purge, taken up to one reclaim tick plus the drain earlier, and null where the platform could not measure. Read it as an approximation of what the replica held; the feed re-downloads a live window straight afterwards. meta is the metadata store's share of that same measurement. |
peers |
number |
Peer count of the served feed, every 3 s while serving. |
feed-changed |
{ streamId, feedKey, url } |
The watched stream's feedKey rotated (broadcaster restart/rotation). The engine already re-resolved and swapped the served feed behind the same url. Reload or remount the player to flush the stale playlist. No re-login, no resolve() call needed. |
zap-prefetch |
{ enabled? } or { state: 'suspended' \| 'resumed', reason: 'metered' \| 'stall' \| 'thin' } |
Reflect the Smooth-zapping toggle / adaptive gate in UI if you surface it. |
upload-policy |
{ policy, rejoined } |
Confirmation of a live setUploadPolicy(). |
recovered |
Error |
Corrupt store purged + retried automatically; informational. |
error |
Error |
Friendly, surfaced failures (e.g. the tune-timeout message). Show, offer retry. |
signin-pair |
{ role, state, code?, sas?, pin?, username?, panelPubKey?, pairingCode?, adopting?, reason?, message? } |
Progress of a phone→TV sign-in (§9). Three states are questions and the exchange blocks on them: match (phone → confirmSignInMatch), pin-entry (TV → submitSignInPin), confirm-service (TV → confirmSignInService). code, sas and pin are live secrets for the length of the exchange — put them on a screen and never in a log. |
signin-keys |
{ username, priv, authPriv, panelPubKey } |
The account keys a received handover was given, emitted once so a television can persist them and come back through signInWithKeys(). Fires only on a build with remote: { keepSignIn: true }. emit() is synchronous, so any retry of your own write belongs on your side of the listener — there is no second delivery. |
remotes |
RemotePeer[] |
The account's own other devices on the rendezvous; re-emitted on every change. deviceId/label/role are each device's own claim — a handle for a picker, not a credential. |
remote |
{ role, state, streamId?, restricted?, title?, command?, reason?, from?, status? } |
Role 'tv': the commands this device was given. Role 'controller': what a television it points at is showing. state: 'play' is a command, not a notification — the engine checked entitlement and deliberately did not tune, so the host tunes it, and a restricted channel must go through the same parental-PIN gate a local zap goes through. |
cast |
{ state: 'ended', streamId, reason } |
A cast session ended on its own — the pinned feed was purged, or a retune abandoned or failed. The server is closed and the token is dead; stop showing "Casting". stopCast() does not emit this. |
fallback, source-changed |
see index.d.ts |
Internal hybrid mode only — production apps never receive them. |
7. React Native binding configuration
AliranBackend
const backend = new AliranBackend()
backend.start(bundle, opts /* StartOptions */)
StartOptions = { panelPubKey, hybrid?, prewarm?, tune?, zapPrefetch?, swarm?, uploadPolicy?, remote?, appVersion?, platform?, debug? }.
These are the same knobs as §3, with two differences: hybrid.cdnUrl
must be a template string, since functions can't cross the worklet
IPC, and debug: true logs every backend message
(adb logcat -s ReactNativeJS). The worklet owns storeDir. remote is
boot-time: by the time a runtime switch could be flipped, the login
has already happened and the material is either kept or unrecoverable.
Methods: login(u,p) · play(streamId) · playRaw(feedKey, encKey) ·
reconnect() · setZapPrefetch(v) · setNetworkProfile(expensive, cellular?) ·
onMessage(fn) (returns an unsubscribe) · prefs: requestPrefs() /
saveCredentials(u,p) / clearCredentials() / toggleFavorite(id) /
isFavorite(id).
Phone→TV sign-in (§9): startSignIn() / submitSignInPin(pin) /
confirmSignInService(ok) / cancelSignIn() on the television, and
sendSignIn(code) / confirmSignInMatch(ok) / cancelSendSignIn() on
the phone. resumeSignIn() is the next-start door on a build with
remote: { keepSignIn: true }; it answers { ok, error?, retry? }, and
retry is what decides whether to keep the stored material — see the
rejection rules in §9.
debug: true ships in release builds of the reference app, so the
logger is production code, not dev instrumentation. It excludes every
signin-* and vault-* message by prefix. If you add messages that
carry a code, a PIN, a cast token or key material, exclude them by prefix
too — not by exact name.
The package also exports secureKeyStatus() and secureReset() from the
Android Keystore half. secureKeyStatus() reports rather than finds
out: it will not create a key in order to describe one, so a device that
never kept a sign-in answers keyPresent: false with an unknown
security level rather than claiming there is no hardware key store.
Cached state for late-mounting screens — the one-shot replies may land
before your screen exists: backend.streams, .port, .url, .source,
.activeStreamId (the engine-confirmed playing channel — the thing to
trust, since one URL serves every channel), .creds, .favorites.
Messages arrive as the BackendMessage union (streams, port, status,
error, login-error, fallback, source-changed, feed-changed,
zap-prefetch, prefs, signin-pair, signin-started, signin-sending,
signin-ack, signin-resumed) — all typed in the package. The engine's
sign-in vocabulary grows and this build's copy of it does not, so check a
reason against isSigninPairError() before you switch on it, and always
keep one sentence for the codes you do not recognise.
<AliranVideo>
Chrome-free video surface. Overlays belong to the host app via callbacks
(client/src/screens/LiveScreen.tsx is a complete dogfooded example).
| Prop | Purpose |
|---|---|
backend, streamId |
Required wiring. |
autoPlay, paused, controls, style, resizeMode |
Standard surface control. |
onTune(e) |
Drive your tuning indicator from this, not raw player events. After a zap, the previous channel keeps playing under the same URL until the engine flips the feed. Phases per monotonic tune id: start → (retune or reconnect — self-heal, show "reconnecting") → playing — the first real playback of this tune, dismiss the indicator here. The friendly tune-timeout arrives via onError and ends the tune. |
onPeers, onBuffering, onSource, onError |
Status surface. |
onFeedChanged |
Informational — the component already remounts itself on feed rotation. |
onStall |
Fired when the frozen-live-edge self-heal kicks in: the playhead is still for stallTimeoutMs while "playing", which triggers a resync remount at the live edge, which escalates to backend.reconnect() if a resync mount doesn't play within another window. |
stallTimeoutMs |
Default 12 000 — the freeze detector above. |
bufferConfig |
Merged over the zap-tuned ExoPlayer defaults (playback starts at ~1 s buffered instead of ~2.5 s). Raise if your feeds need more headroom. |
selectedAudioTrack, selectedTextTrack, onAudioTracks, onTextTracks |
In-stream audio/subtitle track selection. |
videoProps |
Escape hatch: extra props onto the underlying react-native-video. |
EPG (program guide)
Catalog entries may carry epgUrl/epgId pointers. Schedule data is
never in the replicated catalog. The binding ships the data layer:
import { useEpg } from '@aliran/react-native'
const { data, loaded } = useEpg(stream.epgUrl, stream.epgId) // { now, next[] }
EpgService (or the shared epg singleton) sits underneath: a per-URL
cache with ETag revalidation, so one fetch covers every channel sharing
the URL. Options (EpgServiceOpts): maxBytes (8 MiB), minRefetchMs
(5 min), maxAgeMs (3 h), fetchTimeoutMs (15 s), nextCount (4), plus
injectable fetchImpl/now for tests. Playback never depends on it — a
missing or unreachable feed just yields no guide.
App updates (OTA)
The binding carries the whole in-app update path, so an SDK-based app
gets OTA with no native code of its own: backend.checkUpdate(appInfo)
and backend.downloadUpdate() drive the engine (subscribe with
backend.onUpdate(fn) for update-status / update-progress /
update-ready / update-error), and the package's native Android
module supplies getAppInfo() (the running build's identity),
canRequestInstall() / openInstallSettings() (the "install unknown
apps" gate), and installApk(path) (a PackageInstaller session; the
library manifest-merges REQUEST_INSTALL_PACKAGES into your app). The
operator flow is App updates;
client/src/update.ts + UpdateBanner.tsx are the dogfooded consumer.
8. Sessions, devices, and cooperative revocation
login() enrolls a device, subject to the account's maxDevices (the
oldest is evicted), and the panel signs a session token. Two helpers ship
for hosts that keep sessions across launches:
checkSession(panelPubKey, token)— offline: signature + expiry → payload ornull.sessionLive(db, payload)— online: checks that the device is still enrolled, with a matchingtokenVersion, in the replicated user record. This is what notices an admin's per-device revoke — a well-behaved client drops to the login screen.
This is cooperative session hygiene, not content protection. Real access revocation is grant removal plus stream-key rotation, on the operator side (details).
A device that holds a working credential signs itself back in. That has always been true of a saved password, and it is now also true of a television that kept a handover (§9). Neither revoke device nor log out all devices stops it. Changing the password does, because it re-keys the account. Read the residual-risk register before you decide what your host app does with a refusal.
9. Sign a television in from a phone
A television has a remote control, not a keyboard. The set shows a
12-character code, the viewer types it on a phone that is already signed
in, and the phone hands the account over. The television registers its
own device and takes its own panel-signed token, so maxDevices,
the device list and per-device revoke keep working per device. The
password never crosses.
The operator key crosses with it, so one action both sets the service and signs in — a set never types 64 hex characters, or the operator's own pairing code, on a remote.
The two halves
// TELEVISION
const { code, expiresAt, done } = await tv.startSignInPairing()
showOnScreen(code) // 12 characters, ~3 minutes
done.then(streams => enterTheApp(streams)).catch(showFailure)
// PHONE (already signed in, built with remote: { sendToTv: true })
const { done } = await phone.sendSignIn(codeTheViewerTyped)
Everything else arrives on the signin-pair event. Three states are
questions, and the exchange stops until you answer them.
| State | Who sees it | What the host must do |
|---|---|---|
code |
TV | Show the 12 characters and the countdown. |
match |
both; answer on the phone | Show the four digits (sas). On the phone, ask "does the television show these same four digits?" and answer confirmSignInMatch(ok). |
pin |
phone | Show the four digits (pin) for the viewer to type on the television. |
pin-entry |
TV | Ask for four digits and pass them to submitSignInPin(pin). One attempt. |
confirm-service |
TV | Show username, panelPubKey and its printed pairingCode, say whether this set is adopting a new operator, and answer confirmSignInService(ok). |
signed-in / failed |
either | Finish, or show reason and message. |
Rules that are not optional
- Never log
code,sasorpin. All three are live secrets for the length of the exchange. The React Native binding excludes everysignin-*andvault-*message from its debug logger by prefix for this reason; if you add messages of your own, exclude by prefix too. - Never default the
matchanswer, and never let a dismissed dialog answer it. That comparison is the only check that sees a peer relaying between the two devices. - Do not describe this flow as phishing-proof, in your UI or your documentation. The two checks remove a static lure and a silent relay. An attacker who is present in real time defeats both, and so does a viewer who approves without comparing. Say what the viewer must actually check.
- Treat a mismatch as final. Tell the viewer to stop, not to try again on the same network. Each attempt is an independent 1-in-10 000 chance for a relay.
- Every failure spends the code. Mint a new one; do not retry with the old one.
Keeping the sign-in across a restart
Set remote: { keepSignIn: true } by name and the engine emits
signin-keys once after a handover. Put those keys somewhere the
platform protects — the shipped app seals them under an Android Keystore
key — and come back through signInWithKeys(username, keys) on the next
start.
Split the rejections, and let the default run toward keeping. Erasing is the one irreversible act here and it costs a viewer a walk to another room for a phone.
Every message the panel's session responder and the engine's own login
path can produce, and what each one means for a stored sign-in. The table
is generated from client/backend/signin-vault.mjs, and
npm run test:signin-vault fails when a copy of it drifts — including
this one. Change the predicate, not the table.
ERASE authPriv must be 64 bytes (hex or buffer)
ERASE key handover does not match this account
ERASE panel returned an invalid session token
ERASE session failed: account disabled
ERASE session failed: unknown user
ERASE the panel issued no session token — the key handover did not sign this device in
KEEP invalid credentials
KEEP key recovery failed
KEEP session failed: auth failed
KEEP session failed: bad request
KEEP session failed: busy
KEEP session failed: device-limit
KEEP session failed: missing deviceId
KEEP session failed: no session challenge (login first)
KEEP session failed: sessions unavailable
KEEP the panel issued no session challenge — it is too old for a key handover
KEEP unknown user
Two rows differ only by the session failed: prefix, and they are
opposites. The bare form is thrown after reading this device's own
replica of the signed record, which on a cold start has simply not
caught up yet; the prefixed one is the panel's own database answering.
Everything the engine can fail on that is not a message on that table
keeps the keys too: not connected to panel, a closed channel, a swarm
still dialling, a key store that did not answer, a host that did not
answer in time. Two more conditions erase, and no message can name them
for you: a stored record that fails its own integrity check, and a record
naming an operator this device has left.
"Anything the panel said" is not the rule, and reading it that way
destroys credentials. Most of what the session responder answers is a
refusal of the request or a fact about the operator's configuration —
a malformed call, a lost one-shot challenge, a panel missing its own
signing key, an operator whose device slots are full, a moment of write
contention. None of those is a judgement on the keys, and the table is the
only thing that says which ones are.
A note on the device limit, because the reason it keeps has changed even though the answer has not. A device enrolment no longer expires on its own, so a slot no longer frees itself simply by waiting: it frees when a viewer signs another device out, when an admin revokes one, when the operator raises the limit, or when a new device pushes out the least recently used one. Keeping is if anything more clearly right than before — a device that threw its keys away would have to redo a handover to meet a limit that will not clear itself.
Bound your retries by panel logins, not by seconds. Every attempt
that reaches the panel spends a login the panel's throttle counts, per
account and per peer. not connected to panel is thrown before anything
leaves the device, so those cost nothing. A loop that cannot tell the two
apart locks the account out of the panel it is trying to reach — and the
first thing it locks out is your own sign-in screen, seconds later, in
front of a viewer holding the correct password.
10. Play on my TV
Two devices of one account meet on a rendezvous derived from the account key. There is no code and no viewer action. A television announces; a controller looks up and never announces.
await tv.startRemote({ role: 'tv', label: 'Living Room' })
await phone.startRemote({ role: 'controller', label: 'Ana’s phone' })
phone.on('remotes', peers => renderPicker(peers.filter(p => p.role === 'tv')))
await phone.remotePlay(deviceId, streamId)
state: 'play' on the remote event is a command, not a
notification. The engine checks the channel against the receiving
device's own entitlements and then deliberately does not tune it. Your
host tunes it — which is what keeps your own gates in front of it:
tv.on('remote', async info => {
if (info.role !== 'tv' || info.state !== 'play') return
if (info.restricted && !(await yourParentalGate())) return // NOT optional
await tune(info.streamId)
})
The parental-PIN gate is your obligation and the SDK cannot enforce
it. An engine that tuned for the peer would make "play on my TV" the
documented way past that PIN. Where the engine cannot read the channel's
catalog record it refuses the play outright rather than guessing at the
flag, so it never reports a parental state it did not read — but where it
can read it, a host that ignores restricted has no parental control on
this path, and the engine cannot tell.
Two more things worth knowing before you build a picker:
deviceId,labelandroleare each device's own claim. They are authenticated only as far as "some device of this account". The panel's device list is the authority on identity everywhere else.- The rendezvous cannot be revoked for one device. Only "log out all
devices", a password reset or disabling the account move the household
to a new rendezvous.
setRemoteAccept(false)is the per-set opt-out inside the protocol. - If you offer that opt-out, you own making it durable. The engine has no
prefs file, so
setRemoteAccept()is session state: persist the viewer's choice yourself and pass it asstartRemote({ acceptPlay }). CallingsetRemoteAccept(false)after the join has resolved is too late — the set is announced and taking commands from the moment the join lands, so that spelling leaves a window on every boot. Call it for the running session and passacceptPlayfor the next one. The shipped apps do exactly this: the worklet keeps the preference beside the parental PIN and resolves it into its ownstartRemote().
remotePlay() rejects with a RemoteControlError: unknown (not on the
list, or not a television), unavailable (it accepted and could not carry
the command out — a catch-all, most often a catalog record it could not
read, and never "nothing is broadcasting"), and timeout, which never
means the device declined.
11. Cast to a television
startCast() stands up a second HTTP server beside the loopback one.
It exists only while the session does, it binds one private LAN
address, and it serves only /cast/<token>/… from one pinned feed
drive — so the receiver keeps the channel it was given while the phone
zaps somewhere else.
const s = await player.startCast(streamId, { receiverHost: '192.168.1.128' })
// s = { url, streamId, source, host, port, token, receiverHost, feedKey,
// type, headers?, candidates? }
sendToReceiver(s.url)
player.on('cast', e => { if (e.state === 'ended') stopShowingCasting(e.reason) })
await player.stopCast()
A redirect channel casts for free: source: 'cdn', url is the
operator's remote URL, and no local server is stood up. headers comes
with it when the provider checks them — a receiver that cannot send them
will get a 403.
What a host must get right
- The token is a scope, not a secret the network keeps. A receiver
hands the whole media URL back to any unauthenticated peer that joins
its session — measured on a real Google TV, where a process that had
never seen the URL read it in full off port 8009. Pass
receiverHostonce you know which device you launched on: it makes the receiver's address part of the boundary. It is off by default, because the SDK does not speak the Cast protocol and cannot find that address. A multi-room group fetches from every member, so pass every member's address or leave the pin off for groups. An empty array throws — it is not a way to say "unpinned". - Treat "unpinned" as a state your UI acknowledges, not a default it inherits.
- Say what casting costs. The phone becomes the origin server: it
decrypts and serves while it does, it must stay awake and on the
network, and disk grows by about the channel bitrate for the session
(roughly 0.9 GB per hour at 2 Mbit/s), because block reclaim is off for
a pinned feed.
stopCast()reclaims; an app killed mid-cast does not. That is the same growthreclaimBudgetBytesbounds for an ordinary feed (§3), but a cast feed is pinned by design — so on a hole-punching filesystemstopCast()is the reclaim that ends it. On 32-bit Android that pass frees nothing, and a cast-pinned feed there has no disk bound at all while the pin lasts: rotation refuses a pinned feed and the store cap counts it as held. The bytes come back only after the pin is released and the replica is unlinked. Read viewer bandwidth before you ship casting on a 2-4 GB box. - Never log the token, and never put it in an IPC message your debug logger prints.
- Offer
candidates.os.networkInterfaces()cannot tell Wi-Fi from a Hyper-V, WSL or Docker bridge, so the advertised address is a guess. If a receiver never connects, offer another candidate and restart with{ advertiseHost }. advertiseHostis the only thing that widens the bind. An address this device does not own falls back to a bind on every interface, and nothing warns about it.
startCast() rejects when this device has no private IPv4 — it will not
advertise a public address. readIdleMs defaults to 12 000 ms here
(twice the loopback value, which is calibrated to ExoPlayer rather than to
a television); 0 disables the stalled-read abort.
The receiver application is Google's stock Default Media Receiver
(CC1AD845) — no registration, no fee, no hosted page. An operator who
wants a receiver with their own branding registers an application id of
their own and gives it to the sender; the bytes this SDK serves are the
same either way.
Full exposure analysis: the residual-risk register.
12. Troubleshooting
| Symptom | Cause / fix |
|---|---|
login throws not connected to panel |
DHT still dialing — retry loop (§4). Persisting >30 s: wrong panelPubKey, or the panel is down/unreachable. |
channel is not broadcasting right now |
Catalog entry exists but no feedKey — the broadcaster hasn't fed it (or is stopped). Operator-side state; show it gracefully. |
| Tune-timeout errors on one channel | The channel may be unreachable or unseeded right now. The message suggests switching to it again — the ladder already evicted the poisoned open, so a re-zap retries fresh. |
| Black video, audio fine (or instant player error) on some channels | Device lacks the codec (HEVC lineup on an h264-only device) — source compatibility. |
| Release APK can't play (dev build can) | Cleartext-to-loopback missing — client build. |
recovered events after crashes |
Normal: the disposable store self-healed. Frequent recoveries = the host is killing the process uncleanly. |
| First zap slow, later zaps fast | Cold DHT lookup. Enable prewarm. |
sendSignIn throws about a missing feature |
The phone build was not constructed with remote: { sendToTv: true }. The flag is boot-time — restart the engine with it (§3). |
No signin-keys event on the television |
remote: { keepSignIn: true } must be asked for by name. remote: true is shorthand for the other two flags only (§3). |
| A television falls back to its sign-in screen on every start | Its stored keys are being erased, or the account is gone. Check your rejection classification against §9 first — treating any panel error as terminal destroys working credentials. A deleted account is kept, not erased, and re-creating the username evicts the set instead of restoring it. |
login failed: locked on a television nobody is typing at |
The restore loop spent the panel's login budget. Bound retries by panel logins, not by seconds (§9). Restarting the app clears it — the throttle's peer half is new for each process. |
startCast() rejects with no private IPv4 |
This device has only a public address, or no os module was injected. Pass advertiseHost. |
| The receiver plays nothing, or fetches the playlist and no segments | Cross-origin headers or the address. Check the receiver can reach host; if not, offer another entry from candidates and restart with { advertiseHost }. |
| "Casting" stays on screen after the session died | Subscribe to the cast event: { state: 'ended' } means the server is closed and the token is dead. stopCast() does not emit it. |
Deeper playback internals: playback & client runtime and the feed buffer & tuning pages.