From 6147e0fde20f6ad9b703d25b1fda1513fa378310 Mon Sep 17 00:00:00 2001 From: avi Date: Wed, 30 Sep 2026 11:03:05 -0500 Subject: [PATCH] =?UTF-8?q?fix(playback):=20ghost=20ICY=20readers=20?= =?UTF-8?q?=E2=80=94=20stop()=20racing=20a=20blocked=20read=20fired=20onEr?= =?UTF-8?q?ror,=20whose=20auto-restart=20resurrected=20unbounded=20zombie?= =?UTF-8?q?=20streams;=20every=20ghost=20flickered=20its=20own=20song=20ov?= =?UTF-8?q?er=20the=20now-playing=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IcyStreamReader: requestedStop flag — intentional stops never report as errors - PlaybackManager.startIcyReader: always stop the previous reader first; all four listener callbacks ignore events from a replaced reader (ghost audio was also poisoning the rolling buffer) --- .../main/java/com/rada/player/PlaybackManager.kt | 9 +++++++++ .../java/com/rada/recording/IcyStreamReader.kt | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index d656776..1bacbd6 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -465,11 +465,18 @@ class PlaybackManager( } private fun startIcyReader(url: String) { + // Never leave a previous reader's socket open: an unclosed stream + // keeps its OkHttp connection (and its own thread) streaming forever, + // and every ghost feeds the same now-playing field — that is how four + // different songs flickered over one another. + stopIcyReader() val reader = IcyStreamReader() icyReader = reader reader.listener = object : IcyStreamReader.Listener { override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) { + // Drop callbacks from a reader we already replaced or stopped. + if (icyReader !== reader) return Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata") // Record at the reader-reported ABSOLUTE audio position (the same // quantity as audioBuffer.totalBytes). The previous code read @@ -486,6 +493,7 @@ class PlaybackManager( } override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) { + if (icyReader !== reader) return audioBuffer?.write(data, offset, length.toInt()) listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0) @@ -495,6 +503,7 @@ class PlaybackManager( } override fun onError(error: String) { + if (icyReader !== reader) return Log.w(TAG, "ICY reader error: $error") // ExoPlayer often rides out a transient drop on its own // buffer without erroring; if it does, the recorder would diff --git a/app/src/main/java/com/rada/recording/IcyStreamReader.kt b/app/src/main/java/com/rada/recording/IcyStreamReader.kt index 989e393..5c3e899 100644 --- a/app/src/main/java/com/rada/recording/IcyStreamReader.kt +++ b/app/src/main/java/com/rada/recording/IcyStreamReader.kt @@ -18,6 +18,12 @@ class IcyStreamReader( var isRunning: Boolean = false private set + /** True once stop() was called by request. A close() racing a blocked + * stream.read() throws IOException, NOT CancellationException — without + * this flag the generic catch reports an intentional stop as an error, + * and the manager's error-restart resurrects a ghost reader. */ + @Volatile private var requestedStop = false + private var metaInterval: Int = 0 private var bytesInInterval: Int = 0 private var pendingMetaBytes: Int = 0 @@ -52,6 +58,7 @@ class IcyStreamReader( fun start(streamUrl: String) { if (isRunning) stop() + requestedStop = false scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope?.launch { try { @@ -59,8 +66,10 @@ class IcyStreamReader( } catch (e: CancellationException) { // Normal stop } catch (e: Exception) { - Log.e(TAG, "Error reading stream", e) - listener?.onError(e.message ?: "Connection error") + if (!requestedStop) { + Log.e(TAG, "Error reading stream", e) + listener?.onError(e.message ?: "Connection error") + } } finally { isRunning = false } @@ -68,6 +77,7 @@ class IcyStreamReader( } fun stop() { + requestedStop = true scope?.cancel() scope = null closeConnection()