Fix adjust slider lag, drop fade-on-drag, and stop live seeking while scrubbing

Opacity sliders wrote the profile on every drag frame, which is what
made them stutter; they now hold a local live value that drives the
sample cue and only commit to the profile on release. With the panel
usable during drags, the fade-to-15% behavior is removed and the sample
cue moves lower (20dp from the bottom) instead.

The seek preview thumbnail system seeked the real player whenever a
drag paused over an uncached bucket, so scrubbing caused audible
jumps mid-drag. It now serves cached frames only, keeping the
background capture loop; the actual seek happens solely on seekbar
release, which the seekbar already implemented.
This commit is contained in:
KhooLy 2026-07-17 17:50:08 +03:00
parent f9d4e664fe
commit 35ae8ea14a
5 changed files with 26 additions and 91 deletions

View file

@ -27,7 +27,7 @@ internal fun extractSeasonEpisode(videoId: String?): Pair<Int, Int>? {
internal fun PlayerUIContent(
content: PlayerContentUiModel, lang: String, duration: Long, position: Long, bufferedFraction: Float, chapters: List<com.fluxa.app.player.Chapter> = emptyList(), isPlaying: Boolean, isBuffering: Boolean, hasStartedPlaying: Boolean, deviceType: DeviceType,
onPlayPause: () -> Unit, onSeek: (Long) -> Unit, onToggleSubtitles: () -> Unit, onToggleAspect: () -> Unit, onSpeedChange: (Float) -> Unit, playbackSpeed: Float, playPauseFocusRequester: FocusRequester, seekbarFocusRequester: FocusRequester,
isScrubbing: Boolean, scrubPosition: Long, onScrubbingChange: (Boolean, Long) -> Unit, onScrubSeek: (Long) -> Unit = {},
isScrubbing: Boolean, scrubPosition: Long, onScrubbingChange: (Boolean, Long) -> Unit,
isSwitchingAudioSource: Boolean = false, detailedStatus: String = "", episodeMetaLine: String? = null, streamDetailLine: String? = null, subtitlesEnabled: Boolean = false, technicalInfo: String? = null,
supportsTrackSettings: Boolean = true,
seekForwardMs: Long = 10_000L, seekBackwardMs: Long = 10_000L,
@ -46,7 +46,7 @@ internal fun PlayerUIContent(
accentColor: Color = FluxaColors.accent
) {
val seekPreviewBitmap = rememberSeekThumbnail(
LocalSeekSurfaceView.current, scrubPosition, isScrubbing, position, isPlaying, onScrubSeek
LocalSeekSurfaceView.current, scrubPosition, isScrubbing, position, isPlaying
)
if (deviceType == DeviceType.Mobile) {

View file

@ -303,7 +303,6 @@ internal fun BoxScope.PlayerPlaybackSurface(
isScrubbing = isScrubbing,
scrubPosition = scrubPosition,
onScrubbingChange = onScrubbingChange,
onScrubSeek = { activeEngine?.seekTo(it, exact = false) },
isSwitchingAudioSource = isSwitchingAudioSource,
detailedStatus = torrentStatus.detailedStatus,
episodeMetaLine = currentEpisodeMetaLine,

View file

@ -18,11 +18,9 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.io.ByteArrayOutputStream
import kotlin.coroutines.resume
@ -34,7 +32,6 @@ private const val BackgroundCaptureIntervalMs = 60_000L
private const val ThumbWidth = 480
private const val ThumbHeight = 270
private const val CacheMaxBytes = 32 * 1024 * 1024
private const val FrameTimeoutMs = 300L
@Composable
internal fun rememberSeekThumbnail(
@ -42,45 +39,19 @@ internal fun rememberSeekThumbnail(
scrubPosition: Long,
isScrubbing: Boolean,
livePosition: Long,
isPlaying: Boolean,
onScrubSeek: (Long) -> Unit
isPlaying: Boolean
): ImageBitmap? {
val exoPlayer = LocalSeekExoPlayer.current
var thumbnail by remember { mutableStateOf<ImageBitmap?>(null) }
val cache = remember {
object : LruCache<Long, ByteArray>(CacheMaxBytes) {
override fun sizeOf(key: Long, value: ByteArray) = value.size
}
}
val latestScrubPosition by rememberUpdatedState(scrubPosition)
val bucket = (scrubPosition / BucketMs) * BucketMs
LaunchedEffect(bucket, isScrubbing, surfaceView != null) {
LaunchedEffect(bucket, isScrubbing) {
if (!isScrubbing) { thumbnail = null; return@LaunchedEffect }
val sv = surfaceView ?: return@LaunchedEffect
if (android.os.Build.VERSION.SDK_INT < 26) return@LaunchedEffect
cache[bucket]?.let { thumbnail = decodeJpeg(it); return@LaunchedEffect }
delay(80)
if (!isScrubbing) return@LaunchedEffect
val target = latestScrubPosition
if (exoPlayer != null) {
val listener = FirstFrameListener(exoPlayer)
onScrubSeek(target)
withTimeoutOrNull(FrameTimeoutMs) { listener.await() }
listener.detach()
} else {
onScrubSeek(target)
delay(140)
}
if (!isScrubbing) return@LaunchedEffect
val captured = capturePixelCopy(sv) ?: return@LaunchedEffect
val bytes = compressToJpeg(captured)
cache.put(bucket, bytes)
thumbnail = decodeJpeg(bytes)
cache[bucket]?.let { thumbnail = decodeJpeg(it) }
}
val latestIsScrubbing by rememberUpdatedState(isScrubbing)
@ -108,29 +79,6 @@ internal fun rememberSeekThumbnail(
return thumbnail
}
private class FirstFrameListener(private val player: ExoPlayer) : Player.Listener {
private var continuation: kotlinx.coroutines.CancellableContinuation<Unit>? = null
init { player.addListener(this) }
override fun onRenderedFirstFrame() {
val cont = continuation ?: return
continuation = null
player.removeListener(this)
cont.resume(Unit)
}
suspend fun await() = suspendCancellableCoroutine<Unit> { cont ->
continuation = cont
cont.invokeOnCancellation { continuation = null }
}
fun detach() {
continuation = null
runCatching { player.removeListener(this) }
}
}
private fun compressToJpeg(captured: Bitmap): ByteArray {
val composed = Bitmap.createBitmap(ThumbWidth, ThumbHeight, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(composed)

View file

@ -14,8 +14,6 @@ import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -88,7 +86,9 @@ fun UniversalSettingsSidebar(
val twoColumn = deviceType != DeviceType.Mobile || maxWidth > maxHeight
var tab by remember(activeTab) { mutableStateOf(activeTab.coerceIn(0, 1)) }
var showAdjust by remember(activeTab) { mutableStateOf(false) }
var sliderActive by remember { mutableStateOf(false) }
var liveTextOpacity by remember(subtitleTextOpacity) { mutableStateOf(subtitleTextOpacity) }
var liveBackgroundOpacity by remember(subtitleBackgroundOpacity) { mutableStateOf(subtitleBackgroundOpacity) }
var liveOutlineOpacity by remember(subtitleOutlineOpacity) { mutableStateOf(subtitleOutlineOpacity) }
val listMaxHeight = if (deviceType == DeviceType.TV) 400.dp else 440.dp
val audioList: @Composable () -> Unit = {
@ -151,24 +151,24 @@ fun UniversalSettingsSidebar(
)
OpacityAdjustmentItem(
title = AppStrings.t(lang, "settings.subtitle_text"),
value = subtitleTextOpacity,
value = liveTextOpacity,
deviceType = deviceType,
onChange = onSubtitleTextOpacityChange,
onDragActiveChange = { sliderActive = it }
onChange = { liveTextOpacity = it },
onCommit = { onSubtitleTextOpacityChange(liveTextOpacity) }
)
OpacityAdjustmentItem(
title = AppStrings.t(lang, "settings.subtitle_background"),
value = subtitleBackgroundOpacity,
value = liveBackgroundOpacity,
deviceType = deviceType,
onChange = onSubtitleBackgroundOpacityChange,
onDragActiveChange = { sliderActive = it }
onChange = { liveBackgroundOpacity = it },
onCommit = { onSubtitleBackgroundOpacityChange(liveBackgroundOpacity) }
)
OpacityAdjustmentItem(
title = AppStrings.t(lang, "settings.subtitle_outline"),
value = subtitleOutlineOpacity,
value = liveOutlineOpacity,
deviceType = deviceType,
onChange = onSubtitleOutlineOpacityChange,
onDragActiveChange = { sliderActive = it }
onChange = { liveOutlineOpacity = it },
onCommit = { onSubtitleOutlineOpacityChange(liveOutlineOpacity) }
)
}
}
@ -176,9 +176,9 @@ fun UniversalSettingsSidebar(
if (showAdjust) {
SubtitlePreviewCue(
lang = lang,
textOpacity = subtitleTextOpacity,
backgroundOpacity = subtitleBackgroundOpacity,
outlineOpacity = subtitleOutlineOpacity
textOpacity = liveTextOpacity,
backgroundOpacity = liveBackgroundOpacity,
outlineOpacity = liveOutlineOpacity
)
PlayerSidebarShell(
title = AppStrings.t(lang, "player.adjust"),
@ -187,8 +187,7 @@ fun UniversalSettingsSidebar(
onBack = { showAdjust = false },
cardWidth = 380.dp,
anchorTop = true,
scrimAlpha = 0.06f,
dimmed = sliderActive
scrimAlpha = 0.06f
) {
subtitleAdjust()
}
@ -415,7 +414,7 @@ private fun SubtitlePreviewCue(
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 56.dp)
.padding(bottom = 20.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(alpha = backgroundOpacity.coerceIn(0f, 1f)))
.padding(horizontal = 12.dp, vertical = 6.dp)
@ -488,12 +487,8 @@ private fun OpacityAdjustmentItem(
value: Float,
deviceType: DeviceType,
onChange: (Float) -> Unit,
onDragActiveChange: (Boolean) -> Unit = {}
onCommit: () -> Unit = {}
) {
val sliderInteraction = remember { MutableInteractionSource() }
val dragged by sliderInteraction.collectIsDraggedAsState()
val pressed by sliderInteraction.collectIsPressedAsState()
LaunchedEffect(dragged, pressed) { onDragActiveChange(dragged || pressed) }
Row(
modifier = Modifier
.fillMaxWidth()
@ -515,8 +510,8 @@ private fun OpacityAdjustmentItem(
Slider(
value = value.coerceIn(0f, 1f),
onValueChange = { onChange(it.coerceIn(0f, 1f)) },
onValueChangeFinished = onCommit,
valueRange = 0f..1f,
interactionSource = sliderInteraction,
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,

View file

@ -81,7 +81,6 @@ fun PlayerSidebarShell(
cardWidth: Dp? = null,
anchorTop: Boolean = false,
scrimAlpha: Float = 0.42f,
dimmed: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
var shown by remember { mutableStateOf(false) }
@ -104,12 +103,6 @@ fun PlayerSidebarShell(
}
val isMobile = deviceType == DeviceType.Mobile
val dim by animateFloatAsState(
targetValue = if (dimmed) 0.15f else 1f,
animationSpec = tween(150),
label = "sidebarDim"
)
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
@ -124,7 +117,7 @@ fun PlayerSidebarShell(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha * progress * dim))
.background(Color.Black.copy(alpha = scrimAlpha * progress))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
@ -166,7 +159,7 @@ fun PlayerSidebarShell(
.then(panelSizeModifier)
.animateContentSize(tween(220, easing = FastOutSlowInEasing))
.graphicsLayer {
alpha = (if (isCard) progress else 1f) * dim
alpha = if (isCard) progress else 1f
translationY = if (isCard) {
(1f - progress) * (if (anchorTop) (-16).dp.toPx() else 16.dp.toPx())
} else {