Sync video mute with fullscreen volume swipe

This commit is contained in:
davotoula
2026-06-04 23:19:42 +02:00
parent f2df23cfa6
commit 54caab3520
4 changed files with 98 additions and 5 deletions
@@ -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)
}
}
}
@@ -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,
@@ -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
}
@@ -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))
}
}