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}" }