# 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
