From 611da0d60e129be5bf46eaac836b1b73fb8e5e9e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Sep 2025 01:31:11 -0400 Subject: [PATCH] Handle playback progress --- .../dolphin/ui/playback/PlaybackContent.kt | 4 +- .../dolphin/ui/playback/PlaybackViewModel.kt | 25 +++- .../playback/TrackActivityPlaybackListener.kt | 121 ++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index 439d02e4..5fed78ce 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -50,6 +50,7 @@ import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.stashapp.ui.components.playback.SkipIndicator import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState +import kotlin.time.Duration.Companion.milliseconds @OptIn(UnstableApi::class) @Composable @@ -158,8 +159,7 @@ fun PlaybackContent( val showSkipProgress = true // TODO get from preferences if (showSkipProgress) { duration?.let { - val percent = - skipPosition.toFloat() / (it.inWholeMilliseconds).toFloat() + val percent = (skipPosition.milliseconds / it).toFloat() Box( modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 3cff2a34..900e76d2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -16,11 +16,12 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.PlaybackInfoDto +import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID import javax.inject.Inject import kotlin.time.Duration -import kotlin.time.Duration.Companion.nanoseconds enum class TranscodeType { DIRECT_PLAY, @@ -73,7 +74,21 @@ class PlaybackViewModel ) { if (item != null) { title.value = item.name - subtitle.value = item.episodeTitle + if (item.type == BaseItemKind.EPISODE) { + val season = item.parentIndexNumber?.toString()?.padStart(2, '0') + val episode = item.indexNumber?.toString()?.padStart(2, '0') + // TODO multi episode support + if (season != null && episode != null) { + subtitle.value = + buildString { + if (item.seriesName != null) { + append(item.seriesName) + append(" - ") + } + append("S${season}E$episode") + } + } + } } viewModelScope.launch(Dispatchers.IO) { val response = @@ -119,7 +134,11 @@ class PlaybackViewModel } val decision = StreamDecision(itemId, mediaUrl, transcodeType) withContext(Dispatchers.Main) { - duration.value = source.runTimeTicks?.nanoseconds + val activityListener = + TrackActivityPlaybackListener(api, itemId, player) + addCloseable { activityListener.release() } + player.addListener(activityListener) + duration.value = source.runTimeTicks?.ticks stream.value = decision player.setMediaItem(decision.mediaItem) player.prepare() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt new file mode 100644 index 00000000..19dae32a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/TrackActivityPlaybackListener.kt @@ -0,0 +1,121 @@ +package com.github.damontecres.dolphin.ui.playback + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.model.api.PlayMethod +import org.jellyfin.sdk.model.api.PlaybackOrder +import org.jellyfin.sdk.model.api.PlaybackProgressInfo +import org.jellyfin.sdk.model.api.RepeatMode +import org.jellyfin.sdk.model.extensions.inWholeTicks +import timber.log.Timber +import java.util.Timer +import java.util.TimerTask +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Listens to playback and periodically saves playback activity to the server + */ +@OptIn(UnstableApi::class) +class TrackActivityPlaybackListener( + private val api: ApiClient, + private val itemId: UUID, + private val player: Player, +) : Player.Listener { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val task: TimerTask + + private var totalPlayDurationMilliseconds = AtomicLong(0) + private var currentDurationMilliseconds = AtomicLong(0) + private var isPlaying = AtomicBoolean(false) + private var incrementedPlayCount = AtomicBoolean(false) + + init { + val saveActivityInterval = 10.seconds.inWholeMilliseconds + val delay = 1.seconds.inWholeMilliseconds + // Every x seconds, check if the video is playing + task = + object : TimerTask() { + private var timestamp = System.currentTimeMillis() + + override fun run() { + try { + val now = System.currentTimeMillis() + if (isPlaying.get()) { + val diffTime = now - timestamp + // If it is playing, add the interval to currently tracked duration + val current = currentDurationMilliseconds.addAndGet(diffTime) + // TODO currentDuration.getAndUpdate would be better, but requires API 24+ + if (current >= saveActivityInterval) { + // If the accumulated currently tracked duration > threshold, reset it and save activity + totalPlayDurationMilliseconds.addAndGet(current) + saveActivity(-1L) + } + } + timestamp = now + } catch (ex: Exception) { + Timber.w(ex, "Exception during track activity timer") + } + } + } + TIMER.schedule(task, delay, delay) + } + + fun release() { + task.cancel() + TIMER.purge() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + this.isPlaying.set(isPlaying) + if (!isPlaying) { + val diff = currentDurationMilliseconds.getAndSet(0) + if (diff > 0) { + totalPlayDurationMilliseconds.addAndGet(diff) + saveActivity(-1) + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + Timber.v("onPlaybackStateChanged STATE_ENDED") + // TODO mark as watched + } + } + + private fun saveActivity(position: Long) { + coroutineScope.launch { + val totalDuration = totalPlayDurationMilliseconds.get() + val calcPosition = (if (position >= 0) position else player.currentPosition).milliseconds.inWholeTicks + // TODO parameters + api.playStateApi.reportPlaybackProgress( + PlaybackProgressInfo( + itemId = itemId, + positionTicks = calcPosition, + canSeek = true, + isPaused = !player.isPlaying, + isMuted = false, + playMethod = PlayMethod.DIRECT_PLAY, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + ), + ) + } + } + + companion object { + private const val TAG = "TrackActivityPlaybackListener" + + private val TIMER by lazy { Timer("$TAG-timer", true) } + } +}