From e3055396b928edf5440d4c6a643cf3bc3c25f707 Mon Sep 17 00:00:00 2001 From: Mario Ortiz Manero Date: Tue, 17 Mar 2026 00:00:06 +0100 Subject: [PATCH] feat: Add configurable audio session mode for iOS and Android Allow users to control audio interruption behavior (DoNotMix, MixWithOthers, DuckOthers) and iOS silent switch handling via a new AudioMode parameter on rememberVideoPlayerState(). --- .../VideoPlayerState.android.kt | 19 +++++++--- .../composemediaplayer/AudioMode.kt | 31 ++++++++++++++++ .../composemediaplayer/VideoPlayerState.kt | 6 ++-- .../VideoPlayerState.ios.kt | 35 +++++++++++++++++-- .../VideoPlayerState.jvm.kt | 2 +- .../VideoPlayerState.web.kt | 2 +- 6 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioMode.kt diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index 3accae8..a87814a 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -35,9 +35,9 @@ import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* @OptIn(UnstableApi::class) -actual fun createVideoPlayerState(): VideoPlayerState = +actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = try { - DefaultVideoPlayerState() + DefaultVideoPlayerState(audioMode) } catch (e: IllegalStateException) { PreviewableVideoPlayerState( hasMedia = false, @@ -75,7 +75,9 @@ internal val androidVideoLogger = Logger.withTag("AndroidVideoPlayerSurface") @UnstableApi @Stable -open class DefaultVideoPlayerState: VideoPlayerState { +open class DefaultVideoPlayerState( + private val audioMode: AudioMode = AudioMode(), +) : VideoPlayerState { private val context: Context = ContextProvider.getContext() internal var exoPlayer: ExoPlayer? = null private var updateJob: Job? = null @@ -365,10 +367,17 @@ open class DefaultVideoPlayerState: VideoPlayerState { } } + val manageFocus = audioMode.interruptionMode == InterruptionMode.DoNotMix + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build() + exoPlayer = ExoPlayer.Builder(context) .setRenderersFactory(renderersFactory) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_LOCAL) + .setHandleAudioBecomingNoisy(manageFocus) + .setWakeMode(if (manageFocus) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NONE) + .setAudioAttributes(audioAttributes, manageFocus) .setPauseAtEndOfMediaItems(false) .setReleaseTimeoutMs(2000) // Augmenter le timeout de libération .build() diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioMode.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioMode.kt new file mode 100644 index 0000000..59f816e --- /dev/null +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/AudioMode.kt @@ -0,0 +1,31 @@ +package io.github.kdroidfilter.composemediaplayer + +/** + * Controls how the media player interacts with other apps' audio. + */ +enum class InterruptionMode { + /** Exclusive audio focus. Other apps' audio is paused. */ + DoNotMix, + /** Mix with other apps' audio. No audio focus requested. */ + MixWithOthers, + /** Other apps' audio ducks (lowers volume) while this player is active. */ + DuckOthers, +} + +/** + * Configures how the media player interacts with the system audio session. + * + * On iOS, this maps to AVAudioSession category, mode, and options. + * On Android, this maps to AudioAttributes and audio focus behavior. + * On other platforms (JVM desktop, web), this has no effect. + * + * The default [AudioMode] requests exclusive audio focus and ignores the iOS silent switch, + * matching standard media playback behavior. + * + * @param interruptionMode How this player interacts with other apps' audio. + * @param playsInSilentMode iOS only: whether audio plays when the device silent switch is on. + */ +data class AudioMode( + val interruptionMode: InterruptionMode = InterruptionMode.DoNotMix, + val playsInSilentMode: Boolean = true, +) diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt index 9174d93..9315edc 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -132,7 +132,7 @@ interface VideoPlayerState { * Create platform-specific video player state. Supported platforms include Windows, * macOS, and Linux. */ -expect fun createVideoPlayerState(): VideoPlayerState +expect fun createVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlayerState /** * Creates and manages an instance of `VideoPlayerState` within a composable function, ensuring @@ -143,8 +143,8 @@ expect fun createVideoPlayerState(): VideoPlayerState * controlling and managing video playback, such as play, pause, stop, and seek. */ @Composable -fun rememberVideoPlayerState(): VideoPlayerState { - val playerState = remember { createVideoPlayerState() } +fun rememberVideoPlayerState(audioMode: AudioMode = AudioMode()): VideoPlayerState { + val playerState = remember(audioMode) { createVideoPlayerState(audioMode) } DisposableEffect(Unit) { onDispose { playerState.dispose() diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index 71d6415..f9080a1 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -18,7 +18,12 @@ import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.useContents import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryAmbient +import platform.AVFAudio.AVAudioSessionCategoryOptionDuckOthers +import platform.AVFAudio.AVAudioSessionCategoryOptionMixWithOthers import platform.AVFAudio.AVAudioSessionCategoryPlayback +import platform.AVFAudio.AVAudioSessionCategorySoloAmbient +import platform.AVFAudio.AVAudioSessionModeDefault import platform.AVFAudio.AVAudioSessionModeMoviePlayback import platform.AVFAudio.setActive import platform.AVFoundation.* @@ -44,10 +49,12 @@ import platform.darwin.dispatch_async import platform.darwin.dispatch_get_global_queue import platform.darwin.dispatch_get_main_queue -actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() +actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState(audioMode) @Stable -open class DefaultVideoPlayerState: VideoPlayerState { +open class DefaultVideoPlayerState( + private val audioMode: AudioMode = AudioMode(), +) : VideoPlayerState { // Base states private var _volume = mutableStateOf(1.0f) @@ -159,7 +166,29 @@ open class DefaultVideoPlayerState: VideoPlayerState { private fun configureAudioSession() { val session = AVAudioSession.sharedInstance() try { - session.setCategory(AVAudioSessionCategoryPlayback, mode = AVAudioSessionModeMoviePlayback, options = 0u, error = null) + val category = if (audioMode.playsInSilentMode) { + AVAudioSessionCategoryPlayback + } else { + when (audioMode.interruptionMode) { + InterruptionMode.DoNotMix -> AVAudioSessionCategorySoloAmbient + InterruptionMode.MixWithOthers, + InterruptionMode.DuckOthers -> AVAudioSessionCategoryAmbient + } + } + + val mode = if (audioMode.playsInSilentMode) { + AVAudioSessionModeMoviePlayback + } else { + AVAudioSessionModeDefault + } + + val options: ULong = when (audioMode.interruptionMode) { + InterruptionMode.DoNotMix -> 0u + InterruptionMode.MixWithOthers -> AVAudioSessionCategoryOptionMixWithOthers + InterruptionMode.DuckOthers -> AVAudioSessionCategoryOptionMixWithOthers or AVAudioSessionCategoryOptionDuckOthers + } + + session.setCategory(category, mode = mode, options = options, error = null) session.setActive(true, error = null) } catch (e: Exception) { Logger.e { "Failed to configure audio session: ${e.message}" } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index bdb5b7d..f89b127 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -9,7 +9,7 @@ import io.github.kdroidfilter.composemediaplayer.mac.MacVideoPlayerState import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerState import io.github.vinceglb.filekit.PlatformFile -actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() +actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState() /** * Represents the state and behavior of a video player. This class provides properties diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index bbc6d0b..462ba18 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -25,7 +25,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource -actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() +actual fun createVideoPlayerState(audioMode: AudioMode): VideoPlayerState = DefaultVideoPlayerState() /** * Implementation of VideoPlayerState for WebAssembly/JavaScript platform.