Playback & client runtime
This page covers issues in the Android app and player SDK at runtime. For build-time problems, see Android & React Native builds.
Posters and video silently fail to load (blank tiles, no error anywhere)
- Cause: Android API 28+ blocks cleartext HTTP by default. The app serves
media and art from
http://127.0.0.1:<port>(the embedded P2P node), so image loaders and ExoPlayer fail silently. - Fix: the app ships a
network_security_config.xmlthat permits cleartext, and the manifest references it. The engine only needs127.0.0.1(plus the emulator host aliases for Metro in dev), and that is all a third-party host has to permit. The shipped app now permits cleartext to every host — a deliberate posture, so that channels a provider serves over plainhttp://can play. That traffic is unencrypted and can be read or changed on the network path; the config file says so in the file itself. See Client build. - Diagnostic that isolates it: run
adb forward tcp:<x> tcp:<port>, thencurlfrom the host. If that returns 200 while the app shows nothing, this is the cause.
Playback fails with OPLOG_CORRUPT: Oplog file appears corrupt or out of date
- Cause: the app process died mid-write (a crash or force-kill) and corrupted the local Corestore replica cache. Without recovery this is permanent until you wipe the app's data.
- Fix (shipped): the engine detects corruption codes (
OPLOG_CORRUPT,INVALID_CHECKSUM, and others) on open and on read. It purges the whole store and retries once (sdk/recover.js, exercised bynpm run test:corrupt). The store is a disposable replica cache — everything re-replicates from peers, and the in-memory session survives. No re-login is needed. - Manual fallback (always safe): clear the app's data/storage. The same reasoning applies — nothing of value is lost.
Login spins forever on "not connected to panel" / "Cannot reach the service"
Check these causes in order of likelihood:
- The panel is wedged or down. Verify from another machine with a small
hyperswarm read of
catalog/*before you blame the client. A wedged panel can look alive in the process list while it answers nothing — restart it (see Operating the panel & broadcaster). - The first DHT dial after a fresh install legitimately takes 30–90 s. The login screen retries for about a minute, then gives up. Pressing Sign in again restarts the retry loop.
- Stale swarm state sits on the device after the panel restarted, or after the app's data was cleared mid-session. Force-stop and relaunch the app — retrying inside the app is not enough, because the embedded node needs a fresh swarm.
- Device network says connected but isn't validated. Check with
adb shell dumpsys connectivity | grep -i validated.
Player shows black + spinner right after opening a channel
- Normal for a few seconds: the live edge is replicating from peers. The media server holds a request for a not-yet-replicated playlist or segment (bounded at 6 s, under ExoPlayer's 8 s read timeout) and serves it the moment it lands. So the usual cost is the actual replication time, not a 404-to-retry-remount cycle at 2.5 s. Only a path still missing after the 6 s bound 404s, and the player's 2.5 s auto-retry stays as the fallback behind that.
- Persistent spinner with
0 peers: no seeder is found. Either the broadcaster is down, its ffmpeg ingest died while the process kept "seeding" a frozen playlist, or the device holds a stale DHT record. The last case happens because the broadcaster restarted since the last lookup — its feed swarms are ephemeral identities, and hyperswarm re-queries a topic only every ~10 min. The broadcaster side self-heals the same failure via PanelLink. - Fix (shipped) — tune self-heal: while a tune is incomplete (the
playlist is not advancing — merely existing is not enough, see the
wedge below), the engine forces fresh DHT lookups on a 5 s → 60 s backoff.
At 30 s it evicts the cached feed open and re-opens fresh once (the
feed:retunebreadcrumb). At 60 s it destroys the swarm connections serving the feed and dials fresh (feed:reconnect). If that also expires, it surfaces a friendlytune timeouterror instead of spinning forever — zap to the channel again to retry. Worst case is ≤ 90 s to the error at defaults. Pre-fix builds could sit on the spinner indefinitely (S22, 2026-07-16: a zap stuck at "90 %" for 10+ min against a healthy VPS; only an app restart — a fresh swarm — cleared it, because the cached dead open poisoned every retry). Thetest:sdktune section guards the whole cycle. - Persistent spinner with peers connected (
1 peershowing): this is the wedged connection class. A network flap (Wi-Fi degrade, radio cycle) can leave the hyperswarm/UDX connection alive at transport level while replication over it moves zero bytes. Peer counts look healthy on both ends, no error fires, and because hyperswarm keeps one connection per peer across all topics, a retune faithfully reuses the same dead pipe. With prewarm, one wedged connection to the broadcaster starves every channel at once (S22, 2026-07-16: 15+ min stuck at "90 %" with "P2P — 1 peer" while a fresh client played the same feed in 10 s). Fix (shipped): the tune watchdog requires the playlist to advance (a stale pre-flap playlist in the replica no longer counts as tuned), and it tears the wedged connections down on its second expiry (feed:reconnect) so the swarm dials fresh. Thetest:sdkwedged-connection section reproduces the exact signature with a paused socket. - Persistent spinner with zero peers, on a channel you watched earlier in
the same session: this is the deleted replica class. The engine keeps
the last 12 feeds warm. It deletes a feed's data from disk when that feed
leaves the cache (see
docs/kb/viewer-bandwidth.md). A feed whose data was deleted never replicates again over the connection it was deleted on. One connection carries every channel of a broadcaster. A later tune to that channel therefore re-opens over the same live connection, and finds no peer. The tune self-heal above cannot recover it. Its connection teardown step needs a peer to tear down, and this failure has none — so the tune ends at the friendlytune timeouterror. Fix (shipped): the engine records which connections each deleted feed used. It destroys those connections when you tune that channel again, and the swarm dials fresh (feed:reconnect). The other channels on that connection replicate again by themselves. A connection that was already replaced stays up. Thetest:sdkevicted-feed section reproduces the failure with one seeder that serves two channels over one connection. - A redirect channel never hits this failure class at all — there is no P2P feed behind it. The host player fetches the operator's URL directly and owns its own errors. (P2P channels have no CDN failover by design — the self-heal ladder above is their recovery story.)
Video freezes while everything looks healthy (clock ticks, peers connected)
- Symptom: the picture stops dead mid-watch. Peer count and worklet heartbeats stay healthy, the UI stays alive, and no error fires. Zapping away and back fixes it.
- Cause: the HLS live window is short (the code default is 8×2 s = 16 s; reference deploys now run 12×2 s = 24 s). A network blip longer than the window slides it past ExoPlayer's position, and react-native-video raises no error event for that — the surface just freezes.
- Fix (shipped):
<AliranVideo>watches the playhead. Once a mount has played and the position sits still for 12 s (stallTimeoutMs) while not paused, it remounts onto a fresh playlist load at the live edge — the same thing the manual zap did — and firesonStallplus anonTunestart. The app's tuning pill restarts and stays until the resync mount's first real playback (onTuneplaying). - If the resync remount itself never plays within another window, the
freeze is not a slid live window but a wedged connection (see the
tune section above). The stall ladder then escalates to
backend.reconnect(), which tears down the engine's connections serving the feed and dials fresh. The engine's re-armed tune watchdog then drives the outcome — playback resumes, or a friendly error replaces the silently frozen frame. - Widen the margin (operators): the standard
HLS_LIST_SIZE=12(24 s) gives clients room to recover from blips; go to16for very flaky viewer networks — the same lever as the rebuffer cushion in sizing the segment window.
The guide can change before the picture does
The viewer apps play ~10 s behind the live edge. The programme guide's "now playing" label follows the schedule clock, so it can change up to ~10 s before the picture does. This is normal, not a fault.
Channel zapping is slow, or flipping back to a channel hangs
- How long a zap should take: switching happens inside a warm (logged-in) session, so it skips panel connect and login. Expect ~1 s to a new channel and ~0.3 s back to a channel you already watched this session. That is far below the cold time-to-play (~10 s+ with login) — if a zap takes that long, you are not actually in a warm session (the player was torn down between switches).
- Each channel is a separate P2P feed/DHT topic, so the first zap to a channel can't be instant like cable — it joins that feed's topic and pulls its first segments. Subsequent visits are near-instant, because the SDK keeps opened feeds warm.
- What a zap costs since the 2026-07-16 latency pass (all shipped,
covered by
test:serve+test:sdk): - Segment bytes stream to the player as blocks replicate (block-progressive bodies — decode starts on the first 64 KB, and every segment opens on a keyframe).
- Requests for a not-yet-replicated playlist or segment are held and served on arrival (bounded), which kills the old 404-to-2.5 s-retry quantization.
- Serving a live playlist now replicates the whole live window in parallel for the active stream (metered networks keep the newest-3 read-ahead), so replication overlaps the player's sequential fetches.
- ExoPlayer starts at ~1 s buffered instead of ~2.5 s (the
<AliranVideo>bufferConfigdefault). The stall-resync/self-heal ladder covers the slightly higher rebuffer risk this creates. - Optional
zapPrefetchkeeps the adjacent channels' newest segment warm (off by default — it costs standing bandwidth). - Fixed: flipping back to a channel used to hang.
resolve()opened a duplicate Hyperdrive on the same store namespace and deadlocked.sdk/player.js serveFeednow reuses the cached feed perfeedKey, so make sure your build includes this fix (thetest:sdkzapnews → movies → newsregression guards it). - First zap also warm (pre-warm): the SDK opens entitled feeds' topics
at login (the
prewarmoption; the app enables it), so even the first play/zap to a channel is a cache hit — verified on-device asfeed:readywith nofeed:open. See P2P feed buffer & tuning.
App dies the moment the player seeks / switches source / closes (worklet SIGABRT)
- Symptom: the whole app exits. Logcat shows
Uncaught StreamError: Writable stream closedand an abort inside the Bare runtime. - Cause: video players routinely abort in-flight HTTP requests. Writing into the closed response was an unhandled stream error, and any uncaught exception in the embedded worklet aborts the entire app process.
- Fix (shipped): the media server tolerates client aborts on every path.
The worklet also installs a last-resort
uncaughtExceptionguard that reports the error over IPC instead of crashing. If you embed the SDK in your own runtime, keep both.
Signing a television in from a phone fails
Nothing in this feature has been seen on a television yet. What follows comes from the engine's own behaviour and its test lanes.
- The phone says it cannot find the television. Both devices must be on a network that reaches the DHT — this rendezvous is derived from the code, not from the local network, so the two do not have to be on the same Wi-Fi, but each one must reach the internet. Check the code was typed exactly as shown: the alphabet leaves out I, L, O and U on purpose, so nothing on a television screen can be misread as something else.
- The code stopped working. A code lives about three minutes and is spent by any failure — a wrong PIN, an abandoned attempt, a peer that claimed it first. Show a new code on the television; do not retry the old one.
- The four digits do not match. Stop. A mismatch means something is between the two devices. Do not try again on the same network. Each attempt is an independent chance for a relay, not a better one.
- The PIN was refused. There is one attempt, by design. The exchange ends and the code is spent.
- The television is stuck on "enter the digits" and the viewer's phone says "busy". Somebody else claimed that code first — anyone who can see the screen can. It costs a new code and never costs key material. If it repeats, the screen is visible to somebody who should not see it.
A television asks to sign in again after every restart
A set that a phone signed in keeps its account material sealed under a key in the Android Keystore. It erases what it holds only on proof that the material can never work again. So a set that falls back to its sign-in screen has either been erased on purpose, or never restored.
| What happened | What the set does |
|---|---|
| Somebody changed the account password | Erases. A password reset mints a fresh keypair, so the stored keys stop matching. This is the lever that really evicts a set. |
| The account was disabled | Erases. |
| "Log out all devices", or the one device was revoked | Keeps. It takes a fresh token on its next start. |
| The device slots are full | Keeps. The slots free themselves, and the same keys then work. |
| The account was deleted | Keeps — an empty record looks the same as a cold start. The set falls through to its sign-in screen every time and nothing on screen says the account is gone. |
| Somebody used "Change service…", or signed the set out | Erases, both halves. |
Deleting an account and creating the same username again evicts the set rather than restoring it: the new account has a new keypair, the stored key fails, and that erases. Change the password before you delete an account if you want the television cleaned up.
"Cannot sign in, try again in 15 minutes" on a set nobody is touching is the panel's login throttle. A set retrying a credential that cannot work spends a small budget per start, and the throttle counts per account and per peer. Restarting the app clears it, because the peer half is new for each app process. The lasting fix is the same one: change the password, or sign the set out.
"Play on my TV" shows no devices
- Both devices must be signed in to the same account. The rendezvous comes from the account key. Different accounts never meet.
- The television must be the one announcing. A controller looks up and never announces, on purpose, so two phones see each other but two televisions never meet.
- The take-over switch may be off on that set. It refuses
playandstopalike. - The list is not the panel's device list. A device's name and id there are its own claim. Use the panel's list when you need to know what is really enrolled.
- Nothing happens after a viewer picks a channel. A restricted channel
goes through the television's own parental gate first. If the set cannot
read the channel's record it refuses the command rather than guessing,
and the phone sees
unavailable— retry it; both causes are temporary.
Casting to a Chromecast or a Google TV
- The receiver connects and plays nothing. The most common cause is the address. A phone can have several private addresses — Wi-Fi, a VPN, a virtual adapter — and the one the app advertised may not be the one the television can reach. The session carries every candidate it found; try the next one.
- The picture stops when the phone leaves the network or sleeps. That is inherent. While casting, the phone is the server: it replicates, decrypts and serves the stream. It has to stay awake and on the network.
- Storage grows during a long cast. Block reclaim is off for a channel
a cast has pinned, because the phone's copy is the only thing that can
still serve a receiver that fell behind. Expect about the channel bitrate
per hour (roughly 0.9 GB/hour at 2 Mbit/s). Stopping the cast reclaims it;
an app killed in the middle of a cast does not, and the space comes back
when the viewer next tunes that channel.
On a 32-bit Android build neither remedy frees a byte. Both are
clears, and a clear frees nothing on the
armeabi-v7aandx86ABIs (see viewer bandwidth). There the space comes back only when the replica is deleted — when the feed rotates past its byte budget, when the warm cache passes its cap, or when the store is deleted by hand. A cast-pinned feed is never rotated, so on those devices a long cast is bounded by nothing until the cast ends and the feed goes idle. - The session ended by itself. The channel's feed was purged or a retune failed. The server is closed and the URL is dead — start again.
- Who else can watch. Anyone who can reach the television can read the cast URL off it, so on a shared network treat a cast as that one channel extended to that network for the length of the session. This was measured on a real Google TV. Pin the session to the receiver's address where the app offers it, and prefer "play on my TV" on a network you do not trust: a handoff sends no video at all.
Reading the app's own diagnostics
The app writes to logcat under two different tags, and the obvious filter shows only one of them. Knowing which is which is the difference between "the app prints nothing useful" and reading the engine directly.
ReactNativeJS — the JavaScript UI side. adb logcat -s ReactNativeJS
shows the [boot-ui] ladder (splash → login → menu, with the time each step
took), the live screen's stall resyncs, and [backend] {"type": ...} — one
line per backend→UI IPC message, including feed:open / feed:ready
breadcrumbs, peer-count ticks, fallback / source-changed events and
store:reset (corruption recovery). Those [backend] lines come from the
SDK's React Native layer, not from the app's own code, and the app turns them
on in release builds too — so they are there on an ordinary install, not
only a dev one. Messages that could carry a credential or a viewing record
are reduced to their type before printing.
The P2P engine's own prints do NOT carry that tag. Anything the Bare
worklet writes with console.log — the [boot-trace] dumps, the [net]
socket-tuning line, the [reclaim] verdict below — goes to the app
process's own stdout, which logcat labels with a tag derived from the
package name. Android caps tag length, so that tag appears truncated;
it is not something to guess or type, and -s on it is a trap. Do not
filter by tag for these. adb logcat -s ReactNativeJS hides every one of
them, which is the failure mode worth naming: the filter that gives you the
IPC breadcrumbs blinds you to the engine that produced them. Run unfiltered,
or grep the bracketed prefix instead — adb logcat | grep "\[reclaim\]".
Raise the buffer first (adb logcat -G 8M): the default wraps in minutes.
The in-app Debug overlay remains the quickest read of live engine state
(peers, rates, store size) when a cable is not attached.
[reclaim] — whether this device's filesystem can punch holes. Every
build prints one line when the capability probe answers, and one more each
time the answer changes. Both ends of it can move: an inconclusive probe is
retried, and the bounds the verdict switches off flip after it lands. It is
at most a handful of lines per session:
[reclaim] {"ok":true,"canPunch":true,"reason":"measured","freed":262144,"wideFreed":262144,"punchTries":1,"budgetActive":false,"metaBudgetActive":true,"unmeasurable":false}
canPunch with ok:true is the verdict. freed on its own does not
prove it: the probe punches a small hole first and then a wide one (past
the 4 GiB line), and canPunch:true is granted only where both stages
freed bytes. So a healthy freed next to "wideFreed":0 and
"reason":"wide-measured" is not a near miss — it is the signature of a
32-bit fs-native-extensions build whose punch lengths are truncated, which
frees the 256 KiB the probe asks for and none of what a real clear asks for.
wideFreed is absent entirely when the wide stage never ran, and absent
is a different claim from zero.
The two *Active flags say which bounds are still armed, and they are
independent. budgetActive is the media rotation, which the verdict
switches off once the punch is proved. metaBudgetActive is the
metadata rotation, which no hole punch can help and which therefore
stays armed almost everywhere. Read both: "budgetActive":false alone does
not mean this device never rotates.
ok:false is not a verdict at all — the probe could not tell, the bounds
stay as they were, and punchTries counts the retries. One case prints
nothing ever: a host that sets reclaimBudgetBytes: 0 disables the media
budget, and the probe only runs from that budget's own gate, so there is no
verdict to report on such a build. What the answer buys, and what a device
does when the punch fails, is in docs/kb/viewer-bandwidth.md.