居間のテレビに、自分の壁紙アプリの絵がそのまま流れていたら——そう思ったのは、iPhone 向けの壁紙アプリを個人開発で何年か運用してきて、ふと大きな画面で見たくなったときでした。Apple TV は普段ノーコードやクロスプラットフォームの話題から外れがちですが、Rork Max がネイティブ Swift を生成するようになって、ようやく現実的な選択肢になりました。
tvOS は iPhone とは作法がかなり違います。タッチがなく、指で触れない代わりにフォーカスという概念で操作します。常時点けっぱなしにされる前提もあり、消費電力や焼き付きへの配慮も要ります。ここでは、静かな映像をただ流し続ける「アンビエント表示アプリ」を題材に、Rork Max の生成コードへ手を入れていく流れを追います。
なぜ Apple TV を Rork Max で作るのか
標準の Rork は React Native(Expo)でクロスプラットフォームに作りますが、tvOS の本領であるフォーカスエンジンや Top Shelf、AVPlayer の低レベル制御は、ネイティブ Swift でないと素直に届きません。Rork Max は iPhone・iPad・Apple Watch・Apple TV・Vision Pro 向けにネイティブ Swift を生成するため、Apple TV を狙うならこちらが土台になります。
私自身の使い分けは単純です。素早く iOS と Android を同時に出したいときは React Native 版、Apple のハードウェアや OS 統合に踏み込みたいときは Rork Max。Apple TV は後者の典型で、クロスプラットフォームのうまみがそもそも存在しません。なお Rork Max は月 $200、標準の Rork は無料で始められ有料は月 $25 からと、価格差は小さくありません。2クリックで App Store に出せる手軽さを含め、Rork Max でしか届かない領域でこそ、この $200 を活かしたいところです。
| 観点 | React Native 版 Rork | Rork Max(ネイティブ Swift) |
|---|---|---|
| tvOS 対応 | 実質的に非対応 | 正式対応 |
| フォーカスエンジン | 抽象化が薄く扱いにくい | SwiftUI が標準対応 |
| Top Shelf 拡張 | 困難 | App Extension として実装可能 |
| 映像の低レベル制御 | 限定的 | AVFoundation を直接利用 |
Rork Max に「Apple TV 向けに、フルスクリーンで映像をループ再生し続けるアンビエントアプリを作って」と頼むと、SwiftUI ベースの雛形が返ってきます。ただしそのままでは「再生はするが継ぎ目で一瞬黒くなる」「フォーカスが想定外に動く」といった粗が残ります。ここから先が手作業の領域です。
継ぎ目のない映像ループを組む
最初に直面するのが、ループの継ぎ目です。AVPlayer に同じ動画を seek(to: .zero) で巻き戻す素朴な実装だと、巻き戻しのたびにわずかな黒フレームが挟まります。テレビの大画面では、この一瞬がはっきり気になります。
解決策は AVQueuePlayer と AVPlayerLooper の組み合わせです。これは内部で次の再生アイテムを先読みしておき、切れ目なく繋いでくれます。
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 が継ぎ目のループを担う
self.looper = AVPlayerLooper(player: queue, templateItem: item)
self.player = queue
queue.isMuted = true
queue.actionAtItemEnd = .advance
}
func start() { player.play() }
func stop() { player.pause() }
}ポイントは AVPlayerLooper をプロパティとして保持し続けることです。ローカル変数にすると解放されてループが止まります。Rork Max の初期生成ではここがローカル変数になっていることが多く、私は毎回プロパティへ昇格させています。ループが突然止まるときは、まずここを疑うのが近道です。見落としやすい注意点だと感じています。
SwiftUI 側は VideoPlayer を使わず、AVPlayerLayer を薄くラップした方が制御しやすいです。VideoPlayer は再生コントロールの UI を抱えてしまい、アンビエント用途では邪魔になります。
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 }
}videoGravity を .resizeAspectFill にしておくと、テレビの比率に合わせて余白なく敷き詰められます。私の壁紙素材は縦横比がまちまちなので、ここを .resizeAspect にすると左右に黒帯が出てしまい、アンビエントの没入感が削がれました。