markdown

はじめに (対象読者・この記事でわかること)

この記事は、Swiftで動画再生アプリを開発しているが、AVPlayerのデフォルトコントロールではなく、独自のデザインで再生・一時停止・スキップなどのボタンを実装したい方を対象としています。

この記事を読むことで、AVPlayerの各種イベントを取得する方法と、それらのイベントに基づいてカスタムUIを構築する方法がわかります。また、イベントの伝播を適切に制御し、ユーザビリティを高めるための実装テクニックも習得できます。

実際に動画配信アプリを開発していた際、AVPlayerの標準コントロールではブランドイメージに合わないUIになってしまう課題がありました。その解決策として習得した知識を共有します。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - Swiftの基本的な文法とUIKitの基礎知識 - AVPlayerの基本的な使い方(動画の読み込み・再生) - DelegateパターンとNotificationCenterの基本的な理解

AVPlayerイベント取得の重要性と課題

AVPlayerはiOSの標準的な動画再生フレームワークですが、デフォルトのコントロールは汎用的なデザインのため、アプリ独自のUIを実装する際はカスタム化が必要です。

特に、動画再生中の「再生・一時状態の変化」「スキップ操作」「シーク操作」などのイベントを取得することは、ユーザビリティの向上に直結します。しかし、これらのイベントは単純に取得できるわけではなく、いくつかの課題があります。

  • AVPlayerの内部イベントは直接取得できない
  • NotificationCenterを使った実装が必要
  • イベントの重複通知による意図しない動作
  • カスタムUIと標準コントロールの競合

これらの課題を解決するための具体的な実装方法を、実践的なコードとともに解説します。

AVPlayerイベントを完全に制御する実装手法

ここでは、AVPlayerの各種イベントを取得し、カスタムUIに反映する具体的な実装方法を段階的に説明します。

ステップ1:基本的なイベント監視の設定

まず、AVPlayerの状態変化を監視するための基本的な設定を行います。NotificationCenterを使用して、各種イベントを検知します。

Swift
import AVKit import UIKit class CustomVideoPlayerViewController: UIViewController { private var player: AVPlayer? private var playerLayer: AVPlayerLayer? private var timeObserver: Any? // カスタムUI部品 private let playPauseButton = UIButton(type: .custom) private let forwardButton = UIButton(type: .custom) private let backwardButton = UIButton(type: .custom) private let progressView = UIProgressView(progressViewStyle: .default) override func viewDidLoad() { super.viewDidLoad() setupPlayer() setupCustomControls() setupNotifications() } private func setupPlayer() { // 動画の設定 guard let url = URL(string: "https://example.com/video.mp4") else { return } player = AVPlayer(url: url) playerLayer = AVPlayerLayer(player: player) playerLayer?.frame = view.bounds view.layer.addSublayer(playerLayer!) } private func setupNotifications() { // 再生状態の変化を監視 NotificationCenter.default.addObserver( self, selector: #selector(playerItemDidPlayToEndTime), name: .AVPlayerItemDidPlayToEndTime, object: player?.currentItem ) // バッファリング状態の監視 NotificationCenter.default.addObserver( self, selector: #selector(playerItemPlaybackStalled), name: .AVPlayerItemPlaybackStalled, object: player?.currentItem ) } @objc private func playerItemDidPlayToEndTime(notification: Notification) { // 動画終了時の処理 updatePlayPauseButton(isPlaying: false) // 先頭に戻す player?.seek(to: .zero) } @objc private func playerItemPlaybackStalled(notification: Notification) { // バッファリング中の処理 showLoadingIndicator() } }

ステップ2:再生・一時停止イベントの取得とカスタムUI連動

次に、再生・一時停止の状態変化を正確に取得し、カスタムボタンに反映させる方法を実装します。

Swift
extension CustomVideoPlayerViewController { private func setupPlayPauseObservation() { // KVOを使用してrateプロパティの変化を監視 player?.addObserver( self, forKeyPath: "rate", options: [.new, .initial], context: nil ) } override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer? ) { if keyPath == "rate" { let isPlaying = player?.rate != 0 updatePlayPauseButton(isPlaying: isPlaying) updateProgressBar(isPlaying: isPlaying) } } private func updatePlayPauseButton(isPlaying: Bool) { DispatchQueue.main.async { [weak self] in let image = isPlaying ? UIImage(systemName: "pause.fill") : UIImage(systemName: "play.fill") self?.playPauseButton.setImage(image, for: .normal) } } @objc private func playPauseButtonTapped() { guard let player = player else { return } if player.rate == 0 { // 一時停止中なので再生 player.play() // カスタムイベントの発行 NotificationCenter.default.post( name: .customPlayerDidPlay, object: nil, userInfo: ["currentTime": player.currentTime().seconds] ) } else { // 再生中なので一時停止 player.pause() // カスタムイベントの発行 NotificationCenter.default.post( name: .customPlayerDidPause, object: nil, userInfo: ["currentTime": player.currentTime().seconds] ) } } } // カスタム通知名の拡張 extension Notification.Name { static let customPlayerDidPlay = Notification.Name("customPlayerDidPlay") static let customPlayerDidPause = Notification.Name("customPlayerDidPause") static let customPlayerDidSeek = Notification.Name("customPlayerDidSeek") }

ステップ3:スキップ・シークイベントの実装

スキップ(早送り・巻き戻し)とシーク(プログレスバーでの移動)のイベントを実装します。

Swift
extension CustomVideoPlayerViewController { // 早送り・巻き戻しボタンの設定 private func setupSkipButtons() { forwardButton.addTarget( self, action: #selector(skipForward), for: .touchUpInside ) backwardButton.addTarget( self, action: #selector(skipBackward), for: .touchUpInside ) } @objc private func skipForward() { guard let player = player else { return } let currentTime = player.currentTime() let newTime = CMTimeAdd(currentTime, CMTime(seconds: 15, preferredTimescale: 600)) // 動画の長さを超えないように制限 if let duration = player.currentItem?.duration { let finalTime = CMTimeCompare(newTime, duration) > 0 ? duration : newTime player.seek(to: finalTime) { [weak self] completed in if completed { // スキップ完了イベント NotificationCenter.default.post( name: .customPlayerDidSeek, object: nil, userInfo: [ "fromTime": currentTime.seconds, "toTime": finalTime.seconds ] ) } } } } @objc private func skipBackward() { guard let player = player else { return } let currentTime = player.currentTime() let newTime = CMTimeSubtract(currentTime, CMTime(seconds: 15, preferredTimescale: 600)) // 0秒未満にならないように制限 let finalTime = CMTimeCompare(newTime, .zero) < 0 ? .zero : newTime player.seek(to: finalTime) { [weak self] completed in if completed { NotificationCenter.default.post( name: .customPlayerDidSeek, object: nil, userInfo: [ "fromTime": currentTime.seconds, "toTime": finalTime.seconds ] ) } } } // プログレスバーでのシーク @objc private func progressBarValueChanged(_ sender: UIProgressView) { guard let player = player, let duration = player.currentItem?.duration else { return } let targetTime = CMTime( seconds: Double(sender.progress) * duration.seconds, preferredTimescale: 600 ) player.seek(to: targetTime) { [weak self] completed in if completed { self?.hideSeekingIndicator() } } } }

ハマった点やエラー解決

実装中に遭遇した主な問題と解決方法を共有します。

問題1:イベントの重複通知 カスタムUIと標準コントロールを併用していると、同じイベントが複数回発生して意図しない動作をすることがありました。

解決策:イベントの一意性確保

Swift
private var isSeeking = false private func seekWithUniqueness(to time: CMTime) { guard !isSeeking else { return } isSeeking = true player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in self?.isSeeking = false } }

問題2:バッファリング中のUI更新遅延 動画の読み込み中にボタンタップが効かなくなり、ユーザビリティが低下しました。

解決策:バッファリング状態の監視

Swift
private func observeBufferingStatus() { player?.currentItem?.addObserver( self, forKeyPath: "playbackBufferEmpty", options: [.new], context: nil ) player?.currentItem?.addObserver( self, forKeyPath: "playbackLikelyToKeepUp", options: [.new], context: nil ) } // バッファリング状態に応じたUI更新 private func updateUIBasedOnBuffering() { guard let item = player?.currentItem else { return } if item.playbackBufferEmpty { showLoadingIndicator() playPauseButton.isEnabled = false } else if item.isPlaybackLikelyToKeepUp { hideLoadingIndicator() playPauseButton.isEnabled = true } }

問題3:メモリリークと観察者の適切な除去 NotificationCenterやKVOの観察者を適切に除去しないと、メモリリークやクラッシュの原因になります。

解決策:クリーンアップ処理の徹底

Swift
deinit { // 全てのNotificationCenter観察を除去 NotificationCenter.default.removeObserver(self) // KVO観察の除去 player?.removeObserver(self, forKeyPath: "rate") player?.currentItem?.removeObserver(self, forKeyPath: "playbackBufferEmpty") player?.currentItem?.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp") // タイムオブザーバーの除去 if let observer = timeObserver { player?.removeTimeObserver(observer) } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) player?.pause() }

まとめ

本記事では、AVPlayerのボタンイベントを取得し、カスタムUIで完全に制御する方法を解説しました。

  • NotificationCenterとKVOを活用したイベント監視の実装
  • 再生・一時停止・スキップ・シークの各イベントの取得方法
  • バッファリング状態の管理とUI連動
  • メモリリークを防ぐための適切なクリーンアップ処理

この記事を通して、AVPlayerを使った動画再生アプリで、ブランドに合わせた独自のUIを実装できるようになりました。デフォルトの制約に縛られず、ユーザビリティの高い動画再生体験を提供できるでしょう。

今後は、Picture in Picture(PiP)モードでのイベント処理や、複数の動画を連続再生する際のイベント管理についても記事にする予定です。

参考資料

参考にしたドキュメントと実装で参考にした資料です。