# 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" ```