mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
playback: preserve playback across canonical queue resets
This commit is contained in:
@@ -15,7 +15,10 @@ import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
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.QueueResetItem
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.log.Log
|
||||
|
||||
@@ -222,23 +225,48 @@ class Media3PlayerHandle(
|
||||
)
|
||||
}
|
||||
|
||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
||||
QueueItem(
|
||||
id = mediaItem.mediaId,
|
||||
title =
|
||||
mediaItem.mediaMetadata.title
|
||||
?.toString()
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||
?.getLong(EXTRA_DURATION_MS),
|
||||
replayGain = mediaItem.replayGain(),
|
||||
)
|
||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem = queueItemFromMediaItem(mediaItem)
|
||||
}
|
||||
|
||||
internal fun queueItemFromMediaItem(mediaItem: MediaItem): QueueItem =
|
||||
QueueItem(
|
||||
id = mediaItem.mediaId,
|
||||
title =
|
||||
mediaItem.mediaMetadata.title
|
||||
?.toString()
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||
?.getLong(EXTRA_DURATION_MS),
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||
@@ -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_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||
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 {
|
||||
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_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||
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()
|
||||
val builder =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,19 @@ class FakePlayerHandle : PlayerHandle {
|
||||
@Volatile
|
||||
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
|
||||
|
||||
@@ -60,7 +72,11 @@ class FakePlayerHandle : PlayerHandle {
|
||||
|
||||
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. */
|
||||
@Volatile
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.subtracks.playback
|
||||
import androidx.media3.common.C
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.Test
|
||||
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())
|
||||
}
|
||||
|
||||
@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
|
||||
fun anItemWithoutGainReadsBackAsNoGain() {
|
||||
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.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.QueueOrigin
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
@@ -42,9 +43,11 @@ import com.subtracks.data.repo.setShuffle
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -58,6 +61,7 @@ import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -66,7 +70,9 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlaybackControllerTest {
|
||||
@@ -1788,13 +1794,13 @@ class PlaybackControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateCarriesTheQueueContext() {
|
||||
fun stateCarriesTheQueueOrigin() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
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.hasNext)
|
||||
assertTrue(controller.state.value.hasPrevious)
|
||||
@@ -2258,6 +2264,24 @@ class PlaybackControllerTest {
|
||||
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
|
||||
fun movingContextTracksWhileShuffledIsIgnored() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
@@ -2370,7 +2394,7 @@ class PlaybackControllerTest {
|
||||
|
||||
controller.previous()
|
||||
|
||||
assertTrue(handle.operations.contains("seekTo(0)"))
|
||||
await { handle.operations.contains("seekTo(0)") }
|
||||
assertEquals(
|
||||
"s1",
|
||||
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(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
downloadedAt: Long = System.nanoTime(),
|
||||
) {
|
||||
val file = File(downloadsDir, "$sourceId/$songId")
|
||||
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
|
||||
while (System.nanoTime() < deadline) {
|
||||
runBlocking {
|
||||
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed, downloadedAt = downloadedAt))
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
downloads.reconcile()
|
||||
|
||||
Reference in New Issue
Block a user