Live IPTV streams fail in many silent ways that never throw an exception; the player runs four independent watchdogs so it detects every freeze shape and recovers without ever giving up. (15 details)
Four independent recovery layers
Live playback never just stops: an error-reconnect path, a stall watchdog, a frozen-frame watchdog, and a per-frame video-freeze watchdog each target a different failure mode. All four share identical gating (playWhenReady set, not paused/rewound/casting/errored, and at the live edge) so normal playback can never false-fire any of them.
W1 — error-reconnect on a thrown PlaybackException
onPlayerError bumps reconnectTick and a LaunchedEffect re-prepares after a backoff, but only after re-checking the full gate post-delay — because reconnect forces playWhenReady=true and could otherwise override a user pause or wake the local decoder out from under an active cast.
W2 — stall watchdog for silent STATE_BUFFERING
The 'CNN incident' half-stall (C2 decoder flushed, audio to standby, frozen frame, stuck buffering with no exception) never throws, so it needs a time trigger: armed when live buffering begins, it reconnects if still genuinely buffering-to-play after ~12s (easing toward 60s as failures pile up).
W3 — frozen-frame / position-stuck watchdog
A hung decoder or a live .ts that EOFs into STATE_ENDED/IDLE can stop advancing while never entering BUFFERING, so this polls currentPosition every ~5s and re-prepares if it hasn't moved for ~15s. It fires whenever playWhenReady is set but position is stuck, and deliberately excludes BUFFERING (that is W2's job).
W4 — per-frame video-freeze heartbeat
When only the Amlogic video decoder hangs (picture frozen, audio fine) the position clock keeps moving, so W2/W3 are blind; a VideoFrameMetadataListener stamps lastVideoFrameMs on every rendered frame — the only signal that survives a video-only freeze. If no frame renders for ~8s while video should be showing, it escalates to a full release+recreate, since a bare re-prepare reuses the wedged codec.
Complementary freeze gates
W3 requires the position STUCK; W4 requires it MOVING (audio advancing) with no rendered frames, so exactly one fires per freeze shape. The two watchdogs are deliberately mutually exclusive.
No-first-frame wedge detection
W4 handles both 'frames flowed then stopped' and 'no first frame ever rendered' — the latter catches the wedge after returning from the full EPG, where lastVideoFrameMs is reset to 0 on screen re-entry. That branch counts consecutive should-render time toward the same ~8s threshold (normal startup renders within ~1–2s).
reconnect(): fresh source, not a bare prepare
For a live stream dropped into STATE_ENDED, a plain prepare() reuses the already-ended source and never reconnects (observed: the freeze watchdog looping 'attempt 1' for 17 minutes on a frozen frame). reconnect() calls setMediaItem() again to re-open the HTTP stream from scratch — exactly what a channel-zap does — while never touching the output surface.
recreate(): ordered release-then-rebuild
For a hung hardware decoder, recreate() releases the old player FIRST, builds a fresh one, runs an onNew callback to re-bind the PlayerView surface and re-add listeners, THEN setMediaItem+prepare. Old-before-new ordering guarantees no window where two players hold the same surface — the core heap-corruption guard — and must run on the main thread with the TextureView attached.
One recreate max, then cooldown
videoFreezeRetries caps the heavy release+recreate at exactly one so the app never churns into the very surface-swap spin recreate exists to avoid; if that single recreate doesn't bring frames back it logs 'gave up', cools down 30s, then re-arms. The cheaper reconnect-based watchdogs (W1–W3) never give up — they just keep growing the backoff.
Growing, capped backoff per path
Every recovery path delays by an increasing capped interval so a real outage can't hammer the panel: error-reconnect 2s + retries×3s (cap 60s), stall 12s + retries×5s (cap 60s), freeze 3s + retries×5s (cap 60s), video-freeze 8s + retries×6s (cap 30s). A successful isPlaying/READY resets the counters so transient blips don't inflate the delay.
Reentrancy-safe recovery off the callback
Releasing a player inside its own listener callback is unsafe reentrancy, so the 401/403 UA fallback and the freeze recreate both run from a LaunchedEffect keyed by a tick bumped inside the callback. onDispose wraps listener removal in runCatching, because after a recreate the old instance is already released and removing listeners from it would throw.
Player swap follows the fresh instance
`player` is a mutableStateOf var, not a val: recreate's onNew sets both playerViewRef.value.player and the composable's `player` to the fresh ExoPlayer, so every player.* read and the AndroidView update lambda automatically re-target the new instance after a video-freeze rebuild.
hasVideo gating + tracks reseed
Audio-only/radio channels render zero frames forever, so W4 only arms when a selected video track exists (hasVideo from onTracksChanged). Because addListener doesn't replay onTracksChanged, hasVideo is also seeded from currentTracks in the DisposableEffect — without that reseed, returning from the full EPG left hasVideo=false and the EPG→back freeze never recovered.
One-shot VLC User-Agent fallback on 401/403
A picky panel rejecting a custom stream UA surfaces as HTTP 401/403, dug out by walking the PlaybackException cause chain for InvalidResponseCodeException. The holder falls back ONCE per session to plain VLC (VLC/3.0.20, near-universally whitelisted) via the surface-safe recreate path; the flag resets on release so re-entering live retries the configured TV-DB UA.
A single decode surface that is created once and outlives every screen transition — the architectural fix for a hardware-decoder bug that rebooted cheap boxes — plus buffering, frame-rate, and cast handoff all built around never re-surfacing. (18 details)
One TextureView, created once, never swapped
The live PlayerView is inflated from XML pinned to surface_type=texture_view and the single ExoPlayer is hoisted in LivePlayerHolder above the player/guide screens, so the decode surface is bound exactly once. The c2.amlogic decoder flushes a trailing frame into the output surface even after a blocking clearVideoSurface(), so a SurfaceView (whose Surface IS the window's hard-destroyed ANativeWindow) would write into freed graphics memory on a guide↔fullscreen swap — heap corruption and a box reboot; a SurfaceTexture-backed Surface released through a ref-counted handshake cannot.
Shared hoisted live player
LivePlayerHolder owns one ExoPlayer, created lazily and released by MainActivity (not the screen) only when navigation leaves live TV entirely. Moving between fullscreen, the channel overlay, and the full EPG grid never tears it down, so the stream keeps playing and only re-prepares on an actual channel change instead of re-buffering on every transition.
In-player EPG overlay via graphicsLayer projection
The full guide is drawn as an overlay over the still-playing fullscreen video, not a separate screen. The single never-swapped TextureView is projected into the grid's preview-pane rect with a draw-only graphicsLayer (scaleX/Y from the rect, top-left transformOrigin, translation, clip+rounded shape) plus zIndex(1f) to show through a transparent hole in the grid — pure draw-order, so the Amlogic freeze path stays untouched.
zIndex drop for the guide popup
When the guide's per-channel action popup (Watch/Favorite) opens, the preview's zIndex is dropped so the projected live video doesn't cover the modal card. It's a toggle on the same TextureView's draw order — no surface change.
autoPlay=false prepare for the guide
play(url, autoPlay=false) prepares a stream without forcing playback, used by paths that have no video Surface. A hot video renderer with no output Surface is exactly what corrupts the heap when the fullscreen surface is created/destroyed, so this lets the guide pre-buffer without arming that failure mode.
Small, responsive live LoadControl
Live is real-time, so a big buffer just makes the player chase the live edge and re-buffer; the min buffer is coerced into 6–12s (driven by the user's bufferSeconds pref, 18s max). bufferForPlayback is dropped 2500→1000ms so a channel starts ~1.5s sooner on a zap, while buffer-after-rebuffer is held at 3500ms so a jittery channel doesn't start/stutter-oscillate.
Live pause & rewind via back-buffer
setBackBuffer keeps already-played media in native memory so pause and D-pad/transport rewind/forward seek within it in 10s steps, clamped to duration-BACK_BUFFER_MS .. live edge. A 500ms posTick drives a 'behind live' pill, and a 5s live-edge threshold distinguishes 'at live' from 'rewound' — which also gates every watchdog so a deliberate rewind isn't mistaken for a stall.
Display frame-rate matching
An AnalyticsListener.onVideoInputFormatChanged feeds FrameRateMatcher, which sets the window's preferredDisplayModeId to the supported mode whose refresh rate is the content fps or its cleanest integer multiple (24→24/48/120, 25→50, 30→60). It only changes refresh rate (keeps resolution), bails if there is no clean multiple (rateError ≤ 1.0) so it can't worsen judder, is a no-op if already matched, and resets to system default on dispose.
Focus yielding under the guide overlay
The fullscreen root is conditionally focusable: while the EPG overlay is open the root drops focusable so D-pad keys reach the guide instead of being swallowed by the player's gated onKeyEvent. The PlayerView itself is non-focusable with FOCUS_BLOCK_DESCENDANTS so it never steals D-pad focus from the Compose key handler.
Lifecycle-safe PiP & audio handling
Leaving the app during live playback enters 16:9 PiP where supported; on background (ON_STOP) the live player is PAUSED (stopping audio and letting the surface tear down cleanly) but deliberately NOT auto-resumed, because resuming before the surface re-attaches crashes the ExoPlayer thread — so the paused frame shows and OK resumes it. Screen-on is kept during playback and the guide preview.
Debounced channel persistence
The watched channel is persisted only after settling on it for 1200ms, so rapid up/down zapping doesn't spam the watch history while still resuming the channel you actually landed on (including ones reached by number entry).
Cast handoff: pause local, never re-surface
When a cast session starts (or you zap while casting), the local ExoPlayer is only PAUSED — never released or re-surfaced — and the stream is loaded on the receiver; on disconnect, onSessionEnd resumes local at the current channel/position. CastController is strictly additive, owns its own optional CastPlayer, and never touches the local player or its surface.
Cast live: derive HLS + resolve the redirect on-device
Live plays raw .ts locally (which the receiver can't play), so CastMedia derives the panel's .m3u8 variant by swapping the last path segment's extension. It then follows the panel's 302 ON-DEVICE with a CrKey UA to get the FINAL manifest host, because the receiver resolves absolute segment paths against the URL it was given — casting the pre-redirect host 404s every segment.
Cast item: MIME + live stream type
The Chromecast default receiver fetches the URL itself, so CastMedia.item must set a MIME type (or DefaultMediaItemConverter throws) and attaches title/artwork for the cast UI. A custom IptvMediaItemConverter marks live HLS as STREAM_TYPE_LIVE, since the default converter marks everything BUFFERED.
VOD player: per-instance, pooled keep-alive socket
VOD builds its own ExoPlayer keyed on streamUrl (no shared holder, no never-swap constraint since there's no guide overlay), released in onDispose with a final progress save. It runs over an OkHttpDataSource on the shared client so a movie's metadata/Cues/playback range reads reuse one socket instead of opening 2–3 connections per title.
VOD retry only on transient network errors
VOD retries (max 3) only on ERROR_CODE_IO_NETWORK_CONNECTION_FAILED/TIMEOUT; a 403/512 'line busy' (over the panel's connection limit) must NOT retry-storm or it stacks more sessions and worsens the over-limit. The reconnect path does stop() before prepare() to replace the connection rather than stack one, with a 2s×retries backoff so the panel can free the prior session.
VOD resume as an initial seek + 30s safety net
The resume position is passed as the initial seek to setMediaItem (handled during prepare) rather than a post-prepare seekTo that can hang on non-seekable remote MKV. A safety-net effect surfaces a retry UI if playback never starts within 30s.
VOD lifecycle pause + throttled progress
A LifecycleEventObserver pauses on ON_STOP so audio doesn't keep playing when backgrounded. The progress loop ticks every 500ms for a smooth seek bar but only rewrites the resume-position JSON every ~5s, and only while actually playing, so a pause isn't re-stamped as 'playing'.
An inverted buildType setup that ships the R8-shrunk debug APK with AOT unlocked — roughly halving size and speeding cold start — plus a single codebase that produces two editions and dead-strips one entirely from the other. (20 details)
Ship the debug-signed APK on purpose
The distributed build is the debug buildType, signed with the debug key, so the whole fleet keeps one consistent signature and self-update never breaks on a signature mismatch. This inverts the usual setup: all R8/shrink optimization is wired into debug, and the release buildType is left a no-op.
R8 minify lives on debug
isMinifyEnabled=true is set on the debug buildType (not release) because debug is what ships, doing code shrinking + optimization + obfuscation. R8 automatically applies the bundled consumer keep rules of Compose, Media3, Coil, OkHttp, coroutines, and tv-material.
isDebuggable=false unlocks AOT and halves the APK
Flipping the shipped debug build to debuggable=false lets ART apply the profile-guided baseline profiles bundled in the Compose/Media3 AARs (AGP skips those entirely when debuggable=true, where ART runs interpreted+JIT every launch) — faster cold start, smoother first scroll/zap, and roughly half the APK size. install-r/logcat/dumpsys/screencap still work; only debugger-attach and run-as need it flipped back.
No code reads BuildConfig.DEBUG
Because nothing app-side branches on the DEBUG flag, toggling isDebuggable changes zero runtime behavior — it is purely an ART/packaging lever, which is what makes shipping debuggable=false safe.
Resource shrinking paired with R8
isShrinkResources=true strips never-referenced res/ entries and arsc bloat, riding on top of R8's code shrinking. It's especially valuable here because Compose draws almost all UI in code, so the bundled resource table carries lots of dead entries.
English-only locale strip
resourceConfigurations += "en" drops every non-English locale string that androidx and play-services-cast bundle into resources.arsc — the Cast lib alone ships ~70 locales — pure dead weight removed on an English-only fleet.
Optimizing ProGuard base config
Both build types use getDefaultProguardFile("proguard-android-optimize.txt") — the optimizing variant of the AOSP defaults rather than plain proguard-android.txt — enabling more aggressive R8 passes on top of the app's own keep rules.
versionCode bumps every build
versionCode is incremented on EVERY build because auto-update compares it numerically; versionName changes only on milestones (currently 1.0.20).
Keep rule: PlayerView by name
androidx.media3.ui.PlayerView is inflated by name from res/layout/fullscreen_player_view.xml (the live + MultiView surfaces), so R8 is told to keep it — otherwise it would strip or rename the constructor the layout inflater calls reflectively.
Keep rule: data models
com.tvdb.iptv.data.** is fully kept because models are serialized by the hand-rolled binary CatalogCache and parsed from JSON by field name — both break under field-renaming obfuscation. The package is small, so keeping it costs nothing in the shrink.
Keep rule: Cast OptionsProvider
Google Cast loads CastOptionsProvider reflectively by class name from a manifest meta-data entry, so R8 must keep that class, the OptionsProvider interface and implementors, the cast framework package, and androidx.media3.cast.** — none are referenced in code R8 can trace.
dontwarn for unshipped backends
OkHttp/Okio reference optional TLS/platform providers (conscrypt, bouncycastle, openjsse) and java.lang.invoke.StringConcatFactory that this app never bundles; a -dontwarn on each silences R8 so missing-class warnings don't fail the build.
Keep line numbers for crash reports
-keepattributes SourceFile,LineNumberTable preserves line numbers through obfuscation so the server-side crash reporter's stack traces stay readable without a mapping-file round-trip.
Cleartext + broad device reach
usesCleartextTraffic=true is required because IPTV panels serve plain-HTTP streams; uses-feature leanback and touchscreen are both required=false so one APK runs on TV boxes and phones/tablets alike, backed by minSdk 21 (compileSdk 35, targetSdk 34).
buildConfig feature explicitly enabled
buildFeatures { compose=true; buildConfig=true } — AGP 8 disables BuildConfig generation by default, so it's turned back on to expose the UPDATE_URL buildConfigField value at runtime.
Non-transitive R classes + project flags
android.nonTransitiveRClass=true gives each module its own R class without re-exporting dependency resources, shrinking R-class size and speeding builds, alongside useAndroidX=true and kotlin.code.style=official.
Build cache on, config cache off
org.gradle.caching=true enables the Gradle build cache for faster incremental builds, while org.gradle.configuration-cache=false is a deliberate compatibility choice with the AGP/Compose plugin combo. JVM heap is capped at -Xmx2048m.
Pinned toolchain
Plugin versions are pinned once in the root build file (AGP 8.7.2, Kotlin 2.0.21, and the standalone Kotlin Compose compiler plugin 2.0.21 — required since Kotlin 2.0 split the Compose compiler out) and applied in :app, with Java 17 / jvmTarget 17 throughout.
Repository content filtering
settings.gradle uses includeGroupByRegex to route only com.android.*/com.google.*/androidx.* lookups to google() (everything else to mavenCentral), avoiding wasted network round-trips, with FAIL_ON_PROJECT_REPOS enforcing centralized repos.
Deliberate Media3/Cast dependency choices
media3-datasource-okhttp is pulled in so VOD range requests reuse one pooled keep-alive socket instead of a fresh connection per MKV read. The Cast deps (media3-cast + play-services-cast-framework + mediarouter) are strictly additive and no-op on devices without Play Services.
The fleet includes 192MB-heap-class boxes that OOM-kill ExoPlayer on the Player screen, so caches, buffers, and parsers are all tiered and bounded to stay under the ceiling. (14 details)
onTrimMemory catalog eviction
A ComponentCallbacks2 listener drops the in-memory VOD + Series lists (and the repo's copies) only at TRIM_MEMORY_RUNNING_CRITICAL or above — covering severe foreground pressure plus every backgrounded level — so a low-RAM box can't OOM on the Player screen while a mild foreground blip never blanks a grid the user is actively viewing.
Skip trim mid-load
The trim handler bails early if vodLoading || seriesLoading so it won't yank a catalog out from under an in-flight load and force a redundant re-fetch, and no-ops when the lists are already empty to avoid pointless state writes/recompositions.
Memory-free vs disk-invalidate split
freeCatalogsInMemory() nulls only the @Volatile in-RAM VOD/series copies and leaves the on-disk CatalogCache intact, so re-opening Movies/Series reloads from disk in milliseconds with zero network. It's deliberately distinct from invalidateCatalog(), which ALSO clears the disk cache and is reserved for an explicit user Reload.
Callback lifecycle hygiene
registerComponentCallbacks runs in the VM init and unregisterComponentCallbacks in onCleared(), so the trim listener is torn down with the ViewModel and never leaks the Application reference. The dual-free clears both the VM-held lists and the repo's copies so no stale duplicate keeps bitmaps alive.
Coil cache capped below default
The Coil in-memory bitmap cache is capped at 15% of app memory on low-RAM boxes (ActivityManager.memoryClass ≤ 192) and 18% elsewhere, versus Coil's ~25% default. The default was spiking the heap to ~170MB of decoded posters on the Onn 4K and thrashing; the lower cap trades a few re-decodes for headroom.
RGB_565 poster decode
allowRgb565(true) decodes opaque posters at 2 bytes/pixel instead of 4, halving both the resident poster-bitmap heap and the transient decode burst when flinging the grid. Coil only applies 565 to images that don't need alpha, so PNG channel logos keep their transparency.
Back-buffer RAM tiering
The live back-buffer (played media held in native memory for pause/rewind) is set to 60s on low-RAM boxes (memoryClass ≤ 192) vs 300s elsewhere, because five minutes of high-bitrate video is a prime driver of decoder OOM-kills. The 1-minute cap keeps rewind without starving the native codec heap.
largeHeap as the coarse backstop
android:largeHeap="true" requests the larger per-app ART heap to hold ExoPlayer buffers, Coil caches, and large EPG/catalog data. It's the backstop; the Coil cap, RGB_565, trim handler, and EPG windowing are the fine-grained controls that keep usage under the ceiling.
Bounded EPG window
XmltvParser.parseRecent keeps only programmes overlapping now-3h .. now+48h; the far past/future of a 7-day multi-day XMLTV are never even allocated, bounding the in-RAM EPG. Catch-up uses a separate per-channel fetch, so the guide loses nothing it shows.
Streamed parses keep heap flat
Both the catalog JSON and the multi-MB XMLTV (20–60MB) are stream-parsed directly off the socket and processed token-by-token, never buffered into a String or DOM — so peak memory stays flat regardless of catalog/EPG size. The whole XMLTV parse runs on Dispatchers.IO to avoid an ANR.
Cancel-safe atomic EPG swap
loadXmltv builds the merged tvg-id map in locals and only swaps it into EpgStore atomically at the very end, and runs as a CHILD coroutine of load() so a newer playlist load cancels the multi-MB parse mid-flight instead of leaving it stacked. load() also clears the old EPG up front so it's released before the new one is built.
MultiView codec quiescing on teardown
On MultiView exit each cell's decoder is quiesced and its surface detached (playWhenReady=false + clearVideoSurface) BEFORE release, so tearing down up to 3 live TextureView codecs can't write into freed memory — the same heap-corruption class fixed on the live path. Each release is wrapped in runCatching so one failure can't leak the others.
Unfocused MultiView cells drop audio
In MultiView only the focused cell decodes audio; the others fully disable the audio track and release their audio decoder, freeing codec/memory headroom for the concurrent video decoders on a weak box.
Recorder read timeout
The DVR OkHttpClient uses a 30s read timeout (was an infinite readTimeout(0)) so a wedged live stream times out and releases its socket + file handle instead of holding them forever, preventing a slow resource/handle leak during recording.
7k channels, 11k movies, and 6.7k series scroll smoothly because focus animations stay in the draw phase, derived data is memoized, and search/EPG work is bounded per keystroke and per row. (21 details)
Focus scale deferred to the draw phase
Modifier.focusScale keeps animateFloatAsState as a State (no `by`) and reads s.value INSIDE graphicsLayer{scaleX/scaleY}, so the per-frame focus 'lift' only invalidates the draw phase — it never recomposes the focused row/tile/cell. Modifier.scale would have triggered a composition read every frame; this is what keeps the giant lists smooth.
Per-row local focus state
Every list/tile row holds its own `var focused by remember { mutableStateOf(false) }` flipped via onFocusChanged, so a D-pad move only recomposes the two rows gaining/losing focus rather than the whole list — focus visuals are scoped to the leaf composable.
Theme tokens as property getters
Surface/AccentGreen/TextPrimary/Bg are Color getters that read ThemeState (backed by mutableStateOf), so mutating accent/dark/fontScale recomposes exactly the screens that read a given token — a live, observable theme with zero per-screen plumbing and no event bus.
Global font scale via one CompositionLocal
Font scaling is applied once by overriding LocalDensity (Density(density, fontScale × ThemeState.fontScale)) at the app root, so one provider re-scales all text app-wide instead of multiplying every Text size.
Crossfade disabled on the grid
ImageLoader.crossfade(false) removes per-image fade animation, which on a D-pad poster grid is pure overhead (every cell that scrolls in would animate the alpha composite). Posters just pop in.
Stable LazyList/Grid item keys
Lists pass key = { it.id } (channels, posters, guide rows) so Compose reuses item nodes across reorders and background refreshes instead of recomposing everything, and scroll position survives list swaps.
Search lowercases names once per data change
lcChannels/lcMovies/lcSeries precompute name.lowercase()→item pairs via remember(channels/movies/series), so lowercasing happens once when the catalog changes — not per keystroke. At 7k+11k+6.7k items that avoided ~24k throwaway lowercase() Strings on every character typed.
Search uses lazy sequences with hit caps
The query filter runs over asSequence().filter{…}.take(60/60/40), short-circuiting after enough matches per category instead of scanning and materializing the entire catalog — bounded work per keystroke regardless of catalog size.
EPG title search debounced + on IO
The async XMLTV title search is debounced 350ms inside a LaunchedEffect(q) and runs on Dispatchers.IO, capped at 200 hits, so typing doesn't fire a guide-wide scan per character. It builds the tvg-id→Channel map once per call and sorts now-airing first.
Leading-number sort Regex compiled once
leadingNumRegex is a field, not rebuilt per call, because leadingNumber() runs inside the NUMBER-sort comparator — O(n log n) invocations over thousands of channels — so a fresh Regex() per comparison was real allocation/GC waste.
Date formatters built per-load, not per-item
VOD/Series 'added' parsing builds its two UTC SimpleDateFormats once per catalog load and reuses them across all 11k+ items (SimpleDateFormat isn't thread-safe, so per-load beats a shared global). An epoch-numeric fast-path returns before the formatters are touched for the common case.
Cached id→Channel map reused across resolvers
channelsById() memoizes the associateBy map and rebuilds only when ui.channels identity changes (===), so the recent / favorite-group / custom-group resolvers don't each rebuild a 7k-entry map per call or per recompose.
visibleChannels pipeline cached behind a 3-part key
The hidden+reorder+overrides+parental pipeline is cached and recomputed only when channelPrefsRev, the channel-list identity, or parentalRev changes. Without it, every recompose would re-read SharedPreferences, re-parse JSON, and re-sort/filter 7000 channels.
Per-group counts via groupingBy once
Group sidebars precompute counts with remember(channels){ groupingBy{group}.eachCount() } instead of an O(n) scan per group row per recompose — used in the channel list, the EPG guide, and the VOD browse sidebar.
EPG timeline width measured once
BoxWithConstraints computes timelineW = maxWidth - LABEL_W a single time; the time ruler and every channel row reuse that Dp, so there's no per-row sub-layout/measure pass when positioning programme blocks.
EPG blocks memoized + capped per row
Each row's visible block list is remember(list, winStart)-memoized so it isn't recomputed on focus-driven recomposes during D-pad scroll, and is capped at MAX_BLOCKS_PER_ROW (80) so a 24/7 channel with thousands of entries can't blow up a row.
EPG row fetch debounced, store-only when loaded
With bulk XMLTV loaded, rows read the in-memory store instantly with no network; otherwise the short_epg fetch is debounced (300ms in rows, 120ms for the focused-detail pane) so flinging lets rows dispose before firing, avoiding a per-row fetch storm.
Poster TMDB backfill debounced per card
PosterCard waits 250ms before a TMDB lookup for a blank poster, so a card flung past disposes before the call fires — scrolling a blank category doesn't queue dozens of concurrent requests (network/CPU/429 storm). The result is stored in remember(card.id) so it isn't re-fetched on recompose.
Browse derived lists all memoized
MediaBrowseScreen memoizes groups (distinct/sorted), per-group counts, the 'Recently added' slice (filtered, sorted desc, capped at 100), and the filtered grid via remember keyed on the relevant inputs, so category switching and recomposes don't re-derive over the full catalog.
Home clock formats once per minute
HomeScreen ticks nowMs every 20s but derives the clock/date strings with remember(nowMs/60000){ SimpleDateFormat… }, so the formatter re-runs only when the minute bucket changes — three ticks of four reuse the cached strings.
Splash hold trimmed to 700ms
The launch splash cuts the hold from 1100ms to 700ms — the Home screen composes underneath the overlay during the hold, so that extra time was dead perceived latency. The splash is overlaid (drawn last) rather than blocking, so boot work proceeds behind it.
A reflection-free, streaming, cache-first data layer: huge catalogs and EPGs load from a hand-rolled binary cache in milliseconds and never materialize as a giant String, with robust login and User-Agent handling for finicky panels. (23 details)
Hand-rolled binary on-disk catalog cache
CatalogCache writes each catalog as a length-prefixed binary blob via DataOutputStream — writeInt(count) then a fixed field sequence per record — and reads it back one record at a time, so peak memory stays flat regardless of size. No JSON, no library; a cold launch loads from disk in a few ms instead of re-downloading and re-parsing the whole panel.
Atomic temp-then-rename writes
Every cache write goes to a `_<kind>.tmp` file and is renameTo()'d into place only after the body is flushed, so a crash or kill mid-write can never leave a corrupt cache. The whole write is wrapped in runCatching so a disk error degrades to 'no cache' rather than crashing.
Split per-kind cache versioning
Each file begins with a version int, but channels (VERSION_CH) and catalog/VOD/series (VERSION_CAT) carry SEPARATE constants. A version mismatch returns null (forces a refetch), and because the versions are independent, bumping the VOD record format never invalidates the cached channel list.
Stable per-source cache key
keyOf() derives a short hex key from a hashCode: 'xt' + hash(host|username) for Xtream, 'm3u' + hash(url) for M3U. This namespaces cache files per account so switching playlists never mixes catalogs, without storing credentials in the filename.
TTL + background-refresh catalog pattern
A 12-hour TTL governs VOD/series: loadVod/loadSeries serve the disk/memory cache instantly, then refetch over the network only if the cache is missing or its file mtime is older than the TTL. The fresh fetch rewrites both the in-memory copy and the disk file; channels follow the same cached-then-refresh shape at startup.
Instant cold-start with cached channels + zero-result guard
load() paints last-cached channels immediately (pure disk read) while the network login runs in the background, then replaces the list only when the fresh fetch actually returns channels. A 'successful' empty response (rate-limit/outage) is treated as a hiccup and the working list is kept, so a watching user isn't kicked out by a transient blank fetch.
Reflection-free streaming JSON parser
Xtream catalogs (11k+ items) are parsed with android.util.JsonReader: streamArray() walks the top-level array element-by-element and readFlatObject() reads each object into a flat Map<String,String>, building records by field-name lookup. No Gson/Moshi, no reflection, and the giant response String + full JSONArray (the old GC-thrash culprit) are never materialized.
Robust multi-strategy Xtream login
loadLive tries player_api.php (structured JSON) first; on 401/403/non-JSON it falls back to the get.php m3u_plus export and parses that. If the panel explicitly rejects the credentials (auth≠1 with a status) it throws CredentialsRejected rather than pointlessly trying the export, and it captures max_connections + subscription expiry for the dashboard.
User-Agent probing with sticky cache
Every panel call probes a UA candidate list until one authenticates; workingUa() caches the first UA that returns auth=1 in a @Volatile field so later catalog/EPG calls skip re-probing. Probe order: user override, then the app's own TV-DB UA, then the player fallbacks.
TV-DB self-identity UA + VLC-led fallback list
The app's default identity on panels is `TV-DB/<versionName>` — it presents itself honestly rather than spoofing. When a panel whitelists only known players, it falls back through a candidate list led by plain VLC (most widely accepted), then TiviMate/Smarters/okhttp/Lavf/AndroidTV UAs.
Dedicated longer-timeout client for bulk GETs
bulkHttp is a lazy newBuilder() clone of the base OkHttpClient with readTimeout raised to 60s, used only for the giant VOD/series/channel streams — a slow line can take >25s to stream 11k items, and the default 25s timeout would silently leave the cache empty until the next TTL.
Timestamp normalization + epoch fast-path
addedEpochSeconds() normalizes a panel's 'added'/'last_modified' to epoch seconds, handling epoch-seconds, epoch-millis (>1e11 → /1000), and 'yyyy-MM-dd[ HH:mm:ss]' strings. The numeric fast-path returns before the per-load UTC formatters are ever touched for the common case.
Streaming XMLTV pull-parser, programme-only
XmltvParser uses XmlPullParser to pull tokens lazily straight off the socket — a 20–60MB EPG is processed token-by-token, never buffered as a DOM. It keeps only <programme> elements, takes the first non-blank <title>, caps <desc> at 300 chars (uncapped plots across thousands of programmes were a big chunk of EPG heap), and disables namespace processing for speed.
Timezone-independent XMLTV date math
XMLTV datetimes (YYYYMMDDHHMMSS ±HHMM) are converted to epoch seconds with Howard Hinnant's daysFromCivil algorithm instead of java.util.Calendar: the 14 wall-clock digits are treated as UTC, then the parsed offset subtracted. The result never depends on the device's local time zone and avoids Calendar allocation per programme.
In-memory EpgStore with lock-free atomic swap
EpgStore holds tvg-id → sorted programme lists in a single @Volatile map; put() replaces the whole map atomically, so concurrent guide reads always see either the old or new map, never a half-built one — no locks on the read path. It also tracks isLoaded/loadedAt/channelCount for UI state.
Guide rows read the store, never per-row network
The EPG time-grid calls epgForChannelStoreOnly(), which reads ONLY the in-memory EpgStore with no short_epg fallback, so scrolling thousands of rows fires zero network requests. The unified epgForChannel() (store, else short_epg) is reserved for single-channel now/next where one fetch is acceptable.
Multi-source XMLTV merge
loadXmltv() fetches the panel's xmltv.php plus each user-added extra XMLTV URL, each stream-parsed, then merges by concatenating programme lists per tvg-id across sources and re-sorting by start. The merged map is built entirely in locals and swapped into EpgStore only at the end, so cancelling mid-parse never leaves a partial guide.
EPG search ranking
EpgStore.search() scans all channels for a case-insensitive title match and ranks on-now/upcoming programmes first (soonest first), then the most recent past, capped at a limit. The VM resolves each hit's tvg-id back to a playable Channel via epgChannelId.
Base64 short-EPG decode with raw fallback
Xtream short_epg/archive titles and descriptions are base64-encoded; decodeB64() decodes them but falls back to the raw string when decode fails, so panels that send plaintext still render correctly.
Pooled keep-alive OkHttp client
sharedHttp is one app-wide lazy OkHttpClient whose default ConnectionPool keeps sockets alive, so a movie's metadata and playback/range requests reuse one socket instead of opening 2–3 — and it avoids hammering the provider's connection limit.
Live datasource: UA + cross-protocol redirects
The shared live ExoPlayer is built on a DefaultHttpDataSource.Factory seeded with the configured stream UA and setAllowCrossProtocolRedirects(true), because panels frequently redirect http↔https. The stream UA matches the login UA so picky panels accept it.
Cancel-stale-load concurrency guard
load() cancels any in-flight load first, because it's reachable from cold start, reload, switch-playlist, manual add, and the 30-min auto-refresh loop — overlapping loads otherwise interleave shared repo state and the slower one wins the ui.copy(channels=) write, leaving the wrong playlist with desynced EPG/VOD.
Independent auto-refresh prefs
RefreshPrefs lives in its own SharedPreferences file ('tvdb_refresh'), separate from login/app stores. isDue(now) returns true when intervalHours>0 (presets 6/12/24h, 0=off) and enough time has elapsed since lastRefresh; markRefreshed() resets the clock on every successful load.
The app updates itself from its own server with integrity checks, infers native crashes a dying process can't report, and records reliably with proper socket and cancellation hygiene. (7 details)
Self-update via own-server manifest polling
UpdateManager.check() GETs BuildConfig.UPDATE_URL (update.json), parses versionCode/versionName/apkUrl/notes/sha256 with org.json, and returns an UpdateInfo only when versionCode strictly exceeds this build — no Play Store, no third party. Any error returns null (silent no-op).
APK download with sha256 verify + cache hygiene
download() streams the APK to cacheDir in a 64KB-buffer loop emitting integer percent progress, and if the manifest supplies a sha256 it streams the file through MessageDigest and deletes the APK on mismatch. cleanupOldApks() purges prior `update-*.apk` files (~20MB each) before each download and on startup. (It's a plain streaming GET — not ranged/resumable.)
Non-silent install prompt + permission gate
Install hands the APK to the system installer via FileProvider with a content:// URI and GRANT_READ_URI_PERMISSION (never a raw file://). canInstall() checks canRequestPackageInstalls() on Android O+ and, if missing, deep-links the user to the 'install unknown apps' screen. The update prompt is an explicit D-pad dialog (Update/Later) — never a silent background install.
Silent startup check vs explicit check
checkForUpdates(silent) runs once at app start with silent=true (only surfaces a dialog if an update exists) and from Settings with silent=false (also toasts 'you're on the latest version'). It self-guards against concurrent checks/downloads.
Crash + native/OOM telemetry
A custom reporter catches uncaught JVM exceptions (full stack written to disk and POSTed next launch, since a dying process can't finish a network call) AND infers native decoder/Compose SIGSEGVs or OOM-kills by detecting that the previous foreground session ended without a Java exception, via a 'clean' flag toggled on background/foreground. Every report carries device, memory used/max, and a current-screen breadcrumb.
DVR recorder UA-candidate retry
Recorder.record() streams the live .ts to disk in a 64KB loop for a fixed duration, trying each UA in XtreamClient.uaCandidates(): a 401/403 (UA whitelist) advances to the next candidate, but a 404/5xx stops immediately since the UA won't change those. This mirrors the live player's TV-DB→VLC fallback so a recording can't silently fail on the default UA.
Recorder timeout, size-floor & cancel semantics
The recording client uses a 30s readTimeout (was infinite) so a wedged stream releases its socket + file handle. The capture loop honors cancellation: a manual stop (CancellationException) KEEPS whatever was captured, while a natural finish/failure that produced <64KB is deleted as an empty/partial file. Only one instant recording runs at a time.
A single-Activity, no-Navigation-component app whose entire state lives in one snapshot-state ViewModel — built around a D-pad-first focus model and a full TiViMate-style live experience. (14 details)
Single-Activity sealed Screen nav
The whole app is one ComponentActivity; navigation is a `sealed interface Screen` held in one mutableStateOf<Screen?> and dispatched by a giant `when` — no Navigation component, fragments, or back-stack library, with back handled per-screen via BackHandler. Routes carry typed args (e.g. Player(index, backTo), VodPlayer(url, title, backTo, resumeKey, poster)) and each screen is told where Back returns.
AppViewModel as single source of truth
One AndroidViewModel holds all app state as Compose snapshot state (mutableStateOf, no LiveData/Flow/Room) and owns every data store. Reactive recomputation uses cheap integer rev counters (parentalRev, channelPrefsRev, sortRev, favGroupsRev) bumped on writes and read inside cached getters to invalidate them.
D-pad-first Compose-for-TV focus model
Every focusable row/tile tracks its own onFocusChanged boolean and renders a green focus state + subtle focus-scale (glassmorphism was tried and dropped). TV text fields manually intercept DPAD up/down in onPreviewKeyEvent because a focused OutlinedTextField traps the D-pad, and list screens explicitly requestFocus() on open so the first OK isn't swallowed.
Live TV: zapping, number-entry, last-channel
The fullscreen player supports up/down channel hop (skipping PIN-locked channels), typed multi-digit channel-number entry with a 1.5s commit delay, last-channel toggle, an auto-hiding info bar, and an OK-revealed control bar (play/pause, favorite, guide, cast).
EPG timeline guide with forward time-paging
A landscape grid renders programme blocks positioned by start/stop fraction across a 2.5-hour visible window; RIGHT/LEFT page the window forward (capped at +50h) and back to the live edge, with a live 'NOW' marker line and per-window time ruler. Rows read XMLTV from the store instantly when loaded, else a debounced short_epg fetch.
Now/next on highlight
The guide's detail pane shows now/next + progress + description for the channel you're arrowing over (distinct from the corner preview, which stays on what you're watching), resolved with a 120ms debounce so flinging only resolves the channel you settle on. The player info-bar refreshes now/next exactly when the running show ends so it rolls over instead of going stale.
MultiView multi-decoder grid
A 3-up grid (1 large + 2) where cells 0/1 use hardware decoders and cell 2 forces a software-only MediaCodecSelector — three hardware decoders freeze this class of box. It honors the panel's max_connections (a 1-connection line won't open 3 streams), and teardown quiesces+detaches each surface before release to avoid heap corruption.
Catch-up / archive TV
Channels flagged tv_archive expose a catch-up screen that lists past programmes from the panel's full EPG table (get_simple_data_table) and builds a timeshift.php playback URL (start in local time + duration) handed to the VOD player.
DVR instant recording
From the live player the RECORD key opens a duration picker and the Recorder streams the raw .ts to external files; only one recording runs at a time (a second is rejected), a manual stop keeps what was captured, and a failed/empty natural capture under 64KB is auto-deleted.
Scheduled recordings (alarm-backed)
Future programmes can be scheduled from search/guide; the app sets an exact AlarmManager alarm at programme start (setAlarmClock → setExactAndAllowWhileIdle → set fallback chain) that fires a BroadcastReceiver to record for the programme's duration. Stored in its own prefs store and pruned when finished.
Programme reminders
Reminders fire a notification ~2 min before a programme starts via the same exact-alarm fallback chain, with Android 13+ POST_NOTIFICATIONS consent requested at startup. IDs derived from channel+start dedupe duplicates, and past reminders are pruned.
Universal search
One screen searches live channels, movies, and series synchronously (names lowercased once per data change) AND merges async debounced live-EPG hits with NOW badges plus inline Remind/Record actions. EPG search resolves XMLTV titles back to playable channels via tvg-id.
Parental lock / PIN
A PIN locks chosen categories; locked channels are hidden from the visible list/groups/guide AND every direct-play entry point (Home, Favorites, MultiView, search, recent) is gated through a PIN screen at the NAV layer — deliberately not inside PlayerScreen, to keep the surface lifecycle simple. Zapping and number-entry also skip locked channels.
External-player handoff
An escape hatch for codecs ExoPlayer can't decode: builds a standard ACTION_VIEW video intent with MX Player/VLC title extras and launches a chooser (or a specific package with chooser fallback). Requires the manifest <queries> declaration for Android 11+ package visibility.
Adaptive VOD/series browsing with TMDB poster backfill, deep personalization that survives playlist refreshes, type-safe backup/restore. (10 details)
VOD movies & series with TMDB poster fallback
Movies and series browse as adaptive poster grids with a category sidebar, a pinned 'Recently added' view (by the panel's real added timestamp), and sort modes; posters missing from the panel are filled from TMDB by title with a 250ms debounce so flinging a blank category doesn't queue a 429 storm. Detail pages show plot/cast/genre and per-season episodes.
Continue watching & recently watched
Partly-watched VOD items are saved with position+duration+poster and surfaced in a Continue Watching row; a separate history store tracks recently watched live channels and the previous-channel toggle, surviving restart so 'Live TV'/auto-start resume the actual last channel, not channel 0.
Multiple playlists + preset onboarding
Onboarding offers branded preset panels (server URL baked in, user enters only their own credentials — none ever shipped) plus manual Xtream login or M3U URL. Multiple saved playlists can be switched/deleted, and a legacy single-login is migrated into the multi-store on first upgrade.
Multiple EPG sources + auto-refresh
Beyond the panel's xmltv.php, users add extra XMLTV URLs that are merged (programme lists concatenated per tvg-id and re-sorted). A configurable auto-refresh re-fetches live+EPG on an interval but never stacks on an in-progress load, to avoid tripping a connection-limited line.
Themes & UI customization
Dark/light mode, custom accent color, and a global font scale (via LocalDensity.fontScale), plus toggles for channel numbers/logos, compact row density, overlay scrim opacity, and hideable home tiles. All persisted and mirrored into live ThemeState/AppearanceState objects read across composition.
Favorites + named favorite groups
Channels can be favorited (persisted set) and organized into named buckets that store channel IDs and resolve back to live Channels (skipping IDs no longer present). The reused cached id→Channel map avoids rebuilding a 7k-entry map per call.
Per-channel rename & logo overrides
Users can override a channel's display name and logo URL; overrides are stored separately and applied as the final stage of the visible-channels pipeline so they survive playlist refreshes.
Channel manager: hide / reorder / custom groups
A manager lets users hide channels, reorder them, and define custom groups; the cached visibleChannels getter folds hidden+order+overrides+parental filtering and recomputes only when a prefs revision bumps or the channel-list identity changes — avoiding a re-read/re-parse/re-sort of 7000 channels per recompose.
Backup / restore
Exports every app SharedPreferences file into one self-describing JSON with per-value type tags (Boolean/Int/Long/Float/String/StringSet round-trip exactly), shareable via the existing FileProvider. It deliberately excludes alarm-backed reminders/recordings (would become phantom entries) and crash telemetry.
Sleep timer
A minute-counting coroutine exposes remaining minutes and sets a sleepFired flag on elapse, which MainActivity observes to navigate Home — and leaving the player composition releases ExoPlayer and stops playback cleanly.