构建车载媒体应用

Android Auto 和 Android Automotive OS 可帮助您将媒体应用内容带给用户,让他们在车内使用。汽车媒体应用必须提供媒体浏览器服务,以便 Android Auto 和 Android Automotive OS 或其他具有媒体浏览器的应用可以发现和显示您的内容。

本指南假设您已经拥有一个在手机上播放音频的媒体应用,并且您的媒体应用符合 Android 媒体应用架构

本指南描述了您的应用在 Android Auto 或 Android Automotive OS 上运行所需的 MediaBrowserServiceMediaSession 组件。完成核心媒体基础架构后,您可以 添加对 Android Auto 的支持添加对 Android Automotive OS 的支持 到您的媒体应用中。

开始之前

  1. 查看 Android 媒体 API 文档
  2. 查看 创建媒体应用 以获取设计指南。
  3. 查看本节中列出的关键术语和概念。

关键术语和概念

媒体浏览器服务
由您的媒体应用实现的、符合MediaBrowserServiceCompat API 的 Android 服务。您的应用使用此服务来公开其内容。
媒体浏览器
媒体应用用来发现媒体浏览器服务并显示其内容的 API。Android Auto 和 Android Automotive OS 使用媒体浏览器来查找您的应用的媒体浏览器服务。
媒体项目

媒体浏览器将其内容组织成 MediaItem 对象的树形结构。媒体项目可以具有以下一个或两个标志

  • FLAG_PLAYABLE:指示该项目是内容树上的叶子节点。该项目表示单个音流,例如专辑中的歌曲、有声读物中的章节或播客的剧集。
  • FLAG_BROWSABLE:指示该项目是内容树上的节点,并且它具有子节点。例如,该项目表示专辑,其子节点是专辑中的歌曲。

既可浏览又可播放的媒体项目充当播放列表。您可以选择该项目本身来播放其所有子节点,或者可以浏览其子节点。

车辆优化

适用于 Android Automotive OS 应用的活动,其遵循 Android Automotive OS 设计指南。这些活动的界面不是由 Android Automotive OS 绘制的,因此您必须确保您的应用遵循设计指南。通常,这包括更大的点击目标和字体大小、对昼夜模式的支持以及更高的对比度。

仅当汽车用户体验限制 (CUXR) 不生效时,才允许显示车辆优化用户界面,因为这些界面可能需要用户长时间关注或交互。当汽车停止或停放时,CUXR 不生效,但在汽车行驶时始终生效。

您不需要为 Android Auto 设计活动,因为 Android Auto 使用来自您的媒体浏览器服务的信息绘制其自己的车辆优化界面。

配置应用的清单文件

在创建媒体浏览器服务之前,您需要配置应用的 清单文件

声明您的媒体浏览器服务

Android Auto 和 Android Automotive OS 都通过您的媒体浏览器服务连接到您的应用以浏览媒体项目。在清单中声明您的媒体浏览器服务,以便 Android Auto 和 Android Automotive OS 发现该服务并连接到您的应用。

以下代码片段显示了如何在清单中声明您的媒体浏览器服务。将此代码包含在 Android Automotive OS 模块的清单文件中,以及在您的手机应用的清单文件中。

<application>
    ...
    <service android:name=".MyMediaBrowserService"
             android:exported="true">
        <intent-filter>
            <action android:name="android.media.browse.MediaBrowserService"/>
        </intent-filter>
    </service>
    ...
</application>

指定应用图标

您需要指定 Android Auto 和 Android Automotive OS 可以用来在系统 UI 中表示您的应用的应用图标。需要两种图标类型

  • 启动器图标
  • 归属图标

启动器图标

启动器图标在系统 UI 中表示您的应用,例如在启动器和图标托盘中。您可以指定要使用手机应用中的图标来表示您的汽车媒体应用,使用以下清单声明

<application
    ...
    android:icon="@mipmap/ic_launcher"
    ...
/>

要使用与手机应用不同的图标,请在清单中媒体浏览器服务的 <service> 元素上设置 android:icon 属性

<application>
    ...
    <service
        ...
        android:icon="@mipmap/auto_launcher"
        ...
    />
</application>

归属图标

图 1. 媒体卡片上的归属图标。

归属图标用于媒体内容优先的位置,例如媒体卡片。考虑重新使用用于通知的小图标。此图标必须是单色的。您可以指定用于表示您的应用的图标,使用以下清单声明

<application>
    ...
    <meta-data
        android:name="androidx.car.app.TintableAttributionIcon"
        android:resource="@drawable/ic_status_icon" />
    ...
</application>

创建媒体浏览器服务

您可以通过扩展 MediaBrowserServiceCompat 类来创建媒体浏览器服务。然后,Android Auto 和 Android Automotive OS 都可以使用您的服务执行以下操作

  • 浏览应用的内容层次结构以向用户呈现菜单。
  • 获取应用的 MediaSessionCompat 对象的令牌以控制音频播放。

您还可以使用媒体浏览器服务让其他客户端访问您的应用中的媒体内容。这些媒体客户端可能是用户手机上的其他应用,也可能是其他远程客户端。

媒体浏览器服务工作流程

本节介绍 Android Automotive OS 和 Android Auto 在典型用户工作流程期间如何与您的媒体浏览器服务交互。

  1. 用户在 Android Automotive OS 或 Android Auto 上启动您的应用。
  2. Android Automotive OS 或 Android Auto 使用 onCreate() 方法联系您的应用的媒体浏览器服务。在您对 onCreate() 方法的实现中,您必须创建并注册 MediaSessionCompat 对象及其回调对象。
  3. Android Automotive OS 或 Android Auto 调用服务的 onGetRoot() 方法以获取内容层次结构中的根媒体项目。根媒体项目不会显示;相反,它用于从您的应用检索更多内容。
  4. Android Automotive OS 或 Android Auto 调用服务的 onLoadChildren() 方法以获取根媒体项目的子节点。Android Automotive OS 和 Android Auto 将这些媒体项目显示为内容项目的顶层。有关系统在此级别期望的内容,请参阅此页面上的 构建根菜单结构
  5. 如果用户选择了一个可浏览的媒体项目,则会再次调用服务的 onLoadChildren() 方法以检索所选菜单项目的子节点。
  6. 如果用户选择了一个可播放的媒体项目,则 Android Automotive OS 或 Android Auto 会调用相应的媒体会话回调方法来执行该操作。
  7. 如果您的应用支持,用户还可以搜索您的内容。在这种情况下,Android Automotive OS 或 Android Auto 会调用服务的 onSearch() 方法。

构建内容层次结构

Android Auto 和 Android Automotive OS 调用您的应用的媒体浏览器服务以了解哪些内容可用。您需要在媒体浏览器服务中实现两种方法来支持此功能:onGetRoot()onLoadChildren()

实现 onGetRoot

服务的 onGetRoot() 方法返回有关内容层次结构的根节点的信息。Android Auto 和 Android Automotive OS 使用此根节点来使用 onLoadChildren() 方法请求其余内容。

以下代码片段显示了 onGetRoot() 方法的简单实现

Kotlin

override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle?
): BrowserRoot? =
    // Verify that the specified package is allowed to access your
    // content. You'll need to write your own logic to do this.
    if (!isValid(clientPackageName, clientUid)) {
        // If the request comes from an untrusted package, return null.
        // No further calls will be made to other media browsing methods.

        null
    } else MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null)

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {

    // Verify that the specified package is allowed to access your
    // content. You'll need to write your own logic to do this.
    if (!isValid(clientPackageName, clientUid)) {
        // If the request comes from an untrusted package, return null.
        // No further calls will be made to other media browsing methods.

        return null;
    }

    return new MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null);
}

有关此方法的更详细示例,请参阅 GitHub 上的通用 Android 音乐播放器示例应用中的 onGetRoot() 方法。

为 onGetRoot() 添加包验证

当调用服务的 onGetRoot() 方法时,调用方包会将标识信息传递给您的服务。您的服务可以使用此信息来确定该包是否可以访问您的内容。例如,您可以通过将 clientPackageName 与您的允许列表进行比较并验证用于签署包的 APK 的证书来限制对应用内容的访问。如果无法验证包,则返回 null 以拒绝访问您的内容。

要为系统应用(例如 Android Auto 和 Android Automotive OS)提供对内容的访问权限,当这些系统应用调用 onGetRoot() 方法时,您的服务必须始终返回非空 BrowserRoot。Android Automotive OS 系统应用的签名可能会因汽车的制造商和型号而异,因此您需要允许来自所有系统应用的连接以可靠地支持 Android Automotive OS。

以下代码片段显示了您的服务如何验证调用方包是否是系统应用

fun isKnownCaller(
    callingPackage: String,
    callingUid: Int
): Boolean {
    ...
    val isCallerKnown = when {
       // If the system is making the call, allow it.
       callingUid == Process.SYSTEM_UID -> true
       // If the app was signed by the same certificate as the platform
       // itself, also allow it.
       callerSignature == platformSignature -> true
       // ... more cases
    }
    return isCallerKnown
}

此代码片段摘自 GitHub 上的通用 Android 音乐播放器示例应用中的 PackageValidator 类。有关如何为服务的 onGetRoot() 方法实现包验证的更详细示例,请参阅该类。

除了允许系统应用外,您还必须允许 Google 助理连接到您的 MediaBrowserService。请注意,Google 助理对手机(包括 Android Auto)和 Android Automotive OS 具有 单独的包名称

实现 onLoadChildren()

在收到根节点对象后,Android Auto 和 Android Automotive OS 会通过在根节点对象上调用 onLoadChildren() 来获取其子节点,从而构建顶级菜单。客户端应用通过使用子节点对象调用相同的方法来构建子菜单。

内容层次结构中的每个节点都由 MediaBrowserCompat.MediaItem 对象表示。这些媒体项目中的每一个都由唯一的 ID 字符串标识。客户端应用将这些 ID 字符串视为不透明令牌。当客户端应用想要浏览到子菜单或播放媒体项目时,它会传递令牌。您的应用负责将令牌与相应的媒体项目关联。

以下代码片段显示了onLoadChildren()方法的简单实现。

Kotlin

override fun onLoadChildren(
    parentMediaId: String,
    result: Result<List<MediaBrowserCompat.MediaItem>>
) {
    // Assume for example that the music catalog is already loaded/cached.

    val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()

    // Check whether this is the root menu:
    if (MY_MEDIA_ROOT_ID == parentMediaId) {

        // Build the MediaItem objects for the top level
        // and put them in the mediaItems list.
    } else {

        // Examine the passed parentMediaId to see which submenu we're at
        // and put the children of that menu in the mediaItems list.
    }
    result.sendResult(mediaItems)
}

Java

@Override
public void onLoadChildren(final String parentMediaId,
    final Result<List<MediaBrowserCompat.MediaItem>> result) {

    // Assume for example that the music catalog is already loaded/cached.

    List<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();

    // Check whether this is the root menu:
    if (MY_MEDIA_ROOT_ID.equals(parentMediaId)) {

        // Build the MediaItem objects for the top level
        // and put them in the mediaItems list.
    } else {

        // Examine the passed parentMediaId to see which submenu we're at
        // and put the children of that menu in the mediaItems list.
    }
    result.sendResult(mediaItems);
}

有关此方法的完整示例,请参阅 GitHub 上的通用 Android 音乐播放器示例应用中的onLoadChildren()方法。

构建根菜单结构

图 2. 以导航标签形式显示的根内容。

Android Auto 和 Android Automotive OS 对根菜单的结构有特定的约束。这些约束通过根提示传达给MediaBrowserService,可以通过传递给onGetRoot()Bundle参数读取。遵循这些提示可以让系统以导航标签的形式以最佳方式显示根内容。如果您不遵循这些提示,系统可能会删除一些根内容或降低其可发现性。发送两个提示

使用以下代码读取相关的根提示

Kotlin

import androidx.media.utils.MediaConstants

// Later, in your MediaBrowserServiceCompat.
override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle
): BrowserRoot {

  val maximumRootChildLimit = rootHints.getInt(
      MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
      /* defaultValue= */ 4)
  val supportedRootChildFlags = rootHints.getInt(
      MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
      /* defaultValue= */ MediaItem.FLAG_BROWSABLE)

  // Rest of method...
}

Java

import androidx.media.utils.MediaConstants;

// Later, in your MediaBrowserServiceCompat.
@Override
public BrowserRoot onGetRoot(
    String clientPackageName, int clientUid, Bundle rootHints) {

    int maximumRootChildLimit = rootHints.getInt(
        MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
        /* defaultValue= */ 4);
    int supportedRootChildFlags = rootHints.getInt(
        MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
        /* defaultValue= */ MediaItem.FLAG_BROWSABLE);

    // Rest of method...
}

您可以根据这些提示的值选择分支内容层次结构结构的逻辑,尤其是在您的层次结构在 Android Auto 和 Android Automotive OS 之外的MediaBrowser集成之间有所不同时。例如,如果您通常显示可播放的根项目,则可能需要将其嵌套在可浏览的根项目下,这取决于支持的标志提示的值。

除了根提示之外,还有几个其他准则需要遵循,以帮助确保标签以最佳方式呈现

  • 为每个标签项目提供单色(最好是白色)图标。
  • 为每个标签项目提供简短但有意义的标签。保持标签简短可以减少字符串被截断的可能性。

显示媒体插图

媒体项目的插图必须作为本地 URI 传递,使用ContentResolver.SCHEME_CONTENTContentResolver.SCHEME_ANDROID_RESOURCE。此本地 URI 必须解析为应用程序资源中的位图或矢量可绘制对象。对于表示内容层次结构中项目的MediaDescriptionCompat对象,请通过setIconUri()传递 URI。对于表示当前播放项目的MediaMetadataCompat对象,请通过putString()传递 URI,使用以下任何键

以下步骤描述了如何从 Web URI 下载插图并通过本地 URI 公开它。有关更完整的示例,请参阅通用 Android 音乐播放器示例应用中openFile()及其周围方法的实现

  1. 构建与 Web URI 对应的content:// URI。媒体浏览器服务和媒体会话将此内容 URI 传递给 Android Auto 和 Android Automotive OS。

    Kotlin

    fun Uri.asAlbumArtContentURI(): Uri {
      return Uri.Builder()
        .scheme(ContentResolver.SCHEME_CONTENT)
        .authority(CONTENT_PROVIDER_AUTHORITY)
        .appendPath(this.getPath()) // Make sure you trust the URI
        .build()
    }
    

    Java

    public static Uri asAlbumArtContentURI(Uri webUri) {
      return new Uri.Builder()
        .scheme(ContentResolver.SCHEME_CONTENT)
        .authority(CONTENT_PROVIDER_AUTHORITY)
        .appendPath(webUri.getPath()) // Make sure you trust the URI!
        .build();
    }
    
  2. 在您的ContentProvider.openFile()实现中,检查是否存在与对应 URI 对应的文件。如果不存在,则下载并缓存图像文件。以下代码片段使用Glide

    Kotlin

    override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
      val context = this.context ?: return null
      val file = File(context.cacheDir, uri.path)
      if (!file.exists()) {
        val remoteUri = Uri.Builder()
            .scheme("https")
            .authority("my-image-site")
            .appendPath(uri.path)
            .build()
        val cacheFile = Glide.with(context)
            .asFile()
            .load(remoteUri)
            .submit()
            .get(DOWNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)
    
        cacheFile.renameTo(file)
        file = cacheFile
      }
      return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
    }
    

    Java

    @Nullable
    @Override
    public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
        throws FileNotFoundException {
      Context context = this.getContext();
      File file = new File(context.getCacheDir(), uri.getPath());
      if (!file.exists()) {
        Uri remoteUri = new Uri.Builder()
            .scheme("https")
            .authority("my-image-site")
            .appendPath(uri.getPath())
            .build();
        File cacheFile = Glide.with(context)
            .asFile()
            .load(remoteUri)
            .submit()
            .get(DOWNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
    
        cacheFile.renameTo(file);
        file = cacheFile;
      }
      return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
    }
    

有关内容提供程序的更多详细信息,请参阅创建内容提供程序

应用内容样式

使用可浏览或可播放项目构建内容层次结构后,您可以应用内容样式来确定这些项目在汽车中的显示方式。

您可以使用以下内容样式

列表项

此内容样式优先于图像显示标题和元数据。

网格项

此内容样式优先于标题和元数据显示图像。

设置默认内容样式

您可以通过在服务onGetRoot()方法的BrowserRoot额外捆绑包中包含某些常量来设置媒体项目显示方式的全局默认值。Android Auto 和 Android Automotive OS 读取此捆绑包并查找这些常量以确定适当的样式。

以下额外内容可用作捆绑包中的键

键可以映射到以下整数常量值以影响这些项目的呈现

以下代码片段显示了如何将可浏览项目的默认内容样式设置为网格,并将可播放项目的默认内容样式设置为列表

Kotlin

import androidx.media.utils.MediaConstants

@Nullable
override fun onGetRoot(
    @NonNull clientPackageName: String,
    clientUid: Int,
    @Nullable rootHints: Bundle
): BrowserRoot {
    val extras = Bundle()
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM)
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
    return BrowserRoot(ROOT_ID, extras)
}

Java

import androidx.media.utils.MediaConstants;

@Nullable
@Override
public BrowserRoot onGetRoot(
    @NonNull String clientPackageName,
    int clientUid,
    @Nullable Bundle rootHints) {
    Bundle extras = new Bundle();
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM);
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM);
    return new BrowserRoot(ROOT_ID, extras);
}

设置每个项目的样式

内容样式 API 允许您覆盖任何可浏览媒体项目的子项以及任何媒体项目本身的默认内容样式。

要覆盖可浏览媒体项目的子项的默认样式,请在媒体项目的MediaDescription中创建一个额外捆绑包并添加前面提到的相同提示。DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE适用于该项目的可播放子项,而DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE适用于该项目的可浏览子项。

要覆盖特定媒体项目本身(而不是其子项)的默认样式,请在媒体项目的MediaDescription中创建一个额外捆绑包并添加一个提示,其键为DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM。使用前面描述的相同值来指定该项目的呈现。

以下代码片段显示了如何创建一个可浏览的MediaItem,它覆盖了自身及其子项的默认内容样式。它将自身设置为类别列表项,其可浏览子项设置为列表项,其可播放子项设置为网格项

Kotlin

import androidx.media.utils.MediaConstants

private fun createBrowsableMediaItem(
    mediaId: String,
    folderName: String,
    iconUri: Uri
): MediaBrowser.MediaItem {
    val mediaDescriptionBuilder = MediaDescription.Builder()
    mediaDescriptionBuilder.setMediaId(mediaId)
    mediaDescriptionBuilder.setTitle(folderName)
    mediaDescriptionBuilder.setIconUri(iconUri)
    val extras = Bundle()
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM)
    mediaDescriptionBuilder.setExtras(extras)
    return MediaBrowser.MediaItem(
        mediaDescriptionBuilder.build(), MediaBrowser.MediaItem.FLAG_BROWSABLE)
}

Java

import androidx.media.utils.MediaConstants;

private MediaBrowser.MediaItem createBrowsableMediaItem(
    String mediaId,
    String folderName,
    Uri iconUri) {
    MediaDescription.Builder mediaDescriptionBuilder = new MediaDescription.Builder();
    mediaDescriptionBuilder.setMediaId(mediaId);
    mediaDescriptionBuilder.setTitle(folderName);
    mediaDescriptionBuilder.setIconUri(iconUri);
    Bundle extras = new Bundle();
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM);
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM);
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM);
    mediaDescriptionBuilder.setExtras(extras);
    return new MediaBrowser.MediaItem(
        mediaDescriptionBuilder.build(), MediaBrowser.MediaItem.FLAG_BROWSABLE);
}

使用标题提示对项目进行分组

要将相关的媒体项目组合在一起,您可以使用每个项目的提示。组中的每个媒体项目都需要在它们的MediaDescription中声明一个额外捆绑包,其中包含一个映射,该映射的键为DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,并具有相同的字符串值。本地化此字符串,它用作组的标题。

以下代码片段显示了如何创建一个MediaItem,其子组标题为"歌曲"

Kotlin

import androidx.media.utils.MediaConstants

private fun createMediaItem(
    mediaId: String,
    folderName: String,
    iconUri: Uri
): MediaBrowser.MediaItem {
    val mediaDescriptionBuilder = MediaDescription.Builder()
    mediaDescriptionBuilder.setMediaId(mediaId)
    mediaDescriptionBuilder.setTitle(folderName)
    mediaDescriptionBuilder.setIconUri(iconUri)
    val extras = Bundle()
    extras.putString(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
        "Songs")
    mediaDescriptionBuilder.setExtras(extras)
    return MediaBrowser.MediaItem(
        mediaDescriptionBuilder.build(), /* playable or browsable flag*/)
}

Java

import androidx.media.utils.MediaConstants;

private MediaBrowser.MediaItem createMediaItem(String mediaId, String folderName, Uri iconUri) {
   MediaDescription.Builder mediaDescriptionBuilder = new MediaDescription.Builder();
   mediaDescriptionBuilder.setMediaId(mediaId);
   mediaDescriptionBuilder.setTitle(folderName);
   mediaDescriptionBuilder.setIconUri(iconUri);
   Bundle extras = new Bundle();
   extras.putString(
       MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
       "Songs");
   mediaDescriptionBuilder.setExtras(extras);
   return new MediaBrowser.MediaItem(
       mediaDescriptionBuilder.build(), /* playable or browsable flag*/);
}

您的应用必须将您要组合在一起的所有媒体项目作为连续块传递。例如,假设您希望按顺序显示两组媒体项目,“歌曲”和“专辑”,并且您的应用按以下顺序传递了五个媒体项目

  1. 媒体项目 A,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  2. 媒体项目 B,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")
  3. 媒体项目 C,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  4. 媒体项目 D,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  5. 媒体项目 E,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")

由于“歌曲”组和“专辑”组的媒体项目没有保存在连续的块中,因此 Android Auto 和 Android Automotive OS 将其解释为以下四个组

  • 名为“歌曲”的组 1,包含媒体项目 A
  • 名为“专辑”的组 2,包含媒体项目 B
  • 名为“歌曲”的组 3,包含媒体项目 C 和 D
  • 名为“专辑”的组 4,包含媒体项目 E

要以两个组显示这些项目,您的应用必须改为按以下顺序传递媒体项目

  1. 媒体项目 A,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  2. 媒体项目 C,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  3. 媒体项目 D,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  4. 媒体项目 B,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")
  5. 媒体项目 E,其中extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")

显示其他元数据指示器

您可以包含其他元数据指示器,以便在媒体浏览器树和播放过程中为内容提供一目了然的信息。在浏览树中,Android Auto 和 Android Automotive OS 读取与项目关联的额外信息,并查找某些常量以确定要显示哪些指示器。在媒体播放过程中,Android Auto 和 Android Automotive OS 读取媒体会话的元数据,并查找某些常量以确定要显示的指示器。

图 3. 播放视图,其中元数据识别歌曲和艺术家,以及一个指示显式内容的图标。

图 4. 浏览视图,第一个项目上有一个表示未播放内容的点,第二个项目上有一个表示部分播放内容的进度条。

以下常量可用于两者MediaItem描述额外信息和MediaMetadata额外信息

以下常量只能用于MediaItem描述额外信息

要显示用户在浏览媒体浏览器树时出现的指示器,请创建一个包含一个或多个这些常量的额外信息捆绑包,并将该捆绑包传递给MediaDescription.Builder.setExtras()方法。

以下代码片段显示了如何为完成度为 70% 的显式媒体项目显示指示器

Kotlin

import androidx.media.utils.MediaConstants

val extras = Bundle()
extras.putLong(
    MediaConstants.METADATA_KEY_IS_EXPLICIT,
    MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
extras.putInt(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
    MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
extras.putDouble(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, 0.7)
val description =
    MediaDescriptionCompat.Builder()
        .setMediaId(/*...*/)
        .setTitle(resources.getString(/*...*/))
        .setExtras(extras)
        .build()
return MediaBrowserCompat.MediaItem(description, /* flags */)

Java

import androidx.media.utils.MediaConstants;

Bundle extras = new Bundle();
extras.putLong(
    MediaConstants.METADATA_KEY_IS_EXPLICIT,
    MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT);
extras.putInt(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
    MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED);
extras.putDouble(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, 0.7);
MediaDescriptionCompat description =
    new MediaDescriptionCompat.Builder()
        .setMediaId(/*...*/)
        .setTitle(resources.getString(/*...*/))
        .setExtras(extras)
        .build();
return new MediaBrowserCompat.MediaItem(description, /* flags */);

要为当前正在播放的媒体项目显示指示器,您可以在mediaSessionMediaMetadataCompat中为METADATA_KEY_IS_EXPLICITEXTRA_DOWNLOAD_STATUS声明Long值。您无法在播放视图上显示DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUSDESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE指示器。

以下代码片段显示了如何指示播放视图中的当前歌曲是显式且已下载的

Kotlin

import androidx.media.utils.MediaConstants

mediaSession.setMetadata(
    MediaMetadataCompat.Builder()
        .putString(
            MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, "Song Name")
        .putString(
            MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, "Artist name")
        .putString(
            MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
            albumArtUri.toString())
        .putLong(
            MediaConstants.METADATA_KEY_IS_EXPLICIT,
            MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
        .putLong(
            MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
            MediaDescriptionCompat.STATUS_DOWNLOADED)
        .build())

Java

import androidx.media.utils.MediaConstants;

mediaSession.setMetadata(
    new MediaMetadataCompat.Builder()
        .putString(
            MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, "Song Name")
        .putString(
            MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, "Artist name")
        .putString(
            MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
            albumArtUri.toString())
        .putLong(
            MediaConstants.METADATA_KEY_IS_EXPLICIT,
            MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
        .putLong(
            MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
            MediaDescriptionCompat.STATUS_DOWNLOADED)
        .build());

在内容播放时更新浏览视图中的进度条

如前所述,您可以使用DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE额外信息在浏览视图中显示部分播放内容的进度条。但是,如果用户从 Android Auto 或 Android Automotive OS 继续播放部分播放的内容,则随着时间的推移,该指示器会变得不准确。

为了使 Android Auto 和 Android Automotive OS 保持进度条更新,您可以在MediaMetadataCompatPlaybackStateCompat中提供其他信息,以将正在进行的内容链接到浏览视图中的媒体项目。要使媒体项目具有自动更新的进度条,必须满足以下要求

以下代码片段显示了如何指示当前播放的项目链接到浏览视图中的项目

Kotlin

import androidx.media.utils.MediaConstants

// When the MediaItem is constructed to show in the browse view.
// Suppose the item was 25% complete when the user launched the browse view.
val mediaItemExtras = Bundle()
mediaItemExtras.putDouble(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, 0.25)
val description =
    MediaDescriptionCompat.Builder()
        .setMediaId("my-media-id")
        .setExtras(mediaItemExtras)
        // ...and any other setters.
        .build()
return MediaBrowserCompat.MediaItem(description, /* flags */)

// Elsewhere, when the user has selected MediaItem for playback.
mediaSession.setMetadata(
    MediaMetadataCompat.Builder()
        .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, "my-media-id")
        // ...and any other setters.
        .build())

val playbackStateExtras = Bundle()
playbackStateExtras.putString(
    MediaConstants.PLAYBACK_STATE_EXTRAS_KEY_MEDIA_ID, "my-media-id")
mediaSession.setPlaybackState(
    PlaybackStateCompat.Builder()
        .setExtras(playbackStateExtras)
        // ...and any other setters.
        .build())

Java

import androidx.media.utils.MediaConstants;

// When the MediaItem is constructed to show in the browse view.
// Suppose the item was 25% complete when the user launched the browse view.
Bundle mediaItemExtras = new Bundle();
mediaItemExtras.putDouble(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, 0.25);
MediaDescriptionCompat description =
    new MediaDescriptionCompat.Builder()
        .setMediaId("my-media-id")
        .setExtras(mediaItemExtras)
        // ...and any other setters.
        .build();
return MediaBrowserCompat.MediaItem(description, /* flags */);

// Elsewhere, when the user has selected MediaItem for playback.
mediaSession.setMetadata(
    new MediaMetadataCompat.Builder()
        .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, "my-media-id")
        // ...and any other setters.
        .build());

Bundle playbackStateExtras = new Bundle();
playbackStateExtras.putString(
    MediaConstants.PLAYBACK_STATE_EXTRAS_KEY_MEDIA_ID, "my-media-id");
mediaSession.setPlaybackState(
    new PlaybackStateCompat.Builder()
        .setExtras(playbackStateExtras)
        // ...and any other setters.
        .build());

图 5. 播放视图,其中包含“搜索结果”选项,用于查看与用户语音搜索相关的媒体项目。

您的应用可以提供上下文搜索结果,当用户发起搜索查询时显示给用户。Android Auto 和 Android Automotive OS 通过搜索查询界面或通过以先前会话中进行的查询为中心的辅助功能显示这些结果。要了解更多信息,请参阅本指南中的支持语音操作部分。

要显示可浏览的搜索结果,请在服务的onGetRoot()方法的额外信息捆绑包中包含常量键BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED,将其映射到布尔值true

以下代码片段显示了如何在onGetRoot()方法中启用支持

Kotlin

import androidx.media.utils.MediaConstants

@Nullable
fun onGetRoot(
    @NonNull clientPackageName: String,
    clientUid: Int,
    @Nullable rootHints: Bundle
): BrowserRoot {
    val extras = Bundle()
    extras.putBoolean(
        MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
    return BrowserRoot(ROOT_ID, extras)
}

Java

import androidx.media.utils.MediaConstants;

@Nullable
@Override
public BrowserRoot onGetRoot(
    @NonNull String clientPackageName,
    int clientUid,
    @Nullable Bundle rootHints) {
    Bundle extras = new Bundle();
    extras.putBoolean(
        MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true);
    return new BrowserRoot(ROOT_ID, extras);
}

要开始提供搜索结果,请覆盖媒体浏览器服务中的onSearch()方法。每当用户调用搜索查询界面或“搜索结果”辅助功能时,Android Auto 和 Android Automotive OS 都会将用户的搜索词转发到此方法。

您可以使用标题项目组织服务onSearch()方法中的搜索结果,使其更易于浏览。例如,如果您的应用播放音乐,您可以按专辑、艺术家和歌曲组织搜索结果。

以下代码片段显示了onSearch()方法的简单实现

Kotlin

fun onSearch(query: String, extras: Bundle) {
  // Detach from results to unblock the caller (if a search is expensive).
  result.detach()
  object:AsyncTask() {
    internal var searchResponse:ArrayList
    internal var succeeded = false
    protected fun doInBackground(vararg params:Void):Void {
      searchResponse = ArrayList()
      if (doSearch(query, extras, searchResponse))
      {
        succeeded = true
      }
      return null
    }
    protected fun onPostExecute(param:Void) {
      if (succeeded)
      {
        // Sending an empty List informs the caller that there were no results.
        result.sendResult(searchResponse)
      }
      else
      {
        // This invokes onError() on the search callback.
        result.sendResult(null)
      }
      return null
    }
  }.execute()
}
// Populates resultsToFill with search results. Returns true on success or false on error.
private fun doSearch(
    query: String,
    extras: Bundle,
    resultsToFill: ArrayList
): Boolean {
  // Implement this method.
}

Java

@Override
public void onSearch(final String query, final Bundle extras,
                        Result<List<MediaItem>> result) {

  // Detach from results to unblock the caller (if a search is expensive).
  result.detach();

  new AsyncTask<Void, Void, Void>() {
    List<MediaItem> searchResponse;
    boolean succeeded = false;
    @Override
    protected Void doInBackground(Void... params) {
      searchResponse = new ArrayList<MediaItem>();
      if (doSearch(query, extras, searchResponse)) {
        succeeded = true;
      }
      return null;
    }

    @Override
    protected void onPostExecute(Void param) {
      if (succeeded) {
       // Sending an empty List informs the caller that there were no results.
       result.sendResult(searchResponse);
      } else {
        // This invokes onError() on the search callback.
        result.sendResult(null);
      }
    }
  }.execute()
}

/** Populates resultsToFill with search results. Returns true on success or false on error. */
private boolean doSearch(String query, Bundle extras, ArrayList<MediaItem> resultsToFill) {
    // Implement this method.
}

自定义浏览操作

A single custom browse action.

图 6. 单个自定义浏览操作

自定义浏览操作允许您在汽车的媒体应用中向应用的MediaItem对象添加自定义图标和标签,并处理用户与这些操作的交互。这使您能够以多种方式扩展媒体应用的功能,例如添加“下载”、“添加到队列”、“播放电台”、“收藏”或“删除”操作。

A custom browse actions overflow menu.

图 7. 自定义浏览操作溢出菜单

如果自定义操作的数量超过 OEM 允许显示的数量,则会向用户显示溢出菜单。

它们是如何工作的?

每个自定义浏览操作都使用以下内容定义:

  • 操作 ID(唯一的字符串标识符)
  • 操作标签(显示给用户的文本)
  • 操作图标 URI(可以着色的矢量可绘制对象)

您将自定义浏览操作列表全局定义为BrowseRoot的一部分。然后,您可以将这些操作的子集附加到各个MediaItem。

当用户与自定义浏览操作交互时,您的应用会在onCustomAction()中收到回调。然后,您可以处理该操作并在必要时更新MediaItem的操作列表。这对于“收藏”和“下载”等有状态操作很有用。对于不需要更新的操作(例如“播放电台”),您无需更新操作列表。

Custom browse actions in a browse node root.

图 8. 自定义浏览操作工具栏

您还可以将自定义浏览操作附加到浏览节点根目录。这些操作将显示在主工具栏下方的辅助工具栏中。

如何实现自定义浏览操作

以下是在项目中添加自定义浏览操作的步骤

  1. 覆盖MediaBrowserServiceCompat实现中的两种方法
  2. 在运行时解析操作限制
  3. 构建自定义浏览操作的全局列表

    • 对于每个操作,创建一个包含以下键的Bundle对象: * EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID:操作 ID * EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL:操作标签 * EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI:操作图标 URI * 将所有操作Bundle对象添加到列表中。
  4. 将全局列表添加到您的BrowseRoot中。
  5. 将操作添加到您的MediaItem对象中。
    • 您可以通过在MediaDescriptionCompat的 extras 中使用键DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST包含操作 ID 列表,从而将操作添加到单个MediaItem对象中。此列表必须是您在BrowseRoot中定义的全局操作列表的子集。
  6. 处理操作并返回进度或结果。

以下是一些您可以在BrowserServiceCompat中进行的更改,以开始使用自定义浏览操作。

重写 BrowserServiceCompat

您需要在MediaBrowserServiceCompat中重写以下方法。

public void onLoadItem(String itemId, @NonNull Result<MediaBrowserCompat.MediaItem> result)

public void onCustomAction(@NonNull String action, Bundle extras, @NonNull Result<Bundle> result)

解析操作限制

您应该检查支持多少个自定义浏览操作。

public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, Bundle rootHints) {
    rootHints.getInt(
            MediaConstants.BROWSER_ROOT_HINTS_KEY_CUSTOM_BROWSER_ACTION_LIMIT, 0)
}

构建自定义浏览操作

每个操作都需要打包到一个单独的Bundle中。

  • 操作 ID
    bundle.putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID,
                    "<ACTION_ID>")
    
  • 操作标签
    bundle.putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL,
                    "<ACTION_LABEL>")
    
  • 操作图标 URI
    bundle.putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI,
                    "<ACTION_ICON_URI>")
    

将自定义浏览操作添加到Parceable ArrayList

将所有自定义浏览操作Bundle对象添加到ArrayList中。

private ArrayList<Bundle> createCustomActionsList(
                                        CustomBrowseAction browseActions) {
    ArrayList<Bundle> browseActionsBundle = new ArrayList<>();
    for (CustomBrowseAction browseAction : browseActions) {
        Bundle action = new Bundle();
        action.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID,
                browseAction.mId);
        action.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL,
                getString(browseAction.mLabelResId));
        action.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI,
                browseAction.mIcon);
        browseActionsBundle.add(action);
    }
    return browseActionsBundle;
}

将自定义浏览操作列表添加到浏览根节点

public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid,
                             Bundle rootHints) {
    Bundle browserRootExtras = new Bundle();
    browserRootExtras.putParcelableArrayList(
            BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST,
            createCustomActionsList()));
    mRoot = new BrowserRoot(ROOT_ID, browserRootExtras);
    return mRoot;
}

将操作添加到MediaItem

MediaDescriptionCompat buildDescription (long id, String title, String subtitle,
                String description, Uri iconUri, Uri mediaUri,
                ArrayList<String> browseActionIds) {

    MediaDescriptionCompat.Builder bob = new MediaDescriptionCompat.Builder();
    bob.setMediaId(id);
    bob.setTitle(title);
    bob.setSubtitle(subtitle);
    bob.setDescription(description);
    bob.setIconUri(iconUri);
    bob.setMediaUri(mediaUri);

    Bundle extras = new Bundle();
    extras.putStringArrayList(
          DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST,
          browseActionIds);

    bob.setExtras(extras);
    return bob.build();
}
MediaItem mediaItem = new MediaItem(buildDescription(...), flags);

构建onCustomAction结果

  • Bundle extras解析 mediaId
    @Override
    public void onCustomAction(
              @NonNull String action, Bundle extras, @NonNull Result<Bundle> result){
      String mediaId = extras.getString(MediaConstans.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_MEDIA_ITEM_ID);
    }
    
  • 对于异步结果,分离结果。result.detach()
  • 构建结果 bundle
    • 用户消息
      mResultBundle.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_MESSAGE,
                mContext.getString(stringRes))
      
    • 更新项目(用于更新项目中的操作)
      mResultBundle.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM, mediaId);
      
    • 打开播放视图
      //Shows user the PBV without changing the playback state
      mResultBundle.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_SHOW_PLAYING_ITEM, null);
      
    • 更新浏览节点
      //Change current browse node to mediaId
      mResultBundle.putString(EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_BROWSE_NODE, mediaId);
      
  • 如果发生错误,请调用result.sendError(resultBundle)。
  • 如果更新进度,请调用result.sendProgressUpdate(resultBundle)
  • 通过调用result.sendResult(resultBundle)完成。

更新操作状态

通过使用result.sendProgressUpdate(resultBundle)方法以及EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM键,您可以更新MediaItem以反映操作的新状态。这使您可以向用户提供有关其操作的进度和结果的实时反馈。

示例:下载操作

以下是如何使用此功能来实现具有三种状态的下载操作的示例。

  1. 下载:这是操作的初始状态。当用户选择此操作时,您可以将其替换为“正在下载”,并调用sendProgressUpdate以更新 UI。
  2. 正在下载:此状态表示下载正在进行中。您可以使用此状态向用户显示进度条或其他指示器。
  3. 已下载:此状态表示下载已完成。下载完成后,您可以将“正在下载”替换为“已下载”,并使用EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM键调用sendResult,以指示应刷新项目。此外,您可以使用EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_MESSAGE键向用户显示成功消息。

这种方法使您可以向用户提供有关下载过程及其当前状态的清晰反馈。您可以使用图标添加更多详细信息以显示 25%、50%、75% 的下载状态。

示例:收藏操作

另一个示例是具有两种状态的收藏操作。

  1. 收藏:此操作显示在不在用户收藏列表中的项目中。当用户选择此操作时,您可以将其替换为“已收藏”,并使用EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM键调用sendResult以更新 UI。
  2. 已收藏:此操作显示在用户收藏列表中的项目中。当用户选择此操作时,您可以将其替换为“收藏”,并使用EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM键调用sendResult以更新 UI。

这种方法为用户管理其收藏项目提供了一种清晰一致的方式。

这些示例展示了自定义浏览操作的灵活性,以及如何使用它们来实现各种功能,并提供实时反馈,从而在汽车的媒体应用中获得增强的用户体验。

有关此功能的完整示例实现,您可以参考TestMediaApp项目。

启用播放控制

Android Auto 和 Android Automotive OS 通过您服务的MediaSessionCompat发送播放控制命令。您必须注册一个会话并实现关联的回调方法。

注册媒体会话

在媒体浏览器服务的onCreate()方法中,创建一个MediaSessionCompat,然后通过调用setSessionToken()注册媒体会话。

以下代码片段显示了如何创建和注册媒体会话。

Kotlin

override fun onCreate() {
    super.onCreate()
    ...
    // Start a new MediaSession.
    val session = MediaSessionCompat(this, "session tag").apply {
        // Set a callback object that implements MediaSession.Callback
        // to handle play control requests.
        setCallback(MyMediaSessionCallback())
    }
    sessionToken = session.sessionToken
    ...
}

Java

public void onCreate() {
    super.onCreate();
    ...
    // Start a new MediaSession.
    MediaSessionCompat session = new MediaSessionCompat(this, "session tag");
    setSessionToken(session.getSessionToken());

    // Set a callback object that implements MediaSession.Callback
    // to handle play control requests.
    session.setCallback(new MyMediaSessionCallback());
    ...
}

创建媒体会话对象时,您会设置一个回调对象,用于处理播放控制请求。您可以通过为您的应用提供MediaSessionCompat.Callback类的实现来创建此回调对象。下一节将讨论如何实现此对象。

实现播放命令

当用户请求播放您应用中的媒体项目时,Android Automotive OS 和 Android Auto 会使用您应用的MediaSessionCompat对象(从您应用的媒体浏览器服务中获取)的MediaSessionCompat.Callback类。当用户想要控制内容播放(例如暂停播放或跳到下一曲目)时,Android Auto 和 Android Automotive OS 会调用回调对象的方法之一。

为了处理内容播放,您的应用必须扩展抽象MediaSessionCompat.Callback类并实现您的应用支持的方法。

实现所有以下对您的应用提供的內容类型有意义的回调方法。

onPrepare()
在更改媒体源时调用。Android Automotive OS 还在启动后立即调用此方法。您的媒体应用必须实现此方法。
onPlay()
如果用户选择播放而不选择特定项目,则会调用。您的应用必须播放其默认内容,或者,如果播放已通过onPause()暂停,则您的应用恢复播放。

注意:当 Android Automotive OS 或 Android Auto 连接到您的媒体浏览器服务时,您的应用不应自动开始播放音乐。有关更多信息,请参阅有关设置初始播放状态的部分。

onPlayFromMediaId()
当用户选择播放特定项目时调用。该方法会传递媒体浏览器服务在内容层次结构中分配给媒体项目的ID
onPlayFromSearch()
当用户选择从搜索查询中播放时调用。应用必须根据传递的搜索字符串做出适当的选择。
onPause()
当用户选择暂停播放时调用。
onSkipToNext()
当用户选择跳到下一项时调用。
onSkipToPrevious()
当用户选择跳到前一项时调用。
onStop()
当用户选择停止播放时调用。

在您的应用中重写这些方法以提供任何所需的功能。如果您的应用不支持某个方法的功能,则无需实现该方法。例如,如果您的应用播放直播流(例如体育广播),则无需实现onSkipToNext()方法。您可以改用onSkipToNext()的默认实现。

您的应用不需要任何特殊逻辑即可通过汽车的扬声器播放内容。当您的应用收到播放内容的请求时,它可以像通过用户的手机扬声器或耳机播放内容一样播放音频。Android Auto 和 Android Automotive OS 会自动将音频内容发送到汽车系统,以便通过汽车的扬声器播放。

有关播放音频内容的更多信息,请参阅MediaPlayer 概述音频应用概述以及 ExoPlayer 的概述

设置标准播放操作

Android Auto 和 Android Automotive OS 根据PlaybackStateCompat对象中启用的操作显示播放控件。

默认情况下,您的应用必须支持以下操作。

如果与应用内容相关,您的应用还可以支持以下操作

此外,您可以选择创建一个播放队列,该队列可以显示给用户,但不是必需的。为此,请调用 setQueue()setQueueTitle() 方法,启用 ACTION_SKIP_TO_QUEUE_ITEM 操作,并定义回调 onSkipToQueueItem()

此外,请添加对“正在播放”图标的支持,该图标指示当前正在播放的内容。为此,请调用 setActiveQueueItemId() 方法,并将队列中当前正在播放项目的 ID 传递给它。每当队列发生变化时,您都需要更新 setActiveQueueItemId()

Android Auto 和 Android Automotive OS 会为每个启用的操作以及播放队列显示按钮。当点击这些按钮时,系统会调用其相应的回调函数,该回调函数来自 MediaSessionCompat.Callback

保留未使用的空间

Android Auto 和 Android Automotive OS 会在 UI 中为 ACTION_SKIP_TO_PREVIOUSACTION_SKIP_TO_NEXT 操作保留空间。如果您的应用不支持其中一项功能,Android Auto 和 Android Automotive OS 会使用该空间来显示您创建的任何自定义操作。

如果您不想用自定义操作填充这些空间,您可以保留它们,以便 Android Auto 和 Android Automotive OS 在您的应用不支持相应功能时将空间留空。为此,请使用包含对应于保留功能的常量的额外捆绑包调用 setExtras() 方法。 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT 对应于 ACTION_SKIP_TO_NEXTSESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV 对应于 ACTION_SKIP_TO_PREVIOUS。将这些常量用作捆绑包中的键,并使用布尔值 true 作为其值。

设置初始 PlaybackState

由于 Android Auto 和 Android Automotive OS 与您的媒体浏览器服务进行通信,因此您的媒体会话使用 PlaybackStateCompat 传达内容播放的状态。当 Android Automotive OS 或 Android Auto 连接到您的媒体浏览器服务时,您的应用不应自动开始播放音乐。相反,请依赖 Android Auto 和 Android Automotive OS 根据汽车的状态或用户操作来恢复或开始播放。

为此,请将媒体会话的初始 PlaybackStateCompat 设置为 STATE_STOPPEDSTATE_PAUSEDSTATE_NONESTATE_ERROR

Android Auto 和 Android Automotive OS 中的媒体会话仅在驾驶期间有效,因此用户会频繁地启动和停止这些会话。为了在驾驶之间提供无缝体验,请跟踪用户的先前会话状态,以便当媒体应用收到恢复请求时,用户可以自动从上次离开的地方继续操作,例如,上次播放的媒体项目、PlaybackStateCompat 和队列。

添加自定义播放操作

您可以添加自定义播放操作来显示您的媒体应用支持的其他操作。如果空间允许 (并且没有被保留),Android 会将自定义操作添加到传输控件。否则,自定义操作会显示在溢出菜单中。自定义操作按添加到 PlaybackStateCompat 的顺序显示。

使用自定义操作来提供与 标准操作不同的行为。不要使用它们来替换或复制标准操作。

您可以使用 addCustomAction() 方法在 PlaybackStateCompat.Builder 类中添加自定义操作。

以下代码片段显示了如何添加自定义“启动广播电台”操作

Kotlin

stateBuilder.addCustomAction(
    PlaybackStateCompat.CustomAction.Builder(
        CUSTOM_ACTION_START_RADIO_FROM_MEDIA,
        resources.getString(R.string.start_radio_from_media),
        startRadioFromMediaIcon
    ).run {
        setExtras(customActionExtras)
        build()
    }
)

Java

stateBuilder.addCustomAction(
    new PlaybackStateCompat.CustomAction.Builder(
        CUSTOM_ACTION_START_RADIO_FROM_MEDIA,
        resources.getString(R.string.start_radio_from_media),
        startRadioFromMediaIcon)
    .setExtras(customActionExtras)
    .build());

有关此方法的更详细示例,请参阅 GitHub 上的通用 Android 音乐播放器示例应用中的 setCustomAction() 方法。

创建自定义操作后,您的媒体会话可以通过覆盖 onCustomAction() 方法来响应该操作。

以下代码片段显示了您的应用如何响应“启动广播电台”操作

Kotlin

override fun onCustomAction(action: String, extras: Bundle?) {
    when(action) {
        CUSTOM_ACTION_START_RADIO_FROM_MEDIA -> {
            ...
        }
    }
}

Java

@Override
public void onCustomAction(@NonNull String action, Bundle extras) {
    if (CUSTOM_ACTION_START_RADIO_FROM_MEDIA.equals(action)) {
        ...
    }
}

有关此方法的更详细示例,请参阅 GitHub 上的通用 Android 音乐播放器示例应用中的 onCustomAction 方法。

自定义操作的图标

您创建的每个自定义操作都需要一个图标资源。汽车中的应用可以在许多不同的屏幕尺寸和密度上运行,因此您提供的图标必须是 矢量可绘制对象。矢量可绘制对象允许您缩放资源而不损失细节。矢量可绘制对象还可以轻松地将边缘和角对齐到较小分辨率下的像素边界。

如果自定义操作是有状态的(例如,它会打开或关闭播放设置),请为不同的状态提供不同的图标,以便用户在选择操作时可以看到变化。

为禁用的操作提供替代图标样式

当自定义操作在当前上下文中不可用时,请使用显示该操作已禁用的替代图标替换自定义操作图标。

图 6. 关闭样式自定义操作图标的示例。

指示音频格式

为了指示当前正在播放的媒体使用特殊的音频格式,您可以指定在支持此功能的汽车中呈现的图标。您可以在当前正在播放的媒体项目的额外捆绑包中设置 KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URIKEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI(传递给 MediaSession.setMetadata())。请确保设置这两个额外内容,以适应不同的布局。

此外,您可以设置 KEY_IMMERSIVE_AUDIO 额外内容以告诉汽车 OEM 此音频具有沉浸感,并且在决定是否应用可能干扰沉浸式内容的音频效果时,他们应该非常谨慎。

您可以配置当前正在播放的媒体项目,使其副标题、描述或两者都是其他媒体项目的链接。这可以让用户快速跳转到相关项目;例如,他们可能会跳转到同一艺术家的其他歌曲、该播客的其他剧集等。如果汽车支持此功能,用户可以点击链接以浏览到该内容。

要添加链接,请配置 KEY_SUBTITLE_LINK_MEDIA_ID 元数据(从副标题链接)或 KEY_DESCRIPTION_LINK_MEDIA_ID(从描述链接)。有关详细信息,请参阅这些元数据字段的参考文档。

支持语音操作

您的媒体应用必须支持语音操作,以帮助为驾驶员提供安全便捷的体验,最大程度地减少干扰。例如,如果您的应用正在播放一个媒体项目,用户可以说“播放 [歌曲标题]”来告诉您的应用播放不同的歌曲,而无需查看或触摸汽车的显示屏。用户可以通过点击方向盘上的相应按钮或说出热词“好的 Google”来启动查询。

当 Android Auto 或 Android Automotive OS 检测并解释语音操作时,该语音操作将通过 onPlayFromSearch() 传递到应用。收到此回调后,应用会查找与 query 字符串匹配的内容并开始播放。

用户可以在其查询中指定不同类别的术语:流派、艺术家、专辑、歌曲名称、广播电台或播放列表等。在构建搜索支持时,请考虑对您的应用有意义的所有类别。如果 Android Auto 或 Android Automotive OS 检测到给定查询适合某些类别,它会在 extras 参数中追加额外内容。可以发送以下额外内容

请考虑空 query 字符串,如果用户未指定搜索词,则 Android Auto 或 Android Automotive OS 可以发送该字符串。例如,如果用户说“播放一些音乐”。在这种情况下,您的应用可能会选择开始播放最近播放的曲目或新建议的曲目。

如果搜索无法快速处理,请不要在 onPlayFromSearch() 中阻塞。相反,请将播放状态设置为 STATE_CONNECTING 并在异步线程上执行搜索。

播放开始后,请考虑使用相关内容填充媒体会话的队列。例如,如果用户请求播放专辑,您的应用可能会使用专辑的曲目列表填充队列。还可以考虑实现对 可浏览的搜索结果 的支持,以便用户可以选择与他们的查询匹配的不同曲目。

除了“播放”查询外,Android Auto 和 Android Automotive OS 还识别用于控制播放的语音查询,例如“暂停音乐”和“下一首歌曲”,并将这些命令与相应的媒体会话回调(如 onPause()onSkipToNext())匹配。

有关如何在您的应用中实现支持语音的播放操作的详细示例,请参阅Google 助理和媒体应用

实施分心防护措施

由于用户在使用 Android Auto 时,手机已连接到汽车扬声器,因此您必须采取额外的预防措施,以帮助防止驾驶员分心。

抑制车内警报

Android Auto 媒体应用不得在用户通过例如按下播放按钮启动播放之前,通过汽车扬声器开始播放音频。即使是用户在您的媒体应用中预设的警报,也不得通过汽车扬声器开始播放音乐。

为了满足此要求,您的应用可以在播放任何音频之前,使用CarConnection 作为信号。您的应用可以通过观察汽车连接类型LiveData 并检查其是否等于CONNECTION_TYPE_PROJECTION,来检查手机是否正在投影到汽车屏幕上。

如果用户手机正在投影,则支持警报的媒体应用必须执行以下操作之一

  • 禁用警报。
  • 通过STREAM_ALARM播放警报,并在手机屏幕上提供一个 UI 来禁用警报。

处理媒体广告

默认情况下,Android Auto 在音频播放会话期间媒体元数据更改时会显示通知。当媒体应用从播放音乐切换到播放广告时,向用户显示通知会造成干扰。为了防止 Android Auto 在这种情况下显示通知,您必须将媒体元数据键METADATA_KEY_IS_ADVERTISEMENT设置为METADATA_VALUE_ATTRIBUTE_PRESENT,如下面的代码片段所示

Kotlin

import androidx.media.utils.MediaConstants

override fun onPlayFromMediaId(mediaId: String, extras: Bundle?) {
    MediaMetadataCompat.Builder().apply {
        if (isAd(mediaId)) {
            putLong(
                MediaConstants.METADATA_KEY_IS_ADVERTISEMENT,
                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
        }
        // ...add any other properties you normally would.
        mediaSession.setMetadata(build())
    }
}

Java

import androidx.media.utils.MediaConstants;

@Override
public void onPlayFromMediaId(String mediaId, Bundle extras) {
    MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder();
    if (isAd(mediaId)) {
        builder.putLong(
            MediaConstants.METADATA_KEY_IS_ADVERTISEMENT,
            MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT);
    }
    // ...add any other properties you normally would.
    mediaSession.setMetadata(builder.build());
}

处理一般错误

当应用遇到错误时,请将播放状态设置为STATE_ERROR,并使用setErrorMessage()方法提供错误消息。有关您可以设置错误消息时使用的错误代码列表,请参阅PlaybackStateCompat。错误消息必须面向用户,并使用用户的当前区域设置进行本地化。然后,Android Auto 和 Android Automotive OS 可以将错误消息显示给用户。

例如,如果内容在用户当前区域不可用,则在设置错误消息时可以使用ERROR_CODE_NOT_AVAILABLE_IN_REGION错误代码。

Kotlin

mediaSession.setPlaybackState(
    PlaybackStateCompat.Builder()
        .setState(PlaybackStateCompat.STATE_ERROR)
        .setErrorMessage(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, getString(R.string.error_unsupported_region))
        // ...and any other setters.
        .build())

Java

mediaSession.setPlaybackState(
    new PlaybackStateCompat.Builder()
        .setState(PlaybackStateCompat.STATE_ERROR)
        .setErrorMessage(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, getString(R.string.error_unsupported_region))
        // ...and any other setters.
        .build());

有关错误状态的更多信息,请参阅使用媒体会话:状态和错误

如果 Android Auto 用户需要打开您的手机应用来解决错误,请在您的消息中向用户提供该信息。例如,您的错误消息可能显示“登录 [您的应用名称]”,而不是“请登录”。

其他资源