Handle playback progress

This commit is contained in:
Damontecres 2025-09-23 01:31:11 -04:00
parent 3be8706ce6
commit 611da0d60e
No known key found for this signature in database
3 changed files with 145 additions and 5 deletions

View file

@ -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

View file

@ -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()

View file

@ -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) }
}
}