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
1500 lines
47 KiB
Markdown
1500 lines
47 KiB
Markdown
# 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
|
|
<?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:
|
|
|
|
```xml
|
|
android:networkSecurityConfig="@xml/network_security_config"
|
|
```
|
|
|
|
The `<application>` tag should read:
|
|
|
|
```xml
|
|
<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<String>`
|
|
- Loops over URLs, transitioning to `Connecting(station, urls, index)` for each
|
|
- Only transitions to `Playing` after receiving `AudioEngineEvent.Started`
|
|
- On `ConnectionFailed` for a URL, continues to the next
|
|
- Throws after all URLs are exhausted
|
|
|
|
The new `startEngine`:
|
|
|
|
```kotlin
|
|
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`:
|
|
|
|
```kotlin
|
|
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):
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```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<StationPreferenceDao>()
|
|
```
|
|
|
|
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<String>` (the station's current quality order, or global default)
|
|
- Accept `availableQualities: List<String>` (e.g. `["256-ssl", "256-nossl", "128-ssl", "128-nossl"]`)
|
|
- Show a drag-reorderable list of qualities
|
|
- Have "Use Default" and "Save" buttons
|
|
- Call `onSave(newOrder: String?)` — `null` means "use default"
|
|
|
|
**Step 2: Add quality action to StationListViewModel**
|
|
|
|
Add to `StationListViewModel`:
|
|
|
|
```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<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:
|
|
|
|
```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<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`:
|
|
|
|
```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<ParsedStation>
|
|
)
|
|
|
|
private val _pendingImport = MutableStateFlow<PendingImport?>(null)
|
|
val pendingImport: StateFlow<PendingImport?> = _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<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:
|
|
|
|
```kotlin
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```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"
|
|
```
|