Skip to content

Commit

Permalink
Migrate WebSocket code to new SocketApi from our SDK
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsvanvelzen committed Mar 28, 2022
1 parent a39de4e commit 083e7c1
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 240 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.jellyfin.androidtv.auth.SessionRepository
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.util.AutoBitrate
import org.koin.android.ext.android.get
Expand Down Expand Up @@ -61,6 +62,7 @@ class JellyfinApplication : TvApp() {
suspend fun onSessionStart() {
val workManager by inject<WorkManager>()
val autoBitrate by inject<AutoBitrate>()
val socketListener by inject<SocketHandler>()

// Cancel all current workers
workManager.cancelAllWork().await()
Expand All @@ -76,6 +78,8 @@ class JellyfinApplication : TvApp() {

// Detect auto bitrate
GlobalScope.launch(Dispatchers.IO) { autoBitrate.detect() }

socketListener.updateSession()
}

override fun attachBaseContext(base: Context?) {
Expand Down
19 changes: 0 additions & 19 deletions app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,10 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.JellyfinApplication
import org.jellyfin.androidtv.util.apiclient.callApi
import org.jellyfin.androidtv.util.apiclient.callApiEmpty
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.entities.MediaType
import org.jellyfin.apiclient.model.session.ClientCapabilities
import org.jellyfin.apiclient.model.session.GeneralCommandType
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber

Expand Down Expand Up @@ -61,21 +57,6 @@ class ApiBinder(
val user = callApi<UserDto> { callback -> api.GetUserAsync(session.userId.toString(), callback) }
application.currentUser = user

callApiEmpty { callback ->
api.ReportCapabilities(ClientCapabilities().apply {
playableMediaTypes = arrayListOf(MediaType.Video, MediaType.Audio)
supportsMediaControl = true
supportedCommands = arrayListOf(
GeneralCommandType.DisplayContent.toString(),
GeneralCommandType.DisplayMessage.toString(),
)
}, callback)
}

// Connect to WebSocket AFTER HTTP connection confirmed working
// to catch exceptions not catchable with the legacy websocket client
api.ensureWebSocket()

return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package org.jellyfin.androidtv.data.eventhandling

import android.content.Context
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jellyfin.androidtv.TvApp
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
import org.jellyfin.apiclient.model.entities.MediaType
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.sockets.SocketInstance
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
import org.jellyfin.sdk.api.sockets.addListener
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.extensions.getValue
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
import org.jellyfin.sdk.model.socket.PlayMessage
import org.jellyfin.sdk.model.socket.PlayStateMessage
import timber.log.Timber
import java.util.UUID

class SocketHandler(
private val context: Context,
private val api: ApiClient,
private val dataRefreshService: DataRefreshService,
private val mediaManager: MediaManager,
) {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private var socketInstance: SocketInstance? = null

suspend fun updateSession() {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
supportsMediaControl = true,
supportedCommands = listOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
),
)

if (socketInstance != null) socketInstance?.updateCredentials()
else socketInstance = createInstance()
}

private fun createInstance() = api.ws().apply {
// Library
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }

// Media playback
addListener<PlayMessage> { message -> onPlayMessage(message) }
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }

// General commands
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
val itemId by message
val itemUuid = itemId?.toUUIDOrNull()

if (itemUuid != null) onDisplayContent(itemUuid)
}
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
val header by message
val text by message
val string by message

onDisplayMessage(header, text ?: string)
}
}

private fun onLibraryChanged(info: LibraryUpdateInfo) {
Timber.d(buildString {
appendLine("Library changed.")
appendLine("Added ${info.itemsAdded!!.size} items")
appendLine("Removed ${info.itemsRemoved!!.size} items")
appendLine("Updated ${info.itemsUpdated!!.size} items")
})

if (info.itemsAdded!!.any() || info.itemsRemoved!!.any())
dataRefreshService.lastLibraryChange = System.currentTimeMillis()
}

private fun onPlayMessage(message: PlayMessage) {
val itemId = message.request.itemIds?.firstOrNull() ?: return

PlaybackHelper.retrieveAndPlay(
itemId.toString(),
false,
message.request.startPositionTicks,
context
)
}

@Suppress("ComplexMethod")
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.request.command}")
val playbackController = TvApp.getApplication()?.playbackController
// Audio playback uses the mediaManager, video playback and live tv use the playbackController
if (mediaManager.isAudioPlayerInitialized) when (message.request.command) {
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
// Not implemented
PlaystateCommand.SEEK,
PlaystateCommand.REWIND,
PlaystateCommand.FAST_FORWARD -> Unit
} else when (message.request.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
)
// FIXME get rewind/forward amount from displayprefs
PlaystateCommand.REWIND -> playbackController?.skip(REWIND_MS)
PlaystateCommand.FAST_FORWARD -> playbackController?.skip(FORWARD_MS)
}
}

// FIXME: Add an ItemLauncher function that accepts SDK BaseItemDto
// Add "GeneralCommandType.DISPLAY_CONTENT" to supportedCommands in capabilities to receive
// these messages. Otherwise this function is never called.
private fun onDisplayContent(itemId: UUID) {
val playbackController = TvApp.getApplication()?.playbackController

if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
Timber.i("Not launching $itemId: playback in progress")
return
}

Timber.i("Launching $itemId")

coroutineScope.launch {
val item by api.userLibraryApi.getItem(itemId = itemId)
// ItemLauncher.launch(item)
}
}

private fun onDisplayMessage(header: String?, text: String?) {
val toastMessage = buildString {
if (!header.isNullOrBlank()) append(header, ": ")
append(text)
}

runBlocking(Dispatchers.Main) {
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}
}

companion object {
const val TICKS_TO_MS = 10000L
const val REWIND_MS = -11000
const val FORWARD_MS = 30000
}
}
Loading

0 comments on commit 083e7c1

Please sign in to comment.