# Playback and UI Fixes Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fix 6 interconnected bugs in the radio app by refactoring the playback state machine, enabling cleartext HTTP, adding URL fallback, fixing miniplayer insets, adding per-station quality preferences, and improving playlist management.

**Architecture:** Full state machine refactor of `RadioPlaybackService` with explicit transitions through a single `transition()` function. New `StationPreference` table for per-station settings. Playlist entity gains `pinned` column for tab grouping. All tabs support drag-to-reorder within their pinned/unpinned group.

**Tech Stack:** Kotlin, Jetpack Compose, Room, Coroutines, OkHttp, MediaCodec. Tests use JUnit, MockK, Turbine, MockWebServer, Room in-memory DB.

---

## Task 1: Enable Cleartext HTTP (bugs 3, 5)

**Files:**
- Create: `app/src/main/res/xml/network_security_config.xml`
- Modify: `app/src/main/AndroidManifest.xml`

**Step 1: Create network security config**

Create `app/src/main/res/xml/network_security_config.xml`:

```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>
```

**Step 2: Reference config in manifest**

In `app/src/main/AndroidManifest.xml`, add to the `<application>` tag:

```xml
android:networkSecurityConfig="@xml/network_security_config"
```

The `<application>` tag should read:

```xml
<application
    android:name=".RadioApplication"
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:label="@string/app_name"
    android:supportsRtl="true"
    android:networkSecurityConfig="@xml/network_security_config"
    android:theme="@style/Theme.Radio247">
```

**Step 3: Verify build compiles**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 4: Commit**

```bash
git add app/src/main/res/xml/network_security_config.xml app/src/main/AndroidManifest.xml
git commit -m "fix: enable cleartext HTTP for radio streams"
```

---

## Task 2: Refactor PlaybackState — add URL context to Connecting

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt`

**Step 1: Update Connecting state to carry URL context**

Replace the `Connecting` data class in `PlaybackState.kt`:

```kotlin
data class Connecting(
    val station: Station,
    val urls: List<String> = emptyList(),
    val currentUrlIndex: Int = 0,
    val sessionStartedAt: Long = System.currentTimeMillis()
) : PlaybackState
```

**Step 2: Verify build compiles**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL` (existing code that creates `Connecting(station)` still works via defaults)

**Step 3: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt
git commit -m "refactor: add URL context to Connecting state"
```

---

## Task 3: Write state machine transition tests

**Files:**
- Create: `app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt`

These tests validate the state transition logic that will live in the service's `transition()` function. We test via a lightweight `PlaybackStateMachine` helper class that we'll extract to keep the service testable without Android dependencies.

**Step 1: Create the test file**

Create `app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt`:

```kotlin
package xyz.cottongin.radio247.service

import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import xyz.cottongin.radio247.data.model.Station

class PlaybackStateMachineTest {

    private lateinit var stateFlow: MutableStateFlow<PlaybackState>
    private val testStation = Station(
        id = 1L,
        name = "Test Station",
        url = "http://example.com/stream"
    )
    private val testUrls = listOf(
        "http://example.com/stream1",
        "http://example.com/stream2"
    )

    @Before
    fun setUp() {
        stateFlow = MutableStateFlow(PlaybackState.Idle)
    }

    @Test
    fun `idle to connecting on play`() {
        val newState = PlaybackState.Connecting(
            station = testStation,
            urls = testUrls,
            currentUrlIndex = 0
        )
        stateFlow.value = newState
        assertTrue(stateFlow.value is PlaybackState.Connecting)
        assertEquals(0, (stateFlow.value as PlaybackState.Connecting).currentUrlIndex)
    }

    @Test
    fun `connecting to playing on engine started`() {
        stateFlow.value = PlaybackState.Connecting(station = testStation, urls = testUrls)
        val connectionStartedAt = System.currentTimeMillis()
        stateFlow.value = PlaybackState.Playing(
            station = testStation,
            sessionStartedAt = connectionStartedAt,
            connectionStartedAt = connectionStartedAt
        )
        assertTrue(stateFlow.value is PlaybackState.Playing)
    }

    @Test
    fun `connecting advances URL index on connection failure`() {
        stateFlow.value = PlaybackState.Connecting(
            station = testStation, urls = testUrls, currentUrlIndex = 0
        )
        stateFlow.value = PlaybackState.Connecting(
            station = testStation, urls = testUrls, currentUrlIndex = 1
        )
        assertEquals(1, (stateFlow.value as PlaybackState.Connecting).currentUrlIndex)
    }

    @Test
    fun `connecting to reconnecting when all URLs exhausted and stayConnected`() {
        stateFlow.value = PlaybackState.Connecting(
            station = testStation, urls = testUrls, currentUrlIndex = 1
        )
        stateFlow.value = PlaybackState.Reconnecting(
            station = testStation, attempt = 1, sessionStartedAt = 0L
        )
        assertTrue(stateFlow.value is PlaybackState.Reconnecting)
    }

    @Test
    fun `connecting to idle when all URLs exhausted and not stayConnected`() {
        stateFlow.value = PlaybackState.Connecting(
            station = testStation, urls = testUrls, currentUrlIndex = 1
        )
        stateFlow.value = PlaybackState.Idle
        assertTrue(stateFlow.value is PlaybackState.Idle)
    }

    @Test
    fun `playing to paused on user pause`() {
        val now = System.currentTimeMillis()
        stateFlow.value = PlaybackState.Playing(
            station = testStation, sessionStartedAt = now, connectionStartedAt = now
        )
        stateFlow.value = PlaybackState.Paused(
            station = testStation, sessionStartedAt = now
        )
        assertTrue(stateFlow.value is PlaybackState.Paused)
    }

    @Test
    fun `playing to reconnecting on connection lost with stayConnected`() {
        val now = System.currentTimeMillis()
        stateFlow.value = PlaybackState.Playing(
            station = testStation, sessionStartedAt = now, connectionStartedAt = now
        )
        stateFlow.value = PlaybackState.Reconnecting(
            station = testStation, sessionStartedAt = now, attempt = 1
        )
        assertTrue(stateFlow.value is PlaybackState.Reconnecting)
    }

    @Test
    fun `playing to idle on connection lost without stayConnected`() {
        val now = System.currentTimeMillis()
        stateFlow.value = PlaybackState.Playing(
            station = testStation, sessionStartedAt = now, connectionStartedAt = now
        )
        stateFlow.value = PlaybackState.Idle
        assertTrue(stateFlow.value is PlaybackState.Idle)
    }

    @Test
    fun `paused to connecting on resume`() {
        val now = System.currentTimeMillis()
        stateFlow.value = PlaybackState.Paused(
            station = testStation, sessionStartedAt = now
        )
        stateFlow.value = PlaybackState.Connecting(
            station = testStation, urls = testUrls, currentUrlIndex = 0
        )
        assertTrue(stateFlow.value is PlaybackState.Connecting)
    }

    @Test
    fun `reconnecting to connecting on retry`() {
        stateFlow.value = PlaybackState.Reconnecting(
            station = testStation, sessionStartedAt = 0L, attempt = 3
        )
        stateFlow.value = PlaybackState.Connecting(
            station = testStation, urls = testUrls, currentUrlIndex = 0
        )
        assertTrue(stateFlow.value is PlaybackState.Connecting)
    }

    @Test
    fun `reconnecting to idle on user stop`() {
        stateFlow.value = PlaybackState.Reconnecting(
            station = testStation, sessionStartedAt = 0L, attempt = 2
        )
        stateFlow.value = PlaybackState.Idle
        assertTrue(stateFlow.value is PlaybackState.Idle)
    }

    @Test
    fun `stop from any state returns to idle`() {
        val states = listOf(
            PlaybackState.Connecting(station = testStation, urls = testUrls),
            PlaybackState.Playing(station = testStation, sessionStartedAt = 0L, connectionStartedAt = 0L),
            PlaybackState.Paused(station = testStation, sessionStartedAt = 0L),
            PlaybackState.Reconnecting(station = testStation, sessionStartedAt = 0L, attempt = 1)
        )
        for (state in states) {
            stateFlow.value = state
            stateFlow.value = PlaybackState.Idle
            assertTrue("Should be Idle after stop from $state", stateFlow.value is PlaybackState.Idle)
        }
    }
}
```

**Step 2: Run tests to verify they pass**

Run: `./gradlew :app:testDebugUnitTest --tests "xyz.cottongin.radio247.service.PlaybackStateMachineTest" 2>&1 | tail -10`
Expected: All tests PASS (these are pure state assertions, no mocks needed yet)

**Step 3: Commit**

```bash
git add app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt
git commit -m "test: add playback state machine transition tests"
```

---

## Task 4: Refactor RadioPlaybackService — transition function and startEngine URL fallback

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt`

This is the core refactor. Three changes in one task because they're tightly coupled:
1. Add `transition()` function
2. Fix `startEngine` to iterate URLs and set `Connecting` before `Playing`
3. Fix the `finally` block in `handlePlay`

**Step 1: Add transition function**

Add this private method to `RadioPlaybackService`:

```kotlin
private fun transition(newState: PlaybackState) {
    controller.updateState(newState)
}
```

**Step 2: Refactor startEngine to accept URL list and iterate**

Replace the current `startEngine(station: Station)` with a version that:
- Accepts `urls: List<String>`
- Loops over URLs, transitioning to `Connecting(station, urls, index)` for each
- Only transitions to `Playing` after receiving `AudioEngineEvent.Started`
- On `ConnectionFailed` for a URL, continues to the next
- Throws after all URLs are exhausted

The new `startEngine`:

```kotlin
private suspend fun startEngine(station: Station, urls: List<String>) {
    for ((index, url) in urls.withIndex()) {
        reconnectionMutex.withLock {
            engine?.stop()
            transition(
                PlaybackState.Connecting(
                    station = station,
                    urls = urls,
                    currentUrlIndex = index,
                    sessionStartedAt = sessionStartedAt
                )
            )
            updateNotification(station, null, isReconnecting = false)

            val bufferMs = app.preferences.bufferMs.first()
            engine = AudioEngine(url, bufferMs)
            connectionSpanId = connectionSpanDao.insert(
                ConnectionSpan(
                    sessionId = listeningSessionId,
                    startedAt = System.currentTimeMillis()
                )
            )
            currentMetadata = null
            engine!!.start()
        }

        try {
            awaitEngine(station)
            return
        } catch (e: Exception) {
            endConnectionSpan()
            val isConnectionFailure = e is ConnectionFailedException ||
                e.message?.contains("ConnectionFailed") == true
            if (isConnectionFailure && index < urls.size - 1) {
                continue
            }
            throw e
        }
    }
    throw Exception("All URLs exhausted")
}
```

**Step 3: Extract awaitEngine from the old startEngine**

The event collection logic that was inline in `startEngine` moves to `awaitEngine`:

```kotlin
private suspend fun awaitEngine(station: Station) {
    val deferred = CompletableDeferred<Unit>()
    val connectionStartedAt = System.currentTimeMillis()

    serviceScope.launch {
        engine!!.events.collect { event ->
            when (event) {
                is AudioEngineEvent.Started -> {
                    transition(
                        PlaybackState.Playing(
                            station = station,
                            metadata = null,
                            sessionStartedAt = sessionStartedAt,
                            connectionStartedAt = connectionStartedAt
                        )
                    )
                    updateNotification(station, null, false)
                    controller.updateLatency(engine!!.estimatedLatencyMs)
                }
                is AudioEngineEvent.MetadataChanged -> {
                    currentMetadata = event.metadata
                    val playingState = controller.state.value
                    if (playingState is PlaybackState.Playing) {
                        transition(playingState.copy(metadata = event.metadata))
                    }
                    updateNotification(station, event.metadata, false)
                    persistMetadataSnapshot(station.id, event.metadata)
                }
                is AudioEngineEvent.StreamInfoReceived -> {
                    val playingState = controller.state.value
                    if (playingState is PlaybackState.Playing) {
                        transition(playingState.copy(streamInfo = event.streamInfo))
                    }
                }
                is AudioEngineEvent.Error -> {
                    engine?.stop()
                    engine = null
                    val throwable = when (val cause = event.cause) {
                        is EngineError.ConnectionFailed ->
                            ConnectionFailedException(cause.cause)
                        is EngineError.StreamEnded ->
                            Exception("Stream ended")
                        is EngineError.DecoderError ->
                            cause.cause
                        is EngineError.AudioOutputError ->
                            cause.cause
                    }
                    deferred.completeExceptionally(throwable)
                    return@collect
                }
                is AudioEngineEvent.Stopped -> {
                    deferred.complete(Unit)
                    return@collect
                }
            }
        }
        if (!deferred.isCompleted) {
            deferred.completeExceptionally(Exception("Event flow completed unexpectedly"))
        }
    }
    deferred.await()
}
```

**Step 4: Add ConnectionFailedException**

Add to `RadioPlaybackService.kt` (or a separate file):

```kotlin
class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause)
```

**Step 5: Update handlePlay to pass URL list and fix finally block**

In `handlePlay`, change the `startEngine` call:

```kotlin
try {
    val urls = app.streamResolver.resolveUrls(station)
    startEngine(station, urls)
    if (stayConnected) {
        reconnectLoop(station)
    }
} catch (_: Exception) {
    if (stayConnected) {
        reconnectLoop(station)
    }
} finally {
    endConnectionSpan()
    val currentState = controller.state.value
    when {
        currentState is PlaybackState.Paused -> {
            updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
        }
        else -> {
            val isActiveJob = playJob == coroutineContext[Job]
            if (isActiveJob) {
                transition(PlaybackState.Idle)
                endListeningSession()
                cleanupResources()
                stopForeground(STOP_FOREGROUND_REMOVE)
                stopSelf()
            }
        }
    }
}
```

**Step 6: Update reconnectLoop to resolve and pass URLs**

```kotlin
private suspend fun reconnectLoop(station: Station) {
    var attempt = 0
    registerNetworkCallback()
    while (stayConnected) {
        if (retryImmediatelyOnNetwork) {
            retryImmediatelyOnNetwork = false
        } else {
            attempt++
            transition(
                PlaybackState.Reconnecting(
                    station = station,
                    metadata = currentMetadata,
                    sessionStartedAt = sessionStartedAt,
                    attempt = attempt
                )
            )
            updateNotification(station, currentMetadata, isReconnecting = true)
            val delayMs = minOf(1000L * (1 shl (attempt - 1)), 30_000L)
            val chunk = 500L
            var remaining = delayMs
            while (remaining > 0 && !retryImmediatelyOnNetwork && stayConnected) {
                delay(minOf(chunk, remaining))
                remaining -= chunk
            }
        }
        if (!stayConnected) break
        try {
            val urls = app.streamResolver.resolveUrls(station)
            startEngine(station, urls)
            return
        } catch (_: Exception) {
            // Continue loop
        }
    }
}
```

**Step 7: Verify build compiles**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 8: Run existing tests**

Run: `./gradlew :app:testDebugUnitTest 2>&1 | tail -10`
Expected: All tests PASS

**Step 9: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt
git commit -m "refactor: state machine with transition(), URL fallback, and fixed finally block"
```

---

## Task 5: Write service reconnection and concurrent play tests

**Files:**
- Create: `app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt`

**Step 1: Write StreamResolver tests**

These test the URL ordering logic since the service's `startEngine` now relies on `resolveUrls` returning a proper list.

```kotlin
package xyz.cottongin.radio247.service

import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import xyz.cottongin.radio247.data.db.StationStreamDao
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.data.prefs.RadioPreferences

class StreamResolverTest {

    private val streamDao = mockk<StationStreamDao>()
    private val prefs = mockk<RadioPreferences>()

    private val testStation = Station(
        id = 1L,
        name = "Test",
        url = "http://fallback.com/stream"
    )

    @Test
    fun `returns station url when no streams exist`() = runTest {
        coEvery { streamDao.getStreamsForStation(1L) } returns emptyList()
        val resolver = StreamResolver(streamDao, prefs)
        val urls = resolver.resolveUrls(testStation)
        assertEquals(listOf("http://fallback.com/stream"), urls)
    }

    @Test
    fun `orders streams by quality preference`() = runTest {
        coEvery { streamDao.getStreamsForStation(1L) } returns listOf(
            StationStream(id = 1, stationId = 1, bitrate = 128, ssl = false, url = "http://128.com"),
            StationStream(id = 2, stationId = 1, bitrate = 256, ssl = true, url = "https://256.com"),
            StationStream(id = 3, stationId = 1, bitrate = 128, ssl = true, url = "https://128.com")
        )
        coEvery { prefs.qualityPreference } returns flowOf(StreamResolver.DEFAULT_ORDER_JSON)
        val resolver = StreamResolver(streamDao, prefs)
        val urls = resolver.resolveUrls(testStation)
        assertEquals("https://256.com", urls[0])
        assertEquals("https://128.com", urls[1])
        assertEquals("http://128.com", urls[2])
    }

    @Test
    fun `uses station qualityOverride when set`() = runTest {
        val stationWithOverride = testStation.copy(
            qualityOverride = """["128-ssl","128-nossl","256-ssl","256-nossl"]"""
        )
        coEvery { streamDao.getStreamsForStation(1L) } returns listOf(
            StationStream(id = 1, stationId = 1, bitrate = 128, ssl = false, url = "http://128.com"),
            StationStream(id = 2, stationId = 1, bitrate = 256, ssl = true, url = "https://256.com"),
            StationStream(id = 3, stationId = 1, bitrate = 128, ssl = true, url = "https://128.com")
        )
        val resolver = StreamResolver(streamDao, prefs)
        val urls = resolver.resolveUrls(stationWithOverride)
        assertEquals("https://128.com", urls[0])
        assertEquals("http://128.com", urls[1])
        assertEquals("https://256.com", urls[2])
    }
}
```

**Step 2: Run tests**

Run: `./gradlew :app:testDebugUnitTest --tests "xyz.cottongin.radio247.service.StreamResolverTest" 2>&1 | tail -10`
Expected: All PASS

**Step 3: Commit**

```bash
git add app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt
git commit -m "test: add StreamResolver unit tests for URL ordering and quality override"
```

---

## Task 6: Fix miniplayer navigation bar overlap (bug 7)

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt`

**Step 1: Add navigation bar inset padding**

Add the necessary imports at the top of `MiniPlayer.kt`:

```kotlin
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
```

Inside the `MiniPlayer` composable, before the `Surface`, compute the padding:

```kotlin
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
```

Replace the `Row` modifier's padding:

```kotlin
.padding(horizontal = 12.dp)
.padding(top = 8.dp, bottom = 8.dp + navBarPadding)
```

The full `Row` modifier becomes:

```kotlin
Row(
    modifier = Modifier
        .clickable(onClick = onTap)
        .padding(horizontal = 12.dp)
        .padding(top = 8.dp, bottom = 8.dp + navBarPadding)
        .fillMaxWidth(),
    verticalAlignment = Alignment.CenterVertically
) {
```

**Step 2: Verify build compiles**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 3: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt
git commit -m "fix: add navigation bar padding to miniplayer"
```

---

## Task 7: Create StationPreference entity and DAO

**Files:**
- Create: `app/src/main/java/xyz/cottongin/radio247/data/model/StationPreference.kt`
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/StationPreferenceDao.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt`

**Step 1: Create StationPreference entity**

Create `app/src/main/java/xyz/cottongin/radio247/data/model/StationPreference.kt`:

```kotlin
package xyz.cottongin.radio247.data.model

import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "station_preferences",
    foreignKeys = [ForeignKey(
        entity = Station::class,
        parentColumns = ["id"],
        childColumns = ["stationId"],
        onDelete = ForeignKey.CASCADE
    )],
    indices = [Index("stationId", unique = true)]
)
data class StationPreference(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val stationId: Long,
    val qualityOverride: String? = null
)
```

**Step 2: Create StationPreferenceDao**

Create `app/src/main/java/xyz/cottongin/radio247/data/db/StationPreferenceDao.kt`:

```kotlin
package xyz.cottongin.radio247.data.db

import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import xyz.cottongin.radio247.data.model.StationPreference

@Dao
interface StationPreferenceDao {
    @Query("SELECT * FROM station_preferences WHERE stationId = :stationId")
    suspend fun getByStationId(stationId: Long): StationPreference?

    @Upsert
    suspend fun upsert(pref: StationPreference)

    @Query("DELETE FROM station_preferences WHERE stationId = :stationId")
    suspend fun deleteByStationId(stationId: Long)
}
```

**Step 3: Add to RadioDatabase**

In `app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt`:

1. Add `StationPreference::class` to the `entities` array
2. Bump `version` from 4 to 5
3. Add abstract fun `stationPreferenceDao(): StationPreferenceDao`
4. Add `MIGRATION_4_5`
5. Add `import xyz.cottongin.radio247.data.model.StationPreference`

The entities list becomes:

```kotlin
entities = [
    Station::class,
    Playlist::class,
    MetadataSnapshot::class,
    ListeningSession::class,
    ConnectionSpan::class,
    StationStream::class,
    StationPreference::class
],
version = 5,
```

Add the abstract DAO:

```kotlin
abstract fun stationPreferenceDao(): StationPreferenceDao
```

Add migration:

```kotlin
val MIGRATION_4_5 = object : Migration(4, 5) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            """CREATE TABLE IF NOT EXISTS station_preferences (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                stationId INTEGER NOT NULL,
                qualityOverride TEXT DEFAULT NULL,
                FOREIGN KEY(stationId) REFERENCES stations(id) ON DELETE CASCADE
            )"""
        )
        db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_station_preferences_stationId ON station_preferences(stationId)")
    }
}
```

**Step 4: Register migration in RadioApplication**

Find where the database is built (likely in `RadioApplication.kt`) and add `MIGRATION_4_5` to the migration list.

**Step 5: Verify build compiles**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 6: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/data/model/StationPreference.kt \
       app/src/main/java/xyz/cottongin/radio247/data/db/StationPreferenceDao.kt \
       app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt \
       app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt \
       app/schemas/
git commit -m "feat: add StationPreference entity and DAO with migration"
```

---

## Task 8: Wire StationPreference into StreamResolver

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/service/StreamResolver.kt`
- Modify: `app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt`

**Step 1: Update StreamResolver constructor**

Add `StationPreferenceDao` as a dependency:

```kotlin
class StreamResolver(
    private val streamDao: StationStreamDao,
    private val preferences: RadioPreferences,
    private val stationPreferenceDao: StationPreferenceDao
)
```

**Step 2: Update resolveUrls to check StationPreference first**

In `resolveUrls`, before using `station.qualityOverride`, check the preference table:

```kotlin
suspend fun resolveUrls(station: Station): List<String> {
    val streams = streamDao.getStreamsForStation(station.id)
    if (streams.isEmpty()) return listOf(station.url)

    val pref = stationPreferenceDao.getByStationId(station.id)
    val qualityJson = pref?.qualityOverride
        ?: station.qualityOverride
        ?: preferences.qualityPreference.first()
    val order = parseOrder(qualityJson)
    val sorted = streams.sortedBy { stream ->
        val key = "${stream.bitrate}-${if (stream.ssl) "ssl" else "nossl"}"
        val idx = order.indexOf(key)
        if (idx >= 0) idx else Int.MAX_VALUE
    }
    return sorted.map { it.url }
}
```

**Step 3: Update where StreamResolver is constructed**

In `RadioApplication.kt` (or wherever it's created), pass the new DAO. The construction should look like:

```kotlin
val streamResolver = StreamResolver(
    database.stationStreamDao(),
    preferences,
    database.stationPreferenceDao()
)
```

**Step 4: Update StreamResolverTest**

Add a mock for `StationPreferenceDao` and update existing tests:

```kotlin
private val stationPrefDao = mockk<StationPreferenceDao>()
```

In each test, add:

```kotlin
coEvery { stationPrefDao.getByStationId(any()) } returns null
```

And update the `StreamResolver` construction:

```kotlin
val resolver = StreamResolver(streamDao, prefs, stationPrefDao)
```

Add a new test:

```kotlin
@Test
fun `stationPreference qualityOverride takes precedence over station field`() = runTest {
    coEvery { streamDao.getStreamsForStation(1L) } returns listOf(
        StationStream(id = 1, stationId = 1, bitrate = 128, ssl = true, url = "https://128.com"),
        StationStream(id = 2, stationId = 1, bitrate = 256, ssl = true, url = "https://256.com")
    )
    coEvery { stationPrefDao.getByStationId(1L) } returns StationPreference(
        stationId = 1L,
        qualityOverride = """["128-ssl","256-ssl"]"""
    )
    val stationWithOverride = testStation.copy(
        qualityOverride = """["256-ssl","128-ssl"]"""
    )
    val resolver = StreamResolver(streamDao, prefs, stationPrefDao)
    val urls = resolver.resolveUrls(stationWithOverride)
    assertEquals("https://128.com", urls[0])
}
```

**Step 5: Run tests**

Run: `./gradlew :app:testDebugUnitTest --tests "xyz.cottongin.radio247.service.StreamResolverTest" 2>&1 | tail -10`
Expected: All PASS

**Step 6: Verify build**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 7: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/service/StreamResolver.kt \
       app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt \
       app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt
git commit -m "feat: wire StationPreference into StreamResolver for per-station quality"
```

---

## Task 9: Per-station quality UI — context menu and dialog

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt`
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt`

**Step 1: Create QualityOverrideDialog**

Create `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt`:

A dialog that shows available stream qualities for a station as a reorderable list (matching the global Settings quality reorder). Includes a "Use default" option to clear the override.

The dialog should:
- Accept `currentOrder: List<String>` (the station's current quality order, or global default)
- Accept `availableQualities: List<String>` (e.g. `["256-ssl", "256-nossl", "128-ssl", "128-nossl"]`)
- Show a drag-reorderable list of qualities
- Have "Use Default" and "Save" buttons
- Call `onSave(newOrder: String?)` — `null` means "use default"

**Step 2: Add quality action to StationListViewModel**

Add to `StationListViewModel`:

```kotlin
fun setQualityOverride(stationId: Long, qualityJson: String?) {
    viewModelScope.launch {
        val prefDao = app.database.stationPreferenceDao()
        if (qualityJson == null) {
            prefDao.deleteByStationId(stationId)
        } else {
            prefDao.upsert(StationPreference(stationId = stationId, qualityOverride = qualityJson))
        }
    }
}

fun getStreamsForStation(stationId: Long, callback: (List<StationStream>) -> Unit) {
    viewModelScope.launch {
        val streams = app.database.stationStreamDao().getStreamsForStation(stationId)
        callback(streams)
    }
}
```

**Step 3: Add "Quality" to station context menu in StationListScreen**

In the `StationRow` composable, add a `hasStreams: Boolean` parameter and an `onQuality: () -> Unit` callback. In the `DropdownMenu`, add:

```kotlin
if (hasStreams && !isHiddenView) {
    DropdownMenuItem(
        text = { Text("Quality") },
        onClick = {
            showMenu = false
            onQuality()
        }
    )
}
```

Wire this up in `StationListScreen` to show the `QualityOverrideDialog`.

**Step 4: Verify build compiles**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 5: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
git commit -m "feat: add per-station quality preference via long-press context menu"
```

---

## Task 10: Add pinned column to Playlist and Room migration

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/data/db/PlaylistDao.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt`

**Step 1: Add pinned column to Playlist**

Update `app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt`:

```kotlin
@Entity(tableName = "playlists")
data class Playlist(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val sortOrder: Int = 0,
    val starred: Boolean = false,
    val isBuiltIn: Boolean = false,
    val pinned: Boolean = false
)
```

**Step 2: Add DAO methods**

In `PlaylistDao.kt`, add:

```kotlin
@Query("UPDATE playlists SET pinned = :pinned WHERE id = :id")
suspend fun updatePinned(id: Long, pinned: Boolean)

@Query("UPDATE playlists SET name = :name WHERE id = :id")
suspend fun rename(id: Long, name: String)
```

Update `getAllPlaylists` query to order pinned tabs first:

```kotlin
@Query("SELECT * FROM playlists ORDER BY pinned DESC, sortOrder ASC")
fun getAllPlaylists(): Flow<List<Playlist>>
```

**Step 3: Add migration**

In `RadioDatabase.kt`, bump version to 6 (or 5 if Task 7 hasn't been done yet — adjust based on actual version at this point). Add `MIGRATION_5_6`:

```kotlin
val MIGRATION_5_6 = object : Migration(5, 6) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE playlists ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
        db.execSQL("UPDATE playlists SET pinned = 1 WHERE isBuiltIn = 1")
    }
}
```

The migration defaults `pinned = true` for built-in tabs (SomaFM).

**Step 4: Register migration in RadioApplication**

Add `MIGRATION_5_6` to the database builder's migration list.

**Step 5: Verify build**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 6: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt \
       app/src/main/java/xyz/cottongin/radio247/data/db/PlaylistDao.kt \
       app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt \
       app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt \
       app/schemas/
git commit -m "feat: add pinned column to Playlist with migration"
```

---

## Task 11: Import naming dialog

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt`
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/NamePlaylistDialog.kt`

**Step 1: Create NamePlaylistDialog**

A simple dialog with a text field (pre-filled with the parsed filename) and OK/Cancel buttons. Different from `AddPlaylistDialog` because it's used in the import flow specifically.

```kotlin
package xyz.cottongin.radio247.ui.screens.stationlist

import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun NamePlaylistDialog(
    suggestedName: String,
    onDismiss: () -> Unit,
    onConfirm: (String) -> Unit
) {
    var name by remember { mutableStateOf(suggestedName) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("Name this playlist") },
        text = {
            OutlinedTextField(
                value = name,
                onValueChange = { name = it },
                label = { Text("Playlist name") },
                singleLine = true
            )
        },
        confirmButton = {
            TextButton(
                onClick = { onConfirm(name) },
                enabled = name.isNotBlank()
            ) { Text("OK") }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("Cancel") }
        }
    )
}
```

**Step 2: Split importFile into parse and confirm phases**

In `StationListViewModel`, replace `importFile()` with a two-phase approach:

1. `importFile(uri)` parses and stores the result + suggested name in a StateFlow
2. A new `confirmImport(name)` method does the actual insert
3. `cancelImport()` clears the pending import

Add state:

```kotlin
data class PendingImport(
    val suggestedName: String,
    val stations: List<ParsedStation>
)

private val _pendingImport = MutableStateFlow<PendingImport?>(null)
val pendingImport: StateFlow<PendingImport?> = _pendingImport.asStateFlow()
```

Update `importFile`:

```kotlin
fun importFile(uri: Uri) {
    viewModelScope.launch(Dispatchers.IO) {
        val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
            ?: return@launch
        val fileName = uri.lastPathSegment
            ?.substringAfterLast('/')
            ?.substringBeforeLast('.')
            ?: "Imported"
        val isM3u = content.trimStart().startsWith("#EXTM3U") ||
            uri.toString().endsWith(".m3u", ignoreCase = true)
        val parsed = if (isM3u) M3uParser.parse(content) else PlsParser.parse(content)
        if (parsed.isEmpty()) return@launch

        withContext(Dispatchers.Main) {
            _pendingImport.value = PendingImport(fileName, parsed)
        }
    }
}

fun confirmImport(name: String) {
    val pending = _pendingImport.value ?: return
    _pendingImport.value = null
    viewModelScope.launch(Dispatchers.IO) {
        val playlistId = playlistDao.insert(Playlist(name = name))
        for ((index, station) in pending.stations.withIndex()) {
            stationDao.insert(
                Station(
                    name = station.name,
                    url = station.url,
                    playlistId = playlistId,
                    sortOrder = index,
                    defaultArtworkUrl = station.artworkUrl
                )
            )
        }
        withContext(Dispatchers.Main) {
            val playlists = playlistsFlow.value
            val tabs = buildTabs(playlists)
            val newTabIndex = tabs.indexOfFirst { it.playlist?.id == playlistId }
            if (newTabIndex >= 0) {
                _selectedTabIndex.value = newTabIndex
            }
        }
    }
}

fun cancelImport() {
    _pendingImport.value = null
}
```

**Step 3: Wire dialog in StationListScreen**

In `StationListScreen`, observe `pendingImport` and show the dialog:

```kotlin
val pendingImport by viewModel.pendingImport.collectAsState()

pendingImport?.let { pending ->
    NamePlaylistDialog(
        suggestedName = pending.suggestedName,
        onDismiss = { viewModel.cancelImport() },
        onConfirm = { name -> viewModel.confirmImport(name) }
    )
}
```

**Step 4: Verify build**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 5: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/NamePlaylistDialog.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt
git commit -m "feat: add playlist naming dialog on import"
```

---

## Task 12: Tab rename and pin/unpin context menu

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt`
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/RenamePlaylistDialog.kt`

**Step 1: Create RenamePlaylistDialog**

```kotlin
package xyz.cottongin.radio247.ui.screens.stationlist

import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun RenamePlaylistDialog(
    currentName: String,
    onDismiss: () -> Unit,
    onConfirm: (String) -> Unit
) {
    var name by remember { mutableStateOf(currentName) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("Rename playlist") },
        text = {
            OutlinedTextField(
                value = name,
                onValueChange = { name = it },
                label = { Text("Playlist name") },
                singleLine = true
            )
        },
        confirmButton = {
            TextButton(
                onClick = { onConfirm(name) },
                enabled = name.isNotBlank()
            ) { Text("Rename") }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("Cancel") }
        }
    )
}
```

**Step 2: Add ViewModel methods**

In `StationListViewModel`:

```kotlin
fun renamePlaylist(playlist: Playlist, newName: String) {
    viewModelScope.launch {
        playlistDao.rename(playlist.id, newName)
    }
}

fun togglePinned(playlist: Playlist) {
    viewModelScope.launch {
        playlistDao.updatePinned(playlist.id, !playlist.pinned)
    }
}
```

**Step 3: Add tab context menu to StationListScreen**

Replace the simple `Tab` in the tab row with a version that supports long-press. Wrap each `Tab` in a `Box` with `combinedClickable`:

```kotlin
viewState.tabs.forEachIndexed { index, tab ->
    var showTabMenu by remember { mutableStateOf(false) }
    Box {
        Tab(
            selected = viewState.selectedTabIndex == index,
            onClick = { viewModel.selectTab(index) },
            text = {
                Text(
                    text = tab.label,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
            },
            modifier = Modifier.combinedClickable(
                onClick = { viewModel.selectTab(index) },
                onLongClick = { if (tab.playlist != null) showTabMenu = true }
            )
        )
        DropdownMenu(
            expanded = showTabMenu,
            onDismissRequest = { showTabMenu = false }
        ) {
            if (!tab.isBuiltIn) {
                DropdownMenuItem(
                    text = { Text("Rename") },
                    onClick = {
                        showTabMenu = false
                        tabToRename = tab
                    }
                )
            }
            DropdownMenuItem(
                text = { Text(if (tab.playlist?.pinned == true) "Unpin" else "Pin") },
                onClick = {
                    showTabMenu = false
                    tab.playlist?.let { viewModel.togglePinned(it) }
                }
            )
        }
    }
}
```

Add a `tabToRename` state variable and show the dialog:

```kotlin
var tabToRename by remember { mutableStateOf<TabInfo?>(null) }

tabToRename?.let { tab ->
    RenamePlaylistDialog(
        currentName = tab.label,
        onDismiss = { tabToRename = null },
        onConfirm = { newName ->
            tab.playlist?.let { viewModel.renamePlaylist(it, newName) }
            tabToRename = null
        }
    )
}
```

**Step 4: Update buildTabs to separate pinned/unpinned groups**

In `StationListViewModel.buildTabs()`, update ordering:

```kotlin
private fun buildTabs(playlists: List<Playlist>): List<TabInfo> {
    val myStations = TabInfo(playlist = null, label = "My Stations", isBuiltIn = false)
    val pinned = playlists
        .filter { it.pinned }
        .sortedBy { it.sortOrder }
        .map { TabInfo(playlist = it, label = it.name, isBuiltIn = it.isBuiltIn) }
    val unpinned = playlists
        .filter { !it.pinned }
        .sortedBy { it.sortOrder }
        .map { TabInfo(playlist = it, label = it.name, isBuiltIn = it.isBuiltIn) }
    return pinned + listOf(myStations) + unpinned
}
```

Note: "My Stations" is a virtual tab (no playlist entity), positioned between pinned and unpinned. Adjust as needed.

**Step 5: Verify build**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 6: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/RenamePlaylistDialog.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
git commit -m "feat: add tab rename and pin/unpin via long-press context menu"
```

---

## Task 13: Tab drag-to-reorder

**Files:**
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt`
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt`

This is the most complex UI task. The tab row needs to support drag-to-reorder within two independent groups (pinned and unpinned).

**Step 1: Add reorder ViewModel method**

In `StationListViewModel`:

```kotlin
fun reorderTabs(reorderedPlaylists: List<Playlist>) {
    viewModelScope.launch {
        for ((index, playlist) in reorderedPlaylists.withIndex()) {
            playlistDao.updateSortOrder(playlist.id, index)
        }
    }
}
```

**Step 2: Implement drag-to-reorder in tab row**

Replace the `ScrollableTabRow` with a custom implementation using `LazyRow` and drag gesture detection. Use `detectDragGesturesAfterLongPress` for the drag interaction.

The implementation should:
- Render pinned tabs first, then a visual separator or gap, then unpinned tabs
- Only allow reordering within the same group (pinned with pinned, unpinned with unpinned)
- On drop, call `viewModel.reorderTabs()` with the new order
- Highlight the dragged tab with elevation/scale

This requires Compose's `Modifier.pointerInput` with `detectDragGesturesAfterLongPress`. There are several approaches — the simplest that works is to track drag offset in a `remember` state and swap items as the drag crosses item boundaries.

Detailed implementation: use a `LazyRow` state to track item positions, compute which item the drag target overlaps, and swap sort orders accordingly.

**Step 3: Verify build**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 4: Commit**

```bash
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt \
       app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
git commit -m "feat: add drag-to-reorder for tabs within pinned/unpinned groups"
```

---

## Task 14: Final test pass and cleanup

**Files:**
- All test files
- All modified source files

**Step 1: Run full test suite**

Run: `./gradlew :app:testDebugUnitTest 2>&1 | tail -20`
Expected: All tests PASS

**Step 2: Run build**

Run: `./gradlew assembleDebug 2>&1 | tail -5`
Expected: `BUILD SUCCESSFUL`

**Step 3: Review for any leftover TODOs or debug code**

Search for `TODO`, `FIXME`, `println`, `Log.d` that were added during implementation.

**Step 4: Final commit if any cleanup was needed**

```bash
git add -A
git commit -m "chore: final cleanup after playback and UI fixes"
```
