Fix TV episode navigation and live resume freshness

This commit is contained in:
justinzeus 2026-11-06 22:41:16 +01:00
parent 54c23e076a
commit 8e40ac0869
34 changed files with 793 additions and 101 deletions

View file

@ -24,6 +24,13 @@ class NavigationManager
log()
}
/** Replace only the current page, preserving every parent entry. */
fun replaceTop(destination: Destination) {
if (backStack.isEmpty()) backStack.add(destination)
else backStack[backStack.lastIndex] = destination
log()
}
/**
* Go to the specified [Destination], but reset the back stack to Home first
*/

View file

@ -82,9 +82,11 @@ class PlayExternalViewModel
) : ViewModel() {
val launched = savedStateHandle.getMutableStateFlow("launched", false)
val state = MutableStateFlow(PlayExternalState())
private var watchScope: String? = null
fun init(destination: Destination) {
Timber.v("init called: %s", destination)
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
val prefs = userPreferencesService.getCurrent()
@ -358,6 +360,7 @@ class PlayExternalViewModel
failed = false,
),
)
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
}
} else {
Timber.w(

View file

@ -830,10 +830,12 @@ class PlaybackViewModel
}
val playbackItemState = PlaybackItemState(playback)
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
val activityListener =
TrackActivityPlaybackListener(
api = api,
player = player,
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
getState = { playbackItemState },
)
player.addListener(activityListener)

View file

@ -0,0 +1,27 @@
package com.github.damontecres.wholphin.util
import android.content.Context
import org.justinvisser.wolfskin.conductarr.FileCacheStore
import java.io.File
import java.io.IOException
import timber.log.Timber
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/** Only an acknowledged stop invalidates the captured server/user's watch projections. */
object PlaybackWatchState {
private val mutableRevisions = MutableStateFlow<Map<String, Long>>(emptyMap())
val revisions = mutableRevisions.asStateFlow()
/** Remove persisted watch grids before publishing the captured user's acknowledgement. */
fun stopped(context: Context, scope: String) {
try {
FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope)
} catch (exception: IOException) {
Timber.w(exception, "Could not invalidate playback watch snapshots")
}
val time = System.currentTimeMillis()
mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) }
}
}

View file

@ -7,6 +7,8 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.api.PlayMethod
@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds
class TrackActivityPlaybackListener(
private val api: ApiClient,
private val player: Player,
private val onStopped: () -> Unit = {},
private val getState: () -> PlaybackItemState?,
) : Player.Listener {
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
private val reports = Mutex()
@Volatile private var released = false
private val task: TimerTask =
object : TimerTask() {
override fun run() {
@ -70,19 +75,23 @@ class TrackActivityPlaybackListener(
val delay = 5.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing
TIMER.schedule(task, delay, delay)
initialized = true
if (!released) {
TIMER.schedule(task, delay, delay)
initialized = true
}
}
}
}
fun release() {
// player.removeListener(this)
if (released) return
released = true
task.cancel()
TIMER.purge()
val position = player.currentPosition.milliseconds
val stoppedState = getState.invoke()
launch("reportPlaybackStopped") {
getState.invoke()?.let { state ->
stoppedState?.let { state ->
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
if (position < Duration.ZERO) {
Timber.w("Negative position when reporting playback stopped: %s", position)
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
liveStreamId = state.liveStreamId,
),
)
onStopped()
}
}
}
@ -117,6 +127,7 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) {
launch("saveActivity") {
if (released) return@launch
getState.invoke()?.let { state ->
val calcPosition =
withContext(WholphinDispatchers.Main) {
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
) {
coroutineScope.launchIO {
try {
block.invoke(this)
reports.withLock { block.invoke(this) }
} catch (ex: Exception) {
Timber.w(ex, "Exception during %s", name)
}