diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index b06d0b7c1..e1046a32c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -69,6 +69,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false + pendingSeekScrobbleRestart = false seekProgressSyncJob?.cancel() seekProgressSyncJob = null accumulatedSeekResetJob?.cancel() @@ -335,14 +336,23 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() { if (playbackSnapshot.isEnded) { flushWatchProgress(TrackingScrobbleAction.STOP) previousIsPlaying = false + pendingSeekScrobbleRestart = false return@LaunchedEffect } if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { + pendingSeekScrobbleRestart = false flushWatchProgress(TrackingScrobbleAction.PAUSE) } - if (!previousIsPlaying && playbackSnapshot.isPlaying) { + if (playbackSnapshot.isPlaying && pendingSeekScrobbleRestart) { + pendingSeekScrobbleRestart = false + if (hasRequestedScrobbleStartForCurrentItem) { + emitTrackingSeekScrobbleStart() + } else { + emitTrackingScrobbleStart() + } + } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { emitTrackingScrobbleStart() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 983b6637f..33bcf36f4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -59,6 +59,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f) lastProgressPersistEpochMs = 0L previousIsPlaying = false + pendingSeekScrobbleRestart = false autoFetchedAddonSubtitlesForKey = null trackPreferenceRestoreApplied = false preferredAudioSelectionApplied = false @@ -70,6 +71,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { lastResetVideoIdentity = videoIdentity hasRequestedScrobbleStartForCurrentItem = false scrobbleStartRequestGeneration = 0L + pendingSeekScrobbleRestart = false hasSentCompletionScrobbleForCurrentItem = false currentTrackingMedia = null } @@ -179,6 +181,7 @@ private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal( } currentTrackingMedia = null hasRequestedScrobbleStartForCurrentItem = false + pendingSeekScrobbleRestart = false scrobbleStartRequestGeneration += 1L } @@ -195,6 +198,28 @@ internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { } } +internal fun shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble: Boolean, + progressPercent: Float, +): Boolean = hasActiveScrobble && progressPercent >= 1f && progressPercent < 80f + +internal fun PlayerScreenRuntime.emitTrackingSeekScrobbleStart() { + val mediaSnapshot = currentTrackingMedia + val inputsSnapshot = snapshotTrackingScrobbleItemInputs() + scope.launch { + val media = mediaSnapshot ?: inputsSnapshot.buildMedia() + if (!media.hasResolvableIdentity) return@launch + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.START, + event = TrackingScrobbleEvent( + media = media, + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), + ) + } +} + internal fun PlayerScreenRuntime.tryShowParentalGuide() { if (!playerSettingsUiState.showParentalGuide) return if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { @@ -230,6 +255,7 @@ internal fun PlayerScreenRuntime.flushWatchProgress( } internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { + val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying seekProgressSyncJob?.cancel() seekProgressSyncJob = scope.launch { delay(PlayerSeekProgressSyncDebounceMs) @@ -238,6 +264,42 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { snapshot = playbackSnapshot, ) + val progressPercent = currentPlaybackProgressPercent() + if ( + !shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = hasRequestedScrobbleStartForCurrentItem, + progressPercent = progressPercent, + ) + ) { + return@launch + } + + val media = currentTrackingMedia ?: currentTrackingMedia() + if (!media.hasResolvableIdentity) return@launch + val stopEvent = TrackingScrobbleEvent( + media = media, + progressPercent = progressPercent.toDouble(), + ) + scope.launch { + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.STOP, + event = stopEvent, + ) + if (!shouldRestartScrobbleAfterSeek || !shouldPlay || playbackSnapshot.isEnded) return@launch + if (playbackSnapshot.isPlaying) { + pendingSeekScrobbleRestart = false + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.START, + event = stopEvent.copy( + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), + ) + } else { + pendingSeekScrobbleRestart = true + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index b0ca89161..65e71717f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -149,6 +149,7 @@ internal class PlayerScreenRuntime( var previousIsPlaying by mutableStateOf(false) var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false) var scrobbleStartRequestGeneration by mutableStateOf(0L) + var pendingSeekScrobbleRestart by mutableStateOf(false) var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false) var currentTrackingMedia by mutableStateOf(null) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt index 23e83be78..58c38637f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt @@ -35,8 +35,43 @@ object TrackingScrobbleCoordinator { } return failures } + + suspend fun scrobbleSeek( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + TrackingProviderRegistry.ensureLoaded() + val failures = dispatchTrackingSeekScrobble( + scrobblers = TrackingProviderRegistry.connectedScrobblers(), + profileId = profileId, + action = action, + event = event, + ) + failures.forEach { failure -> + log.w(failure.cause) { + "${failure.providerId.storageId} seek scrobble ${action.wireValue} failed" + } + } + return failures + } } +internal suspend fun dispatchTrackingSeekScrobble( + scrobblers: Collection, + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, +): List = dispatchTrackingScrobble( + scrobblers = scrobblers.filter { scrobbler -> + scrobbler.seekScrobblePolicy == TrackingSeekScrobblePolicy.STOP_AND_RESTART + }, + profileId = profileId, + action = action, + event = event, +) + internal suspend fun dispatchTrackingScrobble( scrobblers: Collection, profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt index b74afa369..b1ecca30c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -19,6 +19,11 @@ enum class TrackingScrobbleAction(val wireValue: String) { STOP("stop"), } +enum class TrackingSeekScrobblePolicy { + NONE, + STOP_AND_RESTART, +} + data class TrackingHistoryItem( val media: TrackingMediaReference, val watchedAtEpochMs: Long? = null, @@ -78,6 +83,8 @@ interface TrackingHistoryWriter { interface TrackingScrobbler { val providerId: TrackingProviderId + val seekScrobblePolicy: TrackingSeekScrobblePolicy + get() = TrackingSeekScrobblePolicy.NONE suspend fun scrobble( profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 632f46d33..30958ffd7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -11,6 +11,7 @@ import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.TrackingScrobbler +import com.nuvio.app.features.tracking.TrackingSeekScrobblePolicy import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.serialization.SerialName @@ -75,6 +76,8 @@ internal fun TrackingMediaReference.toTraktEpisodeMappingInput(): TraktEpisodeMa internal object TraktScrobbleRepository : TrackingScrobbler { override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val seekScrobblePolicy: TrackingSeekScrobblePolicy = + TrackingSeekScrobblePolicy.STOP_AND_RESTART private data class ScrobbleStamp( val profileId: Int, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt index 1d9091bb9..608c48680 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt @@ -5,7 +5,9 @@ import androidx.compose.ui.Modifier import com.nuvio.app.features.streams.StreamsUiState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class PlayerScreenRuntimeStateTest { @@ -21,6 +23,28 @@ class PlayerScreenRuntimeStateTest { assertEquals("addon-id", selectedFilter.value) } + @Test + fun seekScrobbleUpdate_requiresActiveIncompletePlayback() { + assertTrue( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = true, + progressPercent = 50f, + ), + ) + assertFalse( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = false, + progressPercent = 50f, + ), + ) + assertFalse( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = true, + progressPercent = 80f, + ), + ) + } + private fun testPlayerScreenArgs() = PlayerScreenArgs( profileId = 1, title = "Title", diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt index a70b1ccc4..87dc33bae 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt @@ -29,8 +29,35 @@ class TrackingScrobbleCoordinatorTest { assertEquals(listOf(TrackingProviderId.SIMKL), failures.map(TrackingScrobbleFailure::providerId)) } + @Test + fun `seek fanout targets only providers that restart scrobbles`() = runBlocking { + val trakt = FakeScrobbler( + providerId = TrackingProviderId.TRAKT, + seekScrobblePolicy = TrackingSeekScrobblePolicy.STOP_AND_RESTART, + ) + val simkl = FakeScrobbler(TrackingProviderId.SIMKL) + val event = TrackingScrobbleEvent( + media = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + ids = TrackingExternalIds(imdb = "tt0111161"), + ), + progressPercent = 55.0, + ) + + dispatchTrackingSeekScrobble( + scrobblers = listOf(trakt, simkl), + profileId = 2, + action = TrackingScrobbleAction.STOP, + event = event, + ) + + assertEquals(1, trakt.callCount) + assertEquals(0, simkl.callCount) + } + private class FakeScrobbler( override val providerId: TrackingProviderId, + override val seekScrobblePolicy: TrackingSeekScrobblePolicy = TrackingSeekScrobblePolicy.NONE, private val failure: Throwable? = null, ) : TrackingScrobbler { var callCount: Int = 0