diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 790996e9eb..05474847c5 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -24,6 +24,8 @@
* Add `SessionError` and use it in `SessionResult` and `LibraryResult`
instead of the error code to provide more information about the error
and how to resolve the error if possible.
+ * Publish the code for the media3 controller test app that can be used to
+ test interactions with apps publishing a media session.
* UI:
* Add customisation of various icons in `PlayerControlView` through xml
attributes to allow different drawables per `PlayerView` instance,
diff --git a/settings.gradle b/settings.gradle
index 206115c3a2..90a90c85cf 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -56,4 +56,8 @@ project(modulePrefix + 'test-session-common').projectDir = new File(rootDir, 'li
include modulePrefix + 'test-session-current'
project(modulePrefix + 'test-session-current').projectDir = new File(rootDir, 'libraries/test_session_current')
+// MediaController test app.
+include modulePrefix + 'testapp-controller'
+project(modulePrefix + 'testapp-controller').projectDir = new File(rootDir, 'testapps/controller')
+
apply from: 'core_settings.gradle'
diff --git a/testapps/README.md b/testapps/README.md
new file mode 100644
index 0000000000..fa1246b039
--- /dev/null
+++ b/testapps/README.md
@@ -0,0 +1,5 @@
+# Android media test apps
+
+This directory contains applications that can be used to test an application's
+integration with Android media APIs. Browse the individual test applications
+and their READMEs to learn more.
diff --git a/testapps/controller/README.md b/testapps/controller/README.md
new file mode 100644
index 0000000000..82511677a9
--- /dev/null
+++ b/testapps/controller/README.md
@@ -0,0 +1,4 @@
+# Media3 controller test app
+
+This is the media3 controller test application. It allows media apps to verify
+their media session implementation.
diff --git a/testapps/controller/build.gradle b/testapps/controller/build.gradle
new file mode 100644
index 0000000000..c43453f7ed
--- /dev/null
+++ b/testapps/controller/build.gradle
@@ -0,0 +1,70 @@
+// 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.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+android {
+ namespace 'androidx.media3.testapp.controller'
+
+ compileSdk 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
+ vectorDrawables.useSupportLibrary = true
+ }
+
+ buildTypes {
+ release {
+ shrinkResources true
+ minifyEnabled true
+ proguardFiles = [
+ 'proguard-rules.txt',
+ getDefaultProguardFile('proguard-android-optimize.txt')
+ ]
+ signingConfig signingConfigs.debug
+ }
+ debug {
+ jniDebuggable = true
+ }
+ }
+
+ lintOptions {
+ // The test app 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 'com.google.android.material:material:' + androidxMaterialVersion
+ implementation 'androidx.media:media:' + androidxMediaVersion
+ implementation project(modulePrefix + 'lib-session')
+ implementation project(modulePrefix + 'lib-datasource')
+}
diff --git a/testapps/controller/lint.xml b/testapps/controller/lint.xml
new file mode 100644
index 0000000000..fea8839e9b
--- /dev/null
+++ b/testapps/controller/lint.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/testapps/controller/proguard-rules.txt b/testapps/controller/proguard-rules.txt
new file mode 100644
index 0000000000..13815a35d0
--- /dev/null
+++ b/testapps/controller/proguard-rules.txt
@@ -0,0 +1,2 @@
+# Proguard rules specific to the media3 controller test app.
+
diff --git a/testapps/controller/src/main/AndroidManifest.xml b/testapps/controller/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..149b393431
--- /dev/null
+++ b/testapps/controller/src/main/AndroidManifest.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/AudioFocusHelper.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/AudioFocusHelper.kt
new file mode 100644
index 0000000000..f3932243c2
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/AudioFocusHelper.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.media.AudioManager
+import android.view.View
+import android.widget.AdapterView
+import android.widget.Spinner
+import android.widget.ToggleButton
+import androidx.appcompat.app.AppCompatActivity
+import androidx.media.AudioFocusRequestCompat
+import androidx.media.AudioManagerCompat
+
+/** Helper class to manage audio focus requests and the UI surrounding this feature. */
+class AudioFocusHelper(activity: Activity) :
+ View.OnClickListener,
+ AudioManager.OnAudioFocusChangeListener,
+ AdapterView.OnItemSelectedListener {
+ private val audioManager: AudioManager =
+ activity.getSystemService(AppCompatActivity.AUDIO_SERVICE) as AudioManager
+ private val toggleButton: ToggleButton = activity.findViewById(R.id.audio_focus_button)
+ private val focusTypeSpinner: Spinner = activity.findViewById(R.id.audio_focus_type)
+
+ private val selectedFocusType: Int
+ get() = FOCUS_TYPES[focusTypeSpinner.selectedItemPosition]
+
+ companion object {
+ private val FOCUS_TYPES =
+ intArrayOf(
+ AudioManager.AUDIOFOCUS_GAIN,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
+ )
+ }
+
+ init {
+ toggleButton.setOnClickListener(this)
+ this.focusTypeSpinner.onItemSelectedListener = this
+ }
+
+ override fun onClick(v: View) =
+ if (toggleButton.isChecked) {
+ gainAudioFocus()
+ } else {
+ abandonAudioFocus()
+ }
+
+ override fun onAudioFocusChange(focusChange: Int) =
+ when (focusChange) {
+ AudioManager.AUDIOFOCUS_GAIN,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> toggleButton.isChecked = true
+ else -> toggleButton.isChecked = false
+ }
+
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ // If we're holding audio focus and the type should change, automatically
+ // request the new type of focus.
+ if (toggleButton.isChecked) {
+ gainAudioFocus()
+ }
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {
+ // Nothing to do.
+ }
+
+ private fun gainAudioFocus() {
+ val audioFocusRequest: AudioFocusRequestCompat =
+ AudioFocusRequestCompat.Builder(selectedFocusType).setOnAudioFocusChangeListener(this).build()
+ AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest)
+ }
+
+ private fun abandonAudioFocus() {
+ val audioFocusRequest: AudioFocusRequestCompat =
+ AudioFocusRequestCompat.Builder(selectedFocusType).setOnAudioFocusChangeListener(this).build()
+ AudioManagerCompat.abandonAudioFocusRequest(audioManager, audioFocusRequest)
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/BitmapUtils.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/BitmapUtils.kt
new file mode 100644
index 0000000000..e76605d2ac
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/BitmapUtils.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.testapp.controller
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+
+/** Utilities for [Bitmap]s. */
+object BitmapUtils {
+ /**
+ * Converts a [Drawable] to an appropriately sized [Bitmap].
+ *
+ * @param resources Resources for the current [android.content.Context].
+ * @param drawable The [Drawable] to convert to a Bitmap.
+ * @param downScale Will downscale the Bitmap to `R.dimen.app_icon_size` dp.
+ * @return A Bitmap, no larger than `R.dimen.app_icon_size` dp if desired.
+ */
+ fun convertDrawable(resources: Resources, drawable: Drawable, downScale: Boolean): Bitmap {
+ val bitmap: Bitmap
+ if (drawable is BitmapDrawable) {
+ bitmap = drawable.bitmap
+ } else {
+ bitmap =
+ Bitmap.createBitmap(
+ drawable.intrinsicWidth,
+ drawable.intrinsicHeight,
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ }
+ if (!downScale) {
+ return bitmap
+ }
+ val iconSize: Int = resources.getDimensionPixelSize(R.dimen.app_icon_size)
+ return if (bitmap.height > iconSize || bitmap.width > iconSize) {
+ // Which needs to be scaled to fit.
+ val height: Int = bitmap.height
+ val width: Int = bitmap.width
+ val scaleHeight: Int
+ val scaleWidth: Int
+
+ // Calculate the new size based on which dimension is larger.
+ if (height > width) {
+ scaleHeight = iconSize
+ scaleWidth = (width * iconSize.toFloat() / height).toInt()
+ } else {
+ scaleWidth = iconSize
+ scaleHeight = (height * iconSize.toFloat() / width).toInt()
+ }
+ Bitmap.createScaledBitmap(bitmap, scaleWidth, scaleHeight, false)
+ } else {
+ bitmap
+ }
+ }
+
+ /**
+ * Creates a Material Design compliant [androidx.appcompat.widget.Toolbar] icon from a given full
+ * sized icon.
+ *
+ * @param resources Resources for the current [android.content.Context].
+ * @param icon The bitmap to convert.
+ * @return A scaled Bitmap of the appropriate size and in-built padding.
+ */
+ fun createToolbarIcon(resources: Resources, icon: Bitmap): Bitmap {
+ val padding: Int = resources.getDimensionPixelSize(R.dimen.margin_small)
+ val iconSize: Int = resources.getDimensionPixelSize(R.dimen.toolbar_icon_size)
+ val sizeWithPadding = iconSize + 2 * padding
+
+ // Create a Bitmap backed Canvas to be the toolbar icon.
+ val toolbarIcon: Bitmap =
+ Bitmap.createBitmap(sizeWithPadding, sizeWithPadding, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(toolbarIcon)
+ canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
+
+ // Resize the app icon to Material Design size.
+ val scaledIcon: Bitmap = Bitmap.createScaledBitmap(icon, iconSize, iconSize, false)
+ canvas.drawBitmap(scaledIcon, padding.toFloat(), padding.toFloat(), null)
+ return toolbarIcon
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/BrowseMediaItemsAdapter.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/BrowseMediaItemsAdapter.kt
new file mode 100644
index 0000000000..496f8b7dec
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/BrowseMediaItemsAdapter.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaBrowser
+import androidx.media3.session.SessionCommand
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.Stack
+
+/** Helper class that enables navigation on tree in MediaBrowser. */
+class BrowseMediaItemsAdapter(
+ private val activity: Activity,
+ private val mediaBrowser: MediaBrowser
+) : RecyclerView.Adapter() {
+ private var items: List = emptyList()
+ // Stack that holds ancestors of current item.
+ private val nodes = Stack()
+
+ init {
+ val browseTreeList: RecyclerView = activity.findViewById(R.id.media_items_list)
+ browseTreeList.layoutManager = LinearLayoutManager(activity)
+ browseTreeList.setHasFixedSize(true)
+ browseTreeList.adapter = this
+
+ val topButtonView: View = activity.findViewById(R.id.media_browse_tree_top)
+ topButtonView.setOnClickListener {
+ if (!supportsSubscribe() || !supportsUnsubscribe()) {
+ Toast.makeText(activity, R.string.command_not_supported_msg, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (nodes.size > 1) {
+ unsubscribe()
+ while (nodes.size > 1) nodes.pop()
+ subscribe()
+ }
+ }
+
+ val upButtonView: View = activity.findViewById(R.id.media_browse_tree_up)
+ upButtonView.setOnClickListener {
+ if (!supportsSubscribe() || !supportsUnsubscribe()) {
+ Toast.makeText(activity, R.string.command_not_supported_msg, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ if (nodes.size > 1) {
+ unsubscribe()
+ nodes.pop()
+ subscribe()
+ }
+ }
+
+ if (
+ mediaBrowser.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT)
+ ) {
+ val libraryResult: ListenableFuture> =
+ mediaBrowser.getLibraryRoot(null)
+ libraryResult.addListener(
+ {
+ val result: LibraryResult = libraryResult.get()
+ result.value?.let { setRoot(it.mediaId) }
+ },
+ ContextCompat.getMainExecutor(activity)
+ )
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
+ ViewHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.media_browse_item, parent, false)
+ )
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ if (items.isEmpty()) {
+ if (!supportsSubscribe() || !supportsUnsubscribe()) {
+ setMessageForEmptyList(holder, activity.getString(R.string.command_not_supported_msg))
+ } else {
+ setMessageForEmptyList(holder, activity.getString(R.string.media_browse_tree_empty))
+ }
+ return
+ }
+
+ val mediaMetadata: MediaMetadata = items[position].mediaMetadata
+ holder.name.text = mediaMetadata.title ?: "Title metadata empty"
+ holder.subtitle.text = mediaMetadata.subtitle ?: "Subtitle metadata empty"
+ holder.subtitle.visibility = View.VISIBLE
+ holder.icon.visibility = View.VISIBLE
+
+ when {
+ mediaMetadata.artworkUri != null -> {
+ holder.icon.setImageURI(mediaMetadata.artworkUri)
+ }
+ mediaMetadata.artworkData != null -> {
+ val bitmap: Bitmap =
+ BitmapFactory.decodeByteArray(
+ mediaMetadata.artworkData,
+ 0,
+ mediaMetadata.artworkData!!.size
+ )
+ holder.icon.setImageBitmap(bitmap)
+ }
+ else -> {
+ holder.icon.setImageResource(R.drawable.ic_album_black_24dp)
+ }
+ }
+
+ val item: MediaItem = items[position]
+ holder.itemView.setOnClickListener {
+ if (mediaMetadata.isBrowsable == true) {
+ unsubscribe()
+ nodes.push(item.mediaId)
+ subscribe()
+ }
+ if (mediaMetadata.isPlayable == true) {
+ mediaBrowser.setMediaItem(MediaItem.Builder().setMediaId(item.mediaId).build())
+ mediaBrowser.prepare()
+ mediaBrowser.play()
+ }
+ }
+ }
+
+ override fun getItemCount(): Int {
+ // Leave one item for message if nodes or items are empty.
+ if (nodes.size == 0 || items.isEmpty()) return 1
+ return items.size
+ }
+
+ private fun supportsSubscribe(): Boolean =
+ mediaBrowser.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_SUBSCRIBE)
+
+ private fun supportsUnsubscribe(): Boolean =
+ mediaBrowser.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_UNSUBSCRIBE)
+
+ private fun setMessageForEmptyList(holder: ViewHolder, message: String) {
+ holder.name.text = message
+ holder.subtitle.visibility = View.GONE
+ holder.icon.visibility = View.GONE
+ holder.itemView.setOnClickListener {}
+ }
+
+ fun updateItems(newItems: List) {
+ items = newItems
+ notifyDataSetChanged()
+ }
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ private fun subscribe() {
+ if (nodes.isNotEmpty() && supportsSubscribe()) {
+ mediaBrowser.subscribe(nodes.peek(), null)
+ }
+ }
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ private fun unsubscribe() {
+ if (nodes.isNotEmpty() && supportsUnsubscribe()) {
+ mediaBrowser.unsubscribe(nodes.peek())
+ }
+ updateItems(emptyList())
+ }
+
+ private fun setRoot(root: String) {
+ unsubscribe()
+ nodes.clear()
+ nodes.push(root)
+ subscribe()
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val name: TextView = itemView.findViewById(R.id.item_name)
+ val subtitle: TextView = itemView.findViewById(R.id.item_subtitle)
+ val icon: ImageView = itemView.findViewById(R.id.item_icon)
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/CustomCommandsAdapter.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/CustomCommandsAdapter.kt
new file mode 100644
index 0000000000..abf2a72ba8
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/CustomCommandsAdapter.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.content.res.Resources
+import android.graphics.drawable.Drawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.content.res.ResourcesCompat
+import androidx.media3.session.CommandButton
+import androidx.media3.session.MediaController
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+
+/** Helper class that displays and handles custom commands. */
+class CustomCommandsAdapter(
+ activity: Activity,
+ private val mediaController: MediaController,
+ packageName: String,
+) : RecyclerView.Adapter() {
+ private var commands: List = emptyList()
+ private val resources: Resources = activity.packageManager.getResourcesForApplication(packageName)
+
+ init {
+ val customCommandsList: RecyclerView = activity.findViewById(R.id.custom_commands_list)
+ customCommandsList.layoutManager = LinearLayoutManager(activity)
+ customCommandsList.setHasFixedSize(true)
+ customCommandsList.adapter = this
+ setCommands(mediaController.customLayout)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
+ ViewHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.media_custom_command, parent, false)
+ )
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val commandButton: CommandButton = commands[position]
+ holder.name.text = commandButton.displayName
+ holder.description.text = commandButton.sessionCommand?.customAction
+ if (commandButton.iconResId != 0) {
+ val iconDrawable: Drawable? =
+ ResourcesCompat.getDrawable(resources, commandButton.iconResId, null)
+ holder.icon.setImageDrawable(iconDrawable)
+ }
+ holder.itemView.setOnClickListener {
+ commandButton.sessionCommand?.let { mediaController.sendCustomCommand(it, Bundle.EMPTY) }
+ }
+ }
+
+ override fun getItemCount(): Int = commands.size
+
+ fun setCommands(newCommands: List) {
+ val diffResult: DiffUtil.DiffResult =
+ DiffUtil.calculateDiff(
+ object : DiffUtil.Callback() {
+ override fun getOldListSize(): Int = commands.size
+
+ override fun getNewListSize(): Int = newCommands.size
+
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
+ commands.size == newCommands.size &&
+ commands[oldItemPosition] == newCommands[newItemPosition]
+
+ override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
+ commands[oldItemPosition] == newCommands[newItemPosition]
+ }
+ )
+ commands = newCommands
+ diffResult.dispatchUpdatesTo(this)
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val name: TextView = itemView.findViewById(R.id.action_name)
+ val description: TextView = itemView.findViewById(R.id.action_description)
+ val icon: ImageView = itemView.findViewById(R.id.action_icon)
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/LaunchActivity.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/LaunchActivity.kt
new file mode 100644
index 0000000000..bc7c190320
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/LaunchActivity.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.testapp.controller
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.media.session.MediaSessionManager as ActiveSessionManager
+import android.os.Build
+import android.os.Build.VERSION_CODES
+import android.os.Bundle
+import android.service.notification.NotificationListenerService
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.core.app.NotificationManagerCompat
+import androidx.media3.testapp.controller.findapps.FindActiveMediaSessionApps
+import androidx.media3.testapp.controller.findapps.FindMediaApps
+import androidx.media3.testapp.controller.findapps.FindMediaServiceApps
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * App entry point. Presents a list of apps that implement
+ * [androidx.media3.session.MediaSessionService], [androidx.media3.session.MediaLibraryService], or
+ * [androidx.media.MediaBrowserServiceCompat]. Also presents a separate list of active media session
+ * apps.
+ */
+class LaunchActivity : AppCompatActivity() {
+ private lateinit var mediaAppListAdapter: MediaAppListAdapter
+ private lateinit var mediaSessionApps: MediaAppListAdapter.Section
+ private val sessionAppsUpdated =
+ object : FindMediaApps.AppListUpdatedCallback {
+ override fun onAppListUpdated(mediaAppEntries: List) {
+ if (mediaAppEntries.isEmpty()) {
+ mediaSessionApps.setError(
+ R.string.no_apps_found,
+ R.string.no_apps_reason_no_media_services,
+ )
+ } else {
+ mediaSessionApps.setAppsList(mediaAppEntries)
+ }
+ }
+ }
+ private var activeSessionListener: ActiveSessionListener? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_launch)
+
+ val toolbar: Toolbar? = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+
+ mediaAppListAdapter =
+ MediaAppListAdapter(
+ object : MediaAppListAdapter.MediaAppSelectedListener {
+ override fun onMediaAppClicked(mediaAppDetails: MediaAppDetails) {
+ startActivity(
+ MediaAppControllerActivity.buildIntent(this@LaunchActivity, mediaAppDetails)
+ )
+ }
+ }
+ )
+
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ activeSessionListener = ActiveSessionListener()
+ }
+
+ mediaSessionApps = mediaAppListAdapter.addSection(R.string.media_app_header_media_service)
+
+ val mediaAppsList: RecyclerView? = findViewById(R.id.app_list)
+ mediaAppsList?.layoutManager = LinearLayoutManager(this)
+ mediaAppsList?.setHasFixedSize(true)
+ mediaAppsList?.adapter = mediaAppListAdapter
+ }
+
+ override fun onStart() {
+ super.onStart()
+
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ activeSessionListener!!.onStart()
+ }
+
+ // Finds apps that implement MediaSessionService, MediaLibraryService, or
+ // MediaBrowserServiceCompat.
+ FindMediaServiceApps(context = this, this.packageManager, this.resources, sessionAppsUpdated)
+ .execute()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ activeSessionListener!!.onStop()
+ }
+ }
+
+ /**
+ * Encapsulates the API 21+ functionality of looking for and observing updates to active media
+ * sessions. We only construct an instance of this class if the device is running L or later, to
+ * avoid any ClassNotFoundExceptions due to the use of MediaSession and related classes.
+ */
+ @RequiresApi(VERSION_CODES.LOLLIPOP)
+ private inner class ActiveSessionListener {
+ private val activeSessionApps: MediaAppListAdapter.Section =
+ mediaAppListAdapter.addSection(R.string.media_app_header_active_session)
+ private val activeSessionManager: ActiveSessionManager =
+ getSystemService(Context.MEDIA_SESSION_SERVICE) as ActiveSessionManager
+ private val sessionAppsUpdated =
+ object : FindMediaApps.AppListUpdatedCallback {
+ override fun onAppListUpdated(mediaAppEntries: List) =
+ if (mediaAppEntries.isEmpty()) {
+ activeSessionApps.setError(
+ R.string.no_apps_found,
+ R.string.no_apps_reason_no_active_sessions,
+ )
+ } else {
+ activeSessionApps.setAppsList(mediaAppEntries)
+ }
+ }
+ private lateinit var findActiveMediaSessionApps: FindActiveMediaSessionApps
+ private val sessionsChangedListener =
+ ActiveSessionManager.OnActiveSessionsChangedListener { findActiveMediaSessionApps.execute() }
+
+ fun onStart() {
+ if (!NotificationListener.isEnabled(this@LaunchActivity)) {
+ activeSessionApps.setError(
+ R.string.no_apps_found,
+ R.string.no_apps_reason_missing_permission,
+ R.string.action_notification_permissions_settings,
+ ) {
+ startActivity(Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"))
+ }
+ } else {
+ val listenerComponent = ComponentName(this@LaunchActivity, NotificationListener::class.java)
+ findActiveMediaSessionApps =
+ FindActiveMediaSessionApps(
+ activeSessionManager,
+ listenerComponent,
+ packageManager,
+ resources,
+ this@LaunchActivity,
+ sessionAppsUpdated,
+ )
+ activeSessionManager.addOnActiveSessionsChangedListener(
+ sessionsChangedListener,
+ listenerComponent,
+ )
+ findActiveMediaSessionApps.execute()
+ }
+ }
+
+ fun onStop() {
+ activeSessionManager.removeOnActiveSessionsChangedListener(sessionsChangedListener)
+ }
+ }
+
+ /**
+ * A notification listener service that allows us to grab active media sessions from their
+ * notifications. This class is only used on API 21+ because the Android media framework added
+ * getActiveSessions in API 21.
+ */
+ @RequiresApi(VERSION_CODES.LOLLIPOP)
+ class NotificationListener : NotificationListenerService() {
+ companion object {
+ // Helper method to check if our notification listener is enabled. In order to get active
+ // media sessions, we need an enabled notification listener component.
+ fun isEnabled(context: Context): Boolean {
+ return NotificationManagerCompat.getEnabledListenerPackages(context)
+ .contains(context.packageName)
+ }
+ }
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppControllerActivity.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppControllerActivity.kt
new file mode 100644
index 0000000000..9fdecb19f5
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppControllerActivity.kt
@@ -0,0 +1,340 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.appcompat.app.ActionBar
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.core.content.ContextCompat
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.Player
+import androidx.media3.common.util.BitmapLoader
+import androidx.media3.common.util.Log
+import androidx.media3.datasource.DataSourceBitmapLoader
+import androidx.media3.session.CacheBitmapLoader
+import androidx.media3.session.CommandButton
+import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaBrowser
+import androidx.media3.session.MediaController
+import androidx.media3.session.MediaLibraryService
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionCommands
+import androidx.media3.session.SessionToken
+import androidx.viewpager.widget.PagerAdapter
+import androidx.viewpager.widget.ViewPager
+import com.google.android.material.tabs.TabLayout
+import com.google.common.collect.ImmutableList
+import com.google.common.util.concurrent.FutureCallback
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+
+class MediaAppControllerActivity : AppCompatActivity() {
+ private lateinit var mediaAppDetails: MediaAppDetails
+ private lateinit var browserFuture: ListenableFuture
+ private val browser: MediaBrowser?
+ get() = if (browserFuture.isDone && !browserFuture.isCancelled) browserFuture.get() else null
+
+ private lateinit var viewPager: ViewPager
+ private lateinit var ratingViewGroup: ViewGroup
+
+ private lateinit var mediaInfoText: TextView
+ private lateinit var mediaTitleView: TextView
+ private lateinit var mediaArtistView: TextView
+ private lateinit var mediaAlbumView: TextView
+ private lateinit var mediaAlbumArtView: ImageView
+
+ private lateinit var transportControlHelper: TransportControlHelper
+ private lateinit var shuffleModeHelper: ShuffleModeHelper
+ private lateinit var repeatModeHelper: RepeatModeHelper
+ private lateinit var ratingHelper: RatingHelper
+ private lateinit var customCommandsAdapter: CustomCommandsAdapter
+ private lateinit var timelineAdapter: TimelineAdapter
+ private lateinit var browseMediaItemsAdapter: BrowseMediaItemsAdapter
+ private lateinit var searchMediaItemsAdapter: SearchMediaItemsAdapter
+
+ private lateinit var bitmapLoader: BitmapLoader
+
+ companion object {
+ private const val TAG = "ControllerActivity"
+
+ // Key name for Intent extras.
+ private const val APP_DETAILS_EXTRA = "androidx.media3.testapp.controller.APP_DETAILS_EXTRA"
+
+ /**
+ * Builds an [Intent] to launch this Activity with a set of extras.
+ *
+ * @param activity The Activity building the Intent.
+ * @param appDetails The app details about the media app to connect to.
+ * @return An Intent that can be used to start the Activity.
+ */
+ fun buildIntent(activity: Activity, appDetails: MediaAppDetails): Intent {
+ val intent = Intent(activity, MediaAppControllerActivity::class.java)
+ intent.putExtra(APP_DETAILS_EXTRA, appDetails.toBundle())
+ return intent
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_media_app_controller)
+
+ val toolbar: Toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ toolbar.setNavigationOnClickListener { finish() }
+
+ bitmapLoader = CacheBitmapLoader(DataSourceBitmapLoader(this))
+ viewPager = findViewById(R.id.view_pager)
+ ratingViewGroup = findViewById(R.id.rating)
+ mediaInfoText = findViewById(R.id.media_info)
+ mediaAlbumArtView = findViewById(R.id.media_art)
+ mediaTitleView = findViewById(R.id.media_title)
+ mediaArtistView = findViewById(R.id.media_artist)
+ mediaAlbumView = findViewById(R.id.media_album)
+
+ mediaAppDetails = parseIntent(intent)
+
+ val pages: Array =
+ arrayOf(
+ R.id.prepare_play_page,
+ R.id.controls_page,
+ R.id.custom_commands_page,
+ R.id.timeline_list_page,
+ R.id.browse_tree_page,
+ R.id.media_search_page,
+ )
+
+ viewPager.offscreenPageLimit = pages.size
+ viewPager.adapter =
+ object : PagerAdapter() {
+ override fun getCount(): Int = pages.size
+
+ override fun isViewFromObject(view: View, obj: Any): Boolean = (view === obj)
+
+ override fun instantiateItem(container: ViewGroup, position: Int): Any =
+ findViewById(pages[position])
+ }
+
+ val pageIndicator: TabLayout = findViewById(R.id.page_indicator)
+ pageIndicator.setupWithViewPager(viewPager)
+
+ setupToolbar()
+ setupMediaBrowser()
+ }
+
+ override fun onDestroy() {
+ MediaBrowser.releaseFuture(browserFuture)
+ super.onDestroy()
+ }
+
+ private fun parseIntent(intent: Intent): MediaAppDetails {
+ val extras: Bundle? = intent.extras
+ return MediaAppDetails.fromBundle(extras!!.getBundle(APP_DETAILS_EXTRA)!!)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBundle(APP_DETAILS_EXTRA, mediaAppDetails.toBundle())
+ }
+
+ private fun setupMediaBrowser() {
+ browserFuture = getMediaBrowser(mediaAppDetails.sessionToken)
+ val listener: Player.Listener =
+ object : Player.Listener {
+ override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
+ updateMediaInfoText()
+ updateMediaMetadataView(mediaMetadata)
+ }
+
+ override fun onPlaybackStateChanged(state: Int) = updateMediaInfoText()
+
+ override fun onAvailableCommandsChanged(availableCommands: Player.Commands) =
+ updateMediaInfoText()
+
+ override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) =
+ updateMediaInfoText()
+ }
+
+ Futures.addCallback(
+ browserFuture,
+ object : FutureCallback {
+ override fun onSuccess(browser: MediaBrowser) {
+ browser.addListener(listener)
+ PreparePlayHelper(this@MediaAppControllerActivity, browser)
+ AudioFocusHelper(this@MediaAppControllerActivity)
+ customCommandsAdapter =
+ CustomCommandsAdapter(
+ this@MediaAppControllerActivity,
+ browser,
+ mediaAppDetails.packageName,
+ )
+ transportControlHelper = TransportControlHelper(this@MediaAppControllerActivity, browser)
+ shuffleModeHelper = ShuffleModeHelper(this@MediaAppControllerActivity, browser)
+ repeatModeHelper = RepeatModeHelper(this@MediaAppControllerActivity, browser)
+ ratingHelper = RatingHelper(ratingViewGroup, browser)
+ timelineAdapter = TimelineAdapter(this@MediaAppControllerActivity, browser)
+ browseMediaItemsAdapter =
+ BrowseMediaItemsAdapter(this@MediaAppControllerActivity, browser)
+ searchMediaItemsAdapter =
+ SearchMediaItemsAdapter(this@MediaAppControllerActivity, browser)
+
+ updateMediaInfoText()
+ updateMediaMetadataView(browser.mediaMetadata)
+ }
+
+ override fun onFailure(t: Throwable) {
+ mediaInfoText.text = getString(R.string.controller_connection_failed_msg, t.message)
+ }
+ },
+ ContextCompat.getMainExecutor(this),
+ )
+ }
+
+ private fun getMediaBrowser(token: SessionToken): ListenableFuture {
+ val listener =
+ object : MediaBrowser.Listener {
+ override fun onAvailableSessionCommandsChanged(
+ controller: MediaController,
+ commands: SessionCommands,
+ ) = updateMediaInfoText()
+
+ override fun onCustomLayoutChanged(
+ controller: MediaController,
+ layout: MutableList,
+ ) {
+ customCommandsAdapter.setCommands(layout)
+ }
+
+ override fun onChildrenChanged(
+ browser: MediaBrowser,
+ parentId: String,
+ itemCount: Int,
+ params: MediaLibraryService.LibraryParams?,
+ ) {
+ if (
+ itemCount >= 1 &&
+ browser.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN)
+ ) {
+ val future: ListenableFuture>> =
+ browser.getChildren(parentId, 0, itemCount, params)
+ future.addListener(
+ {
+ val items: List = future.get().value ?: emptyList()
+ browseMediaItemsAdapter.updateItems(items)
+ },
+ ContextCompat.getMainExecutor(this@MediaAppControllerActivity),
+ )
+ }
+ }
+
+ override fun onDisconnected(controller: MediaController) {
+ mediaInfoText.text = getString(R.string.controller_disconnected_msg)
+ browseMediaItemsAdapter.updateItems(emptyList())
+ searchMediaItemsAdapter.updateItems(emptyList())
+ }
+ }
+ return MediaBrowser.Builder(this, token).setListener(listener).buildAsync()
+ }
+
+ private fun updateMediaInfoText() {
+ val browser = this.browser ?: return
+
+ val mediaInfos = HashMap()
+
+ mediaInfos[getString(R.string.info_state_string)] =
+ MediaIntToString.playbackStateMap.getValue(browser.playbackState)
+
+ val mediaMetadata: MediaMetadata = browser.mediaMetadata
+
+ mediaInfos[getString(R.string.info_title_string)] =
+ mediaMetadata.title ?: "Title metadata empty"
+ mediaInfos[getString(R.string.info_artist_string)] =
+ mediaMetadata.artist ?: "Artist metadata empty"
+ mediaInfos[getString(R.string.info_album_string)] =
+ mediaMetadata.albumTitle ?: "Album title metadata empty"
+ mediaInfos[getString(R.string.info_play_when_ready)] = browser.playWhenReady.toString()
+
+ var infoCharSequence: CharSequence = ""
+ val keys: List = mediaInfos.keys.sorted()
+
+ for (key in keys) {
+ infoCharSequence = TextUtils.concat(infoCharSequence, key, "=", mediaInfos[key], "\n")
+ }
+
+ infoCharSequence = TextUtils.concat(infoCharSequence, "\nSupported Commands=\n")
+
+ val playerCommands: Player.Commands = browser.availableCommands
+ MediaIntToString.playerCommandMap.forEach { (command, string) ->
+ if (playerCommands.contains(command)) {
+ infoCharSequence = TextUtils.concat(infoCharSequence, string, "\n")
+ }
+ }
+
+ val sessionCommands: SessionCommands = browser.availableSessionCommands
+ MediaIntToString.sessionCommandMap.forEach { (command, string) ->
+ if (sessionCommands.contains(command)) {
+ infoCharSequence = TextUtils.concat(infoCharSequence, string, "\n")
+ }
+ }
+
+ mediaInfoText.text = infoCharSequence
+ }
+
+ private fun updateMediaMetadataView(mediaMetadata: MediaMetadata) {
+ mediaTitleView.text = mediaMetadata.title ?: "Title metadata empty"
+ mediaArtistView.text = mediaMetadata.artist ?: "Artist metadata empty"
+ mediaAlbumView.text = mediaMetadata.albumTitle ?: "Album title metadata empty"
+
+ bitmapLoader.loadBitmapFromMetadata(mediaMetadata)?.let {
+ Futures.addCallback(
+ it,
+ object : FutureCallback {
+ override fun onSuccess(result: Bitmap?) {
+ mediaAlbumArtView.setImageBitmap(result)
+ }
+
+ override fun onFailure(t: Throwable) {
+ mediaAlbumArtView.setImageResource(R.drawable.ic_album_black_24dp)
+ t.message?.let { msg -> Log.e("BitmapLoader", msg, t) }
+ }
+ },
+ ContextCompat.getMainExecutor(this),
+ )
+ } ?: mediaAlbumArtView.setImageResource(R.drawable.ic_album_black_24dp)
+ }
+
+ private fun setupToolbar() {
+ val actionBar: ActionBar? = supportActionBar
+ if (actionBar != null) {
+ val toolbarIcon = BitmapUtils.createToolbarIcon(resources, mediaAppDetails.icon)
+ with(actionBar) {
+ setIcon(BitmapDrawable(resources, toolbarIcon))
+ title = mediaAppDetails.appName
+ }
+ }
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppDetails.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppDetails.kt
new file mode 100644
index 0000000000..849d6f8f83
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppDetails.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.testapp.controller
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.media.session.MediaController
+import android.os.Build
+import android.os.Bundle
+import androidx.annotation.RequiresApi
+import androidx.media3.common.util.Util
+import androidx.media3.session.SessionToken
+
+/** Stores details about a media app. */
+class MediaAppDetails
+private constructor(
+ val packageName: String,
+ val appName: String,
+ val icon: Bitmap,
+ val sessionToken: SessionToken,
+ val supportsAutomotive: Boolean,
+ val supportsAuto: Boolean,
+) {
+
+ companion object {
+ fun create(
+ packageManager: PackageManager,
+ resources: Resources,
+ sessionToken: SessionToken,
+ ): MediaAppDetails {
+ val packageName = sessionToken.packageName
+ val appInfo = packageManager.getApplicationInfo(packageName, /* flags= */ 0)
+ val appName = appInfo.loadLabel(packageManager).toString()
+ val icon =
+ BitmapUtils.convertDrawable(resources, appInfo.loadIcon(packageManager), downScale = true)
+ return MediaAppDetails(
+ packageName,
+ appName,
+ icon,
+ sessionToken,
+ getSupportsAutomotive(packageManager, packageName),
+ getSupportsAuto(packageManager, packageName),
+ )
+ }
+
+ @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+ fun create(
+ packageManager: PackageManager,
+ resources: Resources,
+ controller: MediaController,
+ context: Context,
+ ): MediaAppDetails {
+ val sessionToken = SessionToken.createSessionToken(context, controller.sessionToken).get()
+ val packageName = sessionToken.packageName
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
+ val appName = appInfo.loadLabel(packageManager).toString()
+ val icon =
+ BitmapUtils.convertDrawable(resources, appInfo.loadIcon(packageManager), downScale = true)
+ return MediaAppDetails(
+ packageName,
+ appName,
+ icon,
+ sessionToken,
+ getSupportsAutomotive(packageManager, packageName),
+ getSupportsAuto(packageManager, packageName),
+ )
+ }
+
+ private fun getSupportsAutomotive(
+ packageManager: PackageManager,
+ packageName: String,
+ ): Boolean {
+ val features =
+ packageManager.getPackageInfo(packageName, PackageManager.GET_CONFIGURATIONS).reqFeatures
+ return features?.any { it?.name == "android.hardware.type.automotive" } == true
+ }
+
+ private fun getSupportsAuto(packageManager: PackageManager, packageName: String): Boolean {
+ val metaData =
+ packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA).metaData
+ return metaData?.containsKey("com.google.android.gms.car.application") == true
+ }
+
+ private val PACKAGE_NAME = Util.intToStringMaxRadix(0)
+ private val APP_NAME = Util.intToStringMaxRadix(1)
+ private val ICON = Util.intToStringMaxRadix(2)
+ private val SESSION_TOKEN = Util.intToStringMaxRadix(3)
+ private val SUPPORTS_AUTO = Util.intToStringMaxRadix(4)
+ private val SUPPORTS_AUTOMOTIVE = Util.intToStringMaxRadix(5)
+
+ fun fromBundle(bundle: Bundle): MediaAppDetails {
+ return MediaAppDetails(
+ bundle.getString(PACKAGE_NAME)!!,
+ bundle.getString(APP_NAME)!!,
+ (bundle.getParcelable(ICON) as Bitmap?)!!,
+ SessionToken.fromBundle(bundle.getBundle(SESSION_TOKEN)!!),
+ bundle.getBoolean(SUPPORTS_AUTO),
+ bundle.getBoolean(SUPPORTS_AUTOMOTIVE),
+ )
+ }
+ }
+
+ fun toBundle(): Bundle {
+ val bundle = Bundle()
+ bundle.putString(PACKAGE_NAME, packageName)
+ bundle.putString(APP_NAME, appName)
+ bundle.putParcelable(ICON, icon)
+ bundle.putBundle(SESSION_TOKEN, sessionToken.toBundle())
+ bundle.putBoolean(SUPPORTS_AUTO, supportsAuto)
+ bundle.putBoolean(SUPPORTS_AUTOMOTIVE, supportsAutomotive)
+ return bundle
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppListAdapter.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppListAdapter.kt
new file mode 100644
index 0000000000..7a296529de
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppListAdapter.kt
@@ -0,0 +1,221 @@
+/*
+ * 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.testapp.controller
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.StringRes
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+
+/** A sectioned RecyclerView Adapter that displays list(s) of media apps. */
+class MediaAppListAdapter(val mediaAppSelectedListener: MediaAppSelectedListener) :
+ RecyclerView.Adapter() {
+
+ /** Click listener for when an app is selected. */
+ interface MediaAppSelectedListener {
+ fun onMediaAppClicked(mediaAppDetails: MediaAppDetails)
+ }
+
+ /** The types of views that this recycler view adapter displays. */
+ enum class ViewType(val layoutId: Int) {
+ /**
+ * A media app entry, with icon, app name, and package name. Tapping on one of these entries
+ * will fire the MediaAppSelectedListener callback.
+ */
+ AppView(R.layout.media_app_item) {
+ override fun create(itemLayout: View): ViewHolder = AppEntry.ViewHolder(itemLayout)
+ },
+ /** A section header, only displayed if the adapter has multiple sections. */
+ HeaderView(R.layout.media_app_list_header) {
+ override fun create(itemLayout: View): ViewHolder = Header.ViewHolder(itemLayout)
+ },
+ /** An error, such as "no apps", or "missing permission". Can optionally provide an action. */
+ ErrorView(R.layout.media_app_list_error) {
+ override fun create(itemLayout: View): ViewHolder = Error.ViewHolder(itemLayout)
+ };
+
+ abstract fun create(itemLayout: View): ViewHolder
+ }
+
+ /** An interface for items in the recycler view. */
+ interface RecyclerViewItem {
+ fun viewType(): ViewType
+
+ fun bindTo(holder: ViewHolder)
+ }
+
+ /** An implementation of [RecyclerViewItem] for media apps. */
+ class AppEntry(
+ private val appDetails: MediaAppDetails,
+ private val appSelectedListener: MediaAppSelectedListener,
+ ) : RecyclerViewItem {
+ override fun viewType(): ViewType = ViewType.AppView
+
+ override fun bindTo(holder: RecyclerView.ViewHolder) {
+ if (holder is ViewHolder) {
+ holder.appIconView?.setImageBitmap(appDetails.icon)
+ holder.appIconView?.contentDescription =
+ holder.appIconView?.context?.getString(R.string.app_icon_desc, appDetails.appName)
+ holder.appNameView?.text = appDetails.appName
+ holder.appPackageView?.text = appDetails.packageName
+
+ holder.controlButton?.setOnClickListener {
+ appSelectedListener.onMediaAppClicked(appDetails)
+ }
+ }
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val appIconView: ImageView? = itemView.findViewById(R.id.app_icon)
+ val appNameView: TextView? = itemView.findViewById(R.id.app_name)
+ val appPackageView: TextView? = itemView.findViewById(R.id.package_name)
+ val controlButton: Button? = itemView.findViewById(R.id.app_control)
+ }
+ }
+
+ /** An implementation of [RecyclerViewItem] for headers. */
+ class Header(@StringRes private val labelResId: Int) : RecyclerViewItem {
+ override fun viewType(): ViewType = ViewType.HeaderView
+
+ override fun bindTo(holder: RecyclerView.ViewHolder) {
+ if (holder is ViewHolder) {
+ holder.headerView?.setText(labelResId)
+ }
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val headerView: TextView? = itemView.findViewById(R.id.header_text)
+ }
+ }
+
+ /** An implementation of [RecyclerViewItem] for error states, with an optional action. */
+ class Error(
+ @StringRes private val errorMsgId: Int,
+ @StringRes private val errorDetailId: Int,
+ @StringRes private val errorButtonId: Int,
+ private val clickListener: View.OnClickListener?,
+ ) : RecyclerViewItem {
+ override fun viewType(): ViewType = ViewType.ErrorView
+
+ override fun bindTo(holder: RecyclerView.ViewHolder) {
+ if (holder is ViewHolder) {
+ holder.errorMessage?.setText(errorMsgId)
+ holder.errorDetail?.setText(errorDetailId)
+ holder.errorMessage?.visibility = if (errorDetailId == 0) View.GONE else View.VISIBLE
+ holder.actionButton?.setOnClickListener(clickListener)
+ if (errorButtonId == 0 || clickListener == null) {
+ holder.actionButton?.visibility = View.GONE
+ } else {
+ holder.actionButton?.visibility = View.VISIBLE
+ holder.actionButton?.setText(errorButtonId)
+ }
+ }
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val errorMessage: TextView? = itemView.findViewById(R.id.error_message)
+ val errorDetail: TextView? = itemView.findViewById(R.id.error_detail)
+ val actionButton: Button? = itemView.findViewById(R.id.error_action)
+ }
+ }
+
+ /** Represents a section of items in the recycler view. */
+ inner class Section(@StringRes internal val label: Int) {
+ internal val items = mutableListOf()
+
+ val size: Int
+ get() = items.size
+
+ fun setError(@StringRes message: Int, @StringRes detail: Int) =
+ setError(message, detail, 0, null)
+
+ fun setError(
+ @StringRes message: Int,
+ @StringRes detail: Int,
+ @StringRes buttonText: Int,
+ onClickListener: View.OnClickListener?,
+ ) {
+ items.clear()
+ items.add(Error(message, detail, buttonText, onClickListener))
+ updateData()
+ }
+
+ fun setAppsList(appEntries: List) {
+ items.clear()
+ for (appEntry in appEntries) {
+ if (appEntry != null) {
+ items.add(AppEntry(appEntry, mediaAppSelectedListener))
+ }
+ }
+ updateData()
+ }
+ }
+
+ private val sections = ArrayList()
+ private val recyclerViewEntries = ArrayList()
+
+ fun addSection(@StringRes label: Int): Section {
+ val section = Section(label)
+ sections.add(section)
+ return section
+ }
+
+ fun updateData() {
+ val oldEntries = ArrayList(recyclerViewEntries)
+ recyclerViewEntries.clear()
+ for (section in sections) {
+ if (section.size > 0) {
+ recyclerViewEntries.add(Header(section.label))
+ }
+ recyclerViewEntries.addAll(section.items)
+ }
+
+ val diffResult: DiffUtil.DiffResult =
+ DiffUtil.calculateDiff(
+ object : DiffUtil.Callback() {
+ override fun getOldListSize(): Int = oldEntries.size
+
+ override fun getNewListSize(): Int = recyclerViewEntries.size
+
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
+ oldEntries[oldItemPosition] == recyclerViewEntries[newItemPosition]
+
+ override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
+ areItemsTheSame(oldItemPosition, newItemPosition)
+ }
+ )
+ diffResult.dispatchUpdatesTo(this)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val type: ViewType = ViewType.values()[viewType]
+ val itemLayout: View = LayoutInflater.from(parent.context).inflate(type.layoutId, parent, false)
+ return type.create(itemLayout)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) =
+ recyclerViewEntries[position].bindTo(holder)
+
+ override fun getItemViewType(position: Int) = recyclerViewEntries[position].viewType().ordinal
+
+ override fun getItemCount(): Int = recyclerViewEntries.size
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaIntToString.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaIntToString.kt
new file mode 100644
index 0000000000..03d81605ee
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaIntToString.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.testapp.controller
+
+import androidx.media3.common.Player
+import androidx.media3.session.SessionCommand
+
+object MediaIntToString {
+ val playbackStateMap =
+ mapOf(
+ Player.STATE_IDLE to "STATE_IDLE",
+ Player.STATE_BUFFERING to "STATE_BUFFERING",
+ Player.STATE_READY to "STATE_READY",
+ Player.STATE_ENDED to "STATE_ENDED"
+ )
+ val playerCommandMap =
+ mapOf(
+ Player.COMMAND_INVALID to "COMMAND_INVALID",
+ Player.COMMAND_PLAY_PAUSE to "COMMAND_PLAY_PAUSE",
+ Player.COMMAND_PREPARE to "COMMAND_PREPARE",
+ Player.COMMAND_STOP to "COMMAND_STOP",
+ Player.COMMAND_SEEK_TO_DEFAULT_POSITION to "COMMAND_SEEK_TO_DEFAULT_POSITION",
+ Player.COMMAND_SEEK_TO_DEFAULT_POSITION to "COMMAND_SEEK_TO_DEFAULT_POSITION",
+ Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM to "COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM",
+ Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM to "COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM",
+ Player.COMMAND_SEEK_TO_PREVIOUS to "COMMAND_SEEK_TO_PREVIOUS",
+ Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM to "COMMAND_SEEK_TO_NEXT_MEDIA_ITEM",
+ Player.COMMAND_SEEK_TO_NEXT to "COMMAND_SEEK_TO_NEXT",
+ Player.COMMAND_SEEK_TO_MEDIA_ITEM to "COMMAND_SEEK_TO_MEDIA_ITEM",
+ Player.COMMAND_SEEK_BACK to "COMMAND_SEEK_BACK",
+ Player.COMMAND_SEEK_FORWARD to "COMMAND_SEEK_FORWARD",
+ Player.COMMAND_SET_SPEED_AND_PITCH to "COMMAND_SET_SPEED_AND_PITCH",
+ Player.COMMAND_SET_SHUFFLE_MODE to "COMMAND_SET_SHUFFLE_MODE",
+ Player.COMMAND_SET_REPEAT_MODE to "COMMAND_SET_REPEAT_MODE",
+ Player.COMMAND_GET_CURRENT_MEDIA_ITEM to "COMMAND_GET_CURRENT_MEDIA_ITEM",
+ Player.COMMAND_GET_TIMELINE to "COMMAND_GET_TIMELINE",
+ Player.COMMAND_GET_METADATA to "COMMAND_GET_METADATA",
+ Player.COMMAND_SET_PLAYLIST_METADATA to "COMMAND_SET_PLAYLIST_METADATA",
+ Player.COMMAND_SET_MEDIA_ITEM to "COMMAND_SET_MEDIA_ITEM",
+ Player.COMMAND_CHANGE_MEDIA_ITEMS to "COMMAND_CHANGE_MEDIA_ITEMS",
+ Player.COMMAND_GET_AUDIO_ATTRIBUTES to "COMMAND_GET_AUDIO_ATTRIBUTES",
+ Player.COMMAND_GET_VOLUME to "COMMAND_GET_VOLUME",
+ Player.COMMAND_GET_DEVICE_VOLUME to "COMMAND_GET_DEVICE_VOLUME",
+ Player.COMMAND_SET_VOLUME to "COMMAND_SET_VOLUME",
+ Player.COMMAND_SET_DEVICE_VOLUME to "COMMAND_SET_DEVICE_VOLUME",
+ Player.COMMAND_ADJUST_DEVICE_VOLUME to "COMMAND_ADJUST_DEVICE_VOLUME",
+ Player.COMMAND_SET_VIDEO_SURFACE to "COMMAND_SET_VIDEO_SURFACE",
+ Player.COMMAND_GET_TEXT to "COMMAND_GET_TEXT"
+ )
+ val sessionCommandMap =
+ mapOf(
+ SessionCommand.COMMAND_CODE_SESSION_SET_RATING to "COMMAND_SESSION_SET_RATING",
+ SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT to "COMMAND_LIBRARY_GET_LIBRARY_ROOT",
+ SessionCommand.COMMAND_CODE_LIBRARY_SUBSCRIBE to "COMMAND_LIBRARY_SUBSCRIBE",
+ SessionCommand.COMMAND_CODE_LIBRARY_UNSUBSCRIBE to "COMMAND_LIBRARY_UNSUBSCRIBE",
+ SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN to "COMMAND_LIBRARY_GET_CHILDREN",
+ SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM to "COMMAND_LIBRARY_GET_ITEM",
+ SessionCommand.COMMAND_CODE_LIBRARY_SEARCH to "COMMAND_LIBRARY_SEARCH",
+ SessionCommand.COMMAND_CODE_LIBRARY_GET_SEARCH_RESULT to "COMMAND_LIBRARY_GET_SEARCH_RESULT"
+ )
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/PreparePlayHelper.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/PreparePlayHelper.kt
new file mode 100644
index 0000000000..17115470b2
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/PreparePlayHelper.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.net.Uri
+import android.view.View
+import android.widget.Button
+import android.widget.EditText
+import android.widget.Spinner
+import androidx.media3.common.MediaItem
+import androidx.media3.session.MediaController
+
+/** Helper class which handles prepare and play actions. */
+class PreparePlayHelper(activity: Activity, private val mediaController: MediaController) :
+ View.OnClickListener {
+ private val inputType: Spinner = activity.findViewById(R.id.input_type)
+ private val uriInputText: EditText = activity.findViewById(R.id.uri_id_query)
+ private val prepareButton: Button = activity.findViewById(R.id.action_prepare)
+ private val playButton: Button = activity.findViewById(R.id.action_play)
+
+ init {
+ prepareButton.setOnClickListener(this)
+ playButton.setOnClickListener(this)
+ }
+
+ companion object {
+ // Indices of the values in the "input_options" string array.
+ private const val INDEX_SEARCH = 0
+ private const val INDEX_MEDIA_ID = 1
+ private const val INDEX_URI = 2
+ }
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ override fun onClick(v: View) {
+ mediaController.apply {
+ setMediaItem(buildMediaItem())
+ playWhenReady = v.id == R.id.action_play
+ prepare()
+ }
+ }
+
+ private fun buildMediaItem(): MediaItem {
+ val value: String = uriInputText.text.toString()
+ val mediaItemBuilder = MediaItem.Builder()
+ when (inputType.selectedItemPosition) {
+ INDEX_MEDIA_ID -> mediaItemBuilder.setMediaId(value)
+ INDEX_SEARCH ->
+ mediaItemBuilder.setRequestMetadata(
+ MediaItem.RequestMetadata.Builder().setSearchQuery(value).build()
+ )
+ INDEX_URI ->
+ mediaItemBuilder.setRequestMetadata(
+ MediaItem.RequestMetadata.Builder().setMediaUri(Uri.parse(value)).build()
+ )
+ }
+ return mediaItemBuilder.build()
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/RatingHelper.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/RatingHelper.kt
new file mode 100644
index 0000000000..72f83f40d4
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/RatingHelper.kt
@@ -0,0 +1,281 @@
+/*
+ * 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.testapp.controller
+
+import android.text.Editable
+import android.text.TextUtils
+import android.view.View
+import android.view.ViewGroup
+import android.widget.EditText
+import android.widget.ImageView
+import androidx.annotation.IdRes
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.DrawableCompat
+import androidx.media3.common.HeartRating
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.PercentageRating
+import androidx.media3.common.Player
+import androidx.media3.common.Rating
+import androidx.media3.common.StarRating
+import androidx.media3.common.ThumbRating
+import androidx.media3.session.MediaController
+
+/** Helper class to manage displaying and setting different kinds of media ratings. */
+class RatingHelper(private val rootView: ViewGroup, private val mediaController: MediaController) {
+ private var ratingUiHelper: RatingUiHelper?
+ init {
+ ratingUiHelper = ratingUiHelperFor(rootView, mediaController.mediaMetadata)
+
+ val listener: Player.Listener =
+ object : Player.Listener {
+ override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) =
+ updateRating(mediaMetadata)
+ }
+ mediaController.addListener(listener)
+ updateRating(mediaController.mediaMetadata)
+ }
+
+ fun updateRating(mediaMetadata: MediaMetadata) {
+ val rating: Rating? = mediaMetadata.userRating ?: mediaMetadata.overallRating
+ if (rating != null) {
+ if (ratingUiHelper == null) {
+ ratingUiHelper = ratingUiHelperFor(rootView, mediaMetadata)
+ }
+ ratingUiHelper?.setRating(rating)
+ } else {
+ ratingUiHelper?.let { it.setRating(it.unrated()) }
+ }
+ }
+
+ private fun ratingUiHelperFor(
+ viewGroup: ViewGroup,
+ mediaMetadata: MediaMetadata
+ ): RatingUiHelper? {
+ val rating: Rating? = mediaMetadata.userRating ?: mediaMetadata.overallRating
+ viewGroup.visibility = View.VISIBLE
+ return when (rating) {
+ is ThumbRating -> RatingUiHelper.Thumbs(viewGroup, mediaController)
+ is HeartRating -> RatingUiHelper.Heart(viewGroup, mediaController)
+ is PercentageRating -> RatingUiHelper.Percentage(viewGroup, mediaController)
+ is StarRating ->
+ when (rating.maxStars) {
+ 3 -> RatingUiHelper.Stars3(viewGroup, mediaController)
+ 4 -> RatingUiHelper.Stars4(viewGroup, mediaController)
+ 5 -> RatingUiHelper.Stars5(viewGroup, mediaController)
+ else -> {
+ viewGroup.visibility = View.GONE
+ null
+ }
+ }
+ else -> {
+ viewGroup.visibility = View.GONE
+ null
+ }
+ }
+ }
+}
+
+@SuppressWarnings("FutureReturnValueIgnored")
+private abstract class RatingUiHelper(
+ private val rootView: ViewGroup,
+ mediaController: MediaController
+) {
+ private var currentRating: Rating = unrated()
+
+ init {
+ for (i in 0 until rootView.childCount) {
+ val ratingView: View = rootView.getChildAt(i)
+ ratingView.visibility = if (visible(ratingView.id)) View.VISIBLE else View.GONE
+
+ if (ratingView !is Editable) {
+ ratingView.setOnClickListener { view ->
+ val newRating: Rating = ratingFor(view.id, currentRating)
+ val mediaItem: MediaItem? = mediaController.currentMediaItem
+ if (mediaItem != null && !TextUtils.isEmpty(mediaItem.mediaId)) {
+ mediaController.setRating(mediaItem.mediaId, newRating)
+ } else {
+ mediaController.setRating(newRating)
+ }
+ }
+ }
+ }
+ }
+
+ /** Returns whether the given view is enabled with the current rating */
+ abstract fun enabled(@IdRes viewId: Int, rating: Rating): Boolean
+
+ /**
+ * Returns whether the given view is visible for the type of rating. For example, a thumbs up/down
+ * rating will not display stars or heart. And a 4-star rating will not display the fifth star.
+ */
+ abstract fun visible(@IdRes viewId: Int): Boolean
+
+ /** Returns the rating that should be set when the given view is tapped. */
+ abstract fun ratingFor(@IdRes viewId: Int, currentRating: Rating): Rating
+
+ /** Returns unrated rating of the current rating type. */
+ abstract fun unrated(): Rating
+
+ fun setRating(rating: Rating) {
+ for (i in 0 until rootView.childCount) {
+ val view: View = rootView.getChildAt(i)
+ if (view is ImageView) {
+ val tint: Int =
+ if (enabled(view.id, rating)) R.color.colorPrimary else R.color.colorInactive
+ DrawableCompat.setTint(view.drawable, ContextCompat.getColor(rootView.context, tint))
+ } else {
+ view.isEnabled = enabled(view.id, rating)
+ }
+ }
+ currentRating = rating
+ }
+
+ open class Stars3(viewGroup: ViewGroup, controller: MediaController) :
+ RatingUiHelper(viewGroup, controller) {
+ override fun enabled(viewId: Int, rating: Rating): Boolean {
+ if (rating is StarRating) {
+ val starRating: Float = rating.starRating
+ return when (viewId) {
+ R.id.rating_star_1 -> starRating >= 1.0f
+ R.id.rating_star_2 -> starRating >= 2.0f
+ R.id.rating_star_3 -> starRating >= 3.0f
+ else -> false
+ }
+ }
+ return false
+ }
+
+ override fun visible(viewId: Int): Boolean =
+ viewId == R.id.rating_star_1 || viewId == R.id.rating_star_2 || viewId == R.id.rating_star_3
+
+ override fun ratingFor(viewId: Int, currentRating: Rating): Rating =
+ when (viewId) {
+ R.id.rating_star_1 -> StarRating(3, 1.0f)
+ R.id.rating_star_2 -> StarRating(3, 2.0f)
+ R.id.rating_star_3 -> StarRating(3, 3.0f)
+ else -> StarRating(3)
+ }
+
+ override fun unrated(): Rating = StarRating(3)
+ }
+
+ open class Stars4(viewGroup: ViewGroup, controller: MediaController) :
+ Stars3(viewGroup, controller) {
+ override fun enabled(viewId: Int, rating: Rating): Boolean {
+ if (rating is StarRating && viewId == R.id.rating_star_4) {
+ return rating.starRating >= 4.0f
+ }
+ return super.enabled(viewId, rating)
+ }
+
+ override fun visible(viewId: Int): Boolean =
+ viewId == R.id.rating_star_4 || super.visible(viewId)
+
+ override fun ratingFor(viewId: Int, currentRating: Rating): Rating =
+ when (viewId) {
+ R.id.rating_star_1 -> StarRating(4, 1.0f)
+ R.id.rating_star_2 -> StarRating(4, 2.0f)
+ R.id.rating_star_3 -> StarRating(4, 3.0f)
+ R.id.rating_star_4 -> StarRating(4, 4.0f)
+ else -> StarRating(4)
+ }
+
+ override fun unrated(): Rating = StarRating(4)
+ }
+
+ class Stars5(viewGroup: ViewGroup, controller: MediaController) : Stars4(viewGroup, controller) {
+ override fun enabled(viewId: Int, rating: Rating): Boolean {
+ if (rating is StarRating && viewId == R.id.rating_star_5) {
+ return rating.starRating >= 5.0f
+ }
+ return super.enabled(viewId, rating)
+ }
+
+ override fun visible(viewId: Int): Boolean =
+ viewId == R.id.rating_star_5 || super.visible(viewId)
+
+ override fun ratingFor(viewId: Int, currentRating: Rating): Rating =
+ when (viewId) {
+ R.id.rating_star_1 -> StarRating(5, 1.0f)
+ R.id.rating_star_2 -> StarRating(5, 2.0f)
+ R.id.rating_star_3 -> StarRating(5, 3.0f)
+ R.id.rating_star_4 -> StarRating(5, 4.0f)
+ R.id.rating_star_5 -> StarRating(5, 5.0f)
+ else -> StarRating(5)
+ }
+
+ override fun unrated(): Rating = StarRating(5)
+ }
+
+ class Thumbs(viewGroup: ViewGroup, controller: MediaController) :
+ RatingUiHelper(viewGroup, controller) {
+ override fun enabled(viewId: Int, rating: Rating): Boolean {
+ if (rating is ThumbRating) {
+ if (rating.isThumbsUp && viewId == R.id.rating_thumb_up) return true
+ if (isThumbDown(rating) && viewId == R.id.rating_thumb_down) return true
+ }
+ return false
+ }
+
+ override fun visible(viewId: Int): Boolean =
+ viewId == R.id.rating_thumb_up || viewId == R.id.rating_thumb_down
+
+ override fun ratingFor(viewId: Int, currentRating: Rating): Rating {
+ // User tapped on current thumb rating, so reset the rating.
+ if (enabled(viewId, currentRating)) return ThumbRating()
+ return when (viewId) {
+ R.id.rating_thumb_up -> ThumbRating(true)
+ R.id.rating_thumb_down -> ThumbRating(false)
+ else -> ThumbRating()
+ }
+ }
+
+ override fun unrated(): Rating = ThumbRating()
+
+ private fun isThumbDown(rating: ThumbRating): Boolean = rating.isRated && !rating.isThumbsUp
+ }
+
+ class Heart(viewGroup: ViewGroup, controller: MediaController) :
+ RatingUiHelper(viewGroup, controller) {
+ override fun enabled(viewId: Int, rating: Rating): Boolean =
+ rating is HeartRating && rating.isHeart
+
+ override fun visible(viewId: Int): Boolean = viewId == R.id.rating_heart
+
+ override fun ratingFor(viewId: Int, currentRating: Rating): Rating =
+ if (currentRating is HeartRating) HeartRating(!currentRating.isHeart) else HeartRating()
+
+ override fun unrated(): Rating = HeartRating()
+ }
+
+ class Percentage(viewGroup: ViewGroup, controller: MediaController) :
+ RatingUiHelper(viewGroup, controller) {
+ private val percentageEditText: EditText = viewGroup.findViewById(R.id.rating_percentage)
+
+ override fun enabled(viewId: Int, rating: Rating): Boolean = true
+
+ override fun visible(viewId: Int): Boolean =
+ viewId == R.id.rating_percentage || viewId == R.id.rating_percentage_set
+
+ override fun ratingFor(viewId: Int, currentRating: Rating): Rating {
+ val percentage: Int = Integer.parseInt(percentageEditText.text.toString(), 10)
+ return PercentageRating(percentage / 100.0f)
+ }
+
+ override fun unrated(): Rating = PercentageRating()
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/RepeatModeHelper.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/RepeatModeHelper.kt
new file mode 100644
index 0000000000..a4e1e38674
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/RepeatModeHelper.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.ImageView
+import android.widget.Spinner
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.DrawableCompat
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+
+/** Helper class which handles repeat mode changes and the UI surrounding this feature. */
+class RepeatModeHelper(activity: Activity, mediaController: MediaController) {
+ private val container: ViewGroup = activity.findViewById(R.id.group_toggle_repeat)
+ private val spinner: Spinner = container.findViewById(R.id.repeat_mode_spinner)
+ private val icon: ImageView = container.findViewById(R.id.repeat_mode_icon)
+ private val modes: List =
+ listOf(Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL)
+
+ init {
+ spinner.onItemSelectedListener =
+ object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) {
+ mediaController.repeatMode = modes[p2]
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {}
+ }
+
+ val listener: Player.Listener =
+ object : Player.Listener {
+ override fun onRepeatModeChanged(repeatMode: Int) {
+ spinner.setSelection(repeatMode)
+ updateColor(repeatMode)
+ }
+
+ override fun onAvailableCommandsChanged(availableCommands: Player.Commands) =
+ updateBackground(availableCommands.contains(Player.COMMAND_SET_REPEAT_MODE))
+ }
+ mediaController.addListener(listener)
+
+ val isSupported: Boolean =
+ mediaController.availableCommands.contains(Player.COMMAND_SET_REPEAT_MODE)
+ updateBackground(isSupported)
+ }
+
+ fun updateBackground(isSupported: Boolean) {
+ if (isSupported) {
+ container.background = null
+ spinner.visibility = View.VISIBLE
+ } else {
+ container.setBackgroundResource(R.drawable.bg_unsupported_action)
+ spinner.visibility = View.GONE
+ }
+ }
+
+ fun updateColor(mode: Int) {
+ val tint: Int =
+ if (mode == Player.REPEAT_MODE_ONE || mode == Player.REPEAT_MODE_ALL) {
+ R.color.colorPrimary
+ } else {
+ R.color.colorInactive
+ }
+ DrawableCompat.setTint(icon.drawable, ContextCompat.getColor(container.context, tint))
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/SearchMediaItemsAdapter.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/SearchMediaItemsAdapter.kt
new file mode 100644
index 0000000000..00fc77310e
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/SearchMediaItemsAdapter.kt
@@ -0,0 +1,166 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaBrowser
+import androidx.media3.session.SessionCommand
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.common.collect.ImmutableList
+import com.google.common.util.concurrent.ListenableFuture
+
+class SearchMediaItemsAdapter(
+ private val activity: Activity,
+ private val mediaBrowser: MediaBrowser
+) : RecyclerView.Adapter() {
+ private var items: List = emptyList()
+
+ init {
+ val searchItemsList: RecyclerView = activity.findViewById(R.id.search_items_list)
+ searchItemsList.layoutManager = LinearLayoutManager(activity)
+ searchItemsList.setHasFixedSize(true)
+ searchItemsList.adapter = this
+
+ val searchButton: Button = activity.findViewById(R.id.search_button)
+ val queryTextView: TextView = activity.findViewById(R.id.search_query)
+
+ searchButton.setOnClickListener {
+ if (!supportSearch()) {
+ Toast.makeText(activity, R.string.command_not_supported_msg, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ val query: String = queryTextView.text.toString()
+ if (query.isEmpty()) {
+ Toast.makeText(activity, R.string.search_query_empty_msg, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ val future: ListenableFuture> = mediaBrowser.search(query, null)
+ future.addListener(
+ {
+ if (future.get().resultCode == LibraryResult.RESULT_SUCCESS) {
+ val searchFuture: ListenableFuture>> =
+ mediaBrowser.getSearchResult(
+ query,
+ /* page= */ 0,
+ /* pageSize= */ Int.MAX_VALUE,
+ /* params= */ null
+ )
+ searchFuture.addListener(
+ {
+ val mediaItems: List? = searchFuture.get().value
+ updateItems(mediaItems ?: emptyList())
+ },
+ ContextCompat.getMainExecutor(activity)
+ )
+ } else {
+ updateItems(emptyList())
+ }
+ },
+ ContextCompat.getMainExecutor(activity)
+ )
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
+ ViewHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.media_browse_item, parent, false)
+ )
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ if (items.isEmpty()) {
+ if (!supportSearch()) {
+ setMessageForEmptyList(holder, activity.getString(R.string.command_not_supported_msg))
+ } else {
+ setMessageForEmptyList(holder, activity.getString(R.string.search_result_empty))
+ }
+ return
+ }
+
+ val mediaMetadata: MediaMetadata = items[position].mediaMetadata
+ holder.name.text = mediaMetadata.title ?: "Title metadata empty"
+ holder.subtitle.text = mediaMetadata.subtitle ?: "Subtitle metadata empty"
+ holder.subtitle.visibility = View.VISIBLE
+ holder.icon.visibility = View.VISIBLE
+
+ when {
+ mediaMetadata.artworkUri != null -> {
+ holder.icon.setImageURI(mediaMetadata.artworkUri)
+ }
+ mediaMetadata.artworkData != null -> {
+ val bitmap: Bitmap =
+ BitmapFactory.decodeByteArray(
+ mediaMetadata.artworkData,
+ 0,
+ mediaMetadata.artworkData!!.size
+ )
+ holder.icon.setImageBitmap(bitmap)
+ }
+ else -> {
+ holder.icon.setImageResource(R.drawable.ic_album_black_24dp)
+ }
+ }
+
+ val item: MediaItem = items[position]
+ holder.itemView.setOnClickListener {
+ if (mediaMetadata.isPlayable == true) {
+ mediaBrowser.setMediaItem(MediaItem.Builder().setMediaId(item.mediaId).build())
+ mediaBrowser.prepare()
+ mediaBrowser.play()
+ }
+ }
+ }
+
+ override fun getItemCount(): Int {
+ if (items.isEmpty()) return 1
+ return items.size
+ }
+
+ private fun supportSearch(): Boolean =
+ mediaBrowser.availableSessionCommands.contains(SessionCommand.COMMAND_CODE_LIBRARY_SEARCH)
+
+ fun updateItems(newItems: List) {
+ items = newItems
+ notifyDataSetChanged()
+ }
+
+ private fun setMessageForEmptyList(holder: ViewHolder, message: String) {
+ holder.name.text = message
+ holder.subtitle.visibility = View.GONE
+ holder.icon.visibility = View.GONE
+ holder.itemView.setOnClickListener {}
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val name: TextView = itemView.findViewById(R.id.item_name)
+ val subtitle: TextView = itemView.findViewById(R.id.item_subtitle)
+ val icon: ImageView = itemView.findViewById(R.id.item_icon)
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/ShuffleModeHelper.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/ShuffleModeHelper.kt
new file mode 100644
index 0000000000..7e6ceb22c4
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/ShuffleModeHelper.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.ToggleButton
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.DrawableCompat
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+
+/** Helper class which handles shuffle mode changes and the UI surrounding this feature. */
+class ShuffleModeHelper(activity: Activity, mediaController: MediaController) {
+ private val container: ViewGroup = activity.findViewById(R.id.group_toggle_shuffle)
+ private val icon: ImageView = container.findViewById(R.id.shuffle_mode_icon)
+ private val shuffleButton: ToggleButton = container.findViewById(R.id.shuffle_mode_button)
+
+ init {
+ shuffleButton.setOnClickListener {
+ mediaController.shuffleModeEnabled = shuffleButton.isChecked
+ }
+ val listener: Player.Listener =
+ object : Player.Listener {
+ override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
+ shuffleButton.isChecked = shuffleModeEnabled
+ updateColor(shuffleModeEnabled)
+ }
+
+ override fun onAvailableCommandsChanged(availableCommands: Player.Commands) =
+ updateBackground(availableCommands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
+ }
+ mediaController.addListener(listener)
+
+ val isSupported: Boolean =
+ mediaController.availableCommands.contains(Player.COMMAND_SET_SHUFFLE_MODE)
+ updateBackground(isSupported)
+ val isEnabled: Boolean = mediaController.shuffleModeEnabled
+ updateColor(isEnabled)
+ shuffleButton.isChecked = isEnabled
+ }
+
+ fun updateBackground(isSupported: Boolean) {
+ if (isSupported) {
+ container.background = null
+ shuffleButton.visibility = View.VISIBLE
+ } else {
+ container.setBackgroundResource(R.drawable.bg_unsupported_action)
+ shuffleButton.visibility = View.GONE
+ }
+ }
+
+ fun updateColor(isEnabled: Boolean) {
+ val tint: Int = if (isEnabled) R.color.colorPrimary else R.color.colorInactive
+ DrawableCompat.setTint(icon.drawable, ContextCompat.getColor(container.context, tint))
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/TimelineAdapter.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/TimelineAdapter.kt
new file mode 100644
index 0000000000..1a48af6c4b
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/TimelineAdapter.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.content.res.ResourcesCompat
+import androidx.media3.common.Player
+import androidx.media3.common.Timeline
+import androidx.media3.session.MediaController
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+
+class TimelineAdapter(
+ private val activity: Activity,
+ private val mediaController: MediaController
+) : RecyclerView.Adapter() {
+ private var timeline: Timeline = mediaController.currentTimeline
+ private var currentIndex: Int = -1
+
+ init {
+ val timelineList: RecyclerView = activity.findViewById(R.id.timeline_items_list)
+ timelineList.layoutManager = LinearLayoutManager(activity)
+ timelineList.setHasFixedSize(true)
+ timelineList.adapter = this
+
+ val refreshButton: Button = activity.findViewById(R.id.refresh_button)
+ refreshButton.setOnClickListener {
+ refreshTimeline(mediaController.currentTimeline, mediaController.currentMediaItemIndex)
+ }
+
+ val listener =
+ object : Player.Listener {
+ override fun onTimelineChanged(newTimeline: Timeline, reason: Int) {
+ timeline = newTimeline
+ notifyDataSetChanged()
+ }
+
+ override fun onPositionDiscontinuity(
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int
+ ) {
+ currentIndex = newPosition.mediaItemIndex
+ notifyItemChanged(oldPosition.mediaItemIndex)
+ notifyItemChanged(newPosition.mediaItemIndex)
+ }
+
+ override fun onAvailableCommandsChanged(availableCommands: Player.Commands) {
+ notifyDataSetChanged()
+ }
+ }
+
+ mediaController.addListener(listener)
+ }
+
+ fun refreshTimeline(newTimeline: Timeline, index: Int) {
+ timeline = newTimeline
+ currentIndex = index
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
+ ViewHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.media_timeline_item, parent, false)
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val window = timeline.getWindow(position, Timeline.Window())
+ val mediaMetadata = window.mediaItem.mediaMetadata
+ holder.name.text = mediaMetadata.title ?: "Title metadata empty"
+ holder.subtitle.text = mediaMetadata.subtitle ?: "Subtitle metadata empty"
+
+ when {
+ mediaMetadata.artworkUri != null -> {
+ holder.icon.setImageURI(mediaMetadata.artworkUri)
+ }
+ mediaMetadata.artworkData != null -> {
+ val bitmap: Bitmap =
+ BitmapFactory.decodeByteArray(
+ mediaMetadata.artworkData,
+ 0,
+ mediaMetadata.artworkData!!.size
+ )
+ holder.icon.setImageBitmap(bitmap)
+ }
+ else -> {
+ holder.icon.setImageResource(R.drawable.ic_album_black_24dp)
+ }
+ }
+
+ holder.itemView.setOnClickListener { mediaController.seekToDefaultPosition(position) }
+ holder.removeButton.apply {
+ if (mediaController.availableCommands.contains(Player.COMMAND_CHANGE_MEDIA_ITEMS)) {
+ visibility = View.VISIBLE
+ setOnClickListener { mediaController.removeMediaItem(position) }
+ } else {
+ visibility = View.GONE
+ setOnClickListener {}
+ }
+ }
+
+ val colorId =
+ if (position == currentIndex) {
+ R.color.background_grey
+ } else {
+ R.color.background_transparent
+ }
+ holder.itemView.setBackgroundColor(ResourcesCompat.getColor(activity.resources, colorId, null))
+ }
+
+ override fun getItemCount(): Int = timeline.windowCount
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val name: TextView = itemView.findViewById(R.id.item_name)
+ val subtitle: TextView = itemView.findViewById(R.id.item_subtitle)
+ val icon: ImageView = itemView.findViewById(R.id.item_icon)
+ val removeButton: Button = itemView.findViewById(R.id.remove_button)
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/TransportControlHelper.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/TransportControlHelper.kt
new file mode 100644
index 0000000000..448bbefbe1
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/TransportControlHelper.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.testapp.controller
+
+import android.app.Activity
+import android.widget.ImageButton
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+
+/** Helper class which handles transport controls and the UI surrounding this feature. */
+class TransportControlHelper(activity: Activity, mediaController: MediaController) {
+ private val buttonCommandList: List>
+ init {
+ val controls =
+ listOf(
+ Control(
+ { controller: MediaController -> controller.play() },
+ activity.findViewById(R.id.action_resume),
+ Player.COMMAND_PLAY_PAUSE
+ ),
+ Control(
+ { controller: MediaController -> controller.pause() },
+ activity.findViewById(R.id.action_pause),
+ Player.COMMAND_PLAY_PAUSE
+ ),
+ Control(
+ { controller: MediaController -> controller.stop() },
+ activity.findViewById(R.id.action_stop),
+ Player.COMMAND_STOP
+ ),
+ Control(
+ { controller: MediaController -> controller.seekToNext() },
+ activity.findViewById(R.id.action_skip_next),
+ Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM
+ ),
+ Control(
+ { controller: MediaController -> controller.seekToPrevious() },
+ activity.findViewById(R.id.action_skip_previous),
+ Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
+ ),
+ Control(
+ { controller: MediaController ->
+ val positionMs: Long = controller.currentPosition
+ controller.seekTo(positionMs - 1000 * 30)
+ },
+ activity.findViewById(R.id.action_skip_30s_backward),
+ Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM
+ ),
+ Control(
+ { controller: MediaController ->
+ val positionMs: Long = controller.currentPosition
+ controller.seekTo(positionMs + 1000 * 30)
+ },
+ activity.findViewById(R.id.action_skip_30s_forward),
+ Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM
+ ),
+ Control(
+ { controller: MediaController -> controller.seekForward() },
+ activity.findViewById(R.id.action_fast_forward),
+ Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM
+ ),
+ Control(
+ { controller: MediaController -> controller.seekBack() },
+ activity.findViewById(R.id.action_fast_rewind),
+ Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM
+ )
+ )
+
+ for (control in controls) {
+ control.button.setOnClickListener { control.action(mediaController) }
+ }
+ buttonCommandList = controls.map { it.button to it.command }.toList()
+
+ updateBackground(mediaController.availableCommands)
+
+ val listener: Player.Listener =
+ object : Player.Listener {
+ override fun onAvailableCommandsChanged(availableCommands: Player.Commands) =
+ updateBackground(availableCommands)
+ }
+ mediaController.addListener(listener)
+ }
+
+ private class Control(
+ val action: (MediaController) -> Unit,
+ val button: ImageButton,
+ @Player.Command val command: Int
+ )
+
+ fun updateBackground(availableCommands: Player.Commands) =
+ buttonCommandList.forEach { (button: ImageButton, command: Int) ->
+ if (availableCommands.contains(command)) {
+ button.background = null
+ } else {
+ button.setBackgroundResource(R.drawable.bg_unsupported_action)
+ }
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindActiveMediaSessionApps.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindActiveMediaSessionApps.kt
new file mode 100644
index 0000000000..946712d2f0
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindActiveMediaSessionApps.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.testapp.controller.findapps
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.media3.testapp.controller.MediaAppDetails
+
+/**
+ * Implementation of [FindMediaApps] that uses [MediaSessionManager] to populate the list of active
+ * media session apps.
+ */
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class FindActiveMediaSessionApps
+constructor(
+ private val mediaSessionManager: MediaSessionManager,
+ private val componentName: ComponentName,
+ private val packageManager: PackageManager,
+ private val resources: Resources,
+ private val context: Context,
+ callback: AppListUpdatedCallback
+) : FindMediaApps(callback) {
+ override val mediaApps: List
+ get() {
+ return getMediaAppsFromMediaControllers(
+ mediaSessionManager.getActiveSessions(componentName),
+ packageManager,
+ resources
+ )
+ }
+
+ private fun getMediaAppsFromMediaControllers(
+ sessionTokens: List,
+ packageManager: PackageManager,
+ resources: Resources
+ ): List {
+ return sessionTokens.map {
+ MediaAppDetails.create(packageManager, resources, controller = it, context)
+ }
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaApps.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaApps.kt
new file mode 100644
index 0000000000..6c3a63d913
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaApps.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.testapp.controller.findapps
+
+import androidx.media3.testapp.controller.MediaAppDetails
+
+/** Base class that fetches a list of media apps. */
+abstract class FindMediaApps(private val callback: AppListUpdatedCallback) {
+
+ /** Callback used by [FindMediaApps]. */
+ interface AppListUpdatedCallback {
+ fun onAppListUpdated(mediaAppEntries: List)
+ }
+
+ protected abstract val mediaApps: List
+
+ fun execute() {
+ callback.onAppListUpdated(mediaApps)
+ }
+}
diff --git a/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaServiceApps.kt b/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaServiceApps.kt
new file mode 100644
index 0000000000..9a5dbb0546
--- /dev/null
+++ b/testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaServiceApps.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.testapp.controller.findapps
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import androidx.media3.session.SessionToken
+import androidx.media3.testapp.controller.MediaAppDetails
+
+/**
+ * Implementation of [FindMediaApps] that uses [MediaSessionManager] to populate the list of media
+ * service apps.
+ */
+class FindMediaServiceApps
+constructor(
+ private val context: Context,
+ private val packageManager: PackageManager,
+ private val resources: Resources,
+ callback: AppListUpdatedCallback
+) : FindMediaApps(callback) {
+
+ override val mediaApps: List
+ get() {
+ return getMediaAppsFromSessionTokens(
+ SessionToken.getAllServiceTokens(context),
+ packageManager,
+ resources
+ )
+ }
+
+ private fun getMediaAppsFromSessionTokens(
+ sessionTokens: Set,
+ packageManager: PackageManager,
+ resources: Resources
+ ): List {
+ return sessionTokens.map {
+ MediaAppDetails.create(packageManager, resources, sessionToken = it)
+ }
+ }
+}
diff --git a/testapps/controller/src/main/proguard-rules.txt b/testapps/controller/src/main/proguard-rules.txt
new file mode 120000
index 0000000000..499fb08b36
--- /dev/null
+++ b/testapps/controller/src/main/proguard-rules.txt
@@ -0,0 +1 @@
+../../proguard-rules.txt
\ No newline at end of file
diff --git a/testapps/controller/src/main/res/color/bottom_navigation_item_tint.xml b/testapps/controller/src/main/res/color/bottom_navigation_item_tint.xml
new file mode 100644
index 0000000000..4d1d3d5ae1
--- /dev/null
+++ b/testapps/controller/src/main/res/color/bottom_navigation_item_tint.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/testapps/controller/src/main/res/drawable/bg_unsupported_action.xml b/testapps/controller/src/main/res/drawable/bg_unsupported_action.xml
new file mode 100644
index 0000000000..3bb7774751
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/bg_unsupported_action.xml
@@ -0,0 +1,34 @@
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_album_black_24dp.xml b/testapps/controller/src/main/res/drawable/ic_album_black_24dp.xml
new file mode 100644
index 0000000000..27cb0f7ef5
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_album_black_24dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/testapps/controller/src/main/res/drawable/ic_fast_forward_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_fast_forward_black_32dp.xml
new file mode 100644
index 0000000000..e59ddb7712
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_fast_forward_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_fast_rewind_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_fast_rewind_black_32dp.xml
new file mode 100644
index 0000000000..3db400c0c9
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_fast_rewind_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_forward_30_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_forward_30_black_32dp.xml
new file mode 100644
index 0000000000..5b8ef3ee01
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_forward_30_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_heart_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_heart_black_32dp.xml
new file mode 100644
index 0000000000..2766fd808c
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_heart_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_no_apps_black_24dp.xml b/testapps/controller/src/main/res/drawable/ic_no_apps_black_24dp.xml
new file mode 100644
index 0000000000..36c42dbd8c
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_no_apps_black_24dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_pause_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_pause_black_32dp.xml
new file mode 100644
index 0000000000..ead8ddf1f5
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_pause_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_play_arrow_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_play_arrow_black_32dp.xml
new file mode 100644
index 0000000000..4678fedcac
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_play_arrow_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_repeat_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_repeat_black_32dp.xml
new file mode 100644
index 0000000000..d5aba46b43
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_repeat_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_replay_30_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_replay_30_black_32dp.xml
new file mode 100644
index 0000000000..6ead43623b
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_replay_30_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_shuffle_toggle_32dp.xml b/testapps/controller/src/main/res/drawable/ic_shuffle_toggle_32dp.xml
new file mode 100644
index 0000000000..ade14b1cc1
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_shuffle_toggle_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_skip_next_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_skip_next_black_32dp.xml
new file mode 100644
index 0000000000..c718413b01
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_skip_next_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_skip_previous_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_skip_previous_black_32dp.xml
new file mode 100644
index 0000000000..c1205668f4
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_skip_previous_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_star_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_star_black_32dp.xml
new file mode 100644
index 0000000000..65a6577367
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_star_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_stop_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_stop_black_32dp.xml
new file mode 100644
index 0000000000..13a30e66d3
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_stop_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_test.xml b/testapps/controller/src/main/res/drawable/ic_test.xml
new file mode 100644
index 0000000000..cb48b041f4
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_test.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_test_suite.xml b/testapps/controller/src/main/res/drawable/ic_test_suite.xml
new file mode 100644
index 0000000000..b7ac049e49
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_test_suite.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_thumb_down_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_thumb_down_black_32dp.xml
new file mode 100644
index 0000000000..2fb66108b4
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_thumb_down_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/ic_thumb_up_black_32dp.xml b/testapps/controller/src/main/res/drawable/ic_thumb_up_black_32dp.xml
new file mode 100644
index 0000000000..c41ec98795
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/ic_thumb_up_black_32dp.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/tab_indicator.xml b/testapps/controller/src/main/res/drawable/tab_indicator.xml
new file mode 100644
index 0000000000..1c39481b5f
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/tab_indicator.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/drawable/test_result_divider.xml b/testapps/controller/src/main/res/drawable/test_result_divider.xml
new file mode 100644
index 0000000000..8875dd75ba
--- /dev/null
+++ b/testapps/controller/src/main/res/drawable/test_result_divider.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/testapps/controller/src/main/res/layout/activity_launch.xml b/testapps/controller/src/main/res/layout/activity_launch.xml
new file mode 100644
index 0000000000..1cbef60ccf
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/activity_launch.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/activity_media_app_controller.xml b/testapps/controller/src/main/res/layout/activity_media_app_controller.xml
new file mode 100644
index 0000000000..8914f5669b
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/activity_media_app_controller.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_app_item.xml b/testapps/controller/src/main/res/layout/media_app_item.xml
new file mode 100644
index 0000000000..2fd26695fb
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_app_item.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_app_list_error.xml b/testapps/controller/src/main/res/layout/media_app_list_error.xml
new file mode 100644
index 0000000000..0d4ae34599
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_app_list_error.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_app_list_header.xml b/testapps/controller/src/main/res/layout/media_app_list_header.xml
new file mode 100644
index 0000000000..9b3dc71a77
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_app_list_header.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_browse_item.xml b/testapps/controller/src/main/res/layout/media_browse_item.xml
new file mode 100644
index 0000000000..9555948bcd
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_browse_item.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_browse_tree.xml b/testapps/controller/src/main/res/layout/media_browse_tree.xml
new file mode 100644
index 0000000000..cc762222b8
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_browse_tree.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_controls.xml b/testapps/controller/src/main/res/layout/media_controls.xml
new file mode 100644
index 0000000000..ccfac5aa4b
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_controls.xml
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_custom_command.xml b/testapps/controller/src/main/res/layout/media_custom_command.xml
new file mode 100644
index 0000000000..884e8fd11d
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_custom_command.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_custom_commands.xml b/testapps/controller/src/main/res/layout/media_custom_commands.xml
new file mode 100644
index 0000000000..ff7448242f
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_custom_commands.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/testapps/controller/src/main/res/layout/media_info.xml b/testapps/controller/src/main/res/layout/media_info.xml
new file mode 100644
index 0000000000..ecbb854f2f
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_info.xml
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_ratings.xml b/testapps/controller/src/main/res/layout/media_ratings.xml
new file mode 100644
index 0000000000..80eec438ee
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_ratings.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_search_controls.xml b/testapps/controller/src/main/res/layout/media_search_controls.xml
new file mode 100644
index 0000000000..fa9b6cbfc6
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_search_controls.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_test_suites.xml b/testapps/controller/src/main/res/layout/media_test_suites.xml
new file mode 100644
index 0000000000..0e4f1041cd
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_test_suites.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_tests.xml b/testapps/controller/src/main/res/layout/media_tests.xml
new file mode 100644
index 0000000000..25dfd099b9
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_tests.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_timeline.xml b/testapps/controller/src/main/res/layout/media_timeline.xml
new file mode 100644
index 0000000000..3e41933e08
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_timeline.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/layout/media_timeline_item.xml b/testapps/controller/src/main/res/layout/media_timeline_item.xml
new file mode 100644
index 0000000000..5eb8685455
--- /dev/null
+++ b/testapps/controller/src/main/res/layout/media_timeline_item.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/menu/bottom_navigation_menu.xml b/testapps/controller/src/main/res/menu/bottom_navigation_menu.xml
new file mode 100644
index 0000000000..0af40107fb
--- /dev/null
+++ b/testapps/controller/src/main/res/menu/bottom_navigation_menu.xml
@@ -0,0 +1,31 @@
+
+
+
diff --git a/testapps/controller/src/main/res/mipmap-hdpi/ic_launcher.png b/testapps/controller/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/testapps/controller/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/testapps/controller/src/main/res/mipmap-mdpi/ic_launcher.png b/testapps/controller/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/testapps/controller/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/testapps/controller/src/main/res/mipmap-xhdpi/ic_launcher.png b/testapps/controller/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/testapps/controller/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/testapps/controller/src/main/res/mipmap-xxhdpi/ic_launcher.png b/testapps/controller/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/testapps/controller/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/testapps/controller/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/testapps/controller/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/testapps/controller/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/testapps/controller/src/main/res/values/colors.xml b/testapps/controller/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..a69557c72f
--- /dev/null
+++ b/testapps/controller/src/main/res/values/colors.xml
@@ -0,0 +1,36 @@
+
+
+
+ #3f51b5
+ #303f9f
+ #ff4081
+ #a0a0a0
+
+ #ff8080
+ #80ff8080
+
+ #e0e0e0
+ #00000000
+
+ #101010
+ #201E1E
+
+ #fff
+
+ #FF8483
+ #92FF8F
+ #FFDC00
+
diff --git a/testapps/controller/src/main/res/values/dimens.xml b/testapps/controller/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..0ccbb79b00
--- /dev/null
+++ b/testapps/controller/src/main/res/values/dimens.xml
@@ -0,0 +1,45 @@
+
+
+
+
+ 16dp
+ 16dp
+
+ 8dp
+ 16dp
+
+ 4dp
+ 8dp
+ 32dp
+
+ 24dp
+
+ 14sp
+
+ 48dp
+ 18sp
+ 12sp
+ 48dp
+ 96dp
+ 72dp
+
+ 18sp
+ 12sp
+
+ 28sp
+ 24sp
+ 20sp
+
diff --git a/testapps/controller/src/main/res/values/options.xml b/testapps/controller/src/main/res/values/options.xml
new file mode 100644
index 0000000000..8c8442b430
--- /dev/null
+++ b/testapps/controller/src/main/res/values/options.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+ - Search
+ - Media ID
+ - URI
+ - No Input
+
+
+
+
+
+ - GAIN
+ - GAIN_TRANSIENT
+ - GAIN_TRANSIENT_MAY_DUCK
+
+
+
+
+
+ - None
+ - One
+ - All
+
+
+
\ No newline at end of file
diff --git a/testapps/controller/src/main/res/values/strings.xml b/testapps/controller/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..3860c53652
--- /dev/null
+++ b/testapps/controller/src/main/res/values/strings.xml
@@ -0,0 +1,105 @@
+
+
+
+ Media3 Controller Test
+ App icon for %1$s.
+ No Media Apps Found
+ Could not locate any apps with media services.
+ Could not locate any apps with active media sessions.
+ Notification Listener permission is required to scan for active media sessions.
+ Settings
+
+ Media Service Implementations
+ Active MediaSessions
+
+ URI, Media ID, or Query
+
+ Control
+ Test
+
+ Couldn\'t update MediaAppDetails object
+ Failed to create a MediaController from session token
+
+ Prepare
+ Play
+
+ Audio Focus
+ Gain
+ Abandon
+
+ Empty Media Info
+
+ PlaybackState
+ Title
+ Artist
+ Album
+ PlayWhenReady
+
+ Album art
+
+ Pause
+ Resume
+ Stop
+ Previous
+ Next
+ Fast forward
+ Rewind
+
+ Skip 30s
+ Skip back 30s
+
+ Set shuffle mode
+ Set repeat mode
+
+ Shuffle
+ None
+
+ Thumb Up
+ Thumb Down
+
+ Heart
+
+ 1 star
+ 2 stars
+ 3 stars
+ 4 stars
+ 5 stars
+
+ Rating %
+ Set
+
+ App-provided Custom Commands
+ Send custom command
+
+ Browse to top
+ Browse up
+ App-provided MediaItems
+ Empty tree
+
+ MediaController disconnected
+ Failed connecting browser: %s
+
+ Query
+ Query is empty
+ Search
+ Result empty
+
+ MediaItems in Timeline
+ Refresh
+ Remove
+
+ Command not supported
+
diff --git a/testapps/controller/src/main/res/values/styles.xml b/testapps/controller/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..467b62ecd6
--- /dev/null
+++ b/testapps/controller/src/main/res/values/styles.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testapps/controller/src/main/res/values/test_strings.xml b/testapps/controller/src/main/res/values/test_strings.xml
new file mode 100644
index 0000000000..f73d4b01f4
--- /dev/null
+++ b/testapps/controller/src/main/res/values/test_strings.xml
@@ -0,0 +1,56 @@
+
+
+
+
+ Tests
+ Results
+ Query
+ Configure
+ Run Test
+ "Query is required but empty"
+ Test is running
+ Tests
+ Test Suites
+
+
+ Test Suites
+ # Iterations
+ 1
+ Run Suite
+ Done
+ Reset
+ Cancel Suite
+ Close
+ Invalid iteration number, must be between (1..10).
+ %1$s Configuration
+ Suite is running…
+ Passing: (%d/%d)
+ Failing Logs:
+ Passing Logs:
+ --- Iteration ---
+
+
+ Play Test
+ Pause Test
+ Stop Test
+ Play From Search Test
+
+
+ This tests the \'play\' functionality.
+ This tests the \'pause\' functionality.
+ This tests the \'stop\' functionality.
+ This tests the \'play from search\' functionality. Enter the search query in the query field.
+
\ No newline at end of file