mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
Album and artist context menus gain an Info dialog covering the plural album artists, sort names and the play and download details the screens do not show. Multiple artist links collapse into one ellipsised entry that opens the artist chooser instead of listing each artist. Sort lists can now prefer regular names over sort names, or only use sort names for names without Latin letters. The new plain-name indexes keep those orders out of a temp B-tree. Also carry plural artists through the Media3 queue so Now Playing and the mini player keep them, drop the album artist tap ripple, and show the aggregate download progress in the tab bar where offline mode used to sit.
1623 lines
60 KiB
Kotlin
1623 lines
60 KiB
Kotlin
package com.subtracks.playback
|
|
|
|
import com.subtracks.UiMessage
|
|
import com.subtracks.data.model.ArtistRef
|
|
import com.subtracks.data.model.AudioEncoding
|
|
import com.subtracks.data.model.CoverArtRef
|
|
import com.subtracks.data.model.QueueEntry
|
|
import com.subtracks.data.model.QueueKind
|
|
import com.subtracks.data.repo.DownloadRepository
|
|
import com.subtracks.data.repo.QueueRepository
|
|
import com.subtracks.data.repo.QueueSnapshot
|
|
import com.subtracks.data.repo.QueueWindowItem
|
|
import com.subtracks.data.repo.Shuffle
|
|
import com.subtracks.data.repo.SourceRepository
|
|
import com.subtracks.log.Log
|
|
import kotlinx.coroutines.CancellationException
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.drop
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
import kotlinx.coroutines.withContext
|
|
import kotlin.random.Random
|
|
|
|
data class QueueItem(
|
|
val id: String,
|
|
val title: String,
|
|
val artist: String?,
|
|
val album: String?,
|
|
val coverArtId: String?,
|
|
val durationMs: Long? = null,
|
|
val replayGain: ReplayGain = ReplayGain(),
|
|
val albumContext: Boolean = false,
|
|
val artists: List<ArtistRef> = emptyList(),
|
|
)
|
|
|
|
data class QueueContext(
|
|
val kind: QueueKind,
|
|
val sourceId: Long,
|
|
val refId: String,
|
|
)
|
|
|
|
enum class RepeatMode { Off, All, One }
|
|
|
|
data class PlaybackState(
|
|
val item: QueueItem? = null,
|
|
val context: QueueContext? = null,
|
|
val position: Long? = null,
|
|
val isBuffering: Boolean = false,
|
|
val isPlaying: Boolean = false,
|
|
val durationMs: Long = 0,
|
|
val hasNext: Boolean = false,
|
|
val hasPrevious: Boolean = false,
|
|
val shuffle: Boolean = false,
|
|
val repeat: RepeatMode = RepeatMode.Off,
|
|
val layout: Long = 0,
|
|
)
|
|
|
|
private sealed interface QueueUndo {
|
|
val entries: List<QueueEntry>
|
|
val cursor: Long
|
|
val current: Long?
|
|
val shuffleSeed: Long?
|
|
val upNext: List<QueueEntry>
|
|
val upNextAnchor: Long
|
|
}
|
|
|
|
private data class RemovedUndo(
|
|
override val entries: List<QueueEntry>,
|
|
override val cursor: Long,
|
|
override val current: Long,
|
|
val position: Long,
|
|
val item: QueueItem?,
|
|
override val shuffleSeed: Long?,
|
|
override val upNext: List<QueueEntry>,
|
|
override val upNextAnchor: Long,
|
|
) : QueueUndo
|
|
|
|
private data class MovedUndo(
|
|
override val entries: List<QueueEntry>,
|
|
override val cursor: Long,
|
|
override val current: Long,
|
|
val from: Long,
|
|
val to: Long,
|
|
override val shuffleSeed: Long?,
|
|
override val upNext: List<QueueEntry>,
|
|
override val upNextAnchor: Long,
|
|
) : QueueUndo
|
|
|
|
private data class ReloadUndo(
|
|
override val entries: List<QueueEntry>,
|
|
override val cursor: Long,
|
|
override val shuffleSeed: Long?,
|
|
override val upNext: List<QueueEntry>,
|
|
override val upNextAnchor: Long,
|
|
) : QueueUndo {
|
|
override val current: Long? = null
|
|
}
|
|
|
|
private data class PendingPlay(
|
|
val entries: List<QueueEntry>,
|
|
val position: Long,
|
|
val disableShuffle: Boolean,
|
|
)
|
|
|
|
class PlaybackController(
|
|
private val sourceRepository: SourceRepository,
|
|
private val queueRepository: QueueRepository,
|
|
private val connection: PlayerConnection,
|
|
private val downloads: DownloadRepository,
|
|
private val showMessage: (UiMessage) -> Unit = {},
|
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
|
) {
|
|
private val _state = MutableStateFlow(PlaybackState())
|
|
val state: StateFlow<PlaybackState> = _state
|
|
|
|
private val _positionMs = MutableStateFlow(0L)
|
|
val positionMs: StateFlow<Long> = _positionMs
|
|
|
|
fun currentAudioEncoding(): AudioEncoding? = player?.audioEncoding
|
|
|
|
private var player: PlayerHandle? = null
|
|
private var connecting = false
|
|
private var pendingPlay: PendingPlay? = null
|
|
private var pendingShuffle: List<QueueEntry>? = null
|
|
private var positionJob: Job? = null
|
|
private var windowJob: Job? = null
|
|
private var bufferingJob: Job? = null
|
|
private var stallJob: Job? = null
|
|
private var showBuffering = false
|
|
private var stalled = false
|
|
private val startLock = Mutex()
|
|
|
|
private var snapshot: QueueSnapshot? = null
|
|
private var queueSourceId: Long? = null
|
|
private var windowStart = 0L
|
|
private var windowEnd = -1L
|
|
private var lastPosition: Long? = null
|
|
private var layoutVersion = 0L
|
|
private var updating = false
|
|
private var lastEdit: QueueUndo? = null
|
|
private var shuffleEnabled = false
|
|
private var repeatMode = RepeatMode.Off
|
|
private var endedHandled = false
|
|
private var lastSavedPositionMs = 0L
|
|
private val offline: Boolean get() = sourceRepository.offline.value
|
|
private var windowPositions = LongArray(0)
|
|
private var windowFiltered = false
|
|
|
|
init {
|
|
scope.launch {
|
|
sourceRepository.activeSourceId().collect { sourceId ->
|
|
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
|
}
|
|
}
|
|
scope.launch {
|
|
sourceRepository.offline.collect { reloadForOffline() }
|
|
}
|
|
scope.launch {
|
|
sourceRepository.quality.drop(1).collect {
|
|
try {
|
|
reloadQuality()
|
|
} catch (e: CancellationException) {
|
|
throw e
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private val _ready = MutableStateFlow(false)
|
|
val ready: StateFlow<Boolean> = _ready
|
|
|
|
fun connect() {
|
|
if (player != null || connecting) return
|
|
connecting = true
|
|
connection.connect(
|
|
onConnected = { handle ->
|
|
connecting = false
|
|
player = handle
|
|
handle.addListener(playerListener)
|
|
scope.launch {
|
|
val pending = pendingPlay
|
|
pendingPlay = null
|
|
val shuffle = pendingShuffle
|
|
pendingShuffle = null
|
|
when {
|
|
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
|
shuffle != null -> startShuffled(shuffle)
|
|
else -> restore()
|
|
}
|
|
_ready.value = true
|
|
}
|
|
},
|
|
onDisconnected = {
|
|
player = null
|
|
connecting = false
|
|
scope.launch { stop() }
|
|
_ready.value = true
|
|
},
|
|
)
|
|
}
|
|
|
|
fun playAlbum(
|
|
sourceId: Long,
|
|
albumId: String,
|
|
startOrdinal: Long,
|
|
) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
|
|
|
fun playSong(
|
|
sourceId: Long,
|
|
songId: String,
|
|
) = playContext(queueRepository.songEntry(sourceId, songId), 0)
|
|
|
|
fun playPlaylist(
|
|
sourceId: Long,
|
|
playlistId: String,
|
|
startPosition: Long,
|
|
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
|
|
|
fun playGenre(
|
|
sourceId: Long,
|
|
genre: String,
|
|
startOrdinal: Long,
|
|
) = playContext(queueRepository.genreEntry(sourceId, genre), startOrdinal)
|
|
|
|
fun playDownloaded(
|
|
sourceId: Long,
|
|
startOrdinal: Long,
|
|
) = playContext(queueRepository.downloadedEntry(sourceId), startOrdinal)
|
|
|
|
fun playStarred(
|
|
sourceId: Long,
|
|
startOrdinal: Long,
|
|
) = playContext(queueRepository.starredEntry(sourceId), startOrdinal)
|
|
|
|
fun playAlbumInOrder(
|
|
sourceId: Long,
|
|
albumId: String,
|
|
) = playContext(queueRepository.albumEntry(sourceId, albumId), 0, disableShuffle = true)
|
|
|
|
fun playPlaylistInOrder(
|
|
sourceId: Long,
|
|
playlistId: String,
|
|
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), 0, disableShuffle = true)
|
|
|
|
fun shuffleAlbum(
|
|
sourceId: Long,
|
|
albumId: String,
|
|
) = shuffleContext(queueRepository.albumEntry(sourceId, albumId))
|
|
|
|
fun shufflePlaylist(
|
|
sourceId: Long,
|
|
playlistId: String,
|
|
) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId))
|
|
|
|
fun shuffleArtist(
|
|
sourceId: Long,
|
|
artistId: String,
|
|
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
|
|
|
fun shuffleGenre(
|
|
sourceId: Long,
|
|
genre: String,
|
|
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
|
|
|
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
|
|
|
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
|
|
|
private fun playContext(
|
|
entry: QueueEntry,
|
|
start: Long,
|
|
disableShuffle: Boolean = false,
|
|
) = play(listOf(entry), start, disableShuffle)
|
|
|
|
private fun shuffleContext(entry: QueueEntry) = shufflePlay(listOf(entry))
|
|
|
|
private fun shufflePlay(entries: List<QueueEntry>) {
|
|
if (player == null) {
|
|
pendingShuffle = entries
|
|
connect()
|
|
return
|
|
}
|
|
scope.launch { startShuffled(entries) }
|
|
}
|
|
|
|
private suspend fun startShuffled(entries: List<QueueEntry>) =
|
|
startLock.withLock {
|
|
val entry = entries.first()
|
|
windowJob?.cancel()
|
|
windowStart = 0
|
|
windowEnd = -1
|
|
lastEdit = null
|
|
endedHandled = false
|
|
val previous = snapshot
|
|
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
|
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
|
|
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
|
|
queueRepository.replace(entries)
|
|
var snap = queueRepository.snapshot()
|
|
if (snap.contextSize == 0L) {
|
|
stopLocked()
|
|
return@withLock
|
|
}
|
|
val start = randomIndex(snap.contextSize, avoid)
|
|
queueRepository.setShuffle(pinnedSeed(snap.contextSize, start))
|
|
shuffleEnabled = true
|
|
queueSourceId = entry.sourceId
|
|
queueRepository.setCursor(0)
|
|
snap = queueRepository.snapshot()
|
|
this@PlaybackController.snapshot = snap
|
|
loadWindow(0, autoplay = true)
|
|
}
|
|
|
|
private fun randomIndex(
|
|
size: Long,
|
|
avoid: Long?,
|
|
): Long {
|
|
if (size <= 1L) return 0L
|
|
val index = Random.nextLong(size)
|
|
return if (avoid != null && index == avoid) (index + 1L) % size else index
|
|
}
|
|
|
|
// A seed whose permutation puts `flat` at play position 0, so the shuffled queue starts on the
|
|
// track that should play instead of somewhere in the middle. Each random seed hits with
|
|
// probability 1/size; fall back to a random seed for queues too large to search.
|
|
private fun pinnedSeed(
|
|
size: Long,
|
|
flat: Long,
|
|
): Long {
|
|
if (size <= 1L) return Random.nextLong()
|
|
repeat(SEED_PIN_TRIES) {
|
|
val seed = Random.nextLong()
|
|
if (Shuffle.toFlat(seed, size, 0L) == flat) return seed
|
|
}
|
|
return Random.nextLong()
|
|
}
|
|
|
|
fun playAt(position: Long) {
|
|
scope.launch {
|
|
startLock.withLock {
|
|
val player = player ?: return@withLock
|
|
var snapshot = snapshot ?: return@withLock
|
|
if (snapshot.size == 0L) return@withLock
|
|
val anchor = queueRepository.reanchorFor(snapshot, position)
|
|
if (anchor.changed) {
|
|
snapshot = readSnapshot()
|
|
this@PlaybackController.snapshot = snapshot
|
|
layoutVersion++
|
|
}
|
|
val requested = anchor.position.coerceIn(0, snapshot.size - 1)
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
if (offline) {
|
|
downloadedAtOrAfter(snapshot, requested) ?: return@withLock
|
|
} else {
|
|
requested
|
|
}
|
|
windowJob?.cancel()
|
|
queueRepository.setCursor(target)
|
|
if (anchor.changed || !inWindow(target)) {
|
|
loadWindow(target, autoplay = true)
|
|
} else {
|
|
player.ensurePrepared()
|
|
lastPosition = target
|
|
player.seekToIndex(windowIndexOf(target))
|
|
player.play()
|
|
shiftWindowLocked(target)
|
|
refresh(target)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun removeAt(position: Long) =
|
|
startLock.withLock {
|
|
val snapshot = snapshot ?: return@withLock
|
|
if (position !in 0 until snapshot.size) return@withLock
|
|
val player = player ?: return@withLock
|
|
val cursor = queueRepository.cursor()
|
|
val entries = snapshot.entries.map { it.entry }
|
|
val upNext = snapshot.upNext.map { it.entry }
|
|
if (snapshot.size == 1L) {
|
|
queueRepository.removeAt(snapshot, position)
|
|
stopLocked()
|
|
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
|
return@withLock
|
|
}
|
|
val current = currentPosition() ?: return@withLock
|
|
val playing = player.playWhenReady
|
|
val wasUpNext = snapshot.isUpNext(current)
|
|
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
|
queueRepository.removeAt(snapshot, position)
|
|
var updated = readSnapshot()
|
|
this.snapshot = updated
|
|
windowJob?.cancel()
|
|
if (updated.size == 0L) {
|
|
stopLocked()
|
|
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
|
return@withLock
|
|
}
|
|
// Derive the current track's new position from the edit. Resolving its song id after
|
|
// the edit can match an identical song elsewhere (a duplicate in the context or the
|
|
// block), so use positions throughout. Removing the playing track resumes at the same
|
|
// slot, which now holds whatever was next (the block, if one is queued).
|
|
var target =
|
|
when {
|
|
position == current -> {
|
|
current
|
|
}
|
|
|
|
wasUpNext -> {
|
|
val blockIndex = snapshot.upNextIndex(current)
|
|
val newIndex =
|
|
if (snapshot.isUpNext(position) && position < current) blockIndex - 1 else blockIndex
|
|
updated.anchorPlay + 1 + newIndex
|
|
}
|
|
|
|
else -> {
|
|
// The current is a context track. Map its flat index through the (possibly
|
|
// re-derived) order, decremented when a context track before it was removed.
|
|
val flat = snapshot.flatContext(snapshot.contextPlay(current)) ?: 0L
|
|
val removedFlat =
|
|
if (snapshot.isUpNext(position)) null else snapshot.flatContext(snapshot.contextPlay(position))
|
|
val movedFlat = if (removedFlat != null && removedFlat < flat) flat - 1 else flat
|
|
updated.combined(if (snapshot.shuffled) updated.playContext(movedFlat) ?: 0L else movedFlat)
|
|
}
|
|
}.coerceIn(0, updated.size - 1)
|
|
// Keep the up-next block next: after a removal the cursor may have landed on a context
|
|
// track that no longer carries the block (its anchor moved), so re-anchor it there.
|
|
if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
|
|
val reanchored = queueRepository.reanchorFor(updated, target)
|
|
if (reanchored.changed) {
|
|
updated = readSnapshot()
|
|
this.snapshot = updated
|
|
target = reanchored.position
|
|
}
|
|
}
|
|
lastPosition = target
|
|
lastEdit =
|
|
if (removed == null) {
|
|
ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
|
} else {
|
|
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
|
}
|
|
if (offline) {
|
|
queueRepository.setCursor(target)
|
|
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
|
return@withLock
|
|
}
|
|
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) {
|
|
queueRepository.setCursor(target)
|
|
if (position != current) {
|
|
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
|
|
refresh(target)
|
|
} else {
|
|
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
|
}
|
|
return@withLock
|
|
}
|
|
when {
|
|
position == current -> {
|
|
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
|
}
|
|
|
|
position < windowStart -> {
|
|
windowStart--
|
|
windowEnd--
|
|
shiftWindowLocked(target)
|
|
}
|
|
|
|
position <= windowEnd -> {
|
|
updating = true
|
|
player.removeAt((position - windowStart).toInt())
|
|
updating = false
|
|
windowEnd--
|
|
shiftWindowLocked(target)
|
|
}
|
|
|
|
else -> {
|
|
shiftWindowLocked(target)
|
|
}
|
|
}
|
|
queueRepository.setCursor(target)
|
|
refresh(target)
|
|
}
|
|
|
|
suspend fun move(
|
|
from: Long,
|
|
to: Long,
|
|
) = startLock.withLock {
|
|
if (from == to) return@withLock
|
|
val snapshot = snapshot ?: return@withLock
|
|
if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock
|
|
val player = player ?: return@withLock
|
|
val current = currentPosition() ?: return@withLock
|
|
val cursor = queueRepository.cursor()
|
|
val entries = snapshot.entries.map { it.entry }
|
|
val upNext = snapshot.upNext.map { it.entry }
|
|
val wasUpNext = snapshot.isUpNext(current)
|
|
if (!queueRepository.move(snapshot, from, to)) return@withLock
|
|
val updated = readSnapshot()
|
|
this.snapshot = updated
|
|
// The current track still exists, so derive its new position from the edit instead of
|
|
// resolving its song id, which is ambiguous when the same song appears more than once.
|
|
// A context move can also shift the anchor (and with it an up-next block), so read the
|
|
// position back from the updated snapshot rather than only shifting `current`.
|
|
val target =
|
|
if (wasUpNext) {
|
|
val blockIndex = snapshot.upNextIndex(current)
|
|
val newIndex =
|
|
if (snapshot.isUpNext(from) && snapshot.isUpNext(to)) {
|
|
movedCursor(blockIndex, snapshot.upNextIndex(from), snapshot.upNextIndex(to))
|
|
} else {
|
|
blockIndex
|
|
}
|
|
updated.anchorPlay + 1 + newIndex
|
|
} else {
|
|
val play = snapshot.contextPlay(current)
|
|
val newPlay =
|
|
if (!snapshot.isUpNext(from) && !snapshot.isUpNext(to)) {
|
|
movedCursor(play, snapshot.contextPlay(from), snapshot.contextPlay(to))
|
|
} else {
|
|
play
|
|
}
|
|
updated.combined(newPlay)
|
|
}.coerceIn(0, updated.size - 1)
|
|
lastPosition = target
|
|
windowJob?.cancel()
|
|
val fromInWindow = from in windowStart..windowEnd
|
|
val toInWindow = to in windowStart..windowEnd
|
|
lastEdit =
|
|
if (fromInWindow == toInWindow) {
|
|
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
|
} else {
|
|
ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
|
}
|
|
if (offline) {
|
|
queueRepository.setCursor(target)
|
|
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
|
|
return@withLock
|
|
}
|
|
when {
|
|
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
|
updating = true
|
|
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
|
updating = false
|
|
}
|
|
|
|
!fromInWindow && !toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
|
// The moved track never enters the window, so the playlist is unchanged; only
|
|
// the window's positions shift, and a move that spans it shifts them by one.
|
|
when {
|
|
from < windowStart && to > windowEnd -> {
|
|
windowStart--
|
|
windowEnd--
|
|
}
|
|
|
|
from > windowEnd && to < windowStart -> {
|
|
windowStart++
|
|
windowEnd++
|
|
}
|
|
}
|
|
}
|
|
|
|
else -> {
|
|
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
|
|
}
|
|
}
|
|
shiftWindowLocked(target)
|
|
queueRepository.setCursor(target)
|
|
refresh(target)
|
|
}
|
|
|
|
suspend fun undo() =
|
|
startLock.withLock {
|
|
val undo = lastEdit ?: return@withLock
|
|
val player = player ?: return@withLock
|
|
val playing = player.playWhenReady
|
|
val positionMs = player.currentPositionMs
|
|
queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor)
|
|
shuffleEnabled = undo.shuffleSeed != null
|
|
val restored = readSnapshot()
|
|
this.snapshot = restored
|
|
lastEdit = null
|
|
if (restored.size == 0L) {
|
|
stopLocked()
|
|
return@withLock
|
|
}
|
|
val target = undo.cursor.coerceIn(0, restored.size - 1)
|
|
lastPosition = target
|
|
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
|
|
windowJob?.cancel()
|
|
if (offline) {
|
|
queueRepository.setCursor(target)
|
|
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
|
return@withLock
|
|
}
|
|
val canMirror =
|
|
undo.current != null &&
|
|
player.currentItem?.id != null &&
|
|
player.currentItem?.id == restoredId &&
|
|
currentPosition() == undo.current
|
|
val mirrored =
|
|
when {
|
|
!canMirror -> {
|
|
false
|
|
}
|
|
|
|
undo is RemovedUndo && undo.item != null -> {
|
|
if (undo.position < windowStart) {
|
|
windowStart++
|
|
windowEnd++
|
|
} else if (undo.position <= windowEnd) {
|
|
updating = true
|
|
player.insertAt((undo.position - windowStart).toInt(), undo.item)
|
|
updating = false
|
|
windowEnd++
|
|
}
|
|
true
|
|
}
|
|
|
|
undo is MovedUndo && undo.from in windowStart..windowEnd && undo.to in windowStart..windowEnd -> {
|
|
updating = true
|
|
player.move((undo.to - windowStart).toInt(), (undo.from - windowStart).toInt())
|
|
updating = false
|
|
true
|
|
}
|
|
|
|
undo is MovedUndo && undo.from !in windowStart..windowEnd && undo.to !in windowStart..windowEnd -> {
|
|
when {
|
|
undo.from < windowStart && undo.to > windowEnd -> {
|
|
windowStart++
|
|
windowEnd++
|
|
}
|
|
|
|
undo.from > windowEnd && undo.to < windowStart -> {
|
|
windowStart--
|
|
windowEnd--
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
else -> {
|
|
false
|
|
}
|
|
}
|
|
if (mirrored) {
|
|
shiftWindowLocked(target)
|
|
} else {
|
|
loadWindow(target, autoplay = playing, startPositionMs = if (canMirror) positionMs else 0)
|
|
}
|
|
queueRepository.setCursor(target)
|
|
refresh(target)
|
|
}
|
|
|
|
fun togglePlayPause() {
|
|
val player = player ?: return
|
|
if (player.isEnded) {
|
|
player.seekToIndex(player.currentIndex)
|
|
player.play()
|
|
return
|
|
}
|
|
player.ensurePrepared()
|
|
if (player.playWhenReady) {
|
|
player.pause()
|
|
savePosition()
|
|
} else {
|
|
player.play()
|
|
}
|
|
}
|
|
|
|
fun next() {
|
|
val snapshot = snapshot ?: return
|
|
if (snapshot.size == 0L) return
|
|
val current = currentPosition() ?: return
|
|
if (snapshot.isUpNext(current)) {
|
|
scope.launch { consumeUpNext(current) }
|
|
return
|
|
}
|
|
if (offline) {
|
|
scope.launch {
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
downloadedAtOrAfter(snapshot, current + 1)
|
|
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
|
target?.let { jumpTo(it) }
|
|
}
|
|
return
|
|
}
|
|
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
|
}
|
|
|
|
private fun nextPosition(
|
|
snapshot: QueueSnapshot,
|
|
current: Long,
|
|
): Long? =
|
|
when {
|
|
current < snapshot.size - 1 -> current + 1
|
|
repeatMode != RepeatMode.Off -> 0L
|
|
else -> null
|
|
}
|
|
|
|
private fun previousPosition(
|
|
snapshot: QueueSnapshot,
|
|
current: Long,
|
|
): Long? =
|
|
when {
|
|
current > 0 -> current - 1
|
|
repeatMode == RepeatMode.All -> snapshot.size - 1
|
|
else -> null
|
|
}
|
|
|
|
private suspend fun consumeUpNext(position: Long) =
|
|
startLock.withLock {
|
|
val snapshot = snapshot ?: return@withLock
|
|
if (!snapshot.isUpNext(position)) return@withLock
|
|
if (position >= snapshot.size - 1 && repeatMode == RepeatMode.Off) return@withLock
|
|
queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position))
|
|
val updated = readSnapshot()
|
|
this.snapshot = updated
|
|
layoutVersion++
|
|
windowJob?.cancel()
|
|
endedHandled = false
|
|
val next = if (position < updated.size) position else 0L
|
|
queueRepository.setCursor(next)
|
|
if (offline) {
|
|
loadWindow(next, autoplay = true)
|
|
return@withLock
|
|
}
|
|
val player = player
|
|
if (next == position && player != null && position - windowStart in 0 until player.itemCount) {
|
|
updating = true
|
|
player.removeAt((position - windowStart).toInt())
|
|
updating = false
|
|
windowEnd--
|
|
shiftWindowLocked(next)
|
|
refresh(next)
|
|
} else {
|
|
loadWindow(next, autoplay = true)
|
|
}
|
|
}
|
|
|
|
fun clearUpNext() {
|
|
scope.launch {
|
|
startLock.withLock {
|
|
val snapshot = snapshot ?: return@withLock
|
|
if (snapshot.upNextSize == 0L) return@withLock
|
|
player ?: return@withLock
|
|
val current = currentPosition() ?: return@withLock
|
|
val wasUpNext = snapshot.isUpNext(current)
|
|
queueRepository.clearUpNext()
|
|
val updated = readSnapshot()
|
|
this@PlaybackController.snapshot = updated
|
|
layoutVersion++
|
|
windowJob?.cancel()
|
|
endedHandled = false
|
|
val contextPlay = if (wasUpNext) snapshot.anchorPlay + 1 else snapshot.contextPlay(current)
|
|
val target = contextPlay.coerceIn(0, (updated.size - 1).coerceAtLeast(0))
|
|
queueRepository.setCursor(target)
|
|
lastPosition = target
|
|
if (wasUpNext) {
|
|
loadWindow(target, autoplay = true)
|
|
} else {
|
|
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
|
|
refresh(target)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun previous() {
|
|
val player = player ?: return
|
|
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
|
|
scope.launch {
|
|
startLock.withLock { player.seekTo(0) }
|
|
refresh()
|
|
}
|
|
return
|
|
}
|
|
val snapshot = snapshot ?: return
|
|
if (snapshot.size == 0L) return
|
|
val current = currentPosition() ?: return
|
|
if (offline) {
|
|
scope.launch {
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
downloadedAtOrBefore(snapshot, current - 1)
|
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
|
if (target != null) {
|
|
jumpTo(target)
|
|
} else {
|
|
player.seekTo(0)
|
|
refresh()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
val target = previousPosition(snapshot, current)
|
|
if (target != null) {
|
|
jumpTo(target)
|
|
} else {
|
|
player.seekTo(0)
|
|
refresh()
|
|
}
|
|
}
|
|
|
|
fun previousTrack() {
|
|
val snapshot = snapshot ?: return
|
|
if (snapshot.size == 0L) return
|
|
val current = currentPosition() ?: return
|
|
if (offline) {
|
|
scope.launch {
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
downloadedAtOrBefore(snapshot, current - 1)
|
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
|
target?.let { jumpTo(it) }
|
|
}
|
|
return
|
|
}
|
|
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
|
}
|
|
|
|
fun addToQueue(
|
|
sourceId: Long,
|
|
kind: QueueKind,
|
|
refId: String,
|
|
) = enqueue(sourceId, kind, refId, playNext = false)
|
|
|
|
fun playNext(
|
|
sourceId: Long,
|
|
kind: QueueKind,
|
|
refId: String,
|
|
) = enqueue(sourceId, kind, refId, playNext = true)
|
|
|
|
private fun enqueue(
|
|
sourceId: Long,
|
|
kind: QueueKind,
|
|
refId: String,
|
|
playNext: Boolean,
|
|
) {
|
|
val entry = QueueEntry(position = 0, sourceId = sourceId, kind = kind, refId = refId)
|
|
scope.launch { enqueue(entry, playNext) }
|
|
}
|
|
|
|
private suspend fun enqueue(
|
|
entry: QueueEntry,
|
|
playNext: Boolean,
|
|
) = startLock.withLock {
|
|
val snapshot = snapshot ?: return@withLock
|
|
val player = player ?: return@withLock
|
|
if (snapshot.size == 0L) return@withLock
|
|
val current = currentPosition() ?: return@withLock
|
|
val position = queueRepository.addUpNext(snapshot, current, entry, playNext)
|
|
val updated = readSnapshot()
|
|
this.snapshot = updated
|
|
layoutVersion++
|
|
queueRepository.setCursor(position)
|
|
windowJob?.cancel()
|
|
rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position)
|
|
refresh(position)
|
|
}
|
|
|
|
fun cycleRepeat() {
|
|
val next =
|
|
when (repeatMode) {
|
|
RepeatMode.Off -> RepeatMode.All
|
|
RepeatMode.All -> RepeatMode.One
|
|
RepeatMode.One -> RepeatMode.Off
|
|
}
|
|
repeatMode = next
|
|
player?.setRepeatOne(next == RepeatMode.One)
|
|
scope.launch { queueRepository.setRepeat(next.ordinal) }
|
|
refresh()
|
|
}
|
|
|
|
fun toggleShuffle() {
|
|
scope.launch {
|
|
startLock.withLock {
|
|
val snapshot = readSnapshot()
|
|
this@PlaybackController.snapshot = snapshot
|
|
if (snapshot.size == 0L) return@withLock
|
|
player ?: return@withLock
|
|
val current = currentPosition() ?: return@withLock
|
|
val wasUpNext = snapshot.isUpNext(current)
|
|
val blockIndex = snapshot.upNextIndex(current)
|
|
val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock
|
|
windowJob?.cancel()
|
|
val enabling = !shuffleEnabled
|
|
if (enabling) {
|
|
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
|
} else {
|
|
queueRepository.setShuffle(null)
|
|
}
|
|
shuffleEnabled = enabling
|
|
val reordered = readSnapshot()
|
|
this@PlaybackController.snapshot = reordered
|
|
val position =
|
|
when {
|
|
wasUpNext -> reordered.anchorPlay + 1 + blockIndex
|
|
enabling -> reordered.combined(0L)
|
|
else -> reordered.combined(flat)
|
|
}
|
|
val anchor = queueRepository.reanchorFor(reordered, position)
|
|
val finalSnapshot = if (anchor.changed) readSnapshot() else reordered
|
|
if (anchor.changed) layoutVersion++
|
|
this@PlaybackController.snapshot = finalSnapshot
|
|
queueRepository.setCursor(anchor.position)
|
|
rebuildWindow(queueRepository.window(finalSnapshot, anchor.position, QUEUE_WINDOW_RADIUS), anchor.position)
|
|
endedHandled = false
|
|
refresh(anchor.position)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun seekTo(positionMs: Long) {
|
|
player?.seekTo(positionMs)
|
|
lastSavedPositionMs = positionMs
|
|
scope.launch { queueRepository.setPosition(positionMs) }
|
|
}
|
|
|
|
fun coverArt(
|
|
item: QueueItem?,
|
|
thumbnail: Boolean = false,
|
|
): CoverArtRef? = sourceRepository.coverArt(item?.coverArtId, thumbnail)
|
|
|
|
suspend fun upcomingItem(): QueueItem? {
|
|
var current: QueueSnapshot? = null
|
|
var position: Long? = null
|
|
var isOffline = false
|
|
startLock.withLock {
|
|
current = snapshot
|
|
position = currentPosition()
|
|
isOffline = offline
|
|
}
|
|
val snapshot = current ?: return null
|
|
val at = position ?: return null
|
|
if (!isOffline) {
|
|
return nextPosition(snapshot, at)
|
|
?.takeIf { it != at }
|
|
?.let { queueItemAt(snapshot, it) }
|
|
}
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
downloadedAtOrAfter(snapshot, at + 1)
|
|
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
|
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
|
}
|
|
|
|
suspend fun previousItem(): QueueItem? {
|
|
var current: QueueSnapshot? = null
|
|
var position: Long? = null
|
|
var isOffline = false
|
|
startLock.withLock {
|
|
current = snapshot
|
|
position = currentPosition()
|
|
isOffline = offline
|
|
}
|
|
val snapshot = current ?: return null
|
|
val at = position ?: return null
|
|
if (!isOffline) {
|
|
return previousPosition(snapshot, at)
|
|
?.takeIf { it != at }
|
|
?.let { queueItemAt(snapshot, it) }
|
|
}
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
downloadedAtOrBefore(snapshot, at - 1)
|
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
|
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
|
}
|
|
|
|
private fun play(
|
|
entries: List<QueueEntry>,
|
|
startPosition: Long,
|
|
disableShuffle: Boolean = false,
|
|
) {
|
|
if (player == null) {
|
|
pendingPlay = PendingPlay(entries, startPosition, disableShuffle)
|
|
connect()
|
|
return
|
|
}
|
|
scope.launch { start(entries, startPosition, disableShuffle) }
|
|
}
|
|
|
|
private suspend fun start(
|
|
entries: List<QueueEntry>,
|
|
startPosition: Long,
|
|
disableShuffle: Boolean = false,
|
|
) = startLock.withLock {
|
|
windowJob?.cancel()
|
|
windowStart = 0
|
|
windowEnd = -1
|
|
lastEdit = null
|
|
endedHandled = false
|
|
val modes = queueRepository.modes()
|
|
shuffleEnabled = !disableShuffle && modes.shuffle
|
|
repeatMode = modes.repeat.toRepeatMode()
|
|
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
|
queueRepository.replace(entries)
|
|
val snapshot = queueRepository.snapshot()
|
|
this.snapshot = snapshot
|
|
queueSourceId = entries.firstOrNull()?.sourceId
|
|
if (snapshot.size == 0L) {
|
|
stopLocked()
|
|
return@withLock
|
|
}
|
|
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
|
val position =
|
|
if (shuffleEnabled) {
|
|
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
|
0L
|
|
} else {
|
|
start
|
|
}
|
|
queueRepository.setCursor(position)
|
|
this.snapshot = readSnapshot()
|
|
loadWindow(position, autoplay = true)
|
|
}
|
|
|
|
private suspend fun restore() =
|
|
startLock.withLock {
|
|
windowJob?.cancel()
|
|
windowStart = 0
|
|
windowEnd = -1
|
|
endedHandled = false
|
|
val modes = queueRepository.modes()
|
|
shuffleEnabled = modes.shuffle
|
|
repeatMode = modes.repeat.toRepeatMode()
|
|
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
|
var snapshot = queueRepository.snapshot()
|
|
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
|
|
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
|
|
val flat = snapshot.flatContext(start) ?: start
|
|
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
|
queueRepository.setCursor(0)
|
|
snapshot = queueRepository.snapshot()
|
|
} else if (!snapshot.shuffled) {
|
|
shuffleEnabled = false
|
|
}
|
|
this.snapshot = snapshot
|
|
val queueSourceId =
|
|
snapshot.entries
|
|
.firstOrNull()
|
|
?.entry
|
|
?.sourceId
|
|
val activeId = sourceRepository.activeSourceIdOnce()
|
|
if (snapshot.size == 0L || (queueSourceId != null && activeId != null && queueSourceId != activeId)) {
|
|
stopLocked()
|
|
return@withLock
|
|
}
|
|
this.queueSourceId = queueSourceId
|
|
val savedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
|
|
val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
|
|
val anchor = queueRepository.reanchorFor(snapshot, cursor)
|
|
if (anchor.changed) {
|
|
snapshot = queueRepository.snapshot()
|
|
this.snapshot = snapshot
|
|
queueRepository.setCursor(anchor.position)
|
|
queueRepository.setPosition(savedPositionMs)
|
|
}
|
|
lastSavedPositionMs = savedPositionMs
|
|
loadWindow(anchor.position, autoplay = false, startPositionMs = savedPositionMs)
|
|
}
|
|
|
|
private suspend fun stop() = startLock.withLock { stopLocked() }
|
|
|
|
private fun stopLocked() {
|
|
windowJob?.cancel()
|
|
bufferingJob?.cancel()
|
|
bufferingJob = null
|
|
stallJob?.cancel()
|
|
stallJob = null
|
|
stalled = false
|
|
showBuffering = false
|
|
snapshot = null
|
|
queueSourceId = null
|
|
windowStart = 0
|
|
windowEnd = -1
|
|
windowPositions = LongArray(0)
|
|
windowFiltered = false
|
|
lastPosition = null
|
|
lastEdit = null
|
|
player?.run {
|
|
stop()
|
|
clear()
|
|
}
|
|
_state.value = PlaybackState()
|
|
_positionMs.value = 0L
|
|
stopPositionTicker()
|
|
}
|
|
|
|
private suspend fun loadWindow(
|
|
position: Long,
|
|
autoplay: Boolean,
|
|
startPositionMs: Long = 0,
|
|
) {
|
|
val player = player ?: return
|
|
val snapshot = snapshot ?: return
|
|
downloads.awaitLoaded()
|
|
val requested =
|
|
if (offline) {
|
|
downloadedAtOrAfter(snapshot, position) ?: downloadedAtOrBefore(snapshot, position - 1) ?: run {
|
|
stopLocked()
|
|
return
|
|
}
|
|
} else {
|
|
position
|
|
}
|
|
val raw = queueRepository.window(snapshot, requested, QUEUE_WINDOW_RADIUS)
|
|
val window = if (offline) raw.filter { downloads.localUri(it.item.song.id) != null } else raw
|
|
if (window.isEmpty()) {
|
|
stopLocked()
|
|
return
|
|
}
|
|
var startIndex = window.indexOfFirst { it.position == requested }
|
|
if (startIndex < 0) {
|
|
startIndex = window.indexOfFirst { it.position > requested }
|
|
if (startIndex < 0) startIndex = window.lastIndex
|
|
}
|
|
val actual = window[startIndex].position
|
|
windowPositions = LongArray(window.size) { window[it].position }
|
|
windowFiltered = offline
|
|
windowStart = window.first().position
|
|
windowEnd = window.last().position
|
|
lastPosition = actual
|
|
if (actual != requested) queueRepository.setCursor(actual)
|
|
updating = true
|
|
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
|
|
updating = false
|
|
if (autoplay) {
|
|
player.prepare()
|
|
player.play()
|
|
}
|
|
refresh(actual)
|
|
}
|
|
|
|
private suspend fun reloadQuality() =
|
|
startLock.withLock {
|
|
val sourceId = queueSourceId
|
|
if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock
|
|
reloadWindowLocked()
|
|
}
|
|
|
|
fun refreshMediaItems() {
|
|
scope.launch {
|
|
startLock.withLock {
|
|
val player = player ?: return@withLock
|
|
val snapshot = snapshot ?: return@withLock
|
|
if (snapshot.size == 0L || player.isEnded) return@withLock
|
|
downloads.awaitLoaded()
|
|
if (offline) {
|
|
reloadWindowLocked()
|
|
return@withLock
|
|
}
|
|
val position = (currentPosition() ?: return@withLock).coerceIn(0, snapshot.size - 1)
|
|
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
|
val uris = player.itemUris()
|
|
if (window.size != player.itemCount || uris.size != window.size) {
|
|
reloadWindowLocked()
|
|
return@withLock
|
|
}
|
|
// Only an item whose local file appeared or went away needs rebuilding. Re-setting
|
|
// the whole window would re-prepare the player and audibly pause playback.
|
|
var replaced = false
|
|
window.forEachIndexed { index, entry ->
|
|
val local = downloads.localUri(entry.item.song.id) != null
|
|
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
|
player.replaceItem(index, entry.playerItem(snapshot))
|
|
replaced = true
|
|
}
|
|
}
|
|
if (replaced) refresh(position)
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun reloadWindowLocked() {
|
|
val player = player ?: return
|
|
val snapshot = snapshot ?: return
|
|
if (snapshot.size == 0L) return
|
|
if (player.isEnded) return
|
|
val position = (currentPosition() ?: return).coerceIn(0, snapshot.size - 1)
|
|
val positionMs = player.currentPositionMs
|
|
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
|
}
|
|
|
|
private fun jumpTo(target: Long) {
|
|
scope.launch {
|
|
startLock.withLock {
|
|
val player = player ?: return@withLock
|
|
var snapshot = readSnapshot()
|
|
if (snapshot.size == 0L) return@withLock
|
|
val anchor = queueRepository.reanchorFor(snapshot, target)
|
|
if (anchor.changed) {
|
|
snapshot = readSnapshot()
|
|
layoutVersion++
|
|
}
|
|
this@PlaybackController.snapshot = snapshot
|
|
val current = currentPosition() ?: return@withLock
|
|
val requested = anchor.position.coerceIn(0, snapshot.size - 1)
|
|
downloads.awaitLoaded()
|
|
val dest =
|
|
if (offline) {
|
|
downloadedAtOrAfter(snapshot, requested) ?: return@withLock
|
|
} else {
|
|
requested
|
|
}
|
|
val wasEnded = player.isEnded
|
|
if (!anchor.changed && dest == current && !wasEnded) return@withLock
|
|
windowJob?.cancel()
|
|
queueRepository.setCursor(dest)
|
|
if (anchor.changed || !inWindow(dest)) {
|
|
loadWindow(dest, autoplay = player.playWhenReady || wasEnded)
|
|
endedHandled = false
|
|
return@withLock
|
|
}
|
|
lastPosition = dest
|
|
shiftWindowLocked(dest)
|
|
player.ensurePrepared()
|
|
player.seekToIndex(windowIndexOf(dest))
|
|
if (wasEnded) player.play()
|
|
endedHandled = false
|
|
refresh(dest)
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun onPositionChanged() {
|
|
val player = player ?: return
|
|
val snapshot = snapshot ?: return
|
|
if (snapshot.size == 0L) return
|
|
val position = (currentPosition() ?: return).coerceIn(0, snapshot.size - 1)
|
|
val previous = lastPosition
|
|
lastPosition = position
|
|
if (previous != null && previous != position && snapshot.isUpNext(previous)) {
|
|
consumeUpNext(previous)
|
|
return
|
|
}
|
|
lastSavedPositionMs = 0
|
|
queueRepository.setCursor(position)
|
|
refresh(position)
|
|
scheduleWindowShift(position)
|
|
}
|
|
|
|
private fun scheduleWindowShift(center: Long) {
|
|
windowJob?.cancel()
|
|
windowJob =
|
|
scope.launch {
|
|
delay(WINDOW_SHIFT_DELAY_MS)
|
|
shiftWindow(center)
|
|
refresh(center)
|
|
}
|
|
}
|
|
|
|
private suspend fun shiftWindow(center: Long) = startLock.withLock { shiftWindowLocked(center) }
|
|
|
|
private suspend fun shiftWindowLocked(center: Long) {
|
|
val player = player ?: return
|
|
if (offline) {
|
|
lastPosition = center
|
|
return
|
|
}
|
|
val previous = snapshot
|
|
val snapshot = readSnapshot()
|
|
this.snapshot = snapshot
|
|
if (snapshot.size == 0L) return
|
|
if (previous != null && previous.shuffled != snapshot.shuffled) {
|
|
val currentId = player.currentItem?.id
|
|
val position =
|
|
(currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center)
|
|
.coerceIn(0, snapshot.size - 1)
|
|
queueRepository.setCursor(position)
|
|
rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position)
|
|
refresh(position)
|
|
return
|
|
}
|
|
val target = center.coerceIn(0, snapshot.size - 1)
|
|
val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0)
|
|
val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1)
|
|
updating = true
|
|
try {
|
|
while (windowStart > desiredStart) {
|
|
val item = queueItemAt(snapshot, windowStart - 1) ?: break
|
|
player.addFirst(item)
|
|
windowStart--
|
|
}
|
|
while (windowStart < desiredStart) {
|
|
player.removeFirst()
|
|
windowStart++
|
|
}
|
|
while (windowEnd < desiredEnd) {
|
|
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
|
|
player.addLast(item)
|
|
windowEnd++
|
|
}
|
|
while (windowEnd > desiredEnd) {
|
|
player.removeLast()
|
|
windowEnd--
|
|
}
|
|
} finally {
|
|
updating = false
|
|
}
|
|
lastPosition = target
|
|
}
|
|
|
|
private suspend fun rebuildWindow(
|
|
window: List<QueueWindowItem>,
|
|
position: Long,
|
|
) {
|
|
val player = player ?: return
|
|
val snapshot = this.snapshot ?: return
|
|
downloads.awaitLoaded()
|
|
val ordered =
|
|
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
|
val index = ordered.indexOfFirst { it.position == position }
|
|
if (index < 0) {
|
|
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
|
|
return
|
|
}
|
|
// A track downloaded while it was streaming still holds a stream URL; fix the current item's
|
|
// source in place (without resetting the window) so an offline switch can play the local file.
|
|
val current = ordered[index]
|
|
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
|
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
|
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
|
|
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
|
windowFiltered = offline
|
|
windowStart = ordered.first().position
|
|
windowEnd = ordered.last().position
|
|
lastPosition = position
|
|
updating = true
|
|
try {
|
|
repeat(player.currentIndex) { player.removeFirst() }
|
|
while (player.itemCount > 1) player.removeLast()
|
|
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
|
|
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
|
|
} finally {
|
|
updating = false
|
|
}
|
|
}
|
|
|
|
private fun currentPosition(): Long? {
|
|
val player = player ?: return null
|
|
if (player.itemCount == 0) return null
|
|
val index = player.currentIndex
|
|
return if (windowFiltered) windowPositions.getOrNull(index) ?: (windowStart + index) else (windowStart + index)
|
|
}
|
|
|
|
private fun windowIndexOf(position: Long): Int =
|
|
if (windowFiltered) {
|
|
windowPositions.indexOf(position)
|
|
} else {
|
|
(position - windowStart).toInt()
|
|
}
|
|
|
|
private fun inWindow(position: Long): Boolean =
|
|
if (windowFiltered) {
|
|
windowPositions.contains(position)
|
|
} else {
|
|
position in windowStart..windowEnd
|
|
}
|
|
|
|
private suspend fun downloadedAtOrAfter(
|
|
snapshot: QueueSnapshot,
|
|
from: Long,
|
|
): Long? = downloadedInDirection(snapshot, from, 1L)
|
|
|
|
private suspend fun downloadedAtOrBefore(
|
|
snapshot: QueueSnapshot,
|
|
from: Long,
|
|
): Long? = downloadedInDirection(snapshot, from, -1L)
|
|
|
|
private suspend fun downloadedInDirection(
|
|
snapshot: QueueSnapshot,
|
|
from: Long,
|
|
step: Long,
|
|
): Long? =
|
|
withContext(Dispatchers.IO) {
|
|
var position = from
|
|
var scanned = 0L
|
|
while (position in 0 until snapshot.size && scanned < OFFLINE_SCAN_LIMIT) {
|
|
val songId = queueRepository.itemAt(snapshot, position)?.song?.id
|
|
if (songId != null && downloads.localUri(songId) != null) return@withContext position
|
|
position += step
|
|
scanned++
|
|
}
|
|
null
|
|
}
|
|
|
|
private suspend fun reloadForOffline() =
|
|
startLock.withLock {
|
|
val player = player ?: return@withLock
|
|
val snapshot = snapshot ?: return@withLock
|
|
if (snapshot.size == 0L) return@withLock
|
|
downloads.awaitLoaded()
|
|
val current = currentPosition() ?: return@withLock
|
|
windowJob?.cancel()
|
|
val target =
|
|
if (offline) {
|
|
downloadedAtOrAfter(snapshot, current) ?: run {
|
|
stopLocked()
|
|
return@withLock
|
|
}
|
|
} else {
|
|
current
|
|
}
|
|
queueRepository.setCursor(target)
|
|
if (target == current) {
|
|
// Keep the playing item in place and only add/remove its neighbours, so a downloaded
|
|
// track does not re-prepare (and audibly gap) when offline mode is toggled.
|
|
rebuildWindow(queueRepository.window(snapshot, target, QUEUE_WINDOW_RADIUS), target)
|
|
refresh(target)
|
|
} else {
|
|
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = 0)
|
|
}
|
|
}
|
|
|
|
private fun movedCursor(
|
|
current: Long,
|
|
from: Long,
|
|
to: Long,
|
|
): Long =
|
|
when {
|
|
from == current -> to
|
|
from < current && to >= current -> current - 1
|
|
from > current && to <= current -> current + 1
|
|
else -> current
|
|
}
|
|
|
|
private val playerListener =
|
|
object : PlayerHandle.Listener {
|
|
override fun onTransition() {
|
|
endedHandled = false
|
|
if (updating) return
|
|
scope.launch { onPositionChanged() }
|
|
}
|
|
|
|
override fun onEvents() {
|
|
if (player?.isEnded == true) handleEnded() else endedHandled = false
|
|
refresh()
|
|
}
|
|
|
|
override fun onError(message: UiMessage) {
|
|
showMessage(message)
|
|
refresh()
|
|
}
|
|
}
|
|
|
|
private fun handleEnded() {
|
|
if (endedHandled) return
|
|
endedHandled = true
|
|
if (offline) {
|
|
scope.launch {
|
|
val snapshot = snapshot ?: return@launch
|
|
val current = currentPosition() ?: return@launch
|
|
downloads.awaitLoaded()
|
|
val target =
|
|
downloadedAtOrAfter(snapshot, current + 1)
|
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrAfter(snapshot, 0L) else null
|
|
target?.let { jumpTo(it) }
|
|
}
|
|
return
|
|
}
|
|
if (repeatMode == RepeatMode.All) jumpTo(0)
|
|
}
|
|
|
|
private fun Int.toRepeatMode(): RepeatMode = RepeatMode.entries.getOrElse(this) { RepeatMode.Off }
|
|
|
|
private suspend fun readSnapshot(): QueueSnapshot {
|
|
val snapshot = queueRepository.snapshot()
|
|
if (!snapshot.shuffled) {
|
|
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(null)
|
|
shuffleEnabled = false
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
|
|
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
|
|
|
private suspend fun queueItemAt(
|
|
snapshot: QueueSnapshot,
|
|
position: Long,
|
|
): QueueItem? =
|
|
queueRepository
|
|
.itemAt(snapshot, position)
|
|
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
|
|
|
private fun contextAt(position: Long?): QueueContext? {
|
|
val snapshot = snapshot ?: return null
|
|
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
|
val entry = snapshot.locateContext(play)?.first ?: return null
|
|
return QueueContext(entry.kind, entry.sourceId, entry.refId)
|
|
}
|
|
|
|
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
|
|
|
private fun updateBuffering() {
|
|
val player = player
|
|
if (player?.isBuffering != true) {
|
|
bufferingJob?.cancel()
|
|
bufferingJob = null
|
|
stallJob?.cancel()
|
|
stallJob = null
|
|
stalled = false
|
|
showBuffering = false
|
|
return
|
|
}
|
|
if (!showBuffering && bufferingJob?.isActive != true) {
|
|
bufferingJob =
|
|
scope.launch {
|
|
delay(BUFFERING_INDICATOR_DELAY_MS)
|
|
if (player.isBuffering) {
|
|
showBuffering = true
|
|
refresh()
|
|
}
|
|
}
|
|
}
|
|
// A stall is buffering that persists long enough to be a real problem, not a track change.
|
|
if (stallJob?.isActive != true && !stalled) {
|
|
stallJob =
|
|
scope.launch {
|
|
delay(STALL_WATCHDOG_MS)
|
|
if (player === this@PlaybackController.player && player.isBuffering && player.playWhenReady) {
|
|
stalled = true
|
|
val item = player.currentItem
|
|
Log.w(
|
|
"playback",
|
|
"stalled: buffering for ${STALL_WATCHDOG_MS / 1000}s at ${player.currentPositionMs}ms " +
|
|
"item id=${item?.id} title=\"${item?.title}\"",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun refresh(position: Long? = currentPosition()) {
|
|
val player = player ?: return
|
|
updateBuffering()
|
|
val durationMs =
|
|
player.durationMs.takeIf { it > 0 }
|
|
?: player.currentItem?.durationMs?.takeIf { it > 0 }
|
|
?: 0L
|
|
_state.value =
|
|
PlaybackState(
|
|
item = player.currentItem,
|
|
context = contextAt(position),
|
|
position = position,
|
|
isBuffering = showBuffering,
|
|
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
|
durationMs = durationMs,
|
|
hasNext = true,
|
|
hasPrevious = true,
|
|
shuffle = shuffleEnabled,
|
|
repeat = repeatMode,
|
|
layout = layoutVersion,
|
|
)
|
|
_positionMs.value = player.currentPositionMs
|
|
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
|
}
|
|
|
|
private fun startPositionTicker() {
|
|
if (positionJob?.isActive == true) return
|
|
positionJob =
|
|
scope.launch {
|
|
while (true) {
|
|
delay(POSITION_TICK_MS)
|
|
val player = player ?: break
|
|
val positionMs = player.currentPositionMs
|
|
if (player.currentItem?.id == _state.value.item?.id) {
|
|
_positionMs.value = 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
|
|
}
|
|
|
|
private fun PlayerHandle.ensurePrepared() {
|
|
if (isIdle) prepare()
|
|
}
|
|
|
|
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 STALL_WATCHDOG_MS = 15_000L
|
|
const val QUEUE_WINDOW_RADIUS = 25L
|
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
|
const val RESTART_THRESHOLD_MS = 3_000L
|
|
const val SEED_PIN_TRIES = 1_000_000
|
|
const val FILE_SCHEME = "file:"
|
|
|
|
// The offline scan walks the queue one row at a time to find the next downloaded track, so
|
|
// this caps the work on a sparse queue.
|
|
const val OFFLINE_SCAN_LIMIT = 10_000L
|
|
}
|
|
}
|