目次

目次

PlayPASSアプリをApple Vision Pro対応して動画再生を試してみた

アバター画像
村田真矢
アバター画像
村田真矢
最終更新日2025/12/01 投稿日2024/03/05

はじめに

株式会社レコチョクのiOSアプリ開発に関係するグループでエンジニアリングマネージャーをしている村田です。

2023年6月5日にAppleから発表されたApple Vision Proで、PlayPASSアプリの動画を3D表現で再生できるかを試してみました。 割と簡単にできたので、説明とともに共有しようと思います。

PlayPASSとは、CD/DVD/Blu-rayの対応商品が楽しめる「プレイパス(R)」や、「レコチョク」など対象の音楽配信ストアでの購入商品をダウンロードから再生することができるアプリです。

開発環境

本記事で扱うコードは以下の環境で動作することを想定しています。

  • Xcode 15.2 (15C500b)
  • Swift 5.9.2
  • visionOS 1.0 (21N305)

成果物

成果物は以下のようになります。 既存のPlayPASSアプリのソースコードを活用し、購入した動画を選択して前後左右の4面で動画を再生できるようにしてみました。

目次

  • Apple Vision Pro(visionOS)の概要
    • Apple Vision Proとは
    • Apple Vision ProのUI表現方法の種類
      • ウインドウ
      • ボリューム
      • ウインドウとボリュームの指定
    • Apple Vision Proのアプリ空間の概念
      • 共有スペース
      • フルスペース
      • FullImmersionStyle
      • ProgressiveImmersionStyle
      • MixedImmersionStyle
  • 実装内容
    • プロジェクトの設定
    • ソースコード
      • VisionApp
      • VideoSelectionView
      • ImmersiveVideoPlayerView
  • おわりに

Apple Vision Pro(visionOS)の概要

先にApple Vision Proについて説明します。

Apple Vision Proとは

Apple Vision Proは、空間コンピュータとAppleが説明しています。 空間をつかった表現と、目と手、声をつかって操作ができるUIをもつコンピュータと理解しています。 外観からMR、VR等のヘッドセットのイメージを持ちがちですが、 Macのパーソナルコンピューティング、iPhoneのモバイルコンピューティングから進化した新しいコンピュータとして理解するのがAppleの意図と近い気がします。

Apple Vision ProのUI表現方法の種類

UIは2つの種類の表現方法があります。 どちらもSwiftUIで実装することが可能です。

ウインドウ

iOSやmacOSで利用できるような今まで通りの2DのUIを表示することができます。複数のウインドウを表示することも可能です。 今回実装したものでは、購入した動画の一覧を表示している画面をウインドウで実装しています。

ウインドウ上で3Dのコンテンツを表示することも可能です。 以下はAppleの用意したサンプルプロジェクトの3Dモデルの人工衛星を表示するウインドウです。

また、既存のiOSアプリは特に特別な対応することなく、ウインドウとしてApple Vision Proで実行することもできます。

ボリューム

主に3Dコンテンツを表示するための表現です。ボリューム自体に縦横奥行きのサイズを指定することができます。 3Dモデルを表現するための箱のようなイメージに近いかも知れません。 今回の検証では利用していませんが、2Dではない3Dモデルを使ったUIを表現したい際はこれを利用するかと思います。 例としては、3DモデルのCDを表示し、それを手で回すことで楽曲のシークバーとして機能させるといったUIとかでしょうか。 以下はAppleの用意したサンプルプロジェクトの地球の3Dモデルを表示するボリュームです。

ウインドウとボリュームの指定

上記のウインドウとボリュームはWindowGroupというstructで、 .windowStyle(_)という関数で指定可能です。

@main
struct VisionApp: App {    
    var body: some Scene {
        // ウインドウ
        WindowGroup {
            ContentView()
        }
        .windowStyle(.plain)

        // ボリューム
        WindowGroup {
            ContentView()
        }     
        .windowStyle(.volumetric)
    }
}

Apple Vision Proのアプリ空間の概念

Apple Vision Proにはアプリの起動の仕方を指定するスペースという概念があります。

スペースには以下の種類があり、デフォルトでは共有スペースで起動されます。 共有スペースで起動してから、アプリ内のイベントに応じてフルスペースで起動するといったことも可能です。 今回作成したPlayPASSアプリも上記の方法を取っています。 動画一覧は共有スペース、再生はフルスペースを使用するようにしています。

  • 共有スペース(Shared Space)
  • フルスペース(Full Space)

共有スペース

Macのデスクトップのように複数アプリが並んで表示されるスペースです。 共有スペースで起動する設定のアプリであれば複数表示し、表示位置も自由に配置できます。 MRのように画面越しに外も見える状態です。

このように複数アプリを起動することができます。 今回作成したPlayPASSアプリとAppleのサンプリアプリを表示した例です。

フルスペース

アプリ専用のスペースとして起動することができます。フルスペースで起動しているアプリのコンテンツのみが表示されます。 以下の3種類の表示形式があります。

  • FullImmersionStyle
  • ProgressiveImmersionStyle
  • MixedImmersionStyle

FullImmersionStyle

背景含めてすべてをアプリのコンテンツで表示します。 外が全く見えなくなります。 VRのように表示できるイメージですね。

ProgressiveImmersionStyle

正面の一部がFullImmersionStyle同様にアプリの領域として表示します。

MixedImmersionStyle

外を見える状態のまま、アプリのコンテンツだけを表示します。 共有スペースに似ていますが、MixedImmersionStyleはフルスペースにしているアプリのコンテンツのみを表示します。

  

実装内容

さて、ここからは今回の実装について紹介します。 PlayPASSアプリでも呼び出されている既存のコードを活用して、View周りは約200行ほどのコードで実現できました。 プロジェクトの設定も少し変更するだけでvisionOSに対応できます。

プロジェクトの設定

行ったことは以下の2つです。

  • visionOS用のターゲットを作成
  • 関連するターゲットのSupported DestinationsにApple Visionを追加

ターゲットは新規作成すれば問題なく作成できます。

続いて、「関連するターゲットのSupported DestinationsにApple Visionを追加」を行います。 PlayPASSはマルチモジュールの構成になっているため、アプリ固有の画面描画以外のロジックがiOSアプリのターゲットと別になっています。 そのため、上記のターゲットに対してSupported Destinationsを追加するだけで対応できました。 マルチモジュールになっていない場合は、ロジックを切り出して別モジュール化するとソースコードの管理がしやすそうです。

XcodeGenを使っている場合、Xcode 14から追加された単一ターゲットで複数のプラットフォーム含む事ができる機能を利用する場合は2.38.0以上のバージョンである必要があります。

また、外部ライブラリも含めて対応が必要なため、各ライブラリがvisionOSに対応されているか確認しましょう。 有名所のライブラリであれば基本対応が入っているかと思います。

ソースコード

visionOSに画面を表示させるために書いた画面のオブジェクトは以下です。

  • VisionApp
  • VideoSelectionView
  • ImmersiveVideoPlayerView

VisionApp

アプリが起動した際に参照されるstructです。 ここでウインドウやスペースを指定します。 今回はウインドウとしてVideoSelectionViewを、フルスペースでImmersiveVideoPlayerViewを表示しています。 表示形式はplayerImmersionStyleのプロパティとして保持し、切り替えられようにしています。

@main
struct VisionApp: App {
    static let playerSpaceId = "PlayerSpace"

    @State private var playerImmersionStyle: ImmersionStyle = .full

    var body: some Scene {
        WindowGroup {
            VideoSelectionView(playerImmersionStyle: $playerImmersionStyle)
        }
        .windowStyle(.plain)

        ImmersiveSpace(id: VisionApp.playerSpaceId) {
            ImmersiveVideoPlayerView()
        }
        .immersionStyle(
            selection: $playerImmersionStyle,
            in: .progressive, .full, .mixed
        )
    }
}

VideoSelectionView

動画選択画面です。 VideoSelectionViewModel内で以下の処理を書いています。

  • 購入した動画の情報を取得し、表示できる形に変換、データの保持
  • 動画プレイヤーの再生、停止、ダウンロード、ダウンロードキャンセル

2つ目の処理はvisionOS独自の処理ではないので割愛します。 実装的にはPlayPASSアプリでも利用しているモジュールの処理を呼んでいます。

動画情報を読み込み自動でダウンロードし、読み込み済みの動画を選択した際にプレイヤーを表示するためのImmersiveSpaceをidを .onChange(of: showImmersiveSpace)のクロージャで指定して起動しています。

struct VideoSelectionView: View {
    @Binding var playerImmersionStyle: ImmersionStyle

    @State private var playerIsShown = false
    @State private var immersiveSpaceIsShown = false

    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

    @ObservedObject private var viewModel = VideoSelectionViewModel()

    var body: some View {
        VStack {
            if playerIsShown {
                VStack {
                    Button {
                        playerImmersionStyle = .full
                    } label: {
                        Text("Full Immersion")
                    }
                    Button {
                        playerImmersionStyle = .progressive
                    } label: {
                        Text("Progressive Immersion")
                    }
                    Button {
                        playerImmersionStyle = .mixed
                    } label: {
                        Text("Mixed Immersion")
                    }
                    Button {
                        playerIsShown = false
                        viewModel.stop()
                    } label: {
                        Text("閉じる")
                    }
                }
            } else {
                List(viewModel.viewPurchases) { purchase in
                    HStack {
                        AsyncImage(url: purchase.imageURL) { image in
                            image.image?
                                .resizable()
                                .scaledToFit()
                        }
                        .frame(width: 100, height: 100)

                        Text(purchase.name)
                            .frame(maxWidth: .infinity, alignment: .leading)

                        if case .downloaded = purchase.status {
                            Button {
                                viewModel.select(purchase: purchase)
                                playerIsShown = true
                            } label: {
                                Text("再生")
                            }
                            .frame(width: 100)
                        }

                        if case let .downloading(progress) =  purchase.status {
                            ProgressView(value: progress)
                                .frame(width: 100)
                        }
                    }
                }
            }
        }
        .onChange(of: playerIsShown) { _, newValue in
            Task {
                if newValue {
                    switch await openImmersiveSpace(id: VisionApp.playerSpaceId) {
                    case .opened:
                        immersiveSpaceIsShown = true
                    case .error, .userCancelled:
                        fallthrough
                    @unknown default:
                        immersiveSpaceIsShown = false
                        playerIsShown = false
                    }
                } else if immersiveSpaceIsShown {
                    await dismissImmersiveSpace()
                    immersiveSpaceIsShown = false
                }
            }
        }
    }
}

ImmersiveVideoPlayerView

平面の3Dモデルを生成し、動画のレイヤーをマテリアルとしてセットすることで動画を3Dモデル上で再生することができます。 今回は4面で動画を再生してみたかったので、4つのモデルを生成して位置と向きをメートル単位で指定しています。

3軸の指定についてはこちらの記事を参考にしました。

struct ImmersiveVideoPlayerView: View {
    var body: some View {
        RealityView { content in
            if let videoPlayerManager = VideoPlayerManager.current {
                let frontEntity = {
                    $0.position = SIMD3(0, 1.5, -1)
                    return $0
                }(makeVideoEntity(player: videoPlayerManager.player))
                let leftEntity = {
                    $0.position = SIMD3(-1, 1.5, 0)
                    $0.orientation = simd_quatf(
                        angle: Float(Angle.degrees(90).radians),
                        axis: .init(x: 0, y: 1, z: 0)
                    )
                    return $0
                }(makeVideoEntity(player: videoPlayerManager.player))
                let rightEntity = {
                    $0.position = SIMD3(1, 1.5, 0)
                    $0.orientation = simd_quatf(
                        angle: Float(Angle.degrees(-90).radians),
                        axis: .init(x: 0, y: 1, z: 0)
                    )
                    return $0
                }(makeVideoEntity(player: videoPlayerManager.player))
                let backEntity = {
                    $0.position = SIMD3(0, 1.5, 1)
                    $0.orientation = simd_quatf(
                        angle: Float(Angle.degrees(180).radians),
                        axis: .init(x: 1, y: 0, z: 0)
                    )
                    $0.transform.rotation = simd_quatf(
                        angle: Float(Angle.degrees(180).radians),
                        axis: .init(x: 0, y: 1, z: 0)
                    )
                    return $0
                }(makeVideoEntity(player: videoPlayerManager.player))
                content.add(frontEntity)
                content.add(leftEntity)
                content.add(rightEntity)
                content.add(backEntity)
            }
        }
    }
}

private extension ImmersiveVideoPlayerView {
    func makeVideoEntity(player: AVPlayer) -> Entity {
        let material = VideoMaterial(avPlayer: player)

        let videoEntity = Entity()
        videoEntity.components.set(
            ModelComponent(
                mesh: .generatePlane(width: 1, height: 1),
                materials: [material]
            )
        )

        videoEntity.scale = SIMD3(1, 1, 1)

        return videoEntity
    }
}

おわりに

画面の部分については約200行で動画を3D表現で再生する事ができました。 また、既存のiOSの知識でほとんど実装することが可能だったことにも驚きです。 導入のハードルは非常に低く感じました。

音楽の体験もアップデートできるように、空間コンピュータとしてできる表現の仕方はこれから考えていきたいですね。

参考

アバター画像

村田真矢

2018年入社の新卒です。
EMやっています。

目次