diff --git a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt index 79d9d32f0..977af58ed 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt @@ -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 @@ -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) { @@ -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 @@ -395,9 +393,98 @@ 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) @@ -405,19 +492,28 @@ object VoicePlaybackController { 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). */ @@ -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() } diff --git a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt index 3dd20b151..16ef32fb0 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt @@ -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 @@ -72,6 +73,13 @@ import java.io.IOException private val videoMaterializations = SingleFlight() +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 @@ -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) { @@ -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 = @@ -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) { @@ -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), diff --git a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt new file mode 100644 index 000000000..ea21eeec1 --- /dev/null +++ b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt @@ -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") +} diff --git a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt index 334c2a785..20db35a21 100644 --- a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt +++ b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt @@ -131,6 +131,145 @@ class VoicePlaybackControllerFocusTest { assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) } + @Test + fun transientLossRetainsFocusAndResumesTheInterruptedPlayerOnGain() { + val context = RuntimeEnvironment.getApplication() + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val heldFocusRequest = controllerField("focusRequest") + assertNotNull(heldFocusRequest) + val mediaPlayer = TrackingMediaPlayer() + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + assertFalse(mediaPlayer.playing) + assertFalse(VoicePlaybackController.state.value.isPlaying) + assertSame(heldFocusRequest, controllerField("focusRequest")) + assertTrue(controllerField("resumeOnAudioFocusGain") as Boolean) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) + + assertTrue(mediaPlayer.playing) + assertTrue(VoicePlaybackController.state.value.isPlaying) + assertSame(heldFocusRequest, controllerField("focusRequest")) + assertFalse(controllerField("resumeOnAudioFocusGain") as Boolean) + } + + @Test + fun duckableLossLowersVolumeAndGainRestoresItWithoutRestarting() { + val mediaPlayer = TrackingMediaPlayer() + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) + + assertTrue(mediaPlayer.playing) + assertEquals(0.2f, mediaPlayer.leftVolume, 0f) + assertEquals(0.2f, mediaPlayer.rightVolume, 0f) + assertEquals(0, mediaPlayer.startCount) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) + + assertEquals(1f, mediaPlayer.leftVolume, 0f) + assertEquals(1f, mediaPlayer.rightVolume, 0f) + assertEquals(0, mediaPlayer.startCount) + } + + @Test + fun failedDuckFallsBackToTransientPause() { + val mediaPlayer = TrackingMediaPlayer(failVolumeChange = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) + + assertFalse(mediaPlayer.playing) + assertFalse(VoicePlaybackController.state.value.isPlaying) + assertTrue(controllerField("resumeOnAudioFocusGain") as Boolean) + } + + @Test + fun failedTransientPauseReleasesBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val mediaPlayer = TrackingMediaPlayer(failPause = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + + @Test + fun failedUserPauseControlsReleaseBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + listOf( + TrackingMediaPlayer(failPlayingQuery = true), + TrackingMediaPlayer(failPause = true), + ).forEach { mediaPlayer -> + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + primeActivePlayer(mediaPlayer) + + VoicePlaybackController.pause() + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + } + + @Test + fun failedFocusLossStateQueriesReleaseBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + listOf( + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, + ).forEach { focusChange -> + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val mediaPlayer = TrackingMediaPlayer(failPlayingQuery = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(focusChange) + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + } + + @Test + fun failedVolumeRestoreReleasesBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + val restoreActions: List<() -> Unit> = + listOf( + { handleAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) }, + { VoicePlaybackController.pause() }, + ) + restoreActions.forEach { restoreAction -> + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val mediaPlayer = TrackingMediaPlayer(failVolumeRestore = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) + assertEquals(0.2f, mediaPlayer.leftVolume, 0f) + + restoreAction() + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + } + // Hit the private focus path directly; public playback needs MediaPlayer file // setup and would obscure the focus bookkeeping this regression protects. private fun requestFocus(): Boolean { @@ -149,6 +288,26 @@ class VoicePlaybackControllerFocusTest { return method.invoke(VoicePlaybackController, mediaPlayer) as Boolean } + private fun handleAudioFocusChange(change: Int) { + val method = VoicePlaybackController::class.java.getDeclaredMethod("handleAudioFocusChange", Integer.TYPE) + method.isAccessible = true + method.invoke(VoicePlaybackController, change) + } + + private fun primeActivePlayer(mediaPlayer: TrackingMediaPlayer) { + setControllerField("player", mediaPlayer) + setControllerField("currentKey", "voice-key") + setControllerField("currentOwnerKey", "owner") + setPlaybackState( + VoicePlaybackController.PlaybackState( + key = "voice-key", + isPlaying = true, + positionMs = mediaPlayer.positionMs, + durationMs = mediaPlayer.durationMs, + ), + ) + } + private fun controllerField(name: String): Any? { val field = VoicePlaybackController::class.java.getDeclaredField(name) field.isAccessible = true @@ -183,4 +342,57 @@ class VoicePlaybackControllerFocusTest { released = true } } + + private class TrackingMediaPlayer( + private val failPause: Boolean = false, + private val failVolumeChange: Boolean = false, + private val failVolumeRestore: Boolean = false, + private val failPlayingQuery: Boolean = false, + ) : MediaPlayer() { + var playing = true + var released = false + var leftVolume = 1f + var rightVolume = 1f + var startCount = 0 + val positionMs = 123 + val durationMs = 456 + + override fun isPlaying(): Boolean { + if (failPlayingQuery) throw IllegalStateException("isPlaying failed") + return playing + } + + override fun pause() { + if (failPause) throw IllegalStateException("pause failed") + playing = false + } + + override fun start() { + startCount += 1 + playing = true + } + + override fun stop() { + playing = false + } + + override fun getCurrentPosition(): Int = positionMs + + override fun getDuration(): Int = durationMs + + override fun setVolume( + leftVolume: Float, + rightVolume: Float, + ) { + if (failVolumeChange || (failVolumeRestore && leftVolume == 1f && rightVolume == 1f)) { + throw IllegalStateException("setVolume failed") + } + this.leftVolume = leftVolume + this.rightVolume = rightVolume + } + + override fun release() { + released = true + } + } } diff --git a/app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt new file mode 100644 index 000000000..7b0dd1c4d --- /dev/null +++ b/app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt @@ -0,0 +1,50 @@ +package dev.ipf.whitenoise.android.ui + +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 VideoPlaybackCoordinationCoverageTest { + @Test + fun everyVideoPlayerOwnsAudioFocusAndPausesVoicePlayback() { + val source = mediaVideoSource().readText() + + listOf("FullscreenVideoPlayer", "VideoViewerPage").forEach { functionName -> + val body = source.functionBody(functionName) + + assertTrue( + "$functionName must delegate audio focus to Media3", + "setAudioAttributes(videoPlaybackAudioAttributes, true)" in body, + ) + assertTrue( + "$functionName must stop voice-note audio before video playback", + "VoicePlaybackController.pause()" in body, + ) + } + } + + @Test + fun pagerVideoOnlyPreparesWhileItIsCurrent() { + val body = mediaVideoSource().readText().functionBody("VideoViewerPage") + val beforePlaybackEffect = body.substringBefore("LaunchedEffect(isCurrent, exo)") + val playbackEffect = + body + .substringAfter("LaunchedEffect(isCurrent, exo)") + .substringBefore("androidx.compose.ui.viewinterop.AndroidView") + + assertFalse( + "pre-composed neighbour pages must not eagerly prepare a decoder", + "prepare()" in beforePlaybackEffect, + ) + assertTrue("the current page must prepare its player", "if (isCurrent)" in playbackEffect && "exo.prepare()" in playbackEffect) + assertTrue("an off-screen page must release its decoder", "exo.stop()" in playbackEffect) + } + + private fun mediaVideoSource(): File = + listOf( + File("src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt"), + File("app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt"), + ).firstOrNull(File::exists) ?: error("Missing MediaVideo.kt") +}