diff --git a/docs/plans/2026-03-09-android-247-radio-implementation.md b/docs/plans/2026-03-09-android-247-radio-implementation.md
new file mode 100644
index 0000000..631a1cd
--- /dev/null
+++ b/docs/plans/2026-03-09-android-247-radio-implementation.md
@@ -0,0 +1,1649 @@
+# 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**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>` 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 = dataStore.data.map { it[STAY_CONNECTED] ?: false }
+ val bufferMs: Flow = dataStore.data.map { it[BUFFER_MS] ?: 0 }
+ val lastStationId: Flow = 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 {
+ val stations = mutableListOf()
+ 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 {
+ val files = mutableMapOf()
+ val titles = mutableMapOf()
+
+ 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): String` and `toPls(stations: List): 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()
+
+ 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()
+ 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()
+ 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(extraBufferCapacity = 16)
+ val events: SharedFlow = _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.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>` (playlist + its stations, ordered by starred desc then sortOrder)
+- `unsortedStations: StateFlow>` (stations with null playlistId)
+- `playbackState: StateFlow` (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`
+- `sessionElapsed: StateFlow` (ticks every second, calculates from `sessionStartedAt`)
+- `connectionElapsed: StateFlow` (ticks every second, calculates from `connectionStartedAt`)
+- `estimatedLatencyMs: StateFlow` (reads from AudioEngine)
+- `stayConnected: StateFlow` (from prefs)
+- `bufferMs: StateFlow` (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>` (recent sessions with station info)
+- `trackHistory: StateFlow>` (recent metadata, paginated)
+- `trackSearchQuery: StateFlow`
+- 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` 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 |