mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Support remote media control commands (#58)
Adds support for remote media controls (play, pause, etc) and displaying messages.
This commit is contained in:
parent
b5771db54e
commit
27d0a17a00
4 changed files with 120 additions and 34 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
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)
|
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,15 +84,8 @@ class TrackActivityPlaybackListener(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
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) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue