Files
Android-247-Radio/docs/plans/2026-03-11-playback-and-ui-fixes-implementation.md
cottongin a3a00582a0 docs: add playback and UI fixes implementation plan
14 tasks covering cleartext HTTP, state machine refactor, URL
fallback, miniplayer insets, per-station quality, and playlist
management (import naming, rename, pin/unpin, drag reorder).

Made-with: Cursor
2026-03-11 16:08:00 -04:00

47 KiB

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

android:networkSecurityConfig="@xml/network_security_config"

The <application> tag should read:

<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

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:

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

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:

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

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:

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:

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:

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

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:

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

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

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.

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

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:

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:

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

Replace the Row modifier's padding:

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

The full Row modifier becomes:

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

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:

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:

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:

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

Add the abstract DAO:

abstract fun stationPreferenceDao(): StationPreferenceDao

Add migration:

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

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:

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:

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:

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

Step 4: Update StreamResolverTest

Add a mock for StationPreferenceDao and update existing tests:

private val stationPrefDao = mockk<StationPreferenceDao>()

In each test, add:

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

And update the StreamResolver construction:

val resolver = StreamResolver(streamDao, prefs, stationPrefDao)

Add a new test:

@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

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:

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:

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

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:

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

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

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

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

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.

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:

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

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

Update importFile:

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:

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

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

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:

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:

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:

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:

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

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:

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

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

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