构建车载媒体应用

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 应用的 activity。这些 activity 的界面并非由 Android Automotive OS 绘制,因此您必须确保应用遵循设计准则。通常情况下,这包括使用较大的点按目标和字体大小、支持日间模式和夜间模式以及提高对比度。

只有在车载用户体验限制 (CUXR) 未生效时才允许显示车辆优化界面,因为这些界面可能需要用户长时间关注或互动。汽车处于停车状态时 CUXR 不起作用,但在汽车行驶时始终有效。

您无需针对 Android Auto 设计 activity,因为 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 用于在系统界面中表示您的应用。必须提供的两种图标:

  • 启动器图标
  • 提供方图标

启动器图标

启动器图标用于在系统界面中表示您的应用,例如在启动器和图标托盘中。您可以使用以下清单声明,说明要使用您移动应用中的图标来表示您的汽车媒体应用。

<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 上 Universal Android Music Player 示例应用中的 onGetRoot() 方法。

为 onGetRoot() 添加软件包验证

调用服务的 onGetRoot() 方法时,发出调用的软件包会将标识信息传递给您的服务。您的服务可以使用此信息来决定该软件包能否访问您的内容。例如,您可以将 clientPackageName 与许可名单进行比较,并验证用于为软件包的 APK 签名的证书,从而限制只有获批的软件包有权访问应用的内容。如果无法验证软件包,则返回 null 以拒绝它访问您的内容。

为了允许系统应用(例如 Android Auto 和 Android Automotive OS)访问您的内容,当这些系统应用调用 onGetRoot() 方法时,您的服务应始终返回一个非 null 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 上 Universal Android Music Player 示例应用中的 PackageValidator 类。请参阅该类,通过更详细的示例来了解如何为服务的 onGetRoot() 方法实现软件包验证。

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

实现 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 上 Universal Android Music Player 示例应用中的 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 集成而异时。例如,如果您通常显示一个根可播放项,根据受支持标志提示的值,您可能希望将其嵌套在一个根可浏览项下。

除了根提示之外,还应遵循一些其他准则,以帮助确保标签页以最佳方式呈现:

  • 为每个标签页项提供单色(最好是白色)图标。
  • 为每个标签页项提供简短而有意义的标签。使标签保持简洁可降低字符串被截断的可能性。

显示媒体图片

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

以下步骤说明了如何通过网页 URI 下载图片并通过本地 URI 公开图片。如需查看更完整的示例,请参阅 openFile()实现以及通用 Android 音乐播放器示例应用中的相关方法。

  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);
    }

如需详细了解 content provider,请参阅创建 content provider

采用内容样式

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

您可以采用以下内容样式:

列表项

此内容样式重点显示标题和元数据,其次是图片。

网格项

在此内容样式下,图片的优先级高于标题和元数据。

设置默认内容样式

您可以通过在服务的 onGetRoot() 方法的 BrowserRoot extra 包中加入特定常量,设置媒体项的全局默认显示方式。Android Auto 和 Android Automotive OS 会读取此包并查找这些常量来确定相应的样式。

以下 extra 可用作包中的键:

这些键可映射到以下整数常量值,以影响相应项的呈现方式:

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

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);
}

按项设置内容样式

您可以通过 Content Style API,替换任何可浏览媒体项的子项以及任何媒体项本身的默认内容样式。

如需为可浏览媒体项的子项替换默认项,请在媒体项的 MediaDescription 中创建 extra 包,并添加上述相同的提示。DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE 适用于该项的可播放子项,而 DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE 适用于该项的可浏览子项。

如需替换特定媒体项本身(而非其子项)的默认值,请在媒体项的 MediaDescription 中创建 extra 包,并使用键 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 中声明一个 extra 包,其中包含与 DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE 键的映射和一个相同的字符串值。本地化此字符串,它将被用作组的标题。

以下代码段展示了如何创建子组标题为 "Songs"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*/);
}

您的应用必须将您想要归入一组的所有媒体项传递为一个连续的块。例如,假设您希望显示“Songs”和“Albums”这两组媒体项(按此顺序),并且您的应用按以下顺序传递了五个媒体项:

  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")

由于“Songs”组和“Albums”组中的媒体项未归入连续的块中,因此 Android Auto 和 Android Automotive OS 会将其解读为以下四组:

  • 第 1 组名为“Songs”,包含媒体项 A
  • 第 2 组名为“Albums”,包含媒体项 B
  • 第 3 组名为“Songs”,包含媒体项 C 和 D
  • 第 4 组名为“Albums”,包含媒体项 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 会读取与项关联的 extra,并查找特定常量来确定要显示的指示器。在媒体播放期间,Android Auto 和 Android Automotive OS 会读取媒体会话的元数据,并查找特定常量来确定要显示的指示器。

图 3. 一个播放视图,包含用于识别歌曲和艺人的元数据,以及用于指示露骨内容的图标。

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

以下常量可同时用于 MediaItem 说明 extra 和 MediaMetadata extra:

以下常量只能在 MediaItem 说明 extra 中使用:

为了显示在用户浏览媒体浏览树时出现的指示器,可创建一个 extra 包,在其中加入一个或多个常量,然后将该包传递给 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_STATUSLong 值。您不能在播放视图上显示 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 extra 在浏览视图中显示部分播放内容的进度条。但是,如果用户通过 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. 一个播放视图,包含“Search results”选项,用于查看与用户的语音搜索相关的媒体项。

您的应用可以提供在用户发起搜索查询时向其显示的上下文搜索结果。Android Auto 和 Android Automotive OS 将通过搜索查询接口或通过基于会话中之前进行的查询的功能显示这些结果。如需了解详情,请参阅本指南中的支持语音操作部分。

如需显示可浏览的搜索结果,请在您服务的 onGetRoot() 方法的 extra 包中添加常量键 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() 方法。每当用户调用搜索查询接口或“Search results”功能时,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.
}

自定义浏览操作

单个自定义浏览操作。

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

借助自定义浏览操作,您可以在车载媒体应用中为应用的 MediaItem 对象添加自定义图标和标签,并处理用户与这些操作的互动。这样,您就可以通过多种方式扩展媒体应用的功能,例如添加“下载”“添加到队列”“播放电台”“收藏”或“移除”操作。

自定义浏览操作菜单。

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

如果自定义操作的数量超出了 OEM 允许显示的数量,系统会向用户显示一个溢出菜单。

运作方式

每个自定义浏览操作均由以下内容定义:

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

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

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

浏览节点根目录中的自定义浏览操作。

图 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 对象添加操作:
    • 您可以使用键 DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LISTMediaDescriptionCompat 附加信息中添加操作 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()
  • 构建结果软件包
    • 向用户发送的消息
      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 以更新界面。
  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 以更新界面。
  2. 已收藏:系统会针对用户收藏列表中的内容显示此操作。当用户选择此操作时,您可以将其替换为“Favorite”,并使用 EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM 键调用 sendResult 来更新界面。

这种方法可让用户以清晰一致的方式管理其收藏夹。

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

如需查看此功能的完整实现示例,您可以参考 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 会在界面中为 ACTION_SKIP_TO_PREVIOUSACTION_SKIP_TO_NEXT 操作预留空间。如果您的应用不支持其中某一功能,Android Auto 和 Android Automotive OS 会使用对应的空间显示您创建的任何自定义操作。

如果您不希望让自定义操作占据这些空间,则可以保留这些空间。这样,在您的应用不支持相应功能时,Android Auto 和 Android Automotive OS 会使对应空间留空。为此,请创建 extra 包以包含与保留功能对应的常量,然后使用这个 extra 包来调用 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 作为它们的值。

设置初始播放状态

当 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 中的顺序进行显示。

使用自定义操作来提供与标准操作不同的行为。请勿使用此类操作来替换或重复标准操作。

您可以使用 PlaybackStateCompat.Builder 类中的 addCustomAction() 方法添加这些操作。

以下代码段展示了如何添加自定义的“启动电台频道”操作:

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. 样式外自定义操作图标示例。

指明音频格式

为了指明当前播放的媒体使用特殊的音频格式,您可以指定在支持此功能的汽车上呈现相应图标。您可以在当前播放的媒体项(传递给 MediaSession.setMetadata())的 extra 包中设置 KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URIKEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI。为了适应不同的布局,请务必同时设置这些 extra。

此外,您可以设置 KEY_IMMERSIVE_AUDIO extra,告知汽车 OEM 这是沉浸式音频,以便他们判断应用音效是否可能会对沉浸式内容造成干扰,据此做出谨慎决定。

您可以配置当前正在播放的媒体项,使其字幕和/或说明成为指向其他媒体项的链接。这让用户能够快速跳转到相关项;例如,用户可能会跳至同一音乐人的其他歌曲、该播客的其他分集等。如果汽车支持此功能,用户可点按该链接以浏览相应内容。

如需添加链接,请配置 KEY_SUBTITLE_LINK_MEDIA_ID 元数据(以从字幕中添加链接)或 KEY_DESCRIPTION_LINK_MEDIA_ID(以从说明中添加链接)。如需了解详情,请查看这些元数据字段的参考文档。

支持语音操作

您的媒体应用必须支持语音操作,以便为驾驶员提供安全、便利的体验,最大限度地减少干扰。例如,如果您的应用已在播放某一媒体项,则用户可以通过说“播放《[歌曲名]》”让应用播放另一首歌,而无需用户查看或轻触汽车的显示屏。用户可以通过点击方向盘上的相应按钮或说出启动指令(“Ok Google”)来发起询问。

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

用户可以在查询中指定不同类别的字词:流派、音乐人、专辑、歌曲名称、电台或播放列表等。构建搜索支持功能时,请考虑您的应用适用的所有类别。如果 Android Auto 或 Android Automotive OS 检测到指定查询符合特定类别,则会在 extras 参数中附加 extra。可以发送以下 extra:

考虑使用一个空的 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 播放闹钟铃声,并在手机屏幕上提供用于停用闹钟的界面。

处理媒体广告

默认情况下,当媒体元数据在音频播放会话期间发生更改时,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 用户需要打开您的手机应用以解决错误,请在您的消息中向用户提供相应信息。例如,您的错误消息可显示“登录 [您的应用名称]”,而不是“请登录”。

其他资源