playback: preserve playback across canonical queue resets

This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent 762639cab5
commit 018f30a3d8
5 changed files with 1474 additions and 403 deletions
@@ -15,7 +15,10 @@ import com.subtracks.UiMessage
import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.ArtistRefsCodec import com.subtracks.data.model.ArtistRefsCodec
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueResetItem
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.log.Log import com.subtracks.log.Log
@@ -222,7 +225,10 @@ class Media3PlayerHandle(
) )
} }
private fun toQueueItem(mediaItem: MediaItem): QueueItem = private fun toQueueItem(mediaItem: MediaItem): QueueItem = queueItemFromMediaItem(mediaItem)
}
internal fun queueItemFromMediaItem(mediaItem: MediaItem): QueueItem =
QueueItem( QueueItem(
id = mediaItem.mediaId, id = mediaItem.mediaId,
title = title =
@@ -238,6 +244,28 @@ class Media3PlayerHandle(
?.takeIf { it.containsKey(EXTRA_DURATION_MS) } ?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS), ?.getLong(EXTRA_DURATION_MS),
replayGain = mediaItem.replayGain(), replayGain = mediaItem.replayGain(),
resetItem = mediaItem.resetItem(),
)
internal fun MediaItem.resetItem(): QueueResetItem? {
val extras = mediaMetadata.extras ?: return null
val kind = extras.getString(EXTRA_RESET_KIND)?.let { runCatching { QueueKind.valueOf(it) }.getOrNull() } ?: return null
val refId = extras.getString(EXTRA_RESET_REF_ID) ?: return null
val songId = extras.getString(EXTRA_RESET_SONG_ID) ?: return null
if (!extras.containsKey(EXTRA_RESET_SOURCE_ID) ||
!extras.containsKey(EXTRA_RESET_POSITION) ||
!extras.containsKey(EXTRA_RESET_ENTRY_OCCURRENCE) ||
!extras.containsKey(EXTRA_RESET_OCCURRENCE)
) {
return null
}
return QueueResetItem(
origin = QueueOrigin(kind, extras.getLong(EXTRA_RESET_SOURCE_ID), refId),
songId = songId,
position = extras.getLong(EXTRA_RESET_POSITION),
upNext = extras.getBoolean(EXTRA_RESET_UP_NEXT),
entryOccurrence = extras.getInt(EXTRA_RESET_ENTRY_OCCURRENCE),
occurrence = extras.getLong(EXTRA_RESET_OCCURRENCE),
) )
} }
@@ -255,6 +283,14 @@ private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak" private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak" private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
private const val EXTRA_ALBUM_CONTEXT = "albumContext" private const val EXTRA_ALBUM_CONTEXT = "albumContext"
private const val EXTRA_RESET_KIND = "resetKind"
private const val EXTRA_RESET_SOURCE_ID = "resetSourceId"
private const val EXTRA_RESET_REF_ID = "resetRefId"
private const val EXTRA_RESET_SONG_ID = "resetSongId"
private const val EXTRA_RESET_POSITION = "resetPosition"
private const val EXTRA_RESET_UP_NEXT = "resetUpNext"
private const val EXTRA_RESET_ENTRY_OCCURRENCE = "resetEntryOccurrence"
private const val EXTRA_RESET_OCCURRENCE = "resetOccurrence"
internal fun MediaItem?.replayGain(): ReplayGain { internal fun MediaItem?.replayGain(): ReplayGain {
val extras = this?.mediaMetadata?.extras ?: return ReplayGain() val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
@@ -303,6 +339,16 @@ internal fun queueMediaItem(
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak) putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak) putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext) putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
item.resetItem?.let { reset ->
putString(EXTRA_RESET_KIND, reset.origin.kind.name)
putLong(EXTRA_RESET_SOURCE_ID, reset.origin.sourceId)
putString(EXTRA_RESET_REF_ID, reset.origin.refId)
putString(EXTRA_RESET_SONG_ID, reset.songId)
putLong(EXTRA_RESET_POSITION, reset.position)
putBoolean(EXTRA_RESET_UP_NEXT, reset.upNext)
putInt(EXTRA_RESET_ENTRY_OCCURRENCE, reset.entryOccurrence)
putLong(EXTRA_RESET_OCCURRENCE, reset.occurrence)
}
}, },
).build() ).build()
val builder = val builder =
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,19 @@ class FakePlayerHandle : PlayerHandle {
@Volatile @Volatile
private var buffering = false private var buffering = false
override val itemCount: Int get() = items.size @Volatile
var expectedThread: Thread? = null
private fun checkThread() {
val expected = expectedThread ?: return
check(Thread.currentThread() == expected) { "Expected $expected, was ${Thread.currentThread()}" }
}
override val itemCount: Int
get() {
checkThread()
return items.size
}
override val currentIndex: Int get() = index override val currentIndex: Int get() = index
@@ -60,7 +72,11 @@ class FakePlayerHandle : PlayerHandle {
override val currentPositionMs: Long get() = positionMs override val currentPositionMs: Long get() = positionMs
override val currentItem: QueueItem? get() = items.getOrNull(index) override val currentItem: QueueItem?
get() {
checkThread()
return items.getOrNull(index)
}
/** Overrides the URIs the window reports; defaults to a stream URI per item. */ /** Overrides the URIs the window reports; defaults to a stream URI per item. */
@Volatile @Volatile
@@ -3,6 +3,9 @@ package com.subtracks.playback
import androidx.media3.common.C import androidx.media3.common.C
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.repo.QueueResetItem
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -100,6 +103,16 @@ class MediaItemTest {
assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext()) assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext())
} }
@Test
fun theItemKeepsItsResetIdentity() {
val resetItem =
QueueResetItem(QueueOrigin(QueueKind.Playlist, 1, "p1"), "s1", 2, upNext = false, entryOccurrence = 1, occurrence = 1)
val item = queueMediaItem(song.copy(resetItem = resetItem), local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals(resetItem, queueItemFromMediaItem(item).resetItem)
}
@Test @Test
fun anItemWithoutGainReadsBackAsNoGain() { fun anItemWithoutGainReadsBackAsNoGain() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false) val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
@@ -25,6 +25,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
@@ -42,9 +43,11 @@ import com.subtracks.data.repo.setShuffle
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -58,6 +61,7 @@ import okhttp3.OkHttpClient
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -66,7 +70,9 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File import java.io.File
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PlaybackControllerTest { class PlaybackControllerTest {
@@ -1788,13 +1794,13 @@ class PlaybackControllerTest {
} }
@Test @Test
fun stateCarriesTheQueueContext() { fun stateCarriesTheQueueOrigin() {
seedAlbum(3, sourceId = 1) seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0) controller.playAlbum(1, "al1", 0)
await { controller.state.value.item != null } await { controller.state.value.item != null }
assertEquals(QueueContext(QueueKind.Album, 1, "al1"), controller.state.value.context) assertEquals(QueueOrigin(QueueKind.Album, 1, "al1"), controller.state.value.context)
assertTrue(controller.state.value.isPlaying) assertTrue(controller.state.value.isPlaying)
assertTrue(controller.state.value.hasNext) assertTrue(controller.state.value.hasNext)
assertTrue(controller.state.value.hasPrevious) assertTrue(controller.state.value.hasPrevious)
@@ -2258,6 +2264,24 @@ class PlaybackControllerTest {
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id }) assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id })
} }
@Test
fun undoingARejectedRemovalKeepsTheShuffledOrder() {
seedShuffledQueue(seed = 42L)
val orderBefore = resolvedIds()
val currentPosition = controller.state.value.position!!
val before = runBlocking { queues.snapshot() }
val removePosition = (0 until before.size).first { it != currentPosition }
runBlocking { controller.removeAt(removePosition) }
assertEquals(orderBefore, resolvedIds())
runBlocking { controller.undo() }
await { controller.state.value.shuffle && resolvedIds() == orderBefore }
assertEquals(orderBefore, resolvedIds())
assertEquals(currentPosition, controller.state.value.position)
}
@Test @Test
fun movingContextTracksWhileShuffledIsIgnored() { fun movingContextTracksWhileShuffledIsIgnored() {
seedAlbum(6, sourceId = 1) seedAlbum(6, sourceId = 1)
@@ -2370,7 +2394,7 @@ class PlaybackControllerTest {
controller.previous() controller.previous()
assertTrue(handle.operations.contains("seekTo(0)")) await { handle.operations.contains("seekTo(0)") }
assertEquals( assertEquals(
"s1", "s1",
controller.state.value.item controller.state.value.item
@@ -3432,15 +3456,673 @@ class PlaybackControllerTest {
) )
} }
@Test
fun aStaleEndJobCannotMoveAReplacementQueueCursor() {
seedAlbum(3, sourceId = 1)
seedAlbumTracks("al2", 2)
controller.playAlbum(1, "al1", 0)
await { handle.currentItem?.id == "s1" && controller.state.value.isPlaying }
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
runBlocking {
queues.replace(listOf(queues.albumEntry(1, "al2")))
queues.setCursor(1)
}
val dispatcherBlocked = CountDownLatch(1)
val releaseDispatcher = CountDownLatch(1)
controllerScope.launch {
dispatcherBlocked.countDown()
releaseDispatcher.await()
}
assertTrue(dispatcherBlocked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
handle.finish()
releaseDispatcher.countDown()
Thread.sleep(200)
assertEquals(1L, runBlocking { queues.cursor() })
}
@Test
fun anEndJobDoesNotWrapAfterRepeatIsSwitchedOff() {
seedAlbum(2, sourceId = 1)
controller.playAlbum(1, "al1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
val dispatcherBlocked = CountDownLatch(1)
val releaseDispatcher = CountDownLatch(1)
controllerScope.launch {
dispatcherBlocked.countDown()
releaseDispatcher.await()
}
assertTrue(dispatcherBlocked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
handle.finish()
controller.cycleRepeat()
controller.cycleRepeat()
assertEquals(RepeatMode.Off, controller.state.value.repeat)
releaseDispatcher.countDown()
Thread.sleep(200)
assertEquals(1L, controller.state.value.position)
assertEquals(
"s2",
controller.state.value.item
?.id,
)
}
@Test
fun aSourceResetRemapsTheCurrentSongByOrigin() {
seedPlaylist(5)
seedSong("s0", "al1")
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s0", "s1", "s2", "s3", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.currentItem?.id == "s3" && controller.state.value.position == 3L }
assertEquals(listOf("s0", "s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
}
@Test
fun aSourceResetLetsTheRemovedCurrentFinishBeforeTheClampedTrack() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.items.map { it.id } == listOf("s3", "s4", "s5") }
assertEquals("s3", handle.currentItem?.id)
controller.next()
await { handle.currentItem?.id == "s4" }
assertEquals(2L, controller.state.value.position)
}
@Test
fun detachedPlaybackDoesNotPersistOffsetsOntoTheSurvivor() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.currentItem?.id == "s3" && controller.state.value.position == 2L }
handle.positionMs = 42_000L
controller.seekTo(42_000L)
Thread.sleep(100)
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
handle.positionMs = 43_000L
Thread.sleep(700)
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
controller.togglePlayPause()
await { !controller.state.value.isPlaying }
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
val restoredHandle = FakePlayerHandle()
val restored = controllerOn(dispatcher, restoredHandle)
restored.connect()
await {
restored.state.value.item
?.id == "s4"
}
assertEquals(0L, restored.positionMs.value)
}
@Test
fun removingTheCurrentlyPlayingSecondOccurrenceDetachesIt() {
seedPlaylist(3)
setPlaylist("s1", "s2", "s1")
controller.playPlaylist(1, "p1", 0)
await { handle.currentItem?.id == "s1" && controller.state.value.position == 0L }
val point = runBlocking { queues.captureResetPoint() }
controller.playAt(2)
await { handle.currentItem?.id == "s1" && controller.state.value.position == 2L }
setPlaylist("s1", "s2")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
assertEquals(1L, controller.state.value.position)
}
@Test
fun repeatedResetsKeepTheSecondDuplicateContextEntry() {
seedPlaylist(2)
setPlaylist("s1", "s2")
runBlocking { queues.replace(listOf(queues.playlistEntry(1, "p1"), queues.playlistEntry(1, "p1"))) }
controller.connect()
await { controller.state.value.item != null }
controller.playAt(3)
await { handle.currentItem?.id == "s2" && controller.state.value.position == 3L }
repeat(2) {
val point = runBlocking { queues.captureResetPoint() }
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
assertEquals(3L, controller.state.value.position)
}
assertEquals(3L, controller.state.value.position)
}
@Test
fun repeatedResetsKeepTheSecondDuplicateUpNextEntry() {
seedAlbum(3, sourceId = 1)
setPlaylist("s1", "s2")
controller.playAlbum(1, "al1", 0)
await { handle.currentItem?.id == "s1" && controller.state.value.isPlaying }
controller.addToQueue(1, QueueKind.Playlist, "p1")
controller.addToQueue(1, QueueKind.Playlist, "p1")
await { runBlocking { queues.snapshot().upNextSize } == 4L }
controller.playAt(4)
await { handle.currentItem?.id == "s2" && controller.state.value.position == 4L }
repeat(2) {
val point = runBlocking { queues.captureResetPoint() }
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
assertEquals(4L, controller.state.value.position)
}
assertEquals(4L, controller.state.value.position)
}
@Test
fun aPositionSaveQueuedBeforeResetCannotOverwriteTheSurvivorOffset() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
val initialId =
runBlocking {
db
.queueDao()
.entries()
.single()
.id
}
val dispatcherBlocked = CountDownLatch(1)
val releaseDispatcher = CountDownLatch(1)
controllerScope.launch {
dispatcherBlocked.countDown()
releaseDispatcher.await()
}
assertTrue(dispatcherBlocked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
handle.positionMs = 42_000L
controller.seekTo(42_000L)
setPlaylist("s1", "s2", "s4", "s5")
val reset =
repoScope.launch {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
}
try {
await {
runBlocking {
db
.queueDao()
.entries()
.single()
.id != initialId
}
}
} finally {
releaseDispatcher.countDown()
}
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
Thread.sleep(200)
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
}
@Test
fun aResetUsesTheReplacementQueueIdentityForTheSameSongAndPosition() {
seedPlaylist(1)
controller.playAlbum(1, "al1", 0)
await { handle.currentItem?.id == "s1" && controller.state.value.position == 0L }
val point = runBlocking { queues.captureResetPoint() }
controller.playPlaylist(1, "p1", 0)
await {
handle.currentItem?.id == "s1" && controller.state.value.context
?.kind == QueueKind.Playlist
}
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
controller.seekTo(42_000L)
Thread.sleep(100)
assertEquals(42_000L, runBlocking { queues.cursorPositionMs() })
}
private fun seedPlaylist(count: Int) {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "s$track",
albumId = "al1",
artistId = "ar1",
title = "Song $track",
album = "Album",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
db.libraryDao().upsertPlaylistSongs(
(1..count).map { PlaylistSong(1, "p1", "s$it", (it - 1).toLong()) },
)
}
}
private fun setPlaylist(vararg songIds: String) {
runBlocking {
db.libraryDao().deletePlaylistSongsFrom(1, "p1", 0)
db.libraryDao().upsertPlaylistSongs(
songIds.mapIndexed { position, songId -> PlaylistSong(1, "p1", songId, position.toLong()) },
)
}
}
@Test
fun anEmptyResetPointStillDetachesTheRemovedPlayerItem() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
runBlocking { controller.removeAt(0) }
setPlaylist()
runBlocking { queues.invalidateLibraryCache() }
val point = runBlocking { queues.captureResetPoint() }
assertNull(point.current)
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
assertEquals(listOf("s3", "s2", "s4", "s5"), handle.items.map { it.id })
assertEquals("s3", handle.currentItem?.id)
controller.next()
await { handle.currentItem?.id == "s2" }
}
@Test
fun aSecondResetKeepsTheDetachedTrackAndItsSurvivorPosition() {
seedPlaylist(5)
seedSong("s0", "al1")
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val firstPoint = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), firstPoint) }
await { handle.items.map { it.id } == listOf("s3", "s4", "s5") }
val secondPoint = runBlocking { queues.captureResetPoint() }
setPlaylist("s0", "s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), secondPoint) }
assertEquals("s3", handle.currentItem?.id)
controller.next()
await { handle.currentItem?.id == "s4" && controller.state.value.position == 3L }
}
@Test
fun aResetWithoutAConnectedPlayerRemapsTheSavedCursor() {
seedPlaylist(3)
seedSong("s0", "al1")
runBlocking {
queues.replace(listOf(queues.playlistEntry(1, "p1")))
queues.removeAt(queues.snapshot(), 0)
queues.setCursor(0)
queues.setPosition(50_000)
}
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s0", "s1", "s2", "s3")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
assertEquals(50_000L, runBlocking { queues.cursorPositionMs() })
controller.connect()
await { handle.currentItem?.id == "s2" && controller.state.value.position == 2L }
}
@Test
fun aResetFromIoReconcilesOnThePlaybackThread() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
handle.expectedThread = runBlocking { withContext(dispatcher) { Thread.currentThread() } }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking {
withContext(Dispatchers.IO) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
}
}
handle.expectedThread = null
assertEquals(listOf("s3", "s4", "s5"), handle.items.map { it.id })
assertEquals("s3", handle.currentItem?.id)
}
@Test
fun playAtJumpsFromADetachedTrackToTheResetItem() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.items.map { it.id } == listOf("s3", "s4", "s5") }
controller.playAt(2)
await { handle.currentItem?.id == "s4" }
assertEquals(2L, controller.state.value.position)
}
@Test
fun aDetachedTrackSurvivesMediaRefresh() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.items.map { it.id } == listOf("s3", "s4", "s5") }
handle.uris = listOf("http://stream/s3")
controller.refreshMediaItems()
Thread.sleep(200)
assertEquals(listOf("s3", "s4", "s5"), handle.items.map { it.id })
assertEquals("s3", handle.currentItem?.id)
}
@Test
fun addingUpNextWhileDetachedKeepsTheClampedTrackNext() {
seedPlaylist(5)
seedSong("x1", "al1")
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.items.map { it.id } == listOf("s3", "s4", "s5") }
controller.addToQueue(1, QueueKind.Song, "x1")
await { handle.items.map { it.id } == listOf("s3", "s4", "x1", "s5") }
assertEquals("s3", handle.currentItem?.id)
controller.next()
await { handle.currentItem?.id == "s4" }
assertEquals(2L, controller.state.value.position)
}
@Test
fun removingATrackWhileDetachedDoesNotRemoveTheClampedPlayerItem() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 2)
await { handle.currentItem?.id == "s3" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.items.map { it.id } == listOf("s3", "s4", "s5") }
runBlocking { controller.removeAt(3) }
assertEquals(listOf("s3", "s4"), handle.items.map { it.id })
assertEquals("s3", handle.currentItem?.id)
controller.next()
await { handle.currentItem?.id == "s4" }
assertEquals(2L, controller.state.value.position)
}
@Test
fun anUpNextTransitionFromBeforeAResetDoesNotConsumeTheNewSnapshot() {
seedPlaylist(5)
controller.playAlbum(1, "al1", 4)
await { handle.currentItem?.id == "s5" && controller.state.value.isPlaying }
controller.addToQueue(1, QueueKind.Playlist, "p1")
await { runBlocking { queues.snapshot().upNextSize } == 5L }
runBlocking {
controller.removeAt(5)
controller.removeAt(5)
}
controller.next()
await { handle.currentItem?.id == "s3" && controller.state.value.position == 5L }
val point = runBlocking { queues.captureResetPoint() }
val dispatcherBlocked = CountDownLatch(1)
val releaseDispatcher = CountDownLatch(1)
controllerScope.launch {
dispatcherBlocked.countDown()
releaseDispatcher.await()
}
assertTrue(dispatcherBlocked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
handle.advanceTo(handle.currentIndex + 1)
val reset =
repoScope.launch {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
}
await {
runBlocking {
db
.queueDao()
.upNextEntries()
.single()
.rangeStart == null
}
}
releaseDispatcher.countDown()
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
Thread.sleep(200)
assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), resolvedIds().drop(5))
assertEquals("s4", handle.currentItem?.id)
}
@Test
fun aResetRemapsAContextTrackThatMovedToUpNext() {
seedPlaylist(5)
seedSong("s0", "al1")
seedSong("x1", "al1")
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { queues.snapshot().upNextSize } == 1L }
val point = runBlocking { queues.captureResetPoint() }
controller.next()
await { handle.currentItem?.id == "x1" && controller.state.value.position == 2L }
setPlaylist("s0", "s1", "s2", "s3", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.currentItem?.id == "x1" && controller.state.value.position == 3L }
}
@Test
fun aResetRemapsAnUpNextTrackThatMovedToTheContext() {
seedPlaylist(5)
seedSong("s0", "al1")
seedSong("x1", "al1")
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { queues.snapshot().upNextSize } == 1L }
controller.next()
await { handle.currentItem?.id == "x1" && controller.state.value.position == 2L }
val point = runBlocking { queues.captureResetPoint() }
controller.next()
await { handle.currentItem?.id == "s3" && controller.state.value.position == 2L }
setPlaylist("s0", "s1", "s2", "s3", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.currentItem?.id == "s3" && controller.state.value.position == 3L }
}
@Test
fun aResetRemapsTheActualCurrentSongWhenItChangedDuringSync() {
seedPlaylist(5)
seedSong("s0", "al1")
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
val point = runBlocking { queues.captureResetPoint() }
controller.next()
await { handle.currentItem?.id == "s3" }
setPlaylist("s0", "s1", "s2", "s3", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.currentItem?.id == "s3" && controller.state.value.position == 3L }
}
@Test
fun aResetPreservesASameSongJumpToADifferentOrigin() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
runBlocking { controller.removeAt(0) }
await { controller.state.value.position == 0L }
controller.addToQueue(1, QueueKind.Album, "al1")
await { runBlocking { queues.snapshot().upNextSize } == 5L }
val point = runBlocking { queues.captureResetPoint() }
controller.playAt(2)
await { handle.currentItem?.id == "s2" && controller.state.value.position == 2L }
setPlaylist("s1", "s2", "s3", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { handle.currentItem?.id == "s2" && controller.state.value.position == 3L }
}
@Test
fun aResetDoesNotOverwriteANewerQueueEdit() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
runBlocking { controller.removeAt(0) }
val point = runBlocking { queues.captureResetPoint() }
runBlocking {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
queues.replace(listOf(queues.songEntry(1, "s5")))
}
await { runBlocking { queues.snapshot().entries.map { it.entry.kind } == listOf(QueueKind.Song) } }
assertEquals(listOf(QueueKind.Song), runBlocking { queues.snapshot().entries.map { it.entry.kind } })
}
@Test
fun removeAfterRepositoryResetCannotRestoreStaleEntries() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
runBlocking { controller.removeAt(0) }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s3", "s4", "s5")
runBlocking {
val locked = CountDownLatch(1)
val release = CountDownLatch(1)
val locker =
launch(Dispatchers.Default) {
queues.withMutationLock {
locked.countDown()
release.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
}
}
assertTrue(locked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
val reset =
async(Dispatchers.Default) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
}
val edit = async(start = CoroutineStart.UNDISPATCHED) { controller.removeAt(1) }
release.countDown()
reset.await()
edit.await()
locker.join()
}
assertNull(
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart
},
)
}
@Test
fun undoAfterRepositoryResetCannotRestorePreResetEntries() {
seedPlaylist(5)
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
runBlocking {
controller.removeAt(0)
controller.removeAt(1)
}
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s1", "s2", "s3", "s4", "s5")
runBlocking {
withContext(dispatcher) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
controller.undo()
}
}
assertNull(
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart
},
)
}
@Test
fun undoIsClearedByASourceReset() {
seedPlaylist(5)
seedSong("s0", "al1")
controller.playPlaylist(1, "p1", 1)
await { handle.currentItem?.id == "s2" && controller.state.value.isPlaying }
runBlocking { controller.removeAt(0) }
val point = runBlocking { queues.captureResetPoint() }
setPlaylist("s0", "s1", "s2", "s3", "s4", "s5")
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
await { runBlocking { queues.snapshot().contextSize == 6L } }
runBlocking { controller.undo() }
assertEquals(6L, runBlocking { queues.snapshot().contextSize })
assertEquals("s0", runBlocking { queues.itemAt(queues.snapshot(), 0) }?.song?.id)
}
private fun markDownloaded( private fun markDownloaded(
sourceId: Long, sourceId: Long,
songId: String, songId: String,
downloadedAt: Long = System.nanoTime(),
) { ) {
val file = File(downloadsDir, "$sourceId/$songId") val file = File(downloadsDir, "$sourceId/$songId")
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000 val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (System.nanoTime() < deadline) { while (System.nanoTime() < deadline) {
runBlocking { runBlocking {
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed, downloadedAt = downloadedAt))
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
file.writeBytes(byteArrayOf(1)) file.writeBytes(byteArrayOf(1))
downloads.reconcile() downloads.reconcile()