Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ internal class VoicePlaybackRequestSerializer {
object VoicePlaybackController {
private const val TAG = "VoicePlaybackController"
private const val TICK_INTERVAL_MS = 60L
private const val DUCK_VOLUME = 0.2f

// Cap on cached per-clip durations. Each entry is a boxed Int keyed by an
// absolute file path; without a bound the map held one entry per distinct
Expand Down Expand Up @@ -105,15 +106,10 @@ object VoicePlaybackController {

private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
private var resumeOnAudioFocusGain = false
private var duckedForAudioFocusLoss = false
private val focusListener =
AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
-> pause()
}
}
AudioManager.OnAudioFocusChangeListener { change -> handleAudioFocusChange(change) }

/** Call once from Application.onCreate so playback can request audio focus. */
fun attach(context: Context) {
Expand Down Expand Up @@ -218,11 +214,13 @@ object VoicePlaybackController {
file: File,
ownerKey: String?,
): PlaybackStartResult {
if (resumeOnAudioFocusGain) return PlaybackStartResult.FocusDenied
clearAudioFocusInterruption(restoreVolume = true)
if (currentKey == key && player != null) {
// Re-acquire focus before resuming: a transient focus loss
// auto-paused us (focusListener → pause()) and abandoned focus,
// so another app may now own it. Restarting without re-requesting
// would let two streams play at once or get our start() clobbered.
// User-paused playback abandons focus, so reacquire it before
// resuming. The transient-loss path retains focus and is resumed
// only by AUDIOFOCUS_GAIN; the guard above prevents a manual start
// while another transient owner still has focus.
if (!requestFocus()) {
// Focus denied — stay paused rather than playing unfocused.
return PlaybackStartResult.FocusDenied
Expand Down Expand Up @@ -395,29 +393,127 @@ object VoicePlaybackController {
focusRequest = null
}

private fun handleAudioFocusChange(change: Int) {
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> pause()
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseForTransientAudioFocusLoss()
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> duckForTransientAudioFocusLoss()
AudioManager.AUDIOFOCUS_GAIN -> restoreAfterAudioFocusGain()
}
}

private fun pauseForTransientAudioFocusLoss() {
val mp = player ?: return
val wasPlaying =
runCatching { mp.isPlaying }.getOrElse { failure ->
releaseAfterPlayerControlFailure("MediaPlayer state query failed during transient focus loss", failure)
return
}
if (!wasPlaying) return
val pauseFailure = runCatching { mp.pause() }.exceptionOrNull()
if (pauseFailure != null) {
releaseAfterPlayerControlFailure("MediaPlayer transient pause failed", pauseFailure)
return
}
resumeOnAudioFocusGain = true
_state.value =
_state.value.copy(
isPlaying = false,
positionMs = runCatching { mp.currentPosition }.getOrDefault(_state.value.positionMs),
)
stopTicker()
}

private fun duckForTransientAudioFocusLoss() {
val mp = player ?: return
val wasPlaying =
runCatching { mp.isPlaying }.getOrElse { failure ->
releaseAfterPlayerControlFailure("MediaPlayer state query failed while ducking", failure)
return
}
if (!wasPlaying) return
if (runCatching { mp.setVolume(DUCK_VOLUME, DUCK_VOLUME) }.isSuccess) {
duckedForAudioFocusLoss = true
} else {
pauseForTransientAudioFocusLoss()
}
}

private fun restoreAfterAudioFocusGain() {
val mp = player
if (duckedForAudioFocusLoss) {
val restoreFailure = mp?.runCatching { setVolume(1f, 1f) }?.exceptionOrNull()
if (restoreFailure != null) {
releaseAfterPlayerControlFailure("MediaPlayer volume restore failed", restoreFailure)
return
}
duckedForAudioFocusLoss = false
}
if (!resumeOnAudioFocusGain) return
resumeOnAudioFocusGain = false
if (mp == null || !startCurrentPlayer(mp)) return
_state.value =
_state.value.copy(
isPlaying = true,
durationMs = runCatching { mp.duration }.getOrDefault(_state.value.durationMs),
)
startTicker()
}

private fun clearAudioFocusInterruption(restoreVolume: Boolean) {
if (restoreVolume && duckedForAudioFocusLoss) {
val restoreFailure = player?.runCatching { setVolume(1f, 1f) }?.exceptionOrNull()
if (restoreFailure != null) {
releaseAfterPlayerControlFailure("MediaPlayer volume restore failed", restoreFailure)
return
}
}
resumeOnAudioFocusGain = false
duckedForAudioFocusLoss = false
}

private fun releaseAfterPlayerControlFailure(
message: String,
failure: Throwable,
) {
Log.w(TAG, message, failure)
releasePlayerInternal()
_state.value = PlaybackState()
}

/** Pause the active player (no-op if nothing is active). */
fun pause() {
nextPlaybackGeneration()
clearAudioFocusInterruption(restoreVolume = true)
val mp =
player ?: run {
_state.value = _state.value.copy(isPlaying = false)
stopTicker()
abandonFocus()
return
}
if (runCatching { mp.isPlaying }.getOrDefault(false)) {
mp.pause()
_state.value =
_state.value.copy(
isPlaying = false,
positionMs = mp.currentPosition,
)
// Release focus while paused so other apps stop being ducked for
// the (potentially indefinite) pause. Resuming re-requests focus
// in playLocked(). Safe no-op if focus is not currently held.
abandonFocus()
val wasPlaying =
runCatching { mp.isPlaying }.getOrElse { failure ->
releaseAfterPlayerControlFailure("MediaPlayer state query failed while pausing", failure)
return
}
if (wasPlaying) {
val pauseFailure = runCatching { mp.pause() }.exceptionOrNull()
if (pauseFailure != null) {
releaseAfterPlayerControlFailure("MediaPlayer pause failed", pauseFailure)
return
}
}
_state.value =
_state.value.copy(
isPlaying = false,
positionMs = runCatching { mp.currentPosition }.getOrDefault(_state.value.positionMs),
)
stopTicker()
// Release focus while user-paused so other apps stop being ducked for
// the (potentially indefinite) pause. A transient system pause uses a
// separate path and deliberately retains focus for the paired gain.
abandonFocus()
}

/** Seek the active player to [positionMs] (clamped to duration). */
Expand Down Expand Up @@ -456,6 +552,7 @@ object VoicePlaybackController {

private fun releasePlayerInternal() {
stopTicker()
clearAudioFocusInterruption(restoreVolume = false)
player?.let { mp ->
runCatching { if (mp.isPlaying) mp.stop() }
runCatching { mp.release() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.ipf.marmotkit.MediaAttachmentReferenceFfi
import dev.ipf.whitenoise.android.R
import dev.ipf.whitenoise.android.audio.VoicePlaybackController
import dev.ipf.whitenoise.android.media.AttachmentCachePublication
import dev.ipf.whitenoise.android.media.AttachmentPlaintextCache
import dev.ipf.whitenoise.android.media.MediaCacheDirs
Expand All @@ -72,6 +73,13 @@ import java.io.IOException

private val videoMaterializations = SingleFlight<String, java.io.File>()

private val videoPlaybackAudioAttributes =
androidx.media3.common.AudioAttributes
.Builder()
.setContentType(androidx.media3.common.C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(androidx.media3.common.C.USAGE_MEDIA)
.build()

/**
* Single video tile in an album grid. Auto-materialises on first
* composition (mine + cached short-circuit; otherwise FFI download honoring
Expand Down Expand Up @@ -927,6 +935,7 @@ private fun FullscreenVideoPlayer(
.Builder(context)
.build()
.apply {
setAudioAttributes(videoPlaybackAudioAttributes, true)
addListener(
object : androidx.media3.common.Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Expand All @@ -940,11 +949,14 @@ private fun FullscreenVideoPlayer(
androidx.media3.common.MediaItem
.fromUri(android.net.Uri.fromFile(file)),
)
prepare()
playWhenReady = true
}
}
DisposableEffect(exo) { onDispose { exo.release() } }
LaunchedEffect(exo) {
VoicePlaybackController.pause()
exo.prepare()
exo.playWhenReady = true
}
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties =
Expand Down Expand Up @@ -1121,6 +1133,7 @@ internal fun VideoViewerPage(
.Builder(context)
.build()
.apply {
setAudioAttributes(videoPlaybackAudioAttributes, true)
addListener(
object : androidx.media3.common.Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Expand Down Expand Up @@ -1153,14 +1166,22 @@ internal fun VideoViewerPage(
androidx.media3.common.MediaItem
.fromUri(android.net.Uri.fromFile(file)),
)
prepare()
}
}
DisposableEffect(exo) { onDispose { exo.release() } }
// Pre-composed neighbour pages must NOT play audio — only the visible
// one autoplays. Pause when the page scrolls off-screen.
// Only the current page owns a decoder or audio focus. HorizontalPager
// pre-composes neighbours, so preparing in remember would hold multiple
// MediaCodec instances and could invalidate healthy cache entries when a
// neighbour fails decoder acquisition.
LaunchedEffect(isCurrent, exo) {
if (isCurrent) exo.playWhenReady = true else exo.pause()
if (isCurrent) {
VoicePlaybackController.pause()
exo.prepare()
exo.playWhenReady = true
} else {
exo.playWhenReady = false
exo.stop()
}
}
androidx.compose.ui.viewinterop.AndroidView(
modifier = Modifier.fillMaxSize().background(Color.Black),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package dev.ipf.whitenoise.android.audio

import dev.ipf.whitenoise.android.functionBody
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File

class VoicePlaybackAudioFocusPolicyCoverageTest {
@Test
fun transientFocusChangesDoNotUseTheUserPausePath() {
val source = voicePlaybackSource().readText()
val listener = source.functionBody("handleAudioFocusChange")

assertTrue("permanent focus loss must pause normally", "AUDIOFOCUS_LOSS -> pause()" in listener)
assertTrue(
"transient focus loss must retain focus for automatic resume",
"AUDIOFOCUS_LOSS_TRANSIENT -> pauseForTransientAudioFocusLoss()" in listener,
)
assertTrue(
"duck requests must lower volume instead of pausing",
"AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> duckForTransientAudioFocusLoss()" in listener,
)
assertTrue("focus gain must restore interrupted playback", "AUDIOFOCUS_GAIN -> restoreAfterAudioFocusGain()" in listener)
assertFalse("transient focus changes must not abandon the held request", "abandonFocus()" in listener)
}

@Test
fun transientPauseDuckAndGainPathsPreserveTheirIntent() {
val source = voicePlaybackSource().readText()
val transientPause = source.functionBody("pauseForTransientAudioFocusLoss")
val duck = source.functionBody("duckForTransientAudioFocusLoss")
val gain = source.functionBody("restoreAfterAudioFocusGain")

assertTrue("transient pause must remember to resume", "resumeOnAudioFocusGain = true" in transientPause)
assertFalse("transient pause must retain audio focus", "abandonFocus()" in transientPause)
assertTrue("duck must lower both channels", "setVolume(DUCK_VOLUME, DUCK_VOLUME)" in duck)
assertTrue("failed ducking must fall back to a resumable pause", "pauseForTransientAudioFocusLoss()" in duck)
assertTrue("gain must restore both channels", "setVolume(1f, 1f)" in gain)
assertTrue("gain must restart only an interrupted clip", "if (!resumeOnAudioFocusGain) return" in gain)
}

private fun voicePlaybackSource(): File =
listOf(
File("src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt"),
File("app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt"),
).firstOrNull(File::exists) ?: error("Missing VoicePlaybackController.kt")
}
Loading
Loading