14 tasks covering cleartext HTTP, state machine refactor, URL fallback, miniplayer insets, per-station quality, and playlist management (import naming, rename, pin/unpin, drag reorder). Made-with: Cursor
47 KiB
Playback and UI Fixes Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Fix 6 interconnected bugs in the radio app by refactoring the playback state machine, enabling cleartext HTTP, adding URL fallback, fixing miniplayer insets, adding per-station quality preferences, and improving playlist management.
Architecture: Full state machine refactor of RadioPlaybackService with explicit transitions through a single transition() function. New StationPreference table for per-station settings. Playlist entity gains pinned column for tab grouping. All tabs support drag-to-reorder within their pinned/unpinned group.
Tech Stack: Kotlin, Jetpack Compose, Room, Coroutines, OkHttp, MediaCodec. Tests use JUnit, MockK, Turbine, MockWebServer, Room in-memory DB.
Task 1: Enable Cleartext HTTP (bugs 3, 5)
Files:
- Create:
app/src/main/res/xml/network_security_config.xml - Modify:
app/src/main/AndroidManifest.xml
Step 1: Create network security config
Create app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
Step 2: Reference config in manifest
In app/src/main/AndroidManifest.xml, add to the <application> tag:
android:networkSecurityConfig="@xml/network_security_config"
The <application> tag should read:
<application
android:name=".RadioApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Radio247">
Step 3: Verify build compiles
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 4: Commit
git add app/src/main/res/xml/network_security_config.xml app/src/main/AndroidManifest.xml
git commit -m "fix: enable cleartext HTTP for radio streams"
Task 2: Refactor PlaybackState — add URL context to Connecting
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt
Step 1: Update Connecting state to carry URL context
Replace the Connecting data class in PlaybackState.kt:
data class Connecting(
val station: Station,
val urls: List<String> = emptyList(),
val currentUrlIndex: Int = 0,
val sessionStartedAt: Long = System.currentTimeMillis()
) : PlaybackState
Step 2: Verify build compiles
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL (existing code that creates Connecting(station) still works via defaults)
Step 3: Commit
git add app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt
git commit -m "refactor: add URL context to Connecting state"
Task 3: Write state machine transition tests
Files:
- Create:
app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt
These tests validate the state transition logic that will live in the service's transition() function. We test via a lightweight PlaybackStateMachine helper class that we'll extract to keep the service testable without Android dependencies.
Step 1: Create the test file
Create app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt:
package xyz.cottongin.radio247.service
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import xyz.cottongin.radio247.data.model.Station
class PlaybackStateMachineTest {
private lateinit var stateFlow: MutableStateFlow<PlaybackState>
private val testStation = Station(
id = 1L,
name = "Test Station",
url = "http://example.com/stream"
)
private val testUrls = listOf(
"http://example.com/stream1",
"http://example.com/stream2"
)
@Before
fun setUp() {
stateFlow = MutableStateFlow(PlaybackState.Idle)
}
@Test
fun `idle to connecting on play`() {
val newState = PlaybackState.Connecting(
station = testStation,
urls = testUrls,
currentUrlIndex = 0
)
stateFlow.value = newState
assertTrue(stateFlow.value is PlaybackState.Connecting)
assertEquals(0, (stateFlow.value as PlaybackState.Connecting).currentUrlIndex)
}
@Test
fun `connecting to playing on engine started`() {
stateFlow.value = PlaybackState.Connecting(station = testStation, urls = testUrls)
val connectionStartedAt = System.currentTimeMillis()
stateFlow.value = PlaybackState.Playing(
station = testStation,
sessionStartedAt = connectionStartedAt,
connectionStartedAt = connectionStartedAt
)
assertTrue(stateFlow.value is PlaybackState.Playing)
}
@Test
fun `connecting advances URL index on connection failure`() {
stateFlow.value = PlaybackState.Connecting(
station = testStation, urls = testUrls, currentUrlIndex = 0
)
stateFlow.value = PlaybackState.Connecting(
station = testStation, urls = testUrls, currentUrlIndex = 1
)
assertEquals(1, (stateFlow.value as PlaybackState.Connecting).currentUrlIndex)
}
@Test
fun `connecting to reconnecting when all URLs exhausted and stayConnected`() {
stateFlow.value = PlaybackState.Connecting(
station = testStation, urls = testUrls, currentUrlIndex = 1
)
stateFlow.value = PlaybackState.Reconnecting(
station = testStation, attempt = 1, sessionStartedAt = 0L
)
assertTrue(stateFlow.value is PlaybackState.Reconnecting)
}
@Test
fun `connecting to idle when all URLs exhausted and not stayConnected`() {
stateFlow.value = PlaybackState.Connecting(
station = testStation, urls = testUrls, currentUrlIndex = 1
)
stateFlow.value = PlaybackState.Idle
assertTrue(stateFlow.value is PlaybackState.Idle)
}
@Test
fun `playing to paused on user pause`() {
val now = System.currentTimeMillis()
stateFlow.value = PlaybackState.Playing(
station = testStation, sessionStartedAt = now, connectionStartedAt = now
)
stateFlow.value = PlaybackState.Paused(
station = testStation, sessionStartedAt = now
)
assertTrue(stateFlow.value is PlaybackState.Paused)
}
@Test
fun `playing to reconnecting on connection lost with stayConnected`() {
val now = System.currentTimeMillis()
stateFlow.value = PlaybackState.Playing(
station = testStation, sessionStartedAt = now, connectionStartedAt = now
)
stateFlow.value = PlaybackState.Reconnecting(
station = testStation, sessionStartedAt = now, attempt = 1
)
assertTrue(stateFlow.value is PlaybackState.Reconnecting)
}
@Test
fun `playing to idle on connection lost without stayConnected`() {
val now = System.currentTimeMillis()
stateFlow.value = PlaybackState.Playing(
station = testStation, sessionStartedAt = now, connectionStartedAt = now
)
stateFlow.value = PlaybackState.Idle
assertTrue(stateFlow.value is PlaybackState.Idle)
}
@Test
fun `paused to connecting on resume`() {
val now = System.currentTimeMillis()
stateFlow.value = PlaybackState.Paused(
station = testStation, sessionStartedAt = now
)
stateFlow.value = PlaybackState.Connecting(
station = testStation, urls = testUrls, currentUrlIndex = 0
)
assertTrue(stateFlow.value is PlaybackState.Connecting)
}
@Test
fun `reconnecting to connecting on retry`() {
stateFlow.value = PlaybackState.Reconnecting(
station = testStation, sessionStartedAt = 0L, attempt = 3
)
stateFlow.value = PlaybackState.Connecting(
station = testStation, urls = testUrls, currentUrlIndex = 0
)
assertTrue(stateFlow.value is PlaybackState.Connecting)
}
@Test
fun `reconnecting to idle on user stop`() {
stateFlow.value = PlaybackState.Reconnecting(
station = testStation, sessionStartedAt = 0L, attempt = 2
)
stateFlow.value = PlaybackState.Idle
assertTrue(stateFlow.value is PlaybackState.Idle)
}
@Test
fun `stop from any state returns to idle`() {
val states = listOf(
PlaybackState.Connecting(station = testStation, urls = testUrls),
PlaybackState.Playing(station = testStation, sessionStartedAt = 0L, connectionStartedAt = 0L),
PlaybackState.Paused(station = testStation, sessionStartedAt = 0L),
PlaybackState.Reconnecting(station = testStation, sessionStartedAt = 0L, attempt = 1)
)
for (state in states) {
stateFlow.value = state
stateFlow.value = PlaybackState.Idle
assertTrue("Should be Idle after stop from $state", stateFlow.value is PlaybackState.Idle)
}
}
}
Step 2: Run tests to verify they pass
Run: ./gradlew :app:testDebugUnitTest --tests "xyz.cottongin.radio247.service.PlaybackStateMachineTest" 2>&1 | tail -10
Expected: All tests PASS (these are pure state assertions, no mocks needed yet)
Step 3: Commit
git add app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt
git commit -m "test: add playback state machine transition tests"
Task 4: Refactor RadioPlaybackService — transition function and startEngine URL fallback
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt
This is the core refactor. Three changes in one task because they're tightly coupled:
- Add
transition()function - Fix
startEngineto iterate URLs and setConnectingbeforePlaying - Fix the
finallyblock inhandlePlay
Step 1: Add transition function
Add this private method to RadioPlaybackService:
private fun transition(newState: PlaybackState) {
controller.updateState(newState)
}
Step 2: Refactor startEngine to accept URL list and iterate
Replace the current startEngine(station: Station) with a version that:
- Accepts
urls: List<String> - Loops over URLs, transitioning to
Connecting(station, urls, index)for each - Only transitions to
Playingafter receivingAudioEngineEvent.Started - On
ConnectionFailedfor a URL, continues to the next - Throws after all URLs are exhausted
The new startEngine:
private suspend fun startEngine(station: Station, urls: List<String>) {
for ((index, url) in urls.withIndex()) {
reconnectionMutex.withLock {
engine?.stop()
transition(
PlaybackState.Connecting(
station = station,
urls = urls,
currentUrlIndex = index,
sessionStartedAt = sessionStartedAt
)
)
updateNotification(station, null, isReconnecting = false)
val bufferMs = app.preferences.bufferMs.first()
engine = AudioEngine(url, bufferMs)
connectionSpanId = connectionSpanDao.insert(
ConnectionSpan(
sessionId = listeningSessionId,
startedAt = System.currentTimeMillis()
)
)
currentMetadata = null
engine!!.start()
}
try {
awaitEngine(station)
return
} catch (e: Exception) {
endConnectionSpan()
val isConnectionFailure = e is ConnectionFailedException ||
e.message?.contains("ConnectionFailed") == true
if (isConnectionFailure && index < urls.size - 1) {
continue
}
throw e
}
}
throw Exception("All URLs exhausted")
}
Step 3: Extract awaitEngine from the old startEngine
The event collection logic that was inline in startEngine moves to awaitEngine:
private suspend fun awaitEngine(station: Station) {
val deferred = CompletableDeferred<Unit>()
val connectionStartedAt = System.currentTimeMillis()
serviceScope.launch {
engine!!.events.collect { event ->
when (event) {
is AudioEngineEvent.Started -> {
transition(
PlaybackState.Playing(
station = station,
metadata = null,
sessionStartedAt = sessionStartedAt,
connectionStartedAt = connectionStartedAt
)
)
updateNotification(station, null, false)
controller.updateLatency(engine!!.estimatedLatencyMs)
}
is AudioEngineEvent.MetadataChanged -> {
currentMetadata = event.metadata
val playingState = controller.state.value
if (playingState is PlaybackState.Playing) {
transition(playingState.copy(metadata = event.metadata))
}
updateNotification(station, event.metadata, false)
persistMetadataSnapshot(station.id, event.metadata)
}
is AudioEngineEvent.StreamInfoReceived -> {
val playingState = controller.state.value
if (playingState is PlaybackState.Playing) {
transition(playingState.copy(streamInfo = event.streamInfo))
}
}
is AudioEngineEvent.Error -> {
engine?.stop()
engine = null
val throwable = when (val cause = event.cause) {
is EngineError.ConnectionFailed ->
ConnectionFailedException(cause.cause)
is EngineError.StreamEnded ->
Exception("Stream ended")
is EngineError.DecoderError ->
cause.cause
is EngineError.AudioOutputError ->
cause.cause
}
deferred.completeExceptionally(throwable)
return@collect
}
is AudioEngineEvent.Stopped -> {
deferred.complete(Unit)
return@collect
}
}
}
if (!deferred.isCompleted) {
deferred.completeExceptionally(Exception("Event flow completed unexpectedly"))
}
}
deferred.await()
}
Step 4: Add ConnectionFailedException
Add to RadioPlaybackService.kt (or a separate file):
class ConnectionFailedException(cause: Throwable) : Exception("Connection failed", cause)
Step 5: Update handlePlay to pass URL list and fix finally block
In handlePlay, change the startEngine call:
try {
val urls = app.streamResolver.resolveUrls(station)
startEngine(station, urls)
if (stayConnected) {
reconnectLoop(station)
}
} catch (_: Exception) {
if (stayConnected) {
reconnectLoop(station)
}
} finally {
endConnectionSpan()
val currentState = controller.state.value
when {
currentState is PlaybackState.Paused -> {
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
}
else -> {
val isActiveJob = playJob == coroutineContext[Job]
if (isActiveJob) {
transition(PlaybackState.Idle)
endListeningSession()
cleanupResources()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
}
Step 6: Update reconnectLoop to resolve and pass URLs
private suspend fun reconnectLoop(station: Station) {
var attempt = 0
registerNetworkCallback()
while (stayConnected) {
if (retryImmediatelyOnNetwork) {
retryImmediatelyOnNetwork = false
} else {
attempt++
transition(
PlaybackState.Reconnecting(
station = station,
metadata = currentMetadata,
sessionStartedAt = sessionStartedAt,
attempt = attempt
)
)
updateNotification(station, currentMetadata, isReconnecting = true)
val delayMs = minOf(1000L * (1 shl (attempt - 1)), 30_000L)
val chunk = 500L
var remaining = delayMs
while (remaining > 0 && !retryImmediatelyOnNetwork && stayConnected) {
delay(minOf(chunk, remaining))
remaining -= chunk
}
}
if (!stayConnected) break
try {
val urls = app.streamResolver.resolveUrls(station)
startEngine(station, urls)
return
} catch (_: Exception) {
// Continue loop
}
}
}
Step 7: Verify build compiles
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 8: Run existing tests
Run: ./gradlew :app:testDebugUnitTest 2>&1 | tail -10
Expected: All tests PASS
Step 9: Commit
git add app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt
git commit -m "refactor: state machine with transition(), URL fallback, and fixed finally block"
Task 5: Write service reconnection and concurrent play tests
Files:
- Create:
app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt
Step 1: Write StreamResolver tests
These test the URL ordering logic since the service's startEngine now relies on resolveUrls returning a proper list.
package xyz.cottongin.radio247.service
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import xyz.cottongin.radio247.data.db.StationStreamDao
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.data.prefs.RadioPreferences
class StreamResolverTest {
private val streamDao = mockk<StationStreamDao>()
private val prefs = mockk<RadioPreferences>()
private val testStation = Station(
id = 1L,
name = "Test",
url = "http://fallback.com/stream"
)
@Test
fun `returns station url when no streams exist`() = runTest {
coEvery { streamDao.getStreamsForStation(1L) } returns emptyList()
val resolver = StreamResolver(streamDao, prefs)
val urls = resolver.resolveUrls(testStation)
assertEquals(listOf("http://fallback.com/stream"), urls)
}
@Test
fun `orders streams by quality preference`() = runTest {
coEvery { streamDao.getStreamsForStation(1L) } returns listOf(
StationStream(id = 1, stationId = 1, bitrate = 128, ssl = false, url = "http://128.com"),
StationStream(id = 2, stationId = 1, bitrate = 256, ssl = true, url = "https://256.com"),
StationStream(id = 3, stationId = 1, bitrate = 128, ssl = true, url = "https://128.com")
)
coEvery { prefs.qualityPreference } returns flowOf(StreamResolver.DEFAULT_ORDER_JSON)
val resolver = StreamResolver(streamDao, prefs)
val urls = resolver.resolveUrls(testStation)
assertEquals("https://256.com", urls[0])
assertEquals("https://128.com", urls[1])
assertEquals("http://128.com", urls[2])
}
@Test
fun `uses station qualityOverride when set`() = runTest {
val stationWithOverride = testStation.copy(
qualityOverride = """["128-ssl","128-nossl","256-ssl","256-nossl"]"""
)
coEvery { streamDao.getStreamsForStation(1L) } returns listOf(
StationStream(id = 1, stationId = 1, bitrate = 128, ssl = false, url = "http://128.com"),
StationStream(id = 2, stationId = 1, bitrate = 256, ssl = true, url = "https://256.com"),
StationStream(id = 3, stationId = 1, bitrate = 128, ssl = true, url = "https://128.com")
)
val resolver = StreamResolver(streamDao, prefs)
val urls = resolver.resolveUrls(stationWithOverride)
assertEquals("https://128.com", urls[0])
assertEquals("http://128.com", urls[1])
assertEquals("https://256.com", urls[2])
}
}
Step 2: Run tests
Run: ./gradlew :app:testDebugUnitTest --tests "xyz.cottongin.radio247.service.StreamResolverTest" 2>&1 | tail -10
Expected: All PASS
Step 3: Commit
git add app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt
git commit -m "test: add StreamResolver unit tests for URL ordering and quality override"
Task 6: Fix miniplayer navigation bar overlap (bug 7)
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt
Step 1: Add navigation bar inset padding
Add the necessary imports at the top of MiniPlayer.kt:
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
Inside the MiniPlayer composable, before the Surface, compute the padding:
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
Replace the Row modifier's padding:
.padding(horizontal = 12.dp)
.padding(top = 8.dp, bottom = 8.dp + navBarPadding)
The full Row modifier becomes:
Row(
modifier = Modifier
.clickable(onClick = onTap)
.padding(horizontal = 12.dp)
.padding(top = 8.dp, bottom = 8.dp + navBarPadding)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Step 2: Verify build compiles
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 3: Commit
git add app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt
git commit -m "fix: add navigation bar padding to miniplayer"
Task 7: Create StationPreference entity and DAO
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/data/model/StationPreference.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/StationPreferenceDao.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt
Step 1: Create StationPreference entity
Create app/src/main/java/xyz/cottongin/radio247/data/model/StationPreference.kt:
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "station_preferences",
foreignKeys = [ForeignKey(
entity = Station::class,
parentColumns = ["id"],
childColumns = ["stationId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("stationId", unique = true)]
)
data class StationPreference(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stationId: Long,
val qualityOverride: String? = null
)
Step 2: Create StationPreferenceDao
Create app/src/main/java/xyz/cottongin/radio247/data/db/StationPreferenceDao.kt:
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import xyz.cottongin.radio247.data.model.StationPreference
@Dao
interface StationPreferenceDao {
@Query("SELECT * FROM station_preferences WHERE stationId = :stationId")
suspend fun getByStationId(stationId: Long): StationPreference?
@Upsert
suspend fun upsert(pref: StationPreference)
@Query("DELETE FROM station_preferences WHERE stationId = :stationId")
suspend fun deleteByStationId(stationId: Long)
}
Step 3: Add to RadioDatabase
In app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt:
- Add
StationPreference::classto theentitiesarray - Bump
versionfrom 4 to 5 - Add abstract fun
stationPreferenceDao(): StationPreferenceDao - Add
MIGRATION_4_5 - Add
import xyz.cottongin.radio247.data.model.StationPreference
The entities list becomes:
entities = [
Station::class,
Playlist::class,
MetadataSnapshot::class,
ListeningSession::class,
ConnectionSpan::class,
StationStream::class,
StationPreference::class
],
version = 5,
Add the abstract DAO:
abstract fun stationPreferenceDao(): StationPreferenceDao
Add migration:
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS station_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
stationId INTEGER NOT NULL,
qualityOverride TEXT DEFAULT NULL,
FOREIGN KEY(stationId) REFERENCES stations(id) ON DELETE CASCADE
)"""
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_station_preferences_stationId ON station_preferences(stationId)")
}
}
Step 4: Register migration in RadioApplication
Find where the database is built (likely in RadioApplication.kt) and add MIGRATION_4_5 to the migration list.
Step 5: Verify build compiles
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 6: Commit
git add app/src/main/java/xyz/cottongin/radio247/data/model/StationPreference.kt \
app/src/main/java/xyz/cottongin/radio247/data/db/StationPreferenceDao.kt \
app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt \
app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt \
app/schemas/
git commit -m "feat: add StationPreference entity and DAO with migration"
Task 8: Wire StationPreference into StreamResolver
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/service/StreamResolver.kt - Modify:
app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt
Step 1: Update StreamResolver constructor
Add StationPreferenceDao as a dependency:
class StreamResolver(
private val streamDao: StationStreamDao,
private val preferences: RadioPreferences,
private val stationPreferenceDao: StationPreferenceDao
)
Step 2: Update resolveUrls to check StationPreference first
In resolveUrls, before using station.qualityOverride, check the preference table:
suspend fun resolveUrls(station: Station): List<String> {
val streams = streamDao.getStreamsForStation(station.id)
if (streams.isEmpty()) return listOf(station.url)
val pref = stationPreferenceDao.getByStationId(station.id)
val qualityJson = pref?.qualityOverride
?: station.qualityOverride
?: preferences.qualityPreference.first()
val order = parseOrder(qualityJson)
val sorted = streams.sortedBy { stream ->
val key = "${stream.bitrate}-${if (stream.ssl) "ssl" else "nossl"}"
val idx = order.indexOf(key)
if (idx >= 0) idx else Int.MAX_VALUE
}
return sorted.map { it.url }
}
Step 3: Update where StreamResolver is constructed
In RadioApplication.kt (or wherever it's created), pass the new DAO. The construction should look like:
val streamResolver = StreamResolver(
database.stationStreamDao(),
preferences,
database.stationPreferenceDao()
)
Step 4: Update StreamResolverTest
Add a mock for StationPreferenceDao and update existing tests:
private val stationPrefDao = mockk<StationPreferenceDao>()
In each test, add:
coEvery { stationPrefDao.getByStationId(any()) } returns null
And update the StreamResolver construction:
val resolver = StreamResolver(streamDao, prefs, stationPrefDao)
Add a new test:
@Test
fun `stationPreference qualityOverride takes precedence over station field`() = runTest {
coEvery { streamDao.getStreamsForStation(1L) } returns listOf(
StationStream(id = 1, stationId = 1, bitrate = 128, ssl = true, url = "https://128.com"),
StationStream(id = 2, stationId = 1, bitrate = 256, ssl = true, url = "https://256.com")
)
coEvery { stationPrefDao.getByStationId(1L) } returns StationPreference(
stationId = 1L,
qualityOverride = """["128-ssl","256-ssl"]"""
)
val stationWithOverride = testStation.copy(
qualityOverride = """["256-ssl","128-ssl"]"""
)
val resolver = StreamResolver(streamDao, prefs, stationPrefDao)
val urls = resolver.resolveUrls(stationWithOverride)
assertEquals("https://128.com", urls[0])
}
Step 5: Run tests
Run: ./gradlew :app:testDebugUnitTest --tests "xyz.cottongin.radio247.service.StreamResolverTest" 2>&1 | tail -10
Expected: All PASS
Step 6: Verify build
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 7: Commit
git add app/src/main/java/xyz/cottongin/radio247/service/StreamResolver.kt \
app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt \
app/src/test/java/xyz/cottongin/radio247/service/StreamResolverTest.kt
git commit -m "feat: wire StationPreference into StreamResolver for per-station quality"
Task 9: Per-station quality UI — context menu and dialog
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt
Step 1: Create QualityOverrideDialog
Create app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt:
A dialog that shows available stream qualities for a station as a reorderable list (matching the global Settings quality reorder). Includes a "Use default" option to clear the override.
The dialog should:
- Accept
currentOrder: List<String>(the station's current quality order, or global default) - Accept
availableQualities: List<String>(e.g.["256-ssl", "256-nossl", "128-ssl", "128-nossl"]) - Show a drag-reorderable list of qualities
- Have "Use Default" and "Save" buttons
- Call
onSave(newOrder: String?)—nullmeans "use default"
Step 2: Add quality action to StationListViewModel
Add to StationListViewModel:
fun setQualityOverride(stationId: Long, qualityJson: String?) {
viewModelScope.launch {
val prefDao = app.database.stationPreferenceDao()
if (qualityJson == null) {
prefDao.deleteByStationId(stationId)
} else {
prefDao.upsert(StationPreference(stationId = stationId, qualityOverride = qualityJson))
}
}
}
fun getStreamsForStation(stationId: Long, callback: (List<StationStream>) -> Unit) {
viewModelScope.launch {
val streams = app.database.stationStreamDao().getStreamsForStation(stationId)
callback(streams)
}
}
Step 3: Add "Quality" to station context menu in StationListScreen
In the StationRow composable, add a hasStreams: Boolean parameter and an onQuality: () -> Unit callback. In the DropdownMenu, add:
if (hasStreams && !isHiddenView) {
DropdownMenuItem(
text = { Text("Quality") },
onClick = {
showMenu = false
onQuality()
}
)
}
Wire this up in StationListScreen to show the QualityOverrideDialog.
Step 4: Verify build compiles
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 5: Commit
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
git commit -m "feat: add per-station quality preference via long-press context menu"
Task 10: Add pinned column to Playlist and Room migration
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/data/db/PlaylistDao.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt
Step 1: Add pinned column to Playlist
Update app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt:
@Entity(tableName = "playlists")
data class Playlist(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val sortOrder: Int = 0,
val starred: Boolean = false,
val isBuiltIn: Boolean = false,
val pinned: Boolean = false
)
Step 2: Add DAO methods
In PlaylistDao.kt, add:
@Query("UPDATE playlists SET pinned = :pinned WHERE id = :id")
suspend fun updatePinned(id: Long, pinned: Boolean)
@Query("UPDATE playlists SET name = :name WHERE id = :id")
suspend fun rename(id: Long, name: String)
Update getAllPlaylists query to order pinned tabs first:
@Query("SELECT * FROM playlists ORDER BY pinned DESC, sortOrder ASC")
fun getAllPlaylists(): Flow<List<Playlist>>
Step 3: Add migration
In RadioDatabase.kt, bump version to 6 (or 5 if Task 7 hasn't been done yet — adjust based on actual version at this point). Add MIGRATION_5_6:
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE playlists ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
db.execSQL("UPDATE playlists SET pinned = 1 WHERE isBuiltIn = 1")
}
}
The migration defaults pinned = true for built-in tabs (SomaFM).
Step 4: Register migration in RadioApplication
Add MIGRATION_5_6 to the database builder's migration list.
Step 5: Verify build
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 6: Commit
git add app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt \
app/src/main/java/xyz/cottongin/radio247/data/db/PlaylistDao.kt \
app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt \
app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt \
app/schemas/
git commit -m "feat: add pinned column to Playlist with migration"
Task 11: Import naming dialog
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/NamePlaylistDialog.kt
Step 1: Create NamePlaylistDialog
A simple dialog with a text field (pre-filled with the parsed filename) and OK/Cancel buttons. Different from AddPlaylistDialog because it's used in the import flow specifically.
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun NamePlaylistDialog(
suggestedName: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var name by remember { mutableStateOf(suggestedName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Name this playlist") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Playlist name") },
singleLine = true
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(name) },
enabled = name.isNotBlank()
) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
Step 2: Split importFile into parse and confirm phases
In StationListViewModel, replace importFile() with a two-phase approach:
importFile(uri)parses and stores the result + suggested name in a StateFlow- A new
confirmImport(name)method does the actual insert cancelImport()clears the pending import
Add state:
data class PendingImport(
val suggestedName: String,
val stations: List<ParsedStation>
)
private val _pendingImport = MutableStateFlow<PendingImport?>(null)
val pendingImport: StateFlow<PendingImport?> = _pendingImport.asStateFlow()
Update importFile:
fun importFile(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
?: return@launch
val fileName = uri.lastPathSegment
?.substringAfterLast('/')
?.substringBeforeLast('.')
?: "Imported"
val isM3u = content.trimStart().startsWith("#EXTM3U") ||
uri.toString().endsWith(".m3u", ignoreCase = true)
val parsed = if (isM3u) M3uParser.parse(content) else PlsParser.parse(content)
if (parsed.isEmpty()) return@launch
withContext(Dispatchers.Main) {
_pendingImport.value = PendingImport(fileName, parsed)
}
}
}
fun confirmImport(name: String) {
val pending = _pendingImport.value ?: return
_pendingImport.value = null
viewModelScope.launch(Dispatchers.IO) {
val playlistId = playlistDao.insert(Playlist(name = name))
for ((index, station) in pending.stations.withIndex()) {
stationDao.insert(
Station(
name = station.name,
url = station.url,
playlistId = playlistId,
sortOrder = index,
defaultArtworkUrl = station.artworkUrl
)
)
}
withContext(Dispatchers.Main) {
val playlists = playlistsFlow.value
val tabs = buildTabs(playlists)
val newTabIndex = tabs.indexOfFirst { it.playlist?.id == playlistId }
if (newTabIndex >= 0) {
_selectedTabIndex.value = newTabIndex
}
}
}
}
fun cancelImport() {
_pendingImport.value = null
}
Step 3: Wire dialog in StationListScreen
In StationListScreen, observe pendingImport and show the dialog:
val pendingImport by viewModel.pendingImport.collectAsState()
pendingImport?.let { pending ->
NamePlaylistDialog(
suggestedName = pending.suggestedName,
onDismiss = { viewModel.cancelImport() },
onConfirm = { name -> viewModel.confirmImport(name) }
)
}
Step 4: Verify build
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 5: Commit
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/NamePlaylistDialog.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt
git commit -m "feat: add playlist naming dialog on import"
Task 12: Tab rename and pin/unpin context menu
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/RenamePlaylistDialog.kt
Step 1: Create RenamePlaylistDialog
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun RenamePlaylistDialog(
currentName: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var name by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename playlist") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Playlist name") },
singleLine = true
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(name) },
enabled = name.isNotBlank()
) { Text("Rename") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
Step 2: Add ViewModel methods
In StationListViewModel:
fun renamePlaylist(playlist: Playlist, newName: String) {
viewModelScope.launch {
playlistDao.rename(playlist.id, newName)
}
}
fun togglePinned(playlist: Playlist) {
viewModelScope.launch {
playlistDao.updatePinned(playlist.id, !playlist.pinned)
}
}
Step 3: Add tab context menu to StationListScreen
Replace the simple Tab in the tab row with a version that supports long-press. Wrap each Tab in a Box with combinedClickable:
viewState.tabs.forEachIndexed { index, tab ->
var showTabMenu by remember { mutableStateOf(false) }
Box {
Tab(
selected = viewState.selectedTabIndex == index,
onClick = { viewModel.selectTab(index) },
text = {
Text(
text = tab.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
modifier = Modifier.combinedClickable(
onClick = { viewModel.selectTab(index) },
onLongClick = { if (tab.playlist != null) showTabMenu = true }
)
)
DropdownMenu(
expanded = showTabMenu,
onDismissRequest = { showTabMenu = false }
) {
if (!tab.isBuiltIn) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
showTabMenu = false
tabToRename = tab
}
)
}
DropdownMenuItem(
text = { Text(if (tab.playlist?.pinned == true) "Unpin" else "Pin") },
onClick = {
showTabMenu = false
tab.playlist?.let { viewModel.togglePinned(it) }
}
)
}
}
}
Add a tabToRename state variable and show the dialog:
var tabToRename by remember { mutableStateOf<TabInfo?>(null) }
tabToRename?.let { tab ->
RenamePlaylistDialog(
currentName = tab.label,
onDismiss = { tabToRename = null },
onConfirm = { newName ->
tab.playlist?.let { viewModel.renamePlaylist(it, newName) }
tabToRename = null
}
)
}
Step 4: Update buildTabs to separate pinned/unpinned groups
In StationListViewModel.buildTabs(), update ordering:
private fun buildTabs(playlists: List<Playlist>): List<TabInfo> {
val myStations = TabInfo(playlist = null, label = "My Stations", isBuiltIn = false)
val pinned = playlists
.filter { it.pinned }
.sortedBy { it.sortOrder }
.map { TabInfo(playlist = it, label = it.name, isBuiltIn = it.isBuiltIn) }
val unpinned = playlists
.filter { !it.pinned }
.sortedBy { it.sortOrder }
.map { TabInfo(playlist = it, label = it.name, isBuiltIn = it.isBuiltIn) }
return pinned + listOf(myStations) + unpinned
}
Note: "My Stations" is a virtual tab (no playlist entity), positioned between pinned and unpinned. Adjust as needed.
Step 5: Verify build
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 6: Commit
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/RenamePlaylistDialog.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
git commit -m "feat: add tab rename and pin/unpin via long-press context menu"
Task 13: Tab drag-to-reorder
Files:
- Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
This is the most complex UI task. The tab row needs to support drag-to-reorder within two independent groups (pinned and unpinned).
Step 1: Add reorder ViewModel method
In StationListViewModel:
fun reorderTabs(reorderedPlaylists: List<Playlist>) {
viewModelScope.launch {
for ((index, playlist) in reorderedPlaylists.withIndex()) {
playlistDao.updateSortOrder(playlist.id, index)
}
}
}
Step 2: Implement drag-to-reorder in tab row
Replace the ScrollableTabRow with a custom implementation using LazyRow and drag gesture detection. Use detectDragGesturesAfterLongPress for the drag interaction.
The implementation should:
- Render pinned tabs first, then a visual separator or gap, then unpinned tabs
- Only allow reordering within the same group (pinned with pinned, unpinned with unpinned)
- On drop, call
viewModel.reorderTabs()with the new order - Highlight the dragged tab with elevation/scale
This requires Compose's Modifier.pointerInput with detectDragGesturesAfterLongPress. There are several approaches — the simplest that works is to track drag offset in a remember state and swap items as the drag crosses item boundaries.
Detailed implementation: use a LazyRow state to track item positions, compute which item the drag target overlaps, and swap sort orders accordingly.
Step 3: Verify build
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 4: Commit
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt \
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt
git commit -m "feat: add drag-to-reorder for tabs within pinned/unpinned groups"
Task 14: Final test pass and cleanup
Files:
- All test files
- All modified source files
Step 1: Run full test suite
Run: ./gradlew :app:testDebugUnitTest 2>&1 | tail -20
Expected: All tests PASS
Step 2: Run build
Run: ./gradlew assembleDebug 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
Step 3: Review for any leftover TODOs or debug code
Search for TODO, FIXME, println, Log.d that were added during implementation.
Step 4: Final commit if any cleanup was needed
git add -A
git commit -m "chore: final cleanup after playback and UI fixes"