mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
Sync video mute with fullscreen volume swipe
This commit is contained in:
+12
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-4
@@ -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,
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
|
||||
+31
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user