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()