diff --git a/ios/Video/AudioSessionManager.swift b/ios/Video/AudioSessionManager.swift new file mode 100644 index 00000000..5dc43ec5 --- /dev/null +++ b/ios/Video/AudioSessionManager.swift @@ -0,0 +1,325 @@ +import AVFoundation +import Foundation + +class AudioSessionManager { + static let shared = AudioSessionManager() + + private var videoViews = NSHashTable.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 + } + } +} diff --git a/ios/Video/Features/RCTPlayerOperations.swift b/ios/Video/Features/RCTPlayerOperations.swift index d1ca88a9..c559132a 100644 --- a/ios/Video/Features/RCTPlayerOperations.swift +++ b/ios/Video/Features/RCTPlayerOperations.swift @@ -128,9 +128,14 @@ enum RCTPlayerOperations { 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 cmSeekTime: CMTime = CMTimeMakeWithSeconds(Float64(seekTime), preferredTimescale: Int32(timeScale)) + let cmSeekTime: CMTime = CMTimeMakeWithSeconds( + Float64(seekTime), preferredTimescale: Int32(timeScale) + ) let current: CMTime = playerItem.currentTime() let tolerance: CMTime = CMTimeMake(value: Int64(seekTolerance), timescale: Int32(timeScale)) @@ -141,63 +146,11 @@ enum RCTPlayerOperations { if !paused { player.pause() } - player.seek(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 + player.seek( + to: cmSeekTime, toleranceBefore: tolerance, toleranceAfter: tolerance, + completionHandler: { (finished: Bool) in + completion(finished) } - } 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).") - } - } + ) } } diff --git a/ios/Video/NowPlayingInfoCenterManager.swift b/ios/Video/NowPlayingInfoCenterManager.swift index 0b74cea5..7c542122 100644 --- a/ios/Video/NowPlayingInfoCenterManager.swift +++ b/ios/Video/NowPlayingInfoCenterManager.swift @@ -22,14 +22,14 @@ class NowPlayingInfoCenterManager { private let remoteCommandCenter = MPRemoteCommandCenter.shared() - private var receivingRemoveControlEvents = false { + var receivingRemoveControlEvents = false { didSet { if receivingRemoveControlEvents { - try? AVAudioSession.sharedInstance().setCategory(.playback) - try? AVAudioSession.sharedInstance().setActive(true) + AudioSessionManager.shared.setRemoteControlEventsActive(true) UIApplication.shared.beginReceivingRemoteControlEvents() } else { UIApplication.shared.endReceivingRemoteControlEvents() + AudioSessionManager.shared.setRemoteControlEventsActive(false) } } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 7ea1cdf2..315a88de 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? + var _player: AVPlayer? private var _playerItem: AVPlayerItem? private var _source: VideoSource? private var _playerLayer: AVPlayerLayer? @@ -30,7 +30,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" + var _audioOutput: String = "speaker" private var _volume: Float = 1.0 private var _rate: Float = 1.0 private var _maxBitRate: Float? @@ -44,12 +44,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _selectedTextTrackCriteria: SelectedTrackCriteria = .none() private var _selectedAudioTrackCriteria: SelectedTrackCriteria = .none() private var _playbackStalled = false - private var _playInBackground = false + 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 + var _ignoreSilentSwitch: String = "inherit" // inherit, ignore, obey + var _mixWithOthers: String = "inherit" // inherit, mix, duck private var _resizeMode: String = "cover" private var _fullscreen = false private var _fullscreenAutorotate = true @@ -60,7 +60,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _filterEnabled = false private var _presentingViewController: UIViewController? 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 private var _lastBitrate = -2.0 private var _enterPictureInPictureOnLeave = false { @@ -204,6 +204,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _eventDispatcher = eventDispatcher + AudioSessionManager.shared.registerView(view: self) + #if os(iOS) if _enterPictureInPictureOnLeave { initPictureinPicture() @@ -286,6 +288,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _imaAdsManager.releaseAds() _imaAdsManager = nil #endif + AudioSessionManager.shared.unregisterView(view: self) NotificationCenter.default.removeObserver(self) self.removePlayerLayer() @@ -298,7 +301,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH #if os(iOS) _pip = nil #endif + ReactNativeVideoManager.shared.unregisterView(newInstance: self) + AudioSessionManager.shared.unregisterView(view: self) } // MARK: - App lifecycle handlers @@ -767,13 +772,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setEnterPictureInPictureOnLeave(_ enterPictureInPictureOnLeave: Bool) { #if os(iOS) - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playback) - try audioSession.setActive(true, options: []) - } catch {} if _enterPictureInPictureOnLeave != enterPictureInPictureOnLeave { _enterPictureInPictureOnLeave = enterPictureInPictureOnLeave + + AudioSessionManager.shared.playerPropertiesChanged(view: self) } #endif } @@ -792,14 +794,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) { _ignoreSilentSwitch = ignoreSilentSwitch ?? "inherit" - RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput) - applyModifiers() + + AudioSessionManager.shared.playerPropertiesChanged(view: self) } @objc func setMixWithOthers(_ mixWithOthers: String?) { _mixWithOthers = mixWithOthers ?? "inherit" - applyModifiers() + + AudioSessionManager.shared.playerPropertiesChanged(view: self) } @objc @@ -814,8 +817,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _player?.rate = 0.0 } } else { - RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput) - if _adPlaying { #if USE_GOOGLE_IMA _imaAdsManager.getAdsManager()?.resume() @@ -832,6 +833,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } _paused = paused + AudioSessionManager.shared.playerPropertiesChanged(view: self) } @objc @@ -896,18 +898,9 @@ 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)") - } + + // Notify AudioSessionManager about the change instead of directly configuring + AudioSessionManager.shared.playerPropertiesChanged(view: self) } @objc @@ -982,13 +975,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } setSelectedTextTrack(_selectedTextTrackCriteria) - setAudioOutput(_audioOutput) setSelectedAudioTrack(_selectedAudioTrackCriteria) setResizeMode(_resizeMode) setRepeat(_repeat) setControls(_controls) setPaused(_paused) setAllowsExternalPlayback(_allowsExternalPlayback) + + AudioSessionManager.shared.playerPropertiesChanged(view: self) } @objc @@ -1370,6 +1364,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH player.pause() NowPlayingInfoCenterManager.shared.removePlayer(player: player) } + + // Unregister from AudioSessionManager + AudioSessionManager.shared.unregisterView(view: self) + _playerItem = nil _source = nil _chapters = nil