LAB-010keiver/tomotv
Player · Up Next

Up Next on Apple TV with AVContentProposal

tomo-tvapple-tvavkittvosavcontentproposal

Tomo TV on Apple TV uses AVContentProposal, the card Apple's TV app shows between episodes. Leaving didReject unimplemented makes AVKit dismiss the player view controller. The transition time must be a position playback actually passes through: the outro's end and the runtime both sat past the player's own duration, so the card never appeared.

keiver.dev/lab/10

Between episodes on Apple TV, Tomo TV shows a card with the next episode's poster, title, and a countdown. That card is AVContentProposal, the same one Apple's own TV app presents, and it slides in over the still-playing video. The API is small. The surprises came from AVKit's defaults: what it does with a callback you leave out, a race while the card slides in, and the points on the timeline it never reaches.

AVContentProposal brings the system card to tvOS

AVKit presents an AVContentProposal over the player when playback passes a contentTimeForTransition. You supply an AVContentProposalViewController subclass, assign it to contentProposalViewController, and AVKit slides the card in. From there, your view controller owns the layout: poster, title, countdown, two buttons. Play Now and Close both call dismissContentProposal(for:animated:completion:), which triggers either the didAccept or didReject delegate callback.

Both callbacks forward to JS, which owns the queue advance and the route change. AVKit never swaps the player item. React Native Video does not expose these delegate methods, so they live in Tomo TV's patch of that library.

Leave didReject unimplemented and AVKit closes the player

The most important three lines in the implementation are also the easiest to skip:

func playerViewController(_: AVPlayerViewController, didReject _: AVContentProposal) {
    _handlers?.handleContentProposalRejected()
}

When didReject is not implemented, AVKit's default is to dismiss the player view controller. For a presented, full-screen player that is a reasonable default. In Tomo TV on Apple TV the player is an embedded child view controller, not a presentation, so the default would close the wrong thing. Implementing the callback takes the decision back: it forwards the event to JS, which pops the player route.

One card at a time

A seek that crosses the transition point can fire both AVKit's boundary-time observer and a user-navigation handler inside a single animation frame. The second present arrives while the first card is still sliding in. AVKit's own guard, isPresentingContentProposal, is set only after the slide-in, so the second present dismissed the animating card and asserted.

The shouldPresent delegate method returns false while contentProposalViewController.presentingViewController is non-nil. That property is set at the start of the slide-in animation, earlier than isPresentingContentProposal, so it covers the race. A second present during mid-slide is silently declined.

Arrive at the credits marker

The card appears when playback passes contentTimeForTransition, so that time has to be a point playback actually reaches. The obvious values are not. On a test episode the outro's end and the total runtime landed on the same tick, 1926.5707 s. Both sat just past AVPlayer's own reported duration of 1926.5266 s. AVKit never reached either, so the card never appeared. The queue stalled too, because on Apple TV the card owns the advance.

The transition is anchored at the start of the credits, from Jellyfin's media segments. That is a frame the player always passes through, and it is where a viewer expects the next episode to be offered.

The Skip Credits pill is hidden when a card will appear. Both would land on the credits, and the card already has a Play Now button.

A dark film frame in Tomo TV on Apple TV with a Skip Intro button in the lower right corner
The Skip Intro pill reads the same Jellyfin media segments. The intro times this pill; the outro times the Up Next card and the Skip Credits pill.

Automatic acceptance is ours to drive

AVContentProposal has an automaticAcceptanceInterval property and the view controller has a dateOfAutomaticAcceptance. Neither does anything here. React Native Video pauses the player and zeroes its rate the moment the item ends, leaving no playback timeline for AVKit to count down against.

The card owns its own countdown instead. It observes AVPlayerItemDidPlayToEndTime, records the wall time at that notification, and runs a one-second timer from there. When the interval expires the timer calls dismissContentProposal(for: .accept, animated: true, completion: nil), the same call Play Now makes. Expiry and the button are one code path.

Artwork arrives separately. The card is set immediately with a blank poster, then a background URLSession task fetches the image. Once the bytes land, item.nextContentProposal is replaced in place. AVKit re-reads the proposal and updates the poster without re-presenting the card.


The rule behind all of it: when an AVKit default assumes a presentation stack that does not match yours, implement the delegate method and route the event yourself. The same AVKit-first approach drives channel surfing on Apple TV, and the episode's subtitles get there on time thanks to the fix in why Jellyfin subtitles delay playback. The app is at Tomo TV.

Drafted with Claude from Tomo TV's code and device measurements. Reviewed, edited and verified by Keiver Hernandez.

Previous logLAB-009keiver/tomotv
Live TV · Channel wall

Live TV channel previews that loop

tomo-tvapple-tvlive-tvtvosffmpeg

Tomo TV loops a five-second live TV channel preview clip on every card of its channel wall. Each channel is opened once: the first keyframe becomes the card still and the next five seconds are encoded on device to 360p at 1 Mbps. Clips are written as a temporary file and renamed only when whole. A red badge reads LIVE for 15 seconds after the picture changes, then counts down to the next grab. The player pool starts at 12 on Apple TV and lowers itself when the hardware decoder reports busy.

5 min read