persist and restore the playback position within the track

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 89376cef86
commit 20ab30920f
8 changed files with 884 additions and 5 deletions
@@ -65,4 +65,11 @@ val MIGRATION_5_6 =
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
val MIGRATION_6_7 =
object : Migration(6, 7) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `positionMs` INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 6,
version = 7,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -211,6 +211,7 @@ data class PlaybackCursor(
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
@ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0,
@ColumnInfo(defaultValue = "0") val positionMs: Long = 0,
)
@Entity(tableName = "shuffle_order")
@@ -270,6 +270,10 @@ class QueueRepository(
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
suspend fun cursorPositionMs(): Long = dao.cursor()?.positionMs ?: 0
suspend fun setPosition(positionMs: Long) = dao.setCursor(cursorRow().copy(positionMs = positionMs.coerceAtLeast(0)))
suspend fun setCursor(position: Long) = dao.setCursor(cursorRow().copy(queuePosition = position))
private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0)
@@ -123,6 +123,7 @@ class PlaybackController(
private var shuffleEnabled = false
private var repeatMode = RepeatMode.Off
private var endedHandled = false
private var lastSavedPositionMs = 0L
init {
scope.launch {
@@ -463,7 +464,12 @@ class PlaybackController(
return
}
player.ensurePrepared()
if (player.playWhenReady) player.pause() else player.play()
if (player.playWhenReady) {
player.pause()
savePosition()
} else {
player.play()
}
}
fun next() {
@@ -550,6 +556,8 @@ class PlaybackController(
fun seekTo(positionMs: Long) {
player?.seekTo(positionMs)
lastSavedPositionMs = positionMs
scope.launch { queueRepository.setPosition(positionMs) }
}
fun coverArt(
@@ -648,7 +656,8 @@ class PlaybackController(
}
this.queueSourceId = queueSourceId
val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
loadWindow(position, autoplay = false)
lastSavedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
loadWindow(position, autoplay = false, startPositionMs = lastSavedPositionMs)
}
private suspend fun stop() = startLock.withLock { stopLocked() }
@@ -723,6 +732,7 @@ class PlaybackController(
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
lastSavedPositionMs = 0
queueRepository.setCursor(position)
refresh(position)
scheduleWindowShift(position)
@@ -919,11 +929,23 @@ class PlaybackController(
while (true) {
delay(POSITION_TICK_MS)
val player = player ?: break
_state.value = _state.value.copy(positionMs = player.currentPositionMs)
val positionMs = player.currentPositionMs
_state.value = _state.value.copy(positionMs = positionMs)
if (positionMs - lastSavedPositionMs >= POSITION_SAVE_INTERVAL_MS || positionMs < lastSavedPositionMs) {
lastSavedPositionMs = positionMs
queueRepository.setPosition(positionMs)
}
}
}
}
private fun savePosition() {
val player = player ?: return
val positionMs = player.currentPositionMs
lastSavedPositionMs = positionMs
scope.launch { queueRepository.setPosition(positionMs) }
}
private fun stopPositionTicker() {
positionJob?.cancel()
positionJob = null
@@ -935,6 +957,7 @@ class PlaybackController(
private companion object {
const val POSITION_TICK_MS = 500L
const val POSITION_SAVE_INTERVAL_MS = 15_000L
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
const val QUEUE_WINDOW_RADIUS = 25L
const val WINDOW_SHIFT_DELAY_MS = 400L
@@ -70,6 +70,7 @@ class FakePlayerHandle : PlayerHandle {
this.items.clear()
this.items += items
index = startIndex
positionMs = startPositionMs
idle = true
ended = false
buffering = false
@@ -905,6 +905,33 @@ class PlaybackControllerTest {
assertTrue(controller.state.value.shuffle)
}
@Test
fun restoringResumesFromTheSavedPosition() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
restored.connect()
await {
restored.state.value.item
?.id == "s1"
}
assertEquals(12_000L, restored.state.value.positionMs)
assertEquals(
"setWindow(size=3, start=0, position=12000)",
restoredHandle.operations.first { it.startsWith("setWindow") },
)
restored.close()
}
private fun seedAlbum(
count: Int,
sourceId: Long,