From f54991eea103ae7ee30e9e217ca22bddfd6cd8da Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 11 Jun 2024 08:24:31 -0700 Subject: [PATCH] Publish the media3 session controller test app Issue: androidx/media#78 PiperOrigin-RevId: 642277900 --- RELEASENOTES.md | 2 + settings.gradle | 4 + testapps/README.md | 5 + testapps/controller/README.md | 4 + testapps/controller/build.gradle | 70 ++++ testapps/controller/lint.xml | 20 ++ testapps/controller/proguard-rules.txt | 2 + .../controller/src/main/AndroidManifest.xml | 76 ++++ .../testapp/controller/AudioFocusHelper.kt | 93 +++++ .../media3/testapp/controller/BitmapUtils.kt | 100 ++++++ .../controller/BrowseMediaItemsAdapter.kt | 200 +++++++++++ .../controller/CustomCommandsAdapter.kt | 98 +++++ .../testapp/controller/LaunchActivity.kt | 187 ++++++++++ .../controller/MediaAppControllerActivity.kt | 340 ++++++++++++++++++ .../testapp/controller/MediaAppDetails.kt | 128 +++++++ .../testapp/controller/MediaAppListAdapter.kt | 221 ++++++++++++ .../testapp/controller/MediaIntToString.kt | 74 ++++ .../testapp/controller/PreparePlayHelper.kt | 72 ++++ .../media3/testapp/controller/RatingHelper.kt | 281 +++++++++++++++ .../testapp/controller/RepeatModeHelper.kt | 83 +++++ .../controller/SearchMediaItemsAdapter.kt | 166 +++++++++ .../testapp/controller/ShuffleModeHelper.kt | 72 ++++ .../testapp/controller/TimelineAdapter.kt | 140 ++++++++ .../controller/TransportControlHelper.kt | 111 ++++++ .../findapps/FindActiveMediaSessionApps.kt | 60 ++++ .../controller/findapps/FindMediaApps.kt | 33 ++ .../findapps/FindMediaServiceApps.kt | 54 +++ .../controller/src/main/proguard-rules.txt | 1 + .../res/color/bottom_navigation_item_tint.xml | 19 + .../res/drawable/bg_unsupported_action.xml | 34 ++ .../main/res/drawable/ic_album_black_24dp.xml | 24 ++ .../drawable/ic_fast_forward_black_32dp.xml | 24 ++ .../drawable/ic_fast_rewind_black_32dp.xml | 24 ++ .../res/drawable/ic_forward_30_black_32dp.xml | 24 ++ .../main/res/drawable/ic_heart_black_32dp.xml | 24 ++ .../res/drawable/ic_no_apps_black_24dp.xml | 24 ++ .../main/res/drawable/ic_pause_black_32dp.xml | 24 ++ .../res/drawable/ic_play_arrow_black_32dp.xml | 24 ++ .../res/drawable/ic_repeat_black_32dp.xml | 24 ++ .../res/drawable/ic_replay_30_black_32dp.xml | 24 ++ .../res/drawable/ic_shuffle_toggle_32dp.xml | 24 ++ .../res/drawable/ic_skip_next_black_32dp.xml | 24 ++ .../drawable/ic_skip_previous_black_32dp.xml | 24 ++ .../main/res/drawable/ic_star_black_32dp.xml | 24 ++ .../main/res/drawable/ic_stop_black_32dp.xml | 24 ++ .../src/main/res/drawable/ic_test.xml | 26 ++ .../src/main/res/drawable/ic_test_suite.xml | 26 ++ .../res/drawable/ic_thumb_down_black_32dp.xml | 24 ++ .../res/drawable/ic_thumb_up_black_32dp.xml | 24 ++ .../src/main/res/drawable/tab_indicator.xml | 37 ++ .../main/res/drawable/test_result_divider.xml | 20 ++ .../src/main/res/layout/activity_launch.xml | 57 +++ .../layout/activity_media_app_controller.xml | 86 +++++ .../src/main/res/layout/media_app_item.xml | 76 ++++ .../main/res/layout/media_app_list_error.xml | 69 ++++ .../main/res/layout/media_app_list_header.xml | 37 ++ .../src/main/res/layout/media_browse_item.xml | 58 +++ .../src/main/res/layout/media_browse_tree.xml | 60 ++++ .../src/main/res/layout/media_controls.xml | 275 ++++++++++++++ .../main/res/layout/media_custom_command.xml | 61 ++++ .../main/res/layout/media_custom_commands.xml | 37 ++ .../src/main/res/layout/media_info.xml | 118 ++++++ .../src/main/res/layout/media_ratings.xml | 102 ++++++ .../main/res/layout/media_search_controls.xml | 59 +++ .../src/main/res/layout/media_test_suites.xml | 87 +++++ .../src/main/res/layout/media_tests.xml | 77 ++++ .../src/main/res/layout/media_timeline.xml | 44 +++ .../main/res/layout/media_timeline_item.xml | 76 ++++ .../main/res/menu/bottom_navigation_menu.xml | 31 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3394 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2184 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4886 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7492 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10801 bytes .../controller/src/main/res/values/colors.xml | 36 ++ .../controller/src/main/res/values/dimens.xml | 45 +++ .../src/main/res/values/options.xml | 41 +++ .../src/main/res/values/strings.xml | 105 ++++++ .../controller/src/main/res/values/styles.xml | 59 +++ .../src/main/res/values/test_strings.xml | 56 +++ 80 files changed, 5019 insertions(+) create mode 100644 testapps/README.md create mode 100644 testapps/controller/README.md create mode 100644 testapps/controller/build.gradle create mode 100644 testapps/controller/lint.xml create mode 100644 testapps/controller/proguard-rules.txt create mode 100644 testapps/controller/src/main/AndroidManifest.xml create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/AudioFocusHelper.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/BitmapUtils.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/BrowseMediaItemsAdapter.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/CustomCommandsAdapter.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/LaunchActivity.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppControllerActivity.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppDetails.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaAppListAdapter.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/MediaIntToString.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/PreparePlayHelper.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/RatingHelper.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/RepeatModeHelper.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/SearchMediaItemsAdapter.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/ShuffleModeHelper.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/TimelineAdapter.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/TransportControlHelper.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindActiveMediaSessionApps.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaApps.kt create mode 100644 testapps/controller/src/main/java/androidx/media3/testapp/controller/findapps/FindMediaServiceApps.kt create mode 120000 testapps/controller/src/main/proguard-rules.txt create mode 100644 testapps/controller/src/main/res/color/bottom_navigation_item_tint.xml create mode 100644 testapps/controller/src/main/res/drawable/bg_unsupported_action.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_album_black_24dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_fast_forward_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_fast_rewind_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_forward_30_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_heart_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_no_apps_black_24dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_pause_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_play_arrow_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_repeat_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_replay_30_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_shuffle_toggle_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_skip_next_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_skip_previous_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_star_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_stop_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_test.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_test_suite.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_thumb_down_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/ic_thumb_up_black_32dp.xml create mode 100644 testapps/controller/src/main/res/drawable/tab_indicator.xml create mode 100644 testapps/controller/src/main/res/drawable/test_result_divider.xml create mode 100644 testapps/controller/src/main/res/layout/activity_launch.xml create mode 100644 testapps/controller/src/main/res/layout/activity_media_app_controller.xml create mode 100644 testapps/controller/src/main/res/layout/media_app_item.xml create mode 100644 testapps/controller/src/main/res/layout/media_app_list_error.xml create mode 100644 testapps/controller/src/main/res/layout/media_app_list_header.xml create mode 100644 testapps/controller/src/main/res/layout/media_browse_item.xml create mode 100644 testapps/controller/src/main/res/layout/media_browse_tree.xml create mode 100644 testapps/controller/src/main/res/layout/media_controls.xml create mode 100644 testapps/controller/src/main/res/layout/media_custom_command.xml create mode 100644 testapps/controller/src/main/res/layout/media_custom_commands.xml create mode 100644 testapps/controller/src/main/res/layout/media_info.xml create mode 100644 testapps/controller/src/main/res/layout/media_ratings.xml create mode 100644 testapps/controller/src/main/res/layout/media_search_controls.xml create mode 100644 testapps/controller/src/main/res/layout/media_test_suites.xml create mode 100644 testapps/controller/src/main/res/layout/media_tests.xml create mode 100644 testapps/controller/src/main/res/layout/media_timeline.xml create mode 100644 testapps/controller/src/main/res/layout/media_timeline_item.xml create mode 100644 testapps/controller/src/main/res/menu/bottom_navigation_menu.xml create mode 100644 testapps/controller/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 testapps/controller/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 testapps/controller/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 testapps/controller/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 testapps/controller/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 testapps/controller/src/main/res/values/colors.xml create mode 100644 testapps/controller/src/main/res/values/dimens.xml create mode 100644 testapps/controller/src/main/res/values/options.xml create mode 100644 testapps/controller/src/main/res/values/strings.xml create mode 100644 testapps/controller/src/main/res/values/styles.xml create mode 100644 testapps/controller/src/main/res/values/test_strings.xml 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 @@ + + + + + + + + + + + + + +