Skip to content

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 install never 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 is linux-arm64 and works; the same board on a 32-bit OS does not.
  • TypeScript definitions ship in the package (index.d.ts) — no @types needed.
  • 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:

  1. Apply the bare-kit lazy-load patch (patch-package): client/patches/react-native-bare-kit+0.13.3.patch — copy it into your app's patches/ and add the standard patch-package postinstall. It rewrites the module's 22 bare_* calls to go through a dlopen("libbare-kit.so")/dlsym table, resolved lazily and only on API 29+, so no DT_NEEDED survives into libappmodules.so, and it drops the package's minSdk to 24. The engine still ships in the APK (jniLibs are untouched); below Android 10 it is simply never loaded, and a stray init throws a clean JS error instead of a native crash. Set your app's minSdkVersion to 24. (Until this behavior lands upstream in react-native-bare-kit, the patch is version-pinned — regenerate it when you bump the package.)

  2. Gate on AliranBackend.isSupported(). On any Android below 10 it returns false by 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 no onMessage listener 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.loadLibrary sits 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() is false, 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.jsonsrc/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:

  1. 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-http1 wiring into app.bundle) is the client build guide; the shipped app's client/backend/ is the working example.
  2. 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 only (network-security-config). Details are in the client build guide.
  3. Metro visibility, when the package lives outside your app root (monorepo / file: install). Add its path to metro.config.js watchFolders, and map the peers in tsconfig.json paths. The shipped app's client/metro.config.js + client/tsconfig.json are working references. The package ships TypeScript source, so Metro consumes .ts/.tsx directly — 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.

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().

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.

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.

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.
stop() Full teardown.

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, type: 'live' | 'vod', durationSec? }
  • P2P streamsource: '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. type is 'vod', and durationSec carries the runtime (null if the catalog lacks it). None of the live machinery arms: no tune watchdog, no zap prefetch, and no feed-changed follow (a re-ingest applies on the next resolve()), and no status/error self-heal events for it. A stalled download is the host player's to surface, with reconnectActiveFeed() as the manual redial. Build seek/pause UI off type === 'vod', never off a URL shape.
  • Redirect channelsource: 'cdn', url is the operator's remote URL verbatim, localUrl/port are undefined, feedKey is null. There is no feed, no swarm join, and no watchdogs — remote-URL errors belong to the host player.
  • Not entitled → throws not entitled to <id>.
  • Entitled but no broadcaster feeding it (feedKey null in the catalog) → throws channel 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' } Drive a tuning indicator: open means a cold tune started, ready means playable, and retune/reconnect mean self-heal in progress. Say "reconnecting…" — don't freeze a spinner at a fake percentage.
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.
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?, 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.

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).

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) — all typed in the package.

<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.


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 or null.
  • sessionLive(db, payload)online: checks that the device is still enrolled, with a matching tokenVersion, 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).


9. 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.

Deeper playback internals: playback & client runtime and the feed buffer & tuning pages.