diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt index e908c06bd..dacb9b64b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt @@ -627,16 +627,15 @@ fun UnifiedPlayerSheetV2( alpha = miniReadyAlpha transformOrigin = TransformOrigin(0.5f, 1f) } - .then( - if (visualCardShadowElevation > 0.dp) { - Modifier.shadow( - elevation = visualCardShadowElevation, - shape = sheetInteractionState.playerShadowShape, - clip = false - ) - } else { - Modifier - } + // Always apply Modifier.shadow with the dynamic elevation + // (0.dp renders nothing). Keeping the modifier chain + // structurally stable avoids the costly relayout/redraw + // restructure when the elevation crosses 0.dp during + // expand/collapse or right after play/pause. + .shadow( + elevation = visualCardShadowElevation, + shape = sheetInteractionState.playerShadowShape, + clip = false ) .background( color = playerAreaBackground, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt index 98aba6dba..f1ed8d1b4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt @@ -4,6 +4,8 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -24,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -35,6 +38,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.clearAndSetSemantics @@ -98,7 +102,8 @@ fun WavySliderExpressive( val resolvedInteractionSource = interactionSource ?: remember { MutableInteractionSource() } val isDragged by resolvedInteractionSource.collectIsDraggedAsState() val isPressed by resolvedInteractionSource.collectIsPressedAsState() - val isInteracting = isDragged || isPressed + var isPointerSeeking by remember { mutableStateOf(false) } + val isInteracting = isDragged || isPressed || isPointerSeeking val thumbInteractionFraction by animateFloatAsState( targetValue = if (isInteracting) 1f else 0f, @@ -246,5 +251,55 @@ fun WavySliderExpressive( cornerRadius = CornerRadius(currentWidth / 2f) ) } + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(enabled, valueRange, trackEdgePaddingPx) { + if (!enabled) return@pointerInput + + fun valueForX(rawX: Float): Float { + val edgePadding = trackEdgePaddingPx.coerceIn(0f, size.width / 2f) + val trackStart = edgePadding + val trackEnd = size.width - edgePadding + val trackWidth = (trackEnd - trackStart).coerceAtLeast(1f) + val normalized = ((rawX - trackStart) / trackWidth).coerceIn(0f, 1f) + return valueRange.start + + normalized * (valueRange.endInclusive - valueRange.start) + } + + awaitEachGesture { + try { + val down = awaitFirstDown(requireUnconsumed = false) + isPointerSeeking = true + down.consume() + onValueChange(valueForX(down.position.x)) + + var pointerId = down.id + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: event.changes.firstOrNull { it.pressed } + ?: break + + pointerId = change.id + if (!change.pressed) { + change.consume() + break + } + + if (change.position != change.previousPosition) { + change.consume() + onValueChange(valueForX(change.position.x)) + } + } + + onValueChangeFinished?.invoke() + } finally { + isPointerSeeking = false + } + } + } + ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt index 71549f153..2a765764d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt @@ -3,10 +3,8 @@ package com.theveloper.pixelplay.presentation.components.player import androidx.compose.animation.Crossfade import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -161,12 +159,15 @@ fun AnimatedPlaybackControls( animationSpec = pressAnimationSpec, label = "playWeight" ) + // Tween (matching the Crossfade duration) instead of a spring with + // StiffnessMedium. The old spring took ~600 ms to settle and read + // playCorner in the composition phase, recomposing AnimatedPlaybackControls + // every frame for the entire settle. A bounded 220 ms tween that completes + // alongside the icon Crossfade keeps the recomposition window small enough + // that it doesn't overlap with a subsequent sheet-collapse gesture. val playCorner by animateDpAsState( targetValue = if (!playPauseVisualState) playPauseCornerPlaying else playPauseCornerPaused, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium - ), + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), label = "playCorner" ) val playShape = AbsoluteSmoothCornerShape( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 2eba3f1e5..bd47be9c4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -1011,12 +1011,14 @@ private fun FullPlayerAlbumCoverSection( ) { val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delayAlbumCarousel val shouldApplyPausedScale = !isPlayingProvider() && !playWhenReadyProvider() + // Use a short deterministic tween instead of spring(StiffnessLow). The original + // spring took ~1s to settle, producing ~60 frames of graphicsLayer invalidations + // that overlapped with any subsequent sheet-collapse gesture. A 260 ms tween + // finishes well before the user can start the next gesture, keeping the album + // art's "pause squish" visible but removing the long tail of frame work. val albumArtScale by animateFloatAsState( targetValue = if (shouldApplyPausedScale) 0.95f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow - ), + animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), label = "AlbumArtScale" ) @@ -1804,11 +1806,10 @@ private fun PlayerProgressBarSection( valueState = animatedProgressState, onValueChange = { sliderDragValue = it }, onValueChangeFinished = { - sliderDragValue?.let { finalValue -> - val targetMs = (finalValue * durationForCalc).roundToLong() - optimisticPosition = targetMs - onSeek(targetMs) - } + val finalValue = sliderDragValue ?: animatedProgressState.value + val targetMs = (finalValue * durationForCalc).roundToLong() + optimisticPosition = targetMs + onSeek(targetMs) sliderDragValue = null }, thumbColor = thumbColor, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt index ed470e908..2f03fc4c2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt @@ -62,11 +62,20 @@ fun rememberSmoothProgress( val latestPositionProvider by rememberUpdatedState(newValue = currentPositionProvider) val latestIsPlayingProvider by rememberUpdatedState(newValue = isPlayingProvider) + // Read these inside the loop so the LaunchedEffect doesn't restart every time + // they change. With the previous keying scheme, crossing the expansion threshold + // (which flips `isVisible` at 0.01 and the playing sample rate at 0.995) would + // cancel and relaunch this coroutine mid-gesture — the new fresh coroutine + // immediately allocated a new Job + had to re-issue its first `sampleNow` and + // `delay`, which contributed to the post-interaction gesture lag. + val latestSampleWhilePlayingMs by rememberUpdatedState(sampleWhilePlayingMs) + val latestSampleWhilePausedMs by rememberUpdatedState(sampleWhilePausedMs) + val latestIsVisible by rememberUpdatedState(isVisible) val safeUpperBound = totalDuration.coerceAtLeast(0L) val safeDuration = totalDuration.coerceAtLeast(1L) - LaunchedEffect(totalDuration, sampleWhilePlayingMs, sampleWhilePausedMs, isVisible) { + LaunchedEffect(totalDuration) { fun sampleNow() { val rawPosition = latestPositionProvider() val clampedPosition = rawPosition.coerceIn(0L, safeUpperBound) @@ -75,11 +84,14 @@ fun rememberSmoothProgress( } sampleNow() - if (!isVisible) return@LaunchedEffect while (isActive) { + if (!latestIsVisible) { + delay(200L) + continue + } val isPlaying = latestIsPlayingProvider() - val delayMillis = if (isPlaying) sampleWhilePlayingMs else sampleWhilePausedMs + val delayMillis = if (isPlaying) latestSampleWhilePlayingMs else latestSampleWhilePausedMs delay(delayMillis.coerceAtLeast(1L)) sampleNow() } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/FullPlayerCompositionPolicy.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/FullPlayerCompositionPolicy.kt index e71a5ef4d..f93ccfc2d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/FullPlayerCompositionPolicy.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/FullPlayerCompositionPolicy.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first internal data class FullPlayerCompositionPolicy( val shouldRenderFullPlayer: Boolean @@ -51,15 +52,18 @@ internal fun rememberFullPlayerCompositionPolicy( } // Monitor expansion fraction via snapshotFlow instead of using it as a - // LaunchedEffect key. The old approach relaunched the effect on every frame. + // LaunchedEffect key. Once either condition is satisfied + // (expansion crossed 0.12f, or the warm-delay coroutine flipped + // keepFullPlayerComposed itself) we can exit. The previous `collect` + // never terminated — it kept reading expansionFraction on every frame + // for the rest of the song's lifetime, even though there was nothing + // left to do once keepFullPlayerComposed was true. LaunchedEffect(currentSongId) { if (currentSongId == null) return@LaunchedEffect - snapshotFlow { expansionFraction.value } - .collect { fraction -> - if (fraction > 0.12f && !keepFullPlayerComposed) { - keepFullPlayerComposed = true - } - } + snapshotFlow { + keepFullPlayerComposed || expansionFraction.value > 0.12f + }.first { it } + if (!keepFullPlayerComposed) keepFullPlayerComposed = true } // Read expansion fraction inside derivedStateOf so that changes only trigger diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt index 5e623d832..29a82d310 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt @@ -141,12 +141,17 @@ internal fun rememberSheetVisualState( } } + // isPlaying and hasCurrentSong are only used in the fallback branch when + // !showPlayerContentArea. Reading them via rememberUpdatedState keeps the + // shape provider lambda stable across play/pause toggles — so the + // PlayerSheetDynamicShape instance (and the modifier chain that consumes it) + // is not recreated on every isPlaying flip. + val isPlayingState = rememberUpdatedState(isPlaying) + val hasCurrentSongState = rememberUpdatedState(hasCurrentSong) val playerContentActualBottomRadiusProvider: () -> Dp = remember( navBarStyle, showPlayerContentArea, playerContentExpansionFraction, - isPlaying, - hasCurrentSong, predictiveBackCollapseProgress, currentSheetContentState, swipeDismissProgress, @@ -176,7 +181,7 @@ internal fun rememberSheetVisualState( lerp(26.dp, 0.dp, ((fraction - 0.2f) / 0.8f).coerceIn(0f, 1f)) } } else { - if (!isPlaying || !hasCurrentSong) { + if (!isPlayingState.value || !hasCurrentSongState.value) { if (isNavBarHidden) 32.dp else navBarCornerRadiusDp } else { if (isNavBarHidden) 32.dp else 12.dp