Split demo service into its own module for reuse
To support Automotive with the session demo, we need a separate app module. To do this we need to split the service into its own module and make it usable from different modules. PiperOrigin-RevId: 568975271
This commit is contained in:
parent
0b0d02c3e4
commit
2debf0187a
@ -65,9 +65,7 @@ dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
|
||||
implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
||||
implementation 'com.google.android.material:material:' + androidxMaterialVersion
|
||||
implementation project(modulePrefix + 'lib-exoplayer')
|
||||
implementation project(modulePrefix + 'lib-exoplayer-dash')
|
||||
implementation project(modulePrefix + 'lib-exoplayer-hls')
|
||||
implementation project(modulePrefix + 'lib-ui')
|
||||
implementation project(modulePrefix + 'lib-session')
|
||||
implementation project(modulePrefix + 'demo-session-service')
|
||||
}
|
||||
|
@ -1,236 +1,19 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* 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 androidx.media3.demo.session
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.*
|
||||
import android.app.TaskStackBuilder
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PendingIntent.getActivity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.*
|
||||
import androidx.media3.session.MediaSession.ConnectionResult
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
|
||||
class PlaybackService : MediaLibraryService() {
|
||||
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var customLayoutCommandButtons: List<CommandButton>
|
||||
class PlaybackService : DemoPlaybackService() {
|
||||
|
||||
companion object {
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
||||
"android.media3.session.demo.SHUFFLE_ON"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
||||
"android.media3.session.demo.SHUFFLE_OFF"
|
||||
private const val NOTIFICATION_ID = 123
|
||||
private const val CHANNEL_ID = "demo_session_notification_channel_id"
|
||||
private val immutableFlag = if (Build.VERSION.SDK_INT >= 23) FLAG_IMMUTABLE else 0
|
||||
private val immutableFlag = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSessionService.setListener
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
customLayoutCommandButtons =
|
||||
listOf(
|
||||
getShuffleCommandButton(
|
||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY)
|
||||
),
|
||||
getShuffleCommandButton(
|
||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY)
|
||||
)
|
||||
)
|
||||
initializeSessionAndPlayer()
|
||||
setListener(MediaSessionServiceListener())
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
// MediaSession.setSessionActivity
|
||||
// MediaSessionService.clearListener
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onDestroy() {
|
||||
mediaLibrarySession.setSessionActivity(getBackStackedActivity())
|
||||
mediaLibrarySession.release()
|
||||
player.release()
|
||||
clearListener()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
|
||||
|
||||
// ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS
|
||||
// ConnectionResult.AcceptedResultBuilder
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onConnect(session: MediaSession, controller: ControllerInfo): ConnectionResult {
|
||||
if (session.isMediaNotificationController(controller)) {
|
||||
// Set the required available session commands and the custom layout for the notification
|
||||
// on all API levels.
|
||||
val availableSessionCommands =
|
||||
ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
// Add the session commands of all command buttons.
|
||||
customLayoutCommandButtons.forEach { commandButton ->
|
||||
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||
}
|
||||
// Select the buttons to display.
|
||||
val customLayout =
|
||||
ImmutableList.of(customLayoutCommandButtons[if (player.shuffleModeEnabled) 1 else 0])
|
||||
return ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(availableSessionCommands.build())
|
||||
.setCustomLayout(customLayout)
|
||||
.build()
|
||||
}
|
||||
// Default commands without custom layout for common controllers.
|
||||
return ConnectionResult.AcceptedResultBuilder(session).build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSession.isMediaNotificationController
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
if (!session.isMediaNotificationController(controller)) {
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
|
||||
}
|
||||
if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON == customCommand.customAction) {
|
||||
// Enable shuffling.
|
||||
player.shuffleModeEnabled = true
|
||||
// Change the custom layout to contain the `Disable shuffling` command.
|
||||
session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[1]))
|
||||
} else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) {
|
||||
// Disable shuffling.
|
||||
player.shuffleModeEnabled = false
|
||||
// Change the custom layout to contain the `Enable shuffling` command.
|
||||
session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[0]))
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: ControllerInfo,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params))
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val item =
|
||||
MediaItemTree.getItem(mediaId)
|
||||
?: return Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(item, /* params= */ null))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val children =
|
||||
MediaItemTree.getChildren(parentId)
|
||||
?: return Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
|
||||
return Futures.immediateFuture(LibraryResult.ofItemList(children, params))
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: List<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
val updatedMediaItems: List<MediaItem> =
|
||||
mediaItems.map { mediaItem ->
|
||||
if (mediaItem.requestMetadata.searchQuery != null)
|
||||
getMediaItemFromSearchQuery(mediaItem.requestMetadata.searchQuery!!)
|
||||
else MediaItemTree.getItem(mediaItem.mediaId) ?: mediaItem
|
||||
}
|
||||
return Futures.immediateFuture(updatedMediaItems)
|
||||
}
|
||||
|
||||
private fun getMediaItemFromSearchQuery(query: String): MediaItem {
|
||||
// Only accept query with pattern "play [Title]" or "[Title]"
|
||||
// Where [Title]: must be exactly matched
|
||||
// If no media with exact name found, play a random media instead
|
||||
val mediaTitle =
|
||||
if (query.startsWith("play ", ignoreCase = true)) {
|
||||
query.drop(5)
|
||||
} else {
|
||||
query
|
||||
}
|
||||
|
||||
return MediaItemTree.getItemFromTitle(mediaTitle) ?: MediaItemTree.getRandomItem()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeSessionAndPlayer() {
|
||||
player =
|
||||
ExoPlayer.Builder(this)
|
||||
.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true)
|
||||
.build()
|
||||
MediaItemTree.initialize(assets)
|
||||
|
||||
// MediaLibrarySession.Builder.setCustomLayout
|
||||
// MediaLibrarySession.Builder.setBitmapLoader
|
||||
// CacheBitmapLoader
|
||||
// DataSourceBitmapLoader
|
||||
@OptIn(UnstableApi::class)
|
||||
mediaLibrarySession =
|
||||
MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setSessionActivity(getSingleTopActivity())
|
||||
.setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this)))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getSingleTopActivity(): PendingIntent {
|
||||
override fun getSingleTopActivity(): PendingIntent? {
|
||||
return getActivity(
|
||||
this,
|
||||
0,
|
||||
@ -239,73 +22,11 @@ class PlaybackService : MediaLibraryService() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun getBackStackedActivity(): PendingIntent {
|
||||
override fun getBackStackedActivity(): PendingIntent? {
|
||||
return TaskStackBuilder.create(this).run {
|
||||
addNextIntent(Intent(this@PlaybackService, MainActivity::class.java))
|
||||
addNextIntent(Intent(this@PlaybackService, PlayerActivity::class.java))
|
||||
getPendingIntent(0, immutableFlag or FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getShuffleCommandButton(sessionCommand: SessionCommand): CommandButton {
|
||||
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
|
||||
return CommandButton.Builder()
|
||||
.setDisplayName(
|
||||
getString(
|
||||
if (isOn) R.string.exo_controls_shuffle_on_description
|
||||
else R.string.exo_controls_shuffle_off_description
|
||||
)
|
||||
)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setIconResId(if (isOn) R.drawable.exo_icon_shuffle_off else R.drawable.exo_icon_shuffle_on)
|
||||
.build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSessionService.Listener
|
||||
private inner class MediaSessionServiceListener : Listener {
|
||||
|
||||
/**
|
||||
* This method is only required to be implemented on Android 12 or above when an attempt is made
|
||||
* by a media controller to resume playback when the {@link MediaSessionService} is in the
|
||||
* background.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // TODO: b/280766358 - Request this permission at runtime.
|
||||
override fun onForegroundServiceStartNotAllowedException() {
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(this@PlaybackService)
|
||||
ensureNotificationChannel(notificationManagerCompat)
|
||||
val pendingIntent =
|
||||
TaskStackBuilder.create(this@PlaybackService).run {
|
||||
addNextIntent(Intent(this@PlaybackService, MainActivity::class.java))
|
||||
getPendingIntent(0, immutableFlag or FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
val builder =
|
||||
NotificationCompat.Builder(this@PlaybackService, CHANNEL_ID)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSmallIcon(R.drawable.media3_notification_small_icon)
|
||||
.setContentTitle(getString(R.string.notification_content_title))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_content_text))
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
notificationManagerCompat.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel(notificationManagerCompat: NotificationManagerCompat) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT < 26 ||
|
||||
notificationManagerCompat.getNotificationChannel(CHANNEL_ID) != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
notificationManagerCompat.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
@ -24,9 +24,4 @@
|
||||
<string name="no_item_prompt">
|
||||
"! No media in the play list !\nPlease try to add more from browser"
|
||||
</string>
|
||||
<string name="notification_content_title">Playback cannot be resumed</string>
|
||||
<string name="notification_content_text">Press on the play button on the media notification if it
|
||||
is still present, otherwise please open the app to start the playback and re-connect the session
|
||||
to the controller</string>
|
||||
<string name="notification_channel_name">Playback cannot be resumed</string>
|
||||
</resources>
|
||||
|
8
demos/session_service/README.md
Normal file
8
demos/session_service/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Demo `MediaLibraryService` implementation
|
||||
|
||||
A library module with a demo implementation of `MediaLibraryService` and
|
||||
`MediaLibrarySession.Callback`.
|
||||
|
||||
See the `PlaybackService` of the [session demo](../session/README.md) how to use
|
||||
it. Override `assets/cataglog.json` by creating such a file in the same format
|
||||
in your application module that the service will use.
|
63
demos/session_service/build.gradle
Normal file
63
demos/session_service/build.gradle
Normal file
@ -0,0 +1,63 @@
|
||||
// Copyright 2023 The Android Open Source Project
|
||||
//
|
||||
// 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.
|
||||
apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle"
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
namespace 'androidx.media3.demo.session.service'
|
||||
|
||||
compileSdkVersion project.ext.compileSdkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionName project.ext.releaseVersion
|
||||
versionCode project.ext.releaseVersionCode
|
||||
minSdkVersion project.ext.minSdkVersion
|
||||
targetSdkVersion project.ext.appTargetSdkVersion
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
debug {
|
||||
jniDebuggable = true
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
// The demo service module isn't indexed, and doesn't have translations.
|
||||
disable 'GoogleAppIndexingWarning','MissingTranslation'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:' + androidxCoreVersion
|
||||
implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
|
||||
implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
||||
implementation project(modulePrefix + 'lib-exoplayer')
|
||||
implementation project(modulePrefix + 'lib-exoplayer-dash')
|
||||
implementation project(modulePrefix + 'lib-exoplayer-hls')
|
||||
implementation project(modulePrefix + 'lib-ui')
|
||||
implementation project(modulePrefix + 'lib-session')
|
||||
}
|
18
demos/session_service/src/main/AndroidManifest.xml
Normal file
18
demos/session_service/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright 2023 The Android Open Source Project
|
||||
|
||||
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.
|
||||
-->
|
||||
<manifest package="androidx.media3.demo.session.service">
|
||||
<uses-sdk />
|
||||
</manifest>
|
@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* 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 androidx.media3.demo.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.demo.session.service.R
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
|
||||
/** A [MediaLibraryService.MediaLibrarySession.Callback] implementation. */
|
||||
open class DemoMediaLibrarySessionCallback(private val context: Context) :
|
||||
MediaLibraryService.MediaLibrarySession.Callback {
|
||||
|
||||
init {
|
||||
MediaItemTree.initialize(context.assets)
|
||||
}
|
||||
|
||||
private val customLayoutCommandButtons: List<CommandButton> =
|
||||
listOf(
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
|
||||
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.exo_icon_shuffle_on)
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
|
||||
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.exo_icon_shuffle_off)
|
||||
.build()
|
||||
)
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS
|
||||
val mediaNotificationSessionCommands =
|
||||
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
.also { builder ->
|
||||
// Put all custom session commands in the list that may be used by the notification.
|
||||
customLayoutCommandButtons.forEach { commandButton ->
|
||||
commandButton.sessionCommand?.let { builder.add(it) }
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
// ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS
|
||||
// ConnectionResult.AcceptedResultBuilder
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
if (session.isMediaNotificationController(controller)) {
|
||||
// Select the button to display.
|
||||
val customLayout = customLayoutCommandButtons[if (session.player.shuffleModeEnabled) 1 else 0]
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(mediaNotificationSessionCommands)
|
||||
.setCustomLayout(ImmutableList.of(customLayout))
|
||||
.build()
|
||||
}
|
||||
// Default commands without custom layout for common controllers.
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSession.isMediaNotificationController
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
if (!session.isMediaNotificationController(controller)) {
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
|
||||
}
|
||||
if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON == customCommand.customAction) {
|
||||
// Enable shuffling.
|
||||
session.player.shuffleModeEnabled = true
|
||||
// Change the custom layout to contain the `Disable shuffling` command.
|
||||
session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[1]))
|
||||
} else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) {
|
||||
// Disable shuffling.
|
||||
session.player.shuffleModeEnabled = false
|
||||
// Change the custom layout to contain the `Enable shuffling` command.
|
||||
session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[0]))
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params))
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
MediaItemTree.getItem(mediaId)?.let {
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(it, /* params= */ null))
|
||||
}
|
||||
return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val children = MediaItemTree.getChildren(parentId)
|
||||
if (children.isNotEmpty()) {
|
||||
return Futures.immediateFuture(LibraryResult.ofItemList(children, params))
|
||||
}
|
||||
return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: List<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
val playlist = mutableListOf<MediaItem>()
|
||||
mediaItems.forEach { mediaItem ->
|
||||
when (mediaItem.requestMetadata.searchQuery) {
|
||||
null -> MediaItemTree.getItem(mediaItem.mediaId)?.let { playlist.add(it) }
|
||||
else -> playlist.addAll(MediaItemTree.search(mediaItem.requestMetadata.searchQuery!!))
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(playlist)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSession.MediaItemsWithStartPosition
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaItems: List<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
if (mediaItems.size == 1) {
|
||||
// Try to expand a single item to a playlist.
|
||||
val mediaId = mediaItems.first().mediaId
|
||||
val mediaItem = MediaItemTree.getItem(mediaId)
|
||||
val playlist = mutableListOf<MediaItem>()
|
||||
var indexInPlaylist = startIndex
|
||||
mediaItem?.apply {
|
||||
if (mediaMetadata.isBrowsable == true) {
|
||||
// Get children browsable item.
|
||||
playlist.addAll(MediaItemTree.getChildren(mediaId))
|
||||
} else if (requestMetadata.searchQuery == null) {
|
||||
// Try to get the parent and its children.
|
||||
MediaItemTree.getParentId(mediaId)?.let {
|
||||
playlist.addAll(MediaItemTree.getChildren(it))
|
||||
indexInPlaylist = MediaItemTree.getIndexInMediaItems(mediaId, playlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (playlist.isNotEmpty()) {
|
||||
// Return the expanded playlist to be set on the player of the session.
|
||||
return Futures.immediateFuture(
|
||||
MediaSession.MediaItemsWithStartPosition(playlist, indexInPlaylist, startPositionMs)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Let super serve the request if item isn't expanded.
|
||||
return super.onSetMediaItems(mediaSession, browser, mediaItems, startIndex, startPositionMs)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<Void>> {
|
||||
session.notifySearchResultChanged(browser, query, MediaItemTree.search(query).size, params)
|
||||
return Futures.immediateFuture(LibraryResult.ofVoid())
|
||||
}
|
||||
|
||||
override fun onGetSearchResult(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
return Futures.immediateFuture(LibraryResult.ofItemList(MediaItemTree.search(query), params))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
||||
"android.media3.session.demo.SHUFFLE_ON"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
||||
"android.media3.session.demo.SHUFFLE_OFF"
|
||||
}
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* 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 androidx.media3.demo.session
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.demo.session.service.R
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.util.EventLogger
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
|
||||
open class DemoPlaybackService : MediaLibraryService() {
|
||||
|
||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 123
|
||||
private const val CHANNEL_ID = "demo_session_notification_channel_id"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the single top session activity. It is used by the notification when the app task is
|
||||
* active and an activity is in the fore or background.
|
||||
*
|
||||
* Tapping the notification then typically should trigger a single top activity. This way, the
|
||||
* user navigates to the previous activity when pressing back.
|
||||
*
|
||||
* If null is returned, [MediaSession.setSessionActivity] is not set by the demo service.
|
||||
*/
|
||||
open fun getSingleTopActivity(): PendingIntent? = null
|
||||
|
||||
/**
|
||||
* Returns a back stacked session activity that is used by the notification when the service is
|
||||
* running standalone as a foreground service. This is typically the case after the app has been
|
||||
* dismissed from the recent tasks, or after automatic playback resumption.
|
||||
*
|
||||
* Typically, a playback activity should be started with a stack of activities underneath. This
|
||||
* way, when pressing back, the user doesn't land on the home screen of the device, but on an
|
||||
* activity defined in the back stack.
|
||||
*
|
||||
* See [androidx.core.app.TaskStackBuilder] to construct a back stack.
|
||||
*
|
||||
* If null is returned, [MediaSession.setSessionActivity] is not set by the demo service.
|
||||
*/
|
||||
open fun getBackStackedActivity(): PendingIntent? = null
|
||||
|
||||
/**
|
||||
* Creates the library session callback to implement the domain logic. Can be overridden to return
|
||||
* an alternative callback, for example a subclass of [DemoMediaLibrarySessionCallback].
|
||||
*
|
||||
* This method is called when the session is built by the [DemoPlaybackService].
|
||||
*/
|
||||
protected open fun createLibrarySessionCallback(): MediaLibrarySession.Callback {
|
||||
return DemoMediaLibrarySessionCallback(this)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSessionService.setListener
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
initializeSessionAndPlayer()
|
||||
setListener(MediaSessionServiceListener())
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaLibrarySession.player
|
||||
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
// MediaSession.setSessionActivity
|
||||
// MediaSessionService.clearListener
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onDestroy() {
|
||||
getBackStackedActivity()?.let { mediaLibrarySession.setSessionActivity(it) }
|
||||
mediaLibrarySession.release()
|
||||
mediaLibrarySession.player.release()
|
||||
clearListener()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun initializeSessionAndPlayer() {
|
||||
val player =
|
||||
ExoPlayer.Builder(this)
|
||||
.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true)
|
||||
.build()
|
||||
player.addAnalyticsListener(EventLogger())
|
||||
|
||||
// MediaLibrarySession.Builder.setCustomLayout
|
||||
// MediaLibrarySession.Builder.setBitmapLoader
|
||||
// CacheBitmapLoader
|
||||
// DataSourceBitmapLoader
|
||||
@OptIn(UnstableApi::class)
|
||||
mediaLibrarySession =
|
||||
MediaLibrarySession.Builder(this, player, createLibrarySessionCallback())
|
||||
.setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this)))
|
||||
.also { builder -> getSingleTopActivity()?.let { builder.setSessionActivity(it) } }
|
||||
.build()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class) // MediaSessionService.Listener
|
||||
private inner class MediaSessionServiceListener : Listener {
|
||||
|
||||
/**
|
||||
* This method is only required to be implemented on Android 12 or above when an attempt is made
|
||||
* by a media controller to resume playback when the {@link MediaSessionService} is in the
|
||||
* background.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // TODO: b/280766358 - Request this permission at runtime.
|
||||
override fun onForegroundServiceStartNotAllowedException() {
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(this@DemoPlaybackService)
|
||||
ensureNotificationChannel(notificationManagerCompat)
|
||||
val builder =
|
||||
NotificationCompat.Builder(this@DemoPlaybackService, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.media3_notification_small_icon)
|
||||
.setContentTitle(getString(R.string.notification_content_title))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_content_text))
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.also { builder -> getBackStackedActivity()?.let { builder.setContentIntent(it) } }
|
||||
notificationManagerCompat.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel(notificationManagerCompat: NotificationManagerCompat) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT < 26 ||
|
||||
notificationManagerCompat.getNotificationChannel(CHANNEL_ID) != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
notificationManagerCompat.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.google.common.collect.ImmutableList
|
||||
import java.io.BufferedReader
|
||||
import java.lang.StringBuilder
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
@ -47,6 +48,20 @@ object MediaItemTree {
|
||||
private const val ITEM_PREFIX = "[item]"
|
||||
|
||||
private class MediaItemNode(val item: MediaItem) {
|
||||
val searchTitle = normalizeSearchText(item.mediaMetadata.title)
|
||||
val searchText =
|
||||
StringBuilder()
|
||||
.append(searchTitle)
|
||||
.append(" ")
|
||||
.append(normalizeSearchText(item.mediaMetadata.subtitle))
|
||||
.append(" ")
|
||||
.append(normalizeSearchText(item.mediaMetadata.artist))
|
||||
.append(" ")
|
||||
.append(normalizeSearchText(item.mediaMetadata.albumArtist))
|
||||
.append(" ")
|
||||
.append(normalizeSearchText(item.mediaMetadata.albumTitle))
|
||||
.toString()
|
||||
|
||||
private val children: MutableList<MediaItem> = ArrayList()
|
||||
|
||||
fun addChild(childID: String) {
|
||||
@ -255,18 +270,77 @@ object MediaItemTree {
|
||||
return treeNodes[id]?.item
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the media ID of the parent of the given media ID, or null if the media ID wasn't found.
|
||||
*
|
||||
* @param mediaId The media ID of which to search the parent.
|
||||
* @Param parentId The media ID of the media item to start the search from, or undefined to search
|
||||
* from the top most node.
|
||||
*/
|
||||
fun getParentId(mediaId: String, parentId: String = ROOT_ID): String? {
|
||||
for (child in treeNodes[parentId]!!.getChildren()) {
|
||||
if (child.mediaId == mediaId) {
|
||||
return parentId
|
||||
} else if (child.mediaMetadata.isBrowsable == true) {
|
||||
val nextParentId = getParentId(mediaId, child.mediaId)
|
||||
if (nextParentId != null) {
|
||||
return nextParentId
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the [MediaItem] with the give media ID in the given list of items. If the
|
||||
* media ID wasn't found, 0 (zero) is returned.
|
||||
*/
|
||||
fun getIndexInMediaItems(mediaId: String, mediaItems: List<MediaItem>): Int {
|
||||
for ((index, child) in mediaItems.withIndex()) {
|
||||
if (child.mediaId == mediaId) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenizes the query into a list of words with at least two letters and searches in the search
|
||||
* text of the [MediaItemNode].
|
||||
*/
|
||||
fun search(query: String): List<MediaItem> {
|
||||
val matches: MutableList<MediaItem> = mutableListOf()
|
||||
val titleMatches: MutableList<MediaItem> = mutableListOf()
|
||||
val words = query.split(" ").map { it.trim().lowercase() }.filter { it.length > 1 }
|
||||
titleMap.keys.forEach { title ->
|
||||
val mediaItemNode = titleMap[title]!!
|
||||
for (word in words) {
|
||||
if (mediaItemNode.searchText.contains(word)) {
|
||||
if (mediaItemNode.searchTitle.contains(query.lowercase())) {
|
||||
titleMatches.add(mediaItemNode.item)
|
||||
} else {
|
||||
matches.add(mediaItemNode.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
titleMatches.addAll(matches)
|
||||
return titleMatches
|
||||
}
|
||||
|
||||
fun getRootItem(): MediaItem {
|
||||
return treeNodes[ROOT_ID]!!.item
|
||||
}
|
||||
|
||||
fun getChildren(id: String): List<MediaItem>? {
|
||||
return treeNodes[id]?.getChildren()
|
||||
fun getChildren(id: String): List<MediaItem> {
|
||||
return treeNodes[id]?.getChildren() ?: listOf()
|
||||
}
|
||||
|
||||
fun getRandomItem(): MediaItem {
|
||||
var curRoot = getRootItem()
|
||||
while (curRoot.mediaMetadata.isBrowsable == true) {
|
||||
val children = getChildren(curRoot.mediaId)!!
|
||||
val children = getChildren(curRoot.mediaId)
|
||||
curRoot = children.random()
|
||||
}
|
||||
return curRoot
|
||||
@ -275,4 +349,11 @@ object MediaItemTree {
|
||||
fun getItemFromTitle(title: String): MediaItem? {
|
||||
return titleMap[title]?.item
|
||||
}
|
||||
|
||||
private fun normalizeSearchText(text: CharSequence?): String {
|
||||
if (text.isNullOrEmpty() || text.trim().length == 1) {
|
||||
return ""
|
||||
}
|
||||
return "$text".trim().lowercase()
|
||||
}
|
||||
}
|
22
demos/session_service/src/main/res/values/strings.xml
Normal file
22
demos/session_service/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright 2023 The Android Open Source Project
|
||||
|
||||
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.
|
||||
-->
|
||||
<resources>
|
||||
<string name="notification_content_title">Playback cannot be resumed</string>
|
||||
<string name="notification_content_text">Press on the play button on the media notification if it
|
||||
is still present, otherwise please open the app to start the playback and re-connect the session
|
||||
to the controller</string>
|
||||
<string name="notification_channel_name">Playback cannot be resumed</string>
|
||||
</resources>
|
@ -29,6 +29,8 @@ include modulePrefix + 'demo-gl'
|
||||
project(modulePrefix + 'demo-gl').projectDir = new File(rootDir, 'demos/gl')
|
||||
include modulePrefix + 'demo-session'
|
||||
project(modulePrefix + 'demo-session').projectDir = new File(rootDir, 'demos/session')
|
||||
include modulePrefix + 'demo-session-service'
|
||||
project(modulePrefix + 'demo-session-service').projectDir = new File(rootDir, 'demos/session_service')
|
||||
include modulePrefix + 'demo-surface'
|
||||
project(modulePrefix + 'demo-surface').projectDir = new File(rootDir, 'demos/surface')
|
||||
include modulePrefix + 'demo-transformer'
|
||||
|
Loading…
x
Reference in New Issue
Block a user