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:
parent
a490b394d3
commit
6147e0fde2
2 changed files with 21 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
if (!requestedStop) {
|
||||||
Log.e(TAG, "Error reading stream", e)
|
Log.e(TAG, "Error reading stream", e)
|
||||||
listener?.onError(e.message ?: "Connection error")
|
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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue