Add ticker text implementation plan
Two-task plan: replace BounceMarqueeText with TickerText composable, then manually verify on device. Made-with: Cursor
This commit is contained in:
168
docs/plans/2026-03-11-ticker-text-implementation.md
Normal file
168
docs/plans/2026-03-11-ticker-text-implementation.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user