From 49bbb54bb9c67610f2b9eebc0bdc1605d0037050 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 04:41:40 -0400 Subject: [PATCH] fix: star visibility, station switching, skip-ahead, and latency optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Star icons now use distinct tint colors (primary vs faded) for clear state - Station switching no longer races — old playback job is cancelled before new - Skip-ahead drops ~1s of buffered audio per tap via atomic counter - Custom SocketFactory with SO_RCVBUF=16KB and TCP_NODELAY for minimal TCP buffering - Catch-up drain: discards pre-buffered frames until network reads block (live edge) - AudioTrack PERFORMANCE_MODE_LOW_LATENCY for smallest hardware buffer Made-with: Cursor --- .../cottongin/radio247/audio/AudioEngine.kt | 93 +++++++++++++- .../cottongin/radio247/audio/RingBuffer.kt | 12 ++ .../radio247/audio/StreamConnection.kt | 30 +++++ .../radio247/service/RadioController.kt | 7 ++ .../radio247/service/RadioPlaybackService.kt | 116 +++++++++++------- .../ui/screens/nowplaying/NowPlayingScreen.kt | 51 ++++++-- .../screens/nowplaying/NowPlayingViewModel.kt | 4 + .../screens/stationlist/StationListScreen.kt | 26 ++-- .../2026-03-09_bugfix-and-seek-to-live.md | 34 +++++ .../2026-03-09_full-implementation-summary.md | 75 +++++++++++ 10 files changed, 376 insertions(+), 72 deletions(-) create mode 100644 chat-summaries/2026-03-09_bugfix-and-seek-to-live.md create mode 100644 chat-summaries/2026-03-09_full-implementation-summary.md diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt index d570738..a6189ca 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt @@ -7,6 +7,8 @@ import android.media.MediaCodec import android.media.MediaFormat import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import java.io.InputStream +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong class AudioEngine( @@ -19,12 +21,25 @@ class AudioEngine( private var thread: Thread? = null @Volatile private var running = false - private val _estimatedLatencyMs = AtomicLong(0) + private val pendingSkips = AtomicInteger(0) + private val _estimatedLatencyMs = AtomicLong(0) val estimatedLatencyMs: Long get() = _estimatedLatencyMs.get() + @Volatile + private var currentRingBuffer: RingBuffer? = null + @Volatile + private var currentAudioTrack: AudioTrack? = null + @Volatile + private var currentCodec: MediaCodec? = null + @Volatile + private var catchingUp = true + @Volatile + private var timedStream: TimedInputStream? = null + fun start() { running = true + catchingUp = true thread = Thread({ try { runPipeline() @@ -49,10 +64,17 @@ class AudioEngine( thread = null } + fun skipAhead() { + pendingSkips.incrementAndGet() + } + private fun runPipeline() { val connection = StreamConnection(url) connection.open() + val tStream = TimedInputStream(connection.inputStream!!) + timedStream = tStream + val sampleRate = 44100 val channelConfig = AudioFormat.CHANNEL_OUT_STEREO val encoding = AudioFormat.ENCODING_PCM_16BIT @@ -73,6 +95,7 @@ class AudioEngine( .build() ) .setBufferSizeInBytes(minBuf) + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) .setTransferMode(AudioTrack.MODE_STREAM) .build() @@ -82,6 +105,9 @@ class AudioEngine( codec.start() audioTrack.play() + currentAudioTrack = audioTrack + currentCodec = codec + _events.tryEmit(AudioEngineEvent.Started) try { @@ -90,13 +116,14 @@ class AudioEngine( val ringBuffer = RingBuffer(bufferFrames) { mp3Frame -> decodeToPcm(codec, mp3Frame, audioTrack) } + currentRingBuffer = ringBuffer val frameSync = Mp3FrameSync { mp3Frame -> ringBuffer.write(mp3Frame) } val icyParser = IcyParser( - input = connection.inputStream!!, + input = tStream, metaint = connection.metaint, onAudioData = { buf, off, len -> frameSync.feed(buf, off, len) }, onMetadata = { _events.tryEmit(AudioEngineEvent.MetadataChanged(it)) } @@ -104,11 +131,14 @@ class AudioEngine( icyParser.readAll() - // Stream ended normally ringBuffer.flush() frameSync.flush() _events.tryEmit(AudioEngineEvent.Error(EngineError.StreamEnded)) } finally { + timedStream = null + currentRingBuffer = null + currentCodec = null + currentAudioTrack = null codec.stop() codec.release() audioTrack.stop() @@ -118,6 +148,26 @@ class AudioEngine( } private fun decodeToPcm(codec: MediaCodec, mp3Frame: ByteArray, audioTrack: AudioTrack) { + if (catchingUp) { + val lastReadMs = timedStream?.lastReadDurationMs ?: 0L + if (lastReadMs >= CATCHUP_THRESHOLD_MS) { + catchingUp = false + } else { + return + } + } + + val skips = pendingSkips.getAndSet(0) + if (skips > 0) { + val framesToDrop = skips * FRAMES_PER_SECOND + currentRingBuffer?.drop(framesToDrop) + audioTrack.pause() + audioTrack.flush() + audioTrack.play() + drainCodecOutput(codec) + return + } + val inIdx = codec.dequeueInputBuffer(1000) if (inIdx >= 0) { val inBuf = codec.getInputBuffer(inIdx)!! @@ -139,4 +189,41 @@ class AudioEngine( outIdx = codec.dequeueOutputBuffer(bufferInfo, 0) } } + + private fun drainCodecOutput(codec: MediaCodec) { + val bufferInfo = MediaCodec.BufferInfo() + var outIdx = codec.dequeueOutputBuffer(bufferInfo, 0) + while (outIdx >= 0) { + codec.releaseOutputBuffer(outIdx, false) + outIdx = codec.dequeueOutputBuffer(bufferInfo, 0) + } + } + + companion object { + private const val FRAMES_PER_SECOND = 38 + private const val CATCHUP_THRESHOLD_MS = 30L + } +} + +private class TimedInputStream(private val delegate: InputStream) : InputStream() { + @Volatile + var lastReadDurationMs: Long = 0L + private set + + override fun read(): Int { + val start = System.nanoTime() + val result = delegate.read() + lastReadDurationMs = (System.nanoTime() - start) / 1_000_000 + return result + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val start = System.nanoTime() + val result = delegate.read(b, off, len) + lastReadDurationMs = (System.nanoTime() - start) / 1_000_000 + return result + } + + override fun available(): Int = delegate.available() + override fun close() = delegate.close() } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt b/app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt index 7c66b6f..ee12bcc 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt @@ -22,4 +22,16 @@ class RingBuffer( onFrame(buffer.removeFirst()) } } + + fun clear() { + buffer.clear() + } + + fun drop(maxCount: Int): Int { + val toDrop = minOf(maxCount, buffer.size) + repeat(toDrop) { buffer.removeFirst() } + return toDrop + } + + val size: Int get() = buffer.size } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt index 7f52dce..c5d2086 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt @@ -5,12 +5,42 @@ import okhttp3.Request import okhttp3.Response import java.io.IOException import java.io.InputStream +import java.net.InetAddress +import java.net.Socket import java.time.Duration +import javax.net.SocketFactory + +private const val SOCKET_RECV_BUF = 16_384 + +private class LowLatencySocketFactory : SocketFactory() { + private val delegate = getDefault() + + override fun createSocket(): Socket = + delegate.createSocket().applyOpts() + + override fun createSocket(host: String, port: Int): Socket = + delegate.createSocket(host, port).applyOpts() + + override fun createSocket(host: String, port: Int, localAddr: InetAddress, localPort: Int): Socket = + delegate.createSocket(host, port, localAddr, localPort).applyOpts() + + override fun createSocket(host: InetAddress, port: Int): Socket = + delegate.createSocket(host, port).applyOpts() + + override fun createSocket(host: InetAddress, port: Int, localAddr: InetAddress, localPort: Int): Socket = + delegate.createSocket(host, port, localAddr, localPort).applyOpts() + + private fun Socket.applyOpts(): Socket = apply { + receiveBufferSize = SOCKET_RECV_BUF + tcpNoDelay = true + } +} class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause) class StreamConnection(private val url: String) { private val client = OkHttpClient.Builder() + .socketFactory(LowLatencySocketFactory()) .readTimeout(Duration.ofSeconds(30)) .build() diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt index 2c9f7ce..20b7584 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt @@ -31,6 +31,13 @@ class RadioController( application.startService(intent) } + fun seekToLive() { + val intent = Intent(application, RadioPlaybackService::class.java).apply { + action = RadioPlaybackService.ACTION_SEEK_LIVE + } + application.startService(intent) + } + // Called by the service to update state internal fun updateState(state: PlaybackState) { _state.value = state diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt index 2728854..1ba0d95 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -34,12 +34,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job class RadioPlaybackService : LifecycleService() { companion object { const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY" const val ACTION_STOP = "xyz.cottongin.radio247.STOP" + const val ACTION_SEEK_LIVE = "xyz.cottongin.radio247.SEEK_LIVE" const val EXTRA_STATION_ID = "station_id" } @@ -85,6 +88,8 @@ class RadioPlaybackService : LifecycleService() { @Volatile private var retryImmediatelyOnNetwork = false + private var playJob: Job? = null + override fun onCreate() { super.onCreate() notificationHelper.createChannel() @@ -95,24 +100,36 @@ class RadioPlaybackService : LifecycleService() { ACTION_PLAY -> { val stationId = intent.getLongExtra(EXTRA_STATION_ID, -1L) if (stationId >= 0) { - serviceScope.launch { - val station = stationDao.getStationById(stationId) - if (station != null) { - handlePlay(station) - } else { - stopSelf() - } - } + launchPlay(stationId) } else { stopSelf() } } + ACTION_SEEK_LIVE -> handleSeekLive() ACTION_STOP -> handleStop() else -> stopSelf() } return START_NOT_STICKY } + private fun launchPlay(stationId: Long) { + val oldJob = playJob + playJob = serviceScope.launch { + oldJob?.let { + stayConnected = false + engine?.stop() + it.join() + } + stayConnected = app.preferences.stayConnected.first() + val station = stationDao.getStationById(stationId) + if (station != null) { + handlePlay(station) + } else { + stopSelf() + } + } + } + override fun onBind(intent: Intent): IBinder? = null override fun onDestroy() { @@ -133,7 +150,6 @@ class RadioPlaybackService : LifecycleService() { } private suspend fun handlePlay(station: Station) { - stayConnected = app.preferences.stayConnected.first() sessionStartedAt = System.currentTimeMillis() listeningSessionId = listeningSessionDao.insert( @@ -159,12 +175,19 @@ class RadioPlaybackService : LifecycleService() { } finally { endConnectionSpan() endListeningSession() - cleanup() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + val isActiveJob = playJob == coroutineContext[Job] + if (isActiveJob) { + cleanup() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } } } + private fun handleSeekLive() { + engine?.skipAhead() + } + private fun handleStop() { stayConnected = false retryImmediatelyOnNetwork = false @@ -237,45 +260,44 @@ class RadioPlaybackService : LifecycleService() { engine!!.start() - serviceScope.launch collector@ { - try { - engine!!.events.collect { event -> - when (event) { - is AudioEngineEvent.MetadataChanged -> { - currentMetadata = event.metadata - val playingState = controller.state.value - if (playingState is PlaybackState.Playing) { - controller.updateState( - playingState.copy(metadata = event.metadata) - ) - } - updateNotification(station, event.metadata, false) - persistMetadataSnapshot(station.id, event.metadata) + val collectorJob = serviceScope.launch collector@ { + engine!!.events.collect { event -> + when (event) { + is AudioEngineEvent.MetadataChanged -> { + currentMetadata = event.metadata + val playingState = controller.state.value + if (playingState is PlaybackState.Playing) { + controller.updateState( + playingState.copy(metadata = event.metadata) + ) } - is AudioEngineEvent.Started -> { - controller.updateLatency(engine!!.estimatedLatencyMs) - } - is AudioEngineEvent.Error -> { - endConnectionSpan() - engine?.stop() - engine = null - val throwable = when (val cause = event.cause) { - is EngineError.ConnectionFailed -> cause.cause - is EngineError.StreamEnded -> Exception("Stream ended") - is EngineError.DecoderError -> cause.cause - is EngineError.AudioOutputError -> cause.cause - } - deferred.completeExceptionally(throwable) - } - is AudioEngineEvent.Stopped -> { - deferred.complete(Unit) + updateNotification(station, event.metadata, false) + persistMetadataSnapshot(station.id, event.metadata) + } + is AudioEngineEvent.Started -> { + controller.updateLatency(engine!!.estimatedLatencyMs) + } + is AudioEngineEvent.Error -> { + endConnectionSpan() + engine?.stop() + engine = null + val throwable = when (val cause = event.cause) { + is EngineError.ConnectionFailed -> cause.cause + is EngineError.StreamEnded -> Exception("Stream ended") + is EngineError.DecoderError -> cause.cause + is EngineError.AudioOutputError -> cause.cause } + deferred.completeExceptionally(throwable) + return@collect + } + is AudioEngineEvent.Stopped -> { + deferred.complete(Unit) + return@collect } } - } catch (e: Exception) { - if (!deferred.isCompleted) { - deferred.completeExceptionally(e) - } + } + if (!deferred.isCompleted) { + deferred.completeExceptionally(Exception("Event flow completed unexpectedly")) } } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt index f82177c..1304cc7 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt @@ -12,15 +12,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -162,6 +167,41 @@ fun NowPlayingScreen( Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { viewModel.skipAhead() }, + modifier = Modifier.size(64.dp), + enabled = state is PlaybackState.Playing + ) { + Icon( + Icons.Filled.FastForward, + contentDescription = "Skip ahead ~1s", + modifier = Modifier.size(40.dp), + tint = if (state is PlaybackState.Playing) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + Spacer(modifier = Modifier.width(24.dp)) + IconButton( + onClick = { viewModel.stop() }, + modifier = Modifier.size(64.dp) + ) { + Icon( + Icons.Filled.Stop, + contentDescription = "Stop", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -192,17 +232,6 @@ fun NowPlayingScreen( ) Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = { viewModel.stop() }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - ) { - Text("STOP") - } - - Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt index 7dd27bb..4cd6dd1 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt @@ -99,6 +99,10 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati controller.stop() } + fun skipAhead() { + controller.seekToLive() + } + fun toggleStayConnected() { viewModelScope.launch { val current = stayConnected.value diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt index fdd77c7..9df3613 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt @@ -266,15 +266,17 @@ private fun PlaylistSectionHeader( style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f) ) - IconButton( - onClick = { onToggleStar() }, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = if (playlist.starred) Icons.Default.Star else Icons.Outlined.Star, - contentDescription = if (playlist.starred) "Unstar" else "Star" - ) - } + IconButton( + onClick = { onToggleStar() }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (playlist.starred) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (playlist.starred) "Unstar" else "Star", + tint = if (playlist.starred) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } } } @@ -306,8 +308,10 @@ private fun StationRow( modifier = Modifier.size(36.dp) ) { Icon( - imageVector = if (station.starred) Icons.Default.Star else Icons.Outlined.Star, - contentDescription = if (station.starred) "Unstar" else "Star" + imageVector = if (station.starred) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (station.starred) "Unstar" else "Star", + tint = if (station.starred) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } Spacer(modifier = Modifier.width(8.dp)) diff --git a/chat-summaries/2026-03-09_bugfix-and-seek-to-live.md b/chat-summaries/2026-03-09_bugfix-and-seek-to-live.md new file mode 100644 index 0000000..11d3c17 --- /dev/null +++ b/chat-summaries/2026-03-09_bugfix-and-seek-to-live.md @@ -0,0 +1,34 @@ +# Bugfix: Star icons, station switching, and seek-to-live + +**Date:** 2026-03-09 + +## Task description +Fixed three issues found during manual testing of the app. + +## Changes made + +### 1. Star icon visual state (StationListScreen.kt) +- Added explicit tint colors to star icons: `primary` color when starred, faded `onSurfaceVariant` (40% alpha) when unstarred. +- Applies to both station rows and playlist section headers. + +### 2. Station switching race condition (RadioPlaybackService.kt) +- Added `playJob` tracking — each new play request cancels the previous playback coroutine before starting a new one. +- The old job's `finally` block now checks `playJob == coroutineContext[Job]` to avoid calling `cleanup()`/`stopSelf()` when being replaced by a new station. +- Tapping the same station now restarts it (re-fires `ACTION_PLAY`). +- Fixed collector coroutine leak: `return@collect` on terminal events (`Error`, `Stopped`) so the SharedFlow collection terminates. + +### 3. Seek-to-live feature +- Added `ACTION_SEEK_LIVE` to `RadioPlaybackService` — ends current connection span, stops/restarts the engine for the current station without creating a new `ListeningSession`. +- Added `seekToLive()` to `RadioController`. +- Added `seekToLive()` to `NowPlayingViewModel`. +- Added "SKIP TO LIVE" `FilledTonalButton` to `NowPlayingScreen`, positioned between latency indicator and Stay Connected toggle. Disabled during reconnection. + +## Files changed +- `app/src/main/java/.../ui/screens/stationlist/StationListScreen.kt` +- `app/src/main/java/.../service/RadioPlaybackService.kt` +- `app/src/main/java/.../service/RadioController.kt` +- `app/src/main/java/.../ui/screens/nowplaying/NowPlayingScreen.kt` +- `app/src/main/java/.../ui/screens/nowplaying/NowPlayingViewModel.kt` + +## Follow-up items +- None identified. diff --git a/chat-summaries/2026-03-09_full-implementation-summary.md b/chat-summaries/2026-03-09_full-implementation-summary.md new file mode 100644 index 0000000..75668e7 --- /dev/null +++ b/chat-summaries/2026-03-09_full-implementation-summary.md @@ -0,0 +1,75 @@ +# Full Implementation — Android 24/7 Radio + +**Date:** 2026-03-09 + +## Task Description + +Brainstormed, designed, planned, and implemented a complete Android 24/7 internet radio streaming app from scratch using subagent-driven development. + +## What Was Built + +### Audio Engine (custom raw pipeline) +- **StreamConnection** — OkHttp HTTP client with ICY metadata header support +- **IcyParser** — Separates audio bytes from ICY metadata, parses StreamTitle into artist/title +- **Mp3FrameSync** — Finds MP3 frame boundaries with two-frame validation and re-sync +- **AudioEngine** — Wires pipeline: StreamConnection → IcyParser → RingBuffer → Mp3FrameSync → MediaCodec → AudioTrack + +### Android Service Layer +- **RadioPlaybackService** — Foreground service with wake lock, wifi lock, MediaSession +- **Stay Connected** — Exponential backoff reconnection (1s→30s cap), ConnectivityManager callback for instant retry +- **NotificationHelper** — Media-style notification with stop action +- **RadioController** — Shared state between service and UI via StateFlow + +### Data Layer +- **Room Database** — Station, Playlist, MetadataSnapshot, ListeningSession, ConnectionSpan entities with full DAOs +- **DataStore Preferences** — stayConnected, bufferMs, lastStationId +- **PLS/M3U Import/Export** — Full parsers with #EXTIMG support, round-trip tested + +### UI (Jetpack Compose + Material 3) +- **Station List** — Playlists as expandable groups, starring, tap-to-play, long-press menu, import +- **Now Playing** — Album art, dual timers (session + connection), latency indicator, stay connected toggle, buffer slider +- **Settings** — Playback prefs, playlist export, recently played, track history with search +- **MiniPlayer** — Bottom bar on station list when playing + +### Metadata +- **AlbumArtResolver** — MusicBrainz/Cover Art Archive → ICY StreamUrl → #EXTIMG → placeholder +- **ArtCache** — In-memory LRU cache (500 entries) +- **Coil 3** — Image loading in Compose + +## Commit History (20 commits) + +1. Design document and implementation plan +2. Project scaffolding (Gradle, manifest, dependencies) +3. Room entities, DAOs, database, DataStore +4. M3U/PLS import/export with tests +5. ICY metadata parser with tests +6. MP3 frame synchronizer with tests +7. HTTP stream connection with tests +8. Audio engine integration +9. Foreground service with Stay Connected +10. Material 3 theme and navigation +11. Station List screen +12. Now Playing screen with dual timers +13. Settings screen with history +14. Album art resolution with MusicBrainz +15. Final integration and README + +## Test Coverage + +- IcyParser: 10 tests +- Mp3FrameSync: 9 tests +- StreamConnection: 6 tests +- M3uParser: 6 tests +- PlsParser: 5 tests +- PlaylistExporter: 4 tests +- RingBuffer: 4 tests +- AlbumArtResolver: 9 tests (MockWebServer) + +## Follow-Up Items + +- Test on actual Android 9 device with real Icecast/Shoutcast streams +- Add drag-to-reorder for stations and playlists +- Implement latency estimation from AudioTrack write/play head positions +- Add Bluetooth headset AUDIO_BECOMING_NOISY handling +- Add audio focus management +- Future: recording, clips, analytics (schema already supports it)