Code review:

- fix(player): harden fullscreen swipe controls and brightness lifecycle
This commit is contained in:
davotoula
2026-06-04 23:19:28 +02:00
parent 5edfe90321
commit f2df23cfa6
2 changed files with 69 additions and 25 deletions
@@ -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")
@@ -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
}