diff --git a/.gitignore b/.gitignore index 3d377560..7882b58d 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,5 @@ DerivedData/ Podfile.lock Package.resolved /.build +/CLAUDE.md +/.claude diff --git a/README.md b/README.md index d8baf49a..64c624cc 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,18 @@ OR another complete Ionic/Angular application demonstrating every plugin method is available in the **example-app** directory ```typescript -import {NativeAudio} from '@capacitor-community/native-audio' +import { NativeAudio, AudioFocusMode } from '@capacitor-community/native-audio' +/** + * This method will configure the audio focus behavior and fading. + * @param fade - enable audio fade effect + * @param audioFocusMode - audio focus mode: NONE (mixed audio), EXCLUSIVE (pause other audio), DUCK (lower other audio volume) + * @returns void + */ +NativeAudio.configure({ + fade: false, + audioFocusMode: AudioFocusMode.DUCK +}); /** * This method will load more optimized audio files for background into memory. @@ -209,9 +219,13 @@ NativeAudio.isPlaying({ configure(options: ConfigureOptions) => Promise ``` -| Param | Type | -| ------------- | ------------------------------------------------------------- | -| **`options`** | ConfigureOptions | +Configure plugin behavior for audio focus and fading + +| Param | Type | Description | +| ------------- | ------------------------------------------------------------- | --------------------- | +| **`options`** | ConfigureOptions | Configuration options | + +**Since:** 1.0.0 -------------------- @@ -390,10 +404,10 @@ Listen for asset completed playing event #### ConfigureOptions -| Prop | Type | Description | Default | -| ----------- | -------------------- | ------------------------------------------------- | ------------------ | -| **`fade`** | boolean | Indicating whether or not to fade audio. | false | -| **`focus`** | boolean | Indicating whether or not to disable mixed audio. | false | +| Prop | Type | Description | Default | +| -------------------- | --------------------------------------------------------- | ------------------------- | -------------------------------- | +| **`fade`** | boolean | Audio fade configuration | false | +| **`audioFocusMode`** | AudioFocusMode | Audio focus behavior mode | AudioFocusMode.NONE | #### PreloadOptions @@ -413,4 +427,16 @@ Listen for asset completed playing event | ------------ | ----------------------------------------- | | **`remove`** | () => Promise<void> | + +### Enums + + +#### AudioFocusMode + +| Members | Value | Description | +| --------------- | ------------------------ | ---------------------------------------------------- | +| **`NONE`** | 'none' | Allow mixed audio, no focus management | +| **`EXCLUSIVE`** | 'exclusive' | Take exclusive audio focus, pause other audio | +| **`DUCK`** | 'duck' | Take audio focus but duck (lower volume) other audio | + diff --git a/android/src/main/java/com/getcapacitor/community/audio/AudioFocusMode.java b/android/src/main/java/com/getcapacitor/community/audio/AudioFocusMode.java new file mode 100644 index 00000000..e99cd8e0 --- /dev/null +++ b/android/src/main/java/com/getcapacitor/community/audio/AudioFocusMode.java @@ -0,0 +1,31 @@ +package com.getcapacitor.community.audio; + +public enum AudioFocusMode { + NONE("none"), + EXCLUSIVE("exclusive"), + DUCK("duck"); + + private final String value; + + AudioFocusMode(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static AudioFocusMode fromString(String value) { + if (value == null) { + return NONE; + } + + for (AudioFocusMode mode : AudioFocusMode.values()) { + if (mode.value.equals(value)) { + return mode; + } + } + + return NONE; + } +} diff --git a/android/src/main/java/com/getcapacitor/community/audio/Constant.java b/android/src/main/java/com/getcapacitor/community/audio/Constant.java index 73c18717..c721c2cd 100644 --- a/android/src/main/java/com/getcapacitor/community/audio/Constant.java +++ b/android/src/main/java/com/getcapacitor/community/audio/Constant.java @@ -11,7 +11,7 @@ public class Constant { public static final String ASSET_ID = "assetId"; public static final String ASSET_PATH = "assetPath"; public static final String OPT_FADE_MUSIC = "fade"; - public static final String OPT_FOCUS_AUDIO = "focus"; + public static final String OPT_AUDIO_FOCUS_MODE = "audioFocusMode"; public static final String VOLUME = "volume"; public static final String AUDIO_CHANNEL_NUM = "audioChannelNum"; public static final String LOOP = "loop"; diff --git a/android/src/main/java/com/getcapacitor/community/audio/NativeAudio.java b/android/src/main/java/com/getcapacitor/community/audio/NativeAudio.java index 6e9938b4..192f8822 100644 --- a/android/src/main/java/com/getcapacitor/community/audio/NativeAudio.java +++ b/android/src/main/java/com/getcapacitor/community/audio/NativeAudio.java @@ -9,14 +9,16 @@ import static com.getcapacitor.community.audio.Constant.ERROR_AUDIO_EXISTS; import static com.getcapacitor.community.audio.Constant.ERROR_AUDIO_ID_MISSING; import static com.getcapacitor.community.audio.Constant.LOOP; +import static com.getcapacitor.community.audio.Constant.OPT_AUDIO_FOCUS_MODE; import static com.getcapacitor.community.audio.Constant.OPT_FADE_MUSIC; -import static com.getcapacitor.community.audio.Constant.OPT_FOCUS_AUDIO; import static com.getcapacitor.community.audio.Constant.VOLUME; import android.Manifest; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; import android.media.AudioManager; import android.net.Uri; import android.os.ParcelFileDescriptor; @@ -47,6 +49,8 @@ public class NativeAudio extends Plugin implements AudioManager.OnAudioFocusChan private static ArrayList resumeList; private boolean fadeMusic = false; private AudioManager audioManager; + private AudioFocusRequest audioFocusRequest; + private AudioFocusMode configuredAudioFocusMode = AudioFocusMode.NONE; @Override public void load() { @@ -110,13 +114,10 @@ public void configure(PluginCall call) { this.fadeMusic = call.getBoolean(OPT_FADE_MUSIC, false); - if (this.audioManager != null) { - if (call.getBoolean(OPT_FOCUS_AUDIO, false)) { - this.audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); - } else { - this.audioManager.abandonAudioFocus(this); - } - } + // Store the audio focus mode configuration for later use + String audioFocusModeString = call.getString(OPT_AUDIO_FOCUS_MODE, AudioFocusMode.NONE.getValue()); + this.configuredAudioFocusMode = AudioFocusMode.fromString(audioFocusModeString); + call.resolve(); } @@ -271,6 +272,11 @@ public void unload(PluginCall call) { asset.unload(); audioAssetList.remove(audioId); + // Abandon audio focus when no more assets are loaded + if (audioAssetList.isEmpty()) { + abandonAudioFocus(); + } + status = new JSObject(); status.put("status", "OK"); call.resolve(status); @@ -394,6 +400,11 @@ private void preloadAsset(PluginCall call) { AudioAsset asset = new AudioAsset(this, audioId, assetFileDescriptor, audioChannelNum, (float) volume); audioAssetList.put(audioId, asset); + // Request audio focus when first asset is preloaded + if (audioAssetList.size() == 1) { + requestAudioFocus(); + } + JSObject status = new JSObject(); status.put("STATUS", "OK"); call.resolve(status); @@ -440,4 +451,55 @@ private void initSoundPool() { private boolean isStringValid(String value) { return (value != null && !value.isEmpty() && !value.equals("null")); } + + private void requestAudioFocus() { + if (this.audioManager == null) { + return; + } + + int focusGain; + int usage; + int contentType; + + switch (this.configuredAudioFocusMode) { + case EXCLUSIVE: + focusGain = AudioManager.AUDIOFOCUS_GAIN; + usage = AudioAttributes.USAGE_MEDIA; + contentType = AudioAttributes.CONTENT_TYPE_MUSIC; + break; + case DUCK: + focusGain = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK; + usage = AudioAttributes.USAGE_ASSISTANCE_SONIFICATION; + contentType = AudioAttributes.CONTENT_TYPE_SONIFICATION; + break; + case NONE: + return; + default: + return; + } + + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(usage) + .setContentType(contentType) + .build(); + + this.audioFocusRequest = new AudioFocusRequest.Builder(focusGain) + .setAudioAttributes(audioAttributes) + .setOnAudioFocusChangeListener(this) + .setAcceptsDelayedFocusGain(false) + .setWillPauseWhenDucked(false) + .build(); + + int result = this.audioManager.requestAudioFocus(this.audioFocusRequest); + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus request denied"); + } + } + + private void abandonAudioFocus() { + if (this.audioFocusRequest != null) { + this.audioManager.abandonAudioFocusRequest(this.audioFocusRequest); + this.audioFocusRequest = null; + } + } } diff --git a/ios/Sources/NativeAudio/AudioFocusMode.swift b/ios/Sources/NativeAudio/AudioFocusMode.swift new file mode 100644 index 00000000..d6441222 --- /dev/null +++ b/ios/Sources/NativeAudio/AudioFocusMode.swift @@ -0,0 +1,13 @@ +// +// AudioFocusMode.swift +// Plugin +// +// Created by Rabter1 on 2025-09-21. +// Copyright © 2020 Max Lynch. All rights reserved. +// + +public enum AudioFocusMode: String { + case none + case exclusive + case duck +} \ No newline at end of file diff --git a/ios/Sources/NativeAudio/Constant.swift b/ios/Sources/NativeAudio/Constant.swift index 8ce9db5d..c47bc397 100644 --- a/ios/Sources/NativeAudio/Constant.swift +++ b/ios/Sources/NativeAudio/Constant.swift @@ -8,7 +8,7 @@ public class Constant { public static let FadeKey = "fade" - public static let FocusAudio = "focus" + public static let AudioFocusModeKey = "audioFocusMode" public static let AssetPathKey = "assetPath" public static let AssetIdKey = "assetId" public static let Volume = "volume" diff --git a/ios/Sources/NativeAudio/Plugin.swift b/ios/Sources/NativeAudio/Plugin.swift index 38051aa3..6fd4c7df 100644 --- a/ios/Sources/NativeAudio/Plugin.swift +++ b/ios/Sources/NativeAudio/Plugin.swift @@ -49,11 +49,17 @@ public class NativeAudio: CAPPlugin, CAPBridgedPlugin { @objc func configure(_ call: CAPPluginCall) { self.fadeMusic = call.getBool(Constant.FadeKey, false) + let audioFocusModeString = call.getString(Constant.AudioFocusModeKey, AudioFocusMode.none.rawValue) + let audioFocusMode = AudioFocusMode(rawValue: audioFocusModeString) ?? AudioFocusMode.none + do { - if call.getBool(Constant.FocusAudio, false) { - try self.session.setCategory(AVAudioSession.Category.playback) - } else { + switch audioFocusMode { + case .none: try self.session.setCategory(AVAudioSession.Category.ambient) + case .exclusive: + try self.session.setCategory(AVAudioSession.Category.playback) + case .duck: + try self.session.setCategory(AVAudioSession.Category.playback, options: .duckOthers) } } catch { print("Failed to set setCategory audio") @@ -61,6 +67,15 @@ public class NativeAudio: CAPPlugin, CAPBridgedPlugin { call.resolve() } + + private func deactivateAudioSession() { + do { + try self.session.setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("Failed to deactivate audio session") + } + } + @objc func preload(_ call: CAPPluginCall) { preloadAsset(call, isComplex: true) } @@ -179,7 +194,10 @@ public class NativeAudio: CAPPlugin, CAPBridgedPlugin { if asset != nil && asset is AudioAsset { let audioAsset = asset as! AudioAsset audioAsset.unload() - self.audioList[audioId] = nil + self.audioList.removeValue(forKey: audioId); + if self.audioList.isEmpty { + self.deactivateAudioSession() + } } } call.resolve() diff --git a/src/definitions.ts b/src/definitions.ts index f06dc55c..d9912c7c 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,6 +1,36 @@ import type { PluginListenerHandle } from '@capacitor/core'; +export enum AudioFocusMode { + /** Allow mixed audio, no focus management */ + NONE = 'none', + /** Take exclusive audio focus, pause other audio */ + EXCLUSIVE = 'exclusive', + /** Take audio focus but duck (lower volume) other audio */ + DUCK = 'duck', +} + export interface NativeAudio { + /** + * Configure plugin behavior for audio focus and fading + * + * @param options Configuration options + * + * @example + * ```typescript + * // Duck other audio when playing + * await NativeAudio.configure({ + * audioFocusMode: AudioFocusMode.DUCK + * }); + * + * // Take exclusive focus with fade effect + * await NativeAudio.configure({ + * fade: true, + * audioFocusMode: AudioFocusMode.EXCLUSIVE + * }); + * ``` + * + * @since 1.0.0 + */ configure(options: ConfigureOptions): Promise; preload(options: PreloadOptions): Promise; play(options: { assetId: string; time?: number }): Promise; @@ -23,15 +53,16 @@ export interface NativeAudio { export interface ConfigureOptions { /** - * Indicating whether or not to fade audio. + * Audio fade configuration * @default false */ fade?: boolean; + /** - * Indicating whether or not to disable mixed audio. - * @default false + * Audio focus behavior mode + * @default AudioFocusMode.NONE */ - focus?: boolean; + audioFocusMode?: AudioFocusMode; } export interface PreloadOptions {