Support remote media control commands (#58)

Adds support for remote media controls (play, pause, etc) and displaying
messages.
This commit is contained in:
damontecres 2025-10-22 21:04:25 -04:00 committed by GitHub
parent b5771db54e
commit 27d0a17a00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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.nav.NavigationManager
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.AppUpgradeHandler 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.UpdateChecker
import com.github.damontecres.wholphin.util.profile.createDeviceProfile import com.github.damontecres.wholphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -66,6 +67,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var appUpgradeHandler: AppUpgradeHandler lateinit var appUpgradeHandler: AppUpgradeHandler
@Inject
lateinit var serverEventListener: ServerEventListener
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -146,6 +150,9 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
LaunchedEffect(server) {
serverEventListener.init()
}
ApplicationContent( ApplicationContent(
user = user, user = user,
server = server, 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager 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.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
@ -50,6 +52,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -59,6 +63,7 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.trickplayApi import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
@ -68,6 +73,8 @@ 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.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto 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.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.inWholeTicks
@ -147,6 +154,7 @@ class PlaybackViewModel
} }
} }
addCloseable { player.release() } addCloseable { player.release() }
subscribe()
} }
fun init( fun init(
@ -734,6 +742,38 @@ class PlaybackViewModel
activityListener?.release() activityListener?.release()
player.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( 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 timber.log.Timber
import java.util.Timer import java.util.Timer
import java.util.TimerTask 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.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -38,11 +36,6 @@ class TrackActivityPlaybackListener(
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask 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 { init {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStart( api.playStateApi.reportPlaybackStart(
@ -59,28 +52,13 @@ class TrackActivityPlaybackListener(
), ),
) )
} }
val saveActivityInterval = 10.seconds.inWholeMilliseconds val delay = 5.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
task = task =
object : TimerTask() { object : TimerTask() {
private var timestamp = System.currentTimeMillis()
override fun run() { override fun run() {
try { try {
val now = System.currentTimeMillis() saveActivity(-1L)
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) { } catch (ex: Exception) {
Timber.Forest.w(ex, "Exception during track activity timer") Timber.Forest.w(ex, "Exception during track activity timer")
} }
@ -106,14 +84,7 @@ class TrackActivityPlaybackListener(
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
this.isPlaying.set(isPlaying) saveActivity(-1)
if (!isPlaying) {
val diff = currentDurationMilliseconds.getAndSet(0)
if (diff > 0) {
totalPlayDurationMilliseconds.addAndGet(diff)
saveActivity(-1)
}
}
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -125,12 +96,11 @@ class TrackActivityPlaybackListener(
private fun saveActivity(position: Long) { private fun saveActivity(position: Long) {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
// val totalDuration = totalPlayDurationMilliseconds.get()
val calcPosition = val calcPosition =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition).milliseconds (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( api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo( PlaybackProgressInfo(
itemId = itemPlayback.itemId, itemId = itemPlayback.itemId,