TVs

TV apps are the graveyard this project's notes warn about, and they were built anyway, knowingly, in a shape that keeps them small: Android-family TVs run the same Android app with the web page in a TV mode, and the Apple TV - which has no web view at all - gets one native app that follows the same rules.

Two platform families, two apps

TVAppHow
Google TV, Android TV, Fire TVThe Android app (android/)The web page, switched into TV mode
Apple TVios/SoundStormTVNative SwiftUI over the same /api

The scope on both is watching, listening, photos and books. Settings, uploads and account management stay on a phone or computer.

Android-family TVs

The Android app's manifest adds the LEANBACK_LAUNCHER category, a TV banner (drawn by scripts/make-icons.py, like every icon), and marks leanback and a touchscreen as not required, so phones install the app exactly as before. When the app finds itself on a television, the web view's user agent gains SoundStormTV/1 and the page does the rest. In a browser, ?tv=1 turns the TV mode on for testing and ?tv=0 off.

The sign-in screen in TV mode leads with Sign in with your phone - a code and a QR code instead of typing a password with the remote (see accounts) - and a TV asks Who's listening? every time it opens, among the people kept on it (see profiles).

A remote instead of a finger

A TV has no touch and no mouse, only arrows, OK and Back. The TV mode (tvRemote in app.js, html.tv in the stylesheet) handles that:

Films: the whole screen

A film is the whole screen with no browser controls - their small buttons were out of reach of a projector's remote. It opens with the picture focused: OK pauses and plays, left and right skip ten seconds, Back closes. The name, the subtitle and audio pickers and a slim timeline show along the foot when a key is pressed or it is paused, and fade after four seconds. Down reaches the pickers; up returns to the film, which needed handling by hand because the film fills the screen and spatial navigation never finds it "above" anything.

Now Playing on a TV

Now Playing on a projector was reported as laggy and as not fitting. On a TV the visualizers draw at 0.6 of a CSS pixel and 30 frames a second with half the particles, since the drawing runs on the TV's own modest processor. With a look on screen the layout is the TV one: title centred at the top, controls and timeline at the bottom, nothing that scrolls.

Opening Looks once froze the app until it was restarted. The fade's watcher observed Now Playing's classes and woke the buttons by removing tv-idle - and removing a class that is not there still rewrites the attribute, which the watcher saw and answered by waking again, for ever. Any MutationObserver that writes to what it watches must change a class only when it would actually change.

No mini-player, no scrolling

The phone's floating mini-player is gone on a TV: a remote reached it only by scrolling to the foot of a page, and it covered the bottom of every one. Its place is the side bar's first entry while something plays, showing the song's cover; OK opens Now Playing. No view screen scrolls - Now Playing, a film, a photo, a book - after the projector's audiobook screen was found scrolled 120px down. A browser scrolls even an overflow: hidden box to bring a focused element into view, so those views use overflow: clip, which cannot be scrolled at all.

Photos no longer stop the music. On a TV, down over a photo or a book pauses and resumes the music, with a message saying which; left and right step photos and turn pages.

Two bugs from the first real install

Installed on a projector and opened at its LAN address, the app sat on the loading spinner: the page checked it could reach the install's secure name and moved there, and the app took the move for a link out of SoundStorm and refused it. A move to https on the install's own names, same port, is now followed. And on Android 16, Back from inside a menu closed the whole app until the app registered the new back callback (see Android).

Apple TV: a native app

tvOS has no web view, so the page cannot be wrapped as it is on Android TV. The owner decided on a real Apple TV app rather than relying on AirPlay. It is a second target in the iPhone project, written in SwiftUI, with the same bundle id as the iPhone app so that Universal Purchase makes them one App Store listing. What carries over is not code but every behaviour already settled for Android TV - OK plays and pauses, buttons fade after four seconds, the playing song first in the side bar, photos and books keep the music playing - so the app was built to those rules rather than working them out again.

It talks to the same /api the page uses, with the server's session cookie in the shared cookie store. One trap: AVPlayer does not read that cookie store, so every stream is given the cookies explicitly (AVURLAssetHTTPCookiesKey), or the server refuses it.

What it does

AreaHow
MusicAVQueuePlayer with the next song loaded behind the one playing, for gapless playback. Home and Music rows from the page: mixes, radio and moods (topping a station up as the page does), playlists, artists, albums. A play counts at half a song or four minutes.
Now PlayingOne focusable view taking the remote's presses itself, so the focus engine cannot wander. Holding OK opens the menu: favorite, playlist, lyrics, Up next, looks, sleep timer.
Films and TVAVPlayerViewController as it is. Direct files or Jellyfin's HLS through /api/hls. Places shared with the page through /api/book/progress; Up next counts eight seconds into the next episode.
SubtitlesWebVTT read into cues and drawn by the app over the picture (SubtitleOverlay), since AVPlayer cannot attach a separate subtitle file to a stream. Subtitles and Audio are menus in the player's own transport bar.
PhotosOn this day and the camera roll, a full-screen viewer at preview size; left and right step, down pauses the music.
AudiobooksA book as its files on one clock, the place shared with Audiobookshelf, the account's speed, chapters, and left and right by chapter.
ReaderA native EPUB reader (EpubBook, EpubText) laying chapters into pages with TextKit, reading and writing the same CFI as the web reader. PDFs drawn a page at a time by Core Graphics.
Read AlongStoryteller's synced copy, with the sentence at the player's time lit and followed, as the web reader does.
CategoriesEvery tab's pills, in the account's own order and without the ones put away.
Even volume, slow linksReplayGain levelling by the page's formula, set as an audio mix on each item so the level changes exactly where the song does. A song not playing six seconds after it was asked for restarts at 128 kbps, and so does every song after it, until the player measures the link fast again.
Signing inSign in with your phone first: a short code and a QR code a signed-in phone scans or types (see accounts). Or a username and password, waiting for approval where new devices need it.
Who's listening?The people kept on the TV, asked every time it opens, with their PINs (see profiles); Switch person in Settings.
Account rulesA screen for choosing a new password when the account is held to one; while new devices need approval, a signed-in TV is asked to allow other sign-ins, and Back means "Not now", never "Don't allow".
ServersSeveral saved servers on the connect screen, shared code with the iPhone app (ios/Shared).

Crossfade is not built on the TV: it is off by default on the page and set per device, so the TV would need a setting of its own just to turn it on.

All the visualizers, redrawn

The looks were the largest part. All twelve visualizers were ported from the page's JavaScript into SwiftUI Canvas drawing (Visualizers.swift, Visualizers2.swift, Visualizers3.swift, and Storm in Storm.swift), with the page's TV counts and the same frame loop (VizEngine). Where the page fades a trail canvas, which Canvas has no equivalent for, particles keep their last few places and draw them fading.

The beats come from the server first, exactly as on the page: /api/music/beats returns the server's analysis, and the TV gets the page's current lightning rule with it. Only when the server has not heard a song does the TV analyse it itself (SongAnalysis, the page's analysis ported step by step). On the page's own test track it found all 120 beats with a mean error of 6ms.

Signing

Release builds are signed by hand with a distribution profile. Automatic signing archives with a development profile first, which needs a registered Apple TV, and the team had none it could pair. So the Release configuration uses the Apple Distribution certificate and an App Store profile made on the developer site, and an export options file in ios/scripts uses it. When the profile expires, a new one is made under the same name and nothing in the project changes. An unsigned archive was tried and is no good: the Organizer refuses it as having no team.

Much of the Apple TV app was checked on the tvOS simulator against a stand-in server that refuses any stream without the session cookie, and the reader against the real server code. The simulator has no remote, so the press-driven parts - Now Playing's arrows, the hold menus - were written to the rules but checked less than the rest.