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..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 @@ -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,29 @@ 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() + + // 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. + DisposableEffect(Unit) { + onDispose { swipeState.releaseBrightness(dialogWindow) } + } + WatchPlaybackErrors(controllerState) Box( @@ -115,7 +147,20 @@ fun RenderVideoPlayer( } }, ) - }, + }.then( + if (isFullscreen) { + Modifier.fullscreenSwipeControls( + state = swipeState, + audioManager = audioManager, + window = dialogWindow, + resolver = context.contentResolver, + isMuted = isVideoMuted, + setMuted = setVideoMuted, + ) + } else { + Modifier + }, + ), ) { ContentFrame( player = controllerState.controller, @@ -172,5 +217,9 @@ fun RenderVideoPlayer( isLiveStream = isLive, ) } + + if (isFullscreen) { + FullscreenSwipeLevelIndicator(swipeState, isMuted = isVideoMuted) + } } } 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..32585ceb51 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt @@ -0,0 +1,332 @@ +/* + * 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.runtime.snapshotFlow +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 +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.roundToInt + +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 + + // 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?, + window: Window?, + resolver: ContentResolver, + ) { + this.axis = axis + accumulatedDragPx = 0f + 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++ + } + + fun onDrag( + dragAmountPx: Float, + 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) } + 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) + 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++ + } + + 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?, + max: Int, +): Float { + audioManager ?: return 0f + 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 applyBrightness( + window: Window, + brightness: Float, +) { + val params = window.attributes + params.screenBrightness = brightness + 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, + 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 -> + 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, isMuted, setMuted) + }, + 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, + 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 + // 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") + 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 (isMuted() || 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..2e7999b072 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt @@ -0,0 +1,71 @@ +/* + * 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() +} + +/** 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/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 } 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..9eb703c166 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt @@ -0,0 +1,109 @@ +/* + * 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)) + } + + @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)) + } +} diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index f8902f46e3..733b19cde6 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ 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")