From b1f2f6c1857ef85645429112feef8b1369e42de3 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 11 Nov 2020 23:33:29 +0100 Subject: [PATCH] Improve and rework MediaService & Android Auto integration Moves library browser code into extra class and package, as it will only be used for Android Auto. The loader function was split into multiple subfunctions and cleaned up. The format of 'parentIds' was improved, unexpected behaviour with queue selection fixed. Shuffling was temporarily removed and will be handled differently in the future. Also introduces preliminaries for casting and Android 11 seamless transfer, this isn't fully enabled yet however - it seems there are still some changes necessary within the AndroidX libraries. Uses code from the android/uamp reference media player project: https://github.com/android/uamp --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 13 +- .../jellyfin/mobile/media/MediaExtensions.kt | 125 +++ .../mobile/media/MediaNotificationManager.kt | 118 +++ .../org/jellyfin/mobile/media/MediaService.kt | 734 ++++++++---------- .../mobile/media/car/LibraryBrowser.kt | 392 ++++++++++ .../jellyfin/mobile/media/car/LibraryPage.kt | 63 ++ .../org/jellyfin/mobile/utils/Constants.kt | 4 +- .../mobile/webapp/RemotePlayerService.kt | 8 +- app/src/main/res/values/strings.xml | 23 +- buildSrc/src/main/kotlin/Dependencies.kt | 1 + 11 files changed, 1036 insertions(+), 446 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/media/MediaExtensions.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/media/MediaNotificationManager.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/media/car/LibraryBrowser.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/media/car/LibraryPage.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 738b2dc7..6abbb10d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,6 +118,7 @@ dependencies { // Cast implementation(Dependencies.Cast.mediaRouter) + implementation(Dependencies.Cast.exoPlayerCastExtension) proprietaryImplementation(Dependencies.Cast.playServicesCast) proprietaryImplementation(Dependencies.Cast.playServicesCastFramework) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b1d4906..843083d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,18 +47,29 @@ + + + + + + android:exported="true" + tools:ignore="ExportedService"> + + diff --git a/app/src/main/java/org/jellyfin/mobile/media/MediaExtensions.kt b/app/src/main/java/org/jellyfin/mobile/media/MediaExtensions.kt new file mode 100644 index 00000000..23397826 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/media/MediaExtensions.kt @@ -0,0 +1,125 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.media + +import android.graphics.Bitmap +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import androidx.core.net.toUri + +/** + * Useful extensions for [MediaMetadataCompat]. + */ +inline val MediaMetadataCompat.mediaId: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) + +inline val MediaMetadataCompat.title: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_TITLE) + +inline val MediaMetadataCompat.artist: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_ARTIST) + +inline val MediaMetadataCompat.duration + get() = getLong(MediaMetadataCompat.METADATA_KEY_DURATION) + +inline val MediaMetadataCompat.album: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_ALBUM) + +inline val MediaMetadataCompat.author: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_AUTHOR) + +inline val MediaMetadataCompat.writer: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_WRITER) + +inline val MediaMetadataCompat.composer: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_COMPOSER) + +inline val MediaMetadataCompat.compilation: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_COMPILATION) + +inline val MediaMetadataCompat.date: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DATE) + +inline val MediaMetadataCompat.year: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_YEAR) + +inline val MediaMetadataCompat.genre: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_GENRE) + +inline val MediaMetadataCompat.trackNumber + get() = getLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER) + +inline val MediaMetadataCompat.trackCount + get() = getLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS) + +inline val MediaMetadataCompat.discNumber + get() = getLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER) + +inline val MediaMetadataCompat.albumArtist: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST) + +inline val MediaMetadataCompat.art: Bitmap + get() = getBitmap(MediaMetadataCompat.METADATA_KEY_ART) + +inline val MediaMetadataCompat.artUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_ART_URI).toUri() + +inline val MediaMetadataCompat.albumArt: Bitmap? + get() = getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART) + +inline val MediaMetadataCompat.albumArtUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI).toUri() + +inline val MediaMetadataCompat.userRating + get() = getLong(MediaMetadataCompat.METADATA_KEY_USER_RATING) + +inline val MediaMetadataCompat.rating + get() = getLong(MediaMetadataCompat.METADATA_KEY_RATING) + +inline val MediaMetadataCompat.displayTitle: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE) + +inline val MediaMetadataCompat.displaySubtitle: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE) + +inline val MediaMetadataCompat.displayDescription: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION) + +inline val MediaMetadataCompat.displayIcon: Bitmap + get() = getBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON) + +inline val MediaMetadataCompat.displayIconUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI).toUri() + +inline val MediaMetadataCompat.mediaUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI).toUri() + +inline val MediaMetadataCompat.downloadStatus + get() = getLong(MediaMetadataCompat.METADATA_KEY_DOWNLOAD_STATUS) + +inline fun MediaMetadataCompat.Builder.setMediaId(id: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + +inline fun MediaMetadataCompat.Builder.setTitle(title: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + +inline fun MediaMetadataCompat.Builder.setAlbum(album: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + +inline fun MediaMetadataCompat.Builder.setArtist(artist: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + +inline fun MediaMetadataCompat.Builder.setAlbumArtist(artist: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artist) + +inline fun MediaMetadataCompat.Builder.setTrackNumber(number: Long): MediaMetadataCompat.Builder = + putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, number) + +inline fun MediaMetadataCompat.Builder.setMediaUri(uri: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri) + +inline fun MediaMetadataCompat.Builder.setAlbumArtUri(uri: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, uri) + +inline fun MediaMetadataCompat.Builder.setDisplayIconUri(uri: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, uri) diff --git a/app/src/main/java/org/jellyfin/mobile/media/MediaNotificationManager.kt b/app/src/main/java/org/jellyfin/mobile/media/MediaNotificationManager.kt new file mode 100644 index 00000000..7738b39b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/media/MediaNotificationManager.kt @@ -0,0 +1,118 @@ +// Taken and adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/UampNotificationManager.kt + +/* + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jellyfin.mobile.media + +import android.app.PendingIntent +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest +import com.google.android.exoplayer2.DefaultControlDispatcher +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import kotlinx.coroutines.* +import org.jellyfin.mobile.R +import org.jellyfin.mobile.utils.Constants +import org.koin.core.KoinComponent +import org.koin.core.inject + +/** + * A wrapper class for ExoPlayer's PlayerNotificationManager. It sets up the notification shown to + * the user during audio playback and provides track metadata, such as track title and icon image. + */ +class MediaNotificationManager( + private val context: Context, + sessionToken: MediaSessionCompat.Token, + notificationListener: PlayerNotificationManager.NotificationListener +) : KoinComponent { + private val imageLoader: ImageLoader by inject() + + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) + private val notificationManager: PlayerNotificationManager + + init { + val mediaController = MediaControllerCompat(context, sessionToken) + + notificationManager = PlayerNotificationManager.createWithNotificationChannel( + context, + Constants.MEDIA_NOTIFICATION_CHANNEL_ID, + R.string.music_notification_channel, + R.string.music_notification_channel_description, + Constants.MEDIA_PLAYER_NOTIFICATION_ID, + DescriptionAdapter(mediaController), + notificationListener + ).apply { + setMediaSessionToken(sessionToken) + setSmallIcon(R.drawable.ic_notification) + + // Don't display the rewind or fast-forward buttons. + setControlDispatcher(DefaultControlDispatcher(0, 0)) + } + } + + fun showNotificationForPlayer(player: Player) { + notificationManager.setPlayer(player) + } + + fun hideNotification() { + notificationManager.setPlayer(null) + } + + private inner class DescriptionAdapter( + private val controller: MediaControllerCompat + ) : PlayerNotificationManager.MediaDescriptionAdapter { + + var currentIconUri: Uri? = null + var currentBitmap: Bitmap? = null + + override fun createCurrentContentIntent(player: Player): PendingIntent? = + controller.sessionActivity + + override fun getCurrentContentText(player: Player) = + controller.metadata.description.subtitle.toString() + + override fun getCurrentContentTitle(player: Player) = + controller.metadata.description.title.toString() + + override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? { + val iconUri = controller.metadata.description.iconUri + return if (currentIconUri != iconUri || currentBitmap == null) { + // Cache the bitmap for the current song so that successive calls to + // `getCurrentLargeIcon` don't cause the bitmap to be recreated. + currentIconUri = iconUri + serviceScope.launch { + currentBitmap = iconUri?.let { + resolveUriAsBitmap(it) + } + currentBitmap?.let { callback.onBitmap(it) } + } + null + } else currentBitmap + } + + private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? = withContext(Dispatchers.IO) { + imageLoader.execute(ImageRequest.Builder(context).data(uri).build()).drawable?.toBitmap() + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/media/MediaService.kt b/app/src/main/java/org/jellyfin/mobile/media/MediaService.kt index db15b137..013139ef 100644 --- a/app/src/main/java/org/jellyfin/mobile/media/MediaService.kt +++ b/app/src/main/java/org/jellyfin/mobile/media/MediaService.kt @@ -1,5 +1,10 @@ +// Contains code adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/MediaService.kt + package org.jellyfin.mobile.media +import android.app.Notification +import android.app.PendingIntent +import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.ResultReceiver @@ -9,29 +14,31 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat +import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.media.MediaBrowserServiceCompat -import com.google.android.exoplayer2.ControlDispatcher -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.SimpleExoPlayer +import androidx.mediarouter.media.MediaControlIntent +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import androidx.mediarouter.media.MediaRouterParams +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.cast.CastPlayer +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.android.gms.cast.framework.CastContext import kotlinx.coroutines.* import org.jellyfin.apiclient.interaction.ApiClient -import org.jellyfin.apiclient.model.dto.BaseItemDto -import org.jellyfin.apiclient.model.dto.BaseItemType -import org.jellyfin.apiclient.model.dto.ImageOptions -import org.jellyfin.apiclient.model.entities.CollectionType -import org.jellyfin.apiclient.model.entities.ImageType -import org.jellyfin.apiclient.model.entities.SortOrder -import org.jellyfin.apiclient.model.playlists.PlaylistItemQuery -import org.jellyfin.apiclient.model.querying.* import org.jellyfin.mobile.R import org.jellyfin.mobile.controller.ServerController +import org.jellyfin.mobile.media.car.LibraryBrowser +import org.jellyfin.mobile.media.car.LibraryPage import org.jellyfin.mobile.model.sql.entity.ServerUser -import org.jellyfin.mobile.utils.* +import org.jellyfin.mobile.utils.toast import org.koin.android.ext.android.inject -import java.net.URLEncoder -import java.util.* +import timber.log.Timber import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem class MediaService : MediaBrowserServiceCompat() { @@ -41,36 +48,46 @@ class MediaService : MediaBrowserServiceCompat() { private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) + private var isForegroundService = false private lateinit var loadingJob: Job private var serverUser: ServerUser? = null + private val libraryBrowser = LibraryBrowser(this, apiClient) + + // The current player will either be an ExoPlayer (for local playback) or a CastPlayer (for + // remote playback through a Cast device). + private lateinit var currentPlayer: Player + + private lateinit var notificationManager: MediaNotificationManager private lateinit var mediaController: MediaControllerCompat private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSessionConnector: MediaSessionConnector + private lateinit var mediaRouteSelector: MediaRouteSelector + private lateinit var mediaRouter: MediaRouter + private val mediaRouterCallback = MediaRouterCallback() - private var currentPlaylistItems: MutableList = mutableListOf() + private var currentPlaylistItems: List = emptyList() + + private val playerAudioAttributes = AudioAttributes.Builder() + .setContentType(C.CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + + private val playerListener = PlayerEventListener() private val exoPlayer: SimpleExoPlayer by lazy { - SimpleExoPlayer.Builder(this).build() + SimpleExoPlayer.Builder(this).build().apply { + setAudioAttributes(playerAudioAttributes, true) + setHandleAudioBecomingNoisy(true) + addListener(playerListener) + } } - /** - * List of different views when browsing media - * Libraries is the initial view - */ - private enum class MediaItemType { - Libraries, - Library, - LibraryLatest, - LibraryAlbums, - LibraryArtists, - LibrarySongs, - LibraryGenres, - LibraryPlaylists, - Album, - Artist, - Shuffle, - Playlist, + private val castPlayer: CastPlayer by lazy { + CastPlayer(CastContext.getSharedInstance(this)).apply { + setSessionAvailabilityListener(CastSessionAvailabilityListener()) + addListener(playerListener) + } } override fun onCreate() { @@ -84,12 +101,23 @@ class MediaService : MediaBrowserServiceCompat() { } } + val sessionActivityPendingIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> + PendingIntent.getActivity(this, 0, sessionIntent, 0) + } + mediaSession = MediaSessionCompat(this, "MediaService").apply { + setSessionActivity(sessionActivityPendingIntent) isActive = true } sessionToken = mediaSession.sessionToken + notificationManager = MediaNotificationManager( + this, + mediaSession.sessionToken, + PlayerNotificationListener() + ) + mediaController = MediaControllerCompat(this, mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession).apply { @@ -97,6 +125,23 @@ class MediaService : MediaBrowserServiceCompat() { setPlaybackPreparer(MediaPlaybackPreparer()) setQueueNavigator(MediaQueueNavigator(mediaSession)) } + + mediaRouter = MediaRouter.getInstance(this) + mediaRouter.setMediaSessionCompat(mediaSession) + mediaRouteSelector = MediaRouteSelector.Builder().apply { + addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) + }.build() + mediaRouter.routerParams = MediaRouterParams.Builder().apply { + setTransferToLocalEnabled(true) + }.build() + mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) + + switchToPlayer( + previousPlayer = null, + newPlayer = if (castPlayer.isCastSessionAvailable) castPlayer else exoPlayer + ) + notificationManager.showNotificationForPlayer(currentPlayer) + } override fun onDestroy() { @@ -108,417 +153,152 @@ class MediaService : MediaBrowserServiceCompat() { // Cancel coroutines when the service is going away serviceJob.cancel() + // Free ExoPlayer resources + exoPlayer.removeListener(playerListener) exoPlayer.release() + + // Stop listening for route changes. + mediaRouter.removeCallback(mediaRouterCallback) } override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? { - val rootExtras = Bundle().apply { - putBoolean(CONTENT_STYLE_SUPPORTED, true) - putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST_ITEM_HINT_VALUE) - putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST_ITEM_HINT_VALUE) - } - return BrowserRoot(MediaItemType.Libraries.toString(), rootExtras) - } + ): BrowserRoot? = libraryBrowser.getRoot(rootHints) - /** - * The parent id will be in various formats such as - * Libraries - * Library|{id} - * LibraryAlbums|{id} - * Album|{id} - */ override fun onLoadChildren(parentId: String, result: Result>) { result.detach() serviceScope.launch(Dispatchers.IO) { // Ensure credentials were loaded already loadingJob.join() - if (serverUser == null) result.sendResult(emptyList()) - else loadView(parentId, result) + val library = if (serverUser != null) libraryBrowser.loadLibrary(parentId) else null + result.sendResult(library ?: emptyList()) } } - private suspend fun loadView(parentId: String, result: Result>) { - when (parentId) { - /** - * View that shows all the music libraries - */ - MediaItemType.Libraries.toString() -> { - val response = apiClient.getUserViews(apiClient.currentUserId) - if (response != null) { - val views = response.items - .filter { item -> item.collectionType == CollectionType.Music } - .map { item -> - val itemImageUrl = apiClient.GetImageUrl(item, ImageOptions().apply { - imageType = ImageType.Primary - maxWidth = 1080 - quality = 90 - }) - val description = MediaDescriptionCompat.Builder().apply { - setMediaId(MediaItemType.Library.toString() + "|" + item.id) - setTitle(item.name) - setIconUri(Uri.parse(itemImageUrl)) - }.build() - MediaItem(description, MediaItem.FLAG_BROWSABLE) - } + /** + * Load the supplied list of songs and the song to play into the current player. + */ + private fun preparePlaylist( + metadataList: List, + initialPlaybackIndex: Int = 0, + playWhenReady: Boolean, + playbackStartPositionMs: Long = 0 + ) { + currentPlaylistItems = metadataList - result.sendResult(views) - } else { - result.sendResult(emptyList()) - } - } - - /** - * Views that show albums, artists, songs, genres, etc for a specific library - */ - else -> { - val mediaItemTypeSplit = parentId.split("|") - val primaryType = mediaItemTypeSplit[0] - val primaryItemId = mediaItemTypeSplit[1] - val secondaryItemId = - if (mediaItemTypeSplit.count() > 2) mediaItemTypeSplit[2] else null - - if (primaryType == MediaItemType.Library.toString()) { - /** - * The default view for a library that lists various ways to browse - */ - val libraryViews = arrayOf( - Pair(MediaItemType.LibraryLatest, R.string.mediaservice_library_latest), - Pair(MediaItemType.LibraryAlbums, R.string.mediaservice_library_albums), - Pair( - MediaItemType.LibraryArtists, - R.string.mediaservice_library_artists - ), - Pair(MediaItemType.LibrarySongs, R.string.mediaservice_library_songs), - Pair(MediaItemType.LibraryGenres, R.string.mediaservice_library_genres), - Pair( - MediaItemType.LibraryPlaylists, - R.string.mediaservice_library_playlists - ), - ) - - val views = libraryViews.map { item -> - val description = MediaDescriptionCompat.Builder().apply { - setMediaId(item.first.toString() + "|" + primaryItemId) - setTitle(getString(item.second)) - }.build() - MediaItem(description, MediaItem.FLAG_BROWSABLE) - } - - result.sendResult(views.toMutableList()) - return - } - - /** - * Processes items from api responses - * Updates the current play list items and sends results back to android auto - */ - fun processItems(items: Array) { - val mediaItems = items - .map { item -> createMediaItem(primaryType, primaryItemId, item) } - .toMutableList() - - currentPlaylistItems = mediaItems - .filter { item -> item.isPlayable } - .map { item -> item.description } - .toMutableList() - - if (currentPlaylistItems.count() > 1) { - val description = MediaDescriptionCompat.Builder().apply { - setMediaId(primaryType + "|" + primaryItemId + "|" + MediaItemType.Shuffle) - setTitle(getString(R.string.mediaservice_shuffle)) - }.build() - mediaItems.add(0, MediaItem(description, MediaItem.FLAG_PLAYABLE)) - } - - result.sendResult(mediaItems) - } - - fun processItemsResponse(response: ItemsResult?) { - if (response != null) { - processItems(response.items) - } else { - result.sendResult(null) - } - } - - when (primaryType) { - /** - * View for a specific album - */ - MediaItemType.Album.toString() -> { - val query = ItemQuery() - query.parentId = primaryItemId - query.userId = apiClient.currentUserId - query.sortBy = arrayOf(ItemSortBy.SortName) - - processItemsResponse(apiClient.getItems(query)) - } - - /** - * View for a specific artist - */ - MediaItemType.Artist.toString() -> { - val query = ItemQuery() - query.artistIds = arrayOf(primaryItemId) - query.userId = apiClient.currentUserId - query.sortBy = arrayOf(ItemSortBy.SortName) - query.sortOrder = SortOrder.Ascending - query.recursive = true - query.imageTypeLimit = 1 - query.enableImageTypes = arrayOf(ImageType.Primary) - query.limit = 100 - query.includeItemTypes = arrayOf(BaseItemType.MusicAlbum.name) - - processItemsResponse(apiClient.getItems(query)) - } - - /** - * View for a specific playlist - */ - MediaItemType.Playlist.toString() -> { - val query = PlaylistItemQuery() - query.id = primaryItemId - query.userId = apiClient.currentUserId - - processItemsResponse(apiClient.getPlaylistItems(query)) - } - - /** - * View for albums / songs in a library - */ - MediaItemType.LibraryAlbums.toString(), - MediaItemType.LibrarySongs.toString(), - MediaItemType.LibraryPlaylists.toString() -> { - val query = ItemQuery() - query.parentId = primaryItemId - query.userId = apiClient.currentUserId - query.sortBy = arrayOf(ItemSortBy.SortName) - query.sortOrder = SortOrder.Ascending - query.recursive = true - query.imageTypeLimit = 1 - query.enableImageTypes = arrayOf(ImageType.Primary) - query.limit = 100 - - when (primaryType) { - MediaItemType.LibraryAlbums.toString() -> { - if (secondaryItemId != null) { - query.parentId = null - query.artistIds = arrayOf(secondaryItemId) - } - - query.includeItemTypes = arrayOf(BaseItemType.MusicAlbum.name) - } - MediaItemType.LibrarySongs.toString() -> { - query.includeItemTypes = arrayOf(BaseItemType.Audio.name) - } - MediaItemType.LibraryPlaylists.toString() -> { - query.includeItemTypes = arrayOf(BaseItemType.Playlist.name) - } - } - - processItemsResponse(apiClient.getItems(query)) - } - - /** - * View for "Latest Music" in a library - */ - MediaItemType.LibraryLatest.toString() -> { - val query = LatestItemsQuery() - query.parentId = primaryItemId - query.userId = apiClient.currentUserId - query.includeItemTypes = arrayOf(BaseItemType.Audio.name) - query.limit = 100 - - val response = apiClient.getLatestItems(query) - if (response != null) { - processItems(response) - } else { - result.sendResult(null) - } - } - - /** - * View for artists in a library - */ - MediaItemType.LibraryArtists.toString() -> { - val query = ArtistsQuery() - query.parentId = primaryItemId - query.userId = apiClient.currentUserId - query.sortBy = arrayOf(ItemSortBy.SortName) - query.sortOrder = SortOrder.Ascending - query.recursive = true - query.imageTypeLimit = 1 - query.enableImageTypes = arrayOf(ImageType.Primary) - - processItemsResponse(apiClient.getArtists(query)) - } - - /** - * View for genres - */ - MediaItemType.LibraryGenres.toString() -> { - if (secondaryItemId != null) { - /** - * View for a specific genre in a library - */ - val query = ItemQuery() - query.parentId = primaryItemId - query.userId = apiClient.currentUserId - query.sortBy = arrayOf(ItemSortBy.IsFolder, ItemSortBy.SortName) - query.sortOrder = SortOrder.Ascending - query.recursive = true - query.imageTypeLimit = 1 - query.enableImageTypes = arrayOf(ImageType.Primary) - query.includeItemTypes = arrayOf(BaseItemType.MusicAlbum.name) - query.genreIds = arrayOf(secondaryItemId) - - processItemsResponse(apiClient.getItems(query)) - - } else { - /** - * View for genres in a library - */ - val query = ItemsByNameQuery() - query.parentId = primaryItemId - query.userId = apiClient.currentUserId - query.sortBy = arrayOf(ItemSortBy.SortName) - query.sortOrder = SortOrder.Ascending - query.recursive = true - - processItemsResponse(apiClient.getGenres(query)) - } - } - - /** - * Unhandled view - */ - else -> result.sendResult(null) - } - } - } - } - - private fun createMediaItem( - primaryType: String, - primaryItemId: String, - item: BaseItemDto - ): MediaItem { - val extras = Bundle() - extras.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, item.album) - extras.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, item.albumArtist) - - val isSong = item.baseItemType == BaseItemType.Audio - - var mediaSubtitle: String? = null - var mediaImageUri: Uri? = null - - val imageOptions = ImageOptions().apply { - imageType = ImageType.Primary - maxWidth = 1080 - quality = 90 - } - val primaryImageUrl = when { - item.hasPrimaryImage -> apiClient.GetImageUrl(item, imageOptions) - item.albumId != null -> apiClient.GetImageUrl(item.albumId, imageOptions) - else -> null - } - - if (isSong) { - mediaSubtitle = item.albumArtist - - // show album art when playing the song - extras.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, primaryImageUrl) - extras.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.name) - extras.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.albumArtist) - - if (item.indexNumber != null) { - extras.putInt(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, item.indexNumber) - } - } else { - mediaImageUri = primaryImageUrl?.let(Uri::parse) - } - - // songs are playable. everything else is browsable (clicks into another view) - val flag = - if (isSong) MediaItem.FLAG_PLAYABLE else MediaItem.FLAG_BROWSABLE - - // the media id controls the view if it's a browsable item - // otherwise it will control the item that is played when clicked - val mediaId: String = when { - item.baseItemType == BaseItemType.MusicAlbum -> { - MediaItemType.Album.name + "|" + item.id - } - item.baseItemType == BaseItemType.MusicArtist -> { - MediaItemType.Artist.name + "|" + item.id - } - item.baseItemType == BaseItemType.Playlist -> { - MediaItemType.Playlist.name + "|" + item.id - } - flag == MediaItem.FLAG_PLAYABLE -> item.id - else -> primaryType + "|" + primaryItemId + "|" + item.id - } - - val description: MediaDescriptionCompat = - MediaDescriptionCompat.Builder().apply { - setMediaId(mediaId) - setTitle(item.name) - setSubtitle(mediaSubtitle) - setExtras(extras) - setIconUri(mediaImageUri) + val mediaItems = metadataList.map { metadata -> + ExoPlayerMediaItem.Builder().apply { + setUri(metadata.mediaUri) + setTag(metadata) }.build() + } - return MediaItem(description, flag) + currentPlayer.playWhenReady = playWhenReady + currentPlayer.stop(true) + if (currentPlayer == exoPlayer) { + exoPlayer.setMediaItems(mediaItems) + exoPlayer.prepare() + exoPlayer.seekTo(initialPlaybackIndex, playbackStartPositionMs) + } else /* currentPlayer == castPlayer */ { + castPlayer.setMediaItems( + mediaItems, + initialPlaybackIndex, + playbackStartPositionMs, + ) + } } - private inner class MediaQueueNavigator(mediaSession: MediaSessionCompat) : - TimelineQueueNavigator(mediaSession) { - override fun getMediaDescription( - player: Player, - windowIndex: Int - ): MediaDescriptionCompat { - return currentPlaylistItems[windowIndex] + private fun switchToPlayer(previousPlayer: Player?, newPlayer: Player) { + if (previousPlayer == newPlayer) { + return } + currentPlayer = newPlayer + if (previousPlayer != null) { + val playbackState = previousPlayer.playbackState + if (currentPlaylistItems.isEmpty()) { + // We are joining a playback session. + // Loading the session from the new player is not supported, so we stop playback. + currentPlayer.stop(true) + } else if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) { + preparePlaylist( + metadataList = currentPlaylistItems, + initialPlaybackIndex = previousPlayer.currentWindowIndex, + playWhenReady = previousPlayer.playWhenReady, + playbackStartPositionMs = previousPlayer.currentPosition + ) + } + } + mediaSessionConnector.setPlayer(newPlayer) + previousPlayer?.stop(true) + } + + private fun setPlaybackError() { + mediaSession.setPlaybackState(PlaybackStateCompat.Builder().apply { + setState(PlaybackStateCompat.STATE_ERROR, 0, 1f) + setErrorMessage(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, getString(R.string.media_service_item_not_found)) + }.build()) + } + + private inner class CastSessionAvailabilityListener : SessionAvailabilityListener { + override fun onCastSessionAvailable() { + switchToPlayer(currentPlayer, castPlayer) + } + + override fun onCastSessionUnavailable() { + switchToPlayer(currentPlayer, exoPlayer) + } + } + + private inner class MediaQueueNavigator(mediaSession: MediaSessionCompat) : TimelineQueueNavigator(mediaSession) { + override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat = + currentPlaylistItems[windowIndex].description } private inner class MediaPlaybackPreparer : MediaSessionConnector.PlaybackPreparer { - - override fun getSupportedPrepareActions(): Long = + override fun getSupportedPrepareActions(): Long = 0L or + PlaybackStateCompat.ACTION_PREPARE or + PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - override fun onPrepare(playWhenReady: Boolean) {} - - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - val shouldShuffle = mediaId.endsWith(MediaItemType.Shuffle.toString()) - - if (shouldShuffle) { - currentPlaylistItems.shuffle() + override fun onPrepare(playWhenReady: Boolean) { + serviceScope.launch { + val recents = libraryBrowser.getDefaultRecents() + if (recents != null) { + preparePlaylist(recents, 0, playWhenReady) + } else setPlaybackError() } - - val mediaItems = currentPlaylistItems.map { item -> - createMediaItem(item.mediaId!!) - } - - var mediaItemIndex = 0 - - if (!shouldShuffle) { - mediaItemIndex = currentPlaylistItems.indexOfFirst { item -> - item.mediaId == mediaId - } - } - - exoPlayer.setMediaItems(mediaItems) - exoPlayer.prepare() - if (mediaItemIndex in 0 until currentPlaylistItems.size) - exoPlayer.seekTo(mediaItemIndex, 0) } - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {} + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + if (mediaId == LibraryPage.RESUME) { + // Recents requested + onPrepare(playWhenReady) + } else serviceScope.launch { + val result = libraryBrowser.buildPlayQueue(mediaId) + if (result != null) { + val (playbackQueue, initialPlaybackIndex) = result + preparePlaylist(playbackQueue, initialPlaybackIndex, playWhenReady) + } else setPlaybackError() + } + } + + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { + if (query.isEmpty()) { + // No search provided, fallback to recents + onPrepare(playWhenReady) + } else serviceScope.launch { + val results = libraryBrowser.getSearchResults(query, extras) + if (results != null) { + preparePlaylist(results, 0, playWhenReady) + } else setPlaybackError() + } + } override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit @@ -529,28 +309,122 @@ class MediaService : MediaBrowserServiceCompat() { extras: Bundle?, cb: ResultReceiver? ): Boolean = false + } - fun createMediaItem(mediaId: String): ExoPlayerMediaItem { - val url = "${apiClient.serverAddress}/Audio/${mediaId}/universal?" + - "UserId=${apiClient.currentUserId}&" + - "DeviceId=${URLEncoder.encode(apiClient.deviceId, Charsets.UTF_8.name())}&" + - "MaxStreamingBitrate=140000000&" + - "Container=opus,mp3|mp3,aac,m4a,m4b|aac,flac,webma,webm,wav,ogg&" + - "TranscodingContainer=ts&" + - "TranscodingProtocol=hls&" + - "AudioCodec=aac&" + - "api_key=${apiClient.accessToken}&" + - "PlaySessionId=${UUID.randomUUID()}&" + - "EnableRemoteMedia=true" + /** + * Listen for notification events. + */ + private inner class PlayerNotificationListener : PlayerNotificationManager.NotificationListener { + override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { + if (ongoing && !isForegroundService) { + val serviceIntent = Intent(applicationContext, this@MediaService.javaClass) + ContextCompat.startForegroundService(applicationContext, serviceIntent) - return ExoPlayerMediaItem.fromUri(url) + startForeground(notificationId, notification) + isForegroundService = true + } + } + + override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { + stopForeground(true) + isForegroundService = false + stopSelf() + } + } + + /** + * Listen for events from ExoPlayer. + */ + private inner class PlayerEventListener : Player.EventListener { + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING, + Player.STATE_READY -> { + notificationManager.showNotificationForPlayer(currentPlayer) + if (playbackState == Player.STATE_READY) { + // TODO: When playing/paused save the current media item in persistent storage + // so that playback can be resumed between device reboots + + if (!playWhenReady) { + // If playback is paused we remove the foreground state which allows the + // notification to be dismissed. An alternative would be to provide a + // "close" button in the notification which stops playback and clears + // the notification. + stopForeground(false) + } + } + } + else -> notificationManager.hideNotification() + } + } + + override fun onPlayerError(error: ExoPlaybackException) { + var message = R.string.media_service_generic_error + when (error.type) { + ExoPlaybackException.TYPE_SOURCE -> { + message = R.string.media_service_item_not_found + Timber.e("TYPE_SOURCE: %s", error.sourceException.message) + } + ExoPlaybackException.TYPE_RENDERER -> Timber.e("TYPE_RENDERER: %s", error.rendererException.message) + ExoPlaybackException.TYPE_UNEXPECTED -> Timber.e("TYPE_UNEXPECTED: %s", error.unexpectedException.message) + ExoPlaybackException.TYPE_REMOTE -> Timber.e("TYPE_REMOTE: %s", error.message) + ExoPlaybackException.TYPE_OUT_OF_MEMORY -> Timber.e("TYPE_OUT_OF_MEMORY: %s", error.outOfMemoryError.message) + ExoPlaybackException.TYPE_TIMEOUT -> Timber.e("TYPE_TIMEOUT: %s", error.timeoutException.message) + } + applicationContext.toast(message, Toast.LENGTH_LONG) + } + } + + /** + * Listen for MediaRoute changes + */ + private inner class MediaRouterCallback : MediaRouter.Callback() { + override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) { + if (reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) { + Timber.d("Unselected because route changed, continue playback") + } else if (reason == MediaRouter.UNSELECT_REASON_STOPPED) { + Timber.d("Unselected because route was stopped, stop playback") + currentPlayer.stop() + } } } companion object { - private const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED" - private const val CONTENT_STYLE_PLAYABLE_HINT = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT" - private const val CONTENT_STYLE_BROWSABLE_HINT = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" - private const val CONTENT_STYLE_LIST_ITEM_HINT_VALUE = 1 + /** Declares that ContentStyle is supported */ + const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED" + + /** + * Bundle extra indicating the presentation hint for playable media items. + */ + const val CONTENT_STYLE_PLAYABLE_HINT = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT" + + /** + * Bundle extra indicating the presentation hint for browsable media items. + */ + const val CONTENT_STYLE_BROWSABLE_HINT = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" + + /** + * Specifies the corresponding items should be presented as lists. + */ + const val CONTENT_STYLE_LIST_ITEM_HINT_VALUE = 1 + + /** + * Specifies that the corresponding items should be presented as grids. + */ + const val CONTENT_STYLE_GRID_ITEM_HINT_VALUE = 2 + + /** + * Specifies that the corresponding items should be presented as lists and are + * represented by a vector icon. This adds a small margin around the icons + * instead of filling the full available area. + */ + const val CONTENT_STYLE_CATEGORY_LIST_ITEM_HINT_VALUE = 3 + + /** + * Specifies that the corresponding items should be presented as grids and are + * represented by a vector icon. This adds a small margin around the icons + * instead of filling the full available area. + */ + const val CONTENT_STYLE_CATEGORY_GRID_ITEM_HINT_VALUE = 4 } } diff --git a/app/src/main/java/org/jellyfin/mobile/media/car/LibraryBrowser.kt b/app/src/main/java/org/jellyfin/mobile/media/car/LibraryBrowser.kt new file mode 100644 index 00000000..53fc7b78 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/media/car/LibraryBrowser.kt @@ -0,0 +1,392 @@ +package org.jellyfin.mobile.media.car + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE +import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import androidx.media.MediaBrowserServiceCompat +import org.jellyfin.apiclient.interaction.ApiClient +import org.jellyfin.apiclient.model.dto.BaseItemDto +import org.jellyfin.apiclient.model.dto.BaseItemType +import org.jellyfin.apiclient.model.dto.ImageOptions +import org.jellyfin.apiclient.model.entities.CollectionType +import org.jellyfin.apiclient.model.entities.ImageType +import org.jellyfin.apiclient.model.entities.SortOrder +import org.jellyfin.apiclient.model.playlists.PlaylistItemQuery +import org.jellyfin.apiclient.model.querying.* +import org.jellyfin.mobile.R +import org.jellyfin.mobile.media.* +import org.jellyfin.mobile.utils.* +import timber.log.Timber +import java.net.URLEncoder +import java.util.* + +class LibraryBrowser( + private val context: Context, + private val apiClient: ApiClient +) { + fun getRoot(hints: Bundle?): MediaBrowserServiceCompat.BrowserRoot { + /** + * By default return the browsable root. Treat the EXTRA_RECENT flag as a special case + * and return the recent root instead. + */ + val isRecentRequest = hints?.getBoolean(MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT) ?: false + val browserRoot = if (isRecentRequest) LibraryPage.RESUME else LibraryPage.LIBRARIES + + val rootExtras = Bundle().apply { + putBoolean(MediaService.CONTENT_STYLE_SUPPORTED, true) + putInt(MediaService.CONTENT_STYLE_BROWSABLE_HINT, MediaService.CONTENT_STYLE_LIST_ITEM_HINT_VALUE) + putInt(MediaService.CONTENT_STYLE_PLAYABLE_HINT, MediaService.CONTENT_STYLE_LIST_ITEM_HINT_VALUE) + } + return MediaBrowserServiceCompat.BrowserRoot(browserRoot, rootExtras) + } + + suspend fun loadLibrary(parentId: String): List? { + if (parentId == LibraryPage.RESUME) + return getDefaultRecents()?.browsable() + + val split = parentId.split('|') + + if (split.size !in 1..3) + return null + + val type = split[0] + val libraryId = split.getOrNull(1) + val itemId = split.getOrNull(2) + + return when { + libraryId != null -> { + when { + itemId != null -> when (type) { + LibraryPage.ARTIST_ALBUMS -> getAlbums(libraryId, filterArtist = itemId) + LibraryPage.GENRE_ALBUMS -> getAlbums(libraryId, filterGenre = itemId) + else -> null + } + else -> when (type) { + LibraryPage.LIBRARY -> getLibraryViews(context, libraryId) + LibraryPage.RECENTS -> getRecents(libraryId)?.playable() + LibraryPage.ALBUMS -> getAlbums(libraryId) + LibraryPage.ARTISTS -> getArtists(libraryId) + LibraryPage.GENRES -> getGenres(libraryId) + LibraryPage.PLAYLISTS -> getPlaylists(libraryId) + LibraryPage.ALBUM -> getAlbum(libraryId)?.playable() + LibraryPage.PLAYLIST -> getPlaylist(libraryId)?.playable() + else -> null + } + } + } + else -> when (type) { + LibraryPage.LIBRARIES -> getLibraries() + else -> null + } + } + } + + suspend fun buildPlayQueue(mediaId: String): Pair, Int>? { + val split = mediaId.split('|') + if (split.size != 3) + return null + + val (type, collectionId, _) = split + + val playQueue = when (type) { + LibraryPage.RECENTS -> getRecents(collectionId) + LibraryPage.ALBUM -> getAlbum(collectionId) + LibraryPage.PLAYLIST -> getPlaylist(collectionId) + else -> return null + } ?: return null + + val playIndex = playQueue.indexOfFirst { item -> + item.mediaId == mediaId + }.coerceAtLeast(0) + + return playQueue to playIndex + } + + suspend fun getSearchResults(searchQuery: String, extras: Bundle?): List? { + when (extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { + // Search for specific album + extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.let { albumQuery -> + Timber.d("Searching for album $albumQuery") + searchItems(albumQuery, BaseItemType.MusicAlbum) + }?.let { albumId -> + getAlbum(albumId) + }?.let { albumContent -> + Timber.d("Got result, starting playback") + return albumContent + } + } + MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { + // Search for specific artist + extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.let { artistQuery -> + Timber.d("Searching for artist $artistQuery") + searchItems(artistQuery, BaseItemType.MusicArtist) + }?.let { artistId -> + val query = ItemQuery().apply { + userId = apiClient.currentUserId + artistIds = arrayOf(artistId) + includeItemTypes = arrayOf(BaseItemType.Audio.name) + sortBy = arrayOf(ItemSortBy.Random) + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + enableTotalRecordCount = false + limit = 100 + } + apiClient.getItems(query)?.extractItems() + }?.let { artistTracks -> + Timber.d("Got result, starting playback") + return artistTracks + } + } + } + // Fallback to generic search + Timber.d("Searching for '$searchQuery'") + val query = ItemQuery().apply { + userId = apiClient.currentUserId + searchTerm = searchQuery + includeItemTypes = arrayOf(BaseItemType.Audio.name) + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + enableTotalRecordCount = false + limit = 100 + } + return apiClient.getItems(query)?.extractItems() + } + + /** + * Find a single specific item for the given [searchQuery] with a specific [type] + */ + private suspend fun searchItems(searchQuery: String, type: BaseItemType): String? { + val query = ItemQuery().apply { + userId = apiClient.currentUserId + searchTerm = searchQuery + includeItemTypes = arrayOf(type.name) + recursive = true + enableImages = false + enableTotalRecordCount = false + limit = 1 + } + val searchResults = apiClient.getItems(query) ?: return null + return searchResults.items.firstOrNull()?.id + } + + suspend fun getDefaultRecents(): List? = + getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary -> getRecents(defaultLibrary) } + + private suspend fun getLibraries(): List { + return apiClient.getUserViews(apiClient.currentUserId)?.run { + items.asSequence() + .filter { item -> item.collectionType == CollectionType.Music } + .map { item -> + val itemImageUrl = apiClient.GetImageUrl(item, ImageOptions().apply { + imageType = ImageType.Primary + maxWidth = 1080 + quality = 90 + }) + val description = MediaDescriptionCompat.Builder().apply { + setMediaId(LibraryPage.LIBRARY + "|" + item.id) + setTitle(item.name) + setIconUri(Uri.parse(itemImageUrl)) + }.build() + MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE) + } + .toList() + } ?: emptyList() + } + + private fun getLibraryViews(context: Context, libraryId: String): List { + val libraryViews = arrayOf( + LibraryPage.RECENTS to R.string.media_service_car_section_recents, + LibraryPage.ALBUMS to R.string.media_service_car_section_albums, + LibraryPage.ARTISTS to R.string.media_service_car_section_artists, + LibraryPage.GENRES to R.string.media_service_car_section_genres, + LibraryPage.PLAYLISTS to R.string.media_service_car_section_playlists, + ) + return libraryViews.map { item -> + val description = MediaDescriptionCompat.Builder().apply { + setMediaId(item.first + "|" + libraryId) + setTitle(context.getString(item.second)) + + if (item.first == LibraryPage.ALBUMS) { + setExtras(Bundle().apply { + putInt(MediaService.CONTENT_STYLE_BROWSABLE_HINT, MediaService.CONTENT_STYLE_GRID_ITEM_HINT_VALUE) + putInt(MediaService.CONTENT_STYLE_PLAYABLE_HINT, MediaService.CONTENT_STYLE_GRID_ITEM_HINT_VALUE) + }) + } + }.build() + MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE) + } + } + + private suspend fun getRecents(libraryId: String): List? { + val query = ItemQuery().apply { + userId = apiClient.currentUserId + parentId = libraryId + includeItemTypes = arrayOf(BaseItemType.Audio.name) + filters = arrayOf(ItemFilter.IsPlayed) + sortBy = arrayOf(ItemSortBy.DatePlayed) + sortOrder = SortOrder.Descending + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + enableTotalRecordCount = false + limit = 100 + } + return apiClient.getItems(query)?.extractItems("${LibraryPage.RECENTS}|$libraryId") + } + + private suspend fun getAlbums( + libraryId: String, + filterArtist: String? = null, + filterGenre: String? = null + ): List? { + val query = ItemQuery().apply { + userId = apiClient.currentUserId + parentId = libraryId + when { + filterArtist != null -> artistIds = arrayOf(filterArtist) + filterGenre != null -> genreIds = arrayOf(filterGenre) + } + includeItemTypes = arrayOf(BaseItemType.MusicAlbum.name) + sortBy = arrayOf(ItemSortBy.DatePlayed) + sortOrder = SortOrder.Descending + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + limit = 100 + } + return apiClient.getItems(query)?.extractItems()?.browsable() + } + + private suspend fun getArtists(libraryId: String): List? { + val query = ArtistsQuery().apply { + userId = apiClient.currentUserId + parentId = libraryId + sortBy = arrayOf(ItemSortBy.SortName) + sortOrder = SortOrder.Ascending + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + limit = 100 + } + return apiClient.getArtists(query)?.extractItems(libraryId)?.browsable() + } + + + private suspend fun getGenres(libraryId: String): List? { + val query = ItemsByNameQuery().apply { + userId = apiClient.currentUserId + parentId = libraryId + sortBy = arrayOf(ItemSortBy.SortName) + sortOrder = SortOrder.Ascending + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + limit = 100 + } + return apiClient.getGenres(query)?.extractItems(libraryId)?.browsable() + } + + private suspend fun getPlaylists(libraryId: String): List? { + val query = ItemQuery().apply { + userId = apiClient.currentUserId + parentId = libraryId + includeItemTypes = arrayOf(BaseItemType.Playlist.name) + sortBy = arrayOf(ItemSortBy.DatePlayed) + sortOrder = SortOrder.Descending + recursive = true + imageTypeLimit = 1 + enableImageTypes = arrayOf(ImageType.Primary) + limit = 100 + } + return apiClient.getItems(query)?.extractItems()?.browsable() + } + + private suspend fun getAlbum(albumId: String): List? { + val query = ItemQuery().apply { + parentId = albumId + userId = apiClient.currentUserId + sortBy = arrayOf(ItemSortBy.SortName) + } + return apiClient.getItems(query)?.extractItems("${LibraryPage.ALBUM}|$albumId") + } + + private suspend fun getPlaylist(playlistId: String): List? { + val query = PlaylistItemQuery().apply { + userId = apiClient.currentUserId + id = playlistId + } + return apiClient.getPlaylistItems(query)?.extractItems("${LibraryPage.PLAYLIST}|$playlistId") + } + + private fun ItemsResult.extractItems(libraryId: String? = null): List? = + items.map { item -> buildMediaMetadata(item, libraryId) }.toList() + + private fun buildMediaMetadata(item: BaseItemDto, libraryId: String?): MediaMetadataCompat { + val builder = MediaMetadataCompat.Builder() + builder.setMediaId(buildMediaId(item, libraryId)) + builder.setTitle(item.name ?: context.getString(R.string.media_service_car_item_no_title)) + + val isAlbum = item.albumId != null + val imageOptions = ImageOptions().apply { + imageType = ImageType.Primary + maxWidth = 1080 + quality = 90 + tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags[ImageType.Primary] + } + val primaryImageUrl = when { + item.hasPrimaryImage -> apiClient.GetImageUrl(item, imageOptions) + isAlbum -> apiClient.GetImageUrl(item.albumId, imageOptions) + else -> null + } + + if (item.baseItemType == BaseItemType.Audio) { + val uri = "${apiClient.serverAddress}/Audio/${item.id}/universal?" + + "UserId=${apiClient.currentUserId}&" + + "DeviceId=${URLEncoder.encode(apiClient.deviceId, Charsets.UTF_8.name())}&" + + "MaxStreamingBitrate=140000000&" + + "Container=opus,mp3|mp3,aac,m4a,m4b|aac,flac,webma,webm,wav,ogg&" + + "TranscodingContainer=ts&" + + "TranscodingProtocol=hls&" + + "AudioCodec=aac&" + + "api_key=${apiClient.accessToken}&" + + "PlaySessionId=${UUID.randomUUID()}&" + + "EnableRemoteMedia=true" + builder.setMediaUri(uri) + item.album?.let(builder::setAlbum) + builder.setArtist(item.artists.joinToString()) + item.albumArtist?.let(builder::setAlbumArtist) + primaryImageUrl?.let(builder::setAlbumArtUri) + item.indexNumber?.toLong()?.let(builder::setTrackNumber) + } else { + primaryImageUrl?.let(builder::setDisplayIconUri) + } + + return builder.build() + } + + private fun buildMediaId(item: BaseItemDto, extra: String?) = when (item.baseItemType) { + BaseItemType.MusicArtist -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}" + BaseItemType.MusicGenre -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}" + BaseItemType.MusicAlbum -> "${LibraryPage.ALBUM}|${item.id}" + BaseItemType.Playlist -> "${LibraryPage.PLAYLIST}|${item.id}" + BaseItemType.Audio -> "$extra|${item.id}" + else -> throw IllegalArgumentException("Unhandled item type ${item.baseItemType.name}") + } + + private fun List.browsable(): List = map { metadata -> + MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE) + } + + private fun List.playable(): List = map { metadata -> + MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/media/car/LibraryPage.kt b/app/src/main/java/org/jellyfin/mobile/media/car/LibraryPage.kt new file mode 100644 index 00000000..7d5417dd --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/media/car/LibraryPage.kt @@ -0,0 +1,63 @@ +package org.jellyfin.mobile.media.car + +object LibraryPage { + /** + * List of music libraries that the user can access (referred to as "user views" in Jellyfin) + */ + const val LIBRARIES = "libraries" + + /** + * Special root id for use with [EXTRA_RECENT][androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT] + */ + const val RESUME = "resume" + + /** + * A single music library + */ + const val LIBRARY = "library" + + /** + * A list of recently added tracks + */ + const val RECENTS = "recents" + + /** + * A list of albums + */ + const val ALBUMS = "albums" + + /** + * A list of artists + */ + const val ARTISTS = "artists" + + /** + * A list of albums by a specific artist + */ + const val ARTIST_ALBUMS = "artist_albums" + + /** + * A list of genres + */ + const val GENRES = "genres" + + /** + * A list of albums with a specific genre + */ + const val GENRE_ALBUMS = "genre_albums" + + /** + * A list of playlists + */ + const val PLAYLISTS = "playlists" + + /** + * An individual album + */ + const val ALBUM = "album" + + /** + * An individual playlist + */ + const val PLAYLIST = "playlist" +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt index fcde5787..f5f5446d 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -42,7 +42,7 @@ object Constants { const val INPUT_MANAGER_COMMAND_BACK = "back" // Notification - const val MEDIA_NOTIFICATION_CHANNEL_ID = "JellyfinChannelId" + const val MEDIA_NOTIFICATION_CHANNEL_ID = "org.jellyfin.mobile.media.NOW_PLAYING" // Music player constants const val SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or @@ -52,7 +52,7 @@ object Constants { PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS or PlaybackState.ACTION_SET_RATING - const val MUSIC_PLAYER_NOTIFICATION_ID = 84 + const val MEDIA_PLAYER_NOTIFICATION_ID = 42 // Music player intent actions const val ACTION_SHOW_PLAYER = "org.jellyfin.mobile.intent.action.SHOW_PLAYER" diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt b/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt index bde54719..e154a83c 100644 --- a/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt +++ b/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt @@ -55,7 +55,7 @@ import org.jellyfin.mobile.utils.Constants.INPUT_MANAGER_COMMAND_PREVIOUS import org.jellyfin.mobile.utils.Constants.INPUT_MANAGER_COMMAND_REWIND import org.jellyfin.mobile.utils.Constants.INPUT_MANAGER_COMMAND_STOP import org.jellyfin.mobile.utils.Constants.MEDIA_NOTIFICATION_CHANNEL_ID -import org.jellyfin.mobile.utils.Constants.MUSIC_PLAYER_NOTIFICATION_ID +import org.jellyfin.mobile.utils.Constants.MEDIA_PLAYER_NOTIFICATION_ID import org.jellyfin.mobile.utils.Constants.SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes import org.jellyfin.mobile.utils.createMediaNotificationChannel @@ -301,7 +301,7 @@ class RemotePlayerService : Service(), CoroutineScope { }.build() // Post notification - notificationManager.notify(MUSIC_PLAYER_NOTIFICATION_ID, notification) + notificationManager.notify(MEDIA_PLAYER_NOTIFICATION_ID, notification) // Activate MediaSession mediaSession.isActive = true @@ -334,7 +334,7 @@ class RemotePlayerService : Service(), CoroutineScope { val intent = Intent(applicationContext, RemotePlayerService::class.java).apply { action = intentAction } - val pendingIntent = PendingIntent.getService(applicationContext, MUSIC_PLAYER_NOTIFICATION_ID, intent, 0) + val pendingIntent = PendingIntent.getService(applicationContext, MEDIA_PLAYER_NOTIFICATION_ID, intent, 0) @Suppress("DEPRECATION") return Notification.Action.Builder(icon, getString(title), pendingIntent).build() } @@ -388,7 +388,7 @@ class RemotePlayerService : Service(), CoroutineScope { } private fun onStopped() { - notificationManager.cancel(MUSIC_PLAYER_NOTIFICATION_ID) + notificationManager.cancel(MEDIA_PLAYER_NOTIFICATION_ID) mediaSession?.isActive = false headphoneFlag = false stopWakelock() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ce1eeb3..bc038691 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,20 @@ Downloading Cannot download files without storage permissions + An error occurred + Media item could not be found + + + Recently played + Albums + Artists + Songs + Genres + Playlists + No title + + Jellyfin Music Player + Used for music playback controls Previous Rewind Play @@ -62,13 +76,4 @@ Free and open source cross-platform multimedia player that plays most multimedia files. System default Let the system handle the player choice, some players may not be fully compatible. - - - Shuffle - Latest - Albums - Artists - Songs - Genres - Playlists diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 9d05558b..3e50d7ab 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -103,6 +103,7 @@ object Dependencies { object Cast { val mediaRouter = androidx("mediarouter", Versions.mediaRouter) + const val exoPlayerCastExtension = "com.google.android.exoplayer:extension-cast:${Versions.exoPlayer}" const val playServicesCast = "com.google.android.gms:play-services-cast:${Versions.playServicesCast}" const val playServicesCastFramework = "com.google.android.gms:play-services-cast-framework:${Versions.playServicesCast}" }