Improvement to playback activity tracking

This commit is contained in:
Damontecres 2025-10-12 21:11:43 -04:00
parent 29e13cba0f
commit 763589fa0d
No known key found for this signature in database
3 changed files with 68 additions and 33 deletions

View file

@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@ -375,8 +377,10 @@ fun Controller(
Text( Text(
text = "Chapters", text = "Chapters",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp, top = 16.dp),
) )
} else {
Spacer(Modifier.height(32.dp))
} }
} }
} }

View file

@ -53,6 +53,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
@ -74,7 +75,7 @@ enum class TranscodeType {
data class StreamDecision( data class StreamDecision(
val itemId: UUID, val itemId: UUID,
val type: TranscodeType, val type: PlayMethod,
val url: String, val url: String,
) )
@ -188,7 +189,7 @@ class PlaybackViewModel
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) }?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty() .orEmpty()
// TODO audio selection based on channel layout, etc // TODO audio selection based on channel layout or preferences or default
val audioLanguage = preferences.userConfig.audioLanguagePreference val audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex = val audioIndex =
if (audioLanguage != null) { if (audioLanguage != null) {
@ -236,22 +237,14 @@ class PlaybackViewModel
SubtitlePlaybackMode.NONE -> null SubtitlePlaybackMode.NONE -> null
} }
Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("base.mediaStreams=${base.mediaStreams}")
Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("subtitleTracks=$subtitleStreams")
Timber.v("audioStreams=$audioStreams") // Timber.v("audioStreams=$audioStreams")
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.audioStreams.value = audioStreams
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
}
val activityListener =
TrackActivityPlaybackListener(api, itemId, player)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
changeStreams( changeStreams(
itemId, itemId,
@ -328,9 +321,9 @@ class PlaybackViewModel
} }
val transcodeType = val transcodeType =
when { when {
source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
source.supportsDirectStream -> TranscodeType.DIRECT_STREAM source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
source.supportsTranscoding -> TranscodeType.TRANSCODE source.supportsTranscoding -> PlayMethod.TRANSCODE
else -> throw Exception("No supported playback method") else -> throw Exception("No supported playback method")
} }
val decision = StreamDecision(itemId, transcodeType, mediaUrl) val decision = StreamDecision(itemId, transcodeType, mediaUrl)
@ -370,9 +363,24 @@ class PlaybackViewModel
subtitleIndex, subtitleIndex,
source.id?.toUUIDOrNull(), source.id?.toUUIDOrNull(),
listOf(), listOf(),
playMethod = transcodeType,
playSessionId = response.playSessionId,
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
}
val activityListener =
TrackActivityPlaybackListener(
api = api,
player = player,
playback = playback,
)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
duration.value = source.runTimeTicks?.ticks duration.value = source.runTimeTicks?.ticks
stream.value = decision stream.value = decision
currentPlayback.value = playback currentPlayback.value = playback
@ -581,6 +589,8 @@ data class CurrentPlayback(
val subtitleIndex: Int?, val subtitleIndex: Int?,
val mediaSourceId: UUID?, val mediaSourceId: UUID?,
val tracks: List<TrackSupport>, val tracks: List<TrackSupport>,
val playMethod: PlayMethod,
val playSessionId: String?,
) )
private val Format.idAsInt: Int? private val Format.idAsInt: Int?

View file

@ -3,21 +3,22 @@ package com.github.damontecres.dolphin.util
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.dolphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi 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.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber import timber.log.Timber
import java.util.Timer import java.util.Timer
import java.util.TimerTask import java.util.TimerTask
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -29,8 +30,8 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class TrackActivityPlaybackListener( class TrackActivityPlaybackListener(
private val api: ApiClient, private val api: ApiClient,
private val itemId: UUID,
private val player: Player, private val player: Player,
var playback: CurrentPlayback,
) : Player.Listener { ) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask private val task: TimerTask
@ -41,6 +42,21 @@ class TrackActivityPlaybackListener(
private var incrementedPlayCount = AtomicBoolean(false) private var incrementedPlayCount = AtomicBoolean(false)
init { init {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = true,
itemId = playback.itemId,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false,
audioStreamIndex = playback.audioIndex,
subtitleStreamIndex = playback.subtitleIndex,
),
)
}
val saveActivityInterval = 10.seconds.inWholeMilliseconds val saveActivityInterval = 10.seconds.inWholeMilliseconds
val delay = 1.seconds.inWholeMilliseconds val delay = 1.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing // Every x seconds, check if the video is playing
@ -74,12 +90,13 @@ class TrackActivityPlaybackListener(
fun release() { fun release() {
task.cancel() task.cancel()
TIMER.purge() TIMER.purge()
coroutineScope.launch(ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = itemId, itemId = playback.itemId,
positionTicks = player.currentPosition.milliseconds.inWholeTicks, positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks },
failed = false, failed = false,
playSessionId = playback.playSessionId,
), ),
) )
} }
@ -98,28 +115,32 @@ class TrackActivityPlaybackListener(
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED) {
Timber.Forest.v("onPlaybackStateChanged STATE_ENDED") Timber.v("onPlaybackStateChanged STATE_ENDED")
// TODO mark as watched saveActivity(player.duration)
} }
} }
private fun saveActivity(position: Long) { private fun saveActivity(position: Long) {
coroutineScope.launch(ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
val totalDuration = totalPlayDurationMilliseconds.get() // val totalDuration = totalPlayDurationMilliseconds.get()
val calcPosition = val calcPosition =
withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition).milliseconds (if (position >= 0) position else player.currentPosition).milliseconds
Timber.Forest.v("saveActivity: itemId=$itemId, pos=$calcPosition") }
// TODO parameters // Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress( api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo( PlaybackProgressInfo(
itemId = itemId, itemId = playback.itemId,
positionTicks = calcPosition.inWholeTicks, positionTicks = calcPosition.inWholeTicks,
canSeek = true, canSeek = true,
isPaused = !player.isPlaying, isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
isMuted = false, isMuted = false,
playMethod = PlayMethod.DIRECT_PLAY, playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
playSessionId = playback.playSessionId,
audioStreamIndex = playback.audioIndex,
subtitleStreamIndex = playback.subtitleIndex,
), ),
) )
} }