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.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,23 +225,48 @@ class Media3PlayerHandle(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
private fun toQueueItem(mediaItem: MediaItem): QueueItem = queueItemFromMediaItem(mediaItem)
|
||||||
QueueItem(
|
}
|
||||||
id = mediaItem.mediaId,
|
|
||||||
title =
|
internal fun queueItemFromMediaItem(mediaItem: MediaItem): QueueItem =
|
||||||
mediaItem.mediaMetadata.title
|
QueueItem(
|
||||||
?.toString()
|
id = mediaItem.mediaId,
|
||||||
.orEmpty(),
|
title =
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
mediaItem.mediaMetadata.title
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
?.toString()
|
||||||
artists = mediaItem.artistRefs(),
|
.orEmpty(),
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
durationMs =
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
mediaItem.mediaMetadata.extras
|
artists = mediaItem.artistRefs(),
|
||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
durationMs =
|
||||||
replayGain = mediaItem.replayGain(),
|
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> =
|
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_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()
|
||||||
|
|||||||
Reference in New Issue
Block a user