Skip to content

Commit 646f403

Browse files
committed
Final optimization for UnifiedPlayerSheetV2.kt and it's related animations, plus changed BenchmarkEnvironment.kt and added PlayerSheetAnimationBenchmark.kt to properly test and benchmark those animations
1 parent a5c983b commit 646f403

11 files changed

Lines changed: 538 additions & 32 deletions

File tree

app/src/main/java/com/theveloper/pixelplay/MainActivity.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import androidx.compose.material3.rememberDrawerState
6161
import androidx.compose.runtime.Composable
6262
import androidx.compose.runtime.LaunchedEffect
6363
import androidx.lifecycle.compose.collectAsStateWithLifecycle
64+
import androidx.lifecycle.lifecycleScope
6465
import androidx.compose.runtime.derivedStateOf
6566
import androidx.compose.runtime.getValue
6667
import androidx.compose.runtime.mutableStateOf
@@ -169,6 +170,8 @@ class MainActivity : ComponentActivity() {
169170
lateinit var userPreferencesRepository: UserPreferencesRepository // Inject here
170171
@Inject
171172
lateinit var themePreferencesRepository: ThemePreferencesRepository
173+
@Inject
174+
lateinit var syncManager: SyncManager
172175
// For handling shortcut navigation - using StateFlow so composables can observe changes
173176
private val _pendingPlaylistNavigation = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
174177
private val _pendingShuffleAll = kotlinx.coroutines.flow.MutableStateFlow(false)
@@ -207,6 +210,21 @@ class MainActivity : ComponentActivity() {
207210

208211
// LEER SEÑAL DE BENCHMARK
209212
val isBenchmarkMode = intent.getBooleanExtra("is_benchmark", false)
213+
val shouldBenchmarkRebuildDatabase =
214+
isBenchmarkMode && intent.getBooleanExtra("benchmark_rebuild_database", false)
215+
Log.i(
216+
"PixelPlayBenchmark",
217+
"onCreate benchmark=$isBenchmarkMode rebuildDatabase=$shouldBenchmarkRebuildDatabase"
218+
)
219+
if (shouldBenchmarkRebuildDatabase) {
220+
lifecycleScope.launch {
221+
userPreferencesRepository.setInitialSetupDone(true)
222+
Log.i("PixelPlayBenchmark", "Enqueueing benchmark database rebuild")
223+
syncManager.rebuildDatabase()
224+
delay(1_500L)
225+
playerViewModel.prepareBenchmarkPlayerFromLibrary()
226+
}
227+
}
210228

211229
setContent {
212230
val systemDarkTheme = isSystemInDarkTheme()

app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,15 +856,17 @@ constructor(
856856
}
857857

858858
if (rawDataList.isEmpty()) {
859+
Log.i(TAG, "MediaStore cursor produced 0 raw songs after directory filtering")
859860
Trace.endSection()
860861
return emptyList()
861862
}
862863

863864
// Phase 2: Identify changed songs and merge with existing data in chunks
864865
val artistDelimiters = userPreferencesRepository.artistDelimitersFlow.first()
865866
val artistWordDelimiters = userPreferencesRepository.artistWordDelimitersFlow.first()
867+
val rawSongCount = rawDataList.size
866868
val songsToProcess = if (isRebuild) {
867-
rawDataList
869+
rawDataList.toList()
868870
} else {
869871
// Find existing data for these songs to avoid unnecessary reprocessing
870872
// and to preserve user edits.
@@ -886,10 +888,13 @@ constructor(
886888

887889
// rawDataList is no longer needed — release its memory before the processing phase,
888890
// which may allocate large existingMap objects and metadata ByteArrays.
889-
@Suppress("UNUSED_VALUE")
890891
rawDataList.clear()
891892

892893
val totalCount = songsToProcess.size
894+
Log.i(
895+
TAG,
896+
"MediaStore raw=$rawSongCount, songsToProcess=$totalCount, isRebuild=$isRebuild"
897+
)
893898
if (totalCount == 0) {
894899
Trace.endSection()
895900
return emptyList()

app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import androidx.compose.ui.platform.LocalConfiguration
4545
import androidx.compose.ui.platform.LocalContext
4646
import androidx.compose.ui.platform.LocalDensity
4747
import androidx.compose.ui.platform.LocalHapticFeedback
48+
import androidx.compose.ui.semantics.contentDescription
49+
import androidx.compose.ui.semantics.semantics
4850
import androidx.compose.ui.unit.Dp
4951
import androidx.compose.ui.unit.IntOffset
5052
import androidx.compose.ui.unit.dp
@@ -563,6 +565,14 @@ fun UnifiedPlayerSheetV2(
563565

564566
if (!actuallyShowSheetContent) return
565567

568+
val playerSheetSemanticsDescription = remember(
569+
currentSheetContentState,
570+
infrequentPlayerState.currentSong?.title
571+
) {
572+
"PixelPlay player sheet ${currentSheetContentState.name.lowercase()} " +
573+
(infrequentPlayerState.currentSong?.title ?: "")
574+
}
575+
566576
Surface(
567577
modifier = Modifier
568578
.fillMaxWidth()
@@ -632,6 +642,9 @@ fun UnifiedPlayerSheetV2(
632642
shape = sheetInteractionState.playerShadowShape
633643
)
634644
.clipToBounds()
645+
.semantics {
646+
contentDescription = playerSheetSemanticsDescription
647+
}
635648
.playerSheetVerticalDragGesture(
636649
enabled = sheetInteractionState.canDragSheet,
637650
handler = sheetInteractionState.sheetVerticalDragGestureHandler

app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/MiniPlayerDismissGestureHandler.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
1616
import androidx.compose.ui.input.pointer.pointerInput
1717
import androidx.compose.ui.unit.Density
1818
import androidx.compose.ui.util.lerp
19+
import kotlinx.coroutines.CoroutineStart
1920
import kotlinx.coroutines.CoroutineScope
21+
import kotlinx.coroutines.Job
2022
import kotlinx.coroutines.launch
2123
import kotlin.math.abs
2224
import kotlin.math.sign
@@ -37,11 +39,15 @@ internal class MiniPlayerDismissGestureHandler(
3739
) {
3840
private var dragPhase: MiniDismissDragPhase = MiniDismissDragPhase.IDLE
3941
private var accumulatedDragX: Float = 0f
42+
private var offsetJob: Job? = null
4043

4144
fun onDragStart() {
4245
dragPhase = MiniDismissDragPhase.TENSION
4346
accumulatedDragX = 0f
44-
scope.launch { offsetAnimatable.stop() }
47+
offsetJob?.cancel()
48+
offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
49+
offsetAnimatable.stop()
50+
}
4551
}
4652

4753
fun onHorizontalDrag(dragAmount: Float) {
@@ -54,7 +60,8 @@ internal class MiniPlayerDismissGestureHandler(
5460
val maxTensionOffsetPx = 30f * density.density
5561
val dragFraction = (abs(accumulatedDragX) / snapThresholdPx).coerceIn(0f, 1f)
5662
val tensionOffset = lerp(0f, maxTensionOffsetPx, dragFraction)
57-
scope.launch {
63+
offsetJob?.cancel()
64+
offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
5865
offsetAnimatable.snapTo(tensionOffset * accumulatedDragX.sign)
5966
}
6067
} else {
@@ -64,7 +71,8 @@ internal class MiniPlayerDismissGestureHandler(
6471

6572
MiniDismissDragPhase.SNAPPING -> {
6673
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
67-
scope.launch {
74+
offsetJob?.cancel()
75+
offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
6876
offsetAnimatable.animateTo(
6977
targetValue = accumulatedDragX,
7078
animationSpec = spring(
@@ -77,7 +85,8 @@ internal class MiniPlayerDismissGestureHandler(
7785
}
7886

7987
MiniDismissDragPhase.FREE_DRAG -> {
80-
scope.launch {
88+
offsetJob?.cancel()
89+
offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
8190
offsetAnimatable.animateTo(
8291
targetValue = accumulatedDragX,
8392
animationSpec = spring(
@@ -94,10 +103,11 @@ internal class MiniPlayerDismissGestureHandler(
94103

95104
fun onDragEnd() {
96105
dragPhase = MiniDismissDragPhase.IDLE
106+
offsetJob?.cancel()
97107
val dismissThreshold = screenWidthPx * 0.4f
98108
if (abs(accumulatedDragX) > dismissThreshold) {
99109
val targetDismissOffset = if (accumulatedDragX < 0) -screenWidthPx else screenWidthPx
100-
scope.launch {
110+
offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
101111
offsetAnimatable.animateTo(
102112
targetValue = targetDismissOffset,
103113
animationSpec = tween(
@@ -109,7 +119,7 @@ internal class MiniPlayerDismissGestureHandler(
109119
offsetAnimatable.snapTo(0f)
110120
}
111121
} else {
112-
scope.launch {
122+
offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
113123
offsetAnimatable.animateTo(
114124
targetValue = 0f,
115125
animationSpec = spring(

app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetInteractionState.kt

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import androidx.compose.animation.core.AnimationVector1D
66
import androidx.compose.runtime.Composable
77
import androidx.compose.runtime.remember
88
import androidx.compose.runtime.rememberUpdatedState
9-
import androidx.compose.foundation.shape.RoundedCornerShape
9+
import androidx.compose.ui.geometry.CornerRadius
10+
import androidx.compose.ui.geometry.Rect
11+
import androidx.compose.ui.geometry.RoundRect
1012
import androidx.compose.ui.geometry.Size
1113
import androidx.compose.ui.graphics.Outline
1214
import androidx.compose.ui.graphics.Shape
@@ -131,7 +133,21 @@ private class PlayerSheetDynamicShape(
131133
): Outline {
132134
val topRadius = topRadiusProvider().nonNegative()
133135
val bottomRadius = bottomRadiusProvider().nonNegative()
134-
val shape = if (useSmoothShapeProvider()) {
136+
if (!useSmoothShapeProvider()) {
137+
val topRadiusPx = with(density) { topRadius.toPx() }
138+
val bottomRadiusPx = with(density) { bottomRadius.toPx() }
139+
return Outline.Rounded(
140+
RoundRect(
141+
rect = Rect(0f, 0f, size.width, size.height),
142+
topLeft = CornerRadius(topRadiusPx, topRadiusPx),
143+
topRight = CornerRadius(topRadiusPx, topRadiusPx),
144+
bottomRight = CornerRadius(bottomRadiusPx, bottomRadiusPx),
145+
bottomLeft = CornerRadius(bottomRadiusPx, bottomRadiusPx)
146+
)
147+
)
148+
}
149+
150+
val shape =
135151
AbsoluteSmoothCornerShape(
136152
cornerRadiusTL = topRadius,
137153
smoothnessAsPercentBL = 60,
@@ -142,14 +158,6 @@ private class PlayerSheetDynamicShape(
142158
cornerRadiusBL = bottomRadius,
143159
smoothnessAsPercentTR = 60
144160
)
145-
} else {
146-
RoundedCornerShape(
147-
topStart = topRadius,
148-
topEnd = topRadius,
149-
bottomStart = bottomRadius,
150-
bottomEnd = bottomRadius
151-
)
152-
}
153161
return shape.createOutline(size, layoutDirection, density)
154162
}
155163
}

app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetMotionController.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ internal class SheetMotionController(
2929
val targetY = if (targetExpanded) expandedY else collapsedY
3030
val velocityScale = (collapsedY - expandedY).coerceAtLeast(1f)
3131

32+
if (
33+
translationY.value == targetY &&
34+
expansionFraction.value == targetFraction &&
35+
!translationY.isRunning &&
36+
!expansionFraction.isRunning
37+
) {
38+
return
39+
}
40+
3241
mutex.mutate {
3342
coroutineScope {
3443
launch {
@@ -70,6 +79,7 @@ internal class SheetMotionController(
7079

7180
suspend fun syncToExpansion(collapsedY: Float) {
7281
val adjustedY = collapsedY + (expandedY - collapsedY) * expansionFraction.value
82+
if (translationY.value == adjustedY && !translationY.isRunning) return
7383
mutex.mutate {
7484
translationY.snapTo(adjustedY)
7585
}

app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVerticalDragGestureHandler.kt

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import androidx.compose.ui.input.pointer.util.VelocityTracker
1313
import androidx.compose.ui.unit.Density
1414
import androidx.compose.ui.unit.dp
1515
import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState
16+
import kotlinx.coroutines.CoroutineStart
1617
import kotlinx.coroutines.CoroutineScope
18+
import kotlinx.coroutines.Job
1719
import kotlinx.coroutines.launch
1820

1921
/**
@@ -45,9 +47,13 @@ internal class SheetVerticalDragGestureHandler(
4547
private var initialFractionOnDragStart = 0f
4648
private var initialYOnDragStart = 0f
4749
private var accumulatedDragYSinceStart = 0f
50+
private var dragSnapJob: Job? = null
4851

4952
fun onDragStart() {
50-
scope.launch { sheetMotionController.stop() }
53+
dragSnapJob?.cancel()
54+
dragSnapJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
55+
sheetMotionController.stop()
56+
}
5157
onDraggingChange(true)
5258
onDraggingPlayerAreaChange(true)
5359
velocityTracker.resetTracking()
@@ -62,16 +68,17 @@ internal class SheetVerticalDragGestureHandler(
6268
dragAmount: Float
6369
) {
6470
accumulatedDragYSinceStart += dragAmount
65-
scope.launch {
66-
val dragFrame = computeSheetVerticalDragFrame(
67-
currentTranslationY = currentSheetTranslationY.value,
68-
dragAmount = dragAmount,
69-
expandedY = expandedYProvider(),
70-
collapsedY = collapsedYProvider(),
71-
miniHeightPx = miniHeightPxProvider(),
72-
initialFractionOnDragStart = initialFractionOnDragStart,
73-
initialYOnDragStart = initialYOnDragStart
74-
)
71+
val dragFrame = computeSheetVerticalDragFrame(
72+
currentTranslationY = currentSheetTranslationY.value,
73+
dragAmount = dragAmount,
74+
expandedY = expandedYProvider(),
75+
collapsedY = collapsedYProvider(),
76+
miniHeightPx = miniHeightPxProvider(),
77+
initialFractionOnDragStart = initialFractionOnDragStart,
78+
initialYOnDragStart = initialYOnDragStart
79+
)
80+
dragSnapJob?.cancel()
81+
dragSnapJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
7582
sheetMotionController.snapTo(
7683
translationYValue = dragFrame.translationY,
7784
expansionFractionValue = dragFrame.expansionFraction
@@ -81,6 +88,8 @@ internal class SheetVerticalDragGestureHandler(
8188
}
8289

8390
fun onDragEnd() {
91+
dragSnapJob?.cancel()
92+
dragSnapJob = null
8493
onDraggingChange(false)
8594
onDraggingPlayerAreaChange(false)
8695

app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
66
import androidx.compose.runtime.derivedStateOf
77
import androidx.compose.runtime.getValue
88
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.rememberUpdatedState
910
import androidx.compose.ui.platform.LocalDensity
1011
import androidx.compose.ui.unit.Dp
1112
import androidx.compose.ui.unit.dp
@@ -87,13 +88,15 @@ internal fun rememberSheetVisualState(
8788
// Lambda provider: read inside .offset { } block (layout phase) — avoids recomposition
8889
// at ~60fps during drag gestures. The lambda captures Animatable refs and reads them at
8990
// layout time, same pattern as the horizontal padding providers above.
91+
val predictiveBackCollapseProgressState = rememberUpdatedState(predictiveBackCollapseProgress)
9092
val visualSheetTranslationYProvider: () -> Float = remember(
9193
currentSheetTranslationY,
9294
sheetCollapsedTargetY
9395
) {
9496
{
95-
currentSheetTranslationY.value * (1f - predictiveBackCollapseProgress) +
96-
(sheetCollapsedTargetY * predictiveBackCollapseProgress)
97+
val progress = predictiveBackCollapseProgressState.value
98+
currentSheetTranslationY.value * (1f - progress) +
99+
(sheetCollapsedTargetY * progress)
97100
}
98101
}
99102

app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4809,6 +4809,30 @@ class PlayerViewModel @Inject constructor(
48094809
}
48104810
}
48114811

4812+
fun prepareBenchmarkPlayerFromLibrary() {
4813+
viewModelScope.launch {
4814+
repeat(90) { attempt ->
4815+
val controllerReady = mediaController != null
4816+
val songs = withContext(Dispatchers.IO) {
4817+
musicRepository.getAllSongsOnce()
4818+
}
4819+
Log.i(
4820+
"PixelPlayBenchmark",
4821+
"prepare player attempt=$attempt controllerReady=$controllerReady songs=${songs.size}"
4822+
)
4823+
if (controllerReady && songs.isNotEmpty()) {
4824+
playSongs(songs, songs.first(), "Benchmark Player")
4825+
delay(700L)
4826+
collapsePlayerSheet()
4827+
Log.i("PixelPlayBenchmark", "Benchmark player prepared with ${songs.first().title}")
4828+
return@launch
4829+
}
4830+
delay(500L)
4831+
}
4832+
Log.w("PixelPlayBenchmark", "Unable to prepare benchmark player from library")
4833+
}
4834+
}
4835+
48124836
private var pendingBatchGenreEdit: Pair<List<Song>, String>? = null
48134837

48144838
fun batchEditGenre(songs: List<Song>, newGenre: String) {

baselineprofile/src/main/java/com/theveloper/pixelplay/baselineprofile/BenchmarkEnvironment.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ internal fun MacrobenchmarkScope.setupBenchmarkPermissions(packageName: String =
3737
}
3838
}
3939

40+
internal fun MacrobenchmarkScope.executeBenchmarkShellCommand(command: String): String =
41+
executeShellCommandSafely(command)
42+
4043
internal fun MacrobenchmarkScope.waitForAppForeground(
4144
context: String,
4245
packageName: String = benchmarkTargetPackageName(),
@@ -103,7 +106,7 @@ private fun requiredRuntimePermissions(): List<String> = buildList {
103106

104107
private fun MacrobenchmarkScope.executeShellCommandSafely(command: String): String =
105108
try {
106-
device.executeShellCommand("$command 2>&1").orEmpty()
109+
device.executeShellCommand(command).orEmpty()
107110
} catch (exception: Exception) {
108111
Log.w(TAG, "Ignoring shell failure for: $command", exception)
109112
exception.message.orEmpty()

0 commit comments

Comments
 (0)