diff --git a/docs/audio-player.md b/docs/audio-player.md index 8a1f592..167c015 100644 --- a/docs/audio-player.md +++ b/docs/audio-player.md @@ -59,6 +59,70 @@ audioManager.CreatePlayer( For more information, please refer to the Android documentation: https://developer.android.com/reference/android/media/AudioAttributes +## Audio Focus and Interruption Handling + +The `AudioPlayer` automatically handles audio focus on Android and audio interruptions on iOS/macOS by default. This ensures proper behavior when your app interacts with other audio sources, such as phone calls, notifications, or other media apps. + +### Android Audio Focus + +On Android, the plugin automatically: +- **Requests audio focus** when you call `Play()`, notifying the system that your app wants to play audio +- **Abandons audio focus** when you call `Pause()` or `Stop()`, allowing other apps to take control +- **Responds to focus changes** from other apps: + - **Permanent loss**: Stops playback (e.g., user starts music in another app) + - **Temporary loss**: Pauses playback and resumes when focus returns (e.g., phone call) + - **Audio ducking**: Temporarily lowers volume to 20% while other audio plays (e.g., navigation prompts), then restores full volume + +For more information, see the [Android Audio Focus documentation](https://developer.android.com/media/optimize/audio-focus). + +#### Configuring Audio Focus (Android) + +You can control audio focus behavior through the `AudioPlayerOptions`: + +```csharp +var audioPlayer = audioManager.CreatePlayer( + await FileSystem.OpenAppPackageFileAsync("ukelele.mp3"), + new AudioPlayerOptions + { +#if ANDROID + ManageAudioFocus = false // Disable automatic audio focus management +#endif + }); +``` + +When `ManageAudioFocus` is set to `false`, the player will not request or respond to audio focus changes, giving you full manual control. + +### iOS/macOS Audio Interruptions + +On iOS and macOS, the plugin automatically: +- **Registers for interruption notifications** when the player is created +- **Responds to interruptions**: + - **Interruption began**: Pauses playback (e.g., incoming phone call, alarm) + - **Interruption ended**: Resumes playback if the system indicates it should resume +- **Unregisters** interruption observers when the player is disposed + +For more information, see the [iOS Audio Interruptions documentation](https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions). + +#### Configuring Interruption Handling (iOS/macOS) + +You can control interruption handling behavior through the `AudioPlayerOptions`: + +```csharp +var audioPlayer = audioManager.CreatePlayer( + await FileSystem.OpenAppPackageFileAsync("ukelele.mp3"), + new AudioPlayerOptions + { +#if IOS || MACCATALYST + HandleAudioInterruptions = false // Disable automatic interruption handling +#endif + }); +``` + +When `HandleAudioInterruptions` is set to `false`, the player will not automatically pause or resume during interruptions, giving you full manual control. + +> [!NOTE] +> By default, both `ManageAudioFocus` (Android) and `HandleAudioInterruptions` (iOS/macOS) are enabled (`true`). Your app will properly interact with system audio and other apps out of the box. The audio focus management is handled transparently - you can still control playback manually using `Play()`, `Pause()`, and `Stop()` methods. For backward compatibility, playback will continue even if audio focus cannot be acquired, though this is rare. + ## AudioPlayer API Once you have created an `AudioPlayer` you can interact with it in the following ways: diff --git a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.android.cs b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.android.cs index 0fafc9f..ffb97bd 100644 --- a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.android.cs +++ b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.android.cs @@ -16,6 +16,14 @@ partial class AudioPlayer : IAudioPlayer MemoryStream? stream; bool isDisposed = false; AudioStopwatch stopwatch = new(TimeSpan.Zero, 1.0); + Android.Media.AudioManager? audioManager; + AudioFocusRequestClass? audioFocusRequest; + AudioFocusChangeListener? audioFocusChangeListener; + bool wasPlayingBeforeFocusLoss = false; + double volumeBeforeDucking = 0; + AudioPlayerOptions? audioPlayerOptions; + + const double DuckingVolumeMultiplier = 0.2; public double Duration => player.Duration <= -1 ? -1 : player.Duration / 1000.0; @@ -184,6 +192,14 @@ void PrepareAudioSource() internal AudioPlayer(AudioPlayerOptions audioPlayerOptions) { player = new MediaPlayer(); + this.audioPlayerOptions = audioPlayerOptions; + + // Initialize audio manager and focus listener only if audio focus management is enabled + if (audioPlayerOptions.ManageAudioFocus) + { + audioManager = (Android.Media.AudioManager?)Android.App.Application.Context.GetSystemService(Android.Content.Context.AudioService); + audioFocusChangeListener = new AudioFocusChangeListener(this); + } if (OperatingSystem.IsAndroidVersionAtLeast(26)) { @@ -195,6 +211,15 @@ internal AudioPlayer(AudioPlayerOptions audioPlayerOptions) if (audioAttributes is not null) { player.SetAudioAttributes(audioAttributes); + + // Build audio focus request for Android 26+ only if audio focus management is enabled + if (audioPlayerOptions.ManageAudioFocus && audioManager is not null && audioFocusChangeListener is not null) + { + audioFocusRequest = new AudioFocusRequestClass.Builder(AudioFocus.Gain)? + .SetAudioAttributes(audioAttributes)? + .SetOnAudioFocusChangeListener(audioFocusChangeListener)? + .Build(); + } } } else @@ -259,7 +284,14 @@ internal AudioPlayer(Stream audioStream, AudioPlayerOptions audioPlayerOptions) { player = new MediaPlayer(); player.Completion += OnPlaybackEnded; + this.audioPlayerOptions = audioPlayerOptions; + // Initialize audio manager and focus listener only if audio focus management is enabled + if (audioPlayerOptions.ManageAudioFocus) + { + audioManager = (Android.Media.AudioManager?)Android.App.Application.Context.GetSystemService(Android.Content.Context.AudioService); + audioFocusChangeListener = new AudioFocusChangeListener(this); + } if (OperatingSystem.IsAndroidVersionAtLeast(23)) { @@ -294,6 +326,14 @@ internal AudioPlayer(string fileName, AudioPlayerOptions audioPlayerOptions) player = new MediaPlayer(); player.Completion += OnPlaybackEnded; player.Error += OnError; + this.audioPlayerOptions = audioPlayerOptions; + + // Initialize audio manager and focus listener only if audio focus management is enabled + if (audioPlayerOptions.ManageAudioFocus) + { + audioManager = (Android.Media.AudioManager?)Android.App.Application.Context.GetSystemService(Android.Content.Context.AudioService); + audioFocusChangeListener = new AudioFocusChangeListener(this); + } file = fileName; @@ -330,6 +370,18 @@ public void Play() stopwatch.Reset(); } + // Request audio focus before playing + if (!RequestAudioFocus()) + { + System.Diagnostics.Trace.TraceWarning("Failed to request audio focus"); + // Continue playing even if focus request fails for backward compatibility + } + + PlayInternal(); + } + + void PlayInternal() + { isPlaying = true; player.Start(); stopwatch.Start(); @@ -343,6 +395,9 @@ public void Stop() player.Pause(); } + // Abandon audio focus when stopping + AbandonAudioFocus(); + Seek(0); OnPlaybackEnded(player, EventArgs.Empty); @@ -355,6 +410,14 @@ public void Pause() return; } + PauseInternal(); + + // Abandon audio focus when pausing + AbandonAudioFocus(); + } + + void PauseInternal() + { isPlaying = false; player.Pause(); stopwatch.Stop(); @@ -405,6 +468,109 @@ void OnError(object? sender, MediaPlayer.ErrorEventArgs e) OnError(e); } + bool RequestAudioFocus() + { + // Check if audio focus management is enabled + if (audioPlayerOptions?.ManageAudioFocus != true || audioManager is null) + { + return false; + } + + AudioFocusRequest result; + + if (OperatingSystem.IsAndroidVersionAtLeast(26) && audioFocusRequest is not null) + { + result = audioManager.RequestAudioFocus(audioFocusRequest); + } + else + { + // For API < 26, use deprecated method +#pragma warning disable CS0618 // Type or member is obsolete + result = audioManager.RequestAudioFocus( + audioFocusChangeListener, + Android.Media.Stream.Music, + AudioFocus.Gain); +#pragma warning restore CS0618 // Type or member is obsolete + } + + return result == AudioFocusRequest.Granted; + } + + void AbandonAudioFocus() + { + // Check if audio focus management is enabled + if (audioPlayerOptions?.ManageAudioFocus != true || audioManager is null) + { + return; + } + + if (OperatingSystem.IsAndroidVersionAtLeast(26) && audioFocusRequest is not null) + { + audioManager.AbandonAudioFocusRequest(audioFocusRequest); + } + else + { + // For API < 26, use deprecated method +#pragma warning disable CS0618 // Type or member is obsolete + audioManager.AbandonAudioFocus(audioFocusChangeListener); +#pragma warning restore CS0618 // Type or member is obsolete + } + } + + void HandleAudioFocusChange(AudioFocus focusChange) + { + switch (focusChange) + { + case AudioFocus.Loss: + // Permanent loss of audio focus - stop playback + // Reset state before Stop() to prevent incorrect state in any callbacks + wasPlayingBeforeFocusLoss = false; + volumeBeforeDucking = 0; + if (IsPlaying) + { + Stop(); + } + break; + + case AudioFocus.LossTransient: + // Temporary loss of audio focus - pause playback + if (IsPlaying) + { + wasPlayingBeforeFocusLoss = true; + // Don't abandon audio focus here since we want to resume later + PauseInternal(); + } + break; + + case AudioFocus.LossTransientCanDuck: + // Temporary loss of audio focus but can duck (lower volume) + // Lower the volume but continue playing + if (IsPlaying) + { + volumeBeforeDucking = Volume; + Volume = volumeBeforeDucking * DuckingVolumeMultiplier; + } + break; + + case AudioFocus.Gain: + // Regained audio focus + if (wasPlayingBeforeFocusLoss) + { + // Resume playback if it was paused due to transient loss + // Use PlayInternal() since we already have audio focus + PlayInternal(); + wasPlayingBeforeFocusLoss = false; + } + // Restore volume if it was ducked + if (volumeBeforeDucking > 0) + { + Volume = volumeBeforeDucking; + volumeBeforeDucking = 0; + } + break; + } + } + protected virtual void Dispose(bool disposing) { if (isDisposed) @@ -414,6 +580,7 @@ protected virtual void Dispose(bool disposing) if (disposing) { + AbandonAudioFocus(); player.Completion -= OnPlaybackEnded; player.Error -= OnError; player.Reset(); @@ -422,8 +589,27 @@ protected virtual void Dispose(bool disposing) DeleteFile(cachePath); cachePath = string.Empty; stream?.Dispose(); + audioFocusRequest?.Dispose(); } isDisposed = true; } + + /// + /// Listens for audio focus changes from the Android system and delegates handling to the parent AudioPlayer. + /// + class AudioFocusChangeListener : Java.Lang.Object, Android.Media.AudioManager.IOnAudioFocusChangeListener + { + readonly AudioPlayer audioPlayer; + + public AudioFocusChangeListener(AudioPlayer player) + { + audioPlayer = player; + } + + public void OnAudioFocusChange(AudioFocus focusChange) + { + audioPlayer.HandleAudioFocusChange(focusChange); + } + } } diff --git a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.macios.cs b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.macios.cs index 1b3c755..b05d12d 100644 --- a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.macios.cs +++ b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayer.macios.cs @@ -11,6 +11,8 @@ partial class AudioPlayer : IAudioPlayer AVAudioPlayer player; readonly AudioPlayerOptions audioPlayerOptions; bool isDisposed; + NSObject? interruptionObserver; + bool wasPlayingBeforeInterruption = false; /// /// Gets the current position of audio playback in seconds. @@ -131,6 +133,7 @@ public void SetSource(Stream audioStream) { player.FinishedPlaying -= OnPlayerFinishedPlaying; player.DecoderError -= OnPlayerError; + UnregisterFromAudioInterruptions(); ActiveSessionHelper.FinishSession(audioPlayerOptions); Stop(); player.Dispose(); @@ -179,6 +182,7 @@ protected virtual void Dispose(bool disposing) if (disposing) { + UnregisterFromAudioInterruptions(); ActiveSessionHelper.FinishSession(audioPlayerOptions); Stop(); @@ -236,12 +240,86 @@ bool PreparePlayer() player.FinishedPlaying += OnPlayerFinishedPlaying; player.DecoderError += OnPlayerError; + // Subscribe to audio session interruptions + RegisterForAudioInterruptions(); + player.EnableRate = true; player.PrepareToPlay(); return true; } + void RegisterForAudioInterruptions() + { + // Only register if interruption handling is enabled + if (audioPlayerOptions.HandleAudioInterruptions) + { + // Register for AVAudioSession interruption notifications + interruptionObserver = NSNotificationCenter.DefaultCenter.AddObserver( + AVAudioSession.InterruptionNotification, + HandleAudioSessionInterruption); + } + } + + void UnregisterFromAudioInterruptions() + { + if (interruptionObserver is not null) + { + NSNotificationCenter.DefaultCenter.RemoveObserver(interruptionObserver); + interruptionObserver = null; + } + } + + void HandleAudioSessionInterruption(NSNotification notification) + { + var interruptionType = GetInterruptionType(notification); + + if (interruptionType == AVAudioSessionInterruptionType.Began) + { + // Audio session was interrupted (phone call, alarm, etc.) + if (player.Playing) + { + wasPlayingBeforeInterruption = true; + player.Pause(); + } + } + else if (interruptionType == AVAudioSessionInterruptionType.Ended) + { + // Audio session interruption ended + var interruptionOptions = GetInterruptionOptions(notification); + + // Check if we should resume playback + if (interruptionOptions.HasFlag(AVAudioSessionInterruptionOptions.ShouldResume) && wasPlayingBeforeInterruption) + { + wasPlayingBeforeInterruption = false; + Play(); + } + } + } + + /// + /// Retrieves the interruption type from an AVAudioSession interruption notification. + /// + /// The notification containing interruption information. + /// The interruption type, or Began if it cannot be determined (safer default). + AVAudioSessionInterruptionType GetInterruptionType(NSNotification notification) + { + var typeValue = notification.UserInfo?["AVAudioSessionInterruptionTypeKey"] as NSNumber; + // Default to Began if type cannot be determined - safer to assume interruption started + return typeValue != null ? (AVAudioSessionInterruptionType)(int)typeValue : AVAudioSessionInterruptionType.Began; + } + + /// + /// Retrieves the interruption options from an AVAudioSession interruption notification. + /// + /// The notification containing interruption information. + /// The interruption options, or 0 if none are specified. + AVAudioSessionInterruptionOptions GetInterruptionOptions(NSNotification notification) + { + var optionsValue = notification.UserInfo?["AVAudioSessionInterruptionOptionKey"] as NSNumber; + return optionsValue != null ? (AVAudioSessionInterruptionOptions)(int)optionsValue : 0; + } + void OnPlayerError(object? sender, AVErrorEventArgs e) { OnError(e); diff --git a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.android.cs b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.android.cs index 8a3aadc..a7d9790 100644 --- a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.android.cs +++ b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.android.cs @@ -28,4 +28,15 @@ partial class AudioPlayerOptions : BaseOptions /// If any other value is used, the default value of is used. /// public AudioUsageKind AudioUsageKind { get; set; } = AudioUsageKind.Unknown; + + /// + /// Gets or sets whether audio focus should be automatically managed. Default value: . + /// + /// + /// When enabled (default), the player will automatically request audio focus when playing and abandon it when paused or stopped. + /// This ensures proper interaction with other audio sources like phone calls and other apps. + /// When disabled, the player will not request or abandon audio focus, giving you full control over audio focus management. + /// See https://developer.android.com/media/optimize/audio-focus for more information. + /// + public bool ManageAudioFocus { get; set; } = true; } diff --git a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.macios.cs b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.macios.cs index d6dffc1..e296a3e 100644 --- a/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.macios.cs +++ b/src/Plugin.Maui.Audio/AudioPlayer/AudioPlayerOptions.macios.cs @@ -11,4 +11,14 @@ public AudioPlayerOptions() { Category = AVAudioSessionCategory.Playback; } + + /// + /// Gets or sets whether audio interruptions should be automatically handled. Default value: . + /// + /// + /// When enabled (default), the player will automatically pause when interrupted (e.g., phone calls) and resume when appropriate. + /// When disabled, the player will not respond to audio interruptions, giving you full control over interruption handling. + /// See https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions for more information. + /// + public bool HandleAudioInterruptions { get; set; } = true; } \ No newline at end of file