Architecture¶
Sofa King is a Kotlin Multiplatform project. One Compose Multiplatform UI on Material 3 Expressive runs on Android TV, Google TV and Android phones, on Linux, macOS and Windows desktops, on iOS, and in browsers through Wasm or JS.
Modules¶
| Module | What it holds | Targets |
|---|---|---|
core |
Models, the server API and the server connection, repository, playback sessions, lineup cache, settings store and the ViewModels (LineupViewModel, LibraryViewModel, RecordingsViewModel). No Compose. |
Android, JVM, iOS, macOS, tvOS, watchOS, Linux, js, wasmJs |
ui |
Every screen, the app shell, the guide and player logic, the app graph, and the desktop, web and iOS entry points | Android, JVM, iOS, js, wasmJs |
androidApp |
The Android application: manifest, launcher and TV banner, SofaKingApplication and MainActivity |
Android |
baselineprofile |
The Baseline Profile generator for androidApp, which development runs on a device |
Android |
iosApp |
The SwiftUI host for the SofaKingUi framework, as an XcodeGen spec |
iOS |
tvosApp |
The SwiftUI Apple TV app on the SofaKingCore framework (SofaKingTv in core/tvosMain), as an XcodeGen spec |
tvOS |
core targets everything its dependencies publish, so a native host such as a SwiftUI tvOS app can use the same data layer without Compose. Windows native is absent because DataStore has no mingw build; the JVM covers Windows desktops.
All source lives under dev.jamesrich.sofaking. Code is in commonMain unless it needs a platform API; each expect has one actual per platform source set (androidMain, jvmMain, iosMain, webMain). fileMain (Android, JVM and native) holds the stores that live in files, since the web targets keep theirs in the browser.
Components¶
| Component | Responsibility |
|---|---|
App, initSofaKing, AppGraph |
The shell: the ViewModels, the back stacks shown by Navigation 3, the shared player and the session's stop/start lifecycle; each page is drawn by its destination (LiveDestinations, LibraryDestinations, RecordingsDestinations, SettingsDestinations), which collects the state it shows itself. The graph starts once per process |
AppShellState |
The shell's UI state and moves, unit-tested: the back stacks, the watch history, the cold-start decision (decideLaunch), the Settings unlock, the header's focus return and notice, and which tabs the server offers (tabsOffered) |
Navigation |
One back stack per tab of Routes, the arrival details (what to focus, Now, search) and every move: open, Back, switching tabs, leaving Settings |
ServerConnection |
The process-wide link to the server Settings names: the one API client, closed when the server changes, the server's capabilities from its bootstrap, the one PlaybackSessions, the RecordingSchedule, the WatchlistBook and the preference sync. Every ViewModel reads the server through it, so none holds another |
LineupViewModel |
Settings flows and writes, lineup state, refresh, cache ownership, pairing (PairingState) and the update check; its store, cache and connection are constructor arguments |
UpdateChecker, UpdateInstaller |
The snapshot release check against GitHub, and on Android (appUpdateInstaller, absent for a store install) the in-app install: download, verification, then a PackageInstaller session whose status a not-exported receiver hears; one per process, so a download outlives Settings |
RecordingSchedule |
The server's recordings and series rules, read together once for both the DVR tab and the marks in program details, and every change to them: Record and Record series, deletes, rule changes and saved positions |
WatchlistBook |
The household's watchlist on a server that keeps one, as the marks behind Add to watchlist on item pages and in program details, and every change to it |
SofaKingApi |
The server's /api/v1 JSON requests over Ktor with the bearer token, on its own client over the app's one HTTP engine; every engine's transport error surfaces as an IOException |
SofaKingRepository, SofaKingMapper |
Catalog and guide queries mapped to Channel (with its logo and guide status) and Program (with its episode, rating, new flag and image) |
PlaybackSessions |
The one playback session, a live channel's or a library item's: open, promote, renew, restart, release, reopen, replaced streams |
RecordingsViewModel |
The DVR tab: the connection's RecordingSchedule, playing a recording through PlaybackSessions.playRecording and saving where playback stopped |
LibraryViewModel, LibraryRepository |
The library home's shelves, one library's paged grid, item pages, search, marking watched, playing an item and saving where playback stopped |
LineupCache |
Last complete lineup, keyed by server address, in a DataStore file or the browser's localStorage |
ServerDiscovery, DiscoveredServers |
The servers advertising _sofaking._tcp over mDNS, for Settings: NsdServerDiscovery (Android's NsdManager) and JmdnsServerDiscovery (JmDNS on desktops) and HostServerDiscovery (the iOS host's BonjourBrowser, Network framework, behind HostServerBrowser); the Apple TV app browses with its own ServerBrowser |
WatchNext |
The platform's row of what to watch next outside the app, fed the library's Continue watching shelf whenever the home shelves load: where Google TV's Engage service takes this app's row, EngagePublisher publishes it as a Continue watching cluster through the Engage SDK (engage-tv), and otherwise WatchNextPublisher writes the Android TV home screen's Watch Next row through androidx.tvprovider; the entries open sofaking://vod/ID, and WatchNextArtworkProvider serves their artwork to the launcher, which may refuse plain HTTP; elsewhere it is WatchNext.None |
DataStoreSettingsStore, AppStorage |
Server address, API token, client id, favorites, last and recent channels, hidden channels, the last guide filter, caption, preview and playback settings, and the Settings PIN's hash |
GuideScreen, GuideState |
Grid, time window, filtering and D-pad navigation; GuideState holds the guide's UI state, its moves and its key handling, unit-tested |
PreviewVideo |
Muted preview on the shared player through a preview session |
PlayerScreen, PlayerState |
Full-screen playback, remote commands and the system media session; PlayerState holds the channel ring, the panels, the failure and captions state and the key handling, unit-tested |
VodPlayerScreen, VodPlayerState |
Full-screen playback of a library item or a recording: seeking, the seek bar with commercial breaks, saving the position; VodPlayerState holds the position, the scrub, the failure, the Up next card, which of the bar, its buttons and the track card has the keys, and the key handling, unit-tested |
MusicPlayback, NowPlayingScreen, MiniPlayer |
Music on the shared player apart from any page: the queue, each song's session, Now playing and the bar under the pages; MusicPlayback is unit-tested |
LibraryHomeScreen, LibraryGridScreen, ItemDetailsScreen |
The Library tab's rows, one library's poster grid with sort and filter chips, and an item's page with its seasons and episodes |
LivePlayer |
The playback contract each platform implements |
Data flow¶
AppStorage → DataStoreSettingsStore → ServerConnection → SofaKingApi (Ktor), PlaybackSessions, RecordingSchedule
↓ ↓
LineupViewModel ← SofaKingRepository LibraryViewModel, RecordingsViewModel
↓ ↓
LineupCache GuideScreen / PreviewVideo / PlayerScreen → PlaybackSessions → LivePlayer (/media relay)
The cache loads first on startup or server change: its channels publish as soon as the file is read, marked guideLoading, and its programs follow once decoded on the default dispatcher, which takes seconds for a full guide on a TV. The catalog publishes channels before the guide query completes. The catalog is fetched with its ETag, so an unchanged lineup is a header round trip. The guide is queried in chunks of at most 200 channels over 24 hours from the half hour containing the load time, pinning the revision the first chunk returned on the rest, which run four at a time, and restarting without one on 409 RESYNC_REQUIRED. A channel the guide answers as removed left the lineup after the catalog was read: it drops from the lineup and the next load runs after one minute. Mapping applies the channel rule through playbackEligible and displayNumber, sorts the channels by number, files each under every non-blank tag (Other when it has none), resolves each logo's server-relative path against the configured server address, and discards programs with invalid timestamps or nonpositive durations. Successful loads update the cache and repeat every three hours. A day the guide jumps to beyond the live window is queried once over the same path (loadGuideWindow), merged into the lineup's guide beside the live window, never cached, and dropped once the live window passes it. Failed loads retain the available lineup and retry after one minute. Without a valid server address the lineup state is Unconfigured.
Network and cache file work run on the IO dispatcher (the default dispatcher on the web); JSON decoding, the lineup's mapping and the merge of extra guide days run on the default dispatcher, so the main thread only sets state. State flows one way: ViewModels expose immutable StateFlows, each destination collects the ones it shows with collectAsStateWithLifecycle, and user actions go back as ViewModel calls: setters return nothing and log a write the settings store refuses, and a short action whose result matters only while its screen is up, such as Record, Delete, a library search or opening a stream, is a suspend call the screen awaits. Work that must outlive a screen, such as claiming a pairing code, runs in viewModelScope and reports its outcome as state; process-wide work, the server connection's, runs in AppGraph's main-thread scope, started by the first ViewModel, and logs a failure nothing else handles rather than crashing the process. Canceling a load cancels its HTTP call, including while reading the response body.
Storage¶
| Platform | Settings | Lineup cache |
|---|---|---|
| Android | files/datastore/settings.preferences_pb |
files/lineup-cache.bin |
| Desktop | <dir>/datastore/settings.preferences_pb |
<dir>/lineup-cache.bin |
| iOS | datastore/settings.preferences_pb under Application Support |
lineup-cache.bin under Application Support |
| Apple TV | NSUserDefaults, under sofaking.preferences, since tvOS may purge any file; an older build's file under Caches moves there on first read. The recent channels and the library's Continue watching are also written as JSON into the group.dev.jamesrich.sofaking app group's defaults for the Top Shelf extension |
lineup-cache.bin under Caches |
| Web | DataStore's localStorage store for the page's origin, under sofaking.preferences; an older build's sofaking.settings entry moves there on first read |
DataStore's localStorage store under sofaking.lineupCache |
Android and desktops also keep the last three crash reports as JSON files under crashes/ beside those (files/crashes/ on Android), until the next start sends them to the server.
On a desktop, <dir> is the OS's per-user app data directory:
| OS | Directory |
|---|---|
| Linux | $XDG_DATA_HOME/sofa-king, or ~/.local/share/sofa-king when XDG_DATA_HOME is unset or blank |
| macOS | ~/Library/Application Support/Sofa King |
| Windows | %APPDATA%\Sofa King, or Sofa King in the home directory when APPDATA is unset |
Both are DataStores, so writes replace the file whole and a file that does not read is replaced with an empty one. DataStore allows one instance per file, so the graph holds each as a single and each entry point starts the graph once. The lineup cache is one kotlinx-serialization protobuf record, which every platform reads and writes the same way and which gains fields only under new numbers, never renumbering one, with the programs nested as one encoded message (field 4) so reading the file copies them as bytes and decodes them only on demand; a file from an older build, with the programs in field 3, still reads; a cache that does not decode, such as one an older build wrote with a version header, is replaced and the lineup refetched.
Launch¶
A cold start shows the loader until the settings are known (on Android the system launch screen holds first, through the SplashScreen API, until the start is decided or 1.5 s pass), and for Live until the lineup is too, then decides once (launchTarget): Open on start's Where I left off reopens the tab remembered on the device (AppSettings.lastTab, written whenever the open tab changes), which on Live is the last watched channel full screen through the normal session path when the lineup has it; Library or DVR open on their first page with nothing playing, and give way to Live if the server turns out not to offer them; a first run, a channel the lineup lost, an unconfigured or unreachable server open the guide. With no server set, the guide shows its Connect message and focuses the header's Settings button (homeFocusFor). The guide and its preview do not compose before that decision, so no preview session is opened only to be replaced. Back from that player shows the guide focused on the channel being watched (returnedFrom ranks first in initialGuideRow).
UI¶
Screens use multiplatform Material 3 Expressive components (Button, Switch, ListItem, Tab, FilterChip for filters and AssistChip for chips that act, Surface, OutlinedTextField, LoadingIndicator) under MaterialExpressiveTheme with the expressive motion scheme. tvFocus adds the D-pad focus cue, a scale and an outline ring; guide cells keep their size and change color instead. Every action is a SofaButton, in Material's button hierarchy: a screen's main action (Play, Watch, Unlock) rests filled in the primary color, the rest in Material's tonal colors, and the focused one turns white, as androidx.tv.material3's Button does. Strings are Compose resources in US English. Channel logos and program images load with Coil over the API's connection pool (ui/Images.kt), decoded to their slot's size and kept in Coil's memory cache and, except on the web, its disk cache. Back goes through NavigationEvent: the remote's Back on Android, the swipe on iOS, Escape on desktops and the web.
LocalLayoutProfile picks the metrics. A TV (rememberIsTelevision) keeps its metrics at any size and its screens pad themselves with screenPadding (ui/Adaptive.kt): 48 dp at the sides and 27 dp at the top and bottom for overscan, never hard-coded. Other windows keep clear of the system bars, camera cutouts and the on-screen keyboard and adapt by window size class (currentWindowAdaptiveInfoV2(): compact width under 600 dp, compact height under 480 dp; usage says what compact drops). Touch and mouse controls show only after pointer input (LocalLastInput), so a TV's screens never change; that includes a page's own Back button (showsBackButton), which a TV leaves to the remote's Back key.
The top-level destinations are Live (the guide), Library, DVR (Route.Recordings) and Settings. On a TV, the tabs are Material Tabs in the header beside the app's name, the open one on a pill as Google TV draws it, so the guide keeps its full width and Up from its chips reaches them as it reaches the header's buttons; Settings is the header's one button. The header has no Refresh: the lineup reloads every three hours and on resume, Library reads its shelves each time its tab opens, DVR its list, and Settings › Server › Reload asks at once. Elsewhere Material's expressive NavigationSuiteScaffold (material3-adaptive-navigation-suite) draws them as a short bar on a compact window and a rail otherwise, and slides it away over the full-screen players only. Up from the guide's first row climbs to the selected chip and Down from the chips returns to the grid, as keys rather than a geometric search, which could reach a rail item beside them. Library shows when the server's bootstrap reports the library capability, and hides once the library home shows the server has no libraries; DVR shows when it reports recordings. Back from Library's or DVR's pill opens Live with Arrival.inHeader, which keeps focus on the Live pill (the guide holds its first focus and the preview until focus comes down), so one more Back exits; leaving Settings does the same with focus on the Settings button. The Guide key on their pages opens the guide at Now. Back from an item, a recording or a search result returns focus to it (Route.focusKey); a Library or DVR page that has nothing to focus a few frames after it opens (still loading, or empty) gives focus to its pill, and with focus on the pill the page scrolls back to its top.
Routing is Navigation 3 (navigation3-ui). Navigation holds one back stack of Routes per tab, each starting at the tab's first page, and is saved as state; NavDisplay shows Live's stack with the open tab's on top, so Back from a tab's first page lands on the guide. A page's parent is the entry below it, so Back returns where the viewer came from, Settings included, and a page already on the stack is returned to rather than opened twice. Each tab keeps its pages' saved state, so it opens again where it was left, and picking the open tab again closes its pages. Each page also has its own ViewModels (lifecycle-viewmodel-navigation3's entry decorator), cleared when it leaves its back stack: an item page's ItemPageViewModel holds the item's page and the season it lists open in LibraryViewModel, which keeps an open page loaded and reloads it when what was watched changes, and forgets it once nothing holds it. On a window wide enough for two panes (never on a TV), a library grid and the title picked from it share one scene, through adaptive-navigation3's ListDetailSceneStrategy: their routes carry list and detail pane metadata, and rememberLibraryPanes uses the strategy only when a grid is among the pane pages on top. One-shot arrival details (Arrival: the item, chip or channel to focus on return, the guide's Now and search) sit beside the stacks and are not restored. Back on the guide and on a tab's first page is still the page's own (guideBackAction, tabBackAction): it climbs to the header's open tab first. Each page has its entry's own lifecycle, while the players and the preview read the app's (LocalAppLifecycleOwner), so they save their place and free the stream only when the app leaves the foreground, not on every Back. A TV cuts between pages; other windows cross-fade. Entering the library or DVR stops the guide's preview and releases its session, and ON_START resumes a live stream only on the guide and the live player.
Guide¶
The grid covers 24 hours from the half-hour at which it opens. It moves the window when the current time leaves that range. Rows share a horizontal scroll position; Up/Down preserve the time cursor. Filter, search, cursor, scroll and focused channel are saved across full-screen playback. Voice search goes through Android's RecognizerIntent activity; the manifest declares the query so the app can tell whether one is installed, and the text field is the fallback there and on every other platform.
Models are immutable; the UI module's Compose stability configuration marks the core models stable. A minute clock that ticks on the wall-clock minute updates the guide and current-program displays. Focus requests for lazy items wait until their nodes attach. When guide data arrives after the channels, the focused cell's details are refreshed from the new row and focus returns to the cell at the time cursor.
Playback¶
The app holds one LivePlayer. Preview and full-screen playback share it, so opening the previewed channel preserves the stream. Preview tuning debounces focus by 350 ms, and the preview shows the channel's card over the last channel's picture until the new stream is set. Tune effects wait for the host to reach STARTED. PreviewDwell holds the preview's rest timers (timing) on a monotonic clock (uptimeMs, never the wall clock): muted on tune, then sound, then full screen through the same promote handoff as OK, and the timing decisions stay in the class, not in effects; a key press or a focus move restarts both and mutes, focus off the grid or an open search stops them, nothing opens while the host is stopped and ON_START restarts the rest, and the channel the player was just left on is suppressed until another channel takes focus.
| Platform | Player | Plays | Captions | Media session |
|---|---|---|---|---|
| Android | Media3 ExoPlayer | MPEG-TS, HLS | CEA-608/708, drawn by the app with per-channel timing | MediaSession |
| Desktop | libVLC through vlcj | MPEG-TS, HLS | Drawn into the picture by libVLC | On Linux, MPRIS on the session bus through dbus-java (MprisSession: metadata, play, pause, next, previous); none on Windows or macOS |
| iOS | AVPlayer | HLS; the server packages MPEG-TS channels as HLS | Drawn by AVPlayerLayer in the iOS caption style | Now Playing with the channel logo, and remote commands |
| Apple TV | AVPlayer in AVPlayerViewController |
HLS, with MPEG-TS channels packaged as HLS by the server; library files as MP4 or HLS | The system player's | The system player's; its transport bar carries the favorite toggle and a channel menu (recent, favorites, all) that tunes without leaving the player |
| Web | <video>, drawn into the Compose canvas through WebGL |
HLS where the browser plays it natively | The browser's in-band text tracks, drawn by the app in the canvas and by the browser in the element-view fallback; Safari exposes CEA-608, Chrome's built-in HLS exposes none | Page title and the Media Session API (metadata, play, pause, next, previous) |
On the web the <video> element stays out of the page: each frame the video presents (requestVideoFrameCallback, or every display frame where a browser lacks it) is uploaded into a texture of its own size and blitted into a WebGLRenderTarget, which draws as a Compose image, so overlays draw over the picture. Browsers refuse autoplay with sound before a user gesture; the player then plays muted and restores the sound on the first key or pointer press. Without WebGL2, or when a cross-origin server sends no CORS headers on media, the player falls back to an HTML element view, which Compose stacks above its canvas and so hides the full-screen overlays.
LivePlayer exposes state as flows (status, errors, cues), so screens collect them instead of registering listeners. The desktop, iOS and web players share StreamLivePlayer: the held stream, the status and error flows and the live-resume rule, under which a play after more than 30 seconds paused on a live channel reopens the stream at the edge; Android's player keeps its item in Media3 and seeks to the edge instead. The full screen keeps the display awake while it plays (KeepScreenOn): the view's flag on Android, the idle timer on iOS, a screen wake lock in the browser and the OS power API on Windows and macOS; a Linux desktop follows its own idle settings. The window around a full-screen player comes from rememberPlayerWindow: on Android it hides the system bars and, on a phone or tablet, hands the player to Jetpack's core-pip, which shrinks it into picture-in-picture when the viewer leaves the app; the activity stays started there, so the stream plays on through the same session and the players draw only the picture while LocalInPictureInPicture is true; closing the window stops it, which the app's ON_STOP handles as it does behind another app. The media session's player (LivePlayerWrapper) is a ForwardingSimpleBasePlayer that forwards every required overload of Media3's Java interfaces explicitly, never by Kotlin delegation. A desktop window and a browser page go full screen through WindowFullScreen, which their entry points pass to App. The desktop player looks for libVLC and loads it on its own command thread, once per process, so the window never waits on the search, which walks the library directories where VLC is missing. A desktop without libVLC gets a player whose every prepare fails with VLC_NOT_INSTALLED, so the screens show their failure cards and the rest of the app works.
Every stream comes from a playback session. PlaybackSessions holds at most one: the preview opens it with preview intent, and OK on the previewed channel promotes the same session to watch with a PATCH, so the player keeps its item and nothing reloads. The full screen sets the shared player's volume to 1 when it opens, and the preview writes it only while the guide is the page on top, since the guide stays composed for a frame under the player it hands over to. Tuning another channel releases the held session, then opens the next. The lease is renewed every 15 seconds with an empty PATCH, inside the 45-second preview lease. A renewal that returns a new descriptor epoch, or 410 SESSION_EXPIRED followed by a reopen, is published on replaced; the app swaps the held item's URL and prepares it. On Android and iOS, the player reports a 410 from the media relay as an error whose first reconnect runs without the backoff delay, and the preview tunes again up to twice before it shows Preview unavailable; the desktop and web players report no HTTP status, so a failure on them waits out the backoff in the player and shows Preview unavailable at once in the preview. Tuning again re-reads the session and takes the current URL.
A movie's or show's page plays its trailer behind the title as a library preview session (PlaybackSessions.preview), opened only while the app holds no session and no song is queued, so it never releases anything. TrailerDwell holds the three-second rest on a monotonic clock, TrailerPlayback borrows the shared player muted and ends the moment its session or the player is taken, and the server holds the session for 45 seconds past its last renewal and ends it first when a watch open needs the conversion slot. A server that does not know previews answers watch, and the app then releases the session and shows the backdrop alone. The app renews it every 15 seconds like the guide's preview, and does not reopen it when the server drops it.
Items carry the channel id and the MIME type from the descriptor (on Android mediaItemFor sets both), translated to Media3's HLS constant, so Media3 never sniffs an HLS playlist as MPEG-TS. Session opens run under NonCancellable, so they complete even when the tune is canceled, and a canceled tune releases what it opened: a leaked session costs a slot, capacity is small and the server evicts the oldest preview. Screens call tune and setIntent on PlaybackSessions, never the session API directly.
ON_STOP and Settings stop playback and release the session; Settings forgets it, ON_STOP remembers the channel and intent. Both read the app's lifecycle (appLifecycleOwner), not a page's: a page's LocalLifecycleOwner is its Navigation 3 entry's, which also stops when the page is left. ON_START opens a fresh session for that channel, points the held item at its URL and prepares it while preserving paused intent; if the reopen fails, the stale item is prepared and the screen's reconnect takes over. In a browser the app's lifecycle follows the tab's visibility (rememberAppLifecycleOwner): a hidden tab is stopped whatever focus says, and a tab shown again counts as started only after 500 ms visible, so a page flashed into view opens no session. A closing tab cannot wait for the stop's release, so ReleaseOnPageExit sends the held session's DELETE itself on pagehide, as a keepalive request (PlaybackSessions.exitRelease). A new server address or token releases the session before anything tunes on the new server. The app releases the player on disposal. PlayerScreen and VodPlayerScreen hold the media session through LivePlayer.openMediaSession while they are composed and the app is started (LifecycleStartEffect), so no session outlives the stopped player; the music holds it while a song is loaded. The newest page to open it holds it (NewestMediaSession): Up next replaces the page while the two cross-fade, and the older page's close then does nothing. Only the music plays on behind the app: on Android MusicService, Media3's MediaSessionService, carries the music's session while a song is loaded, which puts up its notification and keeps the process alive; iOS declares the audio background mode. Sound follows the volume: Android asks for audio focus, and iOS takes the Playback audio session category, only while the player is audible, so the muted preview never stops other apps' sound.
A PlaybackStream marked seekable (a recording or a library file, never a live channel) can be moved through: isSeekable, positionMs, durationMs and seekTo work on every engine, durationMs grows while a recording is still being made, and play after a pause resumes where it paused instead of rejoining live. PlaybackSessions.play opens a library item with the engine's playbackProfile (the containers, codecs, height and HDR it plays as they are; on Android the codecs Media3 finds a decoder for, and HDR only when an HEVC decoder takes HDR10's profile and the display shows HDR10) and releases whatever was held, live or not; tuning a channel releases an item the same way. The server answers with the file itself or with HLS it copies or converts from the asked start. A converted stream's 0 is that start (offsetMs), so the item's position is the player's plus offsetMs; a file played as it is starts at 0 and the player seeks to startAtMs once it is loaded. A seek past what the server has written calls restartAt, which restarts the conversion there under a new URL. The item's lease is renewed every 15 seconds; a dropped one, and a reopen after ON_STOP, open the item again where the player last reported it was (reportPosition).
LibraryViewModel saves that position on the server every 10 seconds while an item plays and at once when it stops; the server marks it watched past 90% and clears it under 5%.
Music plays apart from any page. MusicPlayback, which the app holds beside the shared player, keeps the queue (core's MusicQueue: the songs as picked, the order they play in, shuffled or not, and Repeat off, all or one) and opens each song's session itself through PlaybackSessions.play, so a song plays on while the viewer browses and the next one opens when the player reports the end. Any other stream the sessions hold (a channel, a movie, a recording) ends the music, so the pages that play nothing of their own leave the player alone while a song is loaded (RouteStreamEffects). Settings holds it, as ON_STOP does a paused song: the player stops, the session goes free, and Play opens the song again where it was. A song playing at ON_STOP plays on (MusicPlayback.playingOn). While a song is loaded the music holds the media session, whose next and previous step through the queue. NowPlayingScreen and, away from a TV, MiniPlayer under the pages show it; on a TV the tab pages' header has a Now playing button instead, and NowPlayingScreen rests (dims and drifts) after 30 seconds without a key, against burn-in. The Visualizer button on NowPlayingScreen draws MusicVisualizer behind the page: core's visualizer package is a MilkDrop re-creation on the CPU (an ns-eel evaluator, .milk presets, a warp mesh with feedback, waveform modes) that renders ARGB pixels at 256 by 144, which PixelBitmap puts on screen; its looks are core's built-in Presets and then the classic pack that ClassicPack reads from files/presets.txt in the ui resources (MilkPreset.parsePack, off the main thread, shuffled), and the Next look button counts a skips value that moves it on; AudioAnalyzer turns PCM into the waveform, spectrum and bass, mid and treble levels it reads. An engine feeds the analyzer through LivePlayer.audioTap; an engine that cannot tap its output leaves the button out:
| Engine | Tap |
|---|---|
| Android | Media3's audio sink through a TeeAudioProcessor |
| Web | The video element's output through a Web Audio AnalyserNode, built the first time the visualizer asks and not for a cross-origin stream without CORS (the graph would play it as silence). AudioAnalyzer.beforeFrame reads it before each frame |
| Desktop | A second, silent libVLC player (VlcAudioTap) that follows the heard one's stream and position and hands its PCM to the analyzer through an audio callback, running only while a visualizer draws, since libVLC's own output cannot be tapped without taking it over |
| iOS | An MTAudioProcessingTap on the item's audio track (AvAudioTap). AVFoundation does not run it on an HLS stream, so a song the server converts to HLS shows a quiet picture |
The Apple TV app plays music on its own AVPlayer (MusicPlayer in tvosApp) over the same queue and session calls (TvMusic), with MPNowPlayingInfoCenter and MPRemoteCommandCenter for the system's Now Playing; it sends Sofa-Library-Kinds: music too.
VodPlayerScreen plays the held item for library files and recordings. The seek steps are AppSettings.seekSteps, and the presses build a target on the seek bar that applies once they rest for 0.7 seconds or on OK. The seek plan (vodSeekPlan in VodPlayback.kt) seeks in the player when the target is in the stream it holds and calls restartAt otherwise. Breaks passed in VodTitle.markers show on the bar, and with skips.commercialBreaks set to AUTO playback that runs into one jumps to its end (BUTTON offers Skip break, OFF nothing); a seek into a break stays there. While another app is in front the screen saves the position, and back in front it reopens the item there itself, so the app's ON_START resume and its replaced-stream handler leave library items to it. Leaving or reaching the end saves the position and releases the session for good.
PlaybackSessions.playRecording opens a recording the same way, with its id as the stream's itemId, and RecordingsViewModel saves its position and marks it watched past 90%; the DVR tab passes the recording's commercial breaks as markers and the viewer's Skips (Skip commercials, Skip intros and Skip credits, each AUTO, BUTTON or OFF) as skips.
LivePlayer also lists the stream's audio tracks and reports what it knows about the stream (StreamInfo), which the stream card shows with the session's source and id from the server's descriptor and, read every 5 seconds while the card is open, the session's health (LiveSessions.health). Channel changes update the info bar at once and tune once presses have rested for ZAP_SETTLE_MS (400 ms; the first tune does not wait); the tune stops the player, resolves the session, sets the item and prepares it. Falling behind the live window re-prepares at the live edge inside the player. Other errors and ended streams reconnect with backoff while the player is visible and the host is started; no watchdog reconnects a stream that stalls but stays open. A session refused with CHANNEL_NOT_FOUND is not retried: the player asks the ViewModel to reload the lineup, waits for its catalog, and follows the channel to the row with the same number, then the same name; when neither exists it returns to the guide, which shows a short notice. A reload that fails or times out is retried with the reconnect backoff; the dead id is never asked for again except by OK.
On Android the TS extractor declares CEA-608/708 tracks for MPEG-TS sources; HLS sources go through Media3's HLS extractor, which exposes CEA-608 by default. CaptionSync and its load control apply per-channel timing (an earlier timing waits for that much buffer, a later one applies at once), and the player picks the preferred text track on every track change. Media3 cues become CaptionCues, whose positioning constants equal Media3's, and the common Captions composable draws them in the selected style on the full screen and over the preview. Renderer-thread settings use volatile fields. Video uses Media3 ContentFrame and a SurfaceView.
Server interface¶
The app uses the Sofa King server API through the request and response types in contract/, which the server compiles against too: GET /api/v1/bootstrap for the server's capabilities, GET /api/v1/channels with If-None-Match, POST /api/v1/guide/query, POST, GET, PATCH and DELETE on /api/v1/playback-sessions, POST /api/v1/pair to claim a pairing code, GET and PUT on /api/v1/preferences/{clientId}, through which ViewerSync keeps the hidden channels, favorites and recent channels in step with the server's copy, the library reads under /api/v1/libraries and /api/v1/library, POST, PATCH and DELETE on /api/v1/vod-sessions, which play recordings too, and GET, POST, PATCH and DELETE on /api/v1/recordings and /api/v1/recording-rules. API requests carry Authorization: Bearer TOKEN when a token is set, which a pairing claim has not; media requests under /media/sessions and /media/vod, and artwork under /artwork, carry none. Descriptor URLs are relative and resolved against the server address. The token is stored with the other settings and is never logged or printed.