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