mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-03 05:10:27 +03:00
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:
@@ -118,6 +118,7 @@ dependencies {
|
||||
|
||||
// Cast
|
||||
implementation(Dependencies.Cast.mediaRouter)
|
||||
implementation(Dependencies.Cast.exoPlayerCastExtension)
|
||||
proprietaryImplementation(Dependencies.Cast.playServicesCast)
|
||||
proprietaryImplementation(Dependencies.Cast.playServicesCastFramework)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user