diff --git a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt index db86ff1e12..a0e038d229 100644 --- a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt +++ b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt @@ -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 @@ -61,6 +62,7 @@ class JellyfinApplication : TvApp() { suspend fun onSessionStart() { val workManager by inject() val autoBitrate by inject() + val socketListener by inject() // Cancel all current workers workManager.cancelAllWork().await() @@ -76,6 +78,8 @@ class JellyfinApplication : TvApp() { // Detect auto bitrate GlobalScope.launch(Dispatchers.IO) { autoBitrate.detect() } + + socketListener.updateSession() } override fun attachBaseContext(base: Context?) { diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt b/app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt index d9c270c930..8208b4ff5a 100644 --- a/app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt +++ b/app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt @@ -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 @@ -61,21 +57,6 @@ class ApiBinder( val user = callApi { 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 } } diff --git a/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/SocketHandler.kt b/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/SocketHandler.kt new file mode 100644 index 0000000000..d372d95fe8 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/SocketHandler.kt @@ -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 { message -> onLibraryChanged(message.info) } + + // Media playback + addListener { message -> onPlayMessage(message) } + addListener { 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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/TvApiEventListener.java b/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/TvApiEventListener.java deleted file mode 100644 index 14ea7f7ba7..0000000000 --- a/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/TvApiEventListener.java +++ /dev/null @@ -1,218 +0,0 @@ -package org.jellyfin.androidtv.data.eventhandling; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.Handler; -import android.os.Looper; -import android.widget.Toast; - -import org.jellyfin.androidtv.R; -import org.jellyfin.androidtv.TvApp; -import org.jellyfin.androidtv.data.model.DataRefreshService; -import org.jellyfin.androidtv.data.querying.StdItemQuery; -import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem; -import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher; -import org.jellyfin.androidtv.ui.playback.MediaManager; -import org.jellyfin.androidtv.ui.playback.PlaybackController; -import org.jellyfin.androidtv.ui.playback.PlaybackLauncher; -import org.jellyfin.androidtv.ui.playback.PlaybackOverlayActivity; -import org.jellyfin.androidtv.util.Utils; -import org.jellyfin.androidtv.util.apiclient.PlaybackHelper; -import org.jellyfin.apiclient.interaction.ApiClient; -import org.jellyfin.apiclient.interaction.ApiEventListener; -import org.jellyfin.apiclient.interaction.Response; -import org.jellyfin.apiclient.model.dto.BaseItemDto; -import org.jellyfin.apiclient.model.entities.LibraryUpdateInfo; -import org.jellyfin.apiclient.model.querying.ItemFields; -import org.jellyfin.apiclient.model.querying.ItemsResult; -import org.jellyfin.apiclient.model.session.BrowseRequest; -import org.jellyfin.apiclient.model.session.MessageCommand; -import org.jellyfin.apiclient.model.session.PlayRequest; -import org.jellyfin.apiclient.model.session.PlaystateRequest; -import org.jellyfin.apiclient.model.session.SessionInfoDto; -import org.koin.java.KoinJavaComponent; - -import java.util.Arrays; - -import timber.log.Timber; - -public class TvApiEventListener extends ApiEventListener { - private final DataRefreshService dataRefreshService; - private final MediaManager mediaManager; - private final Handler mainThreadHandler; - - public TvApiEventListener(DataRefreshService dataRefreshService, MediaManager mediaManager) { - this.dataRefreshService = dataRefreshService; - this.mediaManager = mediaManager; - - //handler to interact with the players (exoplayer doesn't allow access from another thread) - mainThreadHandler = new Handler(Looper.getMainLooper()); - } - - @Override - public void onPlaybackStopped(ApiClient client, SessionInfoDto info) { - TvApp app = TvApp.getApplication(); - Timber.d("Got Playback stopped message from server"); - if (info.getUserId().equals(app.getCurrentUser().getId())) { - dataRefreshService.setLastPlayback(System.currentTimeMillis()); - if (info.getNowPlayingItem() == null) return; - switch (info.getNowPlayingItem().getType()) { - case "Movie": - dataRefreshService.setLastMoviePlayback(System.currentTimeMillis()); - break; - case "Episode": - dataRefreshService.setLastTvPlayback(System.currentTimeMillis()); - break; - - } - } - } - - @Override - public void onLibraryChanged(ApiClient client, LibraryUpdateInfo info) { - Timber.d("Library Changed. Added %o items. Removed %o items. Changed %o items.", info.getItemsAdded().size(), info.getItemsRemoved().size(), info.getItemsUpdated().size()); - if (info.getItemsAdded().size() > 0 || info.getItemsRemoved().size() > 0) - dataRefreshService.setLastLibraryChange(System.currentTimeMillis()); - } - - @Override - public void onPlaystateCommand(ApiClient client, PlaystateRequest command) { - PlaybackController playbackController = TvApp.getApplication().getPlaybackController(); - Timber.d("caught playstate command: %s", command.getCommand()); - - switch (command.getCommand()) { - case Stop: - if (mediaManager.getIsAudioPlayerInitialized()) - mainThreadHandler.post(() -> mediaManager.stopAudio(true)); - else { - Activity currentActivity = TvApp.getApplication().getCurrentActivity(); - if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) - mainThreadHandler.post(() -> playbackController.endPlayback()); - if(currentActivity instanceof PlaybackOverlayActivity) - currentActivity.finish(); - } - break; - case Pause: - case Unpause: - case PlayPause: - if (mediaManager.getIsAudioPlayerInitialized()) - mainThreadHandler.post(() -> mediaManager.playPauseAudio()); - else if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) - mainThreadHandler.post(() -> playbackController.playPause()); - break; - case NextTrack: - if (mediaManager.getIsAudioPlayerInitialized() && mediaManager.hasAudioQueueItems()) - mainThreadHandler.post(() -> mediaManager.nextAudioItem()); - else if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) - mainThreadHandler.post(() -> playbackController.next()); - break; - case PreviousTrack: - if (mediaManager.getIsAudioPlayerInitialized() && mediaManager.hasAudioQueueItems()) - mainThreadHandler.post(() -> mediaManager.prevAudioItem()); - else if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) - mainThreadHandler.post(() -> playbackController.prev()); - break; - case Seek: - if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) { - long pos = command.getSeekPositionTicks() / 10000; - mainThreadHandler.post(() -> playbackController.seek(pos)); - } - break; - case Rewind: - if (playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) - mainThreadHandler.post(() -> playbackController.skip(-11000)); - break; - case FastForward: - if (playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) - mainThreadHandler.post(() -> playbackController.skip(30000)); - break; - } - } - - @Override - public void onBrowseCommand(ApiClient client, BrowseRequest command) { - Timber.d("Browse command received"); - if (Utils.isEmpty(command.getItemId())) return; - - mainThreadHandler.post(() -> { - if (TvApp.getApplication().getCurrentActivity() == null || - (TvApp.getApplication().getPlaybackController() != null && (TvApp.getApplication().getPlaybackController().isPlaying() || TvApp.getApplication().getPlaybackController().isPaused()))) { - Timber.i("Command ignored due to no activity or playback in progress"); - return; - } - client.GetItemAsync(command.getItemId(), TvApp.getApplication().getCurrentUser().getId(), new Response() { - @Override - public void onResponse(BaseItemDto response) { - //Create a rowItem and pass to our handler - ItemLauncher.launch(new BaseRowItem(0, response), null, -1, TvApp.getApplication().getCurrentActivity(), true); - } - }); - }); - } - - @Override - public void onPlayCommand(ApiClient client, PlayRequest command) { - mainThreadHandler.post(() -> { - if (TvApp.getApplication().getPlaybackController() != null && (TvApp.getApplication().getPlaybackController().isPlaying() || TvApp.getApplication().getPlaybackController().isPaused())) { - TvApp.getApplication().getCurrentActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - Utils.showToast(TvApp.getApplication().getCurrentActivity(), TvApp.getApplication().getString(R.string.msg_remote_already_playing)); - } - }); - return; - } - if (command.getItemIds().length > 1) { - Timber.i("Playing multiple items by remote request"); - if (TvApp.getApplication().getCurrentActivity() == null) { - Timber.e("No current activity. Cannot play"); - return; - } - int startIndex = command.getStartIndex() == null ? 0 : command.getStartIndex().intValue(); - int startPosition = command.getStartPositionTicks() == null || command.getStartPositionTicks().longValue() == 0 ? 0 : Long.valueOf(command.getStartPositionTicks() / 10000L).intValue(); - Timber.d("got queue start index: %s position %s", startIndex, startPosition); - StdItemQuery query = new StdItemQuery(new ItemFields[]{ - ItemFields.MediaSources, - ItemFields.ChildCount - }); - query.setIds(command.getItemIds()); - KoinJavaComponent.get(ApiClient.class).GetItemsAsync(query, new Response() { - @Override - public void onResponse(ItemsResult response) { - if (response.getItems() != null && response.getItems().length > 0) { - PlaybackLauncher playbackLauncher = KoinJavaComponent.get(PlaybackLauncher.class); - if (playbackLauncher.interceptPlayRequest(TvApp.getApplication(), response.getItems()[0])) return; - - //peek at first item to see what type it is - switch (response.getItems()[0].getMediaType()) { - case "Video": - Class activity = KoinJavaComponent.get(PlaybackLauncher.class).getPlaybackActivityClass(response.getItems()[0].getBaseItemType()); - mediaManager.setCurrentVideoQueue(Arrays.asList(response.getItems())); - Intent intent = new Intent(TvApp.getApplication().getCurrentActivity(), activity); - intent.putExtra("Position", startPosition); - TvApp.getApplication().getCurrentActivity().startActivity(intent); - break; - case "Audio": - mediaManager.playNow(TvApp.getApplication().getCurrentActivity(), Arrays.asList(response.getItems()), startIndex, false); - break; - } - } - } - }); - - } else { - if (command.getItemIds().length > 0) { - Timber.i("Playing single item by remote request"); - Context context = TvApp.getApplication().getCurrentActivity() != null ? TvApp.getApplication().getCurrentActivity() : TvApp.getApplication(); - PlaybackHelper.retrieveAndPlay(command.getItemIds()[0], false, command.getStartPositionTicks(), context); - } - } - }); - } - - @Override - public void onMessageCommand(ApiClient client, MessageCommand command) { - new Handler(TvApp.getApplication().getMainLooper()).post(() -> Toast.makeText(TvApp.getApplication(), command.getText(), Toast.LENGTH_LONG).show()); - } -} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt index 50d61f8cb8..364c786e9c 100644 --- a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt +++ b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt @@ -4,7 +4,7 @@ import androidx.work.WorkManager import org.jellyfin.androidtv.BuildConfig import org.jellyfin.androidtv.auth.ServerRepository import org.jellyfin.androidtv.auth.ServerRepositoryImpl -import org.jellyfin.androidtv.data.eventhandling.TvApiEventListener +import org.jellyfin.androidtv.data.eventhandling.SocketHandler import org.jellyfin.androidtv.data.model.DataRefreshService import org.jellyfin.androidtv.data.repository.UserViewsRepository import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl @@ -16,6 +16,7 @@ import org.jellyfin.androidtv.util.MarkdownRenderer import org.jellyfin.androidtv.util.sdk.legacy import org.jellyfin.apiclient.AppInfo import org.jellyfin.apiclient.android +import org.jellyfin.apiclient.interaction.ApiEventListener import org.jellyfin.apiclient.logging.AndroidLogger import org.jellyfin.apiclient.serialization.GsonJsonSerializer import org.jellyfin.sdk.android.androidDevice @@ -52,6 +53,8 @@ val appModule = module { get().createApi() } + single { SocketHandler(get(), get(userApiClient), get(), get()) } + single(systemApiClient) { // Create an empty API instance, the actual values are set by the SessionRepository get().createApi() @@ -71,7 +74,7 @@ val appModule = module { single { get().createApi( device = get(defaultDeviceInfo).legacy(), - eventListener = TvApiEventListener(get(), get()) + eventListener = ApiEventListener() ) } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java index 70306351f2..8594da48f7 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java @@ -1058,7 +1058,8 @@ public void stop() { } } - public void endPlayback() { + public void endPlayback(Boolean closeActivity) { + if (closeActivity) mFragment.getActivity().finish(); stop(); removePreviousQueueItems(); if (mVideoManager != null) @@ -1068,6 +1069,10 @@ public void endPlayback() { resetPlayerErrors(); } + public void endPlayback(){ + endPlayback(false); + } + private void resetPlayerErrors() { vlcErrorEncountered = false; exoErrorEncountered = false;