mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
queue: add serialized, origin-aware mutations
This commit is contained in:
@@ -161,7 +161,7 @@ interface DownloadDao {
|
||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||
"SELECT d.sourceId AS sourceId, s.id AS songRow, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||
"al.artistId AS albumArtistId, " +
|
||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||
|
||||
@@ -730,6 +730,35 @@ val MIGRATION_37_38 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_38_39 =
|
||||
object : Migration(38, 39) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originKind` TEXT")
|
||||
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originRefId` TEXT")
|
||||
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originOccurrence` INTEGER")
|
||||
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originKind` TEXT")
|
||||
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originRefId` TEXT")
|
||||
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originOccurrence` INTEGER")
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `pending_sync_origins` (`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
|
||||
"`refId` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`, `refId`), " +
|
||||
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_sync_origins_sourceId` ON `pending_sync_origins` (`sourceId`)")
|
||||
connection.execSQL(
|
||||
"CREATE TABLE `song_downloads_new` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, " +
|
||||
"`engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, " +
|
||||
"`downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), " +
|
||||
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
connection.execSQL("INSERT INTO `song_downloads_new` SELECT * FROM `song_downloads`")
|
||||
connection.execSQL("DROP TABLE `song_downloads`")
|
||||
connection.execSQL("ALTER TABLE `song_downloads_new` RENAME TO `song_downloads`")
|
||||
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)")
|
||||
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||
val triggers = mutableListOf<String>()
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||
@@ -808,4 +837,5 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_35_36,
|
||||
MIGRATION_36_37,
|
||||
MIGRATION_37_38,
|
||||
MIGRATION_38_39,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.PendingSyncOrigin
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
@@ -37,6 +38,12 @@ private const val SONG_SQL =
|
||||
|
||||
@Dao
|
||||
interface QueueDao {
|
||||
@Query("SELECT * FROM pending_sync_origins WHERE sourceId = :sourceId")
|
||||
suspend fun pendingSyncOrigins(sourceId: Long): List<PendingSyncOrigin>
|
||||
|
||||
@Query("DELETE FROM pending_sync_origins WHERE sourceId = :sourceId")
|
||||
suspend fun clearPendingSyncOrigins(sourceId: Long)
|
||||
|
||||
@Query("DELETE FROM queue_entries")
|
||||
suspend fun clear()
|
||||
|
||||
@@ -243,6 +250,44 @@ interface QueueDao {
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||
"AND playlist_songs.songId = :songId AND playlist_songs.position < :position",
|
||||
)
|
||||
suspend fun playlistOccurrenceRank(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
songId: String,
|
||||
position: Long,
|
||||
): Long
|
||||
|
||||
@Query(
|
||||
"SELECT playlist_songs.position FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||
"AND playlist_songs.songId = :songId ORDER BY playlist_songs.position LIMIT 1 OFFSET :occurrence",
|
||||
)
|
||||
suspend fun playlistPositionForOccurrence(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
songId: String,
|
||||
occurrence: Long,
|
||||
): Long?
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(*) - 1 FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||
"AND playlist_songs.position <= :position",
|
||||
)
|
||||
suspend fun playlistRankAtPosition(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
position: Long,
|
||||
): Long
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||
suspend fun genreLength(
|
||||
sourceId: Long,
|
||||
@@ -263,7 +308,11 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed'",
|
||||
)
|
||||
suspend fun downloadedLength(sourceId: Long): Long
|
||||
|
||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.subtracks.data.model.DownloadErrorConverter
|
||||
import com.subtracks.data.model.DownloadStatusConverter
|
||||
import com.subtracks.data.model.PendingAction
|
||||
import com.subtracks.data.model.PendingActionKindConverter
|
||||
import com.subtracks.data.model.PendingSyncOrigin
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSearch
|
||||
@@ -56,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry
|
||||
ArtworkSeed::class,
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
PendingSyncOrigin::class,
|
||||
],
|
||||
version = 38,
|
||||
version = 39,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
|
||||
@@ -38,12 +38,6 @@ enum class DownloadError(
|
||||
childColumns = ["sourceId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Song::class,
|
||||
parentColumns = ["sourceId", "id"],
|
||||
childColumns = ["sourceId", "songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId"), Index("engineId")],
|
||||
)
|
||||
@@ -109,6 +103,7 @@ data class DownloadedSong(
|
||||
|
||||
data class DownloadArtwork(
|
||||
val sourceId: Long,
|
||||
val songRow: String?,
|
||||
val albumId: String?,
|
||||
val artistId: String?,
|
||||
val albumArtistId: String?,
|
||||
@@ -119,7 +114,7 @@ data class DownloadArtwork(
|
||||
val albumArtistCoverArt: String?,
|
||||
) {
|
||||
val hasMissingLibraryRow: Boolean
|
||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||
get() = songRow == null || (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||
}
|
||||
|
||||
data class SourceCoverArt(
|
||||
|
||||
@@ -448,6 +448,31 @@ data class DiscKey(
|
||||
|
||||
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
|
||||
|
||||
data class QueueOrigin(
|
||||
val kind: QueueKind,
|
||||
val sourceId: Long,
|
||||
val refId: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "pending_sync_origins",
|
||||
primaryKeys = ["sourceId", "kind", "refId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Source::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sourceId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId")],
|
||||
)
|
||||
data class PendingSyncOrigin(
|
||||
val sourceId: Long,
|
||||
val kind: QueueKind,
|
||||
val refId: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "queue_entries",
|
||||
foreignKeys = [
|
||||
@@ -468,10 +493,15 @@ data class QueueEntry(
|
||||
val refId: String,
|
||||
val rangeStart: Long? = null,
|
||||
val rangeEnd: Long? = null,
|
||||
val originKind: QueueKind? = null,
|
||||
val originRefId: String? = null,
|
||||
val originOccurrence: Long? = null,
|
||||
) {
|
||||
val offset: Long get() = rangeStart ?: 0
|
||||
|
||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||
|
||||
val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId)
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -494,16 +524,31 @@ data class UpNextEntry(
|
||||
val refId: String,
|
||||
val rangeStart: Long? = null,
|
||||
val rangeEnd: Long? = null,
|
||||
val originKind: QueueKind? = null,
|
||||
val originRefId: String? = null,
|
||||
val originOccurrence: Long? = null,
|
||||
) {
|
||||
val offset: Long get() = rangeStart ?: 0
|
||||
|
||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||
|
||||
fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd)
|
||||
fun toQueueEntry(): QueueEntry =
|
||||
QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd, originKind, originRefId, originOccurrence)
|
||||
|
||||
companion object {
|
||||
fun from(entry: QueueEntry) =
|
||||
UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd)
|
||||
UpNextEntry(
|
||||
entry.id,
|
||||
entry.position,
|
||||
entry.sourceId,
|
||||
entry.kind,
|
||||
entry.refId,
|
||||
entry.rangeStart,
|
||||
entry.rangeEnd,
|
||||
entry.originKind,
|
||||
entry.originRefId,
|
||||
entry.originOccurrence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.QueueOrigin
|
||||
import com.subtracks.data.model.SongItem
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -36,12 +37,6 @@ private data class LoadedSnapshot(
|
||||
val anchor: Long,
|
||||
)
|
||||
|
||||
private data class QueueRef(
|
||||
val kind: QueueKind,
|
||||
val sourceId: Long,
|
||||
val refId: String,
|
||||
)
|
||||
|
||||
private data class OrderKey(
|
||||
val id: String,
|
||||
val disc: Long? = null,
|
||||
@@ -59,58 +54,13 @@ data class ResolvedQueueEntry(
|
||||
val length: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* A shuffled order over a fixed domain of [domain] tracks, plus the domain positions removed since
|
||||
* the order was created. The permutation stays fixed and removals are subtracted (sparsely, in
|
||||
* memory) rather than re-derived, so editing a shuffled queue keeps the remaining tracks in place.
|
||||
* Only the seed and domain are persisted; on restart the removals are gone, so a queue with edits
|
||||
* falls back to unshuffled rather than guessing.
|
||||
*/
|
||||
class ShuffleState(
|
||||
val seed: Long,
|
||||
val domain: Long,
|
||||
private val removedFlats: LongArray = LongArray(0),
|
||||
private val removedSlots: LongArray = LongArray(0),
|
||||
) {
|
||||
fun flatFor(play: Long): Long {
|
||||
val domainFlat = Shuffle.toFlat(seed, domain, liveSlot(play))
|
||||
return domainFlat - removedFlats.count { it < domainFlat }
|
||||
}
|
||||
fun flatFor(play: Long): Long = Shuffle.toFlat(seed, domain, play)
|
||||
|
||||
fun playFor(canonical: Long): Long {
|
||||
val slot = Shuffle.toSequence(seed, domain, liveFlat(canonical))
|
||||
return slot - removedSlots.count { it < slot }
|
||||
}
|
||||
|
||||
fun domainFor(canonical: Long): Long = liveFlat(canonical)
|
||||
|
||||
val liveSize: Long get() = domain - removedFlats.size.toLong()
|
||||
|
||||
fun withRemoved(domainFlat: Long): ShuffleState =
|
||||
ShuffleState(
|
||||
seed,
|
||||
domain,
|
||||
(removedFlats + domainFlat).sortedArray(),
|
||||
(removedSlots + Shuffle.toSequence(seed, domain, domainFlat)).sortedArray(),
|
||||
)
|
||||
|
||||
private fun liveSlot(play: Long): Long {
|
||||
var slot = play
|
||||
while (true) {
|
||||
val next = play + removedSlots.count { it <= slot }
|
||||
if (next <= slot) return slot
|
||||
slot = next
|
||||
}
|
||||
}
|
||||
|
||||
private fun liveFlat(canonical: Long): Long {
|
||||
var flat = canonical
|
||||
while (true) {
|
||||
val next = canonical + removedFlats.count { it <= flat }
|
||||
if (next <= flat) return flat
|
||||
flat = next
|
||||
}
|
||||
}
|
||||
fun playFor(canonical: Long): Long = Shuffle.toSequence(seed, domain, canonical)
|
||||
}
|
||||
|
||||
data class QueueSnapshot(
|
||||
@@ -119,6 +69,7 @@ data class QueueSnapshot(
|
||||
val shuffle: ShuffleState? = null,
|
||||
val upNextAnchor: Long = 0,
|
||||
val version: Long = 0,
|
||||
val resetVersion: Long = 0,
|
||||
) {
|
||||
val contextSize: Long = entries.sumOf { it.length }
|
||||
|
||||
@@ -202,11 +153,33 @@ data class QueueWindowItem(
|
||||
val item: SongItem,
|
||||
val playlistPosition: Long? = null,
|
||||
val upNext: Boolean = false,
|
||||
val resetItem: QueueResetItem? = null,
|
||||
)
|
||||
|
||||
data class Reanchored(
|
||||
val position: Long,
|
||||
val changed: Boolean,
|
||||
val stale: Boolean = false,
|
||||
)
|
||||
|
||||
data class QueueResetItem(
|
||||
val origin: QueueOrigin,
|
||||
val songId: String,
|
||||
val position: Long,
|
||||
val upNext: Boolean,
|
||||
val entryOccurrence: Int = 0,
|
||||
val occurrence: Long = 0,
|
||||
)
|
||||
|
||||
data class QueueResetPoint(
|
||||
val current: QueueResetItem?,
|
||||
val anchor: QueueResetItem?,
|
||||
val upNextAnchor: Long? = null,
|
||||
)
|
||||
|
||||
data class QueueReset(
|
||||
val point: QueueResetPoint,
|
||||
val snapshot: QueueSnapshot,
|
||||
)
|
||||
|
||||
private interface QueueKindResolver {
|
||||
@@ -411,13 +384,23 @@ class QueueRepository(
|
||||
private val dao get() = db.queueDao()
|
||||
|
||||
private val queueVersion = AtomicLong()
|
||||
private val resetVersion = AtomicLong()
|
||||
private val libraryVersion = AtomicLong()
|
||||
private val queueMutex = Mutex()
|
||||
private var resetListener: (suspend QueueMutationScope.(QueueReset) -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var shuffleRemoved: ShuffleState? = null
|
||||
fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> Unit) {
|
||||
resetListener = listener
|
||||
}
|
||||
|
||||
suspend fun <T> withMutationLock(block: suspend QueueMutationScope.() -> T): T =
|
||||
queueMutex.withLock {
|
||||
QueueMutationScope().block()
|
||||
}
|
||||
|
||||
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
|
||||
|
||||
private val keyCache = ConcurrentHashMap<QueueRef, KeyCursor>()
|
||||
private val keyCache = ConcurrentHashMap<QueueOrigin, KeyCursor>()
|
||||
|
||||
internal var keysetSeeks = 0L
|
||||
private set
|
||||
@@ -428,7 +411,7 @@ class QueueRepository(
|
||||
internal var resolvedRowCount = 0L
|
||||
private set
|
||||
|
||||
private val cursorMutex = Mutex()
|
||||
internal val cursorMutex = Mutex()
|
||||
|
||||
fun playlistEntry(
|
||||
sourceId: Long,
|
||||
@@ -514,51 +497,46 @@ class QueueRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replace(
|
||||
private suspend fun replaceLocked(
|
||||
entries: List<QueueEntry>,
|
||||
shuffleSeed: Long? = null,
|
||||
upNext: List<QueueEntry> = emptyList(),
|
||||
upNextAnchor: Long = 0,
|
||||
shuffleSeed: Long?,
|
||||
upNext: List<QueueEntry>,
|
||||
upNextAnchor: Long,
|
||||
) {
|
||||
write(entries)
|
||||
writeUpNext(upNext)
|
||||
setShuffle(shuffleSeed)
|
||||
invalidateLibraryCache()
|
||||
writeLocked(entries)
|
||||
writeUpNextLocked(upNext)
|
||||
setShuffleLocked(shuffleSeed)
|
||||
cursorMutex.withLock {
|
||||
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshot(): QueueSnapshot {
|
||||
val version = queueVersion.get()
|
||||
suspend fun snapshot(): QueueSnapshot = withMutationLock { snapshot() }
|
||||
|
||||
private suspend fun snapshotLocked(): QueueSnapshot {
|
||||
val libraryRevision = libraryVersion.get()
|
||||
val loaded =
|
||||
db.useReaderConnection { transactor ->
|
||||
transactor.deferredTransaction {
|
||||
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) }
|
||||
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, libraryRevision)) }
|
||||
val upNext =
|
||||
dao.upNextEntries().map {
|
||||
val entry = it.toQueueEntry()
|
||||
ResolvedQueueEntry(entry, lengthOf(entry, version))
|
||||
ResolvedQueueEntry(entry, lengthOf(entry, libraryRevision))
|
||||
}
|
||||
val row = dao.cursor()
|
||||
val size = entries.sumOf { it.length }
|
||||
val state = shuffleRemoved
|
||||
val shuffle =
|
||||
if (row != null && row.shuffleEnabled && size > 0L) {
|
||||
when {
|
||||
state != null && state.seed == row.shuffleSeed && state.domain == row.shuffleSize &&
|
||||
state.liveSize == size -> state
|
||||
|
||||
state == null && row.shuffleSize == size -> ShuffleState(row.shuffleSeed, row.shuffleSize)
|
||||
|
||||
else -> null
|
||||
}
|
||||
if (row != null && row.shuffleEnabled && row.shuffleSize == size && size > 0L) {
|
||||
ShuffleState(row.shuffleSeed, row.shuffleSize)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
LoadedSnapshot(entries, upNext, shuffle, row?.upNextAnchor ?: 0L)
|
||||
}
|
||||
}
|
||||
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffle, loaded.anchor, version)
|
||||
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffle, loaded.anchor, queueVersion.get(), resetVersion.get())
|
||||
}
|
||||
|
||||
suspend fun modes(): QueueModes {
|
||||
@@ -566,7 +544,7 @@ class QueueRepository(
|
||||
return QueueModes(row.shuffleEnabled, row.repeatMode)
|
||||
}
|
||||
|
||||
suspend fun setShuffle(seed: Long?) {
|
||||
private suspend fun setShuffleLocked(seed: Long?) {
|
||||
val size = if (seed != null) contextSize() else 0L
|
||||
cursorMutex.withLock {
|
||||
dao.setCursor(
|
||||
@@ -577,14 +555,274 @@ class QueueRepository(
|
||||
),
|
||||
)
|
||||
}
|
||||
shuffleRemoved = seed?.let { ShuffleState(it, size) }
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
fun contentVersion(): Long = queueVersion.get()
|
||||
|
||||
fun invalidateLibraryCache() {
|
||||
lengthCache.clear()
|
||||
keyCache.clear()
|
||||
shuffleRemoved = null
|
||||
libraryVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
suspend fun captureResetPoint(): QueueResetPoint {
|
||||
val snapshot = snapshot()
|
||||
val row = cursorRow()
|
||||
val current =
|
||||
resetItemAt(snapshot, row.queuePosition)
|
||||
?: snapshot.size.takeIf { it > 0L }?.let { resetItemAt(snapshot, 0L) }
|
||||
val anchor = if (snapshot.upNextSize == 0L) null else resetItemAt(snapshot, snapshot.anchorPlay)
|
||||
return QueueResetPoint(current, anchor, snapshot.upNextAnchor.takeIf { snapshot.upNextSize > 0L })
|
||||
}
|
||||
|
||||
suspend fun resetChangedOrigins(
|
||||
changed: Set<QueueOrigin>,
|
||||
point: QueueResetPoint,
|
||||
) {
|
||||
if (changed.isEmpty()) return
|
||||
withMutationLock {
|
||||
val before = snapshot()
|
||||
val entries = canonicalEntries(before.entries.map { it.entry }, changed)
|
||||
val upNext = canonicalEntries(before.upNext.map { it.entry }, changed)
|
||||
if (entries == before.entries.map { it.entry } && upNext == before.upNext.map { it.entry }) return@withMutationLock
|
||||
val updated = writeResetLocked(before, entries, upNext, point)
|
||||
resetListener?.invoke(this, QueueReset(point, updated))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeResetLocked(
|
||||
before: QueueSnapshot,
|
||||
entries: List<QueueEntry>,
|
||||
upNext: List<QueueEntry>,
|
||||
point: QueueResetPoint,
|
||||
): QueueSnapshot {
|
||||
invalidateLibraryCache()
|
||||
val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
||||
val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
||||
val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) }
|
||||
var updated =
|
||||
QueueSnapshot(
|
||||
entries = resolvedEntries,
|
||||
upNext = resolvedUpNext,
|
||||
shuffle = shuffle,
|
||||
upNextAnchor = before.upNextAnchor,
|
||||
version = queueVersion.get(),
|
||||
resetVersion = resetVersion.get(),
|
||||
)
|
||||
val anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor
|
||||
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null
|
||||
val anchor =
|
||||
if (anchorChanged) {
|
||||
remapAnchor(updated, latestAnchor) ?: fallbackAnchor(updated, before.anchorPlay)
|
||||
} else {
|
||||
remapAnchor(updated, point.anchor)
|
||||
?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor)
|
||||
}
|
||||
updated = updated.copy(upNextAnchor = anchor)
|
||||
cursorMutex.withLock {
|
||||
val row = cursorRow()
|
||||
val current = point.current ?: resetItemAt(before, row.queuePosition)
|
||||
val remappedCurrent = current?.let { remapPosition(updated, it) }
|
||||
val cursor =
|
||||
when {
|
||||
updated.size == 0L -> 0L
|
||||
remappedCurrent != null -> remappedCurrent.coerceIn(0, updated.size - 1)
|
||||
current != null -> current.position.coerceIn(0, updated.size - 1)
|
||||
else -> row.queuePosition.coerceIn(0, updated.size - 1)
|
||||
}
|
||||
val previousSongId = current?.songId
|
||||
val updatedSongId = itemAt(updated, cursor)?.song?.id
|
||||
val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
dao.clear()
|
||||
dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) })
|
||||
dao.clearUpNext()
|
||||
dao.insertUpNext(
|
||||
upNext.mapIndexed { index, entry -> UpNextEntry.from(entry).copy(id = 0, position = index.toLong()) },
|
||||
)
|
||||
dao.setCursor(
|
||||
row.copy(
|
||||
queuePosition = cursor,
|
||||
positionMs = positionMs,
|
||||
upNextAnchor = anchor,
|
||||
shuffleEnabled = shuffle != null,
|
||||
shuffleSeed = shuffle?.seed ?: 0L,
|
||||
shuffleSize = shuffle?.domain ?: 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
queueVersion.incrementAndGet()
|
||||
resetVersion.incrementAndGet()
|
||||
return updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get())
|
||||
}
|
||||
|
||||
private suspend fun remapAnchor(
|
||||
snapshot: QueueSnapshot,
|
||||
item: QueueResetItem?,
|
||||
): Long? {
|
||||
val target = item?.let { remapPosition(snapshot, it) } ?: return null
|
||||
return snapshot.flatContext(snapshot.contextPlay(target))
|
||||
}
|
||||
|
||||
private fun fallbackAnchor(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
): Long {
|
||||
val play = position.coerceIn(-1L, snapshot.contextSize - 1L)
|
||||
return if (play < 0L) play else snapshot.flatContext(play) ?: play
|
||||
}
|
||||
|
||||
suspend fun resetItemAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
songId: String? = null,
|
||||
item: SongItem? = null,
|
||||
): QueueResetItem? {
|
||||
if (position !in 0 until snapshot.size) return null
|
||||
val entry = snapshot.locate(position)?.first ?: return null
|
||||
val upNext = snapshot.isUpNext(position)
|
||||
val entries = if (upNext) snapshot.upNext else snapshot.entries
|
||||
val blockPosition =
|
||||
if (upNext) {
|
||||
snapshot.upNextIndex(position)
|
||||
} else {
|
||||
snapshot.flatContext(snapshot.contextPlay(position))
|
||||
?: return null
|
||||
}
|
||||
var start = 0L
|
||||
var entryIndex = -1
|
||||
for (index in entries.indices) {
|
||||
val end = start + entries[index].length
|
||||
if (blockPosition >= start && blockPosition < end) {
|
||||
entryIndex = index
|
||||
break
|
||||
}
|
||||
start = end
|
||||
}
|
||||
if (entryIndex < 0) return null
|
||||
val origin = entry.origin
|
||||
val entryOccurrence = resetEntryOccurrence(entries, entryIndex)
|
||||
val resolvedItem = item ?: itemAt(snapshot, position)
|
||||
val resolvedSongId = songId ?: resolvedItem?.song?.id ?: return null
|
||||
val occurrence =
|
||||
entry.originOccurrence
|
||||
?: if (entry.kind == QueueKind.Playlist && resolvedItem?.playlistPosition != null) {
|
||||
dao.playlistOccurrenceRank(entry.sourceId, entry.refId, resolvedSongId, resolvedItem.playlistPosition!!)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
return QueueResetItem(origin, resolvedSongId, position, upNext, entryOccurrence, occurrence)
|
||||
}
|
||||
|
||||
private fun resetEntryOccurrence(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
targetIndex: Int,
|
||||
): Int {
|
||||
val origin = entries[targetIndex].entry.origin
|
||||
val targetWhole = entries[targetIndex].entry.isWholeOrigin()
|
||||
var occurrence = 0
|
||||
var fragmentSeen = false
|
||||
for (index in 0..targetIndex) {
|
||||
val entry = entries[index].entry
|
||||
if (entry.origin != origin) continue
|
||||
if (entry.isWholeOrigin()) {
|
||||
if (index == targetIndex) return occurrence
|
||||
occurrence++
|
||||
} else if (!fragmentSeen) {
|
||||
fragmentSeen = true
|
||||
if (!targetWhole) return occurrence
|
||||
occurrence++
|
||||
}
|
||||
}
|
||||
return occurrence
|
||||
}
|
||||
|
||||
private fun QueueEntry.isWholeOrigin(): Boolean {
|
||||
val origin = origin
|
||||
return kind == origin.kind && refId == origin.refId && rangeStart == null && rangeEnd == null
|
||||
}
|
||||
|
||||
private fun canonicalEntries(
|
||||
entries: List<QueueEntry>,
|
||||
changed: Set<QueueOrigin>,
|
||||
): List<QueueEntry> {
|
||||
val result = mutableListOf<QueueEntry>()
|
||||
val reset = mutableSetOf<QueueOrigin>()
|
||||
for (entry in entries) {
|
||||
val origin = entry.origin
|
||||
val whole = entry.isWholeOrigin()
|
||||
if (origin !in changed) {
|
||||
result += entry
|
||||
} else if (whole || reset.add(origin)) {
|
||||
result += QueueEntry(position = 0, sourceId = origin.sourceId, kind = origin.kind, refId = origin.refId)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private data class OriginEntryGroup(
|
||||
val starts: List<Long>,
|
||||
val entries: List<ResolvedQueueEntry>,
|
||||
)
|
||||
|
||||
private fun originEntryGroups(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
origin: QueueOrigin,
|
||||
): List<OriginEntryGroup> {
|
||||
val groups = mutableListOf<OriginEntryGroup>()
|
||||
var fragmentIndex: Int? = null
|
||||
var start = 0L
|
||||
for (resolved in entries) {
|
||||
if (resolved.entry.origin == origin) {
|
||||
if (resolved.entry.isWholeOrigin()) {
|
||||
groups += OriginEntryGroup(listOf(start), listOf(resolved))
|
||||
} else {
|
||||
val index = fragmentIndex
|
||||
if (index == null) {
|
||||
fragmentIndex = groups.size
|
||||
groups += OriginEntryGroup(listOf(start), listOf(resolved))
|
||||
} else {
|
||||
groups[index] =
|
||||
groups[index].copy(
|
||||
starts = groups[index].starts + start,
|
||||
entries = groups[index].entries + resolved,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
start += resolved.length
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
suspend fun remapPosition(
|
||||
snapshot: QueueSnapshot,
|
||||
item: QueueResetItem,
|
||||
): Long? {
|
||||
val entries = if (item.upNext) snapshot.upNext else snapshot.entries
|
||||
val groups = originEntryGroups(entries, item.origin)
|
||||
val group = groups.getOrNull(item.entryOccurrence) ?: return null
|
||||
for ((index, resolved) in group.entries.withIndex()) {
|
||||
val entry = resolved.entry
|
||||
val rank =
|
||||
if (entry.kind == QueueKind.Playlist) {
|
||||
val position = dao.playlistPositionForOccurrence(entry.sourceId, entry.refId, item.songId, item.occurrence)
|
||||
position?.let { dao.playlistRankAtPosition(entry.sourceId, entry.refId, it) }
|
||||
} else {
|
||||
resolverFor(entry.kind).rank(dao, entry, item.songId) ?: fallbackRank(entry, resolved.length, item.songId)
|
||||
}
|
||||
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||
val flat = group.starts[index] + rank - entry.offset
|
||||
if (item.upNext) return snapshot.anchorPlay + 1 + flat
|
||||
val play = snapshot.playContext(flat) ?: return null
|
||||
return snapshot.combined(play)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun setRepeat(mode: Int) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) }
|
||||
@@ -644,17 +882,18 @@ class QueueRepository(
|
||||
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
|
||||
}
|
||||
|
||||
suspend fun addUpNext(
|
||||
snapshot: QueueSnapshot,
|
||||
private suspend fun addUpNextLocked(
|
||||
currentPosition: Long,
|
||||
entry: QueueEntry,
|
||||
playNext: Boolean,
|
||||
): Long {
|
||||
val list = snapshot.upNext.map { it.entry }.toMutableList()
|
||||
invalidateLibraryCache()
|
||||
val current = snapshotLocked()
|
||||
val list = current.upNext.map { it.entry }.toMutableList()
|
||||
val insertIndex =
|
||||
when {
|
||||
playNext && snapshot.isUpNext(currentPosition) -> {
|
||||
entryIndexAfter(snapshot.upNext, snapshot.upNextIndex(currentPosition))
|
||||
playNext && current.isUpNext(currentPosition) -> {
|
||||
entryIndexAfter(current.upNext, current.upNextIndex(currentPosition))
|
||||
}
|
||||
|
||||
playNext -> {
|
||||
@@ -667,75 +906,54 @@ class QueueRepository(
|
||||
}.coerceIn(0, list.size)
|
||||
list.add(insertIndex, entry.copy(id = 0, position = 0))
|
||||
val anchor =
|
||||
if (snapshot.upNextSize == 0L) {
|
||||
val play = snapshot.anchorContextPlay(currentPosition)
|
||||
snapshot.flatContext(play) ?: 0L
|
||||
if (current.upNextSize == 0L) {
|
||||
val play = current.anchorContextPlay(currentPosition)
|
||||
current.flatContext(play) ?: 0L
|
||||
} else {
|
||||
snapshot.upNextAnchor
|
||||
current.upNextAnchor
|
||||
}
|
||||
writeUpNext(list)
|
||||
writeUpNextLocked(list)
|
||||
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
|
||||
if (snapshot.upNextSize == 0L) return currentPosition
|
||||
val before = snapshot.upNext.take(insertIndex).sumOf { it.length }
|
||||
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
|
||||
if (current.upNextSize == 0L) return currentPosition
|
||||
val before = current.upNext.take(insertIndex).sumOf { it.length }
|
||||
return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
|
||||
}
|
||||
|
||||
suspend fun reanchorFor(
|
||||
private suspend fun reanchorForLocked(
|
||||
snapshot: QueueSnapshot,
|
||||
target: Long,
|
||||
): Reanchored {
|
||||
if (snapshot.version != queueVersion.get()) return Reanchored(target, false, stale = true)
|
||||
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
|
||||
val play = snapshot.contextPlay(target)
|
||||
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
|
||||
if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
|
||||
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
|
||||
queueVersion.incrementAndGet()
|
||||
return Reanchored(play, true)
|
||||
}
|
||||
|
||||
suspend fun setUpNextAnchor(anchor: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
|
||||
|
||||
suspend fun clearUpNext() {
|
||||
writeUpNext(emptyList())
|
||||
}
|
||||
|
||||
suspend fun removeUpNextAt(
|
||||
private suspend fun removeUpNextAtLocked(
|
||||
snapshot: QueueSnapshot,
|
||||
index: Long,
|
||||
) {
|
||||
if (index !in 0 until snapshot.upNextSize) return
|
||||
writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
|
||||
): Boolean {
|
||||
if (index !in 0 until snapshot.upNextSize) return false
|
||||
writeUpNextLocked(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun removeAt(
|
||||
private suspend fun removeAtLocked(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
) {
|
||||
if (snapshot.isUpNext(position)) {
|
||||
removeUpNextAt(snapshot, snapshot.upNextIndex(position))
|
||||
return
|
||||
}
|
||||
): Boolean {
|
||||
if (snapshot.version != queueVersion.get()) return false
|
||||
if (snapshot.isUpNext(position)) return removeUpNextAtLocked(snapshot, snapshot.upNextIndex(position))
|
||||
if (snapshot.shuffled) return false
|
||||
val play = snapshot.contextPlay(position)
|
||||
if (snapshot.shuffled) {
|
||||
// Subtract the track from the fixed shuffle order instead of re-deriving it, so the
|
||||
// tracks that remain keep their positions.
|
||||
val order = snapshot.shuffle ?: return
|
||||
val flat = snapshot.flatContext(play) ?: return
|
||||
val predecessor = if (play <= 0L) -1L else snapshot.flatContext(play - 1L) ?: -1L
|
||||
shuffleRemoved = order.withRemoved(order.domainFor(flat))
|
||||
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
|
||||
if (cursorRow().upNextAnchor == flat) {
|
||||
// The removed track carried the block; move the anchor to the track before it in
|
||||
// play order so the block stays in place instead of jumping to the canonical
|
||||
// predecessor, which in a shuffled queue is an unrelated position.
|
||||
setUpNextAnchor(if (predecessor > flat) predecessor - 1 else predecessor)
|
||||
} else {
|
||||
adjustAnchorOnRemove(flat)
|
||||
}
|
||||
} else {
|
||||
val removed = compact(removeEntry(snapshot.entries, play))
|
||||
write(removed.map { it.entry })
|
||||
adjustAnchorOnRemove(play)
|
||||
}
|
||||
val removed = compact(removeEntry(snapshot.entries, play))
|
||||
writeLocked(removed.map { it.entry })
|
||||
adjustAnchorOnRemove(play)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun adjustAnchorOnRemove(removedFlat: Long) =
|
||||
@@ -765,19 +983,21 @@ class QueueRepository(
|
||||
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
|
||||
}
|
||||
|
||||
suspend fun move(
|
||||
private suspend fun moveLocked(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
to: Long,
|
||||
): Boolean {
|
||||
if (snapshot.version != queueVersion.get()) return false
|
||||
val fromUpNext = snapshot.isUpNext(from)
|
||||
val toUpNext = snapshot.isUpNext(to)
|
||||
if (fromUpNext && toUpNext) {
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val located = snapshot.locate(from) ?: return false
|
||||
val resetItem = resetItemAt(snapshot, from, item = song)
|
||||
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second, resetItem), 1L)
|
||||
writeUpNextLocked(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||
return true
|
||||
}
|
||||
if (fromUpNext != toUpNext) return false
|
||||
@@ -789,10 +1009,11 @@ class QueueRepository(
|
||||
val playTo = snapshot.contextPlay(to)
|
||||
val located = snapshot.locateContext(playFrom) ?: return false
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val resetItem = resetItemAt(snapshot, from, item = song)
|
||||
val removed = removeEntry(snapshot.entries, playFrom)
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second, resetItem), 1L)
|
||||
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||
writeLocked(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||
adjustAnchorOnMove(playFrom, insertAt)
|
||||
return true
|
||||
}
|
||||
@@ -816,7 +1037,7 @@ class QueueRepository(
|
||||
|
||||
private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() }
|
||||
|
||||
private suspend fun write(entries: List<QueueEntry>) {
|
||||
private suspend fun writeLocked(entries: List<QueueEntry>) {
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.immediateTransaction {
|
||||
dao.clear()
|
||||
@@ -826,7 +1047,7 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||
private suspend fun writeUpNextLocked(entries: List<QueueEntry>) {
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.immediateTransaction {
|
||||
dao.clearUpNext()
|
||||
@@ -840,6 +1061,51 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
inner class QueueMutationScope internal constructor() {
|
||||
suspend fun snapshot(): QueueSnapshot = snapshotLocked()
|
||||
|
||||
suspend fun replace(
|
||||
entries: List<QueueEntry>,
|
||||
shuffleSeed: Long? = null,
|
||||
upNext: List<QueueEntry> = emptyList(),
|
||||
upNextAnchor: Long = 0,
|
||||
) = replaceLocked(entries, shuffleSeed, upNext, upNextAnchor)
|
||||
|
||||
suspend fun setShuffle(seed: Long?) = setShuffleLocked(seed)
|
||||
|
||||
suspend fun addUpNext(
|
||||
currentPosition: Long,
|
||||
entry: QueueEntry,
|
||||
playNext: Boolean,
|
||||
): Long = addUpNextLocked(currentPosition, entry, playNext)
|
||||
|
||||
suspend fun reanchorFor(
|
||||
snapshot: QueueSnapshot,
|
||||
target: Long,
|
||||
): Reanchored = reanchorForLocked(snapshot, target)
|
||||
|
||||
suspend fun clearUpNext() = writeUpNextLocked(emptyList())
|
||||
|
||||
suspend fun removeUpNextAt(
|
||||
snapshot: QueueSnapshot,
|
||||
index: Long,
|
||||
): Boolean {
|
||||
if (snapshot.version != queueVersion.get()) return false
|
||||
return removeUpNextAtLocked(snapshot, index)
|
||||
}
|
||||
|
||||
suspend fun removeAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
): Boolean = removeAtLocked(snapshot, position)
|
||||
|
||||
suspend fun move(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
to: Long,
|
||||
): Boolean = moveLocked(snapshot, from, to)
|
||||
}
|
||||
|
||||
private suspend fun flatIndexOf(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
songId: String,
|
||||
@@ -887,7 +1153,14 @@ class QueueRepository(
|
||||
for (play in first..last) {
|
||||
val located = snapshot.locateContext(play) ?: continue
|
||||
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||
val position = snapshot.combined(play)
|
||||
items +=
|
||||
QueueWindowItem(
|
||||
position,
|
||||
item,
|
||||
playlistPosition = item.playlistPosition,
|
||||
resetItem = resetItemAt(snapshot, position, item = item),
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -901,7 +1174,14 @@ class QueueRepository(
|
||||
val to = minOf(last, end)
|
||||
val offset = resolved.entry.offset + (from - entryStart)
|
||||
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
|
||||
items += QueueWindowItem(snapshot.combined(from + index), item, playlistPosition = item.playlistPosition)
|
||||
val position = snapshot.combined(from + index)
|
||||
items +=
|
||||
QueueWindowItem(
|
||||
position,
|
||||
item,
|
||||
playlistPosition = item.playlistPosition,
|
||||
resetItem = resetItemAt(snapshot, position, item = item),
|
||||
)
|
||||
}
|
||||
}
|
||||
return items
|
||||
@@ -924,12 +1204,14 @@ class QueueRepository(
|
||||
val to = minOf(last, end)
|
||||
val offset = resolved.entry.offset + (from - entryStart)
|
||||
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
|
||||
val position = snapshot.anchorPlay + 1 + from + index
|
||||
items +=
|
||||
QueueWindowItem(
|
||||
snapshot.anchorPlay + 1 + from + index,
|
||||
position,
|
||||
item,
|
||||
playlistPosition = item.playlistPosition,
|
||||
upNext = true,
|
||||
resetItem = resetItemAt(snapshot, position, item = item),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -945,7 +1227,7 @@ class QueueRepository(
|
||||
offsetSeeks++
|
||||
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||
}
|
||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||
val ref = QueueOrigin(entry.kind, entry.sourceId, entry.refId)
|
||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||
if (anchored != null) {
|
||||
keysetSeeks++
|
||||
@@ -961,7 +1243,7 @@ class QueueRepository(
|
||||
}
|
||||
|
||||
private fun remember(
|
||||
ref: QueueRef,
|
||||
ref: QueueOrigin,
|
||||
offset: Long,
|
||||
resolved: List<SongItem>,
|
||||
) {
|
||||
@@ -1056,12 +1338,17 @@ class QueueRepository(
|
||||
source: QueueEntry,
|
||||
songId: String,
|
||||
offsetInEntry: Long,
|
||||
resetItem: QueueResetItem?,
|
||||
): QueueEntry =
|
||||
if (source.kind == QueueKind.Album) {
|
||||
val offset = source.offset + offsetInEntry
|
||||
source.range(offset, offset)
|
||||
} else {
|
||||
songEntry(source.sourceId, songId)
|
||||
songEntry(source.sourceId, songId).copy(
|
||||
originKind = source.origin.kind,
|
||||
originRefId = source.origin.refId,
|
||||
originOccurrence = resetItem?.occurrence ?: source.originOccurrence,
|
||||
)
|
||||
}
|
||||
|
||||
private fun QueueEntry.range(
|
||||
|
||||
@@ -299,7 +299,7 @@ class PlaybackController(
|
||||
return@withLock
|
||||
}
|
||||
val start = randomIndex(snap.contextSize, avoid)
|
||||
queueRepository.setShuffle(pinnedSeed(snap.contextSize, start))
|
||||
queueRepository.withMutationLock { setShuffle(pinnedSeed(snap.contextSize, start)) }
|
||||
shuffleEnabled = true
|
||||
queueSourceId = entry.sourceId
|
||||
queueRepository.setCursor(0)
|
||||
@@ -314,7 +314,7 @@ class PlaybackController(
|
||||
windowEnd = -1
|
||||
lastEdit = null
|
||||
endedHandled = false
|
||||
queueRepository.replace(listOf(entry))
|
||||
queueRepository.withMutationLock { replace(listOf(entry)) }
|
||||
val snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
return snapshot
|
||||
@@ -350,7 +350,7 @@ class PlaybackController(
|
||||
val player = player ?: return@withLock
|
||||
var snapshot = snapshot ?: return@withLock
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val anchor = queueRepository.reanchorFor(snapshot, position)
|
||||
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, position) }
|
||||
if (anchor.changed) {
|
||||
snapshot = readSnapshot()
|
||||
this@PlaybackController.snapshot = snapshot
|
||||
@@ -385,11 +385,12 @@ class PlaybackController(
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
if (position !in 0 until snapshot.size) return@withLock
|
||||
val player = player ?: return@withLock
|
||||
if (snapshot.shuffled && !snapshot.isUpNext(position)) return@withLock
|
||||
val cursor = queueRepository.cursor()
|
||||
val entries = snapshot.entries.map { it.entry }
|
||||
val upNext = snapshot.upNext.map { it.entry }
|
||||
if (snapshot.size == 1L) {
|
||||
queueRepository.removeAt(snapshot, position)
|
||||
queueRepository.withMutationLock { removeAt(snapshot, position) }
|
||||
stopLocked()
|
||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
return@withLock
|
||||
@@ -398,7 +399,7 @@ class PlaybackController(
|
||||
val playing = player.playWhenReady
|
||||
val wasUpNext = snapshot.isUpNext(current)
|
||||
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
||||
queueRepository.removeAt(snapshot, position)
|
||||
queueRepository.withMutationLock { removeAt(snapshot, position) }
|
||||
var updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
windowJob?.cancel()
|
||||
@@ -437,7 +438,7 @@ class PlaybackController(
|
||||
// Keep the up-next block next: after a removal the cursor may have landed on a context
|
||||
// track that no longer carries the block (its anchor moved), so re-anchor it there.
|
||||
if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
|
||||
val reanchored = queueRepository.reanchorFor(updated, target)
|
||||
val reanchored = queueRepository.withMutationLock { reanchorFor(updated, target) }
|
||||
if (reanchored.changed) {
|
||||
updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
@@ -506,7 +507,7 @@ class PlaybackController(
|
||||
val entries = snapshot.entries.map { it.entry }
|
||||
val upNext = snapshot.upNext.map { it.entry }
|
||||
val wasUpNext = snapshot.isUpNext(current)
|
||||
if (!queueRepository.move(snapshot, from, to)) return@withLock
|
||||
if (!queueRepository.withMutationLock { move(snapshot, from, to) }) return@withLock
|
||||
val updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
// The current track still exists, so derive its new position from the edit instead of
|
||||
@@ -586,7 +587,7 @@ class PlaybackController(
|
||||
val player = player ?: return@withLock
|
||||
val playing = player.playWhenReady
|
||||
val positionMs = player.currentPositionMs
|
||||
queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor)
|
||||
queueRepository.withMutationLock { replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor) }
|
||||
shuffleEnabled = undo.shuffleSeed != null
|
||||
val restored = readSnapshot()
|
||||
this.snapshot = restored
|
||||
@@ -725,7 +726,7 @@ class PlaybackController(
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
if (!snapshot.isUpNext(position)) return@withLock
|
||||
if (position >= snapshot.size - 1 && repeatMode == RepeatMode.Off) return@withLock
|
||||
queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position))
|
||||
queueRepository.withMutationLock { removeUpNextAt(snapshot, snapshot.upNextIndex(position)) }
|
||||
val updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
layoutVersion++
|
||||
@@ -758,7 +759,7 @@ class PlaybackController(
|
||||
player ?: return@withLock
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val wasUpNext = snapshot.isUpNext(current)
|
||||
queueRepository.clearUpNext()
|
||||
queueRepository.withMutationLock { clearUpNext() }
|
||||
val updated = readSnapshot()
|
||||
this@PlaybackController.snapshot = updated
|
||||
layoutVersion++
|
||||
@@ -861,7 +862,7 @@ class PlaybackController(
|
||||
val player = player ?: return@withLock
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val position = queueRepository.addUpNext(snapshot, current, entry, playNext)
|
||||
val position = queueRepository.withMutationLock { addUpNext(current, entry, playNext) }
|
||||
val updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
layoutVersion++
|
||||
@@ -898,9 +899,9 @@ class PlaybackController(
|
||||
windowJob?.cancel()
|
||||
val enabling = !shuffleEnabled
|
||||
if (enabling) {
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
||||
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
|
||||
} else {
|
||||
queueRepository.setShuffle(null)
|
||||
queueRepository.withMutationLock { setShuffle(null) }
|
||||
}
|
||||
shuffleEnabled = enabling
|
||||
val reordered = readSnapshot()
|
||||
@@ -911,7 +912,7 @@ class PlaybackController(
|
||||
enabling -> reordered.combined(0L)
|
||||
else -> reordered.combined(flat)
|
||||
}
|
||||
val anchor = queueRepository.reanchorFor(reordered, position)
|
||||
val anchor = queueRepository.withMutationLock { reanchorFor(reordered, position) }
|
||||
val finalSnapshot = if (anchor.changed) readSnapshot() else reordered
|
||||
if (anchor.changed) layoutVersion++
|
||||
this@PlaybackController.snapshot = finalSnapshot
|
||||
@@ -1028,7 +1029,7 @@ class PlaybackController(
|
||||
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
||||
val position =
|
||||
if (shuffle) {
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
||||
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, start)) }
|
||||
0L
|
||||
} else {
|
||||
start
|
||||
@@ -1052,7 +1053,7 @@ class PlaybackController(
|
||||
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
|
||||
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
|
||||
val flat = snapshot.flatContext(start) ?: start
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
||||
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
|
||||
queueRepository.setCursor(0)
|
||||
snapshot = queueRepository.snapshot()
|
||||
} else if (!snapshot.shuffled) {
|
||||
@@ -1072,7 +1073,7 @@ class PlaybackController(
|
||||
this.queueSourceId = queueSourceId
|
||||
val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
|
||||
val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
|
||||
val anchor = queueRepository.reanchorFor(snapshot, cursor)
|
||||
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, cursor) }
|
||||
if (anchor.changed) {
|
||||
snapshot = queueRepository.snapshot()
|
||||
this.snapshot = snapshot
|
||||
@@ -1211,7 +1212,7 @@ class PlaybackController(
|
||||
val player = player ?: return@withLock
|
||||
var snapshot = readSnapshot()
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val anchor = queueRepository.reanchorFor(snapshot, target)
|
||||
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, target) }
|
||||
if (anchor.changed) {
|
||||
snapshot = readSnapshot()
|
||||
layoutVersion++
|
||||
@@ -1490,7 +1491,7 @@ class PlaybackController(
|
||||
private suspend fun readSnapshot(): QueueSnapshot {
|
||||
val snapshot = queueRepository.snapshot()
|
||||
if (!snapshot.shuffled) {
|
||||
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(null)
|
||||
if (shuffleEnabled && snapshot.size > 0L) queueRepository.withMutationLock { setShuffle(null) }
|
||||
shuffleEnabled = false
|
||||
}
|
||||
return snapshot
|
||||
|
||||
@@ -313,14 +313,14 @@ class DownloadRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() =
|
||||
fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
||||
|
||||
assertNull(row(1, "s1"))
|
||||
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
|
||||
suspend fun QueueRepository.replace(
|
||||
entries: List<QueueEntry>,
|
||||
shuffleSeed: Long? = null,
|
||||
upNext: List<QueueEntry> = emptyList(),
|
||||
upNextAnchor: Long = 0,
|
||||
) = withMutationLock {
|
||||
replace(entries, shuffleSeed, upNext, upNextAnchor)
|
||||
}
|
||||
|
||||
suspend fun QueueRepository.setShuffle(seed: Long?) =
|
||||
withMutationLock {
|
||||
setShuffle(seed)
|
||||
}
|
||||
|
||||
suspend fun QueueRepository.addUpNext(
|
||||
currentPosition: Long,
|
||||
entry: QueueEntry,
|
||||
playNext: Boolean,
|
||||
): Long = withMutationLock { addUpNext(currentPosition, entry, playNext) }
|
||||
|
||||
suspend fun QueueRepository.reanchorFor(
|
||||
snapshot: QueueSnapshot,
|
||||
target: Long,
|
||||
): Reanchored = withMutationLock { reanchorFor(snapshot, target) }
|
||||
|
||||
suspend fun QueueRepository.clearUpNext() =
|
||||
withMutationLock {
|
||||
clearUpNext()
|
||||
}
|
||||
|
||||
suspend fun QueueRepository.removeUpNextAt(
|
||||
snapshot: QueueSnapshot,
|
||||
index: Long,
|
||||
): Boolean = withMutationLock { removeUpNextAt(snapshot, index) }
|
||||
|
||||
suspend fun QueueRepository.removeAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
): Boolean = withMutationLock { removeAt(snapshot, position) }
|
||||
|
||||
suspend fun QueueRepository.move(
|
||||
snapshot: QueueSnapshot,
|
||||
from: Long,
|
||||
to: Long,
|
||||
): Boolean = withMutationLock { move(snapshot, from, to) }
|
||||
@@ -11,13 +11,18 @@ 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
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.yield
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -79,6 +84,20 @@ class QueueRepositoryTest {
|
||||
assertEquals(listOf("s1", "s3"), resolveAll(snapshot))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedEntryIgnoresRetainedRowsWhoseSongsArePruned() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||
db.libraryDao().deleteSongs(1, listOf("s2"))
|
||||
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.downloadedEntry(1)))
|
||||
|
||||
assertEquals(1, snapshot.size)
|
||||
assertEquals(listOf("s1"), resolveAll(snapshot))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starredEntryResolvesStarredTracksNewestFirst() =
|
||||
runTest {
|
||||
@@ -416,28 +435,427 @@ class QueueRepositoryTest {
|
||||
assertEquals(4, repository.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aChangedOriginResetMergesEditsAndRemapsTheCurrentItem() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 1)
|
||||
repository.setCursor(1)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
setPlaylist("p1", "s0", "s1", "s2", "s3")
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
val entry = updated.entries.single().entry
|
||||
assertEquals(QueueKind.Playlist, entry.kind)
|
||||
assertEquals("p1", entry.refId)
|
||||
assertNull(entry.rangeStart)
|
||||
assertNull(entry.rangeEnd)
|
||||
assertEquals(3L, repository.remapPosition(updated, point.current!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnrelatedOriginResetRemapsWithinTheSecondUnchangedFragment() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(listOf(song("s0", "al1", track = 0, album = "First Album")))
|
||||
setPlaylist("p2", "s4")
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p2"), repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 2)
|
||||
repository.setCursor(2)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
setPlaylist("p2", "s0", "s4")
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point)
|
||||
|
||||
assertEquals(3L, repository.remapPosition(repository.snapshot(), point.current!!))
|
||||
assertEquals(3L, repository.cursor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnrelatedOriginResetPreservesInterleavedFragmentStarts() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.albumEntry(1, "al2")))
|
||||
repository.move(snapshot, 3, 1)
|
||||
repository.setCursor(3)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
|
||||
|
||||
assertEquals(4L, repository.remapPosition(repository.snapshot(), point.current!!))
|
||||
assertEquals(4L, repository.cursor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMovedPlaylistOccurrenceSurvivesReconstructionAndReset() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s1")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.move(snapshot, 2, 0)
|
||||
repository.setCursor(0)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(2L, repository.remapPosition(repository.snapshot(), point.current!!))
|
||||
assertEquals(2L, repository.cursor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRemovedDuplicateOccurrenceDoesNotLeaveItsOffsetOnTheSurvivor() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s1")
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.setCursor(1)
|
||||
repository.setPosition(42_000L)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
setPlaylist("p1", "s1")
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(0L, repository.cursorPositionMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnrelatedOriginResetKeepsTheEditedQueue() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 1)
|
||||
val edited = repository.snapshot().entries.map { it.entry }
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p2")), point)
|
||||
|
||||
assertEquals(edited, repository.snapshot().entries.map { it.entry })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOriginResetRebuildsOnlyTheChangedUpNextBlock() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false)
|
||||
repository.addUpNext(
|
||||
0,
|
||||
repository.albumEntry(1, "al2").copy(rangeStart = 1, rangeEnd = 1),
|
||||
playNext = false,
|
||||
)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
val upNext = repository.snapshot().upNext.map { it.entry }
|
||||
assertEquals(QueueKind.Playlist, upNext[0].kind)
|
||||
assertNull(upNext[0].rangeStart)
|
||||
assertEquals(QueueKind.Album, upNext[1].kind)
|
||||
assertEquals(1L, upNext[1].rangeStart)
|
||||
assertEquals(1L, upNext[1].rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOriginResetPreservesDuplicateWholeUpNextEntries() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(2, updated.upNext.size)
|
||||
assertEquals(6L, updated.upNextSize)
|
||||
assertTrue(updated.upNext.all { it.entry.rangeStart == null && it.entry.rangeEnd == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetRemapsCurrentWithinTheSecondDuplicateUpNextEntry() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2")
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
|
||||
repository.setCursor(4)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(4L, repository.cursor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetRemapsCurrentAndAnchorWithinTheSecondDuplicateContextEntry() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2")
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1"), repository.playlistEntry(1, "p1")))
|
||||
repository.addUpNext(3, repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.setCursor(3)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(3L, repository.cursor())
|
||||
assertEquals(3L, repository.snapshot().upNextAnchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetRemapsTheSecondOccurrenceOfASongWithinAPlaylist() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s1")
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.setCursor(2)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(2L, repository.cursor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRemovedShuffledAnchorFallbackStaysInPlayCoordinates() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3", "s4", "s5")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.addUpNext(2, repository.songEntry(1, "s4"), playNext = false)
|
||||
val seed = (1L..100L).first { Shuffle.toSequence(it, 4, 2) != 2L }
|
||||
repository.setShuffle(seed)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
setPlaylist("p1", "s1", "s2", "s4", "s5")
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
repository.setShuffle(seed)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(updated.flatContext(point.anchor!!.position), updated.upNextAnchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetDoesNotRestoreShuffleAfterTheUserDisablesIt() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 1)
|
||||
repository.setShuffle(42L)
|
||||
val point = repository.captureResetPoint()
|
||||
repository.setShuffle(null)
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertFalse(repository.snapshot().shuffled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetPreservesShuffleEnabledDuringSync() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 1)
|
||||
val point = repository.captureResetPoint()
|
||||
repository.setShuffle(99L)
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertTrue(repository.snapshot().shuffled)
|
||||
assertEquals(99L, repository.snapshot().shuffleSeed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aConcurrentUpNextEditSurvivesAReset() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 1)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
|
||||
val edit = launch { repository.addUpNext(0, repository.songEntry(1, "s4"), playNext = false) }
|
||||
reset.join()
|
||||
edit.join()
|
||||
|
||||
assertEquals(1L, repository.snapshot().upNextSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleContextEditsCannotOverwriteACompletedReset() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val initial = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(initial, 1)
|
||||
val stale = repository.snapshot()
|
||||
val point = repository.captureResetPoint()
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
val canonical = repository.snapshot().entries.map { it.entry }
|
||||
|
||||
repository.removeAt(stale, 0)
|
||||
assertFalse(repository.move(stale, 0, 1))
|
||||
|
||||
assertEquals(canonical, repository.snapshot().entries.map { it.entry })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleUpNextRemovalCannotOverwriteACompletedReset() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 1), playNext = false)
|
||||
val stale = repository.snapshot()
|
||||
val point = repository.captureResetPoint()
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
val canonical = repository.snapshot().upNext.map { it.entry }
|
||||
|
||||
repository.removeUpNextAt(stale, 0)
|
||||
|
||||
assertEquals(canonical, repository.snapshot().upNext.map { it.entry })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheInvalidationDoesNotAdvanceTheQueueContentVersion() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
val before = repository.snapshot().version
|
||||
|
||||
repository.invalidateLibraryCache()
|
||||
|
||||
assertEquals(before, repository.snapshot().version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetPrefersTheLatestUpNextAnchorOverTheCapturedOne() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false)
|
||||
val point = repository.captureResetPoint()
|
||||
val navigated = repository.snapshot()
|
||||
repository.reanchorFor(navigated, 2)
|
||||
repository.setCursor(repository.snapshot().anchorPlay + 1)
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
|
||||
|
||||
assertEquals(1L, repository.snapshot().upNextAnchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aResetPreservesANewBeforeFirstUpNextAnchor() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.albumEntry(1, "al2").copy(rangeStart = 0, rangeEnd = 0), playNext = false)
|
||||
val point = repository.captureResetPoint()
|
||||
repository.removeAt(repository.snapshot(), 0)
|
||||
assertEquals(-1L, repository.snapshot().upNextAnchor)
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al2")), point)
|
||||
|
||||
assertEquals(-1L, repository.snapshot().upNextAnchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeletedCurrentSongDoesNotLeaveItsPositionOnTheSuccessor() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1").copy(rangeStart = 0, rangeEnd = 2)))
|
||||
repository.setCursor(0)
|
||||
repository.setPosition(50_000)
|
||||
val point = repository.captureResetPoint()
|
||||
setPlaylist("p1", "s2", "s3")
|
||||
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
assertEquals(0L, repository.cursorPositionMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetWaitsForCursorUpdatesBeforeWritingItsCursor() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
repository.removeAt(snapshot, 0)
|
||||
val point = repository.captureResetPoint()
|
||||
|
||||
repository.cursorMutex.lock()
|
||||
val reset = launch { repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) }
|
||||
val repeat = launch { repository.setRepeat(2) }
|
||||
try {
|
||||
yield()
|
||||
assertFalse(reset.isCompleted)
|
||||
assertFalse(repeat.isCompleted)
|
||||
} finally {
|
||||
repository.cursorMutex.unlock()
|
||||
}
|
||||
reset.join()
|
||||
repeat.join()
|
||||
|
||||
assertEquals(2, repository.modes().repeat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upNextResolvesAfterTheCurrentTrack() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(4, updated.size)
|
||||
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reAddingUpNextAfterItsLibraryOriginChangedResolvesFreshRows() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
setPlaylist("p1", "s1", "s2")
|
||||
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
|
||||
val initial = repository.snapshot()
|
||||
assertEquals(listOf("s1", "s2"), (1L..2L).map { repository.itemAt(initial, it)?.song?.id })
|
||||
repository.clearUpNext()
|
||||
setPlaylist("p1", "s1", "s2", "s3")
|
||||
val point = repository.captureResetPoint()
|
||||
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||
|
||||
repository.addUpNext(0, repository.playlistEntry(1, "p1"), playNext = false)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(3L, updated.upNextSize)
|
||||
assertEquals(listOf("s1", "s2", "s3"), (1L..3L).map { repository.itemAt(updated, it)?.song?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playNextGoesToTheFrontOfTheUpNextQueue() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
repository.addUpNext(
|
||||
repository.snapshot(),
|
||||
currentPosition = 0,
|
||||
entry = repository.songEntry(1, "s5"),
|
||||
playNext = true,
|
||||
@@ -451,7 +869,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
repository.removeAt(repository.snapshot(), 1)
|
||||
|
||||
@@ -465,9 +883,8 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(
|
||||
repository.snapshot(),
|
||||
currentPosition = 0,
|
||||
entry = repository.songEntry(1, "s5"),
|
||||
playNext = false,
|
||||
@@ -486,7 +903,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
|
||||
|
||||
repository.move(repository.snapshot(), from = 2, to = 1)
|
||||
|
||||
@@ -509,9 +926,8 @@ class QueueRepositoryTest {
|
||||
),
|
||||
)
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = albumRange(1, 4), playNext = false)
|
||||
repository.addUpNext(
|
||||
repository.snapshot(),
|
||||
currentPosition = 0,
|
||||
entry = repository.songEntry(1, "x1"),
|
||||
playNext = false,
|
||||
@@ -562,7 +978,7 @@ class QueueRepositoryTest {
|
||||
val shuffled = repository.snapshot()
|
||||
val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id }
|
||||
|
||||
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(4, updated.size)
|
||||
@@ -580,7 +996,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
|
||||
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.removeAt(repository.snapshot(), 2)
|
||||
@@ -593,7 +1009,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.removeAt(repository.snapshot(), 0)
|
||||
@@ -606,7 +1022,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.removeAt(repository.snapshot(), 2)
|
||||
@@ -621,7 +1037,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.removeAt(repository.snapshot(), 0)
|
||||
@@ -634,7 +1050,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
repository.replace(listOf(repository.songEntry(1, "s5")))
|
||||
|
||||
@@ -783,98 +1199,22 @@ class QueueRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() =
|
||||
fun removingAContextTrackWhileShuffledIsRejected() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.setShuffle(42L)
|
||||
val before = repository.snapshot()
|
||||
val entriesBefore = before.entries.map { it.entry }
|
||||
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
|
||||
|
||||
repository.removeAt(before, 1)
|
||||
|
||||
val after = repository.snapshot()
|
||||
assertTrue(after.shuffled)
|
||||
assertEquals(2L, after.contextSize)
|
||||
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
|
||||
assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun severalRemovalsWhileShuffledKeepTheRemainingOrder() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.setShuffle(42L)
|
||||
val before = repository.snapshot()
|
||||
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
|
||||
|
||||
repository.removeAt(before, 0)
|
||||
repository.removeAt(repository.snapshot(), 0)
|
||||
|
||||
val after = repository.snapshot()
|
||||
assertTrue(after.shuffled)
|
||||
assertEquals(1L, after.contextSize)
|
||||
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
|
||||
assertEquals(orderBefore.drop(2), orderAfter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingWhileShuffledKeepsTheOrderInverse() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
|
||||
repository.setShuffle(9L)
|
||||
repository.removeAt(repository.snapshot(), 2)
|
||||
|
||||
val after = repository.snapshot()
|
||||
assertEquals(3L, after.contextSize)
|
||||
for (play in 0 until after.contextSize) {
|
||||
assertEquals(play, after.playContext(after.flatContext(play)!!))
|
||||
}
|
||||
assertEquals((0L until after.contextSize).toList(), (0 until after.contextSize).map { after.flatContext(it)!! }.sorted())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingAnyPositionWhileShuffledKeepsTheCoordinatesConsistent() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
for (seed in listOf(1L, 42L, 999L)) {
|
||||
for (position in 0L..4L) {
|
||||
repository.replace(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.setShuffle(seed)
|
||||
repository.addUpNext(
|
||||
repository.snapshot(),
|
||||
currentPosition = 0,
|
||||
entry = repository.songEntry(1, "s4"),
|
||||
playNext = false,
|
||||
)
|
||||
val before = repository.snapshot()
|
||||
if (position >= before.size) continue
|
||||
|
||||
repository.removeAt(before, position)
|
||||
|
||||
val after = repository.snapshot()
|
||||
assertTrue("seed=$seed position=$position stopped being shuffled", after.shuffled)
|
||||
assertEquals(before.size - 1, after.size)
|
||||
val flats = (0 until after.contextSize).map { after.flatContext(it)!! }
|
||||
assertEquals((0L until after.contextSize).toList(), flats.sorted())
|
||||
for (play in 0 until after.contextSize) {
|
||||
assertEquals(play, after.playContext(after.flatContext(play)!!))
|
||||
}
|
||||
for (p in 0 until after.size) {
|
||||
if (after.isUpNext(p)) continue
|
||||
assertEquals(p, after.combined(after.contextPlay(p)))
|
||||
}
|
||||
val block = (0 until after.size).filter { after.isUpNext(it) }
|
||||
assertEquals(
|
||||
(after.anchorPlay + 1..after.anchorPlay + after.upNextSize).toList(),
|
||||
block,
|
||||
)
|
||||
val ids = (0 until after.size).mapNotNull { repository.itemAt(after, it)?.song?.id }
|
||||
assertEquals(ids.distinct().size, ids.size)
|
||||
}
|
||||
}
|
||||
assertEquals(before.contextSize, after.contextSize)
|
||||
assertEquals(entriesBefore, after.entries.map { it.entry })
|
||||
assertEquals(orderBefore, (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -932,7 +1272,7 @@ class QueueRepositoryTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
|
||||
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(1L, updated.anchorPlay)
|
||||
@@ -947,13 +1287,13 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
val before = repository.snapshot()
|
||||
assertEquals(0L, before.anchorPlay)
|
||||
assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
|
||||
|
||||
repository.setUpNextAnchor(2)
|
||||
repository.reanchorFor(before, 3)
|
||||
|
||||
val after = repository.snapshot()
|
||||
assertEquals(2L, after.anchorPlay)
|
||||
@@ -966,7 +1306,7 @@ class QueueRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
repository.addUpNext(currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
|
||||
|
||||
val updated = repository.snapshot()
|
||||
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
|
||||
@@ -1105,6 +1445,16 @@ class QueueRepositoryTest {
|
||||
created = 0,
|
||||
)
|
||||
|
||||
private suspend fun setPlaylist(
|
||||
id: String,
|
||||
vararg songIds: String,
|
||||
) {
|
||||
db.libraryDao().deletePlaylistSongsFrom(1, id, 0)
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
songIds.mapIndexed { position, songId -> PlaylistSong(1, id, songId, position.toLong()) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun seedLibrary() {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
|
||||
@@ -101,7 +101,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedSyncStillInvalidatesTheLibraryCache() =
|
||||
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
|
||||
runBlocking {
|
||||
val before = queueRepository.snapshot().version
|
||||
|
||||
@@ -109,7 +109,7 @@ class SyncManagerTest {
|
||||
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||
|
||||
assertTrue(status is SyncStatus.Failed)
|
||||
assertEquals(before + 1, queueRepository.snapshot().version)
|
||||
assertEquals(before, queueRepository.snapshot().version)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -36,6 +36,9 @@ import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.repo.removeAt
|
||||
import com.subtracks.data.repo.replace
|
||||
import com.subtracks.data.repo.setShuffle
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -55,7 +58,6 @@ 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
|
||||
@@ -2116,40 +2118,28 @@ class PlaybackControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() {
|
||||
fun removingATrackWhileShuffledIsRejected() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
controller.toggleShuffle()
|
||||
await { controller.state.value.shuffle }
|
||||
val currentId =
|
||||
controller.state.value.item!!
|
||||
.id
|
||||
val removeAt = (controller.state.value.position!! + 1) % 6
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
val itemsBefore = handle.items.map { it.id }
|
||||
|
||||
runBlocking { controller.removeAt(removeAt) }
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == currentId && controller.state.value.shuffle
|
||||
}
|
||||
runBlocking { controller.removeAt((controller.state.value.position!! + 1) % 6) }
|
||||
|
||||
assertTrue(handle.items.map { it.id }.contains(currentId))
|
||||
val remaining =
|
||||
runBlocking {
|
||||
val snap = queues.snapshot()
|
||||
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
|
||||
}
|
||||
assertEquals(5, remaining.size)
|
||||
assertEquals(5, remaining.distinct().size)
|
||||
val after = runBlocking { queues.snapshot() }
|
||||
assertEquals(before.contextSize, after.contextSize)
|
||||
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||
assertEquals(itemsBefore, handle.items.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() {
|
||||
fun removingThePlayingTrackWhileShuffledLeavesTheBlockUnchanged() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
runBlocking {
|
||||
@@ -2161,30 +2151,24 @@ class PlaybackControllerTest {
|
||||
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
val currentId = handle.currentItem?.id
|
||||
|
||||
val current = controller.state.value.position!!
|
||||
runBlocking { controller.removeAt(current) }
|
||||
runBlocking { controller.removeAt(controller.state.value.position!!) }
|
||||
|
||||
// The block was queued behind the removed track, so it takes the slot and plays now.
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "x1"
|
||||
}
|
||||
val after = runBlocking { queues.snapshot() }
|
||||
assertTrue(after.shuffled)
|
||||
assertEquals(
|
||||
"x1",
|
||||
runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id },
|
||||
)
|
||||
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||
assertEquals(before.upNextAnchor, after.upNextAnchor)
|
||||
assertEquals(1L, after.upNextSize)
|
||||
assertEquals(currentId, handle.currentItem?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingTheAnchorWhilePlayingABlockTrackKeepsTheBlockInPlace() {
|
||||
fun removingTheAnchorWhilePlayingABlockTrackIsRejected() {
|
||||
seedShuffledQueue(seed = 5L)
|
||||
seedSong("x1", "al2")
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
|
||||
controller.playAt(1)
|
||||
await {
|
||||
controller.state.value.item
|
||||
@@ -2195,8 +2179,9 @@ class PlaybackControllerTest {
|
||||
runBlocking { controller.removeAt(before.anchorPlay) }
|
||||
|
||||
val after = runBlocking { queues.snapshot() }
|
||||
assertEquals(-1L, after.anchorPlay)
|
||||
assertEquals("x1", runBlocking { queues.itemAt(after, 0)?.song?.id })
|
||||
assertEquals(before.upNextAnchor, after.upNextAnchor)
|
||||
assertEquals(before.contextSize, after.contextSize)
|
||||
assertEquals("x1", runBlocking { queues.itemAt(after, 1)?.song?.id })
|
||||
assertEquals(
|
||||
"x1",
|
||||
controller.state.value.item
|
||||
@@ -2205,20 +2190,18 @@ class PlaybackControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingThePlayingTrackWhileShuffledPlaysTheNextShuffledTrack() {
|
||||
fun removingThePlayingTrackWhileShuffledDoesNotAdvance() {
|
||||
seedShuffledQueue(seed = 12345L)
|
||||
val orderBefore = resolvedIds()
|
||||
val current = controller.state.value.position!!
|
||||
val expected = orderBefore.toMutableList().apply { removeAt(current.toInt()) }[current.toInt()]
|
||||
val currentId = handle.currentItem?.id
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
|
||||
runBlocking { controller.removeAt(current) }
|
||||
runBlocking { controller.removeAt(controller.state.value.position!!) }
|
||||
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == expected
|
||||
}
|
||||
val after = runBlocking { queues.snapshot() }
|
||||
assertEquals(expected, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
|
||||
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||
assertEquals(currentId, handle.currentItem?.id)
|
||||
assertEquals(orderBefore, resolvedIds())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2275,73 +2258,6 @@ class PlaybackControllerTest {
|
||||
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undoingARemovalRestoresTheShuffledOrder() {
|
||||
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) }
|
||||
assertNotEquals(orderBefore, resolvedIds())
|
||||
|
||||
runBlocking { controller.undo() }
|
||||
|
||||
await { controller.state.value.shuffle && resolvedIds() == orderBefore }
|
||||
assertEquals(orderBefore, resolvedIds())
|
||||
assertEquals(currentPosition, controller.state.value.position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingANonCurrentTrackWhileShuffledKeepsTheCursorOnThePlayingTrack() {
|
||||
seedShuffledQueue(seed = 12345L)
|
||||
val currentId =
|
||||
controller.state.value.item!!
|
||||
.id
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
val currentPosition = controller.state.value.position!!
|
||||
val removePosition = (0 until before.size).first { it != currentPosition }
|
||||
|
||||
runBlocking { controller.removeAt(removePosition) }
|
||||
|
||||
val after = runBlocking { queues.snapshot() }
|
||||
assertEquals(
|
||||
currentId,
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingANonCurrentTrackWhileShuffledKeepsTheBlockNext() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
runBlocking {
|
||||
queues.replace(listOf(queues.albumEntry(1, "al1")))
|
||||
queues.setShuffle(999L)
|
||||
queues.setCursor(0)
|
||||
}
|
||||
controller.connect()
|
||||
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
|
||||
val currentId =
|
||||
controller.state.value.item!!
|
||||
.id
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
val currentPosition = controller.state.value.position!!
|
||||
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
|
||||
|
||||
runBlocking { controller.removeAt(removePosition) }
|
||||
|
||||
val after = runBlocking { queues.snapshot() }
|
||||
assertEquals(currentId, runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id })
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingContextTracksWhileShuffledIsIgnored() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
@@ -2799,39 +2715,6 @@ class PlaybackControllerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingAContextTrackWhileShuffledKeepsThePlayingTrackAndTheBlock() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
controller.toggleShuffle()
|
||||
await { controller.state.value.shuffle && runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
|
||||
val currentId =
|
||||
controller.state.value.item!!
|
||||
.id
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
val currentPosition = controller.state.value.position!!
|
||||
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
|
||||
|
||||
runBlocking { controller.removeAt(removePosition) }
|
||||
|
||||
await {
|
||||
val snap = runBlocking { queues.snapshot() }
|
||||
controller.state.value.item
|
||||
?.id == currentId &&
|
||||
runBlocking { queues.itemAt(snap, controller.state.value.position!!)?.song?.id } == currentId
|
||||
}
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.repo.replace
|
||||
import com.subtracks.data.repo.setShuffle
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
|
||||
Reference in New Issue
Block a user