feat(player): add sticky auto-brightness zone with hysteresis

Refactor brightness drag to use a normal 0-1 sliding range with a
sticky auto-brightness zone at the bottom. Enter/exit auto mode via
hysteresis thresholds (15% overshoot past 0%) so accidental drags
don't toggle the mode.

Lock the current system brightness on screen open (reading the actual
value even when in auto mode) and restore the original window brightness
on ON_STOP.

Add vertical drag lifecycle callbacks (start/end) to PlayerGesturesLayer
so overshoot can be reset per gesture. Extract drag handlers into local
functions for readability.
This commit is contained in:
2026-07-21 17:01:35 +00:00
parent a4bb57822a
commit 869bb27497
2 changed files with 106 additions and 29 deletions

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.provider.Settings
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -67,8 +68,9 @@ import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
/** Small negative band below zero that represents adaptive/auto brightness mode. */ private const val BRIGHTNESS_DRAG_SENSITIVITY = 800f
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f private const val BRIGHTNESS_AUTO_ENTER_THRESHOLD = 0.15f
private const val BRIGHTNESS_AUTO_EXIT_THRESHOLD = 0.15f
private const val CONTROLS_AUTO_HIDE_MS = 3_000L private const val CONTROLS_AUTO_HIDE_MS = 3_000L
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@@ -90,7 +92,11 @@ fun PlayerScreen(
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) } var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) } var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity, context)) }
var isAutoBrightness by remember { mutableStateOf(false) }
var dragOvershoot by remember { mutableFloatStateOf(0f) }
var autoDragTick by remember { mutableStateOf(0) }
val originalScreenBrightness by remember { mutableFloatStateOf(activity?.window?.attributes?.screenBrightness ?: -1f) }
var showQueuePanel by remember { mutableStateOf(false) } var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) } var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) } var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
@@ -131,9 +137,61 @@ fun PlayerScreen(
toggleControlsVisibility() toggleControlsVisibility()
hideControlsWithTimeout() hideControlsWithTimeout()
} }
fun onBrightnessDragStart(isLeftSide: Boolean) {
if (isLeftSide) {
dragOvershoot = 0f
}
}
fun onBrightnessDragEnd() {
dragOvershoot = 0f
}
fun onBrightnessDrag(delta: Float) {
val diff = -delta / BRIGHTNESS_DRAG_SENSITIVITY
if (isAutoBrightness) {
if (diff > 0f) {
dragOvershoot += diff
if (dragOvershoot >= BRIGHTNESS_AUTO_EXIT_THRESHOLD) {
isAutoBrightness = false
brightness = (dragOvershoot - BRIGHTNESS_AUTO_EXIT_THRESHOLD).coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
} else {
autoDragTick++
}
} else {
val newBrightness = brightness + diff
if (newBrightness <= 0f) {
brightness = 0f
applyBrightness(activity, brightness, isAuto = false)
dragOvershoot += newBrightness
if (dragOvershoot <= -BRIGHTNESS_AUTO_ENTER_THRESHOLD) {
isAutoBrightness = true
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = true)
}
} else {
brightness = newBrightness.coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
}
}
fun onVolumeDrag(delta: Float) {
val diff = -delta / 800f
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
}
LifecycleEventEffect(Lifecycle.Event.ON_START) { LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout() hideControlsWithTimeout()
} }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
restoreBrightness(activity, originalScreenBrightness)
}
Box( Box(
@@ -169,20 +227,10 @@ fun PlayerScreen(
onDoubleTapRight = { viewModel.seekBy(30_000) }, onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) }, onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = { viewModel.togglePlayPause() }, onDoubleTapCenter = { viewModel.togglePlayPause() },
onVerticalDragLeft = { delta -> onVerticalDragStart = ::onBrightnessDragStart,
val diff = (-delta / 800f) onVerticalDragEnd = ::onBrightnessDragEnd,
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f) onVerticalDragLeft = ::onBrightnessDrag,
applyBrightness(activity, brightness) onVerticalDragRight = ::onVolumeDrag,
},
onVerticalDragRight = { delta ->
val diff = (-delta / 800f)
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
},
onHorizontalDragPreview = { deltaMs, previewPositionMs -> onHorizontalDragPreview = { deltaMs, previewPositionMs ->
horizontalSeekFeedback = deltaMs horizontalSeekFeedback = deltaMs
horizontalSeekPreviewPositionMs = previewPositionMs?.let { horizontalSeekPreviewPositionMs = previewPositionMs?.let {
@@ -230,23 +278,21 @@ fun PlayerScreen(
} }
ValueChangeTimedVisibility( ValueChangeTimedVisibility(
value = brightness, value = BrightnessUiState(brightness, isAutoBrightness, autoDragTick),
hideAfterMillis = 800, hideAfterMillis = 800,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.padding(start = 20.dp) .padding(start = 20.dp)
) { currentBrightness -> ) { state ->
PlayerAdjustmentIndicator( PlayerAdjustmentIndicator(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
icon = Icons.Outlined.BrightnessMedium, icon = Icons.Outlined.BrightnessMedium,
contentDescription = "Brightness", contentDescription = "Brightness",
value = currentBrightness, value = state.brightness,
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) { bottomText = if (state.isAuto) {
"Auto" "Auto"
} else if (currentBrightness >= 0f) {
"${(currentBrightness * 100).roundToInt()}%"
} else { } else {
null "${(state.brightness * 100).roundToInt()}%"
} }
) )
} }
@@ -434,14 +480,34 @@ private fun formatSeekDelta(deltaMs: Long): String {
} }
} }
private fun readCurrentBrightness(activity: Activity?): Float { private data class BrightnessUiState(val brightness: Float, val isAuto: Boolean, val dragTick: Int)
val current = activity?.window?.attributes?.screenBrightness
return if (current != null && current >= 0) current else -1f private fun readCurrentBrightness(activity: Activity?, context: Context): Float {
val windowBrightness = activity?.window?.attributes?.screenBrightness
if (windowBrightness != null && windowBrightness >= 0f) {
return windowBrightness
}
return try {
val systemBrightness = Settings.System.getInt(
context.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
(systemBrightness / 255f).coerceIn(0f, 1f)
} catch (e: Settings.SettingNotFoundException) {
0.5f
}
} }
private fun applyBrightness(activity: Activity?, value: Float) { private fun applyBrightness(activity: Activity?, brightness: Float, isAuto: Boolean) {
activity ?: return activity ?: return
val params = activity.window.attributes val params = activity.window.attributes
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value params.screenBrightness = if (isAuto) -1f else brightness
activity.window.attributes = params
}
private fun restoreBrightness(activity: Activity?, originalValue: Float) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = originalValue
activity.window.attributes = params activity.window.attributes = params
} }

View File

@@ -30,6 +30,8 @@ fun PlayerGesturesLayer(
onDoubleTapLeft: () -> Unit, onDoubleTapLeft: () -> Unit,
onVerticalDragLeft: (delta: Float) -> Unit, onVerticalDragLeft: (delta: Float) -> Unit,
onVerticalDragRight: (delta: Float) -> Unit, onVerticalDragRight: (delta: Float) -> Unit,
onVerticalDragStart: (isLeftSide: Boolean) -> Unit = {},
onVerticalDragEnd: () -> Unit = {},
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> }, onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
onHorizontalDragSeekTo: (positionMs: Long) -> Unit, onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
currentPositionProvider: () -> Long, currentPositionProvider: () -> Long,
@@ -79,6 +81,7 @@ fun PlayerGesturesLayer(
var isHorizontalDragActive = false var isHorizontalDragActive = false
var lastPreviewDelta: Long? = null var lastPreviewDelta: Long? = null
var startPositionMs = 0L var startPositionMs = 0L
var verticalDragStarted = false
drag(down.id) { change -> drag(down.id) { change ->
val delta = change.positionChange() val delta = change.positionChange()
@@ -115,6 +118,10 @@ fun PlayerGesturesLayer(
DragDirection.VERTICAL -> { DragDirection.VERTICAL -> {
change.consume() change.consume()
val isLeftSide = startX < size.width / 2 val isLeftSide = startX < size.width / 2
if (!verticalDragStarted) {
verticalDragStarted = true
onVerticalDragStart(isLeftSide)
}
if (isLeftSide) { if (isLeftSide) {
onVerticalDragLeft(delta.y) onVerticalDragLeft(delta.y)
} else { } else {
@@ -134,6 +141,10 @@ fun PlayerGesturesLayer(
} }
} }
onHorizontalDragPreview(null, null) onHorizontalDragPreview(null, null)
if (verticalDragStarted) {
onVerticalDragEnd()
}
} }
} }
) )