diff --git a/docs/plans/2026-03-11-playback-and-ui-fixes-implementation.md b/docs/plans/2026-03-11-playback-and-ui-fixes-implementation.md
new file mode 100644
index 0000000..d2aae13
--- /dev/null
+++ b/docs/plans/2026-03-11-playback-and-ui-fixes-implementation.md
@@ -0,0 +1,1499 @@
+# 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
+
+
+
+
+```
+
+**Step 2: Reference config in manifest**
+
+In `app/src/main/AndroidManifest.xml`, add to the `` tag:
+
+```xml
+android:networkSecurityConfig="@xml/network_security_config"
+```
+
+The `` tag should read:
+
+```xml
+
+```
+
+**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 = 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
+ 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`
+- 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) {
+ 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()
+ 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()
+ private val prefs = mockk()
+
+ 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 {
+ 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()
+```
+
+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` (the station's current quality order, or global default)
+- Accept `availableQualities: List` (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) -> 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>
+```
+
+**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
+)
+
+private val _pendingImport = MutableStateFlow(null)
+val pendingImport: StateFlow = _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(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): List {
+ 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) {
+ 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"
+```