为汽车构建媒体应用

Android Auto 和 Android Automotive OS 可帮助您将媒体应用内容呈现给汽车中的用户。

构建车载媒体应用有两种方式

  • 本指南介绍了如何使用 MediaBrowserServiceMediaSession 来创建应用,Android Auto 和 Android Automotive OS 可以连接到该应用,以呈现针对车载使用优化的媒体浏览和播放视图。

  • 媒体应用还可以使用 Car App Library 模板构建,以实现可自定义的格式设置、浏览功能和扩展自定义操作。有关实现详情,请参阅构建模板化媒体应用。模板化媒体应用目前仅支持 Android Auto。

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

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

开始之前

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

关键术语和概念

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

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

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

既可浏览又可播放的媒体项就像一个播放列表。您可以选择该项本身来播放其所有子项,也可以浏览其子项。

车载优化

Android Automotive OS 应用的一项 Activity,它遵循 Android Automotive OS 设计准则。这些 Activity 的界面不是由 Android Automotive OS 绘制的,因此您必须确保您的应用遵循设计准则。通常,这包括更大的点击目标和字体大小、支持白天和夜晚模式以及更高的对比度。

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

您无需为 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 上的通用 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 Assistant 连接到您的 MediaBrowserService。请注意,Google Assistant 为手机(包括 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 集成之间有所不同时。例如,如果您通常显示一个根可播放项,由于受支持标志提示的值,您可能希望将其嵌套在根可浏览项下。

除了根提示之外,还有一些额外的准则需要遵循,以帮助确保标签页最佳呈现

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

显示媒体封面

媒体项的封面必须使用 ContentResolver.SCHEME_CONTENTContentResolver.SCHEME_ANDROID_RESOURCE 作为本地 URI 传递。此本地 URI 必须解析为应用资源中的位图或矢量 drawable。对于表示内容层次结构中项的 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 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);
}

设置每项内容样式

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

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

要覆盖特定媒体项本身(而非其子项)的默认设置,请在媒体项的 MediaDescription 中创建一个 extras 捆绑包,并添加一个键为 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 中声明一个 extras 捆绑包,该捆绑包包含一个映射,其键为 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*/);
}

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

  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 会读取与项关联的 extras 并查找某些常量来确定要显示的指示器。在媒体播放期间,Android Auto 和 Android Automotive OS 会读取媒体会话的元数据并查找某些常量来确定要显示的指示器。

图 3. 播放视图,带有识别歌曲和艺术家的元数据,以及一个表示明确内容的图标。

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

以下常量可用于 MediaItem 描述 extras 和 MediaMetadata extras 两者

以下常量可用于 MediaItem 描述 extras

要显示用户浏览媒体浏览树时出现的指示器,请创建一个包含这些常量中的一个或多个的 extras 捆绑包,并将该捆绑包传递给 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 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. 播放视图,带有“搜索结果”选项,用于查看与用户语音搜索相关的媒体项。

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

要显示可浏览的搜索结果,请在您的服务的 onGetRoot() 方法的 extras 捆绑包中包含常量键 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. 自定义浏览操作溢出菜单

如果自定义操作的数量超过原始设备制造商允许显示的数量,将向用户呈现溢出菜单。

它们如何工作?

每个自定义浏览操作都定义为

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

您将自定义浏览操作列表全局定义为 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()
  • 构建结果捆绑包
    • 给用户发送消息
      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. 已下载:此状态表示下载已完成。当下载完成时,您可以将“正在下载”替换为“已下载”,并调用 sendResult,使用 EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM 键指示该项应刷新。此外,您可以使用 EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_MESSAGE 键向用户显示成功消息。

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

示例:收藏操作

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

  1. 收藏:此操作显示在用户收藏夹列表中没有的项上。当用户选择此操作时,您可以将其替换为“已收藏”,并调用 sendResult,使用 EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM 键来更新 UI。
  2. 已收藏:此操作显示在用户收藏夹列表中的项上。当用户选择此操作时,您可以将其替换为“收藏”,并调用 sendResult,使用 EXTRAS_KEY_CUSTOM_BROWSER_ACTION_RESULT_REFRESH_ITEM 键来更新 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 会在界面中为 ACTION_SKIP_TO_PREVIOUSACTION_SKIP_TO_NEXT 操作预留空间。如果您的应用不支持其中一项功能,Android Auto 和 Android Automotive OS 会使用该空间显示您创建的任何自定义操作。

如果您不想用自定义操作填充这些空间,可以保留它们,以便 Android Auto 和 Android Automotive OS 在您的应用不支持相应功能时将空间留空。为此,请调用 setExtras() 方法,并传入一个包含与保留功能对应的常量的 extras bundle。SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT 对应于 ACTION_SKIP_TO_NEXTSESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV 对应于 ACTION_SKIP_TO_PREVIOUS。将这些常量用作 bundle 中的键,并将布尔值 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

val customActionExtras = Bundle()
customActionExtras.putInt(
  androidx.media3.session.MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT,
  androidx.media3.session.CommandButton.ICON_RADIO)

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

Java

Bundle customActionExtras = new Bundle();
customActionExtras.putInt(
  androidx.media3.session.MediaConstants.EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT,
  androidx.media3.session.CommandButton.ICON_RADIO);

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

有关此方法的更详细示例,请参阅 GitHub 上的 Universal Android Music Player 示例应用中的 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 上的 Universal Android Music Player 示例应用中的 onCustomAction 方法。

自定义操作的图标

您创建的每个自定义操作都需要一个图标。

如果该图标的描述与 CommandButton.ICON_ 常量之一匹配,则应将该整数值设置为自定义操作 extras 的 EXTRAS_KEY_COMMAND_BUTTON_ICON_COMPAT 键。在受支持的系统上,这将覆盖传递给 CustomAction.Builder 的图标资源,从而允许系统组件以一致的样式渲染您的操作和其他播放操作。

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

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

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

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

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

指示音频格式

为了指示当前播放的媒体使用特殊音频格式,您可以指定在支持此功能的汽车中渲染的图标。您可以将 KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URIKEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI 设置在当前播放的媒体项的 extras bundle 中(传入 MediaSession.setMetadata())。请确保同时设置这两个 extras,以适应不同的布局。

此外,您可以设置 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 参数中附加 extras。可以发送以下 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 用户需要打开您的手机应用来解决错误,请在您的消息中向用户提供该信息。例如,您的错误消息可以说“登录 [您的应用名称]”,而不是“请登录”。

其他资源