replace the play arrow with an animated bar indicator

Three equaliser bars wave with staggered phases to draw the eye to the
playing track, keeping the soft shadow over cover art and flat bars in
the queue.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent c68b631d06
commit a23d9a3c31
@@ -1,5 +1,10 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -13,7 +18,6 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -21,6 +25,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -36,13 +41,8 @@ import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.graphics.vector.VectorGroup
import androidx.compose.ui.graphics.vector.VectorPath
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -56,6 +56,8 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import kotlin.math.PI
import kotlin.math.sin
private val TRACK_COLUMN_WIDTH = 24.dp private val TRACK_COLUMN_WIDTH = 24.dp
private val TIME_COLUMN_WIDTH = 36.dp private val TIME_COLUMN_WIDTH = 36.dp
@@ -66,23 +68,9 @@ private val PLAY_SHADOW_RADIUS = 3.dp
private val PLAY_SHADOW = Color.Black.copy(alpha = 0.6f) private val PLAY_SHADOW = Color.Black.copy(alpha = 0.6f)
private val INNER_SHADOW_RADIUS = 8.dp private val INNER_SHADOW_RADIUS = 8.dp
private val INNER_SHADOW = Color.Black.copy(alpha = 0.85f) private val INNER_SHADOW = Color.Black.copy(alpha = 0.85f)
private val PLAY_ARROW = Icons.Rounded.PlayArrow private const val BAR_COUNT = 3
private val PLAY_ARROW_PATH: Path by lazy { buildVectorPath(PLAY_ARROW) } private const val PLAY_BAR_PERIOD_MS = 900
private const val PLAY_BAR_PHASE = 0.18f
private fun buildVectorPath(vector: ImageVector): Path {
val parser = PathParser()
fun collect(group: VectorGroup) {
for (node in group) {
when (node) {
is VectorPath -> parser.addPathNodes(node.pathData)
is VectorGroup -> collect(node)
}
}
}
collect(vector.root)
return parser.toPath()
}
@Composable @Composable
fun SongRow( fun SongRow(
@@ -216,30 +204,44 @@ private fun PlayIndicator(
shadow: Boolean, shadow: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val phase by
rememberInfiniteTransition(label = "playing")
.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(PLAY_BAR_PERIOD_MS, easing = LinearEasing)),
label = "phase",
)
val tint = MaterialTheme.colorScheme.primary val tint = MaterialTheme.colorScheme.primary
if (shadow) { val shadowRadius = with(LocalDensity.current) { PLAY_SHADOW_RADIUS.toPx() }
val radius = with(LocalDensity.current) { PLAY_SHADOW_RADIUS.toPx() }
Canvas(modifier.semantics { contentDescription = "Playing" }) { Canvas(modifier.semantics { contentDescription = "Playing" }) {
val scale = minOf(size.width / PLAY_ARROW.viewportWidth, size.height / PLAY_ARROW.viewportHeight) val barWidth = size.width * 0.22f
withTransform({ scale(scale, scale, pivot = Offset.Zero) }) { val gap = size.width * 0.11f
val radius = CornerRadius(barWidth / 2f)
val total = barWidth * BAR_COUNT + gap * (BAR_COUNT - 1)
val startX = (size.width - total) / 2f
val minHeight = size.height * 0.3f
val bars =
(0 until BAR_COUNT).map { index ->
val wave = ((sin((phase + index * PLAY_BAR_PHASE) * 2.0 * PI) + 1.0) / 2.0).toFloat()
val height = minHeight + (size.height - minHeight) * wave
val x = startX + index * (barWidth + gap)
Rect(x, (size.height - height) / 2f, x + barWidth, (size.height + height) / 2f)
}
if (shadow) {
val path = Path().apply { bars.forEach { addRoundRect(RoundRect(it, radius)) } }
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
val paint = val paint =
android.graphics.Paint().apply { android.graphics.Paint().apply {
isAntiAlias = true isAntiAlias = true
color = tint.toArgb() color = tint.toArgb()
setShadowLayer(radius, 0f, 0f, PLAY_SHADOW.toArgb()) setShadowLayer(shadowRadius, 0f, 0f, PLAY_SHADOW.toArgb())
}
canvas.nativeCanvas.drawPath(PLAY_ARROW_PATH.asAndroidPath(), paint)
}
} }
canvas.nativeCanvas.drawPath(path.asAndroidPath(), paint)
} }
} else { } else {
Icon( bars.forEach { drawRoundRect(color = tint, topLeft = it.topLeft, size = it.size, cornerRadius = radius) }
imageVector = PLAY_ARROW, }
contentDescription = "Playing",
tint = tint,
modifier = modifier,
)
} }
} }