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