1650 lines
54 KiB
Markdown
1650 lines
54 KiB
Markdown
|
|
# Android 24/7 Radio Implementation Plan
|
|||
|
|
|
|||
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|||
|
|
|
|||
|
|
**Goal:** Build a personal-use Android app for 24/7 internet radio streaming with a custom raw audio pipeline for absolute minimum latency, aggressive reconnection, and Icecast/Shoutcast metadata support.
|
|||
|
|
|
|||
|
|
**Architecture:** Custom raw audio pipeline (OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack) wrapped in a foreground service with aggressive reconnection. Room DB for persistence, Jetpack Compose for UI. See `docs/plans/2026-03-09-android-247-radio-design.md` for full design.
|
|||
|
|
|
|||
|
|
**Tech Stack:** Kotlin, Jetpack Compose (Material 3), Room, DataStore, OkHttp, MediaCodec, AudioTrack, MediaSession
|
|||
|
|
|
|||
|
|
**Package:** `xyz.cottongin.radio247`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: Project Scaffolding
|
|||
|
|
|
|||
|
|
Set up the Gradle Android project with all dependencies and basic structure.
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `settings.gradle.kts`
|
|||
|
|
- Create: `build.gradle.kts` (root)
|
|||
|
|
- Create: `app/build.gradle.kts`
|
|||
|
|
- Create: `gradle.properties`
|
|||
|
|
- Create: `gradle/libs.versions.toml`
|
|||
|
|
- Create: `app/src/main/AndroidManifest.xml`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/MainActivity.kt`
|
|||
|
|
- Create: `app/src/main/res/values/strings.xml`
|
|||
|
|
- Create: `app/src/main/res/values/themes.xml`
|
|||
|
|
- Create: `app/src/main/res/drawable/ic_radio_placeholder.xml` (vector drawable placeholder)
|
|||
|
|
|
|||
|
|
**Step 1: Create Gradle wrapper and project files**
|
|||
|
|
|
|||
|
|
Use the latest stable AGP and Kotlin. Version catalog in `gradle/libs.versions.toml`:
|
|||
|
|
|
|||
|
|
```toml
|
|||
|
|
[versions]
|
|||
|
|
agp = "8.7.3"
|
|||
|
|
kotlin = "2.1.0"
|
|||
|
|
compose-bom = "2025.02.00"
|
|||
|
|
room = "2.7.1"
|
|||
|
|
datastore = "1.1.4"
|
|||
|
|
okhttp = "4.12.0"
|
|||
|
|
lifecycle = "2.9.0"
|
|||
|
|
coroutines = "1.10.1"
|
|||
|
|
ksp = "2.1.0-1.0.29"
|
|||
|
|
media = "1.5.1"
|
|||
|
|
junit = "4.13.2"
|
|||
|
|
mockk = "1.13.16"
|
|||
|
|
turbine = "1.2.0"
|
|||
|
|
|
|||
|
|
[libraries]
|
|||
|
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
|||
|
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
|||
|
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
|||
|
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
|||
|
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
|||
|
|
compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.10.1" }
|
|||
|
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
|||
|
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
|||
|
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
|||
|
|
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
|||
|
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
|||
|
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
|||
|
|
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
|||
|
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
|||
|
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
|||
|
|
lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
|
|||
|
|
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
|||
|
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
|||
|
|
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
|||
|
|
media-session = { group = "androidx.media", name = "media", version.ref = "media" }
|
|||
|
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|||
|
|
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
|||
|
|
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
|||
|
|
|
|||
|
|
[plugins]
|
|||
|
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
|||
|
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
|||
|
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
|||
|
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Root `build.gradle.kts`:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
plugins {
|
|||
|
|
alias(libs.plugins.android.application) apply false
|
|||
|
|
alias(libs.plugins.kotlin.android) apply false
|
|||
|
|
alias(libs.plugins.kotlin.compose) apply false
|
|||
|
|
alias(libs.plugins.ksp) apply false
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`settings.gradle.kts`:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
pluginManagement {
|
|||
|
|
repositories {
|
|||
|
|
google()
|
|||
|
|
mavenCentral()
|
|||
|
|
gradlePluginPortal()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
dependencyResolutionManagement {
|
|||
|
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|||
|
|
repositories {
|
|||
|
|
google()
|
|||
|
|
mavenCentral()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
rootProject.name = "Android-247-Radio"
|
|||
|
|
include(":app")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`app/build.gradle.kts`:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
plugins {
|
|||
|
|
alias(libs.plugins.android.application)
|
|||
|
|
alias(libs.plugins.kotlin.android)
|
|||
|
|
alias(libs.plugins.kotlin.compose)
|
|||
|
|
alias(libs.plugins.ksp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
android {
|
|||
|
|
namespace = "xyz.cottongin.radio247"
|
|||
|
|
compileSdk = 35
|
|||
|
|
|
|||
|
|
defaultConfig {
|
|||
|
|
applicationId = "xyz.cottongin.radio247"
|
|||
|
|
minSdk = 28
|
|||
|
|
targetSdk = 35
|
|||
|
|
versionCode = 1
|
|||
|
|
versionName = "1.0"
|
|||
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
buildTypes {
|
|||
|
|
release {
|
|||
|
|
isMinifyEnabled = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
compileOptions {
|
|||
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|||
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|||
|
|
}
|
|||
|
|
kotlinOptions {
|
|||
|
|
jvmTarget = "17"
|
|||
|
|
}
|
|||
|
|
buildFeatures {
|
|||
|
|
compose = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dependencies {
|
|||
|
|
implementation(platform(libs.compose.bom))
|
|||
|
|
implementation(libs.compose.material3)
|
|||
|
|
implementation(libs.compose.ui)
|
|||
|
|
implementation(libs.compose.ui.tooling.preview)
|
|||
|
|
implementation(libs.compose.activity)
|
|||
|
|
debugImplementation(libs.compose.ui.tooling)
|
|||
|
|
|
|||
|
|
implementation(libs.room.runtime)
|
|||
|
|
implementation(libs.room.ktx)
|
|||
|
|
ksp(libs.room.compiler)
|
|||
|
|
|
|||
|
|
implementation(libs.datastore.preferences)
|
|||
|
|
implementation(libs.okhttp)
|
|||
|
|
implementation(libs.lifecycle.viewmodel.compose)
|
|||
|
|
implementation(libs.lifecycle.runtime.compose)
|
|||
|
|
implementation(libs.lifecycle.service)
|
|||
|
|
implementation(libs.coroutines.core)
|
|||
|
|
implementation(libs.coroutines.android)
|
|||
|
|
implementation(libs.media.session)
|
|||
|
|
|
|||
|
|
testImplementation(libs.junit)
|
|||
|
|
testImplementation(libs.mockk)
|
|||
|
|
testImplementation(libs.coroutines.test)
|
|||
|
|
testImplementation(libs.turbine)
|
|||
|
|
testImplementation(libs.okhttp.mockwebserver)
|
|||
|
|
testImplementation(libs.room.testing)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Create AndroidManifest.xml**
|
|||
|
|
|
|||
|
|
```xml
|
|||
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|||
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|||
|
|
|
|||
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|||
|
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|||
|
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
|||
|
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|||
|
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|||
|
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|||
|
|
|
|||
|
|
<application
|
|||
|
|
android:name=".RadioApplication"
|
|||
|
|
android:allowBackup="true"
|
|||
|
|
android:label="@string/app_name"
|
|||
|
|
android:supportsRtl="true"
|
|||
|
|
android:theme="@style/Theme.Radio247">
|
|||
|
|
|
|||
|
|
<activity
|
|||
|
|
android:name=".MainActivity"
|
|||
|
|
android:exported="true"
|
|||
|
|
android:theme="@style/Theme.Radio247">
|
|||
|
|
<intent-filter>
|
|||
|
|
<action android:name="android.intent.action.MAIN" />
|
|||
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|||
|
|
</intent-filter>
|
|||
|
|
</activity>
|
|||
|
|
|
|||
|
|
<service
|
|||
|
|
android:name=".service.RadioPlaybackService"
|
|||
|
|
android:exported="false"
|
|||
|
|
android:foregroundServiceType="mediaPlayback" />
|
|||
|
|
</application>
|
|||
|
|
</manifest>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 3: Create stub Application, Activity, resource files**
|
|||
|
|
|
|||
|
|
`RadioApplication.kt` — empty Application subclass (will hold DB singleton later).
|
|||
|
|
`MainActivity.kt` — minimal ComponentActivity with `setContent {}` showing a placeholder Text composable.
|
|||
|
|
`strings.xml` — app_name = "24/7 Radio".
|
|||
|
|
`themes.xml` — empty Material3 theme stub.
|
|||
|
|
|
|||
|
|
**Step 4: Install Gradle wrapper**
|
|||
|
|
|
|||
|
|
Run: `gradle wrapper --gradle-version 8.12` (or download wrapper files manually).
|
|||
|
|
|
|||
|
|
**Step 5: Verify build**
|
|||
|
|
|
|||
|
|
Run: `./gradlew assembleDebug`
|
|||
|
|
Expected: BUILD SUCCESSFUL
|
|||
|
|
|
|||
|
|
**Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: scaffold Android project with dependencies"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: Data Layer — Room Entities and DAOs
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/model/Station.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/model/MetadataSnapshot.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/model/ListeningSession.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/model/ConnectionSpan.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/StationDao.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/PlaylistDao.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/MetadataSnapshotDao.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/ListeningSessionDao.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/ConnectionSpanDao.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/prefs/RadioPreferences.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/data/model/` (entity unit tests if any logic)
|
|||
|
|
|
|||
|
|
**Step 1: Write Room entities**
|
|||
|
|
|
|||
|
|
`Station.kt`:
|
|||
|
|
```kotlin
|
|||
|
|
@Entity(
|
|||
|
|
tableName = "stations",
|
|||
|
|
foreignKeys = [ForeignKey(
|
|||
|
|
entity = Playlist::class,
|
|||
|
|
parentColumns = ["id"],
|
|||
|
|
childColumns = ["playlistId"],
|
|||
|
|
onDelete = ForeignKey.SET_NULL
|
|||
|
|
)],
|
|||
|
|
indices = [Index("playlistId")]
|
|||
|
|
)
|
|||
|
|
data class Station(
|
|||
|
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|||
|
|
val name: String,
|
|||
|
|
val url: String,
|
|||
|
|
val playlistId: Long? = null,
|
|||
|
|
val sortOrder: Int = 0,
|
|||
|
|
val starred: Boolean = false,
|
|||
|
|
val defaultArtworkUrl: String? = null
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`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
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`MetadataSnapshot.kt`:
|
|||
|
|
```kotlin
|
|||
|
|
@Entity(
|
|||
|
|
tableName = "metadata_snapshots",
|
|||
|
|
foreignKeys = [ForeignKey(
|
|||
|
|
entity = Station::class,
|
|||
|
|
parentColumns = ["id"],
|
|||
|
|
childColumns = ["stationId"],
|
|||
|
|
onDelete = ForeignKey.CASCADE
|
|||
|
|
)],
|
|||
|
|
indices = [Index("stationId"), Index("timestamp")]
|
|||
|
|
)
|
|||
|
|
data class MetadataSnapshot(
|
|||
|
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|||
|
|
val stationId: Long,
|
|||
|
|
val title: String? = null,
|
|||
|
|
val artist: String? = null,
|
|||
|
|
val artworkUrl: String? = null,
|
|||
|
|
val timestamp: Long
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`ListeningSession.kt`:
|
|||
|
|
```kotlin
|
|||
|
|
@Entity(
|
|||
|
|
tableName = "listening_sessions",
|
|||
|
|
foreignKeys = [ForeignKey(
|
|||
|
|
entity = Station::class,
|
|||
|
|
parentColumns = ["id"],
|
|||
|
|
childColumns = ["stationId"],
|
|||
|
|
onDelete = ForeignKey.CASCADE
|
|||
|
|
)],
|
|||
|
|
indices = [Index("stationId")]
|
|||
|
|
)
|
|||
|
|
data class ListeningSession(
|
|||
|
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|||
|
|
val stationId: Long,
|
|||
|
|
val startedAt: Long,
|
|||
|
|
val endedAt: Long? = null
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`ConnectionSpan.kt`:
|
|||
|
|
```kotlin
|
|||
|
|
@Entity(
|
|||
|
|
tableName = "connection_spans",
|
|||
|
|
foreignKeys = [ForeignKey(
|
|||
|
|
entity = ListeningSession::class,
|
|||
|
|
parentColumns = ["id"],
|
|||
|
|
childColumns = ["sessionId"],
|
|||
|
|
onDelete = ForeignKey.CASCADE
|
|||
|
|
)],
|
|||
|
|
indices = [Index("sessionId")]
|
|||
|
|
)
|
|||
|
|
data class ConnectionSpan(
|
|||
|
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|||
|
|
val sessionId: Long,
|
|||
|
|
val startedAt: Long,
|
|||
|
|
val endedAt: Long? = null
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Write DAOs**
|
|||
|
|
|
|||
|
|
`StationDao.kt` — CRUD, queries: all stations ordered by starred desc then sortOrder, stations by playlist, update sortOrder, toggle starred.
|
|||
|
|
|
|||
|
|
`PlaylistDao.kt` — CRUD, all playlists ordered by starred desc then sortOrder, update sortOrder, toggle starred.
|
|||
|
|
|
|||
|
|
`MetadataSnapshotDao.kt` — insert, query by stationId (newest first), query recent across all stations, search by artist/title.
|
|||
|
|
|
|||
|
|
`ListeningSessionDao.kt` — insert, update endedAt, query active session, query recent sessions.
|
|||
|
|
|
|||
|
|
`ConnectionSpanDao.kt` — insert, update endedAt, query by sessionId, query active span.
|
|||
|
|
|
|||
|
|
Key DAO patterns:
|
|||
|
|
- Return `Flow<List<T>>` for observable queries (station list, playlist list).
|
|||
|
|
- Return `suspend` functions for writes and one-shot reads.
|
|||
|
|
|
|||
|
|
**Step 3: Write RadioDatabase**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@Database(
|
|||
|
|
entities = [Station::class, Playlist::class, MetadataSnapshot::class,
|
|||
|
|
ListeningSession::class, ConnectionSpan::class],
|
|||
|
|
version = 1,
|
|||
|
|
exportSchema = true
|
|||
|
|
)
|
|||
|
|
abstract class RadioDatabase : RoomDatabase() {
|
|||
|
|
abstract fun stationDao(): StationDao
|
|||
|
|
abstract fun playlistDao(): PlaylistDao
|
|||
|
|
abstract fun metadataSnapshotDao(): MetadataSnapshotDao
|
|||
|
|
abstract fun listeningSessionDao(): ListeningSessionDao
|
|||
|
|
abstract fun connectionSpanDao(): ConnectionSpanDao
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Provide the database singleton via `RadioApplication` (manual DI — no Hilt/Dagger for personal use, keep it simple).
|
|||
|
|
|
|||
|
|
**Step 4: Write RadioPreferences**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class RadioPreferences(private val context: Context) {
|
|||
|
|
private val dataStore = context.dataStore
|
|||
|
|
|
|||
|
|
val stayConnected: Flow<Boolean> = dataStore.data.map { it[STAY_CONNECTED] ?: false }
|
|||
|
|
val bufferMs: Flow<Int> = dataStore.data.map { it[BUFFER_MS] ?: 0 }
|
|||
|
|
val lastStationId: Flow<Long?> = dataStore.data.map { it[LAST_STATION_ID] }
|
|||
|
|
|
|||
|
|
suspend fun setStayConnected(value: Boolean) { ... }
|
|||
|
|
suspend fun setBufferMs(value: Int) { ... }
|
|||
|
|
suspend fun setLastStationId(value: Long) { ... }
|
|||
|
|
|
|||
|
|
companion object {
|
|||
|
|
private val Context.dataStore by preferencesDataStore(name = "radio_prefs")
|
|||
|
|
private val STAY_CONNECTED = booleanPreferencesKey("stay_connected")
|
|||
|
|
private val BUFFER_MS = intPreferencesKey("buffer_ms")
|
|||
|
|
private val LAST_STATION_ID = longPreferencesKey("last_station_id")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 5: Verify build**
|
|||
|
|
|
|||
|
|
Run: `./gradlew assembleDebug`
|
|||
|
|
Expected: BUILD SUCCESSFUL
|
|||
|
|
|
|||
|
|
**Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add Room entities, DAOs, database, and DataStore preferences"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: PLS/M3U Import & Export
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/import/M3uParser.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/import/PlsParser.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/import/PlaylistExporter.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/data/import/ParsedStation.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/data/import/M3uParserTest.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/data/import/PlsParserTest.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/data/import/PlaylistExporterTest.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Define ParsedStation data class**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
data class ParsedStation(
|
|||
|
|
val name: String,
|
|||
|
|
val url: String,
|
|||
|
|
val artworkUrl: String? = null
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
No Room annotations — this is a transfer object for import/export.
|
|||
|
|
|
|||
|
|
**Step 2: Write failing M3U parser tests**
|
|||
|
|
|
|||
|
|
Test cases:
|
|||
|
|
- Parse basic M3U with `#EXTINF` and URLs
|
|||
|
|
- Parse M3U with `#EXTIMG` artwork URLs
|
|||
|
|
- Handle missing `#EXTINF` (URL-only lines)
|
|||
|
|
- Handle blank lines and comments
|
|||
|
|
- Handle Windows-style line endings (`\r\n`)
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class M3uParserTest {
|
|||
|
|
@Test
|
|||
|
|
fun `parse basic m3u with extinf`() {
|
|||
|
|
val input = """
|
|||
|
|
#EXTM3U
|
|||
|
|
#EXTINF:-1,Station One
|
|||
|
|
http://stream.example.com:8000/live
|
|||
|
|
#EXTINF:-1,Station Two
|
|||
|
|
http://other.example.com/stream
|
|||
|
|
""".trimIndent()
|
|||
|
|
val result = M3uParser.parse(input)
|
|||
|
|
assertEquals(2, result.size)
|
|||
|
|
assertEquals(ParsedStation("Station One", "http://stream.example.com:8000/live"), result[0])
|
|||
|
|
assertEquals(ParsedStation("Station Two", "http://other.example.com/stream"), result[1])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `parse m3u with extimg`() {
|
|||
|
|
val input = """
|
|||
|
|
#EXTM3U
|
|||
|
|
#EXTINF:-1,My Station
|
|||
|
|
#EXTIMG:http://example.com/art.jpg
|
|||
|
|
http://stream.example.com/live
|
|||
|
|
""".trimIndent()
|
|||
|
|
val result = M3uParser.parse(input)
|
|||
|
|
assertEquals(1, result.size)
|
|||
|
|
assertEquals("http://example.com/art.jpg", result[0].artworkUrl)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `parse url-only lines`() { ... }
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `handle blank lines and comments`() { ... }
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `handle crlf line endings`() { ... }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 3: Run tests to verify they fail**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.M3uParserTest" -v`
|
|||
|
|
Expected: FAIL — class not found
|
|||
|
|
|
|||
|
|
**Step 4: Implement M3uParser**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
object M3uParser {
|
|||
|
|
fun parse(content: String): List<ParsedStation> {
|
|||
|
|
val stations = mutableListOf<ParsedStation>()
|
|||
|
|
var currentName: String? = null
|
|||
|
|
var currentArt: String? = null
|
|||
|
|
|
|||
|
|
for (rawLine in content.lines()) {
|
|||
|
|
val line = rawLine.trim()
|
|||
|
|
when {
|
|||
|
|
line.isEmpty() || line == "#EXTM3U" -> continue
|
|||
|
|
line.startsWith("#EXTINF:") -> {
|
|||
|
|
currentName = line.substringAfter(",").trim().ifEmpty { null }
|
|||
|
|
}
|
|||
|
|
line.startsWith("#EXTIMG:") -> {
|
|||
|
|
currentArt = line.removePrefix("#EXTIMG:").trim().ifEmpty { null }
|
|||
|
|
}
|
|||
|
|
line.startsWith("#") -> continue
|
|||
|
|
else -> {
|
|||
|
|
stations.add(ParsedStation(
|
|||
|
|
name = currentName ?: line.substringAfterLast("/"),
|
|||
|
|
url = line,
|
|||
|
|
artworkUrl = currentArt
|
|||
|
|
))
|
|||
|
|
currentName = null
|
|||
|
|
currentArt = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return stations
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 5: Run tests to verify they pass**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.M3uParserTest" -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 6: Write failing PLS parser tests**
|
|||
|
|
|
|||
|
|
Test cases:
|
|||
|
|
- Parse basic PLS with numbered entries
|
|||
|
|
- Handle missing titles (use URL as fallback name)
|
|||
|
|
- Handle case-insensitive keys
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class PlsParserTest {
|
|||
|
|
@Test
|
|||
|
|
fun `parse basic pls`() {
|
|||
|
|
val input = """
|
|||
|
|
[playlist]
|
|||
|
|
NumberOfEntries=2
|
|||
|
|
File1=http://stream.example.com/live
|
|||
|
|
Title1=Station One
|
|||
|
|
File2=http://other.example.com/stream
|
|||
|
|
Title2=Station Two
|
|||
|
|
""".trimIndent()
|
|||
|
|
val result = PlsParser.parse(input)
|
|||
|
|
assertEquals(2, result.size)
|
|||
|
|
assertEquals("Station One", result[0].name)
|
|||
|
|
assertEquals("http://stream.example.com/live", result[0].url)
|
|||
|
|
}
|
|||
|
|
// ... more test cases
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 7: Run PLS tests to verify they fail**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.PlsParserTest" -v`
|
|||
|
|
Expected: FAIL
|
|||
|
|
|
|||
|
|
**Step 8: Implement PlsParser**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
object PlsParser {
|
|||
|
|
fun parse(content: String): List<ParsedStation> {
|
|||
|
|
val files = mutableMapOf<Int, String>()
|
|||
|
|
val titles = mutableMapOf<Int, String>()
|
|||
|
|
|
|||
|
|
for (rawLine in content.lines()) {
|
|||
|
|
val line = rawLine.trim()
|
|||
|
|
val lower = line.lowercase()
|
|||
|
|
when {
|
|||
|
|
lower.startsWith("file") -> {
|
|||
|
|
val (key, value) = line.split("=", limit = 2)
|
|||
|
|
val index = key.removePrefix("File").removePrefix("file").toIntOrNull() ?: continue
|
|||
|
|
files[index] = value.trim()
|
|||
|
|
}
|
|||
|
|
lower.startsWith("title") -> {
|
|||
|
|
val (key, value) = line.split("=", limit = 2)
|
|||
|
|
val index = key.removePrefix("Title").removePrefix("title").toIntOrNull() ?: continue
|
|||
|
|
titles[index] = value.trim()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return files.keys.sorted().map { index ->
|
|||
|
|
val url = files[index]!!
|
|||
|
|
ParsedStation(
|
|||
|
|
name = titles[index] ?: url.substringAfterLast("/"),
|
|||
|
|
url = url
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 9: Run PLS tests to verify they pass**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.PlsParserTest" -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 10: Write failing exporter tests and implement PlaylistExporter**
|
|||
|
|
|
|||
|
|
`PlaylistExporter` has two functions: `toM3u(stations: List<Station>): String` and `toPls(stations: List<Station>): String`. Writes `#EXTIMG` lines for stations with `defaultArtworkUrl`. Test round-trip: export → parse → compare.
|
|||
|
|
|
|||
|
|
**Step 11: Run all import/export tests**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.import.*" -v`
|
|||
|
|
Expected: ALL PASS
|
|||
|
|
|
|||
|
|
**Step 12: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add M3U/PLS import and export with EXTIMG support"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: Audio Engine — IcyParser
|
|||
|
|
|
|||
|
|
The IcyParser separates audio bytes from ICY metadata in a Shoutcast/Icecast stream. This is pure byte-level logic with no Android dependencies — fully unit-testable.
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Write failing IcyParser tests**
|
|||
|
|
|
|||
|
|
Test cases:
|
|||
|
|
- Correctly separates audio bytes from metadata given a known metaint
|
|||
|
|
- Parses `StreamTitle='Artist - Song';` into artist + title
|
|||
|
|
- Handles empty metadata blocks (length byte = 0)
|
|||
|
|
- Handles metadata with no ` - ` separator (title only, no artist)
|
|||
|
|
- Passthrough mode when metaint is null (no metadata in stream)
|
|||
|
|
- Handles metadata spanning the maximum size (255 × 16 = 4080 bytes)
|
|||
|
|
- Handles multiple consecutive audio+metadata cycles
|
|||
|
|
|
|||
|
|
The IcyParser reads from an `InputStream` and emits two things: audio bytes (via a callback or output stream) and metadata events. Design it as:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class IcyParser(
|
|||
|
|
private val input: InputStream,
|
|||
|
|
private val metaint: Int?,
|
|||
|
|
private val onAudioData: (ByteArray, Int, Int) -> Unit,
|
|||
|
|
private val onMetadata: (IcyMetadata) -> Unit
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
data class IcyMetadata(
|
|||
|
|
val raw: String,
|
|||
|
|
val title: String?,
|
|||
|
|
val artist: String?
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Test by constructing a `ByteArrayInputStream` with hand-crafted ICY stream bytes:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@Test
|
|||
|
|
fun `separates audio and metadata`() {
|
|||
|
|
val metaint = 8
|
|||
|
|
val audioChunk = ByteArray(8) { 0x42 }
|
|||
|
|
val metaString = "StreamTitle='Test Title';"
|
|||
|
|
val metaPadded = padMetadata(metaString) // pad to 16-byte boundary
|
|||
|
|
val metaLengthByte = (metaPadded.size / 16).toByte()
|
|||
|
|
|
|||
|
|
val stream = ByteArrayInputStream(
|
|||
|
|
audioChunk + byteArrayOf(metaLengthByte) + metaPadded + audioChunk + byteArrayOf(0)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
val audioCollected = ByteArrayOutputStream()
|
|||
|
|
val metadataCollected = mutableListOf<IcyMetadata>()
|
|||
|
|
|
|||
|
|
val parser = IcyParser(stream, metaint,
|
|||
|
|
onAudioData = { buf, off, len -> audioCollected.write(buf, off, len) },
|
|||
|
|
onMetadata = { metadataCollected.add(it) }
|
|||
|
|
)
|
|||
|
|
parser.readAll()
|
|||
|
|
|
|||
|
|
assertEquals(16, audioCollected.size()) // 8 + 8 from two cycles
|
|||
|
|
assertEquals(1, metadataCollected.size)
|
|||
|
|
assertEquals("Test Title", metadataCollected[0].title)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Run tests to verify they fail**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.IcyParserTest" -v`
|
|||
|
|
Expected: FAIL
|
|||
|
|
|
|||
|
|
**Step 3: Implement IcyParser**
|
|||
|
|
|
|||
|
|
Core loop:
|
|||
|
|
```kotlin
|
|||
|
|
fun readAll() {
|
|||
|
|
if (metaint == null) {
|
|||
|
|
// Passthrough — no metadata in this stream
|
|||
|
|
val buf = ByteArray(8192)
|
|||
|
|
while (true) {
|
|||
|
|
val read = input.read(buf)
|
|||
|
|
if (read == -1) break
|
|||
|
|
onAudioData(buf, 0, read)
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
val audioBuf = ByteArray(metaint)
|
|||
|
|
while (true) {
|
|||
|
|
// Read exactly metaint audio bytes
|
|||
|
|
val audioRead = input.readFully(audioBuf, 0, metaint)
|
|||
|
|
if (audioRead < metaint) break
|
|||
|
|
onAudioData(audioBuf, 0, metaint)
|
|||
|
|
|
|||
|
|
// Read metadata length byte
|
|||
|
|
val lengthByte = input.read()
|
|||
|
|
if (lengthByte == -1) break
|
|||
|
|
val metaLength = lengthByte * 16
|
|||
|
|
if (metaLength == 0) continue
|
|||
|
|
|
|||
|
|
// Read metadata
|
|||
|
|
val metaBuf = ByteArray(metaLength)
|
|||
|
|
val metaRead = input.readFully(metaBuf, 0, metaLength)
|
|||
|
|
if (metaRead < metaLength) break
|
|||
|
|
val metaString = String(metaBuf, Charsets.UTF_8).trimEnd('\u0000')
|
|||
|
|
onMetadata(parseMetaString(metaString))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`parseMetaString` extracts `StreamTitle` value, attempts split on ` - ` for artist/title.
|
|||
|
|
|
|||
|
|
Provide a `readFully` extension on `InputStream` that loops until requested bytes are read or EOF.
|
|||
|
|
|
|||
|
|
**Step 4: Run tests to verify they pass**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.IcyParserTest" -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add ICY metadata parser with artist/title extraction"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: Audio Engine — Mp3FrameSync
|
|||
|
|
|
|||
|
|
Finds MP3 frame boundaries in a raw byte stream. Pure logic, no Android dependencies.
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Write failing tests**
|
|||
|
|
|
|||
|
|
Test cases:
|
|||
|
|
- Finds valid MP3 frame at start of data
|
|||
|
|
- Finds frame after garbage bytes (re-sync)
|
|||
|
|
- Correctly calculates frame size from header (MPEG1 Layer3 at various bitrates)
|
|||
|
|
- Handles padding bit
|
|||
|
|
- Emits complete frames via callback
|
|||
|
|
- Handles truncated frame at end of data (discards it)
|
|||
|
|
- Validates frame header (rejects invalid bitrate/sample rate combos)
|
|||
|
|
|
|||
|
|
Reference: MP3 frame header is 4 bytes. Byte 0-1 contain sync word (11 bits of 1s). Bytes 1-3 contain MPEG version, layer, bitrate index, sample rate index, padding.
|
|||
|
|
|
|||
|
|
Frame size = `(144 * bitrate / sampleRate) + padding`
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class Mp3FrameSyncTest {
|
|||
|
|
@Test
|
|||
|
|
fun `finds frame at start of data`() {
|
|||
|
|
// MPEG1 Layer3, 128kbps, 44100Hz, no padding = 417 bytes
|
|||
|
|
val header = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x00)
|
|||
|
|
val frameBody = ByteArray(417 - 4) // fill with zeros
|
|||
|
|
val frame = header + frameBody
|
|||
|
|
|
|||
|
|
val frames = mutableListOf<ByteArray>()
|
|||
|
|
val sync = Mp3FrameSync { frames.add(it) }
|
|||
|
|
sync.feed(frame)
|
|||
|
|
sync.flush()
|
|||
|
|
|
|||
|
|
assertEquals(1, frames.size)
|
|||
|
|
assertEquals(417, frames[0].size)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `re-syncs after garbage bytes`() {
|
|||
|
|
val garbage = ByteArray(100) { 0x42 }
|
|||
|
|
val header = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x00)
|
|||
|
|
val frameBody = ByteArray(413)
|
|||
|
|
val frame = header + frameBody
|
|||
|
|
|
|||
|
|
val frames = mutableListOf<ByteArray>()
|
|||
|
|
val sync = Mp3FrameSync { frames.add(it) }
|
|||
|
|
sync.feed(garbage + frame)
|
|||
|
|
sync.flush()
|
|||
|
|
|
|||
|
|
assertEquals(1, frames.size)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Run tests to verify they fail**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.Mp3FrameSyncTest" -v`
|
|||
|
|
Expected: FAIL
|
|||
|
|
|
|||
|
|
**Step 3: Implement Mp3FrameSync**
|
|||
|
|
|
|||
|
|
The sync maintains an internal byte buffer. `feed(bytes)` appends to the buffer. After each feed, it scans for frame headers, validates them, reads the full frame, and emits via callback.
|
|||
|
|
|
|||
|
|
Key implementation details:
|
|||
|
|
- Bitrate table for MPEG1 Layer 3: `[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]` (index 0 and 15 are invalid)
|
|||
|
|
- Sample rate table for MPEG1: `[44100, 48000, 32000, 0]`
|
|||
|
|
- Frame size formula: `144 * bitrate * 1000 / sampleRate + padding`
|
|||
|
|
- After finding a valid header and reading the frame, verify the next bytes also start with a valid sync word (two-frame validation) to reduce false sync hits in corrupted data.
|
|||
|
|
|
|||
|
|
**Step 4: Run tests to verify they pass**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.Mp3FrameSyncTest" -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add MP3 frame synchronizer with re-sync and validation"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: Audio Engine — StreamConnection
|
|||
|
|
|
|||
|
|
HTTP connection to the radio stream using OkHttp. Requests ICY metadata. Tested with MockWebServer.
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/audio/StreamConnectionTest.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Write failing tests**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class StreamConnectionTest {
|
|||
|
|
@get:Rule val server = MockWebServer()
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `sends icy-metadata header and reads metaint from response`() {
|
|||
|
|
server.enqueue(MockResponse()
|
|||
|
|
.setHeader("icy-metaint", "16000")
|
|||
|
|
.setHeader("Content-Type", "audio/mpeg")
|
|||
|
|
.setBody("fake audio data"))
|
|||
|
|
|
|||
|
|
val conn = StreamConnection(server.url("/stream").toString())
|
|||
|
|
conn.open()
|
|||
|
|
|
|||
|
|
val request = server.takeRequest()
|
|||
|
|
assertEquals("1", request.getHeader("Icy-MetaData"))
|
|||
|
|
assertEquals(16000, conn.metaint)
|
|||
|
|
assertNotNull(conn.inputStream)
|
|||
|
|
conn.close()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `metaint is null when server does not provide it`() { ... }
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `throws ConnectionFailed on HTTP error`() { ... }
|
|||
|
|
|
|||
|
|
@Test
|
|||
|
|
fun `throws ConnectionFailed on network error`() { ... }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Run tests to verify they fail**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.StreamConnectionTest" -v`
|
|||
|
|
Expected: FAIL
|
|||
|
|
|
|||
|
|
**Step 3: Implement StreamConnection**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class StreamConnection(private val url: String) {
|
|||
|
|
private val client = OkHttpClient.Builder()
|
|||
|
|
.readTimeout(Duration.ofSeconds(30))
|
|||
|
|
.build()
|
|||
|
|
|
|||
|
|
var metaint: Int? = null
|
|||
|
|
private set
|
|||
|
|
var inputStream: InputStream? = null
|
|||
|
|
private set
|
|||
|
|
private var response: Response? = null
|
|||
|
|
|
|||
|
|
fun open() {
|
|||
|
|
val request = Request.Builder()
|
|||
|
|
.url(url)
|
|||
|
|
.header("Icy-MetaData", "1")
|
|||
|
|
.header("User-Agent", "Radio247/1.0")
|
|||
|
|
.build()
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
val resp = client.newCall(request).execute()
|
|||
|
|
if (!resp.isSuccessful) {
|
|||
|
|
resp.close()
|
|||
|
|
throw ConnectionFailed("HTTP ${resp.code}")
|
|||
|
|
}
|
|||
|
|
response = resp
|
|||
|
|
metaint = resp.header("icy-metaint")?.toIntOrNull()
|
|||
|
|
inputStream = resp.body?.byteStream()
|
|||
|
|
?: throw ConnectionFailed("Empty response body")
|
|||
|
|
} catch (e: IOException) {
|
|||
|
|
throw ConnectionFailed("Network error", e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fun close() {
|
|||
|
|
response?.close()
|
|||
|
|
response = null
|
|||
|
|
inputStream = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4: Run tests to verify they pass**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.StreamConnectionTest" -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add HTTP stream connection with ICY header support"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: Audio Engine — Integration (AudioEngine)
|
|||
|
|
|
|||
|
|
Wires all stages together. This class coordinates the full pipeline: StreamConnection → IcyParser → (optional ring buffer) → Mp3FrameSync → MediaCodec → AudioTrack.
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineState.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Define AudioEngineState sealed class**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
sealed interface AudioEngineEvent {
|
|||
|
|
data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent
|
|||
|
|
data class Error(val cause: EngineError) : AudioEngineEvent
|
|||
|
|
data object Started : AudioEngineEvent
|
|||
|
|
data object Stopped : AudioEngineEvent
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sealed interface EngineError {
|
|||
|
|
data class ConnectionFailed(val cause: Throwable) : EngineError
|
|||
|
|
data object StreamEnded : EngineError
|
|||
|
|
data class DecoderError(val cause: Throwable) : EngineError
|
|||
|
|
data class AudioOutputError(val cause: Throwable) : EngineError
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Implement RingBuffer**
|
|||
|
|
|
|||
|
|
A simple fixed-capacity byte-array ring buffer for the configurable audio buffer between frame sync and decoder. When capacity is 0, `write()` calls `read callback` immediately (passthrough).
|
|||
|
|
|
|||
|
|
**Step 3: Implement AudioEngine**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class AudioEngine(
|
|||
|
|
private val url: String,
|
|||
|
|
private val bufferMs: Int = 0
|
|||
|
|
) {
|
|||
|
|
private val _events = MutableSharedFlow<AudioEngineEvent>(extraBufferCapacity = 16)
|
|||
|
|
val events: SharedFlow<AudioEngineEvent> = _events
|
|||
|
|
|
|||
|
|
private var thread: Thread? = null
|
|||
|
|
@Volatile private var running = false
|
|||
|
|
|
|||
|
|
val pendingLatencyMs: Long
|
|||
|
|
get() {
|
|||
|
|
// Estimate from AudioTrack write head vs play head
|
|||
|
|
// + ring buffer contents
|
|||
|
|
// Updated by the engine thread
|
|||
|
|
return _estimatedLatencyMs.get()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fun start() {
|
|||
|
|
running = true
|
|||
|
|
thread = Thread({
|
|||
|
|
try {
|
|||
|
|
runPipeline()
|
|||
|
|
} catch (e: Exception) {
|
|||
|
|
if (running) {
|
|||
|
|
_events.tryEmit(AudioEngineEvent.Error(categorizeError(e)))
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
_events.tryEmit(AudioEngineEvent.Stopped)
|
|||
|
|
}
|
|||
|
|
}, "AudioEngine").apply { start() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fun stop() {
|
|||
|
|
running = false
|
|||
|
|
thread?.interrupt()
|
|||
|
|
thread = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private fun runPipeline() {
|
|||
|
|
val connection = StreamConnection(url)
|
|||
|
|
connection.open()
|
|||
|
|
|
|||
|
|
val sampleRate = 44100 // Will be refined from first MP3 frame header
|
|||
|
|
val channels = AudioFormat.CHANNEL_OUT_STEREO
|
|||
|
|
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
|||
|
|
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channels, encoding)
|
|||
|
|
|
|||
|
|
val audioTrack = AudioTrack.Builder()
|
|||
|
|
.setAudioAttributes(AudioAttributes.Builder()
|
|||
|
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|||
|
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|||
|
|
.build())
|
|||
|
|
.setAudioFormat(AudioFormat.Builder()
|
|||
|
|
.setSampleRate(sampleRate)
|
|||
|
|
.setChannelMask(channels)
|
|||
|
|
.setEncoding(encoding)
|
|||
|
|
.build())
|
|||
|
|
.setBufferSizeInBytes(minBuf)
|
|||
|
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
|||
|
|
.build()
|
|||
|
|
|
|||
|
|
audioTrack.play()
|
|||
|
|
|
|||
|
|
// Configure MediaCodec for MP3
|
|||
|
|
val codec = MediaCodec.createDecoderByType("audio/mpeg")
|
|||
|
|
val format = MediaFormat.createAudioFormat("audio/mpeg", sampleRate, 2)
|
|||
|
|
codec.configure(format, null, null, 0)
|
|||
|
|
codec.start()
|
|||
|
|
|
|||
|
|
_events.tryEmit(AudioEngineEvent.Started)
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
val frameSync = Mp3FrameSync { mp3Frame ->
|
|||
|
|
decodeToPcm(codec, mp3Frame, audioTrack)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
val icyParser = IcyParser(
|
|||
|
|
input = connection.inputStream!!,
|
|||
|
|
metaint = connection.metaint,
|
|||
|
|
onAudioData = { buf, off, len -> frameSync.feed(buf, off, len) },
|
|||
|
|
onMetadata = { _events.tryEmit(AudioEngineEvent.MetadataChanged(it)) }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
icyParser.readAll() // Blocks until stream ends or error
|
|||
|
|
} finally {
|
|||
|
|
codec.stop()
|
|||
|
|
codec.release()
|
|||
|
|
audioTrack.stop()
|
|||
|
|
audioTrack.release()
|
|||
|
|
connection.close()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private fun decodeToPcm(codec: MediaCodec, mp3Frame: ByteArray, audioTrack: AudioTrack) {
|
|||
|
|
// Feed MP3 frame to codec input
|
|||
|
|
val inIdx = codec.dequeueInputBuffer(1000)
|
|||
|
|
if (inIdx >= 0) {
|
|||
|
|
val inBuf = codec.getInputBuffer(inIdx)!!
|
|||
|
|
inBuf.clear()
|
|||
|
|
inBuf.put(mp3Frame)
|
|||
|
|
codec.queueInputBuffer(inIdx, 0, mp3Frame.size, 0, 0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pull decoded PCM from codec output
|
|||
|
|
val bufferInfo = MediaCodec.BufferInfo()
|
|||
|
|
var outIdx = codec.dequeueOutputBuffer(bufferInfo, 1000)
|
|||
|
|
while (outIdx >= 0) {
|
|||
|
|
val outBuf = codec.getOutputBuffer(outIdx)!!
|
|||
|
|
val pcmData = ByteArray(bufferInfo.size)
|
|||
|
|
outBuf.get(pcmData)
|
|||
|
|
outBuf.clear()
|
|||
|
|
codec.releaseOutputBuffer(outIdx, false)
|
|||
|
|
|
|||
|
|
audioTrack.write(pcmData, 0, pcmData.size)
|
|||
|
|
outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Note: `AudioEngine` uses Android APIs (`MediaCodec`, `AudioTrack`) so it can't be fully unit-tested without an Android device. The individual stages (IcyParser, Mp3FrameSync, StreamConnection) are tested independently. Integration testing of the full pipeline will be done manually on-device with real streams.
|
|||
|
|
|
|||
|
|
**Step 4: Verify build**
|
|||
|
|
|
|||
|
|
Run: `./gradlew assembleDebug`
|
|||
|
|
Expected: BUILD SUCCESSFUL
|
|||
|
|
|
|||
|
|
**Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: integrate audio engine pipeline with MediaCodec and AudioTrack"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: Foreground Service & Stay Connected
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt`
|
|||
|
|
- Modify: `app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt` (expose DB + prefs)
|
|||
|
|
|
|||
|
|
**Step 1: Define PlaybackState**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
sealed interface PlaybackState {
|
|||
|
|
data object Idle : PlaybackState
|
|||
|
|
data class Playing(
|
|||
|
|
val station: Station,
|
|||
|
|
val metadata: IcyMetadata? = null,
|
|||
|
|
val sessionStartedAt: Long = System.currentTimeMillis(),
|
|||
|
|
val connectionStartedAt: Long = System.currentTimeMillis()
|
|||
|
|
) : PlaybackState
|
|||
|
|
data class Reconnecting(
|
|||
|
|
val station: Station,
|
|||
|
|
val metadata: IcyMetadata? = null,
|
|||
|
|
val sessionStartedAt: Long,
|
|||
|
|
val attempt: Int = 1
|
|||
|
|
) : PlaybackState
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Implement NotificationHelper**
|
|||
|
|
|
|||
|
|
Creates and manages the foreground notification. Uses `NotificationCompat` with a `MEDIA` channel. Shows station name, track title, album art bitmap, and a stop action. Updates notification content without recreating it (use same notification ID).
|
|||
|
|
|
|||
|
|
**Step 3: Implement RadioPlaybackService**
|
|||
|
|
|
|||
|
|
Key responsibilities:
|
|||
|
|
- Start/stop audio engine
|
|||
|
|
- Manage `MediaSession` (metadata, transport controls)
|
|||
|
|
- Acquire/release `WakeLock` and `WifiManager.WifiLock`
|
|||
|
|
- Stay Connected reconnection loop (exponential backoff, `ConnectivityManager` callback)
|
|||
|
|
- Persist `ListeningSession` and `ConnectionSpan` rows
|
|||
|
|
- Persist `MetadataSnapshot` on ICY metadata changes
|
|||
|
|
- Expose `PlaybackState` as `StateFlow` for the UI to observe
|
|||
|
|
|
|||
|
|
Reconnection logic:
|
|||
|
|
```kotlin
|
|||
|
|
private suspend fun reconnectLoop(station: Station, sessionId: Long) {
|
|||
|
|
var attempt = 0
|
|||
|
|
while (stayConnected && running) {
|
|||
|
|
attempt++
|
|||
|
|
_state.value = PlaybackState.Reconnecting(station, attempt = attempt, ...)
|
|||
|
|
updateNotification("Reconnecting... (attempt $attempt)")
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
startEngine(station, sessionId)
|
|||
|
|
return // Connected successfully
|
|||
|
|
} catch (e: Exception) {
|
|||
|
|
val delayMs = min(1000L * (1 shl (attempt - 1)), 30_000L)
|
|||
|
|
delay(delayMs)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Also register a `ConnectivityManager.NetworkCallback` — when network becomes available, cancel the current backoff delay and retry immediately.
|
|||
|
|
|
|||
|
|
**Step 4: Wire up Application class**
|
|||
|
|
|
|||
|
|
`RadioApplication` provides:
|
|||
|
|
- `RadioDatabase` singleton via `Room.databaseBuilder`
|
|||
|
|
- `RadioPreferences` instance
|
|||
|
|
- A way for the service and UI to share state (a `RadioController` singleton or direct service binding)
|
|||
|
|
|
|||
|
|
**Step 5: Verify build**
|
|||
|
|
|
|||
|
|
Run: `./gradlew assembleDebug`
|
|||
|
|
Expected: BUILD SUCCESSFUL
|
|||
|
|
|
|||
|
|
**Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add foreground playback service with Stay Connected reconnection"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 9: UI — Theme & Navigation
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/theme/Theme.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/theme/Color.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/theme/Type.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/navigation/Screen.kt`
|
|||
|
|
- Modify: `app/src/main/java/xyz/cottongin/radio247/MainActivity.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Define theme**
|
|||
|
|
|
|||
|
|
Use Material 3 dynamic color where available (Android 12+), fall back to a custom dark/light scheme for API 28+. Define a `Radio247Theme` composable.
|
|||
|
|
|
|||
|
|
**Step 2: Define navigation**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
sealed class Screen {
|
|||
|
|
data object StationList : Screen()
|
|||
|
|
data object NowPlaying : Screen()
|
|||
|
|
data object Settings : Screen()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Simple state-driven navigation in `MainActivity`:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
var currentScreen by remember { mutableStateOf<Screen>(Screen.StationList) }
|
|||
|
|
|
|||
|
|
Radio247Theme {
|
|||
|
|
when (currentScreen) {
|
|||
|
|
Screen.StationList -> StationListScreen(
|
|||
|
|
onNavigateToNowPlaying = { currentScreen = Screen.NowPlaying },
|
|||
|
|
onNavigateToSettings = { currentScreen = Screen.Settings }
|
|||
|
|
)
|
|||
|
|
Screen.NowPlaying -> NowPlayingScreen(
|
|||
|
|
onBack = { currentScreen = Screen.StationList }
|
|||
|
|
)
|
|||
|
|
Screen.Settings -> SettingsScreen(
|
|||
|
|
onBack = { currentScreen = Screen.StationList }
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Handle system back button via `BackHandler`.
|
|||
|
|
|
|||
|
|
**Step 3: Verify build**
|
|||
|
|
|
|||
|
|
Run: `./gradlew assembleDebug`
|
|||
|
|
Expected: BUILD SUCCESSFUL
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add Material 3 theme and screen navigation"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 10: UI — Station List Screen
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddPlaylistDialog.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Implement StationListViewModel**
|
|||
|
|
|
|||
|
|
Exposes:
|
|||
|
|
- `playlists: StateFlow<List<PlaylistWithStations>>` (playlist + its stations, ordered by starred desc then sortOrder)
|
|||
|
|
- `unsortedStations: StateFlow<List<Station>>` (stations with null playlistId)
|
|||
|
|
- `playbackState: StateFlow<PlaybackState>` (from service)
|
|||
|
|
- Functions: `playStation(station)`, `toggleStar(station)`, `deleteStation(station)`, `addStation(name, url, playlistId)`, `addPlaylist(name)`, `reorderStation(from, to)`, `reorderPlaylist(from, to)`, `importFile(uri)` (detects format, parses, inserts)
|
|||
|
|
|
|||
|
|
**Step 2: Implement StationListScreen**
|
|||
|
|
|
|||
|
|
- `TopAppBar` with title "24/7 Radio" and action icons (Import, Add Station, Add Playlist, Settings)
|
|||
|
|
- `LazyColumn` body:
|
|||
|
|
- "Unsorted" section (if any ungrouped stations exist)
|
|||
|
|
- Each playlist as an expandable section header (tap to expand/collapse)
|
|||
|
|
- Station rows within each section
|
|||
|
|
- Each station row: drag handle, star icon, station name, "now playing" indicator
|
|||
|
|
- Drag-to-reorder via `rememberReorderableLazyListState` (use `org.burnoutcrew.reorderable` library or implement manually with `detectDragGestures`)
|
|||
|
|
- `Scaffold` with `bottomBar` = `MiniPlayer` (visible when playback active)
|
|||
|
|
- Long-press station → context menu (Edit, Delete)
|
|||
|
|
- Add Station dialog: name + URL text fields, playlist dropdown
|
|||
|
|
- Add Playlist dialog: name text field
|
|||
|
|
- Import: launch `ACTION_OPEN_DOCUMENT` intent for `.m3u`/`.pls`, parse result, show confirmation
|
|||
|
|
|
|||
|
|
**Step 3: Implement MiniPlayer**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@Composable
|
|||
|
|
fun MiniPlayer(
|
|||
|
|
playbackState: PlaybackState.Playing,
|
|||
|
|
onTap: () -> Unit,
|
|||
|
|
onStop: () -> Unit
|
|||
|
|
) {
|
|||
|
|
Surface(
|
|||
|
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onTap),
|
|||
|
|
tonalElevation = 4.dp
|
|||
|
|
) {
|
|||
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
|
|||
|
|
Column(modifier = Modifier.weight(1f)) {
|
|||
|
|
Text(playbackState.station.name, style = MaterialTheme.typography.bodyMedium)
|
|||
|
|
playbackState.metadata?.let {
|
|||
|
|
Text(it.raw, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
IconButton(onClick = onStop) {
|
|||
|
|
Icon(Icons.Default.Stop, contentDescription = "Stop")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4: Verify build and test on device**
|
|||
|
|
|
|||
|
|
Run: `./gradlew assembleDebug`
|
|||
|
|
Install on device, verify station list displays, add/delete/star/reorder works, import PLS/M3U works.
|
|||
|
|
|
|||
|
|
**Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add Station List screen with playlists, drag-reorder, and import"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 11: UI — Now Playing Screen
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Implement NowPlayingViewModel**
|
|||
|
|
|
|||
|
|
Exposes:
|
|||
|
|
- `playbackState: StateFlow<PlaybackState>`
|
|||
|
|
- `sessionElapsed: StateFlow<Duration>` (ticks every second, calculates from `sessionStartedAt`)
|
|||
|
|
- `connectionElapsed: StateFlow<Duration>` (ticks every second, calculates from `connectionStartedAt`)
|
|||
|
|
- `estimatedLatencyMs: StateFlow<Long>` (reads from AudioEngine)
|
|||
|
|
- `stayConnected: StateFlow<Boolean>` (from prefs)
|
|||
|
|
- `bufferMs: StateFlow<Int>` (from prefs)
|
|||
|
|
- Functions: `stop()`, `toggleStayConnected()`, `setBufferMs(ms)`
|
|||
|
|
|
|||
|
|
Timer implementation: a coroutine that emits every 1 second using `delay(1000)`. Both timers tick from the same coroutine. Session timer reads `sessionStartedAt` from PlaybackState. Connection timer reads `connectionStartedAt`.
|
|||
|
|
|
|||
|
|
**Step 2: Implement NowPlayingScreen**
|
|||
|
|
|
|||
|
|
Layout (top to bottom):
|
|||
|
|
- Back arrow in top bar
|
|||
|
|
- Album art (large, centered, 200dp+ square)
|
|||
|
|
- Station name
|
|||
|
|
- Track title + artist (or "No track info")
|
|||
|
|
- Divider
|
|||
|
|
- Session timer: "Session: 1h 23m 45s"
|
|||
|
|
- Connection timer: "Connected: 14m 22s"
|
|||
|
|
- Latency indicator: "Latency: ~52ms"
|
|||
|
|
- Divider
|
|||
|
|
- Stay Connected toggle row
|
|||
|
|
- Buffer slider row (label shows current ms value)
|
|||
|
|
- Stop button (prominent, centered)
|
|||
|
|
|
|||
|
|
Connection status overlay: when `PlaybackState.Reconnecting`, show a semi-transparent overlay with "Reconnecting... (attempt N)" and a progress indicator.
|
|||
|
|
|
|||
|
|
**Step 3: Verify on device**
|
|||
|
|
|
|||
|
|
Run on device, play a station, verify:
|
|||
|
|
- Metadata updates
|
|||
|
|
- Both timers tick
|
|||
|
|
- Latency shows a reasonable value
|
|||
|
|
- Stay Connected toggle works
|
|||
|
|
- Stop button works
|
|||
|
|
- Reconnecting state displays correctly (test by toggling airplane mode)
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add Now Playing screen with dual timers and latency indicator"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 12: UI — Settings Screen
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsScreen.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModel.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Implement SettingsViewModel**
|
|||
|
|
|
|||
|
|
Exposes preferences as state, plus:
|
|||
|
|
- `recentStations: StateFlow<List<ListeningSession>>` (recent sessions with station info)
|
|||
|
|
- `trackHistory: StateFlow<List<MetadataSnapshot>>` (recent metadata, paginated)
|
|||
|
|
- `trackSearchQuery: StateFlow<String>`
|
|||
|
|
- Functions: `exportPlaylist(playlistId, format, uri)`, `setTrackSearchQuery(query)`
|
|||
|
|
|
|||
|
|
**Step 2: Implement SettingsScreen**
|
|||
|
|
|
|||
|
|
Sections:
|
|||
|
|
- **Playback** — Stay Connected toggle, Buffer slider
|
|||
|
|
- **Export** — Button to pick playlist and format, launches `ACTION_CREATE_DOCUMENT`
|
|||
|
|
- **Recently Played** — List of station names with "last listened" timestamps
|
|||
|
|
- **Track History** — Search bar + scrollable list of "Artist - Title" with station name and timestamp
|
|||
|
|
|
|||
|
|
**Step 3: Verify on device**
|
|||
|
|
|
|||
|
|
Test export (open exported file in another app to verify format). Check history populates after listening.
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add Settings screen with export, history, and track search"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 13: Metadata — Album Art Resolution
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt`
|
|||
|
|
- Create: `app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt`
|
|||
|
|
- Test: `app/src/test/java/xyz/cottongin/radio247/metadata/AlbumArtResolverTest.kt`
|
|||
|
|
|
|||
|
|
**Step 1: Write failing tests for AlbumArtResolver**
|
|||
|
|
|
|||
|
|
Test cases:
|
|||
|
|
- Returns cached URL if available (cache hit)
|
|||
|
|
- Queries MusicBrainz when artist and title present
|
|||
|
|
- Skips MusicBrainz when no ` - ` separator in metadata (spoken word)
|
|||
|
|
- Falls through to ICY StreamUrl when MusicBrainz returns nothing
|
|||
|
|
- Falls through to station defaultArtworkUrl when ICY StreamUrl is null
|
|||
|
|
- Returns null (placeholder) when all lookups fail
|
|||
|
|
- Respects MusicBrainz rate limit (1 req/sec)
|
|||
|
|
|
|||
|
|
**Step 2: Run tests to verify they fail**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.AlbumArtResolverTest" -v`
|
|||
|
|
Expected: FAIL
|
|||
|
|
|
|||
|
|
**Step 3: Implement AlbumArtResolver**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
class AlbumArtResolver(
|
|||
|
|
private val client: OkHttpClient,
|
|||
|
|
private val artCache: ArtCache
|
|||
|
|
) {
|
|||
|
|
suspend fun resolve(
|
|||
|
|
artist: String?,
|
|||
|
|
title: String?,
|
|||
|
|
icyStreamUrl: String?,
|
|||
|
|
stationArtworkUrl: String?
|
|||
|
|
): String? {
|
|||
|
|
val cacheKey = "${artist.orEmpty()}-${title.orEmpty()}"
|
|||
|
|
artCache.get(cacheKey)?.let { return it }
|
|||
|
|
|
|||
|
|
// 1. MusicBrainz — only if we have artist AND title
|
|||
|
|
if (artist != null && title != null) {
|
|||
|
|
val artUrl = queryMusicBrainz(artist, title)
|
|||
|
|
if (artUrl != null) {
|
|||
|
|
artCache.put(cacheKey, artUrl)
|
|||
|
|
return artUrl
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. ICY StreamUrl
|
|||
|
|
if (icyStreamUrl != null && isImageUrl(icyStreamUrl)) {
|
|||
|
|
artCache.put(cacheKey, icyStreamUrl)
|
|||
|
|
return icyStreamUrl
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Station default artwork
|
|||
|
|
if (stationArtworkUrl != null) {
|
|||
|
|
return stationArtworkUrl
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Station favicon — skip for V1, would require HTML parsing
|
|||
|
|
|
|||
|
|
// 5. Null → caller shows placeholder
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private suspend fun queryMusicBrainz(artist: String, title: String): String? {
|
|||
|
|
// Query: https://musicbrainz.org/ws/2/recording?query=artist:"$artist" AND recording:"$title"&fmt=json&limit=1
|
|||
|
|
// Extract release ID from first result
|
|||
|
|
// Then: https://coverartarchive.org/release/$releaseId/front-250
|
|||
|
|
// If 200, return that URL. If 404, return null.
|
|||
|
|
// Rate limit: delay(1000) between requests
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4: Implement ArtCache**
|
|||
|
|
|
|||
|
|
Simple LRU disk cache. Key = `"artist-title"` SHA-256 hash. Value = URL string stored in a small SQLite table or flat file. Bounded by entry count (e.g., 5000 entries). For the actual image bitmap caching, rely on OkHttp's cache or Coil (image loading library — add as dependency).
|
|||
|
|
|
|||
|
|
Consider adding Coil to dependencies for image loading in Compose:
|
|||
|
|
|
|||
|
|
```toml
|
|||
|
|
# In libs.versions.toml
|
|||
|
|
coil = "3.1.0"
|
|||
|
|
|
|||
|
|
[libraries]
|
|||
|
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
|||
|
|
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Use `AsyncImage` from Coil in Compose to load album art URLs with automatic caching.
|
|||
|
|
|
|||
|
|
**Step 5: Run tests to verify they pass**
|
|||
|
|
|
|||
|
|
Run: `./gradlew test --tests "*.AlbumArtResolverTest" -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 6: Wire album art into Now Playing and notification**
|
|||
|
|
|
|||
|
|
- `NowPlayingScreen`: Use `AsyncImage(model = artUrl, ...)` with placeholder drawable
|
|||
|
|
- `NotificationHelper`: Load bitmap via Coil's `ImageLoader.execute()`, set on notification via `setLargeIcon()`
|
|||
|
|
- `MediaSession`: Set artwork bitmap via `MediaMetadataCompat.Builder().putBitmap(METADATA_KEY_ART, bitmap)`
|
|||
|
|
|
|||
|
|
**Step 7: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: add album art resolution with MusicBrainz and fallback chain"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 14: Final Integration & Polish
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: various files for wiring everything together
|
|||
|
|
- Create: `app/src/main/res/drawable/ic_launcher_foreground.xml` (app icon)
|
|||
|
|
|
|||
|
|
**Step 1: Wire service binding to UI**
|
|||
|
|
|
|||
|
|
Ensure `MainActivity` binds to `RadioPlaybackService` and ViewModels can observe `PlaybackState`. Options:
|
|||
|
|
- Use a `RadioController` singleton in `RadioApplication` that both the service and UI access
|
|||
|
|
- Or use `LocalBroadcastManager` / `SharedFlow` exposed via Application class
|
|||
|
|
|
|||
|
|
Recommended: A `RadioController` object that holds the shared `MutableStateFlow<PlaybackState>` and provides `play(station)`, `stop()` functions that start/communicate with the service.
|
|||
|
|
|
|||
|
|
**Step 2: Handle Android lifecycle edge cases**
|
|||
|
|
|
|||
|
|
- Notification permission request on Android 13+ (POST_NOTIFICATIONS)
|
|||
|
|
- Audio focus: request `AUDIOFOCUS_GAIN` when playing, release on stop. Don't duck or pause for transient focus loss (this is a 24/7 radio — user expects it to keep playing).
|
|||
|
|
- Handle `ACTION_AUDIO_BECOMING_NOISY` (headphones unplugged) — pause playback.
|
|||
|
|
|
|||
|
|
**Step 3: Test end-to-end on device**
|
|||
|
|
|
|||
|
|
Manual test checklist:
|
|||
|
|
- [ ] Add station manually, verify it appears in list
|
|||
|
|
- [ ] Import M3U file, verify stations appear with artwork URLs
|
|||
|
|
- [ ] Play a station, verify audio comes out
|
|||
|
|
- [ ] Verify metadata appears on Now Playing and notification
|
|||
|
|
- [ ] Verify album art loads (or placeholder shows)
|
|||
|
|
- [ ] Verify session timer and connection timer tick
|
|||
|
|
- [ ] Enable Stay Connected, toggle airplane mode, verify reconnection
|
|||
|
|
- [ ] Verify dual timers: session keeps counting, connection resets on reconnect
|
|||
|
|
- [ ] Star a station, verify it moves to top
|
|||
|
|
- [ ] Reorder stations via drag
|
|||
|
|
- [ ] Export playlist as M3U, verify file content
|
|||
|
|
- [ ] Check Track History in Settings after listening
|
|||
|
|
- [ ] Kill app from recents, verify foreground service keeps playing
|
|||
|
|
- [ ] Verify lockscreen controls work
|
|||
|
|
- [ ] Verify Bluetooth headset button works
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "feat: wire service to UI, handle lifecycle, final integration"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 15: App Icon & README
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `app/src/main/res/mipmap-*/ic_launcher.webp` (or adaptive icon XMLs)
|
|||
|
|
- Create: `README.md`
|
|||
|
|
|
|||
|
|
**Step 1: Create a simple adaptive icon**
|
|||
|
|
|
|||
|
|
Vector drawable foreground with a radio/antenna icon. Use Material Icons or a simple custom SVG.
|
|||
|
|
|
|||
|
|
**Step 2: Write README**
|
|||
|
|
|
|||
|
|
Cover: what the app does, how to build (`./gradlew assembleDebug`), how to import stations (M3U/PLS), key features, architecture overview (link to design doc).
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git commit -m "docs: add app icon and README"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Dependency Summary
|
|||
|
|
|
|||
|
|
| Library | Purpose |
|
|||
|
|
|---|---|
|
|||
|
|
| Jetpack Compose + Material 3 | UI |
|
|||
|
|
| Room | Database (stations, playlists, metadata, sessions) |
|
|||
|
|
| DataStore | Preferences |
|
|||
|
|
| OkHttp | HTTP stream connections |
|
|||
|
|
| Coil 3 | Image loading and caching for album art |
|
|||
|
|
| MediaCodec (Android SDK) | MP3 decoding |
|
|||
|
|
| AudioTrack (Android SDK) | PCM audio output |
|
|||
|
|
| MediaSession (AndroidX) | Lockscreen/notification/Bluetooth controls |
|
|||
|
|
| MockWebServer (test) | HTTP testing for StreamConnection |
|
|||
|
|
| MockK (test) | Mocking |
|
|||
|
|
| Turbine (test) | Flow testing |
|
|||
|
|
| JUnit 4 (test) | Test framework |
|