Migrate to media3 (#1655)

* Migrate to media3

* Restore old play and pause icon

* Various small cleanups

---------

Co-authored-by: Maxr1998 <max.rumpf1998@gmail.com>
This commit is contained in:
Niels van Velzen
2025-05-27 14:54:47 +02:00
committed by GitHub
parent 219b5487db
commit 0d36d61626
32 changed files with 121 additions and 1420 deletions

View File

@@ -11,4 +11,8 @@
<!-- Weblate doesn't use ellipsis characters -->
<ignore regexp="app/src/main/res/values-.*" />
</issue>
<!-- We're the end-user of media3 so we can more safely use unstable API's -->
<issue id="UnsafeOptInUsageError">
<option name="opt-in" value="androidx.media3.common.util.UnstableApi" />
</issue>
</lint>

View File

@@ -147,17 +147,12 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.okio)
implementation(libs.coil)
implementation(libs.cronet.embedded)
// Media
implementation(libs.androidx.media)
implementation(libs.androidx.mediarouter)
implementation(libs.bundles.exoplayer) {
// Exclude Play Services cronet provider library
exclude("com.google.android.gms", "play-services-cronet")
}
implementation(libs.jellyfin.exoplayer.ffmpegextension)
proprietaryImplementation(libs.exoplayer.cast)
implementation(libs.bundles.androidx.media3)
proprietaryImplementation(libs.androidx.media3.cast)
proprietaryImplementation(libs.bundles.playservices)
// Room

View File

@@ -1,10 +0,0 @@
package org.jellyfin.mobile.player.cast
import com.google.android.exoplayer2.Player
import org.jellyfin.mobile.player.audio.MediaService
class CastPlayerProvider(@Suppress("UNUSED_PARAMETER") mediaService: MediaService) : ICastPlayerProvider {
override val isCastSessionAvailable: Boolean = false
override fun get(): Player? = null
}

View File

@@ -78,16 +78,6 @@
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<service
android:name="org.jellyfin.mobile.player.audio.MediaService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<service android:name="org.jellyfin.mobile.downloads.JellyfinDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">

View File

@@ -2,36 +2,32 @@ package org.jellyfin.mobile.app
import android.content.Context
import androidx.core.net.toUri
import androidx.media3.common.util.Util
import androidx.media3.database.DatabaseProvider
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
import coil.ImageLoader
import com.google.android.exoplayer2.database.DatabaseProvider
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.ts.TsExtractor
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.ResolvingDataSource
import com.google.android.exoplayer2.upstream.cache.Cache
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import org.chromium.net.CronetProvider
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.downloads.DownloadsViewModel
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayerEvent
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
@@ -53,10 +49,8 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.io.File
import java.util.concurrent.Executors
const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
private const val HTTP_CACHE_SIZE: Long = 16 * 1024 * 1024
private const val TS_SEARCH_PACKETS = 1800
val applicationModule = module {
@@ -111,24 +105,8 @@ val applicationModule = module {
val context: Context = get()
val apiClient: ApiClient = get()
val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
(provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled
}
val baseDataSourceFactory = if (provider != null) {
val cronetEngine = provider.createBuilder()
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_IN_MEMORY, HTTP_CACHE_SIZE)
.build()
CronetDataSource.Factory(cronetEngine, Executors.newCachedThreadPool()).apply {
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
}
} else {
DefaultHttpDataSource.Factory().apply {
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
}
val baseDataSourceFactory = DefaultHttpDataSource.Factory().apply {
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
}
val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)
@@ -183,7 +161,4 @@ val applicationModule = module {
single { ProgressiveMediaSource.Factory(get<CacheDataSource.Factory>()) }
single { HlsMediaSource.Factory(get<CacheDataSource.Factory>()) }
single { SingleSampleMediaSource.Factory(get<CacheDataSource.Factory>()) }
// Media components
single { LibraryBrowser(get(), get()) }
}

View File

@@ -1,11 +1,11 @@
package org.jellyfin.mobile.downloads
import android.content.Context
import com.google.android.exoplayer2.database.DatabaseProvider
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.cache.Cache
import androidx.media3.database.DatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

View File

@@ -1,9 +1,9 @@
package org.jellyfin.mobile.downloads
import android.net.Uri
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadIndex
import com.google.android.exoplayer2.offline.DownloadManager
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadIndex
import androidx.media3.exoplayer.offline.DownloadManager
import com.google.common.base.Preconditions
import timber.log.Timber
import java.io.IOException

View File

@@ -19,11 +19,11 @@ import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.Requirements
import coil.ImageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.offline.DownloadRequest
import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.exoplayer2.scheduler.Requirements
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext

View File

@@ -4,14 +4,14 @@ import android.app.Notification
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.exoplayer2.scheduler.PlatformScheduler
import com.google.android.exoplayer2.scheduler.Scheduler
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
import com.google.android.exoplayer2.util.NotificationUtil
import com.google.android.exoplayer2.util.Util
import androidx.media3.common.util.NotificationUtil
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.PlatformScheduler
import androidx.media3.exoplayer.scheduler.Scheduler
import org.jellyfin.mobile.R
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.toFileSize

View File

@@ -12,20 +12,20 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector
import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException
import com.google.android.exoplayer2.mediacodec.MediaCodecInfo
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.util.Clock
import com.google.android.exoplayer2.util.EventLogger
import com.google.android.exoplayer2.util.MimeTypes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.Clock
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector
import androidx.media3.exoplayer.mediacodec.MediaCodecDecoderException
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.util.EventLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

View File

@@ -1,7 +1,7 @@
package org.jellyfin.mobile.player
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import androidx.media3.common.C
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType

View File

@@ -1,133 +0,0 @@
// 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.player.audio
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.ForwardingPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.mobile.R
import org.jellyfin.mobile.utils.Constants.MEDIA_NOTIFICATION_CHANNEL_ID
import org.jellyfin.mobile.utils.Constants.MEDIA_PLAYER_NOTIFICATION_ID
import org.koin.core.component.KoinComponent
import org.koin.core.component.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 AudioNotificationManager(
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
.Builder(context, MEDIA_PLAYER_NOTIFICATION_ID, MEDIA_NOTIFICATION_CHANNEL_ID)
.setChannelNameResourceId(R.string.music_notification_channel)
.setChannelDescriptionResourceId(R.string.music_notification_channel_description)
.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
.setNotificationListener(notificationListener)
.build()
notificationManager.apply {
setMediaSessionToken(sessionToken)
setSmallIcon(R.drawable.ic_notification)
}
}
fun showNotificationForPlayer(player: Player) {
notificationManager.setPlayer(NotificationForwardingPlayer(player))
}
fun hideNotification() {
notificationManager.setPlayer(null)
}
/**
* Removes rewind and fast-forward buttons from notification
*/
private class NotificationForwardingPlayer(player: Player) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands = super.getAvailableCommands().buildUpon().removeAll(
COMMAND_SEEK_BACK,
COMMAND_SEEK_FORWARD,
).build()
}
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 when {
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()
}
}
}

View File

@@ -1,432 +0,0 @@
// 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.player.audio
import android.app.Notification
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
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 androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import androidx.mediarouter.media.MediaRouterParams
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.audio.car.LibraryPage
import org.jellyfin.mobile.player.cast.CastPlayerProvider
import org.jellyfin.mobile.player.cast.ICastPlayerProvider
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.mediaUri
import org.jellyfin.mobile.utils.toast
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import timber.log.Timber
import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem
class MediaService : MediaBrowserServiceCompat() {
private val apiClientController: ApiClientController by inject()
private val libraryBrowser: LibraryBrowser by inject()
private val serviceScope = MainScope()
private var isForegroundService = false
private lateinit var loadingJob: Job
// 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: AudioNotificationManager
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: List<MediaMetadataCompat> = emptyList()
private val playerAudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
@Suppress("MemberVisibilityCanBePrivate")
val playerListener: Player.Listener = PlayerEventListener()
private val exoPlayer: Player by lazy {
ExoPlayer.Builder(this, get<MediaSource.Factory>()).apply {
setUsePlatformDiagnostics(false)
}.build().apply {
setAudioAttributes(playerAudioAttributes, true)
setHandleAudioBecomingNoisy(true)
addListener(playerListener)
}
}
private val castPlayerProvider: ICastPlayerProvider by lazy {
CastPlayerProvider(this)
}
override fun onCreate() {
super.onCreate()
loadingJob = serviceScope.launch {
apiClientController.loadSavedServerUser()
}
val packageLaunchIntent = packageManager?.getLaunchIntentForPackage(packageName)
val sessionActivityPendingIntent = packageLaunchIntent?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, Constants.PENDING_INTENT_FLAGS)
}
mediaSession = MediaSessionCompat(this, "MediaService").apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
sessionToken = mediaSession.sessionToken
notificationManager = AudioNotificationManager(
this,
mediaSession.sessionToken,
PlayerNotificationListener(),
)
mediaController = MediaControllerCompat(this, mediaSession)
mediaSessionConnector = MediaSessionConnector(mediaSession).apply {
setPlayer(exoPlayer)
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 (castPlayerProvider.isCastSessionAvailable) castPlayerProvider.get()!! else exoPlayer,
)
notificationManager.showNotificationForPlayer(currentPlayer)
}
override fun onDestroy() {
super.onDestroy()
mediaSession.run {
isActive = false
release()
}
// Cancel coroutines when the service is going away
serviceScope.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 = libraryBrowser.getRoot(rootHints)
override fun onLoadChildren(parentId: String, result: Result<List<MediaItem>>) {
result.detach()
serviceScope.launch(Dispatchers.IO) {
// Ensure that server and credentials are available
loadingJob.join()
val items = try {
libraryBrowser.loadLibrary(parentId)
} catch (e: ApiClientException) {
Timber.e(e)
null
}
result.sendResult(items ?: emptyList())
}
}
/**
* 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
val mediaItems = metadataList.map { metadata ->
ExoPlayerMediaItem.Builder().apply {
setUri(metadata.mediaUri)
setTag(metadata)
}.build()
}
currentPlayer.playWhenReady = playWhenReady
with(currentPlayer) {
stop()
clearMediaItems()
}
if (currentPlayer == exoPlayer) {
with(exoPlayer) {
setMediaItems(mediaItems)
prepare()
seekTo(initialPlaybackIndex, playbackStartPositionMs)
}
} else {
val castPlayer = castPlayerProvider.get()
if (currentPlayer == castPlayer) {
castPlayer.setMediaItems(
mediaItems,
initialPlaybackIndex,
playbackStartPositionMs,
)
}
}
}
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.
with(currentPlayer) {
stop()
clearMediaItems()
}
} else if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) {
preparePlaylist(
metadataList = currentPlaylistItems,
initialPlaybackIndex = previousPlayer.currentMediaItemIndex,
playWhenReady = previousPlayer.playWhenReady,
playbackStartPositionMs = previousPlayer.currentPosition,
)
}
}
mediaSessionConnector.setPlayer(newPlayer)
previousPlayer?.run {
stop()
clearMediaItems()
}
}
private fun setPlaybackError() {
val errorState = PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_ERROR, 0, 1f)
.setErrorMessage(
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED,
getString(R.string.media_service_item_not_found),
)
.build()
mediaSession.setPlaybackState(errorState)
}
@Suppress("unused")
fun onCastSessionAvailable() {
val castPlayer = castPlayerProvider.get() ?: return
switchToPlayer(currentPlayer, castPlayer)
}
@Suppress("unused")
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 = 0L or
PlaybackStateCompat.ACTION_PREPARE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
override fun onPrepare(playWhenReady: Boolean) {
serviceScope.launch {
val recents = try {
libraryBrowser.getDefaultRecents()
} catch (e: ApiClientException) {
Timber.e(e)
null
}
if (recents != null) {
preparePlaylist(recents, 0, playWhenReady)
} else {
setPlaybackError()
}
}
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
if (mediaId == LibraryPage.RESUME) {
// Requested recents
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 = try {
libraryBrowser.getSearchResults(query, extras)
} catch (e: ApiClientException) {
Timber.e(e)
null
}
if (results != null) {
preparePlaylist(results, 0, playWhenReady)
} else {
setPlaybackError()
}
}
}
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit
override fun onCommand(
player: Player,
command: String,
extras: Bundle?,
cb: ResultReceiver?,
): Boolean = false
}
/**
* 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)
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.Listener {
@Deprecated("Deprecated in Java")
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: PlaybackException) {
toast("${getString(R.string.media_service_generic_error)}: ${error.errorCodeName}", 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 {
/** Declares that content style is supported */
const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
}
}

View File

@@ -1,457 +0,0 @@
package org.jellyfin.mobile.player.audio.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 androidx.media.utils.MediaConstants
import org.jellyfin.mobile.R
import org.jellyfin.mobile.player.audio.MediaService
import org.jellyfin.mobile.utils.extensions.mediaId
import org.jellyfin.mobile.utils.extensions.setAlbum
import org.jellyfin.mobile.utils.extensions.setAlbumArtUri
import org.jellyfin.mobile.utils.extensions.setAlbumArtist
import org.jellyfin.mobile.utils.extensions.setArtist
import org.jellyfin.mobile.utils.extensions.setDisplayIconUri
import org.jellyfin.mobile.utils.extensions.setMediaId
import org.jellyfin.mobile.utils.extensions.setMediaUri
import org.jellyfin.mobile.utils.extensions.setTitle
import org.jellyfin.mobile.utils.extensions.setTrackNumber
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.GenresApi
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.PlaylistsApi
import org.jellyfin.sdk.api.operations.UniversalAudioApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.*
@Suppress("TooManyFunctions")
class LibraryBrowser(
private val context: Context,
private val apiClient: ApiClient,
) {
private val itemsApi: ItemsApi = apiClient.itemsApi
private val userViewsApi: UserViewsApi = apiClient.userViewsApi
private val genresApi: GenresApi = apiClient.genresApi
private val playlistsApi: PlaylistsApi = apiClient.playlistsApi
private val imageApi: ImageApi = apiClient.imageApi
private val universalAudioApi: UniversalAudioApi = apiClient.universalAudioApi
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(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
)
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
)
}
return MediaBrowserServiceCompat.BrowserRoot(browserRoot, rootExtras)
}
suspend fun loadLibrary(parentId: String): List<MediaBrowserCompat.MediaItem>? {
if (parentId == LibraryPage.RESUME) {
return getDefaultRecents()?.browsable()
}
val split = parentId.split('|')
@Suppress("MagicNumber")
if (split.size !in 1..3) {
Timber.e("Invalid libraryId format '$parentId'")
return null
}
val type = split[0]
val libraryId = split.getOrNull(1)?.toUUIDOrNull()
val itemId = split.getOrNull(2)?.toUUIDOrNull()
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('|')
@Suppress("MagicNumber")
if (split.size != 3) {
Timber.e("Invalid mediaId format '$mediaId'")
return null
}
val type = split[0]
val collectionId = split[1].toUUID()
val playQueue = try {
when (type) {
LibraryPage.RECENTS -> getRecents(collectionId)
LibraryPage.ALBUM -> getAlbum(collectionId)
LibraryPage.PLAYLIST -> getPlaylist(collectionId)
else -> null
}
} catch (e: ApiClientException) {
Timber.e(e)
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, BaseItemKind.MUSIC_ALBUM)
}?.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, BaseItemKind.MUSIC_ARTIST)
}?.let { artistId ->
itemsApi.getItems(
artistIds = listOf(artistId),
includeItemTypes = listOf(BaseItemKind.AUDIO),
sortBy = listOf(ItemSortBy.RANDOM),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 50,
).content.extractItems()
}?.let { artistTracks ->
Timber.d("Got result, starting playback")
return artistTracks
}
}
}
// Fallback to generic search
Timber.d("Searching for '$searchQuery'")
val result by itemsApi.getItems(
searchTerm = searchQuery,
includeItemTypes = listOf(BaseItemKind.AUDIO),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 50,
)
return result.extractItems()
}
/**
* Find a single specific item for the given [searchQuery] with a specific [type]
*/
private suspend fun searchItems(searchQuery: String, type: BaseItemKind): UUID? {
val result by itemsApi.getItems(
searchTerm = searchQuery,
includeItemTypes = listOf(type),
recursive = true,
enableImages = false,
enableTotalRecordCount = false,
limit = 1,
)
return result.items.firstOrNull()?.id
}
suspend fun getDefaultRecents(): List<MediaMetadataCompat>? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary ->
val libraryId = defaultLibrary.split('|').getOrNull(1) ?: return@let null
getRecents(libraryId.toUUID())
}
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
val userViews by userViewsApi.getUserViews()
return userViews.items
.filter { item -> item.collectionType == CollectionType.MUSIC }
.map { item ->
val itemImageUrl = imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.PRIMARY,
)
val description = MediaDescriptionCompat.Builder().apply {
setMediaId(LibraryPage.LIBRARY + "|" + item.id)
setTitle(item.name)
setIconUri(Uri.parse(itemImageUrl))
}.build()
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
}
.toList()
}
private fun getLibraryViews(context: Context, libraryId: UUID): 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(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
)
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
)
},
)
}
}.build()
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
}
}
private suspend fun getRecents(libraryId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
filters = listOf(ItemFilter.IS_PLAYED),
sortBy = listOf(ItemSortBy.DATE_PLAYED),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
enableTotalRecordCount = false,
limit = 50,
)
return result.extractItems("${LibraryPage.RECENTS}|$libraryId")
}
private suspend fun getAlbums(
libraryId: UUID,
filterArtist: UUID? = null,
filterGenre: UUID? = null,
): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
parentId = libraryId,
artistIds = filterArtist?.let(::listOf),
genreIds = filterGenre?.let(::listOf),
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 400,
)
return result.extractItems()?.browsable()
}
private suspend fun getArtists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 200,
)
return result.extractItems(libraryId.toString())?.browsable()
}
private suspend fun getGenres(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by genresApi.getGenres(
parentId = libraryId,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 50,
)
return result.extractItems(libraryId.toString())?.browsable()
}
private suspend fun getPlaylists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
limit = 50,
)
return result.extractItems()?.browsable()
}
private suspend fun getAlbum(albumId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItems(
parentId = albumId,
sortBy = listOf(ItemSortBy.SORT_NAME),
)
return result.extractItems("${LibraryPage.ALBUM}|$albumId")
}
private suspend fun getPlaylist(playlistId: UUID): List<MediaMetadataCompat>? {
val result by playlistsApi.getPlaylistItems(
playlistId = playlistId,
)
return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId")
}
private fun BaseItemDtoQueryResult.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 itemId = when {
item.imageTags?.containsKey(ImageType.PRIMARY) == true -> item.id
isAlbum -> item.albumId
else -> null
}
val primaryImageUrl = itemId?.let {
imageApi.getItemImageUrl(
itemId = itemId,
imageType = ImageType.PRIMARY,
tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags?.get(ImageType.PRIMARY),
)
}
if (item.type == BaseItemKind.AUDIO) {
val uri = universalAudioApi.getUniversalAudioStreamUrl(
itemId = item.id,
deviceId = apiClient.deviceInfo.id,
maxStreamingBitrate = 140000000,
container = listOf(
"opus",
"mp3|mp3",
"aac",
"m4a",
"m4b|aac",
"flac",
"webma",
"webm",
"wav",
"ogg",
),
transcodingProtocol = MediaStreamProtocol.HLS,
transcodingContainer = "ts",
audioCodec = "aac",
enableRemoteMedia = true,
)
builder.setMediaUri(uri)
item.album?.let(builder::setAlbum)
item.artists?.let { builder.setArtist(it.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.type) {
BaseItemKind.MUSIC_ARTIST -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}"
BaseItemKind.MUSIC_GENRE -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}"
BaseItemKind.MUSIC_ALBUM -> "${LibraryPage.ALBUM}|${item.id}"
BaseItemKind.PLAYLIST -> "${LibraryPage.PLAYLIST}|${item.id}"
BaseItemKind.AUDIO -> "$extra|${item.id}"
else -> throw IllegalArgumentException("Unhandled item type ${item.type}")
}
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)
}
}

View File

@@ -1,63 +0,0 @@
package org.jellyfin.mobile.player.audio.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"
}

View File

@@ -1,9 +0,0 @@
package org.jellyfin.mobile.player.cast
import com.google.android.exoplayer2.Player
interface ICastPlayerProvider {
val isCastSessionAvailable: Boolean
fun get(): Player?
}

View File

@@ -2,7 +2,7 @@ package org.jellyfin.mobile.player.deviceprofile
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaFormat
import com.google.android.exoplayer2.util.MimeTypes
import androidx.media3.common.MimeTypes
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
object CodecHelpers {

View File

@@ -13,9 +13,9 @@ import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player
import coil.ImageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.Player
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

View File

@@ -5,12 +5,12 @@ import androidx.annotation.CheckResult
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.MergingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.PlayerViewModel

View File

@@ -25,8 +25,8 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerView
import androidx.media3.common.Player
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
@@ -51,7 +51,7 @@ import org.jellyfin.mobile.utils.extensions.keepScreenOn
import org.jellyfin.mobile.utils.toast
import org.jellyfin.sdk.model.api.MediaStream
import org.koin.android.ext.android.inject
import com.google.android.exoplayer2.ui.R as ExoplayerR
import androidx.media3.ui.R as Media3R
class PlayerFragment : Fragment(), BackPressInterceptor {
private val appPreferences: AppPreferences by inject()
@@ -199,6 +199,9 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
// Set controller timeout
suppressControllerAutoHide(false)
// Disable controller animations
playerView.setControllerAnimationEnabled(false)
playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
@@ -358,7 +361,7 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
}
}
setAspectRatio(aspectRational)
val contentFrame: View = playerView.findViewById(ExoplayerR.id.exo_content_frame)
val contentFrame: View = playerView.findViewById(Media3R.id.exo_content_frame)
val contentRect = with(contentFrame) {
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
Rect(x, y, x + width, y + height)

View File

@@ -14,8 +14,8 @@ import android.widget.ProgressBar
import androidx.core.content.getSystemService
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.PlayerView
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
@@ -123,7 +123,7 @@ class PlayerGestureHelper(
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
playerView.apply {
if (!isControllerVisible) showController() else hideController()
if (!isControllerFullyVisible) showController() else hideController()
}
return true
}

View File

@@ -4,7 +4,7 @@ import android.content.pm.ActivityInfo
import android.view.OrientationEventListener
import android.widget.ImageButton
import androidx.core.view.isVisible
import com.google.android.exoplayer2.ui.PlayerView
import androidx.media3.ui.PlayerView
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.Constants
@@ -50,7 +50,7 @@ class PlayerLockScreenHelper(
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
playerView.useController = true
playerView.apply {
if (!isControllerVisible) showController()
if (!isControllerFullyVisible) showController()
}
}
}

View File

@@ -7,15 +7,14 @@ import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.analytics.AnalyticsCollector
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.analytics.AnalyticsCollector
import org.jellyfin.mobile.player.source.JellyfinMediaSource
import org.jellyfin.mobile.ui.content.ImageProvider
import org.jellyfin.mobile.utils.extensions.width
import org.jellyfin.sdk.model.api.ImageType
import com.google.android.exoplayer2.audio.AudioAttributes as ExoPlayerAudioAttributes
import androidx.media3.common.AudioAttributes as Media3AudioAttributes
inline fun MediaSession.applyDefaultLocalAudioAttributes(contentType: Int) {
val audioAttributes = AudioAttributes.Builder().apply {
@@ -79,10 +78,10 @@ fun AudioManager.getVolumeLevelPercent(): Int {
}
/**
* Set ExoPlayer [ExoPlayerAudioAttributes], make ExoPlayer handle audio focus
* Configure [Media3AudioAttributes] to handle audio focus
*/
inline fun ExoPlayer.applyDefaultAudioAttributes(@C.AudioContentType contentType: Int) {
val audioAttributes = ExoPlayerAudioAttributes.Builder()
inline fun Player.applyDefaultAudioAttributes(@C.AudioContentType contentType: Int) {
val audioAttributes = Media3AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(contentType)
.build()

View File

@@ -17,7 +17,7 @@ import android.provider.Settings
import android.provider.Settings.System.ACCELEROMETER_ROTATION
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.getSystemService
import com.google.android.exoplayer2.offline.DownloadService
import androidx.media3.exoplayer.offline.DownloadService
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jellyfin.mobile.BuildConfig

View File

@@ -1,9 +1,8 @@
package org.jellyfin.mobile.utils
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
/**
* Select the [trackGroup] of the specified [type] and ensure the type is enabled.

View File

@@ -1,122 +0,0 @@
@file:Suppress("NOTHING_TO_INLINE")
package org.jellyfin.mobile.utils.extensions
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import androidx.core.net.toUri
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)

View File

@@ -42,23 +42,12 @@
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/exo_play"
android:id="@+id/exo_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ripple_background_circular"
android:padding="@dimen/exo_center_icon_padding"
android:src="@drawable/ic_play_black_42dp"
android:tint="?android:textColorPrimary" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/exo_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/ripple_background_circular"
android:padding="@dimen/exo_center_icon_padding"
android:src="@drawable/ic_pause_black_42dp"
android:tint="?android:textColorPrimary"
tools:visibility="gone" />
</FrameLayout>
<androidx.appcompat.widget.AppCompatImageButton
@@ -85,7 +74,7 @@
app:layout_constraintTop_toTopOf="@id/exo_progress"
tools:text="33:01" />
<com.google.android.exoplayer2.ui.DefaultTimeBar
<androidx.media3.ui.DefaultTimeBar
android:id="@+id/exo_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@@ -1,15 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<com.google.android.exoplayer2.ui.PlayerView
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="@drawable/ripple_background" />
android:foreground="@drawable/ripple_background"
app:pause_icon="@drawable/ic_pause_black_42dp"
app:play_icon="@drawable/ic_play_black_42dp" />
<FrameLayout
android:id="@+id/player_overlay"

View File

@@ -1,31 +0,0 @@
package org.jellyfin.mobile.player.cast
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.gms.cast.framework.CastContext
import org.jellyfin.mobile.player.audio.MediaService
class CastPlayerProvider(private val mediaService: MediaService) : ICastPlayerProvider, SessionAvailabilityListener {
private val castPlayer: CastPlayer? = try {
CastPlayer(CastContext.getSharedInstance(mediaService)).apply {
setSessionAvailabilityListener(this@CastPlayerProvider)
addListener(mediaService.playerListener)
}
} catch (e: Exception) {
null
}
override val isCastSessionAvailable: Boolean
get() = castPlayer?.isCastSessionAvailable == true
override fun get(): Player? = castPlayer
override fun onCastSessionAvailable() {
mediaService.onCastSessionAvailable()
}
override fun onCastSessionUnavailable() {
mediaService.onCastSessionUnavailable()
}
}

View File

@@ -11,7 +11,7 @@ allprojects {
content {
includeVersionByRegex(JellyfinSdk.GROUP, ".*", JellyfinSdk.SNAPSHOT)
includeVersionByRegex(JellyfinSdk.GROUP, ".*", JellyfinSdk.SNAPSHOT_UNSTABLE)
includeVersionByRegex(JellyfinExoPlayer.GROUP, ".*", JellyfinExoPlayer.SNAPSHOT)
includeVersionByRegex(JellyfinMedia3.GROUP, ".*", JellyfinMedia3.SNAPSHOT)
}
}
}

View File

@@ -6,7 +6,7 @@ object JellyfinSdk {
const val SNAPSHOT_UNSTABLE = "openapi-unstable-SNAPSHOT"
}
object JellyfinExoPlayer {
const val GROUP = "org.jellyfin.exoplayer"
object JellyfinMedia3 {
const val GROUP = "org.jellyfin.media3"
const val SNAPSHOT = "SNAPSHOT"
}

View File

@@ -40,13 +40,12 @@ jellyfin-sdk = "1.6.8"
okhttp = "4.12.0"
okio = "3.11.0"
coil = "2.7.0"
cronet-embedded = "119.6045.31"
# Media
androidx-media = "1.7.0"
androidx-mediarouter = "1.7.0"
exoplayer = "2.19.1"
jellyfin-exoplayer-ffmpegextension = "2.19.1+1"
androidx-media3 = "1.6.1"
jellyfin-androidx-media = "1.6.1+1"
playservices = "22.1.0"
# Room
@@ -118,20 +117,20 @@ jellyfin-sdk = { group = "org.jellyfin.sdk", name = "jellyfin-core", version.ref
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
coil = { group = "io.coil-kt", name = "coil-base", version.ref = "coil" }
cronet-embedded = { group = "org.chromium.net", name = "cronet-embedded", version.ref = "cronet-embedded" }
# Media
androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" }
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" }
exoplayer-core = { group = "com.google.android.exoplayer", name = "exoplayer-core", version.ref = "exoplayer" }
exoplayer-ui = { group = "com.google.android.exoplayer", name = "exoplayer-ui", version.ref = "exoplayer" }
exoplayer-mediaSession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" }
exoplayer-hls = { group = "com.google.android.exoplayer", name = "exoplayer-hls", version.ref = "exoplayer" }
exoplayer-cast = { group = "com.google.android.exoplayer", name = "extension-cast", version.ref = "exoplayer" }
exoplayer-cronet = { group = "com.google.android.exoplayer", name = "extension-cronet", version.ref = "exoplayer" }
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" }
androidx-media3-cast = { module = "androidx.media3:media3-cast", version.ref = "androidx-media3" }
playservices-cast = { group = "com.google.android.gms", name = "play-services-cast", version.ref = "playservices" }
playservices-castframework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playservices" }
jellyfin-exoplayer-ffmpegextension = { group = "org.jellyfin.exoplayer", name = "exoplayer-ffmpeg-extension", version.ref = "jellyfin-exoplayer-ffmpegextension" }
jellyfin-androidx-media3-ffmpeg-decoder = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version.ref = "jellyfin-androidx-media" }
# Room
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" }
@@ -174,12 +173,14 @@ compose = [
"compose-material-icons",
"compose-material-icons-extended",
]
exoplayer = [
"exoplayer-core",
"exoplayer-ui",
"exoplayer-mediaSession",
"exoplayer-hls",
"exoplayer-cronet",
androidx-media3 = [
"androidx-media3-common",
"androidx-media3-ui",
"androidx-media3-session",
"androidx-media3-exoplayer",
"androidx-media3-exoplayer-hls",
"androidx-media3-datasource-okhttp",
"jellyfin-androidx-media3-ffmpeg-decoder",
]
playservices = [
"playservices-cast",