Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading