From 0d36d6162664fe581ac294d037bc867c294e772b Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Tue, 27 May 2025 14:54:47 +0200 Subject: [PATCH] Migrate to media3 (#1655) * Migrate to media3 * Restore old play and pause icon * Various small cleanups --------- Co-authored-by: Maxr1998 --- android-lint.xml | 4 + app/build.gradle.kts | 9 +- .../mobile/player/cast/CastPlayerProvider.kt | 10 - app/src/main/AndroidManifest.xml | 10 - .../java/org/jellyfin/mobile/app/AppModule.kt | 67 +-- .../mobile/downloads/DownloadServiceUtil.kt | 10 +- .../mobile/downloads/DownloadTracker.kt | 6 +- .../mobile/downloads/DownloadUtils.kt | 6 +- .../downloads/JellyfinDownloadService.kt | 16 +- .../jellyfin/mobile/player/PlayerViewModel.kt | 28 +- .../mobile/player/TrackSelectionHelper.kt | 4 +- .../player/audio/AudioNotificationManager.kt | 133 ----- .../mobile/player/audio/MediaService.kt | 432 ----------------- .../mobile/player/audio/car/LibraryBrowser.kt | 457 ------------------ .../mobile/player/audio/car/LibraryPage.kt | 63 --- .../mobile/player/cast/ICastPlayerProvider.kt | 9 - .../player/deviceprofile/CodecHelpers.kt | 2 +- .../interaction/PlayerNotificationHelper.kt | 2 +- .../mobile/player/queue/QueueManager.kt | 12 +- .../mobile/player/ui/PlayerFragment.kt | 11 +- .../mobile/player/ui/PlayerGestureHelper.kt | 6 +- .../player/ui/PlayerLockScreenHelper.kt | 4 +- .../jellyfin/mobile/utils/MediaExtensions.kt | 15 +- .../org/jellyfin/mobile/utils/SystemUtils.kt | 2 +- .../mobile/utils/TrackSelectionUtils.kt | 7 +- .../utils/extensions/MediaMetadataCompat.kt | 122 ----- .../res/layout/exo_player_control_view.xml | 15 +- app/src/main/res/layout/fragment_player.xml | 7 +- .../mobile/player/cast/CastPlayerProvider.kt | 31 -- build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Jellyfin.kt | 4 +- gradle/libs.versions.toml | 35 +- 32 files changed, 121 insertions(+), 1420 deletions(-) delete mode 100644 app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt delete mode 100644 app/src/proprietary/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt diff --git a/android-lint.xml b/android-lint.xml index e20f8e8f..e0983c2d 100644 --- a/android-lint.xml +++ b/android-lint.xml @@ -11,4 +11,8 @@ + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8e26eb3..75cb78bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt b/app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt deleted file mode 100644 index 70d84821..00000000 --- a/app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 58d4e85f..7f6734d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -78,16 +78,6 @@ android:exported="false" android:foregroundServiceType="mediaPlayback" /> - - - - - - diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt index 4cf5df8e..52770847 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt @@ -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()) } single { HlsMediaSource.Factory(get()) } single { SingleSampleMediaSource.Factory(get()) } - - // Media components - single { LibraryBrowser(get(), get()) } } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt index 94f93b10..e65430ac 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt index b219cae9..decbb09f 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt index acd52dae..0659834a 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt index 26404a3e..ceded11b 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 02acdb87..4ac47b76 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt index 11e5be84..fa484431 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt deleted file mode 100644 index 928755b0..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt +++ /dev/null @@ -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() - } - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt deleted file mode 100644 index da9bd6dc..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt +++ /dev/null @@ -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 = 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()).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>) { - 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, - 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" - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt deleted file mode 100644 index 22eceac5..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt +++ /dev/null @@ -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? { - 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, 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? { - 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? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary -> - val libraryId = defaultLibrary.split('|').getOrNull(1) ?: return@let null - - getRecents(libraryId.toUUID()) - } - - private suspend fun getLibraries(): List { - 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 { - 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? { - 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? { - 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? { - 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? { - 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? { - 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? { - 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? { - val result by playlistsApi.getPlaylistItems( - playlistId = playlistId, - ) - - return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId") - } - - private fun BaseItemDtoQueryResult.extractItems(libraryId: String? = null): List? = - items?.map { item -> buildMediaMetadata(item, libraryId) }?.toList() - - private fun buildMediaMetadata(item: BaseItemDto, libraryId: String?): MediaMetadataCompat { - val builder = MediaMetadataCompat.Builder() - builder.setMediaId(buildMediaId(item, libraryId)) - builder.setTitle(item.name ?: context.getString(R.string.media_service_car_item_no_title)) - - val isAlbum = item.albumId != null - val 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.browsable(): List = map { metadata -> - MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE) - } - - private fun List.playable(): List = map { metadata -> - MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE) - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt deleted file mode 100644 index 99b72837..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt +++ /dev/null @@ -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" -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt b/app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt deleted file mode 100644 index 3be6d01f..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.jellyfin.mobile.player.cast - -import com.google.android.exoplayer2.Player - -interface ICastPlayerProvider { - val isCastSessionAvailable: Boolean - - fun get(): Player? -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt index 3de90ef1..79fe28ea 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt @@ -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 { diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt index d13c3a4e..ef4e8def 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt index a082bd65..01a3584f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index d6034da7..062abd06 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -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) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt index 0bec40cf..463e9b1a 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt @@ -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 } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt index 71dbf14f..f10b061f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt @@ -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() } } } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt index cc75b2f4..e873f94a 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt @@ -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() diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt index b99b7ac9..0798a11d 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt index 76aa9db2..4ac7edd3 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt @@ -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. diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt deleted file mode 100644 index 7e43eafb..00000000 --- a/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt +++ /dev/null @@ -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) diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml index 24206ac9..0b66e88c 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -42,23 +42,12 @@ app:layout_constraintTop_toTopOf="parent"> - - - - + android:foreground="@drawable/ripple_background" + app:pause_icon="@drawable/ic_pause_black_42dp" + app:play_icon="@drawable/ic_play_black_42dp" />