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:
+2082
File diff suppressed because it is too large
Load Diff
@@ -161,7 +161,7 @@ interface DownloadDao {
|
|||||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||||
|
|
||||||
@Query(
|
@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.artistId AS albumArtistId, " +
|
||||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
"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 {
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
val triggers = mutableListOf<String>()
|
val triggers = mutableListOf<String>()
|
||||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
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_35_36,
|
||||||
MIGRATION_36_37,
|
MIGRATION_36_37,
|
||||||
MIGRATION_37_38,
|
MIGRATION_37_38,
|
||||||
|
MIGRATION_38_39,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.room3.Insert
|
|||||||
import androidx.room3.OnConflictStrategy
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.PendingSyncOrigin
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -37,6 +38,12 @@ private const val SONG_SQL =
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface QueueDao {
|
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")
|
@Query("DELETE FROM queue_entries")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
@@ -243,6 +250,44 @@ interface QueueDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Long?
|
): 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")
|
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||||
suspend fun genreLength(
|
suspend fun genreLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -263,7 +308,11 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): 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
|
suspend fun downloadedLength(sourceId: Long): Long
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
|
@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.DownloadStatusConverter
|
||||||
import com.subtracks.data.model.PendingAction
|
import com.subtracks.data.model.PendingAction
|
||||||
import com.subtracks.data.model.PendingActionKindConverter
|
import com.subtracks.data.model.PendingActionKindConverter
|
||||||
|
import com.subtracks.data.model.PendingSyncOrigin
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSearch
|
import com.subtracks.data.model.PlaylistSearch
|
||||||
@@ -56,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
|
PendingSyncOrigin::class,
|
||||||
],
|
],
|
||||||
version = 38,
|
version = 39,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ enum class DownloadError(
|
|||||||
childColumns = ["sourceId"],
|
childColumns = ["sourceId"],
|
||||||
onDelete = ForeignKey.CASCADE,
|
onDelete = ForeignKey.CASCADE,
|
||||||
),
|
),
|
||||||
ForeignKey(
|
|
||||||
entity = Song::class,
|
|
||||||
parentColumns = ["sourceId", "id"],
|
|
||||||
childColumns = ["sourceId", "songId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
indices = [Index("sourceId"), Index("engineId")],
|
indices = [Index("sourceId"), Index("engineId")],
|
||||||
)
|
)
|
||||||
@@ -109,6 +103,7 @@ data class DownloadedSong(
|
|||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
|
val songRow: String?,
|
||||||
val albumId: String?,
|
val albumId: String?,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
val albumArtistId: String?,
|
val albumArtistId: String?,
|
||||||
@@ -119,7 +114,7 @@ data class DownloadArtwork(
|
|||||||
val albumArtistCoverArt: String?,
|
val albumArtistCoverArt: String?,
|
||||||
) {
|
) {
|
||||||
val hasMissingLibraryRow: Boolean
|
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(
|
data class SourceCoverArt(
|
||||||
|
|||||||
@@ -448,6 +448,31 @@ data class DiscKey(
|
|||||||
|
|
||||||
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
|
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(
|
@Entity(
|
||||||
tableName = "queue_entries",
|
tableName = "queue_entries",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
@@ -468,10 +493,15 @@ data class QueueEntry(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
val rangeStart: Long? = null,
|
val rangeStart: Long? = null,
|
||||||
val rangeEnd: 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 offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||||
|
|
||||||
|
val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -494,16 +524,31 @@ data class UpNextEntry(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
val rangeStart: Long? = null,
|
val rangeStart: Long? = null,
|
||||||
val rangeEnd: 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 offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(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 {
|
companion object {
|
||||||
fun from(entry: QueueEntry) =
|
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.PlaybackCursor
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.SongItem
|
import com.subtracks.data.model.SongItem
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -36,12 +37,6 @@ private data class LoadedSnapshot(
|
|||||||
val anchor: Long,
|
val anchor: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class QueueRef(
|
|
||||||
val kind: QueueKind,
|
|
||||||
val sourceId: Long,
|
|
||||||
val refId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class OrderKey(
|
private data class OrderKey(
|
||||||
val id: String,
|
val id: String,
|
||||||
val disc: Long? = null,
|
val disc: Long? = null,
|
||||||
@@ -59,58 +54,13 @@ data class ResolvedQueueEntry(
|
|||||||
val length: Long,
|
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(
|
class ShuffleState(
|
||||||
val seed: Long,
|
val seed: Long,
|
||||||
val domain: Long,
|
val domain: Long,
|
||||||
private val removedFlats: LongArray = LongArray(0),
|
|
||||||
private val removedSlots: LongArray = LongArray(0),
|
|
||||||
) {
|
) {
|
||||||
fun flatFor(play: Long): Long {
|
fun flatFor(play: Long): Long = Shuffle.toFlat(seed, domain, play)
|
||||||
val domainFlat = Shuffle.toFlat(seed, domain, liveSlot(play))
|
|
||||||
return domainFlat - removedFlats.count { it < domainFlat }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playFor(canonical: Long): Long {
|
fun playFor(canonical: Long): Long = Shuffle.toSequence(seed, domain, canonical)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class QueueSnapshot(
|
data class QueueSnapshot(
|
||||||
@@ -119,6 +69,7 @@ data class QueueSnapshot(
|
|||||||
val shuffle: ShuffleState? = null,
|
val shuffle: ShuffleState? = null,
|
||||||
val upNextAnchor: Long = 0,
|
val upNextAnchor: Long = 0,
|
||||||
val version: Long = 0,
|
val version: Long = 0,
|
||||||
|
val resetVersion: Long = 0,
|
||||||
) {
|
) {
|
||||||
val contextSize: Long = entries.sumOf { it.length }
|
val contextSize: Long = entries.sumOf { it.length }
|
||||||
|
|
||||||
@@ -202,11 +153,33 @@ data class QueueWindowItem(
|
|||||||
val item: SongItem,
|
val item: SongItem,
|
||||||
val playlistPosition: Long? = null,
|
val playlistPosition: Long? = null,
|
||||||
val upNext: Boolean = false,
|
val upNext: Boolean = false,
|
||||||
|
val resetItem: QueueResetItem? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Reanchored(
|
data class Reanchored(
|
||||||
val position: Long,
|
val position: Long,
|
||||||
val changed: Boolean,
|
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 {
|
private interface QueueKindResolver {
|
||||||
@@ -411,13 +384,23 @@ class QueueRepository(
|
|||||||
private val dao get() = db.queueDao()
|
private val dao get() = db.queueDao()
|
||||||
|
|
||||||
private val queueVersion = AtomicLong()
|
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
|
fun setResetListener(listener: suspend QueueMutationScope.(QueueReset) -> Unit) {
|
||||||
private var shuffleRemoved: ShuffleState? = null
|
resetListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> withMutationLock(block: suspend QueueMutationScope.() -> T): T =
|
||||||
|
queueMutex.withLock {
|
||||||
|
QueueMutationScope().block()
|
||||||
|
}
|
||||||
|
|
||||||
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
|
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
|
||||||
|
|
||||||
private val keyCache = ConcurrentHashMap<QueueRef, KeyCursor>()
|
private val keyCache = ConcurrentHashMap<QueueOrigin, KeyCursor>()
|
||||||
|
|
||||||
internal var keysetSeeks = 0L
|
internal var keysetSeeks = 0L
|
||||||
private set
|
private set
|
||||||
@@ -428,7 +411,7 @@ class QueueRepository(
|
|||||||
internal var resolvedRowCount = 0L
|
internal var resolvedRowCount = 0L
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val cursorMutex = Mutex()
|
internal val cursorMutex = Mutex()
|
||||||
|
|
||||||
fun playlistEntry(
|
fun playlistEntry(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -514,51 +497,46 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun replace(
|
private suspend fun replaceLocked(
|
||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
shuffleSeed: Long? = null,
|
shuffleSeed: Long?,
|
||||||
upNext: List<QueueEntry> = emptyList(),
|
upNext: List<QueueEntry>,
|
||||||
upNextAnchor: Long = 0,
|
upNextAnchor: Long,
|
||||||
) {
|
) {
|
||||||
write(entries)
|
invalidateLibraryCache()
|
||||||
writeUpNext(upNext)
|
writeLocked(entries)
|
||||||
setShuffle(shuffleSeed)
|
writeUpNextLocked(upNext)
|
||||||
|
setShuffleLocked(shuffleSeed)
|
||||||
cursorMutex.withLock {
|
cursorMutex.withLock {
|
||||||
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
|
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun snapshot(): QueueSnapshot {
|
suspend fun snapshot(): QueueSnapshot = withMutationLock { snapshot() }
|
||||||
val version = queueVersion.get()
|
|
||||||
|
private suspend fun snapshotLocked(): QueueSnapshot {
|
||||||
|
val libraryRevision = libraryVersion.get()
|
||||||
val loaded =
|
val loaded =
|
||||||
db.useReaderConnection { transactor ->
|
db.useReaderConnection { transactor ->
|
||||||
transactor.deferredTransaction {
|
transactor.deferredTransaction {
|
||||||
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) }
|
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, libraryRevision)) }
|
||||||
val upNext =
|
val upNext =
|
||||||
dao.upNextEntries().map {
|
dao.upNextEntries().map {
|
||||||
val entry = it.toQueueEntry()
|
val entry = it.toQueueEntry()
|
||||||
ResolvedQueueEntry(entry, lengthOf(entry, version))
|
ResolvedQueueEntry(entry, lengthOf(entry, libraryRevision))
|
||||||
}
|
}
|
||||||
val row = dao.cursor()
|
val row = dao.cursor()
|
||||||
val size = entries.sumOf { it.length }
|
val size = entries.sumOf { it.length }
|
||||||
val state = shuffleRemoved
|
|
||||||
val shuffle =
|
val shuffle =
|
||||||
if (row != null && row.shuffleEnabled && size > 0L) {
|
if (row != null && row.shuffleEnabled && row.shuffleSize == size && size > 0L) {
|
||||||
when {
|
ShuffleState(row.shuffleSeed, row.shuffleSize)
|
||||||
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
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
LoadedSnapshot(entries, upNext, shuffle, row?.upNextAnchor ?: 0L)
|
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 {
|
suspend fun modes(): QueueModes {
|
||||||
@@ -566,7 +544,7 @@ class QueueRepository(
|
|||||||
return QueueModes(row.shuffleEnabled, row.repeatMode)
|
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
|
val size = if (seed != null) contextSize() else 0L
|
||||||
cursorMutex.withLock {
|
cursorMutex.withLock {
|
||||||
dao.setCursor(
|
dao.setCursor(
|
||||||
@@ -577,14 +555,274 @@ class QueueRepository(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
shuffleRemoved = seed?.let { ShuffleState(it, size) }
|
queueVersion.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun contentVersion(): Long = queueVersion.get()
|
||||||
|
|
||||||
fun invalidateLibraryCache() {
|
fun invalidateLibraryCache() {
|
||||||
lengthCache.clear()
|
lengthCache.clear()
|
||||||
keyCache.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()
|
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)) }
|
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))
|
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addUpNext(
|
private suspend fun addUpNextLocked(
|
||||||
snapshot: QueueSnapshot,
|
|
||||||
currentPosition: Long,
|
currentPosition: Long,
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
playNext: Boolean,
|
playNext: Boolean,
|
||||||
): Long {
|
): Long {
|
||||||
val list = snapshot.upNext.map { it.entry }.toMutableList()
|
invalidateLibraryCache()
|
||||||
|
val current = snapshotLocked()
|
||||||
|
val list = current.upNext.map { it.entry }.toMutableList()
|
||||||
val insertIndex =
|
val insertIndex =
|
||||||
when {
|
when {
|
||||||
playNext && snapshot.isUpNext(currentPosition) -> {
|
playNext && current.isUpNext(currentPosition) -> {
|
||||||
entryIndexAfter(snapshot.upNext, snapshot.upNextIndex(currentPosition))
|
entryIndexAfter(current.upNext, current.upNextIndex(currentPosition))
|
||||||
}
|
}
|
||||||
|
|
||||||
playNext -> {
|
playNext -> {
|
||||||
@@ -667,75 +906,54 @@ class QueueRepository(
|
|||||||
}.coerceIn(0, list.size)
|
}.coerceIn(0, list.size)
|
||||||
list.add(insertIndex, entry.copy(id = 0, position = 0))
|
list.add(insertIndex, entry.copy(id = 0, position = 0))
|
||||||
val anchor =
|
val anchor =
|
||||||
if (snapshot.upNextSize == 0L) {
|
if (current.upNextSize == 0L) {
|
||||||
val play = snapshot.anchorContextPlay(currentPosition)
|
val play = current.anchorContextPlay(currentPosition)
|
||||||
snapshot.flatContext(play) ?: 0L
|
current.flatContext(play) ?: 0L
|
||||||
} else {
|
} else {
|
||||||
snapshot.upNextAnchor
|
current.upNextAnchor
|
||||||
}
|
}
|
||||||
writeUpNext(list)
|
writeUpNextLocked(list)
|
||||||
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
|
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
|
||||||
if (snapshot.upNextSize == 0L) return currentPosition
|
if (current.upNextSize == 0L) return currentPosition
|
||||||
val before = snapshot.upNext.take(insertIndex).sumOf { it.length }
|
val before = current.upNext.take(insertIndex).sumOf { it.length }
|
||||||
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
|
return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reanchorFor(
|
private suspend fun reanchorForLocked(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
target: Long,
|
target: Long,
|
||||||
): Reanchored {
|
): Reanchored {
|
||||||
|
if (snapshot.version != queueVersion.get()) return Reanchored(target, false, stale = true)
|
||||||
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
|
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
|
||||||
val play = snapshot.contextPlay(target)
|
val play = snapshot.contextPlay(target)
|
||||||
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
|
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
|
||||||
if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
|
if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
|
||||||
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
|
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
|
||||||
|
queueVersion.incrementAndGet()
|
||||||
return Reanchored(play, true)
|
return Reanchored(play, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setUpNextAnchor(anchor: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
|
private suspend fun removeUpNextAtLocked(
|
||||||
|
|
||||||
suspend fun clearUpNext() {
|
|
||||||
writeUpNext(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeUpNextAt(
|
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
index: Long,
|
index: Long,
|
||||||
) {
|
): Boolean {
|
||||||
if (index !in 0 until snapshot.upNextSize) return
|
if (index !in 0 until snapshot.upNextSize) return false
|
||||||
writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
|
writeUpNextLocked(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeAt(
|
private suspend fun removeAtLocked(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
position: Long,
|
position: Long,
|
||||||
) {
|
): Boolean {
|
||||||
if (snapshot.isUpNext(position)) {
|
if (snapshot.version != queueVersion.get()) return false
|
||||||
removeUpNextAt(snapshot, snapshot.upNextIndex(position))
|
if (snapshot.isUpNext(position)) return removeUpNextAtLocked(snapshot, snapshot.upNextIndex(position))
|
||||||
return
|
if (snapshot.shuffled) return false
|
||||||
}
|
|
||||||
val play = snapshot.contextPlay(position)
|
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))
|
val removed = compact(removeEntry(snapshot.entries, play))
|
||||||
write(removed.map { it.entry })
|
writeLocked(removed.map { it.entry })
|
||||||
adjustAnchorOnRemove(play)
|
adjustAnchorOnRemove(play)
|
||||||
}
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun adjustAnchorOnRemove(removedFlat: Long) =
|
private suspend fun adjustAnchorOnRemove(removedFlat: Long) =
|
||||||
@@ -765,19 +983,21 @@ class QueueRepository(
|
|||||||
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
|
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun move(
|
private suspend fun moveLocked(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
if (snapshot.version != queueVersion.get()) return false
|
||||||
val fromUpNext = snapshot.isUpNext(from)
|
val fromUpNext = snapshot.isUpNext(from)
|
||||||
val toUpNext = snapshot.isUpNext(to)
|
val toUpNext = snapshot.isUpNext(to)
|
||||||
if (fromUpNext && toUpNext) {
|
if (fromUpNext && toUpNext) {
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val located = snapshot.locate(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 removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||||
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)
|
||||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
writeUpNextLocked(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (fromUpNext != toUpNext) return false
|
if (fromUpNext != toUpNext) return false
|
||||||
@@ -789,10 +1009,11 @@ class QueueRepository(
|
|||||||
val playTo = snapshot.contextPlay(to)
|
val playTo = snapshot.contextPlay(to)
|
||||||
val located = snapshot.locateContext(playFrom) ?: return false
|
val located = snapshot.locateContext(playFrom) ?: return false
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
|
val resetItem = resetItemAt(snapshot, from, item = song)
|
||||||
val removed = removeEntry(snapshot.entries, playFrom)
|
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 })
|
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)
|
adjustAnchorOnMove(playFrom, insertAt)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -816,7 +1037,7 @@ class QueueRepository(
|
|||||||
|
|
||||||
private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() }
|
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 ->
|
db.useWriterConnection { transactor ->
|
||||||
transactor.immediateTransaction {
|
transactor.immediateTransaction {
|
||||||
dao.clear()
|
dao.clear()
|
||||||
@@ -826,7 +1047,7 @@ class QueueRepository(
|
|||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
private suspend fun writeUpNextLocked(entries: List<QueueEntry>) {
|
||||||
db.useWriterConnection { transactor ->
|
db.useWriterConnection { transactor ->
|
||||||
transactor.immediateTransaction {
|
transactor.immediateTransaction {
|
||||||
dao.clearUpNext()
|
dao.clearUpNext()
|
||||||
@@ -840,6 +1061,51 @@ class QueueRepository(
|
|||||||
queueVersion.incrementAndGet()
|
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(
|
private suspend fun flatIndexOf(
|
||||||
entries: List<ResolvedQueueEntry>,
|
entries: List<ResolvedQueueEntry>,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -887,7 +1153,14 @@ class QueueRepository(
|
|||||||
for (play in first..last) {
|
for (play in first..last) {
|
||||||
val located = snapshot.locateContext(play) ?: continue
|
val located = snapshot.locateContext(play) ?: continue
|
||||||
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: 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
|
return items
|
||||||
}
|
}
|
||||||
@@ -901,7 +1174,14 @@ class QueueRepository(
|
|||||||
val to = minOf(last, end)
|
val to = minOf(last, end)
|
||||||
val offset = resolved.entry.offset + (from - entryStart)
|
val offset = resolved.entry.offset + (from - entryStart)
|
||||||
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
|
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
|
return items
|
||||||
@@ -924,12 +1204,14 @@ class QueueRepository(
|
|||||||
val to = minOf(last, end)
|
val to = minOf(last, end)
|
||||||
val offset = resolved.entry.offset + (from - entryStart)
|
val offset = resolved.entry.offset + (from - entryStart)
|
||||||
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
|
rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item ->
|
||||||
|
val position = snapshot.anchorPlay + 1 + from + index
|
||||||
items +=
|
items +=
|
||||||
QueueWindowItem(
|
QueueWindowItem(
|
||||||
snapshot.anchorPlay + 1 + from + index,
|
position,
|
||||||
item,
|
item,
|
||||||
playlistPosition = item.playlistPosition,
|
playlistPosition = item.playlistPosition,
|
||||||
upNext = true,
|
upNext = true,
|
||||||
|
resetItem = resetItemAt(snapshot, position, item = item),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,7 +1227,7 @@ class QueueRepository(
|
|||||||
offsetSeeks++
|
offsetSeeks++
|
||||||
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
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) }
|
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||||
if (anchored != null) {
|
if (anchored != null) {
|
||||||
keysetSeeks++
|
keysetSeeks++
|
||||||
@@ -961,7 +1243,7 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun remember(
|
private fun remember(
|
||||||
ref: QueueRef,
|
ref: QueueOrigin,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
resolved: List<SongItem>,
|
resolved: List<SongItem>,
|
||||||
) {
|
) {
|
||||||
@@ -1056,12 +1338,17 @@ class QueueRepository(
|
|||||||
source: QueueEntry,
|
source: QueueEntry,
|
||||||
songId: String,
|
songId: String,
|
||||||
offsetInEntry: Long,
|
offsetInEntry: Long,
|
||||||
|
resetItem: QueueResetItem?,
|
||||||
): QueueEntry =
|
): QueueEntry =
|
||||||
if (source.kind == QueueKind.Album) {
|
if (source.kind == QueueKind.Album) {
|
||||||
val offset = source.offset + offsetInEntry
|
val offset = source.offset + offsetInEntry
|
||||||
source.range(offset, offset)
|
source.range(offset, offset)
|
||||||
} else {
|
} 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(
|
private fun QueueEntry.range(
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ class PlaybackController(
|
|||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val start = randomIndex(snap.contextSize, avoid)
|
val start = randomIndex(snap.contextSize, avoid)
|
||||||
queueRepository.setShuffle(pinnedSeed(snap.contextSize, start))
|
queueRepository.withMutationLock { setShuffle(pinnedSeed(snap.contextSize, start)) }
|
||||||
shuffleEnabled = true
|
shuffleEnabled = true
|
||||||
queueSourceId = entry.sourceId
|
queueSourceId = entry.sourceId
|
||||||
queueRepository.setCursor(0)
|
queueRepository.setCursor(0)
|
||||||
@@ -314,7 +314,7 @@ class PlaybackController(
|
|||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
lastEdit = null
|
lastEdit = null
|
||||||
endedHandled = false
|
endedHandled = false
|
||||||
queueRepository.replace(listOf(entry))
|
queueRepository.withMutationLock { replace(listOf(entry)) }
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
return snapshot
|
return snapshot
|
||||||
@@ -350,7 +350,7 @@ class PlaybackController(
|
|||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
var snapshot = snapshot ?: return@withLock
|
var snapshot = snapshot ?: return@withLock
|
||||||
if (snapshot.size == 0L) return@withLock
|
if (snapshot.size == 0L) return@withLock
|
||||||
val anchor = queueRepository.reanchorFor(snapshot, position)
|
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, position) }
|
||||||
if (anchor.changed) {
|
if (anchor.changed) {
|
||||||
snapshot = readSnapshot()
|
snapshot = readSnapshot()
|
||||||
this@PlaybackController.snapshot = snapshot
|
this@PlaybackController.snapshot = snapshot
|
||||||
@@ -385,11 +385,12 @@ class PlaybackController(
|
|||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
if (position !in 0 until snapshot.size) return@withLock
|
if (position !in 0 until snapshot.size) return@withLock
|
||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
|
if (snapshot.shuffled && !snapshot.isUpNext(position)) return@withLock
|
||||||
val cursor = queueRepository.cursor()
|
val cursor = queueRepository.cursor()
|
||||||
val entries = snapshot.entries.map { it.entry }
|
val entries = snapshot.entries.map { it.entry }
|
||||||
val upNext = snapshot.upNext.map { it.entry }
|
val upNext = snapshot.upNext.map { it.entry }
|
||||||
if (snapshot.size == 1L) {
|
if (snapshot.size == 1L) {
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.withMutationLock { removeAt(snapshot, position) }
|
||||||
stopLocked()
|
stopLocked()
|
||||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||||
return@withLock
|
return@withLock
|
||||||
@@ -398,7 +399,7 @@ class PlaybackController(
|
|||||||
val playing = player.playWhenReady
|
val playing = player.playWhenReady
|
||||||
val wasUpNext = snapshot.isUpNext(current)
|
val wasUpNext = snapshot.isUpNext(current)
|
||||||
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.withMutationLock { removeAt(snapshot, position) }
|
||||||
var updated = readSnapshot()
|
var updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
windowJob?.cancel()
|
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
|
// 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.
|
// track that no longer carries the block (its anchor moved), so re-anchor it there.
|
||||||
if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
|
if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
|
||||||
val reanchored = queueRepository.reanchorFor(updated, target)
|
val reanchored = queueRepository.withMutationLock { reanchorFor(updated, target) }
|
||||||
if (reanchored.changed) {
|
if (reanchored.changed) {
|
||||||
updated = readSnapshot()
|
updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
@@ -506,7 +507,7 @@ class PlaybackController(
|
|||||||
val entries = snapshot.entries.map { it.entry }
|
val entries = snapshot.entries.map { it.entry }
|
||||||
val upNext = snapshot.upNext.map { it.entry }
|
val upNext = snapshot.upNext.map { it.entry }
|
||||||
val wasUpNext = snapshot.isUpNext(current)
|
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()
|
val updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
// The current track still exists, so derive its new position from the edit instead of
|
// 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 player = player ?: return@withLock
|
||||||
val playing = player.playWhenReady
|
val playing = player.playWhenReady
|
||||||
val positionMs = player.currentPositionMs
|
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
|
shuffleEnabled = undo.shuffleSeed != null
|
||||||
val restored = readSnapshot()
|
val restored = readSnapshot()
|
||||||
this.snapshot = restored
|
this.snapshot = restored
|
||||||
@@ -725,7 +726,7 @@ class PlaybackController(
|
|||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
if (!snapshot.isUpNext(position)) return@withLock
|
if (!snapshot.isUpNext(position)) return@withLock
|
||||||
if (position >= snapshot.size - 1 && repeatMode == RepeatMode.Off) 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()
|
val updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
layoutVersion++
|
layoutVersion++
|
||||||
@@ -758,7 +759,7 @@ class PlaybackController(
|
|||||||
player ?: return@withLock
|
player ?: return@withLock
|
||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val wasUpNext = snapshot.isUpNext(current)
|
val wasUpNext = snapshot.isUpNext(current)
|
||||||
queueRepository.clearUpNext()
|
queueRepository.withMutationLock { clearUpNext() }
|
||||||
val updated = readSnapshot()
|
val updated = readSnapshot()
|
||||||
this@PlaybackController.snapshot = updated
|
this@PlaybackController.snapshot = updated
|
||||||
layoutVersion++
|
layoutVersion++
|
||||||
@@ -861,7 +862,7 @@ class PlaybackController(
|
|||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
if (snapshot.size == 0L) return@withLock
|
if (snapshot.size == 0L) return@withLock
|
||||||
val current = currentPosition() ?: 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()
|
val updated = readSnapshot()
|
||||||
this.snapshot = updated
|
this.snapshot = updated
|
||||||
layoutVersion++
|
layoutVersion++
|
||||||
@@ -898,9 +899,9 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
val enabling = !shuffleEnabled
|
val enabling = !shuffleEnabled
|
||||||
if (enabling) {
|
if (enabling) {
|
||||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
|
||||||
} else {
|
} else {
|
||||||
queueRepository.setShuffle(null)
|
queueRepository.withMutationLock { setShuffle(null) }
|
||||||
}
|
}
|
||||||
shuffleEnabled = enabling
|
shuffleEnabled = enabling
|
||||||
val reordered = readSnapshot()
|
val reordered = readSnapshot()
|
||||||
@@ -911,7 +912,7 @@ class PlaybackController(
|
|||||||
enabling -> reordered.combined(0L)
|
enabling -> reordered.combined(0L)
|
||||||
else -> reordered.combined(flat)
|
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
|
val finalSnapshot = if (anchor.changed) readSnapshot() else reordered
|
||||||
if (anchor.changed) layoutVersion++
|
if (anchor.changed) layoutVersion++
|
||||||
this@PlaybackController.snapshot = finalSnapshot
|
this@PlaybackController.snapshot = finalSnapshot
|
||||||
@@ -1028,7 +1029,7 @@ class PlaybackController(
|
|||||||
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
||||||
val position =
|
val position =
|
||||||
if (shuffle) {
|
if (shuffle) {
|
||||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, start)) }
|
||||||
0L
|
0L
|
||||||
} else {
|
} else {
|
||||||
start
|
start
|
||||||
@@ -1052,7 +1053,7 @@ class PlaybackController(
|
|||||||
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
|
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
|
||||||
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
|
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
|
||||||
val flat = snapshot.flatContext(start) ?: start
|
val flat = snapshot.flatContext(start) ?: start
|
||||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
queueRepository.withMutationLock { setShuffle(pinnedSeed(snapshot.contextSize, flat)) }
|
||||||
queueRepository.setCursor(0)
|
queueRepository.setCursor(0)
|
||||||
snapshot = queueRepository.snapshot()
|
snapshot = queueRepository.snapshot()
|
||||||
} else if (!snapshot.shuffled) {
|
} else if (!snapshot.shuffled) {
|
||||||
@@ -1072,7 +1073,7 @@ class PlaybackController(
|
|||||||
this.queueSourceId = queueSourceId
|
this.queueSourceId = queueSourceId
|
||||||
val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
|
val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
|
||||||
val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
|
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) {
|
if (anchor.changed) {
|
||||||
snapshot = queueRepository.snapshot()
|
snapshot = queueRepository.snapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
@@ -1211,7 +1212,7 @@ class PlaybackController(
|
|||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
var snapshot = readSnapshot()
|
var snapshot = readSnapshot()
|
||||||
if (snapshot.size == 0L) return@withLock
|
if (snapshot.size == 0L) return@withLock
|
||||||
val anchor = queueRepository.reanchorFor(snapshot, target)
|
val anchor = queueRepository.withMutationLock { reanchorFor(snapshot, target) }
|
||||||
if (anchor.changed) {
|
if (anchor.changed) {
|
||||||
snapshot = readSnapshot()
|
snapshot = readSnapshot()
|
||||||
layoutVersion++
|
layoutVersion++
|
||||||
@@ -1490,7 +1491,7 @@ class PlaybackController(
|
|||||||
private suspend fun readSnapshot(): QueueSnapshot {
|
private suspend fun readSnapshot(): QueueSnapshot {
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
if (!snapshot.shuffled) {
|
if (!snapshot.shuffled) {
|
||||||
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(null)
|
if (shuffleEnabled && snapshot.size > 0L) queueRepository.withMutationLock { setShuffle(null) }
|
||||||
shuffleEnabled = false
|
shuffleEnabled = false
|
||||||
}
|
}
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|||||||
@@ -313,14 +313,14 @@ class DownloadRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() =
|
fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() =
|
||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
runBlocking { repository.download(1, "s1") }
|
runBlocking { repository.download(1, "s1") }
|
||||||
|
|
||||||
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
||||||
|
|
||||||
assertNull(row(1, "s1"))
|
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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.PlaylistSong
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -79,6 +84,20 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s1", "s3"), resolveAll(snapshot))
|
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
|
@Test
|
||||||
fun starredEntryResolvesStarredTracksNewestFirst() =
|
fun starredEntryResolvesStarredTracksNewestFirst() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -416,28 +435,427 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(4, repository.snapshot().size)
|
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
|
@Test
|
||||||
fun upNextResolvesAfterTheCurrentTrack() =
|
fun upNextResolvesAfterTheCurrentTrack() =
|
||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()
|
val updated = repository.snapshot()
|
||||||
assertEquals(4, updated.size)
|
assertEquals(4, updated.size)
|
||||||
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
|
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
|
@Test
|
||||||
fun playNextGoesToTheFrontOfTheUpNextQueue() =
|
fun playNextGoesToTheFrontOfTheUpNextQueue() =
|
||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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.addUpNext(
|
||||||
repository.snapshot(),
|
|
||||||
currentPosition = 0,
|
currentPosition = 0,
|
||||||
entry = repository.songEntry(1, "s5"),
|
entry = repository.songEntry(1, "s5"),
|
||||||
playNext = true,
|
playNext = true,
|
||||||
@@ -451,7 +869,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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)
|
repository.removeAt(repository.snapshot(), 1)
|
||||||
|
|
||||||
@@ -465,9 +883,8 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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.addUpNext(
|
||||||
repository.snapshot(),
|
|
||||||
currentPosition = 0,
|
currentPosition = 0,
|
||||||
entry = repository.songEntry(1, "s5"),
|
entry = repository.songEntry(1, "s5"),
|
||||||
playNext = false,
|
playNext = false,
|
||||||
@@ -486,7 +903,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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)
|
repository.move(repository.snapshot(), from = 2, to = 1)
|
||||||
|
|
||||||
@@ -509,9 +926,8 @@ class QueueRepositoryTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
|
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.addUpNext(
|
||||||
repository.snapshot(),
|
|
||||||
currentPosition = 0,
|
currentPosition = 0,
|
||||||
entry = repository.songEntry(1, "x1"),
|
entry = repository.songEntry(1, "x1"),
|
||||||
playNext = false,
|
playNext = false,
|
||||||
@@ -562,7 +978,7 @@ class QueueRepositoryTest {
|
|||||||
val shuffled = repository.snapshot()
|
val shuffled = repository.snapshot()
|
||||||
val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id }
|
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()
|
val updated = repository.snapshot()
|
||||||
assertEquals(4, updated.size)
|
assertEquals(4, updated.size)
|
||||||
@@ -580,7 +996,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()))
|
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
repository.removeAt(repository.snapshot(), 2)
|
repository.removeAt(repository.snapshot(), 2)
|
||||||
@@ -593,7 +1009,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()))
|
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
repository.removeAt(repository.snapshot(), 0)
|
repository.removeAt(repository.snapshot(), 0)
|
||||||
@@ -606,7 +1022,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()))
|
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
repository.removeAt(repository.snapshot(), 2)
|
repository.removeAt(repository.snapshot(), 2)
|
||||||
@@ -621,7 +1037,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()))
|
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
repository.removeAt(repository.snapshot(), 0)
|
repository.removeAt(repository.snapshot(), 0)
|
||||||
@@ -634,7 +1050,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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")))
|
repository.replace(listOf(repository.songEntry(1, "s5")))
|
||||||
|
|
||||||
@@ -783,98 +1199,22 @@ class QueueRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() =
|
fun removingAContextTrackWhileShuffledIsRejected() =
|
||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||||
repository.setShuffle(42L)
|
repository.setShuffle(42L)
|
||||||
val before = repository.snapshot()
|
val before = repository.snapshot()
|
||||||
|
val entriesBefore = before.entries.map { it.entry }
|
||||||
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
|
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
|
||||||
|
|
||||||
repository.removeAt(before, 1)
|
repository.removeAt(before, 1)
|
||||||
|
|
||||||
val after = repository.snapshot()
|
val after = repository.snapshot()
|
||||||
assertTrue(after.shuffled)
|
assertTrue(after.shuffled)
|
||||||
assertEquals(2L, after.contextSize)
|
assertEquals(before.contextSize, after.contextSize)
|
||||||
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
|
assertEquals(entriesBefore, after.entries.map { it.entry })
|
||||||
assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter)
|
assertEquals(orderBefore, (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id })
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -932,7 +1272,7 @@ class QueueRepositoryTest {
|
|||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()
|
val updated = repository.snapshot()
|
||||||
assertEquals(1L, updated.anchorPlay)
|
assertEquals(1L, updated.anchorPlay)
|
||||||
@@ -947,13 +1287,13 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
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()
|
val before = repository.snapshot()
|
||||||
assertEquals(0L, before.anchorPlay)
|
assertEquals(0L, before.anchorPlay)
|
||||||
assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
|
assertEquals(3L, repository.combinedContextIndexOf(before, "s3"))
|
||||||
|
|
||||||
repository.setUpNextAnchor(2)
|
repository.reanchorFor(before, 3)
|
||||||
|
|
||||||
val after = repository.snapshot()
|
val after = repository.snapshot()
|
||||||
assertEquals(2L, after.anchorPlay)
|
assertEquals(2L, after.anchorPlay)
|
||||||
@@ -966,7 +1306,7 @@ class QueueRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4")))
|
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()
|
val updated = repository.snapshot()
|
||||||
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
|
assertEquals(0L, repository.combinedContextIndexOf(updated, "s4"))
|
||||||
@@ -1105,6 +1445,16 @@ class QueueRepositoryTest {
|
|||||||
created = 0,
|
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() {
|
private suspend fun seedLibrary() {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class SyncManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aFailedSyncStillInvalidatesTheLibraryCache() =
|
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val before = queueRepository.snapshot().version
|
val before = queueRepository.snapshot().version
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ class SyncManagerTest {
|
|||||||
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
assertTrue(status is SyncStatus.Failed)
|
assertTrue(status is SyncStatus.Failed)
|
||||||
assertEquals(before + 1, queueRepository.snapshot().version)
|
assertEquals(before, queueRepository.snapshot().version)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import com.subtracks.data.prefs.fakeUserPreferences
|
|||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
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.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -55,7 +58,6 @@ import okhttp3.OkHttpClient
|
|||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -2116,40 +2118,28 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() {
|
fun removingATrackWhileShuffledIsRejected() {
|
||||||
seedAlbum(6, sourceId = 1)
|
seedAlbum(6, sourceId = 1)
|
||||||
|
|
||||||
controller.playAlbum(1, "al1", 2)
|
controller.playAlbum(1, "al1", 2)
|
||||||
await {
|
await {
|
||||||
controller.state.value.item
|
controller.state.value.item
|
||||||
?.id == "s3"
|
?.id == "s3"
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.toggleShuffle()
|
controller.toggleShuffle()
|
||||||
await { controller.state.value.shuffle }
|
await { controller.state.value.shuffle }
|
||||||
val currentId =
|
val before = runBlocking { queues.snapshot() }
|
||||||
controller.state.value.item!!
|
val itemsBefore = handle.items.map { it.id }
|
||||||
.id
|
|
||||||
val removeAt = (controller.state.value.position!! + 1) % 6
|
|
||||||
|
|
||||||
runBlocking { controller.removeAt(removeAt) }
|
runBlocking { controller.removeAt((controller.state.value.position!! + 1) % 6) }
|
||||||
await {
|
|
||||||
controller.state.value.item
|
|
||||||
?.id == currentId && controller.state.value.shuffle
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(handle.items.map { it.id }.contains(currentId))
|
val after = runBlocking { queues.snapshot() }
|
||||||
val remaining =
|
assertEquals(before.contextSize, after.contextSize)
|
||||||
runBlocking {
|
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||||
val snap = queues.snapshot()
|
assertEquals(itemsBefore, handle.items.map { it.id })
|
||||||
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
|
|
||||||
}
|
|
||||||
assertEquals(5, remaining.size)
|
|
||||||
assertEquals(5, remaining.distinct().size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() {
|
fun removingThePlayingTrackWhileShuffledLeavesTheBlockUnchanged() {
|
||||||
seedAlbum(6, sourceId = 1)
|
seedAlbum(6, sourceId = 1)
|
||||||
seedSong("x1", "al2")
|
seedSong("x1", "al2")
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -2161,30 +2151,24 @@ class PlaybackControllerTest {
|
|||||||
await { controller.state.value.item != null && controller.state.value.shuffle }
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||||
await { runBlocking { controller.upcomingItem()?.id } == "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(controller.state.value.position!!) }
|
||||||
runBlocking { controller.removeAt(current) }
|
|
||||||
|
|
||||||
// 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() }
|
val after = runBlocking { queues.snapshot() }
|
||||||
assertTrue(after.shuffled)
|
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||||
assertEquals(
|
assertEquals(before.upNextAnchor, after.upNextAnchor)
|
||||||
"x1",
|
assertEquals(1L, after.upNextSize)
|
||||||
runBlocking { queues.itemAt(after, controller.state.value.position!!)?.song?.id },
|
assertEquals(currentId, handle.currentItem?.id)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingTheAnchorWhilePlayingABlockTrackKeepsTheBlockInPlace() {
|
fun removingTheAnchorWhilePlayingABlockTrackIsRejected() {
|
||||||
seedShuffledQueue(seed = 5L)
|
seedShuffledQueue(seed = 5L)
|
||||||
seedSong("x1", "al2")
|
seedSong("x1", "al2")
|
||||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||||
|
|
||||||
controller.playAt(1)
|
controller.playAt(1)
|
||||||
await {
|
await {
|
||||||
controller.state.value.item
|
controller.state.value.item
|
||||||
@@ -2195,8 +2179,9 @@ class PlaybackControllerTest {
|
|||||||
runBlocking { controller.removeAt(before.anchorPlay) }
|
runBlocking { controller.removeAt(before.anchorPlay) }
|
||||||
|
|
||||||
val after = runBlocking { queues.snapshot() }
|
val after = runBlocking { queues.snapshot() }
|
||||||
assertEquals(-1L, after.anchorPlay)
|
assertEquals(before.upNextAnchor, after.upNextAnchor)
|
||||||
assertEquals("x1", runBlocking { queues.itemAt(after, 0)?.song?.id })
|
assertEquals(before.contextSize, after.contextSize)
|
||||||
|
assertEquals("x1", runBlocking { queues.itemAt(after, 1)?.song?.id })
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"x1",
|
"x1",
|
||||||
controller.state.value.item
|
controller.state.value.item
|
||||||
@@ -2205,20 +2190,18 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingThePlayingTrackWhileShuffledPlaysTheNextShuffledTrack() {
|
fun removingThePlayingTrackWhileShuffledDoesNotAdvance() {
|
||||||
seedShuffledQueue(seed = 12345L)
|
seedShuffledQueue(seed = 12345L)
|
||||||
val orderBefore = resolvedIds()
|
val orderBefore = resolvedIds()
|
||||||
val current = controller.state.value.position!!
|
val currentId = handle.currentItem?.id
|
||||||
val expected = orderBefore.toMutableList().apply { removeAt(current.toInt()) }[current.toInt()]
|
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() }
|
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
|
@Test
|
||||||
@@ -2275,73 +2258,6 @@ class PlaybackControllerTest {
|
|||||||
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id })
|
assertEquals("x1", runBlocking { queues.itemAt(after, after.anchorPlay + 2)?.song?.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun 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
|
@Test
|
||||||
fun movingContextTracksWhileShuffledIsIgnored() {
|
fun movingContextTracksWhileShuffledIsIgnored() {
|
||||||
seedAlbum(6, sourceId = 1)
|
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
|
@Test
|
||||||
fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
|
fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
|
||||||
seedAlbum(5, sourceId = 1)
|
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.DownloadRepository
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
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.FakePlayerConnection
|
||||||
import com.subtracks.playback.FakePlayerHandle
|
import com.subtracks.playback.FakePlayerHandle
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
|||||||
@@ -84,13 +84,15 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
|
|||||||
|
|
||||||
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
|
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
|
||||||
|
|
||||||
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Reordering the context while shuffled would need that order back, so the context is playback-only while shuffled and manual ordering goes through the up-next block.
|
All queue and player-window mutations run through one `QueueMutationScope`. Snapshot reads, edits, sync/download resets, Undo and playback reconciliation share that lock, and reset reconciliation is invoked synchronously before the reset operation returns; there is no asynchronous reset collector. Content revisions reject stale writes, a separate reset generation rejects row operations created before a canonical reset, and a library revision only controls resolution caches. Navigation computes its target inside the mutation operation, while position-based UI edits carry their reset generation from operation creation. External initialization waits, including download-state loading, stay outside the mutation lock.
|
||||||
|
|
||||||
|
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. Every entry also retains its origin context, so explicit songs created by rearranges can later be merged back into the album, playlist, artist or genre they came from. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Context removal and reordering are disabled while shuffled; manual ordering and deletion still work in the up-next block and in non-shuffled contexts.
|
||||||
|
|
||||||
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
||||||
|
|
||||||
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
||||||
|
|
||||||
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index while retaining its origin context, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
||||||
|
|
||||||
Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point.
|
Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point.
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ Songs have a primary and a fallback fetch. The primary probes `search3` with an
|
|||||||
|
|
||||||
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
||||||
|
|
||||||
Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. The one hazard is a scan that stops early — a server returning a short page by mistake, or a library larger than the page cap — because the prune trusts that the stream actually reached the end. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable.
|
Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. Changed origins are merged only after their batch transaction commits and are persisted per source after failures, so the next successful sync includes them even after a process restart. A successful sync reports the origins it actually changed and collapses only those context and up-next blocks back to their canonical references; unrelated origins and failed syncs leave local edits alone. The current item and up-next anchor are remapped by origin, song ID and position as a best effort, without promising exact duplicate-occurrence identity. If the current song disappeared it finishes in a detached slot before the refreshed queue continues at a deterministic clamped position. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable.
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user