fix(playback): ghost ICY readers — stop() racing a blocked read fired onError, whose auto-restart resurrected unbounded zombie streams; every ghost flickered its own song over the now-playing field

- 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)
This commit is contained in:
avi 2026-09-30 11:03:05 -05:00
commit 6147e0fde2
2 changed files with 21 additions and 2 deletions

View file

@ -465,11 +465,18 @@ class PlaybackManager(
} }
private fun startIcyReader(url: String) { 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() val reader = IcyStreamReader()
icyReader = reader icyReader = reader
reader.listener = object : IcyStreamReader.Listener { reader.listener = object : IcyStreamReader.Listener {
override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) { 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") Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata")
// Record at the reader-reported ABSOLUTE audio position (the same // Record at the reader-reported ABSOLUTE audio position (the same
// quantity as audioBuffer.totalBytes). The previous code read // quantity as audioBuffer.totalBytes). The previous code read
@ -486,6 +493,7 @@ class PlaybackManager(
} }
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) { override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
if (icyReader !== reader) return
audioBuffer?.write(data, offset, length.toInt()) audioBuffer?.write(data, offset, length.toInt())
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0) listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
@ -495,6 +503,7 @@ class PlaybackManager(
} }
override fun onError(error: String) { override fun onError(error: String) {
if (icyReader !== reader) return
Log.w(TAG, "ICY reader error: $error") Log.w(TAG, "ICY reader error: $error")
// ExoPlayer often rides out a transient drop on its own // ExoPlayer often rides out a transient drop on its own
// buffer without erroring; if it does, the recorder would // buffer without erroring; if it does, the recorder would

View file

@ -18,6 +18,12 @@ class IcyStreamReader(
var isRunning: Boolean = false var isRunning: Boolean = false
private set 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 metaInterval: Int = 0
private var bytesInInterval: Int = 0 private var bytesInInterval: Int = 0
private var pendingMetaBytes: Int = 0 private var pendingMetaBytes: Int = 0
@ -52,6 +58,7 @@ class IcyStreamReader(
fun start(streamUrl: String) { fun start(streamUrl: String) {
if (isRunning) stop() if (isRunning) stop()
requestedStop = false
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope?.launch { scope?.launch {
try { try {
@ -59,8 +66,10 @@ class IcyStreamReader(
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Normal stop // Normal stop
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error reading stream", e) if (!requestedStop) {
listener?.onError(e.message ?: "Connection error") Log.e(TAG, "Error reading stream", e)
listener?.onError(e.message ?: "Connection error")
}
} finally { } finally {
isRunning = false isRunning = false
} }
@ -68,6 +77,7 @@ class IcyStreamReader(
} }
fun stop() { fun stop() {
requestedStop = true
scope?.cancel() scope?.cancel()
scope = null scope = null
closeConnection() closeConnection()