Design

ATV Bili.

A BiliBili client for Apple TV. The playback core — DASH remuxing, danmaku, casting — is yichengchen's open-source project; I forked it and rebuilt the interface. That meant a new design system, a home page of shelves, a collapsing sidebar, a theater-style player, SwiftUI settings, and a fair amount of playback plumbing to make it all feel immediate. This page is about the part a diff can't show: what designing for a TV is actually like.

The focus engine

On a TV the whole interface is driven from across the room, and the system owns the scroll position. There is one focused view at a time, the Siri Remote moves it in four directions, and the focus engine decides where it lands: on every press, tvOS searches the view hierarchy geometrically and picks the next view itself. Most of the design work on this platform comes down to arranging views so that search does the right thing — and learning the few, badly documented places where you're allowed to overrule it.

My welcome to tvOS came early: the new sidebar rendered fine, focused fine, and did nothing. Rows lit up, the white pill slid between them, but pressing Select went nowhere — a bare UIControl never fires .primaryActionTriggered on tvOS, only UIButton does, and you find that out at runtime. The fix is four lines. I ended up rediscovering it in three separate files:

RailRowButton.swiftswift
// RailRowButton.swift — why the whole sidebar was dead on arrival.// A bare UIControl does not fire .primaryActionTriggered on tvOS — only// UIButton does. The rail looked alive (rows focused, took the pill),// but Select did nothing.override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {    if presses.contains(where: { $0.type == .select }) {        sendActions(for: .primaryActionTriggered)    }    super.pressesEnded(presses, with: event)}

Home screen

The original app is a row of system tabs across the top, each one a flat paginated grid. It works, and it's honest UIKit, but every session starts with a hunt through the tabs. I replaced it with the layout every TV product has converged on: a collapsing icon rail on the left, and a home page of horizontal shelves — 继续观看 (continue watching), 推荐 (recommended), history, watch-later, weekly picks.

The rework: a left icon rail, a chip bar, and horizontal shelves of cards with duration chips.
The same account, before and after — upstream's recommend tab against the reworked home's five shelves, chip index and rail.

The interesting problems were all focus problems. The chip bar above the shelves works as an index into the page: each chip maps to one shelf and jumps to it, and the bar sits outside the scroll view so it's still there to come back to. The five shelves load concurrently, and ideally whichever arrived first would appear first — but inserting a collection view section above the one the user is focused on shoves the whole page down under them. So arrivals commit in declared order, as far as the leading contiguous run reaches:

HomeViewController.swiftswift
// HomeViewController.swift, compressed. Five shelves load concurrently,// but only the leading contiguous run is ever committed: inserting a// section *above* one already on screen shoves the page — and whatever// the focus engine is sitting on — down under the user.await withTaskGroup { group in    for (i, shelf) in shelves.enumerated() {        group.addTask { (i, try? await shelf.load()) }    }    for await (i, items) in group {        slots[i] = items        while let ready = slots[committed] {   // commit in declared order only            append(section: ready)             // below everything on screen            committed += 1        }    }}

Before any of that lands, the page is a skeleton, and the skeleton cards are focusable on purpose. If the placeholders refused focus, first launch would put focus on the rail and leave it there after the content arrived. Because they accept it, and the collection view remembers its last focused index path, focus starts in the content and rides the swap: the card under your thumb turns from grey box into video without moving. The skeleton shows up about 0.8 s after a cold launch (measured below), which gives the ~1.5 s of API round trips somewhere to happen while the page still looks alive.

The home page as a skeleton: real chip titles over two shelves of grey placeholder cards.
A polled frame from the launch measurement, 809 ms after launch. The chips carry their real titles from the first frame — and only shelves that actually return content keep a chip afterwards, so every chip points at a section that exists.

The rail itself runs on two UIFocusGuides — invisible focusable rectangles that give the beam search somewhere to land when no real view sits in that direction — and each guide is enabled in exactly one state. Collapsed, a full-height guide at the rail's trailing edge catches any leftward move: without it, a card low on a long page has no row geometrically beside it, and Left goes nowhere. Expanded, the guide moves to the rail's other edge so Right can reach a content column that has slid out of the way. The column slides at a fixed width — pinning it to the open rail's edge would re-solve the compositional layout every frame of the animation, and you can watch the cards shrink from 384pt to 312pt and back while it happens. Sliding a rendered column is free.

The theater player

In the original flow, pressing a card opens a detail screen — title, stats, a play button, related videos — and pressing play opens the player on top of that. On a couch it feels like an interstitial: you already chose the video one screen ago, and now you're asked to confirm it. Upstream seems to have felt the same, because its direct-play setting keeps presenting that screen invisibly, as a data source behind a black curtain — so every request it makes still sits between your press and the first frame.

The theater's transport: scrubber, action row, pane chips and a related-videos shelf drawn over the dimmed, playing video.
What a press used to buy, and what it buys now: the detail screen that stood between a card and its video, against the theater — where the video is already playing and 简介 (info), 评论 (comments), 选集 (episodes) and 设置 (settings) are one press away.

I deleted that screen. A press now presents the theater — the container that owns the player — immediately, with nothing but the video's id in hand. The play-URL request runs during the presentation animation, and the info, comments and related panes stream in behind the picture, so startup work that used to be serial now overlaps motion the user was going to watch anyway. The container can also dock: the playing video shrinks into the top-left corner while a pane opens beside it. The docked rect is the full-bleed rect at one uniform scale (1126÷633 is still 16:9), so the picture never letterboxes or distorts on the way. The player survives the transition intact — the theater only animates the frame of the view the player already lives in, and the AVPlayer, its item and every plugin ride along untouched.

The theater docked: the video plays in the upper left while a comments pane fills the right column.
Docked: the same AVPlayer, the same frame animation, comments beside a live player. Threads open in place — the upstream reply screen was a full-width layout that couldn't survive a 587pt column.

The theater has a price: it turns AVKit's chrome off — showsPlaybackControls = false — and with it loses everything AVKit gave for free: the scrubber, play/pause, the buffering spinner, the info panel, and above all AVKit's handling of the remote. Every input the system player used to arbitrate now lands in my code, and it turns out the Siri Remote speaks two unrelated languages. A click of the clickpad ring is a UIPress and walks the responder chain. A swipe across the surface is an indirect touch and produces no UIPress at all — so a transport that only listens for presses answers the click but sleeps through the gesture every other TV player wakes on. The idle theater arms four swipe recognizers (one per direction; a single masked recognizer drops off-axis thumb strokes) restricted to allowedTouchTypes = [.indirect], and disarms them the moment the controls come up — once something on screen is focusable, those same swipes belong to the focus engine, and a recognizer on an ancestor would be fighting it.

Even with the controls up, the engine needed overruling in one place. The scrubber wants Left/Right for seeking, but the play button sits directly to its left — and since the focus engine gets first refusal on every directional input, Left moved focus onto the button and the seek never ran. The fix is a veto:

TheaterFullscreenChrome.swiftswift
// TheaterFullscreenChrome.swift. The play button sits directly left of// the scrubber, so Left moved focus onto it and the scrubber's seek never// ran; only Right, with nothing beyond it to move to, ever reached it.// Refusing the update hands both directions back to the bar. Vetoed here,// on the chrome — the ancestor is reliably in the chain for every move.override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool {    if context.previouslyFocusedItem === scrubber,       context.focusHeading.contains(.left) || context.focusHeading.contains(.right) {        return false    }    return super.shouldUpdateFocus(in: context)}

Two more fixes of the same kind, briefly. Menu is handled by a UITapGestureRecognizer rather than pressesBegan, because a Menu press made inside the docked panel never reaches the container's press chain — UIKit dismisses the whole presentation first, which turned "back one level" into "quit playback". And when the transport wakes, focus is claimed only if the controls are actually arriving: every button press routes back through the same wake call to restart the auto-hide timer, and an unconditional focus update threw focus back to the head of the row on every press — tap ±10s once, and your next Select landed on play/pause instead.

Replacing UIAlertController started as a correctness fix and picked up its styling later. If playback fails while the theater is still animating in, the player — a child controller — tries to present its error alert during its parent's presentation, and UIKit silently drops it. All the user gets is a black screen; the message and its buttons went down with the alert. In the rework, containers own their alerts, and the alert itself is a SwiftUI panel presented over everything: MorphingModal, one component for every confirm, notice and picker in the app.

A dark glass panel near the bottom of the screen: a quality picker with 1080p focused and checked, 4K, Dolby Vision and cancel below.
The quality picker over settings. Focus opens on the value the picker already holds — a picker opened by mistake closes on the same value it opened with. Bottom-placed on purpose: a dialog that grows out of the middle of a ten-foot screen reads as an interruption.

Its motion numbers were ported from a web component, with one correction on the way in: the reference spring (mass 0.5, stiffness 420, damping 40) is overdamped — critical damping at those values is 2√(km) ≈ 29, so 40 puts the ratio at ~1.38, and an overdamped spring spends its last third of travel crawling. On a TV the crawl read as lag, so the panel runs at exactly critical damping (ω₀ = 40 rad/s, settle ≈ 120 ms, no overshoot — a dialog that wobbles on a ten-foot screen reads as cheap). The focus engine, meanwhile, needed three separate concessions, ending in the least dignified code in the repo — which ships anyway, because the alternative is a visible focus jump off the cancel button a frame after every open:

MorphingModal.swiftswift
// MorphingModal.swift. The panel enters at opacity 0.01 — not zero,// because a fully transparent view is not focusable, and a panel that// entered from 0 had no rows for the focus engine to choose from during// its first update. Even then `defaultFocus` loses that race often// enough that the panel re-states where focus belongs — five times, each// rung timed to land inside the entrance animation, where a correction// is invisible. The last one is insurance; reaching it means something// is wrong anyway.static let focusClaims: [TimeInterval] = [0, 0.016, 0.04, 0.08, 0.16]​private func claimFocus() {    for delay in Motion.focusClaims {        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {            focusTarget = initialFocus   // the value the picker opened with        }    }}

Design tokens

Two token systems drive the visuals, and both were measured from something real. The UIKit system (DS.swift) is transcribed from a reference design and verified at 1920×1080; its colors come in appearance-aware pole pairs — the focus pill is always the opposite pole of the ground and its ink the counterpart, which is what lets a glyph invert on focus instead of washing out. The SwiftUI settings screen runs on a second set (Theme.swift): tokens extracted from Discord's live web client CSS custom properties, kept in web pixels, then scaled by exactly ×1.5 — at 1080p a point is a pixel, and a 16px row label doesn't survive ten feet. The result is a settings screen with web-level information density that still reads from the couch, wearing a focus style of its own in place of the native tvOS lift, halo and scale. Getting rid of those took its own fight: a custom ButtonStyle to suppress the system treatment, and .focusSection() on each column, because the raw beam search finds no path from a low sidebar row to a high pane row.

The rework: a dark two-pane settings screen with iconed sections and drawn toggle switches.
Settings, before and after. The toggles on the right are drawn from scratch — UISwitch does not exist on tvOS, and the upstream convention of 开/关 text made every toggle read like a picker.

The same discipline goes down into details that are easy to shrug off. The two chips on a card — duration bottom-right, stats bottom-left — used to sit at visibly different heights, because one sized itself around an icon and the other got its padding from literal spaces in the string. Now both share one height and one inset, and the corner radius comes off the card's own:

DS.swiftswift
// DS.swift — the chip on the card. An inner corner sitting `inset`// inside an outer corner of `Radius.card` has to be `card - inset` for// the two curves to stay parallel; anything else and the chip reads as a// sticker laid on the card rather than part of it.static let radius: CGFloat = Radius.card - inset   // 16 - 8 = 8

The display face is Outfit, a single variable file, which tvOS makes harder than it should be: the file's default instance is Thin, its named instances carry no PostScript names, and CoreText substitutes on its own — ask for a family that doesn't exist and Helvetica comes back with no error. So every weight is dialled on the raw wght axis and the resolved family name is verified before a font is handed out. Even the login QR is a design surface: the module matrix is read back out of the generated bitmap and redrawn — runs of modules merge into capsules, the finder eyes become brand-pink squircles, and the error-correction level is lowered from H to Q on purpose, because fewer, fatter modules are what make the rounding legible at ten feet. Scanners read luminance alone, so the data stays near-black.

The add-account screen: a styled QR code with rounded module blobs, pink finder eyes and a TV-head mark punched into the centre.
The styled login code — correction level Q recovers six times what the centre mark costs.
The account switcher: a row of large round avatars over a blurred backdrop built from the signed-in user's own avatar.
The switcher it lives in — the backdrop is the user's own avatar blurred past recognition, the YouTube-TV trick for a colour that belongs to this user.

Startup performance

Upstream attacked startup latency speculatively: pre-build entire players — asset, resource loader, prefetched segment index — for the videos around your focus, and hope you press one of them. It worked, but it cost real API calls against rate-limited endpoints for signed URLs that expire, four live assets in flight at a time, and a tail of cancellation-race fixes. The rework deletes that pipeline and goes after the same seconds from three cheaper directions: prefetch only the one id that's idempotent and free (the video's cid, fetched after a 250 ms dwell on a focused card); present the theater on the press, so the network overlaps the animation; and coalesce the duplicate requests one press fans out (the player and the panes both want the same detail JSON — whoever asks first opens the connection, the other joins it).

The DASH shim earns the rest. tvOS AVPlayer doesn't speak BiliBili's DASH, so the app synthesizes HLS playlists on the fly (an atv:// resource loader), and two lines of that playlist are doing perceptual work:

synthesized playlisttext
#EXTM3U#EXT-X-START:TIME-OFFSET=214.6,PRECISE=YES#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1183000,CODECS="hvc1..."atv://dash/0#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2470000,CODECS="avc1..."atv://dash/1

The EXT-X-START tag is the resume position — before it, the player buffered from 0:00, then seeked, throwing away an entire forward buffer and paying startup twice. And the first variant listed is the cheapest codec of the top quality tier, because a cold AVPlayer takes variant #1 before it has any bandwidth history — HEVC first means the first segment is 30–40% fewer bytes at the same quality. Per-session CDN host probing (serial, 256KB per candidate, deferred 12 s so it never steals the first segment's bandwidth) is remembered for ten minutes and keyed on the candidate set, so the next video on the same CDNs starts on the measured-fastest host for free.

Measured Cold launch → something to watch
Method: tvOS 26.5 simulator, Debug builds of both branches, same logged-in account and network, warm image caches. Terminate, launch, poll simctl screenshots at ~3–4 fps with monotonic timestamps; the number is the first frame showing each state, so ±0.3 s. Three interleaved runs each — skeleton 0.80–0.91 s, first shelf 1.63–1.91 s, settled 2.08–2.59 s; original settles 1.18–1.21 s on an empty default tab, and its grid lands 2.46–2.56 s after a manual hop to 推荐 (drawn hatched; the human reaction time between the two isn't counted). Simulator numbers are good for comparing the two builds; hardware needs its own run.

The chart stops one number short on purpose: press-to-first-frame for playback. Neither branch instruments it, and a screenshot poll can't separate the theater's entrance animation from buffering. The pieces above — the resume hint, the cheap first variant, the remembered host, the overlap with the presentation — each rest on mechanism alone. On a hobby project I'd rather say that plainly than dress a plausible number up as data.

Conclusion

Credit first: the DASH playback core, the danmaku engine, the DLNA casting and the API layer are yichengchen's and its contributors' work. The rework deleted some of upstream's newest UI experiments, but it stands on that foundation and says so. It remains a hobby fork — sideloaded, unaffiliated, and one BiliBili API change away from breaking. There are rough edges I know about: two design systems coexist (the UIKit one is appearance-aware, the web-derived one is dark-only, and they disagree on principle); the like/favorite tints flip optimistically and can lie if the request fails; favourites always land in the default folder because a picker over a playing video wasn't worth it; and the modal restores focus on dismissal by UIKit convention rather than by design. A rework is finished the way a lawn is mowed — it'll need doing again. But the focus engine and I are on speaking terms now, and that was the point.

Source: github.com/AvocadoKing1210/ATV-Bilibili-demo, branch ui-rework. Built on yichengchen/ATV-Bilibili-demo — an unaffiliated, non-commercial demo project; the original's disclaimer applies here in full. Screens captured 2026-08-13 from the tvOS 26.5 simulator.