mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2025-12-23 23:37:53 -05:00
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.jellyfin.mobile.player.cast
|
||||
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
interface ICastPlayerProvider {
|
||||
val isCastSessionAvailable: Boolean
|
||||
|
||||
fun get(): Player?
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user