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:
bachinger 2023-09-27 15:18:56 -07:00 committed by Copybara-Service
parent 0b0d02c3e4
commit 2debf0187a
12 changed files with 601 additions and 297 deletions

View File

@ -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')
}

View File

@ -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)
}
}

View File

@ -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>

View 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.

View 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')
}

View 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>

View File

@ -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"
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View 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>

View File

@ -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'