mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
fix scrobble double-count and missed plays found in review
Only treat a rewind as a new listen when it lands in the first few seconds, so a small seek-back no longer re-arms and duplicates a scrobble. Fall back to the player's resolved duration when the queue item has none, and stop the position flow publishing a value for a track the state has not caught up to. Submit off the collector so an offline write cannot stall counting.
This commit is contained in:
@@ -1213,7 +1213,6 @@ class PlaybackController(
|
||||
player.durationMs.takeIf { it > 0 }
|
||||
?: player.currentItem?.durationMs?.takeIf { it > 0 }
|
||||
?: 0L
|
||||
_positionMs.value = player.currentPositionMs
|
||||
_state.value =
|
||||
PlaybackState(
|
||||
item = player.currentItem,
|
||||
@@ -1228,6 +1227,7 @@ class PlaybackController(
|
||||
repeat = repeatMode,
|
||||
layout = layoutVersion,
|
||||
)
|
||||
_positionMs.value = player.currentPositionMs
|
||||
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
|
||||
}
|
||||
|
||||
@@ -1239,7 +1239,9 @@ class PlaybackController(
|
||||
delay(POSITION_TICK_MS)
|
||||
val player = player ?: break
|
||||
val positionMs = player.currentPositionMs
|
||||
_positionMs.value = positionMs
|
||||
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)
|
||||
|
||||
@@ -5,11 +5,12 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
sealed interface Scrobble {
|
||||
val songId: String
|
||||
@@ -28,6 +29,7 @@ class ScrobblePolicy(
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
private var songId: String? = null
|
||||
private var lastItemId: String? = null
|
||||
private var durationMs = 0L
|
||||
private var startedAt = 0L
|
||||
private var playedMs = 0L
|
||||
@@ -37,6 +39,7 @@ class ScrobblePolicy(
|
||||
|
||||
fun reset() {
|
||||
songId = null
|
||||
lastItemId = null
|
||||
playedMs = 0
|
||||
lastPositionMs = 0
|
||||
nowPlayingSent = false
|
||||
@@ -45,13 +48,18 @@ class ScrobblePolicy(
|
||||
|
||||
fun advance(
|
||||
item: QueueItem?,
|
||||
durationMs: Long,
|
||||
isPlaying: Boolean,
|
||||
positionMs: Long,
|
||||
): Scrobble? {
|
||||
if (durationMs > 0) this.durationMs = durationMs
|
||||
val previousPosition = lastPositionMs
|
||||
if (item?.id != songId || positionMs < previousPosition) {
|
||||
val restarted =
|
||||
item?.id != songId ||
|
||||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||
if (restarted) {
|
||||
songId = item?.id
|
||||
durationMs = item?.durationMs ?: 0L
|
||||
lastItemId = null
|
||||
playedMs = 0
|
||||
lastPositionMs = positionMs
|
||||
nowPlayingSent = false
|
||||
@@ -62,6 +70,7 @@ class ScrobblePolicy(
|
||||
return Scrobble.NowPlaying(item.id)
|
||||
}
|
||||
lastPositionMs = positionMs
|
||||
lastItemId = item?.id
|
||||
val id = songId ?: return null
|
||||
if (!isPlaying) return null
|
||||
if (!nowPlayingSent) {
|
||||
@@ -85,6 +94,7 @@ class ScrobblePolicy(
|
||||
const val MIN_DURATION_MS = 30_000L
|
||||
const val MAX_THRESHOLD_MS = 4 * 60_000L
|
||||
const val MAX_STEP_MS = 2_000L
|
||||
const val RESTART_POSITION_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,25 +104,26 @@ class Scrobbler(
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
private val policy: ScrobblePolicy = ScrobblePolicy(),
|
||||
) {
|
||||
private val submitLock = Mutex()
|
||||
|
||||
fun attach(
|
||||
state: StateFlow<PlaybackState>,
|
||||
positionMs: StateFlow<Long>,
|
||||
) {
|
||||
scope.launch {
|
||||
combine(state, positionMs, enabled) { current, position, on -> Triple(current, position, on) }
|
||||
combine(state, positionMs, enabled, ::Triple)
|
||||
.collect { (current, position, on) ->
|
||||
if (!on) {
|
||||
policy.reset()
|
||||
return@collect
|
||||
}
|
||||
val event = policy.advance(current.item, current.isPlaying, position) ?: return@collect
|
||||
submit(event)
|
||||
val durationMs = current.item?.durationMs?.takeIf { it > 0 } ?: current.durationMs
|
||||
val event = policy.advance(current.item, durationMs, current.isPlaying, position) ?: return@collect
|
||||
scope.launch { submitLock.withLock { submit(event) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = scope.cancel()
|
||||
|
||||
private suspend fun submit(event: Scrobble) {
|
||||
try {
|
||||
when (event) {
|
||||
|
||||
Reference in New Issue
Block a user