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
| TV | App | How |
|---|---|---|
| Google TV, Android TV, Fire TV | The Android app (android/) | The web page, switched into TV mode |
| Apple TV | ios/SoundStormTV | Native 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:
- The arrows move a focus to whatever can be pressed nearest in that direction, scoring the gap along the arrow plus three times the offset to the side. Only the top layer is reachable - a menu over Now Playing, Now Playing over the library - and closing something puts the focus back where it was.
- OK acts on release, so holding it for 450ms opens the menu a hold opens on a phone. It is timed rather than counted in key repeats, because remotes differ in whether a held button repeats.
- A text box reached with the arrows is only highlighted, read-only until OK. Focused for real, it brought up the TV's keyboard at once, which took the arrows - the first emulator run could not leave the search box.
- The TV's web view reports
pointer: coarse, like a phone, so every touch rule applied, including hiding buttons until a hold a remote cannot do.touchScreen()in JavaScript andhtml:not(.tv)in CSS keep touch behaviour off a TV. - A white ring and a slightly grown cover mark the focus, with padding in sideways rows so the ring is not clipped, and margins clear of overscan.
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.
- The buttons fade after four seconds without the remote, leaving the music, its animation and the title. The next press only brings them back, so nobody skips a song by pressing a key to see the controls.
- No play, previous or next buttons. OK plays and pauses; left and right change song while the controls are faded, and move ten seconds while the timeline shows.
- An audiobook's left and right go by chapters.
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
| Area | How |
|---|---|
| Music | AVQueuePlayer 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 Playing | One 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 TV | AVPlayerViewController 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. |
| Subtitles | WebVTT 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. |
| Photos | On this day and the camera roll, a full-screen viewer at preview size; left and right step, down pauses the music. |
| Audiobooks | A book as its files on one clock, the place shared with Audiobookshelf, the account's speed, chapters, and left and right by chapter. |
| Reader | A 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 Along | Storyteller's synced copy, with the sentence at the player's time lit and followed, as the web reader does. |
| Categories | Every tab's pills, in the account's own order and without the ones put away. |
| Even volume, slow links | ReplayGain 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 in | Sign 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 rules | A 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". |
| Servers | Several 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.