Files
Android-247-Radio/docs/plans/2026-03-11-ticker-text-implementation.md
cottongin 5d551c9380 Add ticker text implementation plan
Two-task plan: replace BounceMarqueeText with TickerText composable,
then manually verify on device.

Made-with: Cursor
2026-03-11 18:24:11 -04:00

5.9 KiB
Raw Permalink Blame History

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 449543) with TickerText

The new composable:

@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:

        BounceMarqueeText(

To:

        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

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