diff --git a/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryMediaId.kt b/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryMediaId.kt new file mode 100644 index 00000000..6d40ec8d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryMediaId.kt @@ -0,0 +1,20 @@ +@file:UseSerializers(UUIDSerializer::class) + +package org.jellyfin.mobile.sessionbrowser + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +@Serializable +sealed interface LibraryMediaId { + @Serializable + @SerialName("item") + data class Item(val itemId: UUID, val route: LibraryRoute) : LibraryMediaId + + @Serializable + @SerialName("route") + data class Route(val route: LibraryRoute) : LibraryMediaId +} diff --git a/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryService.kt b/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryService.kt index 49acbc6b..3a14d853 100644 --- a/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryService.kt +++ b/app/src/main/java/org/jellyfin/mobile/sessionbrowser/LibraryService.kt @@ -2,7 +2,6 @@ package org.jellyfin.mobile.sessionbrowser import androidx.media3.common.AudioAttributes import androidx.media3.common.C -import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession @@ -15,41 +14,40 @@ class LibraryService : MediaLibraryService() { private val apiClientController: ApiClientController by inject() private val apiClient: ApiClient by inject() - private var mediaLibrarySession: MediaLibrarySession? = null - private var callback: MediaLibrarySession.Callback? = null - private val playerAudioAttributes = AudioAttributes.Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setUsage(C.USAGE_MEDIA) .build() - private val exoPlayer: Player by lazy { + private val exoPlayer: ExoPlayer by lazy { ExoPlayer.Builder(this).build().apply { setAudioAttributes(playerAudioAttributes, true) setHandleAudioBecomingNoisy(true) } } + private val mediaLibrarySession: MediaLibrarySession by lazy { + MediaLibrarySession.Builder(this, exoPlayer, callback) + .build() + } + + private val callback: MediaLibrarySession.Callback by lazy { + SessionBrowserCallback(this, apiClient) + } + override fun onGetSession( controllerInfo: MediaSession.ControllerInfo, - ): MediaLibrarySession? = mediaLibrarySession + ): MediaLibrarySession = mediaLibrarySession override fun onCreate() { super.onCreate() runBlocking { apiClientController.loadSavedServerUser() } - - if (callback == null) callback = SessionBrowserCallback(this, apiClient) - mediaLibrarySession = MediaLibrarySession.Builder(this, exoPlayer, callback!!) - .build() } override fun onDestroy() { - mediaLibrarySession?.run { - player.release() - release() - mediaLibrarySession = null - } + exoPlayer.release() + mediaLibrarySession.release() super.onDestroy() } diff --git a/app/src/main/java/org/jellyfin/mobile/sessionbrowser/SessionBrowserCallback.kt b/app/src/main/java/org/jellyfin/mobile/sessionbrowser/SessionBrowserCallback.kt index afd51c0e..19e5a48b 100644 --- a/app/src/main/java/org/jellyfin/mobile/sessionbrowser/SessionBrowserCallback.kt +++ b/app/src/main/java/org/jellyfin/mobile/sessionbrowser/SessionBrowserCallback.kt @@ -42,8 +42,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamProtocol -import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @Suppress("InjectDispatcher") @@ -79,6 +77,7 @@ class SessionBrowserCallback( private val LibraryRoute.page get() = pages.firstOrNull { page -> page.route == this::class } private fun LibraryPageElement.Item.toMediaItem( + route: LibraryRoute, groupTitle: String? = null, ): MediaItem = MediaItem.Builder().apply { val extras = bundleOf() @@ -95,9 +94,9 @@ class SessionBrowserCallback( } extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle) extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle) - setMediaId(Json.encodeToString(action.route)) + setMediaId(Json.encodeToString(LibraryMediaId.Route(action.route))) } else if (action is LibraryItemAction.Play) { - setMediaId(action.item.id.toString()) + setMediaId(Json.encodeToString(LibraryMediaId.Item(action.item.id, route))) } setMediaMetadata( @@ -126,48 +125,31 @@ class SessionBrowserCallback( .appendPath(context.resources.getResourceEntryName(this)) .build() - private fun List.toMediaItems(): List = flatMap { element -> + private fun List.toMediaItems(route: LibraryRoute): List = flatMap { element -> when (element) { - is LibraryPageElement.Group -> element.items.map { item -> item.toMediaItem(groupTitle = element.title) } - is LibraryPageElement.Item -> listOf(element.toMediaItem()) + is LibraryPageElement.Group -> element.items.map { item -> item.toMediaItem(route, groupTitle = element.title) } + is LibraryPageElement.Item -> listOf(element.toMediaItem(route)) } } - private fun createPageResult( - route: LibraryRoute, - params: LibraryParams? = null, - ): LibraryResult { - val page = route.page - - return if (page == null) { - LibraryResult.ofError( - SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page)), - params ?: LibraryParams.Builder().build(), - ) - } else { - LibraryResult.ofItem( - MediaItem.Builder().apply { - val extras = Bundle() - val contentStyle = when (page.grid) { - true -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM - false -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM - } - extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle) - extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle) - - setMediaId(Json.encodeToString(route)) - setMediaMetadata( - MediaMetadata.Builder().apply { - setIsBrowsable(true) - setIsPlayable(false) - setExtras(extras) - }.build(), - ) - }.build(), - params, - ) + private fun createPageMediaItem(route: LibraryRoute, page: LibraryPage<*> = route.page!!) = MediaItem.Builder().apply { + val extras = Bundle() + val contentStyle = when (page.grid ?: false) { + true -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM + false -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM } - } + extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle) + extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle) + + setMediaId(Json.encodeToString(LibraryMediaId.Route(route))) + setMediaMetadata( + MediaMetadata.Builder().apply { + setIsBrowsable(true) + setIsPlayable(false) + setExtras(extras) + }.build(), + ) + }.build() private suspend fun createPageContentResult( route: LibraryRoute, @@ -191,7 +173,7 @@ class SessionBrowserCallback( route, pageIndex * pageSize, minOf(pageSize, MAX_PAGE_SIZE), - )?.toMediaItems() + )?.toMediaItems(route) return if (items == null) { LibraryResult.ofError( @@ -214,7 +196,17 @@ class SessionBrowserCallback( } Timber.d("onGetLibraryRoot $session $browser $params $route") - createPageResult(route, params) + + val page = route.page + + if (page == null) { + LibraryResult.ofError( + SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page)), + params ?: LibraryParams.Builder().build(), + ) + } else { + LibraryResult.ofItem(createPageMediaItem(route, page), params) + } } override fun onGetChildren( @@ -225,15 +217,17 @@ class SessionBrowserCallback( pageSize: Int, params: LibraryParams?, ): ListenableFuture>> = CoroutineScope(Dispatchers.IO).future { - val route = runCatching { Json.decodeFromString(parentId) }.getOrNull() Timber.d("onGetChildren $parentId $page $pageSize $params") - if (route == null) { + val libraryMediaId = runCatching { Json.decodeFromString(parentId) }.getOrNull() + Timber.d("onGetChildren $libraryMediaId") + + if (libraryMediaId !is LibraryMediaId.Route) { LibraryResult.ofError( SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_unknown_page)), ) } else { - createPageContentResult(route, params, page, pageSize) + createPageContentResult(libraryMediaId.route, params, page, pageSize) } } @@ -269,14 +263,26 @@ class SessionBrowserCallback( ): ListenableFuture> = CoroutineScope(Dispatchers.IO).future { Timber.d("onGetItem $session $browser $mediaId") - val itemId = mediaId.toUUIDOrNull() - if (itemId == null) { - LibraryResult.ofError( + val libraryMediaId = runCatching { Json.decodeFromString(mediaId) }.getOrNull() + when (libraryMediaId) { + is LibraryMediaId.Item -> { + val item by api.userLibraryApi.getItem(itemId = libraryMediaId.itemId) + LibraryResult.ofItem(LibraryPageElement.baseItem(api, item).toMediaItem(libraryMediaId.route), null) + } + + is LibraryMediaId.Route -> { + val page = libraryMediaId.route.page + + if (page == null) { + LibraryResult.ofError(SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page))) + } else { + LibraryResult.ofItem(createPageMediaItem(libraryMediaId.route, page), null) + } + } + + null -> LibraryResult.ofError( SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_invalid_id)), ) - } else { - val item by api.userLibraryApi.getItem(itemId = mediaId.toUUID()) - LibraryResult.ofItem(LibraryPageElement.baseItem(api, item).toMediaItem(), null) } } @@ -287,9 +293,12 @@ class SessionBrowserCallback( ): ListenableFuture> = CoroutineScope(Dispatchers.IO).future { Timber.d("onAddMediaItems $mediaSession $controller $mediaItems") - mediaItems.map { + mediaItems.mapNotNull { + val libraryMediaId = runCatching { Json.decodeFromString(it.mediaId) }.getOrNull() + if (libraryMediaId !is LibraryMediaId.Item) return@mapNotNull null + val playbackUri = api.universalAudioApi.getUniversalAudioStreamUrl( - itemId = it.mediaId.toUUID(), + itemId = libraryMediaId.itemId, deviceId = api.deviceInfo.id, maxStreamingBitrate = 140000000, container = listOf( @@ -310,7 +319,9 @@ class SessionBrowserCallback( enableRemoteMedia = true, ) - it.buildUpon().setUri(playbackUri + "&ApiKey=${api.accessToken}").build() + it.buildUpon().apply { + setUri(playbackUri + "&ApiKey=${api.accessToken}") + }.build() } } }