From 5edfe90321bc1f2147f67eaff4b0530c617eb0d7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 4 Jun 2026 19:34:58 +0200 Subject: [PATCH 1/3] feat(player): enable brightness/volume swipe in fullscreen video --- .../playback/composable/RenderVideoPlayer.kt | 40 ++- .../playback/composable/VideoViewInner.kt | 1 + .../controls/FullscreenSwipeControls.kt | 268 ++++++++++++++++++ .../controls/FullscreenSwipeMath.kt | 46 +++ .../controls/FullscreenSwipeMathTest.kt | 78 +++++ .../font/material_symbols_outlined.ttf | Bin 435080 -> 437648 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt 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 77a390d21b..5c488fde80 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 @@ -20,11 +20,14 @@ */ package com.vitorpamplona.amethyst.service.playback.composable +import android.content.Context +import android.media.AudioManager import androidx.annotation.OptIn import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,18 +37,23 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay +import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeControlsState +import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeLevelIndicator import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay +import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming +import com.vitorpamplona.amethyst.ui.components.getDialogWindow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel internal const val SKIP_SECONDS = 10 @@ -87,6 +95,7 @@ fun RenderVideoPlayer( onDialog: (() -> Unit)? = null, controllerVisible: MutableState = remember { mutableStateOf(false) }, hasBlurhash: Boolean = false, + isFullscreen: Boolean = false, accountViewModel: AccountViewModel, ) { // Hold the container size in a non-state holder so layout passes don't trigger an @@ -95,6 +104,20 @@ fun RenderVideoPlayer( val containerWidth = remember { intArrayOf(0) } val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) } + val swipeState = remember { FullscreenSwipeControlsState() } + val context = LocalContext.current + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } + // Brightness is applied to the fullscreen dialog window so it auto-reverts on dismiss. + // Returns null for the inline feed player (not inside a dialog) — fine, gated by isFullscreen below. + val dialogWindow = getDialogWindow() + + // 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. + DisposableEffect(Unit) { + onDispose { swipeState.releaseBrightness(dialogWindow) } + } + WatchPlaybackErrors(controllerState) Box( @@ -115,7 +138,18 @@ fun RenderVideoPlayer( } }, ) - }, + }.then( + if (isFullscreen) { + Modifier.fullscreenSwipeControls( + state = swipeState, + audioManager = audioManager, + window = dialogWindow, + resolver = context.contentResolver, + ) + } else { + Modifier + }, + ), ) { ContentFrame( player = controllerState.controller, @@ -172,5 +206,9 @@ fun RenderVideoPlayer( isLiveStream = isLive, ) } + + if (isFullscreen) { + FullscreenSwipeLevelIndicator(swipeState) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 46cccd28ce..a9a1c43924 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -107,6 +107,7 @@ fun VideoViewInner( controllerVisible = controllerVisible, onDialog = onZoom, hasBlurhash = hasBlurhash, + isFullscreen = isFullscreen, accountViewModel = accountViewModel, ) } 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 new file mode 100644 index 0000000000..a3bbaf1ee1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import android.content.ContentResolver +import android.media.AudioManager +import android.provider.Settings +import android.view.Window +import android.view.WindowManager +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import kotlinx.coroutines.delay + +enum class SwipeAxis { Brightness, Volume } + +private const val BRIGHTNESS_FLOOR = 0.01f +private const val AUTO_HIDE_MILLIS = 800L + +/** + * Holds the live state for the fullscreen brightness/volume swipe. A single instance is remembered + * by RenderVideoPlayer and shared between the drag [Modifier] and the [FullscreenSwipeLevelIndicator] + * overlay. All device side-effects (AudioManager / window brightness) are applied here. + */ +class FullscreenSwipeControlsState { + var axis by mutableStateOf(null) + private set + var level by mutableFloatStateOf(0f) + private set + var visible by mutableStateOf(false) + private set + + // Bumped on every drag event and on drag end; the overlay keys its auto-hide timer on this so + // the timer restarts while dragging and fires AUTO_HIDE_MILLIS after the last event. + var interactionId by mutableIntStateOf(0) + private set + + private var dragStartLevel = 0f + private var accumulatedDragPx = 0f + + fun startDrag( + axis: SwipeAxis, + audioManager: AudioManager?, + window: Window?, + resolver: ContentResolver, + ) { + this.axis = axis + accumulatedDragPx = 0f + dragStartLevel = + when (axis) { + SwipeAxis.Volume -> currentVolumeFraction(audioManager) + SwipeAxis.Brightness -> currentBrightnessFraction(window, resolver) + } + level = dragStartLevel + visible = true + interactionId++ + } + + fun onDrag( + dragAmountPx: Float, + heightPx: Float, + audioManager: AudioManager?, + window: Window?, + ) { + accumulatedDragPx += dragAmountPx + level = computeLevel(dragStartLevel, accumulatedDragPx, heightPx) + when (axis) { + SwipeAxis.Volume -> audioManager?.let { applyVolume(it, level) } + SwipeAxis.Brightness -> window?.let { applyBrightness(it, level) } + null -> Unit + } + interactionId++ + } + + fun endDrag() { + interactionId++ + } + + fun hide() { + visible = false + } + + /** + * Clears any brightness override this controller applied, restoring the window to the system + * brightness. Call from the fullscreen player's onDispose so leaving fullscreen never leaves + * the screen dimmed. + */ + fun releaseBrightness(window: Window?) { + window?.let { releaseBrightnessOverride(it) } + } +} + +private fun currentVolumeFraction(audioManager: AudioManager?): Float { + audioManager ?: return 0f + val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (max <= 0) return 0f + return audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max +} + +private fun currentBrightnessFraction( + window: Window?, + resolver: ContentResolver, +): Float { + val override = window?.attributes?.screenBrightness ?: -1f + if (override in 0f..1f) return override + val system = + try { + Settings.System.getInt(resolver, Settings.System.SCREEN_BRIGHTNESS) + } catch (e: Settings.SettingNotFoundException) { + 128 + } + return (system / 255f).coerceIn(0f, 1f) +} + +private fun applyVolume( + audioManager: AudioManager, + level: Float, +) { + val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (max <= 0) return + // Flag 0 = no system volume UI; we draw our own ring. + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, levelToVolumeIndex(level, max), 0) +} + +private fun applyBrightness( + window: Window, + level: Float, +) { + val params = window.attributes + params.screenBrightness = level.coerceIn(BRIGHTNESS_FLOOR, 1f) + window.attributes = params +} + +private fun releaseBrightnessOverride(window: Window) { + val params = window.attributes + params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + window.attributes = params +} + +/** + * Vertical-drag handler for the fullscreen video surface. Left half of the surface controls + * brightness, right half controls volume. Must be a separate [pointerInput] from the existing + * tap/double-tap handler so taps still work. + */ +fun Modifier.fullscreenSwipeControls( + state: FullscreenSwipeControlsState, + audioManager: AudioManager?, + window: Window?, + resolver: ContentResolver, +): Modifier = + pointerInput(state, audioManager, window, resolver) { + detectVerticalDragGestures( + onDragStart = { offset -> + val axis = if (offset.x < size.width / 2f) SwipeAxis.Brightness else SwipeAxis.Volume + state.startDrag(axis, audioManager, window, resolver) + }, + onVerticalDrag = { _, dragAmount -> + state.onDrag(dragAmount, size.height.toFloat(), audioManager, window) + }, + onDragEnd = { state.endDrag() }, + onDragCancel = { state.endDrag() }, + ) + } + +/** Centered ring + glyph that appears while swiping and fades out shortly after the drag ends. */ +@Composable +fun BoxScope.FullscreenSwipeLevelIndicator(state: FullscreenSwipeControlsState) { + LaunchedEffect(state.interactionId) { + if (state.visible) { + delay(AUTO_HIDE_MILLIS) + state.hide() + } + } + + val alpha by animateFloatAsState(if (state.visible) 1f else 0f, label = "swipeIndicatorAlpha") + if (alpha <= 0f) return + + val axis = state.axis ?: return + val level = state.level + val ringColor = MaterialTheme.colorScheme.onBackground + val trackColor = ringColor.copy(alpha = 0.25f) + val backdrop = MaterialTheme.colorScheme.background.copy(alpha = 0.5f) + + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(110.dp) + .alpha(alpha) + .clip(CircleShape) + .background(backdrop), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) { + val stroke = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round) + drawArc( + color = trackColor, + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + style = stroke, + ) + drawArc( + color = ringColor, + startAngle = -90f, + sweepAngle = 360f * level.coerceIn(0f, 1f), + useCenter = false, + style = stroke, + ) + } + + val symbol = + when (axis) { + SwipeAxis.Brightness -> MaterialSymbols.BrightnessMedium + SwipeAxis.Volume -> + if (level <= 0f) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp + } + Icon( + symbol = symbol, + contentDescription = null, + tint = ringColor, + modifier = Modifier.size(36.dp), + ) + } +} 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 new file mode 100644 index 0000000000..acb4fc1677 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import kotlin.math.roundToInt + +/** + * Maps a vertical drag to a 0..1 level. Dragging up (negative accumulated pixels) increases the + * level; dragging down decreases it. A drag spanning the full element height covers the entire + * 0..1 range. The result is clamped to 0..1. + */ +fun computeLevel( + startLevel: Float, + accumulatedDragPx: Float, + heightPx: Float, +): Float { + if (heightPx <= 0f) return startLevel.coerceIn(0f, 1f) + return (startLevel - accumulatedDragPx / heightPx).coerceIn(0f, 1f) +} + +/** Clamps [level] to 0..1, then rounds it to a discrete stream-volume index in 0..max. Returns 0 when max <= 0. */ +fun levelToVolumeIndex( + level: Float, + max: Int, +): Int { + if (max <= 0) return 0 + return (level.coerceIn(0f, 1f) * max).roundToInt() +} 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 new file mode 100644 index 0000000000..1bca4e82fd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FullscreenSwipeMathTest { + @Test + fun dragUpIncreasesLevel() { + // Drag up 500px (negative) on a 1000px screen from 0.2 -> +0.5 = 0.7 + assertEquals(0.7f, computeLevel(0.2f, -500f, 1000f), 0.0001f) + } + + @Test + fun dragDownDecreasesLevel() { + assertEquals(0.3f, computeLevel(0.8f, 500f, 1000f), 0.0001f) + } + + @Test + fun clampsToOne() { + assertEquals(1f, computeLevel(0.9f, -500f, 1000f), 0.0001f) + } + + @Test + fun clampsToZero() { + assertEquals(0f, computeLevel(0.1f, 500f, 1000f), 0.0001f) + } + + @Test + fun zeroHeightReturnsStartClamped() { + assertEquals(0.5f, computeLevel(0.5f, -100f, 0f), 0.0001f) + } + + @Test + fun zeroHeightClampsOutOfRangeStartLevel() { + assertEquals(1f, computeLevel(1.5f, -100f, 0f), 0.0001f) + assertEquals(0f, computeLevel(-0.3f, 100f, 0f), 0.0001f) + } + + @Test + fun volumeIndexExactMidpoint() { + assertEquals(5, levelToVolumeIndex(0.5f, 10)) + } + + @Test + fun volumeIndexFull() { + assertEquals(15, levelToVolumeIndex(1f, 15)) + } + + @Test + fun volumeIndexZeroLevel() { + assertEquals(0, levelToVolumeIndex(0f, 15)) + } + + @Test + fun volumeIndexZeroMaxGuard() { + assertEquals(0, levelToVolumeIndex(0.5f, 0)) + } +} diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index f8902f46e3d0d565bbb138b9204bcdf565d42a4e..733b19cde6283483dd86d761b7043208ddf0dd99 100644 GIT binary patch delta 3972 zcmbW1dvsGp9>>3P@7$zGLz>zo4QZgUrM8L+NK08krB5mk=>vK=qC#yD)E2w)SXfun z&~9NBS@&F4JuaehfCH;y*Xp5y4?KddEV2ryh1J?sg<&SN7WDW57ct8lu zsTg-tUfIHFKx;kCT2wJNZ|=J870&=|S=2vJwW{i2n0{!czMuM~)wNZ1PftHIhx8qQ zuKYf-KXJZxH#t-F{v}n*y>;}Dg&e*}ZQPQYHTS)ALfQ{VR^Zk7OK8IJ$62{}bAMxI9Zh>18}~N#*A**QR(Lje20z!JrD^Z#l{;2O zP7Rto8@ee})w6fNbt<{O!m)n1OD&Mgy~eERQ6q@;YK<{wQ3T|&6fcT(q8mv_$3!5v z#o%kao_Sg}zM3a^ubWmq?fGf%ng#eLDMw*$ZaxpB=bk)g2$sshqRBG{5xavXmKR6=lzq z9W1}CTrU5r!ccKf#S3#&<}RE2{ycHs>Ul>i$5cL8`3l{+n(o56zuz|c;>v{yQ!m5` z;(i*z=}G5DYi+KxyJb`J6hWl(Z8`G#EiC?4np8v6^r(^TZ%kpkTT0)kWz@X==0tY2 z;tsl}xkK(A_s-M^rBs)?N@{27DYrFsR_c51M`X($yDrr;`oYu<@^yPs=IBOkbp7O7 z=6ce#W0b@7m}@fNI#0#s%5jZMsY|h@Q0k{7q>NG*az>mXXJ<-} zSw1p6@`aIiJO1god1So2Y0tGujgD2xio=)uL2?R^+?!nB$d*6dlRhqF|A#$nUt<5* zo@HBOE46l7J8gfkO|U+Zm|*!Nu{!a;mTJ|DB?KgVXYtEvtr>>w1ebX$5Z@t}wkGT8 zS+Tp=BKfzi8FGEA)be!eST!-oh4a2IN`qTuu42?41Jw7petM%T4m zS-N7`;^oU1)-Lwkzp~bT#YoG?pL`(N7?`5a+oKj{WP#o+n=fzuYzkW^w}19oij$3| z5Yt%(%VgIw3Y(2%6WC;SGn*bc1bNi4F$Q7+{Tvj{jAI7`dHlOvHh=Li z`QVq8hMfcqx_@0=%b2!1xHoG(_}G)+?;E;;rB_CxOrD11~=WKF4C-)NXy=awEQHbRrQd3G}!bbq>ZZ~{lx|8*)5PZ)7bVVNW0o0z4kby z77l4&Go<}wJNO2qk557ReX0?OPB*SPIelBpyh)164Ijjr8*Y`2cuIMPToH=bINY*s2}Di$hNk$waf(?|z| zfghuvK!2A$cz0lPp<+=i(O1-3*bGl0y3x(%sx>?f+w*_XB6zSlPo2?_Y`?U+C=4x3 z9_r()PAmMU{h?l;F&gL%L?IYWM#0`c3acs9Yw{WeT(a4$xD=Nbpc*UZygsR?<9J68 z%mWlNFgm$D|rLIK-UTxvH zNM9P-;Yv|w(N_ipm*2^p)opqs5fSpWaDt_;h);t5U>5~J&f$E#Gl$wpA>3uKiS?Mf|Qn)nU*FY z)Y}{KnR|MBPxtoH?oB4A$z+x@F4wfTn@kcZRU2ZoGIp2+BOSMPzI6D3Xv6k|*e+d$ zv4QU291RBhfUWY;#}gK$r+bvXpY&eBc*DCs`U=BM~qy(uYfE)X1n}k?2T#TZ`ygwHDUeF1>DIpL*y4MwzV8b+9G~ zO*ZW{47`8!b+aMwZ4a zKr)Ds0ck&28i)znHBqgswJF3JKY>ZnnazYAy~0G1DGgAX=(8o6&DA<}c~t_Egu@XM l&_hTcj*u80XnatS(x?$679hjX(GQe$k1}b`qf8gS^?yuY8fO3i delta 1373 zcmX|B3rv$&6#j0x7evq&?F0V)Kz$$*Wk`)kMCDnYl@U8sk4~!071XIZt9aAGt-7YTKpPYOC^PTgZ$Gtaq=N+N? zoL~n5;KLURMUo{uPHNfvIdHWeAXUjZv6&yu-Jk(_ML;%evV`elGnT~wPVUEYOIB=_ z<4O5m;HHAxJ=V3>58(Z%6}WYp+vfZ-tL<9oH&HzPDd3)3$o<>?Dm%jCuXF$JMbH@DJrcfEU8_$j~C|!TS|*KVK7uZ3+PjTU0hqd!dfsl$|wg~M|ly&9PoJH zq2dKb?Wx6OFRu|V|XbA4!^a_tm|#0k`!>en#b!_Sp&*e+^vwBcP0SD%l;o^G@ZSnJkawW3sfcTkWiE3zpe~W0GR_#$1Sf zH?}EuC{7cX9`}CSfw*h&Q{zkGdlEbnW+zl8oKJXekQ;&wdPBOwYS?I~Gjtd(86Fv) zCweEACZ0@sCuwKW&1AP^L-LxG2`PCgS5hNWKTI7=i%hFaYd20d<{I0LgNsrZZB9qJ zIsLvV()6==j`^VZ*NjPt8K#V%GW|0PGo?(ICB$;j@**oOYkRgVdr9_@>}NR%@0j*r?Kbl2&E&MIGv>8H@AP8FFE58w8j$rnC z>9^l+vF~}`d48T!RcFY=kA2HE|M=Q9KWMao=BXy$S1+CEoDw#o{#N}`U8Fvzp027; znUyZ3OSM52uH5D$SN!6W?{iPVPm6$^tg^(G@BNK>{1Q1a=b1G`C39U%R0W{$r?ve~*!E6kX6tWoM zm`~n=jYOjvBKk-WZj)G?z!zxXcT^-2A>mT1>>yt|Wzo`Zw|Z$#w~pjFbGmzE&VVxw zKt>)A;1Mu&ATcA9UeQ+u#GXvlcbur^SA1L%TF zpmBMiNmD^no`V|afu{3EA#)#Sb{6OoCFt^8(86lam9IcoodsRXu?-QRniA!wTo^f*s` zvIX?QFz6K>=&dE7zl{YQf>O61z?jo9@+<{ ze*?DgQ?P_$Fw-@#9B(jdE7%Gb*h=>pu(B4g3Jz`(z$y=bRVRbhT>z`+SVJ4wmt(=2 ykAZzX3#{!fSUdN1-Us_$0rungtpojzfque8#>K6Dpr2lz;&6Dec88a{XVbsMMVRdX diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index cf17505499..88c122bfdd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -46,6 +46,7 @@ object MaterialSymbols { val BookmarkAdd = MaterialSymbol("\uE598") val BookmarkBorder = MaterialSymbol("\uE8E7") val BookmarkRemove = MaterialSymbol("\uE59A") + val BrightnessMedium = MaterialSymbol("\uE1AE") val Forward10 = MaterialSymbol("\uE056") val Replay10 = MaterialSymbol("\uE059") val CalendarMonth = MaterialSymbol("\uEBCC") From f2df23cfa664022e958eee73d613debe99b9ec27 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 4 Jun 2026 20:52:35 +0200 Subject: [PATCH 2/3] Code review: - fix(player): harden fullscreen swipe controls and brightness lifecycle --- .../controls/FullscreenSwipeControls.kt | 88 +++++++++++++------ .../ui/components/ZoomableContentDialog.kt | 6 ++ 2 files changed, 69 insertions(+), 25 deletions(-) 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 a3bbaf1ee1..4e63f5e9c7 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 @@ -43,6 +43,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -54,6 +55,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.roundToInt enum class SwipeAxis { Brightness, Volume } @@ -81,6 +84,16 @@ class FullscreenSwipeControlsState { private var dragStartLevel = 0f private var accumulatedDragPx = 0f + // Max volume is device-invariant; cache it at drag start so the per-frame drag path doesn't + // re-query AudioManager on every pointer event. + private var maxVolume = 0 + + // Last discrete value actually pushed to the device this drag. Continuous finger movement maps + // to the same volume index / brightness step across several frames; skipping unchanged writes + // avoids redundant AudioManager calls and window-attribute relayouts with no visible effect. + private var lastVolumeIndex = -1 + private var lastBrightnessStep = -1 + fun startDrag( axis: SwipeAxis, audioManager: AudioManager?, @@ -89,11 +102,17 @@ class FullscreenSwipeControlsState { ) { this.axis = axis accumulatedDragPx = 0f - dragStartLevel = - when (axis) { - SwipeAxis.Volume -> currentVolumeFraction(audioManager) - SwipeAxis.Brightness -> currentBrightnessFraction(window, resolver) + when (axis) { + SwipeAxis.Volume -> { + maxVolume = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 0 + lastVolumeIndex = -1 + dragStartLevel = currentVolumeFraction(audioManager, maxVolume) } + SwipeAxis.Brightness -> { + lastBrightnessStep = -1 + dragStartLevel = currentBrightnessFraction(window, resolver) + } + } level = dragStartLevel visible = true interactionId++ @@ -108,13 +127,32 @@ class FullscreenSwipeControlsState { accumulatedDragPx += dragAmountPx level = computeLevel(dragStartLevel, accumulatedDragPx, heightPx) when (axis) { - SwipeAxis.Volume -> audioManager?.let { applyVolume(it, level) } - SwipeAxis.Brightness -> window?.let { applyBrightness(it, level) } + SwipeAxis.Volume -> audioManager?.let { applyVolumeIfChanged(it) } + SwipeAxis.Brightness -> window?.let { applyBrightnessIfChanged(it) } null -> Unit } interactionId++ } + private fun applyVolumeIfChanged(audioManager: AudioManager) { + if (maxVolume <= 0) return + val index = levelToVolumeIndex(level, maxVolume) + if (index == lastVolumeIndex) return + lastVolumeIndex = index + // Flag 0 = no system volume UI; we draw our own ring. + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0) + } + + private fun applyBrightnessIfChanged(window: Window) { + val target = level.coerceIn(BRIGHTNESS_FLOOR, 1f) + // Quantize to the panel's 0..255 range; sub-step movement is imperceptible and would + // otherwise reassign window.attributes (a relayout) every frame for no visible change. + val step = (target * 255f).roundToInt() + if (step == lastBrightnessStep) return + lastBrightnessStep = step + applyBrightness(window, target) + } + fun endDrag() { interactionId++ } @@ -133,9 +171,11 @@ class FullscreenSwipeControlsState { } } -private fun currentVolumeFraction(audioManager: AudioManager?): Float { +private fun currentVolumeFraction( + audioManager: AudioManager?, + max: Int, +): Float { audioManager ?: return 0f - val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) if (max <= 0) return 0f return audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max } @@ -155,22 +195,12 @@ private fun currentBrightnessFraction( return (system / 255f).coerceIn(0f, 1f) } -private fun applyVolume( - audioManager: AudioManager, - level: Float, -) { - val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - if (max <= 0) return - // Flag 0 = no system volume UI; we draw our own ring. - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, levelToVolumeIndex(level, max), 0) -} - private fun applyBrightness( window: Window, - level: Float, + brightness: Float, ) { val params = window.attributes - params.screenBrightness = level.coerceIn(BRIGHTNESS_FLOOR, 1f) + params.screenBrightness = brightness window.attributes = params } @@ -208,11 +238,19 @@ fun Modifier.fullscreenSwipeControls( /** Centered ring + glyph that appears while swiping and fades out shortly after the drag ends. */ @Composable fun BoxScope.FullscreenSwipeLevelIndicator(state: FullscreenSwipeControlsState) { - LaunchedEffect(state.interactionId) { - if (state.visible) { - delay(AUTO_HIDE_MILLIS) - state.hide() - } + // 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 + // indicator still recomposes per frame to redraw the arc as level changes — fine for a transient + // drag overlay. + LaunchedEffect(state) { + snapshotFlow { state.interactionId } + .collectLatest { + if (state.visible) { + delay(AUTO_HIDE_MILLIS) + state.hide() + } + } } val alpha by animateFloatAsState(if (state.visible) 1f else 0f, label = "swipeIndicatorAlpha") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index e53321661e..93ab093d99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -191,12 +191,18 @@ fun ZoomableImageDialog( val dialogWindow = getDialogWindow() if (activityWindow != null && dialogWindow != null) { + // Preserve any brightness override already applied to the dialog window (e.g. by the + // fullscreen swipe controls). This block re-runs on recomposition (orientation change + // re-reads `orientation` above), and copying the activity attributes would otherwise + // reset screenBrightness and snap the user's brightness back mid-session. + val currentBrightness = dialogWindow.attributes.screenBrightness val attributes = WindowManager.LayoutParams() attributes.copyFrom(activityWindow.attributes) attributes.type = dialogWindow.attributes.type // Disable the system dim so the thumbnail stays visible behind the growing dialog. attributes.dimAmount = 0f attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() + attributes.screenBrightness = currentBrightness dialogWindow.attributes = attributes } From 54caab3520e7c5646a2225abca6649bc58fa311e Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 4 Jun 2026 22:42:58 +0200 Subject: [PATCH 3/3] 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)) + } }