The thought came to me one evening: what if the artwork from my own wallpaper app simply played on the living-room TV? I had been running iPhone wallpaper apps as an indie developer for a few years, and I suddenly wanted to see them on a large screen. Apple TV tends to fall outside the usual no-code or cross-platform conversation, but once Rork Max started generating native Swift, it finally became a realistic option.
tvOS works quite differently from iPhone. There is no touch; instead of a finger, you operate through a concept called focus. It is also assumed to stay on for long stretches, so power draw and burn-in deserve attention. Here I will use a quiet "ambient display app" that just keeps playing video as a vehicle for walking through how to refine the Swift that Rork Max generates.
Why build Apple TV with Rork Max
Standard Rork builds cross-platform with React Native (Expo), but the heart of tvOS — the focus engine, Top Shelf, and low-level AVPlayer control — does not come naturally without native Swift. Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, so when you are targeting Apple TV, this is the foundation.
My own rule of thumb is simple. When I want to ship iOS and Android together quickly, I use the React Native version; when I want to reach into Apple hardware or OS integration, I use Rork Max. Apple TV is a textbook case of the latter, because the cross-platform advantage simply does not exist here.
| Aspect | React Native Rork | Rork Max (native Swift) |
|---|---|---|
| tvOS support | Effectively unsupported | Officially supported |
| Focus engine | Thin abstraction, awkward | Native in SwiftUI |
| Top Shelf extension | Difficult | Implementable as an App Extension |
| Low-level video control | Limited | Direct AVFoundation access |
If you ask Rork Max to "make an ambient app for Apple TV that plays video full-screen on a loop," it returns a SwiftUI scaffold. As-is, though, rough edges remain: it plays, but flashes black at the seam, or focus moves in ways you did not intend. Everything from here is the hand-work.
Building a seamless video loop
The first thing you hit is the loop seam. A naive implementation that rewinds the same clip with AVPlayer and seek(to: .zero) inserts a faint black frame on every rewind. On a large TV, that instant is clearly noticeable.
The fix is the combination of AVQueuePlayer and AVPlayerLooper. Internally it prefetches the next item and stitches playback without a gap.
import AVFoundation
import SwiftUI
final class AmbientPlayerModel: ObservableObject {
let player: AVQueuePlayer
private var looper: AVPlayerLooper?
init(resourceName: String) {
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "mp4") else {
self.player = AVQueuePlayer()
return
}
let item = AVPlayerItem(url: url)
let queue = AVQueuePlayer()
// AVPlayerLooper handles the seamless loop
self.looper = AVPlayerLooper(player: queue, templateItem: item)
self.player = queue
queue.isMuted = true
queue.actionAtItemEnd = .advance
}
func start() { player.play() }
func stop() { player.pause() }
}The key is to keep AVPlayerLooper retained as a property. Make it a local variable and it gets released, and the loop stops. Rork Max's initial output often leaves this as a local variable, so I promote it to a property every time.
On the SwiftUI side, wrapping AVPlayerLayer thinly is easier to control than using VideoPlayer. VideoPlayer carries playback control UI that just gets in the way for an ambient use case.
struct PlayerLayerView: UIViewRepresentable {
let player: AVQueuePlayer
func makeUIView(context: Context) -> PlayerUIView {
let view = PlayerUIView()
view.playerLayer.player = player
view.playerLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PlayerUIView, context: Context) {}
}
final class PlayerUIView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}Setting videoGravity to .resizeAspectFill fills the TV's aspect ratio without margins. My wallpaper assets have all sorts of aspect ratios, so switching this to .resizeAspect produced black bars on the sides and broke the ambient immersion.