From 5d551c9380b218b33296a280677cd5fbd48372a1 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 11 Mar 2026 18:24:11 -0400 Subject: [PATCH] Add ticker text implementation plan Two-task plan: replace BounceMarqueeText with TickerText composable, then manually verify on device. Made-with: Cursor --- .../2026-03-11-ticker-text-implementation.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/plans/2026-03-11-ticker-text-implementation.md diff --git a/docs/plans/2026-03-11-ticker-text-implementation.md b/docs/plans/2026-03-11-ticker-text-implementation.md new file mode 100644 index 0000000..563a32b --- /dev/null +++ b/docs/plans/2026-03-11-ticker-text-implementation.md @@ -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