Merge branch 'main' into fea/live-tv

This commit is contained in:
Damontecres 2025-10-22 21:05:45 -04:00
commit e0840bef3f
No known key found for this signature in database
4 changed files with 120 additions and 34 deletions

View file

@ -37,6 +37,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.AppUpgradeHandler
import com.github.damontecres.wholphin.util.ServerEventListener
import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint
@ -66,6 +67,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var appUpgradeHandler: AppUpgradeHandler
@Inject
lateinit var serverEventListener: ServerEventListener
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -146,6 +150,9 @@ class MainActivity : AppCompatActivity() {
}
}
}
LaunchedEffect(server) {
serverEventListener.init()
}
ApplicationContent(
user = user,
server = server,

View file

@ -32,6 +32,8 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
@ -51,6 +53,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -60,6 +64,7 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
@ -69,6 +74,8 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo
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.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.inWholeTicks
@ -148,6 +155,7 @@ class PlaybackViewModel
}
}
addCloseable { player.release() }
subscribe()
}
fun init(
@ -741,6 +749,38 @@ class PlaybackViewModel
activityListener?.release()
player.release()
}
fun subscribe() {
api.webSocket
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let {
when (it.command) {
PlaystateCommand.STOP -> {
release()
navigationManager.goBack()
}
PlaystateCommand.PAUSE -> player.pause()
PlaystateCommand.UNPAUSE -> player.play()
PlaystateCommand.NEXT_TRACK -> playUpNextUp()
PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) }
PlaystateCommand.REWIND ->
player.seekBack(
preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
)
PlaystateCommand.FAST_FORWARD ->
player.seekForward(
preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
)
PlaystateCommand.PLAY_PAUSE -> if (player.isPlaying) player.pause() else player.play()
}
}
}.launchIn(viewModelScope)
}
}
data class CurrentPlayback(

View file

@ -0,0 +1,69 @@
package com.github.damontecres.wholphin.util
import android.content.Context
import android.widget.Toast
import com.github.damontecres.wholphin.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.GeneralCommandMessage
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerEventListener
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
@param:IoCoroutineScope private val scope: CoroutineScope,
) {
init {
setupListeners()
}
fun init() {
scope.launch(ExceptionHandler()) {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands =
listOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
),
supportsMediaControl = true,
)
}
}
fun setupListeners() {
Timber.v("Subscribing to WebSocket")
api.webSocket
.subscribe<GeneralCommandMessage>()
.onEach { message ->
if (message.data?.name in
setOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
)
) {
val header = message.data?.arguments["Header"]
val text =
message.data?.arguments["Text"] ?: message.data?.arguments["String"]
val toast =
listOfNotNull(header, text)
.joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG)
}
}.launchIn(scope)
}
}

View file

@ -20,8 +20,6 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -38,11 +36,6 @@ class TrackActivityPlaybackListener(
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 {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStart(
@ -60,28 +53,13 @@ class TrackActivityPlaybackListener(
),
)
}
val saveActivityInterval = 10.seconds.inWholeMilliseconds
val delay = 1.seconds.inWholeMilliseconds
val delay = 5.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
saveActivity(-1L)
} catch (ex: Exception) {
Timber.Forest.w(ex, "Exception during track activity timer")
}
@ -108,14 +86,7 @@ class TrackActivityPlaybackListener(
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
this.isPlaying.set(isPlaying)
if (!isPlaying) {
val diff = currentDurationMilliseconds.getAndSet(0)
if (diff > 0) {
totalPlayDurationMilliseconds.addAndGet(diff)
saveActivity(-1)
}
}
saveActivity(-1)
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -127,12 +98,11 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
// val totalDuration = totalPlayDurationMilliseconds.get()
val calcPosition =
withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition).milliseconds
}
Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
// Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = itemPlayback.itemId,