From 27d0a17a00a5d1d7d78a6e4630d1bfc7945ea10f Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 22 Oct 2025 21:04:25 -0400 Subject: [PATCH] Support remote media control commands (#58) Adds support for remote media controls (play, pause, etc) and displaying messages. --- .../damontecres/wholphin/MainActivity.kt | 7 ++ .../wholphin/ui/playback/PlaybackViewModel.kt | 40 +++++++++++ .../wholphin/util/ServerEventListener.kt | 69 +++++++++++++++++++ .../util/TrackActivityPlaybackListener.kt | 38 ++-------- 4 files changed, 120 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index f100cb48..46f49ae2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index a741911a..10c51e2f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData @@ -50,6 +52,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 @@ -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.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 @@ -68,6 +73,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 @@ -147,6 +154,7 @@ class PlaybackViewModel } } addCloseable { player.release() } + subscribe() } fun init( @@ -734,6 +742,38 @@ class PlaybackViewModel activityListener?.release() player.release() } + + fun subscribe() { + api.webSocket + .subscribe() + .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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt new file mode 100644 index 00000000..6c819a8d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt @@ -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() + .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) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 41c23ea5..48bc464d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -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( @@ -59,28 +52,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") } @@ -106,14 +84,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) { @@ -125,12 +96,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,