Improve and rework MediaService & Android Auto integration

Moves library browser code into extra class and package, as it will only be used for Android Auto.
The loader function was split into multiple subfunctions and cleaned up.
The format of 'parentIds' was improved, unexpected behaviour with queue selection fixed.
Shuffling was temporarily removed and will be handled differently in the future.
Also introduces preliminaries for casting and Android 11 seamless transfer, this isn't fully enabled yet however -
it seems there are still some changes necessary within the AndroidX libraries.

Uses code from the android/uamp reference media player project: https://github.com/android/uamp
This commit is contained in:
Maxr1998
2020-11-15 22:04:12 +01:00
parent 3b19fa8465
commit b1f2f6c185
11 changed files with 1036 additions and 446 deletions
+1
View File
@@ -118,6 +118,7 @@ dependencies {
// Cast
implementation(Dependencies.Cast.mediaRouter)
implementation(Dependencies.Cast.exoPlayerCastExtension)
proprietaryImplementation(Dependencies.Cast.playServicesCast)
proprietaryImplementation(Dependencies.Cast.playServicesCastFramework)
+12 -1
View File
@@ -47,18 +47,29 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- declare legacy support for voice actions -->
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service android:name=".webapp.RemotePlayerService" />
<service
android:name="org.jellyfin.mobile.media.MediaService"
android:exported="true">
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true"
tools:ignore="ExportedReceiver" />
<meta-data
android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
@@ -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)
@@ -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()
}
}
}
@@ -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<MediaDescriptionCompat> = mutableListOf()
private var currentPlaylistItems: List<MediaMetadataCompat> = 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<List<MediaItem>>) {
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<List<MediaItem>>) {
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<MediaMetadataCompat>,
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<BaseItemDto>) {
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
}
}
@@ -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<MediaBrowserCompat.MediaItem>? {
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<List<MediaMetadataCompat>, 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<MediaMetadataCompat>? {
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<MediaMetadataCompat>? =
getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary -> getRecents(defaultLibrary) }
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
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<MediaBrowserCompat.MediaItem> {
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<MediaMetadataCompat>? {
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<MediaBrowserCompat.MediaItem>? {
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<MediaBrowserCompat.MediaItem>? {
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<MediaBrowserCompat.MediaItem>? {
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<MediaBrowserCompat.MediaItem>? {
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<MediaMetadataCompat>? {
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<MediaMetadataCompat>? {
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<MediaMetadataCompat>? =
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<MediaMetadataCompat>.browsable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE)
}
private fun List<MediaMetadataCompat>.playable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE)
}
}
@@ -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"
}
@@ -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"
@@ -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()
+14 -9
View File
@@ -17,6 +17,20 @@
<string name="downloading">Downloading</string>
<string name="download_no_storage_permission">Cannot download files without storage permissions</string>
<string name="media_service_generic_error">An error occurred</string>
<string name="media_service_item_not_found">Media item could not be found</string>
<!-- MediaService / Car-related strings -->
<string name="media_service_car_section_recents">Recently played</string>
<string name="media_service_car_section_albums">Albums</string>
<string name="media_service_car_section_artists">Artists</string>
<string name="media_service_car_section_songs">Songs</string>
<string name="media_service_car_section_genres">Genres</string>
<string name="media_service_car_section_playlists">Playlists</string>
<string name="media_service_car_item_no_title">No title</string>
<string name="music_notification_channel">Jellyfin Music Player</string>
<string name="music_notification_channel_description">Used for music playback controls</string>
<string name="notification_action_previous">Previous</string>
<string name="notification_action_rewind">Rewind</string>
<string name="notification_action_play">Play</string>
@@ -62,13 +76,4 @@
<string name="external_player_vlc_player_description">Free and open source cross-platform multimedia player that plays most multimedia files.</string>
<string name="external_player_system_default">System default</string>
<string name="external_player_system_default_description">Let the system handle the player choice, some players may not be fully compatible.</string>
<!-- MediaService strings, e.g. for Android Auto -->
<string name="mediaservice_shuffle">Shuffle</string>
<string name="mediaservice_library_latest">Latest</string>
<string name="mediaservice_library_albums">Albums</string>
<string name="mediaservice_library_artists">Artists</string>
<string name="mediaservice_library_songs">Songs</string>
<string name="mediaservice_library_genres">Genres</string>
<string name="mediaservice_library_playlists">Playlists</string>
</resources>
+1
View File
@@ -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}"
}