public final class

WearUnsuitableOutputPlaybackSuppressionResolverListener

extends java.lang.Object

implements Player.Listener

 java.lang.Object

↳androidx.media3.ui.WearUnsuitableOutputPlaybackSuppressionResolverListener

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-ui', version: '1.5.0-alpha01'

  • groupId: androidx.media3
  • artifactId: media3-ui
  • version: 1.5.0-alpha01

Artifact androidx.media3:media3-ui:1.5.0-alpha01 it located at Google repository (https://maven.google.com/)

Overview

A that launches a system dialog in response to Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT to allow the user to connect a suitable audio output. Also, it auto-resumes the playback when the playback suppression reason is changed from Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT to Player.PLAYBACK_SUPPRESSION_REASON_NONE.

This listener only reacts to Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT on Wear OS devices, while being no-op for non-Wear OS devices.

The system dialog will be the Media Output Switcher if it is available on the device, or otherwise the Bluetooth settings screen.

This implementation also pauses playback before opening the system dialog. If the user connects a suitable media output within the specified timeout, playback resumes automatically. During this timeout, a power wakelock of the PowerManager level is obtained to prevent the system from freezing the app.

Summary

Fields
public static final longDEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS

The default timeout for auto-resume of suppressed playback when the playback suppression reason as Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT is removed, in milliseconds.

Constructors
publicWearUnsuitableOutputPlaybackSuppressionResolverListener(Context context)

Creates a new instance.

publicWearUnsuitableOutputPlaybackSuppressionResolverListener(Context context, long autoResumeTimeoutMs)

Creates a new instance.

Methods
public voidonEvents(Player player, Player.Events events)

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Fields

public static final long DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS

The default timeout for auto-resume of suppressed playback when the playback suppression reason as Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT is removed, in milliseconds.

Constructors

public WearUnsuitableOutputPlaybackSuppressionResolverListener(Context context)

Creates a new instance.

See WearUnsuitableOutputPlaybackSuppressionResolverListener.WearUnsuitableOutputPlaybackSuppressionResolverListener(Context, long) for more details. The auto-resume timeout defaults to WearUnsuitableOutputPlaybackSuppressionResolverListener.DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS.

Parameters:

context: Any context.

public WearUnsuitableOutputPlaybackSuppressionResolverListener(Context context, long autoResumeTimeoutMs)

Creates a new instance.

Parameters:

context: Any context.
autoResumeTimeoutMs: Duration in milliseconds after the playback suppression during which playback will be resumed automatically if the playback suppression reason is changed from Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT to Player.PLAYBACK_SUPPRESSION_REASON_NONE. Calling with autoResumeTimeoutMs = 0 will cause playback to never resume automatically.

Methods

public void onEvents(Player player, Player.Events events)

Source

/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.ui;

import static androidx.media3.common.util.Assertions.checkArgument;
import static java.util.concurrent.TimeUnit.MINUTES;

import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import android.provider.Settings;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.C;
import androidx.media3.common.Player;
import androidx.media3.common.Player.Events;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.SystemClock;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import java.util.List;

/**
 * A {@link Player.Listener} that launches a system dialog in response to {@link
 * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} to allow the user to connect a
 * suitable audio output. Also, it auto-resumes the playback when the playback suppression reason is
 * changed from {@link Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} to {@link
 * Player#PLAYBACK_SUPPRESSION_REASON_NONE}.
 *
 * <p>This listener only reacts to {@link
 * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} on Wear OS devices, while being no-op
 * for non-Wear OS devices.
 *
 * <p>The system dialog will be the <a
 * href="https://developer.android.com/media/routing#output-switcher">Media Output Switcher</a> if
 * it is available on the device, or otherwise the Bluetooth settings screen.
 *
 * <p>This implementation also pauses playback before opening the system dialog. If the user
 * connects a suitable media output within the specified timeout, playback resumes automatically.
 * During this timeout, a power wakelock of the {@link PowerManager#PARTIAL_WAKE_LOCK} level is
 * obtained to prevent the system from freezing the app.
 */
@UnstableApi
public final class WearUnsuitableOutputPlaybackSuppressionResolverListener
    implements Player.Listener {

  /** Output switcher intent action for the Wear OS. */
  private static final String OUTPUT_SWITCHER_INTENT_ACTION_NAME =
      "com.android.settings.panel.action.MEDIA_OUTPUT";

  /** A package name key for output switcher intent in the Wear OS. */
  private static final String EXTRA_OUTPUT_SWITCHER_PACKAGE_NAME =
      "com.android.settings.panel.extra.PACKAGE_NAME";

  /**
   * Extra in the Bluetooth Activity intent to control whether the fragment should close when a
   * device connects.
   */
  private static final String EXTRA_BLUETOOTH_SETTINGS_CLOSE_ON_CONNECT = "EXTRA_CLOSE_ON_CONNECT";

  /**
   * Extra in the Bluetooth Activity intent to indicate that the user only wants to connect or
   * disconnect, not forget paired devices or do any other device management.
   */
  private static final String EXTRA_BLUETOOTH_SETTINGS_CONNECTION_ONLY = "EXTRA_CONNECTION_ONLY";

  /**
   * Extra in the Bluetooth Activity intent to specify the type of filtering that needs to be be
   * applied to the device list.
   */
  private static final String EXTRA_BLUETOOTH_SETTINGS_FILTER_TYPE =
      "android.bluetooth.devicepicker.extra.FILTER_TYPE";

  /**
   * The value for the {@link #EXTRA_BLUETOOTH_SETTINGS_FILTER_TYPE} in the Bluetooth intent to show
   * BT devices that support AUDIO profiles
   */
  private static final int FILTER_TYPE_AUDIO = 1;

  private static final String WAKE_LOCK_TAG =
      "WearUnsuitableOutputPlaybackSuppressionResolverListener:WakeLock";

  /**
   * The default timeout for auto-resume of suppressed playback when the playback suppression reason
   * as {@link Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} is removed, in
   * milliseconds.
   */
  public static final long DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS =
      MINUTES.toMillis(5);

  private final Context applicationContext;
  private final long autoResumeTimeoutAfterUnsuitableOutputSuppressionMs;
  private final Clock clock;

  @Nullable private final WakeLock wakeLock;

  private long unsuitableOutputPlaybackSuppressionStartRealtimeMs;

  /**
   * Creates a new instance.
   *
   * <p>See {@link #WearUnsuitableOutputPlaybackSuppressionResolverListener(Context, long)} for more
   * details. The auto-resume timeout defaults to {@link
   * #DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS}.
   *
   * @param context Any context.
   */
  public WearUnsuitableOutputPlaybackSuppressionResolverListener(Context context) {
    this(context, DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS);
  }

  /**
   * Creates a new instance.
   *
   * @param context Any context.
   * @param autoResumeTimeoutMs Duration in milliseconds after the playback suppression during which
   *     playback will be resumed automatically if the playback suppression reason is changed from
   *     {@link Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} to {@link
   *     Player#PLAYBACK_SUPPRESSION_REASON_NONE}. Calling with {@code autoResumeTimeoutMs = 0} will
   *     cause playback to never resume automatically.
   */
  public WearUnsuitableOutputPlaybackSuppressionResolverListener(
      Context context, @IntRange(from = 0) long autoResumeTimeoutMs) {
    this(context, autoResumeTimeoutMs, SystemClock.DEFAULT);
  }

  @VisibleForTesting
  /* package */ WearUnsuitableOutputPlaybackSuppressionResolverListener(
      Context context, @IntRange(from = 0) long autoResumeTimeoutMs, Clock clock) {
    checkArgument(autoResumeTimeoutMs >= 0);
    applicationContext = context.getApplicationContext();
    autoResumeTimeoutAfterUnsuitableOutputSuppressionMs = autoResumeTimeoutMs;
    this.clock = clock;
    unsuitableOutputPlaybackSuppressionStartRealtimeMs = C.TIME_UNSET;
    WakeLock wakeLock = null;
    PowerManager powerManager =
        (PowerManager) applicationContext.getSystemService(Context.POWER_SERVICE);
    if (powerManager != null) {
      wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
      wakeLock.setReferenceCounted(false);
    }
    this.wakeLock = wakeLock;
  }

  @Override
  public void onEvents(Player player, Events events) {
    if (!Util.isWear(applicationContext)) {
      return;
    }
    if ((events.contains(Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED)
            || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED))
        && player.getPlayWhenReady()
        && player.getPlaybackSuppressionReason()
            == Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
      player.pause();
      unsuitableOutputPlaybackSuppressionStartRealtimeMs = clock.elapsedRealtime();
      if (wakeLock != null && !wakeLock.isHeld()) {
        wakeLock.acquire(autoResumeTimeoutAfterUnsuitableOutputSuppressionMs);
      }
      if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
        launchSystemMediaOutputSwitcherUi(applicationContext);
      }
    } else if (events.contains(Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED)
        && player.getPlaybackSuppressionReason() == Player.PLAYBACK_SUPPRESSION_REASON_NONE
        && unsuitableOutputPlaybackSuppressionStartRealtimeMs != C.TIME_UNSET
        && (clock.elapsedRealtime() - unsuitableOutputPlaybackSuppressionStartRealtimeMs
            < autoResumeTimeoutAfterUnsuitableOutputSuppressionMs)) {
      unsuitableOutputPlaybackSuppressionStartRealtimeMs = C.TIME_UNSET;
      player.play();
      if (wakeLock != null) {
        wakeLock.release();
      }
    }
  }

  /**
   * Launches the system media output switcher app if it is available on the device, or otherwise
   * the Bluetooth settings screen.
   */
  private static void launchSystemMediaOutputSwitcherUi(Context context) {
    Intent outputSwitcherLaunchIntent =
        new Intent(OUTPUT_SWITCHER_INTENT_ACTION_NAME)
            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            .putExtra(EXTRA_OUTPUT_SWITCHER_PACKAGE_NAME, context.getPackageName());
    ComponentName outputSwitcherSystemComponentName =
        getSystemOrSystemUpdatedAppComponent(context, outputSwitcherLaunchIntent);
    if (outputSwitcherSystemComponentName != null) {
      outputSwitcherLaunchIntent.setComponent(outputSwitcherSystemComponentName);
      context.startActivity(outputSwitcherLaunchIntent);
    } else {
      Intent bluetoothSettingsLaunchIntent =
          new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
              .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
              .putExtra(EXTRA_BLUETOOTH_SETTINGS_CLOSE_ON_CONNECT, true)
              .putExtra(EXTRA_BLUETOOTH_SETTINGS_CONNECTION_ONLY, true)
              .putExtra(EXTRA_BLUETOOTH_SETTINGS_FILTER_TYPE, FILTER_TYPE_AUDIO);
      ComponentName bluetoothSettingsSystemComponentName =
          getSystemOrSystemUpdatedAppComponent(context, bluetoothSettingsLaunchIntent);
      if (bluetoothSettingsSystemComponentName != null) {
        bluetoothSettingsLaunchIntent.setComponent(bluetoothSettingsSystemComponentName);
        context.startActivity(bluetoothSettingsLaunchIntent);
      }
    }
  }

  /**
   * Returns {@link ComponentName} of system or updated system app's activity resolved from the
   * {@link Intent} passed to it.
   */
  private static @Nullable ComponentName getSystemOrSystemUpdatedAppComponent(
      Context context, Intent intent) {
    PackageManager packageManager = context.getPackageManager();
    List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(intent, /* flags= */ 0);
    for (ResolveInfo resolveInfo : resolveInfos) {
      ActivityInfo activityInfo = resolveInfo.activityInfo;
      if (activityInfo == null || activityInfo.applicationInfo == null) {
        continue;
      }
      ApplicationInfo appInfo = activityInfo.applicationInfo;
      int systemAndUpdatedSystemAppFlags =
          ApplicationInfo.FLAG_SYSTEM | ApplicationInfo.FLAG_UPDATED_SYSTEM_APP;
      if ((systemAndUpdatedSystemAppFlags & appInfo.flags) != 0) {
        return new ComponentName(activityInfo.packageName, activityInfo.name);
      }
    }
    return null;
  }
}