Skip to content

fix(android): sync Kotlin queue in QueuedAudioPlayer.load()#2589

Open
bitcrumb wants to merge 1 commit intodoublesymmetry:mainfrom
bitcrumb:fix/queued-audio-player-load-queue-sync-standalone
Open

fix(android): sync Kotlin queue in QueuedAudioPlayer.load()#2589
bitcrumb wants to merge 1 commit intodoublesymmetry:mainfrom
bitcrumb:fix/queued-audio-player-load-queue-sync-standalone

Conversation

@bitcrumb
Copy link

@bitcrumb bitcrumb commented Feb 26, 2026

Summary

In the else branch of QueuedAudioPlayer.load(), ExoPlayer's internal playlist was updated (via addMediaItem + removeMediaItem) but the Kotlin queue LinkedList was never updated to match. This caused queue[currentIndex] to always return the first-ever loaded item after switching tracks.

The immediate symptom: updateMetadataForTrack(index, bundle) reads the current track from queue[index], then calls replaceItem(index, track.toAudioItem()). Because queue[0] still pointed to the original track, replaceItem would call exoPlayer.replaceMediaItem with the original stream URL — reverting playback to the first-ever loaded track within ~10 seconds of switching (the metadata refresh interval).

Root cause

load() in the non-empty queue case bypasses add() and manipulates ExoPlayer directly, but forgot to mirror the change to the Kotlin queue:

// Before — queue[currentIndex] is never updated
} else {
    exoPlayer.addMediaItem(currentIndex + 1, item.toMediaItem())
    exoPlayer.removeMediaItem(currentIndex)
    exoPlayer.seekTo(currentIndex, C.TIME_UNSET)
    exoPlayer.prepare()
}

All properties that read from the queue (currentItem, items, previousItems, nextItems) and any caller that uses queue[index] directly (e.g. replaceItem) receive stale data after load().

Fix

Materialise the MediaItem once and assign it to queue[currentIndex] before passing it to ExoPlayer:

} else {
    val mediaItem = item.toMediaItem()
    // Keep the Kotlin queue in sync with ExoPlayer's internal queue.
    // Without this, queue[currentIndex] always returns the first-ever loaded item,
    // causing updateMetadataForTrack to call replaceItem() with a stale stream URL
    // every time metadata is refreshed.
    queue[currentIndex] = mediaItem
    exoPlayer.addMediaItem(currentIndex + 1, mediaItem)
    exoPlayer.removeMediaItem(currentIndex)
    exoPlayer.seekTo(currentIndex, C.TIME_UNSET)
    exoPlayer.prepare()
}

In the else branch of load(), ExoPlayer's media items were replaced but
the internal Kotlin queue LinkedList was never updated. This caused
queue[currentIndex] to always return the first-ever loaded item, so
updateMetadataForTrack would resolve to the wrong track and call
replaceItem() with a stale stream URL — reverting the current station
back to the original within ~10 seconds of switching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bitcrumb bitcrumb mentioned this pull request Feb 26, 2026
@bitcrumb
Copy link
Author

Question: could replaceItem be used here instead?

The existing replaceItem method already handles keeping both the Kotlin queue and ExoPlayer in sync as a single atomic operation:

fun replaceItem(index: Int, item: AudioItem) {
    val mediaItem = item.toMediaItem()
    queue[index] = mediaItem
    exoPlayer.replaceMediaItem(index, mediaItem)
}

Which would simplify load() to:

override fun load(item: AudioItem) {
    if (queue.isEmpty()) {
        add(item)
    } else {
        replaceItem(currentIndex, item)
        exoPlayer.seekTo(currentIndex, C.TIME_UNSET)
        exoPlayer.prepare()
    }
}

The main difference is that the current implementation uses addMediaItem + removeMediaItem (two separate operations) whereas replaceItem uses exoPlayer.replaceMediaItem (a single atomic operation). Was the add/remove approach chosen intentionally — for example, to ensure a specific transition behaviour with live streams or to avoid a stream restart that replaceMediaItem might trigger? If not, delegating to replaceItem would eliminate the risk of this sync bug recurring.

@bitcrumb
Copy link
Author

Just noticed that this issue is talking about the same thing: #2587

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant