refactor(ios): add audio session manager (#4466)

This commit is contained in:
Krzysztof Moch
2025-03-12 18:22:50 +01:00
committed by GitHub
parent fa20223c44
commit 9f02614f5d
4 changed files with 367 additions and 91 deletions

View File

@@ -0,0 +1,325 @@
import AVFoundation
import Foundation
class AudioSessionManager {
static let shared = AudioSessionManager()
private var videoViews = NSHashTable<RCTVideo>.weakObjects()
private var isAudioSessionActive = false
private var remoteControlEventsActive = false
private init() {
// Subscribe to audio interruption notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
// Subscribe to route change notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioRouteChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Public API
func registerView(view: RCTVideo) {
if videoViews.contains(view) {
return
}
videoViews.add(view)
updateAudioSessionConfiguration()
}
func unregisterView(view: RCTVideo) {
if !videoViews.contains(view) {
return
}
videoViews.remove(view)
updateAudioSessionConfiguration()
if videoViews.allObjects.isEmpty && !remoteControlEventsActive {
deactivateAudioSession()
}
}
func updateAudioSessionConfiguration() {
// Activate audio session if needed
let isAnyPlayerPlaying = videoViews.allObjects.contains { view in
return !view.isMuted() && view._player != nil && view._player?.rate != 0
}
if isAnyPlayerPlaying || remoteControlEventsActive {
activateAudioSession()
}
configureAudioSession()
}
// Handle remote control events from NowPlayingInfoCenterManager
func setRemoteControlEventsActive(_ active: Bool) {
remoteControlEventsActive = active
if active {
// Force playback category and activate session when remote control events are active
configureForRemoteControlEvents()
} else {
// If no active players, we can deactivate the session
if !videoViews.allObjects.contains(where: { view in
return view._player != nil && view._player?.rate != 0
}) {
deactivateAudioSession()
} else {
// Otherwise reconfigure based on current players
updateAudioSessionConfiguration()
}
}
}
// Notification that a player's properties have changed
func playerPropertiesChanged(view: RCTVideo) {
// Only update if this is a registered view
if videoViews.contains(view) {
updateAudioSessionConfiguration()
}
}
// MARK: - Audio Session Configuration
private func configureForRemoteControlEvents() {
let audioSession = AVAudioSession.sharedInstance()
do {
// Remote control events always need playback category
try audioSession.setCategory(.playback, mode: .moviePlayback)
activateAudioSession()
} catch {
print(
"Failed to configure audio session for remote control events: \(error.localizedDescription)"
)
}
}
private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance()
var options: AVAudioSession.CategoryOptions = []
// Check player properties
let anyPlayerShowNotificationControls = videoViews.allObjects.contains { view in
return view._showNotificationControls
}
let anyPlayerNeedsPiP = videoViews.allObjects.contains { view in
return view.isPictureInPictureActive()
}
let anyPlayerNeedsBackgroundPlayback = videoViews.allObjects.contains { view in
return view._playInBackground
}
let canAllowMixing = !anyPlayerShowNotificationControls && !anyPlayerNeedsBackgroundPlayback
if canAllowMixing {
let shouldEnableMixing = videoViews.allObjects.contains { view in
return view._mixWithOthers == "mix"
}
let shouldEnableDucking = videoViews.allObjects.contains { view in
return view._mixWithOthers == "duck"
}
if shouldEnableMixing && shouldEnableDucking {
print(
"Warning: Conflicting mixWithOthers settings found (mix vs duck) - defaulting to mix"
)
options.insert(.mixWithOthers)
} else {
if shouldEnableMixing {
options.insert(.mixWithOthers)
}
if shouldEnableDucking {
options.insert(.duckOthers)
}
}
}
let isAnyPlayerUsingEarpiece = videoViews.allObjects.contains { view in
return view._audioOutput == "earpiece"
}
let isSilentSwitchIgnore = videoViews.allObjects.contains { view in
return view._ignoreSilentSwitch == "ignore"
}
let isSilentSwitchObey = videoViews.allObjects.contains { view in
return view._ignoreSilentSwitch == "obey"
}
// Determine audio category based on player requirements
let category = determineAudioCategory(
silentSwitchObey: isSilentSwitchObey,
silentSwitchIgnore: isSilentSwitchIgnore,
earpiece: isAnyPlayerUsingEarpiece,
pip: anyPlayerNeedsPiP,
backgroundPlayback: anyPlayerNeedsBackgroundPlayback,
notificationControls: anyPlayerShowNotificationControls
)
do {
try audioSession.setCategory(
category, mode: .moviePlayback, options: canAllowMixing ? options : []
)
// Configure audio port
if isAnyPlayerUsingEarpiece, audioSession.category == .playAndRecord {
#if os(iOS) || os(visionOS)
try audioSession.overrideOutputAudioPort(.speaker)
#endif
} else {
try audioSession.overrideOutputAudioPort(.none)
}
} catch {
print("Failed to configure audio session: \(error.localizedDescription)")
}
}
private func determineAudioCategory(
silentSwitchObey: Bool,
silentSwitchIgnore: Bool,
earpiece: Bool,
pip: Bool,
backgroundPlayback: Bool,
notificationControls: Bool
) -> AVAudioSession.Category {
// Handle conflicting settings
if silentSwitchObey && silentSwitchIgnore {
print(
"Warning: Conflicting ignoreSilentSwitch settings found (obey vs ignore) - defaulting to ignore"
)
return .playback
}
// PiP, background playback, or notification controls require playback category
if pip || backgroundPlayback || notificationControls || remoteControlEventsActive {
if silentSwitchObey {
print(
"Warning: ignoreSilentSwitch=obey cannot be used with PiP, backgroundPlayback, or notification controls - using playback category"
)
}
if earpiece {
print(
"Warning: audioOutput=earpiece cannot be used with PiP, backgroundPlayback, or notification controls - using playback category"
)
}
return .playback
}
// Earpiece requires playAndRecord
if earpiece {
if silentSwitchObey {
print(
"Warning: audioOutput=earpiece cannot be used with ignoreSilentSwitch=obey - using playAndRecord category"
)
}
return .playAndRecord
}
// Honor silent switch if requested
if silentSwitchObey {
return .ambient
}
// Default to playback for most cases
return .playback
}
private func activateAudioSession() {
if isAudioSessionActive {
return
}
do {
try AVAudioSession.sharedInstance().setActive(true)
isAudioSessionActive = true
} catch {
print("Failed to activate audio session: \(error.localizedDescription)")
}
}
private func deactivateAudioSession() {
if !isAudioSessionActive {
return
}
do {
try AVAudioSession.sharedInstance().setActive(
false, options: .notifyOthersOnDeactivation
)
isAudioSessionActive = false
} catch {
print("Failed to deactivate audio session: \(error.localizedDescription)")
}
}
// MARK: - Notification Handlers
@objc
private func handleAudioSessionInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
switch type {
case .began:
// Audio session interrupted, nothing to do as players will pause automatically
break
case .ended:
// Interruption ended, check if we should resume audio session
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
updateAudioSessionConfiguration()
}
}
@unknown default:
break
}
}
@objc
private func handleAudioRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
return
}
switch reason {
case .categoryChange, .override, .wakeFromSleep, .newDeviceAvailable, .oldDeviceUnavailable:
// Reconfigure audio session when route changes
updateAudioSessionConfiguration()
default:
break
}
}
}

View File

@@ -128,9 +128,14 @@ enum RCTPlayerOperations {
await player?.currentItem?.select(mediaOption, in: group) await player?.currentItem?.select(mediaOption, in: group)
} }
static func seek(player: AVPlayer, playerItem: AVPlayerItem, paused: Bool, seekTime: Float, seekTolerance: Float, completion: @escaping (Bool) -> Void) { static func seek(
player: AVPlayer, playerItem: AVPlayerItem, paused: Bool, seekTime: Float,
seekTolerance: Float, completion: @escaping (Bool) -> Void
) {
let timeScale = 1000 let timeScale = 1000
let cmSeekTime: CMTime = CMTimeMakeWithSeconds(Float64(seekTime), preferredTimescale: Int32(timeScale)) let cmSeekTime: CMTime = CMTimeMakeWithSeconds(
Float64(seekTime), preferredTimescale: Int32(timeScale)
)
let current: CMTime = playerItem.currentTime() let current: CMTime = playerItem.currentTime()
let tolerance: CMTime = CMTimeMake(value: Int64(seekTolerance), timescale: Int32(timeScale)) let tolerance: CMTime = CMTimeMake(value: Int64(seekTolerance), timescale: Int32(timeScale))
@@ -141,63 +146,11 @@ enum RCTPlayerOperations {
if !paused { player.pause() } if !paused { player.pause() }
player.seek(to: cmSeekTime, toleranceBefore: tolerance, toleranceAfter: tolerance, completionHandler: { (finished: Bool) in player.seek(
completion(finished) to: cmSeekTime, toleranceBefore: tolerance, toleranceAfter: tolerance,
}) completionHandler: { (finished: Bool) in
} completion(finished)
static func configureAudio(ignoreSilentSwitch: String, mixWithOthers: String, audioOutput: String) {
let audioSession: AVAudioSession! = AVAudioSession.sharedInstance()
var category: AVAudioSession.Category?
var options: AVAudioSession.CategoryOptions?
if ignoreSilentSwitch == "ignore" {
category = audioOutput == "earpiece" ? AVAudioSession.Category.playAndRecord : AVAudioSession.Category.playback
} else if ignoreSilentSwitch == "obey" {
category = AVAudioSession.Category.ambient
}
if mixWithOthers == "mix" {
options = .mixWithOthers
} else if mixWithOthers == "duck" {
options = .duckOthers
}
if let category, let options {
do {
try audioSession.setCategory(category, options: options)
} catch {
debugPrint("[RCTPlayerOperations] Problem setting up AVAudioSession category and options. Error: \(error).")
#if !os(tvOS)
// Handle specific set category and option combination error
// setCategory:AVAudioSessionCategoryPlayback withOptions:mixWithOthers || duckOthers
// Failed to set category, error: 'what' Error Domain=NSOSStatusErrorDomain
// https://developer.apple.com/forums/thread/714598
if #available(iOS 16.0, *) {
do {
debugPrint("[RCTPlayerOperations] Reseting AVAudioSession category to playAndRecord with defaultToSpeaker options.")
try audioSession.setCategory(
audioOutput == "earpiece" ? AVAudioSession.Category.playAndRecord : AVAudioSession.Category.playback,
options: AVAudioSession.CategoryOptions.defaultToSpeaker
)
} catch {
debugPrint("[RCTPlayerOperations] Reseting AVAudioSession category and options problem. Error: \(error).")
}
}
#endif
} }
} else if let category, options == nil { )
do {
try audioSession.setCategory(category)
} catch {
debugPrint("[RCTPlayerOperations] Problem setting up AVAudioSession category. Error: \(error).")
}
} else if category == nil, let options {
do {
try audioSession.setCategory(audioSession.category, options: options)
} catch {
debugPrint("[RCTPlayerOperations] Problem setting up AVAudioSession options. Error: \(error).")
}
}
} }
} }

View File

@@ -22,14 +22,14 @@ class NowPlayingInfoCenterManager {
private let remoteCommandCenter = MPRemoteCommandCenter.shared() private let remoteCommandCenter = MPRemoteCommandCenter.shared()
private var receivingRemoveControlEvents = false { var receivingRemoveControlEvents = false {
didSet { didSet {
if receivingRemoveControlEvents { if receivingRemoveControlEvents {
try? AVAudioSession.sharedInstance().setCategory(.playback) AudioSessionManager.shared.setRemoteControlEventsActive(true)
try? AVAudioSession.sharedInstance().setActive(true)
UIApplication.shared.beginReceivingRemoteControlEvents() UIApplication.shared.beginReceivingRemoteControlEvents()
} else { } else {
UIApplication.shared.endReceivingRemoteControlEvents() UIApplication.shared.endReceivingRemoteControlEvents()
AudioSessionManager.shared.setRemoteControlEventsActive(false)
} }
} }
} }

View File

@@ -9,7 +9,7 @@ import React
// MARK: - RCTVideo // MARK: - RCTVideo
class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverHandler { class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverHandler {
private var _player: AVPlayer? var _player: AVPlayer?
private var _playerItem: AVPlayerItem? private var _playerItem: AVPlayerItem?
private var _source: VideoSource? private var _source: VideoSource?
private var _playerLayer: AVPlayerLayer? private var _playerLayer: AVPlayerLayer?
@@ -30,7 +30,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _controls = false private var _controls = false
/* Keep track of any modifiers, need to be applied after each play */ /* Keep track of any modifiers, need to be applied after each play */
private var _audioOutput: String = "speaker" var _audioOutput: String = "speaker"
private var _volume: Float = 1.0 private var _volume: Float = 1.0
private var _rate: Float = 1.0 private var _rate: Float = 1.0
private var _maxBitRate: Float? private var _maxBitRate: Float?
@@ -44,12 +44,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _selectedTextTrackCriteria: SelectedTrackCriteria = .none() private var _selectedTextTrackCriteria: SelectedTrackCriteria = .none()
private var _selectedAudioTrackCriteria: SelectedTrackCriteria = .none() private var _selectedAudioTrackCriteria: SelectedTrackCriteria = .none()
private var _playbackStalled = false private var _playbackStalled = false
private var _playInBackground = false var _playInBackground = false
private var _preventsDisplaySleepDuringVideoPlayback = true private var _preventsDisplaySleepDuringVideoPlayback = true
private var _preferredForwardBufferDuration: Float = 0.0 private var _preferredForwardBufferDuration: Float = 0.0
private var _playWhenInactive = false private var _playWhenInactive = false
private var _ignoreSilentSwitch: String = "inherit" // inherit, ignore, obey var _ignoreSilentSwitch: String = "inherit" // inherit, ignore, obey
private var _mixWithOthers: String = "inherit" // inherit, mix, duck var _mixWithOthers: String = "inherit" // inherit, mix, duck
private var _resizeMode: String = "cover" private var _resizeMode: String = "cover"
private var _fullscreen = false private var _fullscreen = false
private var _fullscreenAutorotate = true private var _fullscreenAutorotate = true
@@ -60,7 +60,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _filterEnabled = false private var _filterEnabled = false
private var _presentingViewController: UIViewController? private var _presentingViewController: UIViewController?
private var _startPosition: Float64 = -1 private var _startPosition: Float64 = -1
private var _showNotificationControls = false var _showNotificationControls = false
// Buffer last bitrate value received. Initialized to -2 to ensure -1 (sometimes reported by AVPlayer) is not missed // Buffer last bitrate value received. Initialized to -2 to ensure -1 (sometimes reported by AVPlayer) is not missed
private var _lastBitrate = -2.0 private var _lastBitrate = -2.0
private var _enterPictureInPictureOnLeave = false { private var _enterPictureInPictureOnLeave = false {
@@ -204,6 +204,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_eventDispatcher = eventDispatcher _eventDispatcher = eventDispatcher
AudioSessionManager.shared.registerView(view: self)
#if os(iOS) #if os(iOS)
if _enterPictureInPictureOnLeave { if _enterPictureInPictureOnLeave {
initPictureinPicture() initPictureinPicture()
@@ -286,6 +288,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_imaAdsManager.releaseAds() _imaAdsManager.releaseAds()
_imaAdsManager = nil _imaAdsManager = nil
#endif #endif
AudioSessionManager.shared.unregisterView(view: self)
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
self.removePlayerLayer() self.removePlayerLayer()
@@ -298,7 +301,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
#if os(iOS) #if os(iOS)
_pip = nil _pip = nil
#endif #endif
ReactNativeVideoManager.shared.unregisterView(newInstance: self) ReactNativeVideoManager.shared.unregisterView(newInstance: self)
AudioSessionManager.shared.unregisterView(view: self)
} }
// MARK: - App lifecycle handlers // MARK: - App lifecycle handlers
@@ -767,13 +772,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setEnterPictureInPictureOnLeave(_ enterPictureInPictureOnLeave: Bool) { func setEnterPictureInPictureOnLeave(_ enterPictureInPictureOnLeave: Bool) {
#if os(iOS) #if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playback)
try audioSession.setActive(true, options: [])
} catch {}
if _enterPictureInPictureOnLeave != enterPictureInPictureOnLeave { if _enterPictureInPictureOnLeave != enterPictureInPictureOnLeave {
_enterPictureInPictureOnLeave = enterPictureInPictureOnLeave _enterPictureInPictureOnLeave = enterPictureInPictureOnLeave
AudioSessionManager.shared.playerPropertiesChanged(view: self)
} }
#endif #endif
} }
@@ -792,14 +794,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) { func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) {
_ignoreSilentSwitch = ignoreSilentSwitch ?? "inherit" _ignoreSilentSwitch = ignoreSilentSwitch ?? "inherit"
RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput)
applyModifiers() AudioSessionManager.shared.playerPropertiesChanged(view: self)
} }
@objc @objc
func setMixWithOthers(_ mixWithOthers: String?) { func setMixWithOthers(_ mixWithOthers: String?) {
_mixWithOthers = mixWithOthers ?? "inherit" _mixWithOthers = mixWithOthers ?? "inherit"
applyModifiers()
AudioSessionManager.shared.playerPropertiesChanged(view: self)
} }
@objc @objc
@@ -814,8 +817,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_player?.rate = 0.0 _player?.rate = 0.0
} }
} else { } else {
RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput)
if _adPlaying { if _adPlaying {
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
_imaAdsManager.getAdsManager()?.resume() _imaAdsManager.getAdsManager()?.resume()
@@ -832,6 +833,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
_paused = paused _paused = paused
AudioSessionManager.shared.playerPropertiesChanged(view: self)
} }
@objc @objc
@@ -896,18 +898,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setAudioOutput(_ audioOutput: String) { func setAudioOutput(_ audioOutput: String) {
_audioOutput = audioOutput _audioOutput = audioOutput
RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput)
do { // Notify AudioSessionManager about the change instead of directly configuring
if audioOutput == "speaker" { AudioSessionManager.shared.playerPropertiesChanged(view: self)
#if os(iOS) || os(visionOS)
try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.PortOverride.speaker)
#endif
} else if audioOutput == "earpiece" {
try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.PortOverride.none)
}
} catch {
print("Error occurred: \(error.localizedDescription)")
}
} }
@objc @objc
@@ -982,13 +975,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
setSelectedTextTrack(_selectedTextTrackCriteria) setSelectedTextTrack(_selectedTextTrackCriteria)
setAudioOutput(_audioOutput)
setSelectedAudioTrack(_selectedAudioTrackCriteria) setSelectedAudioTrack(_selectedAudioTrackCriteria)
setResizeMode(_resizeMode) setResizeMode(_resizeMode)
setRepeat(_repeat) setRepeat(_repeat)
setControls(_controls) setControls(_controls)
setPaused(_paused) setPaused(_paused)
setAllowsExternalPlayback(_allowsExternalPlayback) setAllowsExternalPlayback(_allowsExternalPlayback)
AudioSessionManager.shared.playerPropertiesChanged(view: self)
} }
@objc @objc
@@ -1370,6 +1364,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
player.pause() player.pause()
NowPlayingInfoCenterManager.shared.removePlayer(player: player) NowPlayingInfoCenterManager.shared.removePlayer(player: player)
} }
// Unregister from AudioSessionManager
AudioSessionManager.shared.unregisterView(view: self)
_playerItem = nil _playerItem = nil
_source = nil _source = nil
_chapters = nil _chapters = nil