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