mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
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.appcompat:appcompat:' + androidxAppCompatVersion
|
||||||
implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
||||||
implementation 'com.google.android.material:material:' + androidxMaterialVersion
|
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-ui')
|
||||||
implementation project(modulePrefix + 'lib-session')
|
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
|
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.PendingIntent.*
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
import android.app.TaskStackBuilder
|
import android.app.PendingIntent.getActivity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import androidx.core.app.TaskStackBuilder
|
||||||
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
|
|
||||||
|
|
||||||
class PlaybackService : MediaLibraryService() {
|
class PlaybackService : DemoPlaybackService() {
|
||||||
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
|
|
||||||
|
|
||||||
private lateinit var player: ExoPlayer
|
|
||||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
|
||||||
private lateinit var customLayoutCommandButtons: List<CommandButton>
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
private val immutableFlag = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class) // MediaSessionService.setListener
|
override fun getSingleTopActivity(): PendingIntent? {
|
||||||
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 {
|
|
||||||
return getActivity(
|
return getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
@ -239,73 +22,11 @@ class PlaybackService : MediaLibraryService() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBackStackedActivity(): PendingIntent {
|
override fun getBackStackedActivity(): PendingIntent? {
|
||||||
return TaskStackBuilder.create(this).run {
|
return TaskStackBuilder.create(this).run {
|
||||||
addNextIntent(Intent(this@PlaybackService, MainActivity::class.java))
|
addNextIntent(Intent(this@PlaybackService, MainActivity::class.java))
|
||||||
addNextIntent(Intent(this@PlaybackService, PlayerActivity::class.java))
|
addNextIntent(Intent(this@PlaybackService, PlayerActivity::class.java))
|
||||||
getPendingIntent(0, immutableFlag or FLAG_UPDATE_CURRENT)
|
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">
|
<string name="no_item_prompt">
|
||||||
"! No media in the play list !\nPlease try to add more from browser"
|
"! No media in the play list !\nPlease try to add more from browser"
|
||||||
</string>
|
</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>
|
</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 androidx.media3.common.MediaMetadata
|
||||||
import com.google.common.collect.ImmutableList
|
import com.google.common.collect.ImmutableList
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
import java.lang.StringBuilder
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,6 +48,20 @@ object MediaItemTree {
|
|||||||
private const val ITEM_PREFIX = "[item]"
|
private const val ITEM_PREFIX = "[item]"
|
||||||
|
|
||||||
private class MediaItemNode(val item: MediaItem) {
|
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()
|
private val children: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
fun addChild(childID: String) {
|
fun addChild(childID: String) {
|
||||||
@ -255,18 +270,77 @@ object MediaItemTree {
|
|||||||
return treeNodes[id]?.item
|
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 {
|
fun getRootItem(): MediaItem {
|
||||||
return treeNodes[ROOT_ID]!!.item
|
return treeNodes[ROOT_ID]!!.item
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChildren(id: String): List<MediaItem>? {
|
fun getChildren(id: String): List<MediaItem> {
|
||||||
return treeNodes[id]?.getChildren()
|
return treeNodes[id]?.getChildren() ?: listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRandomItem(): MediaItem {
|
fun getRandomItem(): MediaItem {
|
||||||
var curRoot = getRootItem()
|
var curRoot = getRootItem()
|
||||||
while (curRoot.mediaMetadata.isBrowsable == true) {
|
while (curRoot.mediaMetadata.isBrowsable == true) {
|
||||||
val children = getChildren(curRoot.mediaId)!!
|
val children = getChildren(curRoot.mediaId)
|
||||||
curRoot = children.random()
|
curRoot = children.random()
|
||||||
}
|
}
|
||||||
return curRoot
|
return curRoot
|
||||||
@ -275,4 +349,11 @@ object MediaItemTree {
|
|||||||
fun getItemFromTitle(title: String): MediaItem? {
|
fun getItemFromTitle(title: String): MediaItem? {
|
||||||
return titleMap[title]?.item
|
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')
|
project(modulePrefix + 'demo-gl').projectDir = new File(rootDir, 'demos/gl')
|
||||||
include modulePrefix + 'demo-session'
|
include modulePrefix + 'demo-session'
|
||||||
project(modulePrefix + 'demo-session').projectDir = new File(rootDir, 'demos/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'
|
include modulePrefix + 'demo-surface'
|
||||||
project(modulePrefix + 'demo-surface').projectDir = new File(rootDir, 'demos/surface')
|
project(modulePrefix + 'demo-surface').projectDir = new File(rootDir, 'demos/surface')
|
||||||
include modulePrefix + 'demo-transformer'
|
include modulePrefix + 'demo-transformer'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user