diff --git a/demos/session/build.gradle b/demos/session/build.gradle index fcd3c99d8f..c279b96f30 100644 --- a/demos/session/build.gradle +++ b/demos/session/build.gradle @@ -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') } diff --git a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt index c81bac520b..48228df130 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt @@ -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 +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 { - 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> { - return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params)) - } - - override fun onGetItem( - session: MediaLibrarySession, - browser: ControllerInfo, - mediaId: String - ): ListenableFuture> { - 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>> { - 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 - ): ListenableFuture> { - val updatedMediaItems: List = - 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) - } } diff --git a/demos/session/src/main/res/values/strings.xml b/demos/session/src/main/res/values/strings.xml index 0add882c72..727772e190 100644 --- a/demos/session/src/main/res/values/strings.xml +++ b/demos/session/src/main/res/values/strings.xml @@ -24,9 +24,4 @@ "! No media in the play list !\nPlease try to add more from browser" - Playback cannot be resumed - 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 - Playback cannot be resumed diff --git a/demos/session_service/README.md b/demos/session_service/README.md new file mode 100644 index 0000000000..82178b7cca --- /dev/null +++ b/demos/session_service/README.md @@ -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. diff --git a/demos/session_service/build.gradle b/demos/session_service/build.gradle new file mode 100644 index 0000000000..121eb4396e --- /dev/null +++ b/demos/session_service/build.gradle @@ -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') +} diff --git a/demos/session_service/src/main/AndroidManifest.xml b/demos/session_service/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ec9063f36d --- /dev/null +++ b/demos/session_service/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/demos/session/src/main/assets/catalog.json b/demos/session_service/src/main/assets/catalog.json similarity index 100% rename from demos/session/src/main/assets/catalog.json rename to demos/session_service/src/main/assets/catalog.json diff --git a/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt new file mode 100644 index 0000000000..f0c74905d0 --- /dev/null +++ b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt @@ -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 = + 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 { + 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> { + return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params)) + } + + override fun onGetItem( + session: MediaLibraryService.MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + 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>> { + 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 + ): ListenableFuture> { + val playlist = mutableListOf() + 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, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture { + 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() + 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> { + 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>> { + 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" + } +} diff --git a/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt new file mode 100644 index 0000000000..453d5b28dc --- /dev/null +++ b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt @@ -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) + } +} diff --git a/demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt b/demos/session_service/src/main/java/androidx/media3/demo/session/MediaItemTree.kt similarity index 75% rename from demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt rename to demos/session_service/src/main/java/androidx/media3/demo/session/MediaItemTree.kt index 690cbc11f0..e68fca8ccf 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/MediaItemTree.kt +++ b/demos/session_service/src/main/java/androidx/media3/demo/session/MediaItemTree.kt @@ -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 = 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): 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 { + val matches: MutableList = mutableListOf() + val titleMatches: MutableList = 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? { - return treeNodes[id]?.getChildren() + fun getChildren(id: String): List { + 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() + } } diff --git a/demos/session_service/src/main/res/values/strings.xml b/demos/session_service/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1412b5b2ba --- /dev/null +++ b/demos/session_service/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Playback cannot be resumed + 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 + Playback cannot be resumed + diff --git a/settings.gradle b/settings.gradle index f1b3347913..f0311aee5f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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'