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.

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.