mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-07-26 20:02:14 +00:00
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:
parent
f9d4e664fe
commit
35ae8ea14a
5 changed files with 26 additions and 91 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue