Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

iOS 16のロック画面映え機能を実装したい衝動に駆られた話

ピクミンのスマホゲームに歩かされ続けている福山です(現在Lv51 - 合計歩数1,415,248)

iOS 16で音楽や動画を流しているときにロック画面がいい感じになりましたよね!
正式名称はわかりませんが、ここに表示される画像はアルバムアートやアートワークなどと呼ばれているようです。

Mirrativ iOSアプリの配信視聴時もいい感じになっているはず... と思ったのですが、ただの灰色の画面になっていました😨 「なんとかしたいなぁ」と思い既存のコードとリソースをほんの少しいじった結果、以下のようになりました!Mirrativアプリv9.88.0から反映されています。

大画面で画像を表示したくない or 他の通知もすぐ見たいという場合には画像部分をタップすることでコンパクトに表示されます。

コントロールセンターの右上のブロックを押すことでも画像を見ることができます。

iPhone 14 Pro, iPhone 14 Pro Maxに搭載された Dynamic Island にも自動で適用されます。 縮小時は左側に画像が小さく表示され、長押しして展開するとクルッと回るアニメーション付きで画像がより大きく表示されます。

Appleの違和感のないアニメーションも相まって良いですね!

iPhone 14 Pro, iPhone 14 Pro Maxの常時表示ディスプレイにもバッチリ表示されます!

サンプルコード

再生している動画のスクショを作成してアートワークにするサンプルコードです。
AVPlayerViewControllerAVPlayerLayer の両方のケースを想定した作りになっています。

XcodeのSigning & CapabilitiesBackground ModesAudio, AirPlay, and Picture in Picture にチェックを入れるのを忘れないでください。

//
//  ViewController.swift
//

import UIKit
import AVKit
import MediaPlayer

enum PlayerMode {
    case avPlayerViewController
    case avPlayerLayer
}

class ViewController: UIViewController {
    private let playerViewController = AVPlayerViewController()

    private let playerView = PlayerView()

    private let player = AVPlayer()

    private var observer: NSKeyValueObservation?

    private let playerMode: PlayerMode = .avPlayerViewController

    override func viewDidLoad() {
        super.viewDidLoad()
        setupAppStatusObserver()
        setupPlayer()
    }
}

// MARK: Player

extension ViewController {
    private func setupPlayer() {
        guard let url = Bundle.main.url(forResource: "video", withExtension: "mp4") else {
            print("video not found")
            return
        }

        let item = AVPlayerItem(url: url)
        // KVOでstatusを監視し準備ができたら再生開始
        observer = item.observe(\.status) { [weak self] item, _ in
            switch item.status {
            case .readyToPlay:
                self?.play()
            case .failed, .unknown:
                break
            @unknown default:
                break
            }
        }
        player.replaceCurrentItem(with: item)

        switch playerMode {
        case .avPlayerViewController:
            playerViewController.willMove(toParent: self)
            addChild(playerViewController)
            view.addSubview(playerViewController.view)
            playerViewController.view.frame = view.bounds
            playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            playerViewController.didMove(toParent: self)
            // ピクチャー・イン・ピクチャーを無効化
            playerViewController.allowsPictureInPicturePlayback = false
            // アルバムアート/アートワーク表示が上書きされないようにする
            playerViewController.updatesNowPlayingInfoCenter = false
            playerViewController.player = player
        case .avPlayerLayer:
            view.addSubview(playerView)
            playerView.frame = view.bounds
            playerView.player = player
        }
    }

    private func play() {
        do {
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
            try AVAudioSession.sharedInstance().setActive(true)
        } catch {
            print("audio session error: \(error.localizedDescription)")
            return
        }
        player.play()
        setupNowPlayingInfo()
        // ロック画面のアルバムアート/アートワーク表示を利用するためには、
        // UIApplication.shared.beginReceivingRemoteControlEvents() を呼ぶ
        // もしくはリモートコントロールをセットする必要がある
        setupRemoteControl()
    }

    /// 再生情報をセット
    private func setupNowPlayingInfo() {
        var nowPlayingInfo: [String: Any] = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
        // タイトル
        nowPlayingInfo[MPMediaItemPropertyTitle] = "My Video"
        // アーティスト (投稿者・撮影者など)
        nowPlayingInfo[MPMediaItemPropertyArtist] = "someone"
        // ライブストリーミングかどうか
        nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
        if let duration = player.currentItem?.duration {
            // 動画全体の時間
            nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = "\(CMTimeGetSeconds(duration))"
            // 再生位置
            nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = "0"
            // 再生速度
            nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = "1"
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
        // 数秒後の動画を静止画にして、アルバムアート/アートワーク表示に利用する
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.setAlbumArt()
        }
    }

    /// アルバムアート/アートワーク表示に利用する画像をセット
    private func setAlbumArt() {
        takeScreenshot { image in
            // 画像を正方形に切り抜き (ここでは適当に、一片600px以下にしている)
            guard let croppedImage = image?.cropToSquare(desiredDemension: 600) else { return }
            let item = MPMediaItemArtwork(boundsSize: croppedImage.size) { _ in croppedImage }
            var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
            info[MPMediaItemPropertyArtwork] = item
            MPNowPlayingInfoCenter.default().nowPlayingInfo = info
        }
    }

    /// 再生中の動画のスクリーンショットを撮る
    /// - Parameter completion: UIImage? を返すクロージャ
    private func takeScreenshot(completion: @escaping ((UIImage?) -> Void)) {
        guard let asset = player.currentItem?.asset else {
            completion(nil)
            return
        }
        let imageGenerator = AVAssetImageGenerator(asset: asset)
        imageGenerator.appliesPreferredTrackTransform = true
        let times = [NSValue(time: player.currentTime())]
        imageGenerator.generateCGImagesAsynchronously(forTimes: times) { _, cgImage, _, _, _ in
            DispatchQueue.main.async {
                if let cgImage {
                    completion(UIImage(cgImage: cgImage))
                } else {
                    completion(nil)
                }
            }
        }
    }

    /// ロック画面やコントロールセンターからの操作を受け付ける
    private func setupRemoteControl() {
        let remoteCommandCenter = MPRemoteCommandCenter.shared()
        [
            remoteCommandCenter.playCommand,
            remoteCommandCenter.pauseCommand
        ].forEach { $0.addTarget(self, action: #selector(handleRemoteCommand(_:))) }
    }

    /// ロック画面やコントロールセンターからの操作を処理する
    @objc private func handleRemoteCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
        let remoteCommandCenter = MPRemoteCommandCenter.shared()
        switch event.command {
        case remoteCommandCenter.playCommand:
            player.play()
        case remoteCommandCenter.pauseCommand:
            player.pause()
        default:
            return .commandFailed
        }
        return .success
    }
}

// MARK: Notification

extension ViewController {
    /// バックグラウンド再生のための通知
    /// https://developer.apple.com/documentation/avfoundation/media_playback/creating_a_basic_video_player_ios_and_tvos/playing_audio_from_a_video_asset_in_the_background
    private func setupAppStatusObserver() {
        [
            UIApplication.willEnterForegroundNotification,
            UIApplication.didEnterBackgroundNotification
        ].forEach {
            NotificationCenter.default.addObserver(self, selector: #selector(appStateChange(_:)), name: $0, object: nil)
        }
    }

    @objc private func appStateChange(_ notification: Notification) {
        switch notification.name {
        case UIApplication.willEnterForegroundNotification:
            switch playerMode {
            case .avPlayerViewController:
                playerViewController.player = player
            case .avPlayerLayer:
                playerView.player = player
            }

        case UIApplication.didEnterBackgroundNotification:
            playerViewController.player = nil
            playerView.player = nil
        default:
            break
        }
    }
}

// MARK: etc

extension UIImage {
    func cropToSquare(desiredDemension: CGFloat) -> UIImage? {
        guard size.width > 0, size.height > 0 else { return nil }
        let dimension = min(desiredDemension, size.width, size.height)
        let targetSize = CGSize(width: dimension, height: dimension)
        UIGraphicsBeginImageContextWithOptions(targetSize, false, 0)
        if size.height > size.width {
            let (width, height) = (dimension, size.height * dimension / size.width)
            draw(in: CGRect(x: 0, y: (height - dimension) * -0.5, width: width, height: height))
        } else {
            let (width, height) = (size.width * dimension / size.height, dimension)
            draw(in: CGRect(x: (width - dimension) * -0.5, y: 0, width: width, height: height))
        }
        let outputImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return outputImage
    }
}

/// AVPlayerLayer
/// https://developer.apple.com/documentation/avfoundation/avplayerlayer
class PlayerView: UIView {
    override static var layerClass: AnyClass { AVPlayerLayer.self }

    var player: AVPlayer? {
        get { playerLayer.player }
        set { playerLayer.player = newValue }
    }

    private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}

音楽や動画の再生機能があるけど画像はまだ指定していないといったアプリの開発の参考になれば幸いです!

We are hiring!

ミラティブでは一緒に開発してくれるiOS開発者を募集しています!少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。
www.mirrativ.co.jp

Mirrativ Engineering - ミラティブのエンジニア情報を伝えるポータルサイト -

mirrativ.notion.site