address second review: shuffle cache invalidation, locking, ended latch

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 2c333195ef
commit b2f654e10f
7 changed files with 78 additions and 34 deletions
@@ -109,11 +109,8 @@ class QueueRepository(
} }
} }
val (entries, order, shuffled) = loaded val (entries, order, shuffled) = loaded
if (shuffled && order == null) { val valid = if (shuffled && order == null) null else order
setShuffle(false, null) return QueueSnapshot(entries, valid, queueVersion)
return QueueSnapshot(entries, null, queueVersion)
}
return QueueSnapshot(entries, order, queueVersion)
} }
suspend fun modes(): QueueModes { suspend fun modes(): QueueModes {
@@ -126,15 +123,24 @@ class QueueRepository(
order: LongArray?, order: LongArray?,
) { ) {
val seed = if (enabled && order != null) System.nanoTime() else 0L val seed = if (enabled && order != null) System.nanoTime() else 0L
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clearShuffleOrder() dao.clearShuffleOrder()
if (enabled && order != null) { if (enabled && order != null) {
dao.insertShuffleOrder(order.mapIndexed { index, flat -> ShuffleOrder(index.toLong(), flat) }) dao.insertShuffleOrder(order.mapIndexed { index, flat -> ShuffleOrder(index.toLong(), flat) })
} }
dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleSeed = seed)) dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleSeed = seed))
}
}
cachedShuffleSeed = 0 cachedShuffleSeed = 0
cachedShuffleOrder = null cachedShuffleOrder = null
} }
fun invalidateLibraryCache() {
cachedFlatIds = null
queueVersion++
}
suspend fun setRepeat(mode: Int) = dao.setCursor(cursorRow().copy(repeatMode = mode)) suspend fun setRepeat(mode: Int) = dao.setCursor(cursorRow().copy(repeatMode = mode))
private suspend fun loadShuffleOrder( private suspend fun loadShuffleOrder(
@@ -271,7 +277,7 @@ class QueueRepository(
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> { private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
val cached = cachedFlatIds val cached = cachedFlatIds
if (cached != null && cachedFlatVersion == snapshot.version) return cached if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.size) return cached
val ids = ArrayList<String>(snapshot.size.toInt()) val ids = ArrayList<String>(snapshot.size.toInt())
for (resolved in snapshot.entries) { for (resolved in snapshot.entries) {
val entry = resolved.entry val entry = resolved.entry
@@ -1,6 +1,7 @@
package com.subtracks.data.sync package com.subtracks.data.sync
import android.util.Log import android.util.Log
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -23,6 +24,7 @@ sealed interface SyncStatus {
class SyncManager( class SyncManager(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle) private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
@@ -36,7 +38,10 @@ class SyncManager(
_status.value = SyncStatus.Running _status.value = SyncStatus.Running
_status.value = _status.value =
sourceRepository.sync().fold( sourceRepository.sync().fold(
onSuccess = { SyncStatus.Success }, onSuccess = {
queueRepository.invalidateLibraryCache()
SyncStatus.Success
},
onFailure = { error -> onFailure = { error ->
Log.w(TAG, "Sync failed", error) Log.w(TAG, "Sync failed", error)
SyncStatus.Failed(error.message ?: "Sync failed") SyncStatus.Failed(error.message ?: "Sync failed")
@@ -34,7 +34,7 @@ fun appModule(
single { SourceRepository(get(), get(), get()) } single { SourceRepository(get(), get(), get()) }
single { LibraryRepository(get(), get()) } single { LibraryRepository(get(), get()) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) } single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get()) } single { SyncManager(get(), get()) }
single { QueueRepository(get()) } single { QueueRepository(get()) }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
single { PlaybackController(get(), get(), get()) } single { PlaybackController(get(), get(), get()) }
@@ -537,7 +537,15 @@ class PlaybackController(
shuffleEnabled = modes.shuffle shuffleEnabled = modes.shuffle
repeatMode = modes.repeat.toRepeatMode() repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One) player?.setRepeatOne(repeatMode == RepeatMode.One)
val snapshot = readSnapshot() var snapshot = queueRepository.snapshot()
if (shuffleEnabled && !snapshot.shuffled && snapshot.size > 0L) {
val start = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, start))
queueRepository.setCursor(0)
snapshot = queueRepository.snapshot()
} else if (!snapshot.shuffled) {
shuffleEnabled = false
}
this.snapshot = snapshot this.snapshot = snapshot
val queueSourceId = val queueSourceId =
snapshot.entries snapshot.entries
@@ -600,17 +608,18 @@ class PlaybackController(
private fun jumpTo(target: Long) { private fun jumpTo(target: Long) {
scope.launch { scope.launch {
startLock.withLock {
val snapshot = readSnapshot() val snapshot = readSnapshot()
this@PlaybackController.snapshot = snapshot this@PlaybackController.snapshot = snapshot
if (snapshot.size == 0L) return@launch if (snapshot.size == 0L) return@withLock
val current = currentPosition() ?: return@launch val current = currentPosition() ?: return@withLock
val dest = target.coerceIn(0, snapshot.size - 1) val dest = target.coerceIn(0, snapshot.size - 1)
val wasEnded = player?.isEnded == true val wasEnded = player?.isEnded == true
if (dest == current && !wasEnded) return@launch if (dest == current && !wasEnded) return@withLock
windowJob?.cancel() windowJob?.cancel()
queueRepository.setCursor(dest) queueRepository.setCursor(dest)
shiftWindow(dest) shiftWindowLocked(dest)
val player = player ?: return@launch val player = player ?: return@withLock
player.ensurePrepared() player.ensurePrepared()
player.seekToIndex((dest - windowStart).toInt()) player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play() if (wasEnded) player.play()
@@ -618,6 +627,7 @@ class PlaybackController(
refresh(dest) refresh(dest)
} }
} }
}
private suspend fun onPositionChanged() { private suspend fun onPositionChanged() {
val player = player ?: return val player = player ?: return
@@ -702,7 +712,7 @@ class PlaybackController(
} }
override fun onEvents() { override fun onEvents() {
if (player?.isEnded == true) handleEnded() if (player?.isEnded == true) handleEnded() else endedHandled = false
refresh() refresh()
} }
@@ -730,7 +740,10 @@ class PlaybackController(
private suspend fun readSnapshot(): QueueSnapshot { private suspend fun readSnapshot(): QueueSnapshot {
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
if (!snapshot.shuffled) shuffleEnabled = false if (!snapshot.shuffled) {
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, null)
shuffleEnabled = false
}
return snapshot return snapshot
} }
@@ -250,7 +250,7 @@ fun QueueRoute(
sourceRepository: SourceRepository = koinInject(), sourceRepository: SourceRepository = koinInject(),
) { ) {
val playback by controller.state.collectAsStateWithLifecycle() val playback by controller.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.open() } LaunchedEffect(playback.shuffle) { viewModel.open() }
QueueScreen( QueueScreen(
rows = viewModel.rows, rows = viewModel.rows,
ready = viewModel.ready, ready = viewModel.ready,
@@ -321,7 +321,7 @@ class QueueRepositoryTest {
(0 until snapshot.size).mapNotNull { repository.itemAt(snapshot, it)?.song?.id } (0 until snapshot.size).mapNotNull { repository.itemAt(snapshot, it)?.song?.id }
@Test @Test
fun aStaleShuffleOrderIsDroppedWhenTheQueueLengthChanges() = fun aStaleShuffleOrderIsIgnoredWhenTheQueueLengthChanges() =
runTest { runTest {
seedLibrary() seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1"))) repository.replace(listOf(repository.albumEntry(1, "al1")))
@@ -331,7 +331,6 @@ class QueueRepositoryTest {
db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album"))) db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album")))
assertFalse(repository.snapshot().shuffled) assertFalse(repository.snapshot().shuffled)
assertFalse(repository.modes().shuffle)
} }
private suspend fun seedLibrary() { private suspend fun seedLibrary() {
@@ -819,6 +819,27 @@ class PlaybackControllerTest {
) )
} }
@Test
fun repeatAllLoopsAfterEndingWithRepeatOff() {
seedAlbum(2, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { controller.state.value.isPlaying }
handle.finish()
handle.emitEvents()
await { !controller.state.value.isPlaying }
controller.togglePlayPause()
await { controller.state.value.isPlaying }
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
handle.finish()
handle.emitEvents()
await { controller.state.value.isPlaying }
}
private fun seedAlbum( private fun seedAlbum(
count: Int, count: Int,
sourceId: Long, sourceId: Long,