mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-02 21:04:10 +03: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:
co-authored by
Maxr1998
parent
219b5487db
commit
0d36d61626
@@ -11,4 +11,8 @@
|
|||||||
<!-- Weblate doesn't use ellipsis characters -->
|
<!-- Weblate doesn't use ellipsis characters -->
|
||||||
<ignore regexp="app/src/main/res/values-.*" />
|
<ignore regexp="app/src/main/res/values-.*" />
|
||||||
</issue>
|
</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>
|
</lint>
|
||||||
|
|||||||
@@ -147,17 +147,12 @@ dependencies {
|
|||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.okio)
|
implementation(libs.okio)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
implementation(libs.cronet.embedded)
|
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
implementation(libs.androidx.mediarouter)
|
implementation(libs.androidx.mediarouter)
|
||||||
implementation(libs.bundles.exoplayer) {
|
implementation(libs.bundles.androidx.media3)
|
||||||
// Exclude Play Services cronet provider library
|
proprietaryImplementation(libs.androidx.media3.cast)
|
||||||
exclude("com.google.android.gms", "play-services-cronet")
|
|
||||||
}
|
|
||||||
implementation(libs.jellyfin.exoplayer.ffmpegextension)
|
|
||||||
proprietaryImplementation(libs.exoplayer.cast)
|
|
||||||
proprietaryImplementation(libs.bundles.playservices)
|
proprietaryImplementation(libs.bundles.playservices)
|
||||||
|
|
||||||
// Room
|
// 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:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback" />
|
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"
|
<service android:name="org.jellyfin.mobile.downloads.JellyfinDownloadService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync">
|
android:foregroundServiceType="dataSync">
|
||||||
|
|||||||
@@ -2,36 +2,32 @@ package org.jellyfin.mobile.app
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.net.toUri
|
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 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 kotlinx.coroutines.channels.Channel
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.chromium.net.CronetEngine
|
|
||||||
import org.chromium.net.CronetProvider
|
|
||||||
import org.jellyfin.mobile.MainViewModel
|
import org.jellyfin.mobile.MainViewModel
|
||||||
import org.jellyfin.mobile.bridge.NativePlayer
|
import org.jellyfin.mobile.bridge.NativePlayer
|
||||||
import org.jellyfin.mobile.downloads.DownloadsViewModel
|
import org.jellyfin.mobile.downloads.DownloadsViewModel
|
||||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
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.deviceprofile.DeviceProfileBuilder
|
||||||
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
||||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
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.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
|
const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
|
||||||
private const val HTTP_CACHE_SIZE: Long = 16 * 1024 * 1024
|
|
||||||
private const val TS_SEARCH_PACKETS = 1800
|
private const val TS_SEARCH_PACKETS = 1800
|
||||||
|
|
||||||
val applicationModule = module {
|
val applicationModule = module {
|
||||||
@@ -111,24 +105,8 @@ val applicationModule = module {
|
|||||||
val context: Context = get()
|
val context: Context = get()
|
||||||
val apiClient: ApiClient = get()
|
val apiClient: ApiClient = get()
|
||||||
|
|
||||||
val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
|
val baseDataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||||
(provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled
|
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
|
||||||
}
|
|
||||||
|
|
||||||
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 dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)
|
val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)
|
||||||
@@ -183,7 +161,4 @@ val applicationModule = module {
|
|||||||
single { ProgressiveMediaSource.Factory(get<CacheDataSource.Factory>()) }
|
single { ProgressiveMediaSource.Factory(get<CacheDataSource.Factory>()) }
|
||||||
single { HlsMediaSource.Factory(get<CacheDataSource.Factory>()) }
|
single { HlsMediaSource.Factory(get<CacheDataSource.Factory>()) }
|
||||||
single { SingleSampleMediaSource.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
|
package org.jellyfin.mobile.downloads
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.google.android.exoplayer2.database.DatabaseProvider
|
import androidx.media3.database.DatabaseProvider
|
||||||
import com.google.android.exoplayer2.offline.DownloadManager
|
import androidx.media3.datasource.DataSource
|
||||||
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
|
import androidx.media3.datasource.cache.Cache
|
||||||
import com.google.android.exoplayer2.upstream.DataSource
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
import com.google.android.exoplayer2.upstream.cache.Cache
|
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||||
import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package org.jellyfin.mobile.downloads
|
package org.jellyfin.mobile.downloads
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.google.android.exoplayer2.offline.Download
|
import androidx.media3.exoplayer.offline.Download
|
||||||
import com.google.android.exoplayer2.offline.DownloadIndex
|
import androidx.media3.exoplayer.offline.DownloadIndex
|
||||||
import com.google.android.exoplayer2.offline.DownloadManager
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
import com.google.common.base.Preconditions
|
import com.google.common.base.Preconditions
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import androidx.annotation.RequiresApi
|
|||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import androidx.core.net.toUri
|
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.ImageLoader
|
||||||
import coil.request.ImageRequest
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import android.app.Notification
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.google.android.exoplayer2.offline.Download
|
import androidx.media3.common.util.NotificationUtil
|
||||||
import com.google.android.exoplayer2.offline.DownloadManager
|
import androidx.media3.common.util.Util
|
||||||
import com.google.android.exoplayer2.offline.DownloadService
|
import androidx.media3.exoplayer.offline.Download
|
||||||
import com.google.android.exoplayer2.scheduler.PlatformScheduler
|
import androidx.media3.exoplayer.offline.DownloadManager
|
||||||
import com.google.android.exoplayer2.scheduler.Scheduler
|
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||||
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
|
import androidx.media3.exoplayer.offline.DownloadService
|
||||||
import com.google.android.exoplayer2.util.NotificationUtil
|
import androidx.media3.exoplayer.scheduler.PlatformScheduler
|
||||||
import com.google.android.exoplayer2.util.Util
|
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||||
import org.jellyfin.mobile.R
|
import org.jellyfin.mobile.R
|
||||||
import org.jellyfin.mobile.utils.Constants
|
import org.jellyfin.mobile.utils.Constants
|
||||||
import org.jellyfin.mobile.utils.extensions.toFileSize
|
import org.jellyfin.mobile.utils.extensions.toFileSize
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ import androidx.lifecycle.LiveData
|
|||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.google.android.exoplayer2.C
|
import androidx.media3.common.C
|
||||||
import com.google.android.exoplayer2.DefaultRenderersFactory
|
import androidx.media3.common.MimeTypes
|
||||||
import com.google.android.exoplayer2.ExoPlayer
|
import androidx.media3.common.PlaybackException
|
||||||
import com.google.android.exoplayer2.PlaybackException
|
import androidx.media3.common.Player
|
||||||
import com.google.android.exoplayer2.Player
|
import androidx.media3.common.util.Clock
|
||||||
import com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import com.google.android.exoplayer2.mediacodec.MediaCodecInfo
|
import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector
|
||||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecDecoderException
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
import com.google.android.exoplayer2.util.Clock
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
import com.google.android.exoplayer2.util.EventLogger
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import com.google.android.exoplayer2.util.MimeTypes
|
import androidx.media3.exoplayer.util.EventLogger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.jellyfin.mobile.player
|
package org.jellyfin.mobile.player
|
||||||
|
|
||||||
import com.google.android.exoplayer2.C
|
import androidx.media3.common.C
|
||||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
|
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
|
||||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||||
import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType
|
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.MediaCodecInfo.CodecProfileLevel
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import com.google.android.exoplayer2.util.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
|
|
||||||
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
|
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
|
||||||
object CodecHelpers {
|
object CodecHelpers {
|
||||||
|
|||||||
+1
-1
@@ -13,9 +13,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.Player
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.google.android.exoplayer2.Player
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import androidx.annotation.CheckResult
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||||
import com.google.android.exoplayer2.source.MergingMediaSource
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||||
import com.google.android.exoplayer2.source.SingleSampleMediaSource
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||||||
import org.jellyfin.mobile.data.dao.DownloadDao
|
import org.jellyfin.mobile.data.dao.DownloadDao
|
||||||
import org.jellyfin.mobile.player.PlayerException
|
import org.jellyfin.mobile.player.PlayerException
|
||||||
import org.jellyfin.mobile.player.PlayerViewModel
|
import org.jellyfin.mobile.player.PlayerViewModel
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import androidx.core.view.updatePadding
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.exoplayer2.Player
|
import androidx.media3.common.Player
|
||||||
import com.google.android.exoplayer2.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.mobile.R
|
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.mobile.utils.toast
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.koin.android.ext.android.inject
|
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 {
|
class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||||
private val appPreferences: AppPreferences by inject()
|
private val appPreferences: AppPreferences by inject()
|
||||||
@@ -199,6 +199,9 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
|||||||
// Set controller timeout
|
// Set controller timeout
|
||||||
suppressControllerAutoHide(false)
|
suppressControllerAutoHide(false)
|
||||||
|
|
||||||
|
// Disable controller animations
|
||||||
|
playerView.setControllerAnimationEnabled(false)
|
||||||
|
|
||||||
playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
|
playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
|
||||||
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
|
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
|
||||||
|
|
||||||
@@ -358,7 +361,7 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAspectRatio(aspectRational)
|
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 contentRect = with(contentFrame) {
|
||||||
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
|
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
|
||||||
Rect(x, y, x + width, y + height)
|
Rect(x, y, x + width, y + height)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import android.widget.ProgressBar
|
|||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.postDelayed
|
import androidx.core.view.postDelayed
|
||||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import com.google.android.exoplayer2.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import org.jellyfin.mobile.R
|
import org.jellyfin.mobile.R
|
||||||
import org.jellyfin.mobile.app.AppPreferences
|
import org.jellyfin.mobile.app.AppPreferences
|
||||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||||
@@ -123,7 +123,7 @@ class PlayerGestureHelper(
|
|||||||
|
|
||||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||||
playerView.apply {
|
playerView.apply {
|
||||||
if (!isControllerVisible) showController() else hideController()
|
if (!isControllerFullyVisible) showController() else hideController()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.view.OrientationEventListener
|
import android.view.OrientationEventListener
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import androidx.core.view.isVisible
|
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.databinding.FragmentPlayerBinding
|
||||||
import org.jellyfin.mobile.utils.AndroidVersion
|
import org.jellyfin.mobile.utils.AndroidVersion
|
||||||
import org.jellyfin.mobile.utils.Constants
|
import org.jellyfin.mobile.utils.Constants
|
||||||
@@ -50,7 +50,7 @@ class PlayerLockScreenHelper(
|
|||||||
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
|
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
|
||||||
playerView.useController = true
|
playerView.useController = true
|
||||||
playerView.apply {
|
playerView.apply {
|
||||||
if (!isControllerVisible) showController()
|
if (!isControllerFullyVisible) showController()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import android.media.AudioManager
|
|||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.session.MediaSession
|
import android.media.session.MediaSession
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import com.google.android.exoplayer2.C
|
import androidx.media3.common.C
|
||||||
import com.google.android.exoplayer2.ExoPlayer
|
import androidx.media3.common.Player
|
||||||
import com.google.android.exoplayer2.Player
|
import androidx.media3.exoplayer.analytics.AnalyticsCollector
|
||||||
import com.google.android.exoplayer2.analytics.AnalyticsCollector
|
|
||||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||||
import org.jellyfin.mobile.ui.content.ImageProvider
|
import org.jellyfin.mobile.ui.content.ImageProvider
|
||||||
import org.jellyfin.mobile.utils.extensions.width
|
import org.jellyfin.mobile.utils.extensions.width
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
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) {
|
inline fun MediaSession.applyDefaultLocalAudioAttributes(contentType: Int) {
|
||||||
val audioAttributes = AudioAttributes.Builder().apply {
|
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) {
|
inline fun Player.applyDefaultAudioAttributes(@C.AudioContentType contentType: Int) {
|
||||||
val audioAttributes = ExoPlayerAudioAttributes.Builder()
|
val audioAttributes = Media3AudioAttributes.Builder()
|
||||||
.setUsage(C.USAGE_MEDIA)
|
.setUsage(C.USAGE_MEDIA)
|
||||||
.setContentType(contentType)
|
.setContentType(contentType)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import android.provider.Settings
|
|||||||
import android.provider.Settings.System.ACCELEROMETER_ROTATION
|
import android.provider.Settings.System.ACCELEROMETER_ROTATION
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
import androidx.core.content.getSystemService
|
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 com.google.android.material.snackbar.Snackbar
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.jellyfin.mobile.BuildConfig
|
import org.jellyfin.mobile.BuildConfig
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package org.jellyfin.mobile.utils
|
package org.jellyfin.mobile.utils
|
||||||
|
|
||||||
import com.google.android.exoplayer2.C
|
import androidx.media3.common.TrackGroup
|
||||||
import com.google.android.exoplayer2.source.TrackGroup
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select the [trackGroup] of the specified [type] and ensure the type is enabled.
|
* 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">
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
<androidx.appcompat.widget.AppCompatImageButton
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
android:id="@+id/exo_play"
|
android:id="@+id/exo_play_pause"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/ripple_background_circular"
|
android:background="@drawable/ripple_background_circular"
|
||||||
android:padding="@dimen/exo_center_icon_padding"
|
android:padding="@dimen/exo_center_icon_padding"
|
||||||
android:src="@drawable/ic_play_black_42dp"
|
|
||||||
android:tint="?android:textColorPrimary" />
|
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>
|
</FrameLayout>
|
||||||
|
|
||||||
<androidx.appcompat.widget.AppCompatImageButton
|
<androidx.appcompat.widget.AppCompatImageButton
|
||||||
@@ -85,7 +74,7 @@
|
|||||||
app:layout_constraintTop_toTopOf="@id/exo_progress"
|
app:layout_constraintTop_toTopOf="@id/exo_progress"
|
||||||
tools:text="33:01" />
|
tools:text="33:01" />
|
||||||
|
|
||||||
<com.google.android.exoplayer2.ui.DefaultTimeBar
|
<androidx.media3.ui.DefaultTimeBar
|
||||||
android:id="@+id/exo_progress"
|
android:id="@+id/exo_progress"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<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"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@android:color/black">
|
android:background="@android:color/black">
|
||||||
|
|
||||||
<com.google.android.exoplayer2.ui.PlayerView
|
<androidx.media3.ui.PlayerView
|
||||||
android:id="@+id/player_view"
|
android:id="@+id/player_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="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
|
<FrameLayout
|
||||||
android:id="@+id/player_overlay"
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -11,7 +11,7 @@ allprojects {
|
|||||||
content {
|
content {
|
||||||
includeVersionByRegex(JellyfinSdk.GROUP, ".*", JellyfinSdk.SNAPSHOT)
|
includeVersionByRegex(JellyfinSdk.GROUP, ".*", JellyfinSdk.SNAPSHOT)
|
||||||
includeVersionByRegex(JellyfinSdk.GROUP, ".*", JellyfinSdk.SNAPSHOT_UNSTABLE)
|
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"
|
const val SNAPSHOT_UNSTABLE = "openapi-unstable-SNAPSHOT"
|
||||||
}
|
}
|
||||||
|
|
||||||
object JellyfinExoPlayer {
|
object JellyfinMedia3 {
|
||||||
const val GROUP = "org.jellyfin.exoplayer"
|
const val GROUP = "org.jellyfin.media3"
|
||||||
const val SNAPSHOT = "SNAPSHOT"
|
const val SNAPSHOT = "SNAPSHOT"
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-17
@@ -40,13 +40,12 @@ jellyfin-sdk = "1.6.8"
|
|||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
okio = "3.11.0"
|
okio = "3.11.0"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
cronet-embedded = "119.6045.31"
|
|
||||||
|
|
||||||
# Media
|
# Media
|
||||||
androidx-media = "1.7.0"
|
androidx-media = "1.7.0"
|
||||||
androidx-mediarouter = "1.7.0"
|
androidx-mediarouter = "1.7.0"
|
||||||
exoplayer = "2.19.1"
|
androidx-media3 = "1.6.1"
|
||||||
jellyfin-exoplayer-ffmpegextension = "2.19.1+1"
|
jellyfin-androidx-media = "1.6.1+1"
|
||||||
playservices = "22.1.0"
|
playservices = "22.1.0"
|
||||||
|
|
||||||
# Room
|
# 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" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
|
okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
|
||||||
coil = { group = "io.coil-kt", name = "coil-base", version.ref = "coil" }
|
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
|
# Media
|
||||||
androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" }
|
androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" }
|
||||||
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" }
|
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" }
|
||||||
exoplayer-core = { group = "com.google.android.exoplayer", name = "exoplayer-core", version.ref = "exoplayer" }
|
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" }
|
||||||
exoplayer-ui = { group = "com.google.android.exoplayer", name = "exoplayer-ui", version.ref = "exoplayer" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||||
exoplayer-mediaSession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" }
|
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
|
||||||
exoplayer-hls = { group = "com.google.android.exoplayer", name = "exoplayer-hls", version.ref = "exoplayer" }
|
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
|
||||||
exoplayer-cast = { group = "com.google.android.exoplayer", name = "extension-cast", version.ref = "exoplayer" }
|
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
|
||||||
exoplayer-cronet = { group = "com.google.android.exoplayer", name = "extension-cronet", version.ref = "exoplayer" }
|
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-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" }
|
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
|
# Room
|
||||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-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",
|
||||||
"compose-material-icons-extended",
|
"compose-material-icons-extended",
|
||||||
]
|
]
|
||||||
exoplayer = [
|
androidx-media3 = [
|
||||||
"exoplayer-core",
|
"androidx-media3-common",
|
||||||
"exoplayer-ui",
|
"androidx-media3-ui",
|
||||||
"exoplayer-mediaSession",
|
"androidx-media3-session",
|
||||||
"exoplayer-hls",
|
"androidx-media3-exoplayer",
|
||||||
"exoplayer-cronet",
|
"androidx-media3-exoplayer-hls",
|
||||||
|
"androidx-media3-datasource-okhttp",
|
||||||
|
"jellyfin-androidx-media3-ffmpeg-decoder",
|
||||||
]
|
]
|
||||||
playservices = [
|
playservices = [
|
||||||
"playservices-cast",
|
"playservices-cast",
|
||||||
|
|||||||
Reference in New Issue
Block a user