diff --git a/ios/Video/AudioSessionManager.swift b/ios/Video/AudioSessionManager.swift new file mode 100644 index 0000000000..e196ab6b57 --- /dev/null +++ b/ios/Video/AudioSessionManager.swift @@ -0,0 +1,180 @@ +import AVFoundation +import Foundation + +class AudioSessionManager { + static let shared = AudioSessionManager() + + private var videoViews = NSHashTable.weakObjects() + + func registerView(view: RCTVideo) { + if videoViews.contains(view) { + return + } + + videoViews.add(view) + } + + func removePlayer(view: RCTVideo) { + if !videoViews.contains(view) { + return + } + + videoViews.remove(view) + + if videoViews.allObjects.isEmpty { + try? AVAudioSession().setActive(false) + } + } + + private func getCategory(silentSwitchObey: Bool, earpiece: Bool, pip: Bool, needsPlayback: Bool) -> AVAudioSession.Category { + if needsPlayback { + if earpiece { + RCTLogWarn( + """ + You can't set \"audioOutput\"=\"earpiece\" and \"ignoreSilentSwitch\"=\"obey\" + at the same time (in same or different components) - skipping those props + """ + ) + return .playback + } + + if silentSwitchObey { + RCTLogWarn( + """ + You can't use \"playInBackground or \"notificationControls with \"ignoreSilentSwitch\"=\"obey\" + at the same time (in same or different components) - skipping \"ignoreSilentSwitch\" prop + """ + ) + return .playback + } + } + + if silentSwitchObey { + if earpiece { + RCTLogWarn( + """ + You can't set \"audioOutput\"=\"earpiece\" and \"ignoreSilentSwitch\"=\"obey\" + at the same time (in same or different components) - skipping those props + """ + ) + return .playback + } + + if pip { + RCTLogWarn( + """ + You use \"pictureInPicture\"=\"true\" and \"ignoreSilentSwitch\"=\"obey\" + at the same time (in same or different components) - skipping those props + """ + ) + return .playback + } + + return .ambient + } + + return earpiece ? .playAndRecord : .playback + } + + func updateAudioSessionCategory() { + let audioSession = AVAudioSession() + var options: AVAudioSession.CategoryOptions = [] + + let isAnyPlayerPlaying = videoViews.allObjects.contains { view in + view._player?.isMuted == false || (view._player != nil && view._player?.rate != 0) + } + + let anyPlayerShowNotificationControls = videoViews.allObjects.contains { view in + view._showNotificationControls + } + + let anyPlayerNeedsPiP = videoViews.allObjects.contains { view in + view.isPipEnabled() + } + + let anyPlayerNeedsBackgroundPlayback = videoViews.allObjects.contains { view in + view._playInBackground + } + + let canAllowMixing = !anyPlayerShowNotificationControls && !anyPlayerNeedsBackgroundPlayback + + if canAllowMixing { + let shouldEnableMixing = videoViews.allObjects.contains { view in + view._mixWithOthers == "mix" + } + + let shouldEnableDucking = videoViews.allObjects.contains { view in + view._mixWithOthers == "duck" + } + + if shouldEnableMixing && shouldEnableDucking { + RCTLogWarn("You are trying to set \"mixWithOthers\" to \"mix\" and \"duck\" at the same time (in different components) - skiping prop") + } else { + if shouldEnableMixing { + options.insert(.mixWithOthers) + } + + if shouldEnableDucking { + options.insert(.duckOthers) + } + } + } + + let isAnyPlayerUsingEarpiece = videoViews.allObjects.contains { view in + view._audioOutput == "earpiece" + } + + var isSilentSwitchIgnore = videoViews.allObjects.contains { view in + view._ignoreSilentSwitch == "ignore" + } + + var isSilentSwitchObey = videoViews.allObjects.contains { view in + view._ignoreSilentSwitch == "obey" + } + + if isSilentSwitchObey && isSilentSwitchIgnore { + RCTLogWarn("You are trying to set \"ignoreSilentSwitch\" to \"ignore\" and \"obey\" at the same time (in diffrent components) - skiping prop") + isSilentSwitchObey = false + isSilentSwitchIgnore = false + } + + let needUpdateCategory = isAnyPlayerUsingEarpiece || isSilentSwitchIgnore || isSilentSwitchObey || canAllowMixing + + if anyPlayerNeedsPiP || anyPlayerShowNotificationControls || needUpdateCategory { + let category = getCategory( + silentSwitchObey: isSilentSwitchObey, + earpiece: isAnyPlayerUsingEarpiece, + pip: anyPlayerNeedsPiP, + needsPlayback: canAllowMixing + ) + + do { + try audioSession.setCategory(category, mode: .moviePlayback, options: canAllowMixing ? options : []) + } catch { + RCTLogWarn("Failed to update audio session category. This can cause issue with background audio playback and PiP or notification controls") + } + } + + if isAnyPlayerPlaying { + do { + try audioSession.setActive(true) + } catch { + RCTLogWarn("Failed activate audio session. This can cause issue audio playback") + } + } + + if isAnyPlayerUsingEarpiece { + do { + if isAnyPlayerUsingEarpiece { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.PortOverride.none) + } else { + #if os(iOS) || os(visionOS) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.PortOverride.speaker) + #endif + } + } catch { + print("Error occurred: \(error.localizedDescription)") + } + } + } +} diff --git a/ios/Video/NowPlayingInfoCenterManager.swift b/ios/Video/NowPlayingInfoCenterManager.swift index 153a5dafa2..71160ec549 100644 --- a/ios/Video/NowPlayingInfoCenterManager.swift +++ b/ios/Video/NowPlayingInfoCenterManager.swift @@ -25,12 +25,12 @@ class NowPlayingInfoCenterManager { private var receivingRemoveControlEvents = false { didSet { if receivingRemoveControlEvents { - try? AVAudioSession.sharedInstance().setCategory(.playback) - try? AVAudioSession.sharedInstance().setActive(true) UIApplication.shared.beginReceivingRemoteControlEvents() } else { UIApplication.shared.endReceivingRemoteControlEvents() } + + AudioSessionManager.shared.updateAudioSessionCategory() } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 345acbf0e4..37fa1d16a4 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -9,7 +9,7 @@ import React // MARK: - RCTVideo class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverHandler { - private var _player: AVPlayer? + private(set) var _player: AVPlayer? private var _playerItem: AVPlayerItem? private var _source: VideoSource? private var _playerLayer: AVPlayerLayer? @@ -31,7 +31,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _controls = false /* Keep track of any modifiers, need to be applied after each play */ - private var _audioOutput: String = "speaker" + private(set) var _audioOutput: String = "speaker" private var _volume: Float = 1.0 private var _rate: Float = 1.0 private var _maxBitRate: Float? @@ -46,12 +46,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _selectedTextTrackCriteria: SelectedTrackCriteria? private var _selectedAudioTrackCriteria: SelectedTrackCriteria? private var _playbackStalled = false - private var _playInBackground = false + private(set) var _playInBackground = false private var _preventsDisplaySleepDuringVideoPlayback = true private var _preferredForwardBufferDuration: Float = 0.0 private var _playWhenInactive = false - private var _ignoreSilentSwitch: String! = "inherit" // inherit, ignore, obey - private var _mixWithOthers: String! = "inherit" // inherit, mix, duck + private(set) var _ignoreSilentSwitch: String! = "inherit" // inherit, ignore, obey + private(set) var _mixWithOthers: String! = "inherit" // inherit, mix, duck private var _resizeMode: String! = "cover" private var _fullscreen = false private var _fullscreenAutorotate = true @@ -62,7 +62,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _filterEnabled = false private var _presentingViewController: UIViewController? private var _startPosition: Float64 = -1 - private var _showNotificationControls = false + private(set) var _showNotificationControls = false // 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 _pictureInPictureEnabled = false { @@ -208,6 +208,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH #endif _eventDispatcher = eventDispatcher + AudioSessionManager.shared.registerView(view: self) #if os(iOS) if _pictureInPictureEnabled { @@ -260,6 +261,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) + AudioSessionManager.shared.registerView(view: self) #if USE_GOOGLE_IMA _imaAdsManager = RCTIMAAdsManager(video: self, pipEnabled: isPipEnabled) #endif @@ -277,6 +279,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH if let player = _player { NowPlayingInfoCenterManager.shared.removePlayer(player: player) + AudioSessionManager.shared.removePlayer(view: self) } #if os(iOS) @@ -586,6 +589,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } } } + + AudioSessionManager.shared.updateAudioSessionCategory() } self._videoLoadStarted = true @@ -694,6 +699,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setPlayInBackground(_ playInBackground: Bool) { _playInBackground = playInBackground + AudioSessionManager.shared.updateAudioSessionCategory() } @objc @@ -718,17 +724,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setPictureInPicture(_ pictureInPicture: Bool) { #if os(iOS) - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playback) - try audioSession.setActive(true, options: []) - } catch {} if pictureInPicture { _pictureInPictureEnabled = true } else { _pictureInPictureEnabled = false } _pip?.setPictureInPicture(pictureInPicture) + AudioSessionManager.shared.updateAudioSessionCategory() #endif } @@ -746,7 +748,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) { _ignoreSilentSwitch = ignoreSilentSwitch - RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput) + AudioSessionManager.shared.updateAudioSessionCategory() applyModifiers() } @@ -768,7 +770,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _player?.rate = 0.0 } } else { - RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput) + AudioSessionManager.shared.updateAudioSessionCategory() if _adPlaying { #if USE_GOOGLE_IMA @@ -850,18 +852,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setAudioOutput(_ audioOutput: String) { _audioOutput = audioOutput - RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput) - do { - if audioOutput == "speaker" { - #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)") - } + AudioSessionManager.shared.updateAudioSessionCategory() } @objc