Two-task plan: replace BounceMarqueeText with TickerText composable, then manually verify on device. Made-with: Cursor
169 lines
5.9 KiB
Markdown
169 lines
5.9 KiB
Markdown
# Ticker Text Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Replace the bounce-marquee animation with a constant-speed continuous ticker for long track titles.
|
||
|
||
**Architecture:** Replace the `BounceMarqueeText` composable with `TickerText` in `NowPlayingScreen.kt`. The new composable uses the same `rememberInfiniteTransition` + `animateFloat` pattern but with linear one-directional keyframes and duration derived from a fixed velocity (33 dp/s) rather than a clamped formula. One file changes.
|
||
|
||
**Tech Stack:** Jetpack Compose animation APIs (`rememberInfiniteTransition`, `animateFloat`, `keyframes`, `LinearEasing`)
|
||
|
||
---
|
||
|
||
### Task 1: Replace `BounceMarqueeText` with `TickerText`
|
||
|
||
**Files:**
|
||
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt:449-543` (replace composable)
|
||
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt:578` (update call site)
|
||
|
||
**Step 1: Replace the `BounceMarqueeText` composable (lines 449–543) with `TickerText`**
|
||
|
||
The new composable:
|
||
|
||
```kotlin
|
||
@Composable
|
||
private fun TickerText(
|
||
text: String,
|
||
style: TextStyle,
|
||
color: Color,
|
||
strokeColor: Color? = null,
|
||
strokeWidth: Float = 2f,
|
||
velocityDpPerSecond: Float = 33f,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
val density = LocalDensity.current
|
||
val textMeasurer = rememberTextMeasurer()
|
||
|
||
val textWidthPx = remember(text, style) {
|
||
textMeasurer.measure(text, style, maxLines = 1, softWrap = false).size.width.toFloat()
|
||
}
|
||
|
||
var containerWidthPx by remember { mutableStateOf(0f) }
|
||
|
||
Box(
|
||
modifier = modifier
|
||
.fillMaxWidth()
|
||
.onSizeChanged { containerWidthPx = it.width.toFloat() }
|
||
.clipToBounds()
|
||
) {
|
||
val overflowPx = if (containerWidthPx > 0f)
|
||
(textWidthPx - containerWidthPx).coerceAtLeast(0f) else 0f
|
||
|
||
if (overflowPx > 0f) {
|
||
key(text) {
|
||
val velocityPxPerMs = with(density) { velocityDpPerSecond.dp.toPx() } / 1000f
|
||
val totalScrollPx = textWidthPx + containerWidthPx
|
||
val scrollMs = (totalScrollPx / velocityPxPerMs).toInt()
|
||
val initialDelayMs = 1500
|
||
val durationMs = initialDelayMs + scrollMs
|
||
|
||
val transition = rememberInfiniteTransition(label = "ticker")
|
||
val offset by transition.animateFloat(
|
||
initialValue = 0f,
|
||
targetValue = 0f,
|
||
animationSpec = infiniteRepeatable(
|
||
animation = keyframes {
|
||
durationMillis = durationMs
|
||
0f at 0 using LinearEasing
|
||
0f at initialDelayMs using LinearEasing
|
||
-(textWidthPx + containerWidthPx) at durationMs using LinearEasing
|
||
},
|
||
repeatMode = RepeatMode.Restart
|
||
),
|
||
label = "ticker"
|
||
)
|
||
|
||
Box(
|
||
modifier = Modifier
|
||
.wrapContentWidth(align = Alignment.Start, unbounded = true)
|
||
.align(Alignment.CenterStart)
|
||
.graphicsLayer { translationX = offset }
|
||
) {
|
||
if (strokeColor != null) {
|
||
Text(
|
||
text = text,
|
||
style = style.merge(TextStyle(drawStyle = Stroke(width = strokeWidth))),
|
||
color = strokeColor,
|
||
maxLines = 1,
|
||
softWrap = false
|
||
)
|
||
}
|
||
Text(
|
||
text = text,
|
||
style = style,
|
||
color = color,
|
||
maxLines = 1,
|
||
softWrap = false
|
||
)
|
||
}
|
||
}
|
||
} else {
|
||
Box(modifier = Modifier.align(Alignment.Center)) {
|
||
if (strokeColor != null) {
|
||
Text(
|
||
text = text,
|
||
style = style.merge(TextStyle(drawStyle = Stroke(width = strokeWidth))),
|
||
color = strokeColor,
|
||
maxLines = 1,
|
||
softWrap = false
|
||
)
|
||
}
|
||
Text(
|
||
text = text,
|
||
style = style,
|
||
color = color,
|
||
maxLines = 1,
|
||
softWrap = false
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Update the call site in `TrackInfoSection` (line 578)**
|
||
|
||
Change:
|
||
```kotlin
|
||
BounceMarqueeText(
|
||
```
|
||
To:
|
||
```kotlin
|
||
TickerText(
|
||
```
|
||
|
||
**Step 3: Remove unused imports if any**
|
||
|
||
The `FastOutSlowInEasing` import (line 8) may now be unused. Check and remove if so. `RepeatMode` is still used (Restart).
|
||
|
||
**Step 4: Build the project**
|
||
|
||
Run: `./gradlew assembleDebug`
|
||
Expected: BUILD SUCCESSFUL
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt
|
||
git commit -m "feat: replace bounce marquee with constant-speed ticker
|
||
|
||
Replaces BounceMarqueeText with TickerText that scrolls at a fixed
|
||
33 dp/s regardless of text length. Text scrolls left off-screen with
|
||
a container-width gap before looping. Initial 1.5s delay lets the
|
||
user read the beginning."
|
||
```
|
||
|
||
### Task 2: Manual verification
|
||
|
||
**Step 1: Install and launch on device/emulator**
|
||
|
||
Run: `./gradlew installDebug`
|
||
|
||
**Step 2: Verify ticker behavior**
|
||
|
||
1. Play a station with a long track title
|
||
2. Confirm text scrolls smoothly leftward at a comfortable reading pace
|
||
3. Confirm text disappears off the left, gap passes, text reappears from start
|
||
4. Confirm short titles remain centered and static
|
||
5. Confirm the 1.5s initial pause is visible before scrolling starts
|