From 54caab3520e7c5646a2225abca6649bc58fa311e Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 4 Jun 2026 22:42:58 +0200 Subject: [PATCH] Sync video mute with fullscreen volume swipe --- .../playback/composable/RenderVideoPlayer.kt | 13 ++++++- .../controls/FullscreenSwipeControls.kt | 34 ++++++++++++++++--- .../controls/FullscreenSwipeMath.kt | 25 ++++++++++++++ .../controls/FullscreenSwipeMathTest.kt | 31 +++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 5c488fde80..2cae673cab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -111,6 +111,15 @@ fun RenderVideoPlayer( // Returns null for the inline feed player (not inside a dialog) — fine, gated by isFullscreen below. val dialogWindow = getDialogWindow() + // Sync the per-video mute (Media3 player volume) with the volume swipe. Mirrors the mute + // button's handler exactly, so its listener-driven icon flips when the swipe mutes/unmutes, + // and the global default carries to the next video. + val isVideoMuted = { controllerState.controller.volume < 0.001f } + val setVideoMuted = { mute: Boolean -> + DEFAULT_MUTED_SETTING.value = mute + controllerState.controller.volume = if (mute) 0f else 1f + } + // Belt-and-suspenders: clear any brightness override when this player leaves composition, so // exiting fullscreen never leaves the screen dimmed. releaseBrightness no-ops when dialogWindow // is null (the inline feed path) or when no override was applied, so this is safe unconditionally. @@ -145,6 +154,8 @@ fun RenderVideoPlayer( audioManager = audioManager, window = dialogWindow, resolver = context.contentResolver, + isMuted = isVideoMuted, + setMuted = setVideoMuted, ) } else { Modifier @@ -208,7 +219,7 @@ fun RenderVideoPlayer( } if (isFullscreen) { - FullscreenSwipeLevelIndicator(swipeState) + FullscreenSwipeLevelIndicator(swipeState, isMuted = isVideoMuted) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt index 4e63f5e9c7..32585ceb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt @@ -123,17 +123,36 @@ class FullscreenSwipeControlsState { heightPx: Float, audioManager: AudioManager?, window: Window?, + isMuted: () -> Boolean, + setMuted: (Boolean) -> Unit, ) { accumulatedDragPx += dragAmountPx level = computeLevel(dragStartLevel, accumulatedDragPx, heightPx) when (axis) { - SwipeAxis.Volume -> audioManager?.let { applyVolumeIfChanged(it) } + SwipeAxis.Volume -> { + audioManager?.let { applyVolumeIfChanged(it) } + applyMuteSync(isMuted, setMuted) + } SwipeAxis.Brightness -> window?.let { applyBrightnessIfChanged(it) } null -> Unit } interactionId++ } + // Directional mute sync: dragging up unmutes a muted video; reaching zero mutes it. Kept outside + // the AudioManager branch so per-video mute still tracks the gesture even if device volume is + // unavailable. + private fun applyMuteSync( + isMuted: () -> Boolean, + setMuted: (Boolean) -> Unit, + ) { + when (muteActionFor(level, movedUp = accumulatedDragPx < 0f, isMuted = isMuted())) { + MuteAction.Mute -> setMuted(true) + MuteAction.Unmute -> setMuted(false) + MuteAction.None -> Unit + } + } + private fun applyVolumeIfChanged(audioManager: AudioManager) { if (maxVolume <= 0) return val index = levelToVolumeIndex(level, maxVolume) @@ -220,7 +239,11 @@ fun Modifier.fullscreenSwipeControls( audioManager: AudioManager?, window: Window?, resolver: ContentResolver, + isMuted: () -> Boolean, + setMuted: (Boolean) -> Unit, ): Modifier = + // isMuted/setMuted are intentionally not pointerInput keys: they change identity every + // recomposition but read live state, so adding them would restart the gesture for no reason. pointerInput(state, audioManager, window, resolver) { detectVerticalDragGestures( onDragStart = { offset -> @@ -228,7 +251,7 @@ fun Modifier.fullscreenSwipeControls( state.startDrag(axis, audioManager, window, resolver) }, onVerticalDrag = { _, dragAmount -> - state.onDrag(dragAmount, size.height.toFloat(), audioManager, window) + state.onDrag(dragAmount, size.height.toFloat(), audioManager, window, isMuted, setMuted) }, onDragEnd = { state.endDrag() }, onDragCancel = { state.endDrag() }, @@ -237,7 +260,10 @@ fun Modifier.fullscreenSwipeControls( /** Centered ring + glyph that appears while swiping and fades out shortly after the drag ends. */ @Composable -fun BoxScope.FullscreenSwipeLevelIndicator(state: FullscreenSwipeControlsState) { +fun BoxScope.FullscreenSwipeLevelIndicator( + state: FullscreenSwipeControlsState, + isMuted: () -> Boolean, +) { // Launch once on the stable state and watch interactionId via a snapshotFlow instead of keying // the effect on it: collectLatest restarts the auto-hide delay on each drag event, and reading // interactionId here (not as a composition key) avoids re-keying the effect every frame. The @@ -294,7 +320,7 @@ fun BoxScope.FullscreenSwipeLevelIndicator(state: FullscreenSwipeControlsState) when (axis) { SwipeAxis.Brightness -> MaterialSymbols.BrightnessMedium SwipeAxis.Volume -> - if (level <= 0f) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp + if (isMuted() || level <= 0f) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp } Icon( symbol = symbol, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt index acb4fc1677..2e7999b072 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt @@ -44,3 +44,28 @@ fun levelToVolumeIndex( if (max <= 0) return 0 return (level.coerceIn(0f, 1f) * max).roundToInt() } + +/** The mute change a volume swipe should trigger on the per-video player. */ +enum class MuteAction { Mute, Unmute, None } + +/** + * Directional mute sync for the volume swipe. + * + * - Reaching zero mutes the video (no-op if already muted). + * - Dragging the finger up ([movedUp], i.e. net upward from where the drag started) unmutes a muted + * video — so a muted video pinned at max device volume still unmutes even though [level] can't rise. + * - A downward swipe that stays above zero leaves the mute state untouched. + * + * [movedUp] is the drag direction, not a level comparison, so the clamp at level 1.0 doesn't swallow + * the unmute intent. + */ +fun muteActionFor( + level: Float, + movedUp: Boolean, + isMuted: Boolean, +): MuteAction = + when { + level <= 0f -> if (isMuted) MuteAction.None else MuteAction.Mute + isMuted && movedUp -> MuteAction.Unmute + else -> MuteAction.None + } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt index 1bca4e82fd..9eb703c166 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt @@ -75,4 +75,35 @@ class FullscreenSwipeMathTest { fun volumeIndexZeroMaxGuard() { assertEquals(0, levelToVolumeIndex(0.5f, 0)) } + + @Test + fun reachingZeroWhileUnmutedMutes() { + assertEquals(MuteAction.Mute, muteActionFor(level = 0f, movedUp = false, isMuted = false)) + } + + @Test + fun reachingZeroWhileMutedIsNoop() { + assertEquals(MuteAction.None, muteActionFor(level = 0f, movedUp = false, isMuted = true)) + } + + @Test + fun movingUpWhileMutedUnmutes() { + assertEquals(MuteAction.Unmute, muteActionFor(level = 0.5f, movedUp = true, isMuted = true)) + } + + @Test + fun movingUpAtMaxWhileMutedStillUnmutes() { + // Device volume pinned at 1.0 can't rise, but the upward drag still expresses intent. + assertEquals(MuteAction.Unmute, muteActionFor(level = 1f, movedUp = true, isMuted = true)) + } + + @Test + fun movingUpWhileUnmutedIsNoop() { + assertEquals(MuteAction.None, muteActionFor(level = 0.5f, movedUp = true, isMuted = false)) + } + + @Test + fun movingDownAboveZeroWhileMutedIsNoop() { + assertEquals(MuteAction.None, muteActionFor(level = 0.5f, movedUp = false, isMuted = true)) + } }