public final class

MediaMetricsListener

extends java.lang.Object

implements AnalyticsListener, PlaybackSessionManager.Listener

 java.lang.Object

↳androidx.media3.exoplayer.analytics.MediaMetricsListener

Gradle dependencies

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

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

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

Overview

An AnalyticsListener that interacts with the Android .

It listens to playback events and forwards them to a . The of the playback session can be obtained with MediaMetricsListener.getLogSessionId().

Summary

Methods
public static MediaMetricsListenercreate(Context context)

Creates a media metrics listener.

public LogSessionIdgetLogSessionId()

Returns the used by this listener.

public voidonAdPlaybackStarted(AnalyticsListener.EventTime eventTime, java.lang.String contentSessionId, java.lang.String adSessionId)

public voidonBandwidthEstimate(AnalyticsListener.EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate)

public voidonDownstreamFormatChanged(AnalyticsListener.EventTime eventTime, MediaLoadData mediaLoadData)

public voidonEvents(Player player, AnalyticsListener.Events events)

public voidonLoadError(AnalyticsListener.EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, java.io.IOException error, boolean wasCanceled)

public voidonPlayerError(AnalyticsListener.EventTime eventTime, PlaybackException error)

public voidonPositionDiscontinuity(AnalyticsListener.EventTime eventTime, Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason)

public voidonSessionActive(AnalyticsListener.EventTime eventTime, java.lang.String sessionId)

public voidonSessionCreated(AnalyticsListener.EventTime eventTime, java.lang.String sessionId)

public voidonSessionFinished(AnalyticsListener.EventTime eventTime, java.lang.String sessionId, boolean automaticTransitionToNextPlayback)

public voidonVideoDisabled(AnalyticsListener.EventTime eventTime, DecoderCounters decoderCounters)

public voidonVideoSizeChanged(AnalyticsListener.EventTime eventTime, VideoSize videoSize)

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

Methods

public static MediaMetricsListener create(Context context)

Creates a media metrics listener.

Parameters:

context: A context.

Returns:

The MediaMetricsListener, or null if the isn't available.

public LogSessionId getLogSessionId()

Returns the used by this listener.

public void onSessionCreated(AnalyticsListener.EventTime eventTime, java.lang.String sessionId)

public void onSessionActive(AnalyticsListener.EventTime eventTime, java.lang.String sessionId)

public void onAdPlaybackStarted(AnalyticsListener.EventTime eventTime, java.lang.String contentSessionId, java.lang.String adSessionId)

public void onSessionFinished(AnalyticsListener.EventTime eventTime, java.lang.String sessionId, boolean automaticTransitionToNextPlayback)

public void onPositionDiscontinuity(AnalyticsListener.EventTime eventTime, Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason)

public void onVideoDisabled(AnalyticsListener.EventTime eventTime, DecoderCounters decoderCounters)

public void onBandwidthEstimate(AnalyticsListener.EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate)

public void onDownstreamFormatChanged(AnalyticsListener.EventTime eventTime, MediaLoadData mediaLoadData)

public void onVideoSizeChanged(AnalyticsListener.EventTime eventTime, VideoSize videoSize)

public void onLoadError(AnalyticsListener.EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, java.io.IOException error, boolean wasCanceled)

public void onPlayerError(AnalyticsListener.EventTime eventTime, PlaybackException error)

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

Source

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

import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Util.castNonNull;

import android.annotation.SuppressLint;
import android.content.Context;
import android.media.DeniedByServerException;
import android.media.MediaCodec;
import android.media.MediaDrm;
import android.media.MediaDrmResetException;
import android.media.NotProvisionedException;
import android.media.metrics.LogSessionId;
import android.media.metrics.MediaMetricsManager;
import android.media.metrics.NetworkEvent;
import android.media.metrics.PlaybackErrorEvent;
import android.media.metrics.PlaybackMetrics;
import android.media.metrics.PlaybackSession;
import android.media.metrics.PlaybackStateEvent;
import android.media.metrics.TrackChangeEvent;
import android.os.SystemClock;
import android.system.ErrnoException;
import android.system.OsConstants;
import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.media3.common.C;
import androidx.media3.common.C.ContentType;
import androidx.media3.common.DrmInitData;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.ParserException;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize;
import androidx.media3.common.util.NetworkTypeObserver;
import androidx.media3.common.util.NullableType;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.FileDataSource;
import androidx.media3.datasource.HttpDataSource;
import androidx.media3.datasource.UdpDataSource;
import androidx.media3.exoplayer.DecoderCounters;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager;
import androidx.media3.exoplayer.drm.DrmSession;
import androidx.media3.exoplayer.drm.UnsupportedDrmException;
import androidx.media3.exoplayer.mediacodec.MediaCodecDecoderException;
import androidx.media3.exoplayer.mediacodec.MediaCodecRenderer;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaSource;
import com.google.common.collect.ImmutableList;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.UUID;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/**
 * An {@link AnalyticsListener} that interacts with the Android {@link MediaMetricsManager}.
 *
 * <p>It listens to playback events and forwards them to a {@link PlaybackSession}. The {@link
 * LogSessionId} of the playback session can be obtained with {@link #getLogSessionId()}.
 */
@UnstableApi
@RequiresApi(31)
public final class MediaMetricsListener
    implements AnalyticsListener, PlaybackSessionManager.Listener {

  /**
   * Creates a media metrics listener.
   *
   * @param context A context.
   * @return The {@link MediaMetricsListener}, or null if the {@link Context#MEDIA_METRICS_SERVICE
   *     media metrics service} isn't available.
   */
  @Nullable
  public static MediaMetricsListener create(Context context) {
    @Nullable
    MediaMetricsManager mediaMetricsManager =
        (MediaMetricsManager) context.getSystemService(Context.MEDIA_METRICS_SERVICE);
    return mediaMetricsManager == null
        ? null
        : new MediaMetricsListener(context, mediaMetricsManager.createPlaybackSession());
  }

  private final Context context;
  private final PlaybackSessionManager sessionManager;
  private final PlaybackSession playbackSession;
  private final long startTimeMs;
  private final Timeline.Window window;
  private final Timeline.Period period;
  private final HashMap<String, Long> bandwidthTimeMs;
  private final HashMap<String, Long> bandwidthBytes;

  @Nullable private String activeSessionId;
  @Nullable private PlaybackMetrics.Builder metricsBuilder;
  private @Player.DiscontinuityReason int discontinuityReason;
  private int currentPlaybackState;
  private int currentNetworkType;
  @Nullable private PlaybackException pendingPlayerError;
  @Nullable private PendingFormatUpdate pendingVideoFormat;
  @Nullable private PendingFormatUpdate pendingAudioFormat;
  @Nullable private PendingFormatUpdate pendingTextFormat;
  @Nullable private Format currentVideoFormat;
  @Nullable private Format currentAudioFormat;
  @Nullable private Format currentTextFormat;
  private boolean isSeeking;
  private int ioErrorType;
  private boolean hasFatalError;
  private int droppedFrames;
  private int playedFrames;
  private int audioUnderruns;
  private boolean reportedEventsForCurrentSession;

  /**
   * Creates the listener.
   *
   * @param context A {@link Context}.
   */
  private MediaMetricsListener(Context context, PlaybackSession playbackSession) {
    context = context.getApplicationContext();
    this.context = context;
    this.playbackSession = playbackSession;
    window = new Timeline.Window();
    period = new Timeline.Period();
    bandwidthBytes = new HashMap<>();
    bandwidthTimeMs = new HashMap<>();
    startTimeMs = SystemClock.elapsedRealtime();
    currentPlaybackState = PlaybackStateEvent.STATE_NOT_STARTED;
    currentNetworkType = NetworkEvent.NETWORK_TYPE_UNKNOWN;
    sessionManager = new DefaultPlaybackSessionManager();
    sessionManager.setListener(this);
  }

  /** Returns the {@link LogSessionId} used by this listener. */
  public LogSessionId getLogSessionId() {
    return playbackSession.getSessionId();
  }

  // PlaybackSessionManager.Listener implementation.

  @Override
  public void onSessionCreated(EventTime eventTime, String sessionId) {}

  @Override
  public void onSessionActive(EventTime eventTime, String sessionId) {
    if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
      // Ignore ad sessions.
      return;
    }
    finishCurrentSession();
    activeSessionId = sessionId;
    metricsBuilder =
        new PlaybackMetrics.Builder()
            .setPlayerName(MediaLibraryInfo.TAG)
            .setPlayerVersion(MediaLibraryInfo.VERSION);
    maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId);
  }

  @Override
  public void onAdPlaybackStarted(
      EventTime eventTime, String contentSessionId, String adSessionId) {}

  @Override
  public void onSessionFinished(
      EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback) {
    if ((eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd())
        || !sessionId.equals(activeSessionId)) {
      // Ignore ad sessions and other sessions that are finished before becoming active.
    } else {
      finishCurrentSession();
    }
    bandwidthTimeMs.remove(sessionId);
    bandwidthBytes.remove(sessionId);
  }

  // AnalyticsListener implementation.

  @Override
  public void onPositionDiscontinuity(
      EventTime eventTime,
      Player.PositionInfo oldPosition,
      Player.PositionInfo newPosition,
      @Player.DiscontinuityReason int reason) {
    if (reason == Player.DISCONTINUITY_REASON_SEEK) {
      isSeeking = true;
    }
    discontinuityReason = reason;
  }

  @Override
  public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) {
    // TODO(b/181122234): DecoderCounters are not re-reported at period boundaries.
    droppedFrames += decoderCounters.droppedBufferCount;
    playedFrames += decoderCounters.renderedOutputBufferCount;
  }

  @Override
  public void onBandwidthEstimate(
      EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
    if (eventTime.mediaPeriodId != null) {
      String sessionId =
          sessionManager.getSessionForMediaPeriodId(
              eventTime.timeline, checkNotNull(eventTime.mediaPeriodId));
      @Nullable Long prevBandwidthBytes = bandwidthBytes.get(sessionId);
      @Nullable Long prevBandwidthTimeMs = bandwidthTimeMs.get(sessionId);
      bandwidthBytes.put(
          sessionId, (prevBandwidthBytes == null ? 0 : prevBandwidthBytes) + totalBytesLoaded);
      bandwidthTimeMs.put(
          sessionId, (prevBandwidthTimeMs == null ? 0 : prevBandwidthTimeMs) + totalLoadTimeMs);
    }
  }

  @Override
  public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
    if (eventTime.mediaPeriodId == null) {
      // This event arrived after the media has been removed from the playlist or a custom
      // MediaSource forgot to set the right id. Ignore the track change in these cases.
      return;
    }
    PendingFormatUpdate update =
        new PendingFormatUpdate(
            checkNotNull(mediaLoadData.trackFormat),
            mediaLoadData.trackSelectionReason,
            sessionManager.getSessionForMediaPeriodId(
                eventTime.timeline, checkNotNull(eventTime.mediaPeriodId)));
    switch (mediaLoadData.trackType) {
      case C.TRACK_TYPE_VIDEO:
      case C.TRACK_TYPE_DEFAULT:
        pendingVideoFormat = update;
        break;
      case C.TRACK_TYPE_AUDIO:
        pendingAudioFormat = update;
        break;
      case C.TRACK_TYPE_TEXT:
        pendingTextFormat = update;
        break;
      default:
        // Other track type. Ignore.
    }
  }

  @Override
  public void onVideoSizeChanged(EventTime eventTime, VideoSize videoSize) {
    @Nullable PendingFormatUpdate pendingVideoFormat = this.pendingVideoFormat;
    if (pendingVideoFormat != null && pendingVideoFormat.format.height == Format.NO_VALUE) {
      Format formatWithHeightAndWidth =
          pendingVideoFormat
              .format
              .buildUpon()
              .setWidth(videoSize.width)
              .setHeight(videoSize.height)
              .build();
      this.pendingVideoFormat =
          new PendingFormatUpdate(
              formatWithHeightAndWidth,
              pendingVideoFormat.selectionReason,
              pendingVideoFormat.sessionId);
    }
  }

  @Override
  public void onLoadError(
      EventTime eventTime,
      LoadEventInfo loadEventInfo,
      MediaLoadData mediaLoadData,
      IOException error,
      boolean wasCanceled) {
    ioErrorType = mediaLoadData.dataType;
  }

  @Override
  public void onPlayerError(EventTime eventTime, PlaybackException error) {
    pendingPlayerError = error;
  }

  @Override
  public void onEvents(Player player, Events events) {
    if (events.size() == 0) {
      return;
    }
    maybeAddSessions(events);

    long realtimeMs = SystemClock.elapsedRealtime();
    maybeUpdateMetricsBuilderValues(player, events);
    maybeReportPlaybackError(realtimeMs);
    maybeReportTrackChanges(player, events, realtimeMs);
    maybeReportNetworkChange(realtimeMs);
    maybeReportPlaybackStateChange(player, events, realtimeMs);

    if (events.contains(AnalyticsListener.EVENT_PLAYER_RELEASED)) {
      sessionManager.finishAllSessions(events.getEventTime(EVENT_PLAYER_RELEASED));
    }
  }

  private void maybeAddSessions(Events events) {
    for (int i = 0; i < events.size(); i++) {
      @EventFlags int event = events.get(i);
      EventTime eventTime = events.getEventTime(event);
      if (event == EVENT_TIMELINE_CHANGED) {
        sessionManager.updateSessionsWithTimelineChange(eventTime);
      } else if (event == EVENT_POSITION_DISCONTINUITY) {
        sessionManager.updateSessionsWithDiscontinuity(eventTime, discontinuityReason);
      } else {
        sessionManager.updateSessions(eventTime);
      }
    }
  }

  private void maybeUpdateMetricsBuilderValues(Player player, Events events) {
    if (events.contains(EVENT_TIMELINE_CHANGED)) {
      EventTime eventTime = events.getEventTime(EVENT_TIMELINE_CHANGED);
      if (metricsBuilder != null) {
        maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId);
      }
    }
    if (events.contains(EVENT_TRACKS_CHANGED) && metricsBuilder != null) {
      @Nullable DrmInitData drmInitData = getDrmInitData(player.getCurrentTracks().getGroups());
      if (drmInitData != null) {
        castNonNull(metricsBuilder).setDrmType(getDrmType(drmInitData));
      }
    }
    if (events.contains(EVENT_AUDIO_UNDERRUN)) {
      audioUnderruns++;
    }
  }

  private void maybeReportPlaybackError(long realtimeMs) {
    @Nullable PlaybackException error = pendingPlayerError;
    if (error == null) {
      return;
    }
    ErrorInfo errorInfo =
        getErrorInfo(
            error, context, /* lastIoErrorForManifest= */ ioErrorType == C.DATA_TYPE_MANIFEST);
    playbackSession.reportPlaybackErrorEvent(
        new PlaybackErrorEvent.Builder()
            .setTimeSinceCreatedMillis(realtimeMs - startTimeMs)
            .setErrorCode(errorInfo.errorCode)
            .setSubErrorCode(errorInfo.subErrorCode)
            .setException(error)
            .build());
    reportedEventsForCurrentSession = true;
    pendingPlayerError = null;
  }

  private void maybeReportTrackChanges(Player player, Events events, long realtimeMs) {
    if (events.contains(EVENT_TRACKS_CHANGED)) {
      Tracks tracks = player.getCurrentTracks();
      boolean isVideoSelected = tracks.isTypeSelected(C.TRACK_TYPE_VIDEO);
      boolean isAudioSelected = tracks.isTypeSelected(C.TRACK_TYPE_AUDIO);
      boolean isTextSelected = tracks.isTypeSelected(C.TRACK_TYPE_TEXT);
      if (isVideoSelected || isAudioSelected || isTextSelected) {
        // Ignore updates with insufficient information where no tracks are selected.
        if (!isVideoSelected) {
          maybeUpdateVideoFormat(realtimeMs, /* videoFormat= */ null, C.SELECTION_REASON_UNKNOWN);
        }
        if (!isAudioSelected) {
          maybeUpdateAudioFormat(realtimeMs, /* audioFormat= */ null, C.SELECTION_REASON_UNKNOWN);
        }
        if (!isTextSelected) {
          maybeUpdateTextFormat(realtimeMs, /* textFormat= */ null, C.SELECTION_REASON_UNKNOWN);
        }
      }
    }
    if (canReportPendingFormatUpdate(pendingVideoFormat)
        && pendingVideoFormat.format.height != Format.NO_VALUE) {
      maybeUpdateVideoFormat(
          realtimeMs, pendingVideoFormat.format, pendingVideoFormat.selectionReason);
      pendingVideoFormat = null;
    }
    if (canReportPendingFormatUpdate(pendingAudioFormat)) {
      maybeUpdateAudioFormat(
          realtimeMs, pendingAudioFormat.format, pendingAudioFormat.selectionReason);
      pendingAudioFormat = null;
    }
    if (canReportPendingFormatUpdate(pendingTextFormat)) {
      maybeUpdateTextFormat(
          realtimeMs, pendingTextFormat.format, pendingTextFormat.selectionReason);
      pendingTextFormat = null;
    }
  }

  @EnsuresNonNullIf(result = true, expression = "#1")
  private boolean canReportPendingFormatUpdate(@Nullable PendingFormatUpdate pendingFormatUpdate) {
    return pendingFormatUpdate != null
        && pendingFormatUpdate.sessionId.equals(sessionManager.getActiveSessionId());
  }

  private void maybeReportNetworkChange(long realtimeMs) {
    int networkType = getNetworkType(context);
    if (networkType != currentNetworkType) {
      currentNetworkType = networkType;
      playbackSession.reportNetworkEvent(
          new NetworkEvent.Builder()
              .setNetworkType(networkType)
              .setTimeSinceCreatedMillis(realtimeMs - startTimeMs)
              .build());
    }
  }

  private void maybeReportPlaybackStateChange(Player player, Events events, long realtimeMs) {
    if (player.getPlaybackState() != Player.STATE_BUFFERING) {
      isSeeking = false;
    }
    if (player.getPlayerError() == null) {
      hasFatalError = false;
    } else if (events.contains(EVENT_PLAYER_ERROR)) {
      hasFatalError = true;
    }
    int newPlaybackState = resolveNewPlaybackState(player);
    if (currentPlaybackState != newPlaybackState) {
      currentPlaybackState = newPlaybackState;
      reportedEventsForCurrentSession = true;
      playbackSession.reportPlaybackStateEvent(
          new PlaybackStateEvent.Builder()
              .setState(currentPlaybackState)
              .setTimeSinceCreatedMillis(realtimeMs - startTimeMs)
              .build());
    }
  }

  private int resolveNewPlaybackState(Player player) {
    @Player.State int playerPlaybackState = player.getPlaybackState();
    if (isSeeking) {
      // Seeking takes precedence over errors such that we report a seek while in error state.
      return PlaybackStateEvent.STATE_SEEKING;
    } else if (hasFatalError) {
      return PlaybackStateEvent.STATE_FAILED;
    } else if (playerPlaybackState == Player.STATE_ENDED) {
      return PlaybackStateEvent.STATE_ENDED;
    } else if (playerPlaybackState == Player.STATE_BUFFERING) {
      if (currentPlaybackState == PlaybackStateEvent.STATE_NOT_STARTED
          || currentPlaybackState == PlaybackStateEvent.STATE_JOINING_FOREGROUND
          || currentPlaybackState == PlaybackStateEvent.STATE_STOPPED) {
        return PlaybackStateEvent.STATE_JOINING_FOREGROUND;
      }
      if (!player.getPlayWhenReady()) {
        return PlaybackStateEvent.STATE_PAUSED_BUFFERING;
      }
      return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
          ? PlaybackStateEvent.STATE_SUPPRESSED_BUFFERING
          : PlaybackStateEvent.STATE_BUFFERING;
    } else if (playerPlaybackState == Player.STATE_READY) {
      if (!player.getPlayWhenReady()) {
        return PlaybackStateEvent.STATE_PAUSED;
      }
      return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
          ? PlaybackStateEvent.STATE_SUPPRESSED
          : PlaybackStateEvent.STATE_PLAYING;
    } else if (playerPlaybackState == Player.STATE_IDLE
        && currentPlaybackState != PlaybackStateEvent.STATE_NOT_STARTED) {
      // This case only applies for calls to player.stop(). All other IDLE cases are handled by
      // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored.
      return PlaybackStateEvent.STATE_STOPPED;
    }
    return currentPlaybackState;
  }

  private void maybeUpdateVideoFormat(
      long realtimeMs, @Nullable Format videoFormat, @C.SelectionReason int trackSelectionReason) {
    if (Util.areEqual(currentVideoFormat, videoFormat)) {
      return;
    }
    if (currentVideoFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) {
      trackSelectionReason = C.SELECTION_REASON_INITIAL;
    }
    currentVideoFormat = videoFormat;
    reportTrackChangeEvent(
        TrackChangeEvent.TRACK_TYPE_VIDEO, realtimeMs, videoFormat, trackSelectionReason);
  }

  private void maybeUpdateAudioFormat(
      long realtimeMs, @Nullable Format audioFormat, @C.SelectionReason int trackSelectionReason) {
    if (Util.areEqual(currentAudioFormat, audioFormat)) {
      return;
    }
    if (currentAudioFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) {
      trackSelectionReason = C.SELECTION_REASON_INITIAL;
    }
    currentAudioFormat = audioFormat;
    reportTrackChangeEvent(
        TrackChangeEvent.TRACK_TYPE_AUDIO, realtimeMs, audioFormat, trackSelectionReason);
  }

  private void maybeUpdateTextFormat(
      long realtimeMs, @Nullable Format textFormat, @C.SelectionReason int trackSelectionReason) {
    if (Util.areEqual(currentTextFormat, textFormat)) {
      return;
    }
    if (currentTextFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) {
      trackSelectionReason = C.SELECTION_REASON_INITIAL;
    }
    currentTextFormat = textFormat;
    reportTrackChangeEvent(
        TrackChangeEvent.TRACK_TYPE_TEXT, realtimeMs, textFormat, trackSelectionReason);
  }

  private void reportTrackChangeEvent(
      int type,
      long realtimeMs,
      @Nullable Format format,
      @C.SelectionReason int trackSelectionReason) {
    TrackChangeEvent.Builder builder =
        new TrackChangeEvent.Builder(type).setTimeSinceCreatedMillis(realtimeMs - startTimeMs);
    if (format != null) {
      builder.setTrackState(TrackChangeEvent.TRACK_STATE_ON);
      builder.setTrackChangeReason(getTrackChangeReason(trackSelectionReason));
      if (format.containerMimeType != null) {
        // TODO(b/181121074): Progressive container MIME type is not filled in by MediaSource.
        builder.setContainerMimeType(format.containerMimeType);
      }
      if (format.sampleMimeType != null) {
        builder.setSampleMimeType(format.sampleMimeType);
      }
      if (format.codecs != null) {
        builder.setCodecName(format.codecs);
      }
      if (format.bitrate != Format.NO_VALUE) {
        builder.setBitrate(format.bitrate);
      }
      if (format.width != Format.NO_VALUE) {
        builder.setWidth(format.width);
      }
      if (format.height != Format.NO_VALUE) {
        builder.setHeight(format.height);
      }
      if (format.channelCount != Format.NO_VALUE) {
        builder.setChannelCount(format.channelCount);
      }
      if (format.sampleRate != Format.NO_VALUE) {
        builder.setAudioSampleRate(format.sampleRate);
      }
      if (format.language != null) {
        Pair<String, @NullableType String> languageAndRegion =
            getLanguageAndRegion(format.language);
        builder.setLanguage(languageAndRegion.first);
        if (languageAndRegion.second != null) {
          builder.setLanguageRegion(languageAndRegion.second);
        }
      }
      if (format.frameRate != Format.NO_VALUE) {
        builder.setVideoFrameRate(format.frameRate);
      }
    } else {
      builder.setTrackState(TrackChangeEvent.TRACK_STATE_OFF);
    }
    reportedEventsForCurrentSession = true;
    playbackSession.reportTrackChangeEvent(builder.build());
  }

  @RequiresNonNull("metricsBuilder")
  private void maybeUpdateTimelineMetadata(
      Timeline timeline, @Nullable MediaSource.MediaPeriodId mediaPeriodId) {
    PlaybackMetrics.Builder metricsBuilder = this.metricsBuilder;
    if (mediaPeriodId == null) {
      return;
    }
    int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid);
    if (periodIndex == C.INDEX_UNSET) {
      return;
    }
    timeline.getPeriod(periodIndex, period);
    timeline.getWindow(period.windowIndex, window);
    metricsBuilder.setStreamType(getStreamType(window.mediaItem));
    if (window.durationUs != C.TIME_UNSET
        && !window.isPlaceholder
        && !window.isDynamic
        && !window.isLive()) {
      metricsBuilder.setMediaDurationMillis(window.getDurationMs());
    }
    metricsBuilder.setPlaybackType(
        window.isLive() ? PlaybackMetrics.PLAYBACK_TYPE_LIVE : PlaybackMetrics.PLAYBACK_TYPE_VOD);
    reportedEventsForCurrentSession = true;
  }

  private void finishCurrentSession() {
    if (metricsBuilder != null && reportedEventsForCurrentSession) {
      metricsBuilder.setAudioUnderrunCount(audioUnderruns);
      metricsBuilder.setVideoFramesDropped(droppedFrames);
      metricsBuilder.setVideoFramesPlayed(playedFrames);
      @Nullable Long networkTimeMs = bandwidthTimeMs.get(activeSessionId);
      metricsBuilder.setNetworkTransferDurationMillis(networkTimeMs == null ? 0 : networkTimeMs);
      // TODO(b/181121847): Report localBytesRead. This requires additional callbacks or plumbing.
      @Nullable Long networkBytes = bandwidthBytes.get(activeSessionId);
      metricsBuilder.setNetworkBytesRead(networkBytes == null ? 0 : networkBytes);
      // TODO(b/181121847): Detect stream sources mixed and local depending on localBytesRead.
      metricsBuilder.setStreamSource(
          networkBytes != null && networkBytes > 0
              ? PlaybackMetrics.STREAM_SOURCE_NETWORK
              : PlaybackMetrics.STREAM_SOURCE_UNKNOWN);
      playbackSession.reportPlaybackMetrics(metricsBuilder.build());
    }
    metricsBuilder = null;
    activeSessionId = null;
    audioUnderruns = 0;
    droppedFrames = 0;
    playedFrames = 0;
    currentVideoFormat = null;
    currentAudioFormat = null;
    currentTextFormat = null;
    reportedEventsForCurrentSession = false;
  }

  private static int getTrackChangeReason(@C.SelectionReason int trackSelectionReason) {
    switch (trackSelectionReason) {
      case C.SELECTION_REASON_INITIAL:
        return TrackChangeEvent.TRACK_CHANGE_REASON_INITIAL;
      case C.SELECTION_REASON_ADAPTIVE:
        return TrackChangeEvent.TRACK_CHANGE_REASON_ADAPTIVE;
      case C.SELECTION_REASON_MANUAL:
        return TrackChangeEvent.TRACK_CHANGE_REASON_MANUAL;
      case C.SELECTION_REASON_TRICK_PLAY:
      case C.SELECTION_REASON_UNKNOWN:
      default:
        return TrackChangeEvent.TRACK_CHANGE_REASON_OTHER;
    }
  }

  private static Pair<String, @NullableType String> getLanguageAndRegion(String languageCode) {
    String[] parts = Util.split(languageCode, "-");
    return Pair.create(parts[0], parts.length >= 2 ? parts[1] : null);
  }

  private static int getNetworkType(Context context) {
    switch (NetworkTypeObserver.getInstance(context).getNetworkType()) {
      case C.NETWORK_TYPE_WIFI:
        return NetworkEvent.NETWORK_TYPE_WIFI;
      case C.NETWORK_TYPE_2G:
        return NetworkEvent.NETWORK_TYPE_2G;
      case C.NETWORK_TYPE_3G:
        return NetworkEvent.NETWORK_TYPE_3G;
      case C.NETWORK_TYPE_4G:
        return NetworkEvent.NETWORK_TYPE_4G;
      case C.NETWORK_TYPE_5G_SA:
        return NetworkEvent.NETWORK_TYPE_5G_SA;
      case C.NETWORK_TYPE_5G_NSA:
        return NetworkEvent.NETWORK_TYPE_5G_NSA;
      case C.NETWORK_TYPE_ETHERNET:
        return NetworkEvent.NETWORK_TYPE_ETHERNET;
      case C.NETWORK_TYPE_OFFLINE:
        return NetworkEvent.NETWORK_TYPE_OFFLINE;
      case C.NETWORK_TYPE_UNKNOWN:
        return NetworkEvent.NETWORK_TYPE_UNKNOWN;
      default:
        return NetworkEvent.NETWORK_TYPE_OTHER;
    }
  }

  private static int getStreamType(MediaItem mediaItem) {
    if (mediaItem.localConfiguration == null) {
      return PlaybackMetrics.STREAM_TYPE_UNKNOWN;
    }
    @ContentType
    int contentType =
        Util.inferContentTypeForUriAndMimeType(
            mediaItem.localConfiguration.uri, mediaItem.localConfiguration.mimeType);
    switch (contentType) {
      case C.CONTENT_TYPE_HLS:
        return PlaybackMetrics.STREAM_TYPE_HLS;
      case C.CONTENT_TYPE_DASH:
        return PlaybackMetrics.STREAM_TYPE_DASH;
      case C.CONTENT_TYPE_SS:
        return PlaybackMetrics.STREAM_TYPE_SS;
      case C.CONTENT_TYPE_RTSP:
      default:
        return PlaybackMetrics.STREAM_TYPE_OTHER;
    }
  }

  private static ErrorInfo getErrorInfo(
      PlaybackException error, Context context, boolean lastIoErrorForManifest) {
    if (error.errorCode == PlaybackException.ERROR_CODE_REMOTE_ERROR) {
      return new ErrorInfo(PlaybackErrorEvent.ERROR_PLAYER_REMOTE, /* subErrorCode= */ 0);
    }
    // Unpack the PlaybackException.
    // TODO(b/190203080): Use error codes instead of the Exception's cause where possible.
    boolean isRendererExoPlaybackException = false;
    int rendererFormatSupport = C.FORMAT_UNSUPPORTED_TYPE;
    if (error instanceof ExoPlaybackException) {
      ExoPlaybackException exoPlaybackException = (ExoPlaybackException) error;
      isRendererExoPlaybackException =
          exoPlaybackException.type == ExoPlaybackException.TYPE_RENDERER;
      rendererFormatSupport = exoPlaybackException.rendererFormatSupport;
    }
    Throwable cause = checkNotNull(error.getCause());
    if (cause instanceof IOException) {
      if (cause instanceof HttpDataSource.InvalidResponseCodeException) {
        int responseCode = ((HttpDataSource.InvalidResponseCodeException) cause).responseCode;
        return new ErrorInfo(
            PlaybackErrorEvent.ERROR_IO_BAD_HTTP_STATUS, /* subErrorCode= */ responseCode);
      } else if (cause instanceof HttpDataSource.InvalidContentTypeException
          || cause instanceof ParserException) {
        return new ErrorInfo(
            lastIoErrorForManifest
                ? PlaybackErrorEvent.ERROR_PARSING_MANIFEST_MALFORMED
                : PlaybackErrorEvent.ERROR_PARSING_CONTAINER_MALFORMED,
            /* subErrorCode= */ 0);
      } else if (cause instanceof HttpDataSource.HttpDataSourceException
          || cause instanceof UdpDataSource.UdpDataSourceException) {
        if (NetworkTypeObserver.getInstance(context).getNetworkType() == C.NETWORK_TYPE_OFFLINE) {
          return new ErrorInfo(
              PlaybackErrorEvent.ERROR_IO_NETWORK_UNAVAILABLE, /* subErrorCode= */ 0);
        } else {
          @Nullable Throwable detailedCause = cause.getCause();
          if (detailedCause instanceof UnknownHostException) {
            return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_DNS_FAILED, /* subErrorCode= */ 0);
          } else if (detailedCause instanceof SocketTimeoutException) {
            return new ErrorInfo(
                PlaybackErrorEvent.ERROR_IO_CONNECTION_TIMEOUT, /* subErrorCode= */ 0);
          } else if (cause instanceof HttpDataSource.HttpDataSourceException
              && ((HttpDataSource.HttpDataSourceException) cause).type
                  == HttpDataSource.HttpDataSourceException.TYPE_OPEN) {
            return new ErrorInfo(
                PlaybackErrorEvent.ERROR_IO_NETWORK_CONNECTION_FAILED, /* subErrorCode= */ 0);
          } else {
            return new ErrorInfo(
                PlaybackErrorEvent.ERROR_IO_CONNECTION_CLOSED, /* subErrorCode= */ 0);
          }
        }
      } else if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
        return new ErrorInfo(
            PlaybackErrorEvent.ERROR_PLAYER_BEHIND_LIVE_WINDOW, /* subErrorCode= */ 0);
      } else if (cause instanceof DrmSession.DrmSessionException) {
        // Unpack DrmSessionException.
        cause = checkNotNull(cause.getCause());
        if (cause instanceof MediaDrm.MediaDrmStateException) {
          String diagnosticsInfo = ((MediaDrm.MediaDrmStateException) cause).getDiagnosticInfo();
          int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo);
          int errorCode = getDrmErrorCode(subErrorCode);
          return new ErrorInfo(errorCode, subErrorCode);
        } else if (Util.SDK_INT >= 23 && cause instanceof MediaDrmResetException) {
          return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_SYSTEM_ERROR, /* subErrorCode= */ 0);
        } else if (cause instanceof NotProvisionedException) {
          return new ErrorInfo(
              PlaybackErrorEvent.ERROR_DRM_PROVISIONING_FAILED, /* subErrorCode= */ 0);
        } else if (cause instanceof DeniedByServerException) {
          return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_DEVICE_REVOKED, /* subErrorCode= */ 0);
        } else if (cause instanceof UnsupportedDrmException) {
          return new ErrorInfo(
              PlaybackErrorEvent.ERROR_DRM_SCHEME_UNSUPPORTED, /* subErrorCode= */ 0);
        } else if (cause instanceof DefaultDrmSessionManager.MissingSchemeDataException) {
          return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_CONTENT_ERROR, /* subErrorCode= */ 0);
        } else {
          return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_OTHER, /* subErrorCode= */ 0);
        }
      } else if (cause instanceof FileDataSource.FileDataSourceException
          && cause.getCause() instanceof FileNotFoundException) {
        @Nullable Throwable notFoundCause = checkNotNull(cause.getCause()).getCause();
        if (notFoundCause instanceof ErrnoException
            && ((ErrnoException) notFoundCause).errno == OsConstants.EACCES) {
          return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_NO_PERMISSION, /* subErrorCode= */ 0);
        } else {
          return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_FILE_NOT_FOUND, /* subErrorCode= */ 0);
        }
      } else {
        return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_OTHER, /* subErrorCode= */ 0);
      }
    } else if (isRendererExoPlaybackException
        && (rendererFormatSupport == C.FORMAT_UNSUPPORTED_TYPE
            || rendererFormatSupport == C.FORMAT_UNSUPPORTED_SUBTYPE)) {
      return new ErrorInfo(
          PlaybackErrorEvent.ERROR_DECODING_FORMAT_UNSUPPORTED, /* subErrorCode= */ 0);
    } else if (isRendererExoPlaybackException
        && rendererFormatSupport == C.FORMAT_EXCEEDS_CAPABILITIES) {
      return new ErrorInfo(
          PlaybackErrorEvent.ERROR_DECODING_FORMAT_EXCEEDS_CAPABILITIES, /* subErrorCode= */ 0);
    } else if (isRendererExoPlaybackException
        && rendererFormatSupport == C.FORMAT_UNSUPPORTED_DRM) {
      return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_SCHEME_UNSUPPORTED, /* subErrorCode= */ 0);
    } else if (cause instanceof MediaCodecRenderer.DecoderInitializationException) {
      @Nullable
      String diagnosticsInfo =
          ((MediaCodecRenderer.DecoderInitializationException) cause).diagnosticInfo;
      int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo);
      return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODER_INIT_FAILED, subErrorCode);
    } else if (cause instanceof MediaCodecDecoderException) {
      int subErrorCode = ((MediaCodecDecoderException) cause).errorCode;
      return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODING_FAILED, subErrorCode);
    } else if (cause instanceof OutOfMemoryError) {
      return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODING_FAILED, /* subErrorCode= */ 0);
    } else if (cause instanceof AudioSink.InitializationException) {
      int subErrorCode = ((AudioSink.InitializationException) cause).audioTrackState;
      return new ErrorInfo(PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, subErrorCode);
    } else if (cause instanceof AudioSink.WriteException) {
      int subErrorCode = ((AudioSink.WriteException) cause).errorCode;
      return new ErrorInfo(PlaybackErrorEvent.ERROR_AUDIO_TRACK_WRITE_FAILED, subErrorCode);
    } else if (cause instanceof MediaCodec.CryptoException) {
      int subErrorCode = ((MediaCodec.CryptoException) cause).getErrorCode();
      int errorCode = getDrmErrorCode(subErrorCode);
      return new ErrorInfo(errorCode, subErrorCode);
    } else {
      return new ErrorInfo(PlaybackErrorEvent.ERROR_PLAYER_OTHER, /* subErrorCode= */ 0);
    }
  }

  @Nullable
  private static DrmInitData getDrmInitData(ImmutableList<Tracks.Group> trackGroups) {
    for (Tracks.Group trackGroup : trackGroups) {
      for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
        if (trackGroup.isTrackSelected(trackIndex)) {
          @Nullable DrmInitData drmInitData = trackGroup.getTrackFormat(trackIndex).drmInitData;
          if (drmInitData != null) {
            return drmInitData;
          }
        }
      }
    }
    return null;
  }

  private static int getDrmType(DrmInitData drmInitData) {
    for (int i = 0; i < drmInitData.schemeDataCount; i++) {
      UUID uuid = drmInitData.get(i).uuid;
      if (uuid.equals(C.WIDEVINE_UUID)) {
        // TODO(b/77625596): Forward MediaDrm metrics to distinguish between L1 and L3 and to set
        //  the drm session id.
        return PlaybackMetrics.DRM_TYPE_WIDEVINE_L1;
      }
      if (uuid.equals(C.PLAYREADY_UUID)) {
        return PlaybackMetrics.DRM_TYPE_PLAY_READY;
      }
      if (uuid.equals(C.CLEARKEY_UUID)) {
        return PlaybackMetrics.DRM_TYPE_CLEARKEY;
      }
    }
    return PlaybackMetrics.DRM_TYPE_OTHER;
  }

  @SuppressLint("SwitchIntDef") // Only DRM error codes are relevant here.
  private static int getDrmErrorCode(int mediaDrmErrorCode) {
    switch (Util.getErrorCodeForMediaDrmErrorCode(mediaDrmErrorCode)) {
      case PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED:
        return PlaybackErrorEvent.ERROR_DRM_PROVISIONING_FAILED;
      case PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED:
        return PlaybackErrorEvent.ERROR_DRM_LICENSE_ACQUISITION_FAILED;
      case PlaybackException.ERROR_CODE_DRM_DISALLOWED_OPERATION:
        return PlaybackErrorEvent.ERROR_DRM_DISALLOWED_OPERATION;
      case PlaybackException.ERROR_CODE_DRM_CONTENT_ERROR:
        return PlaybackErrorEvent.ERROR_DRM_CONTENT_ERROR;
      case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR:
      default:
        return PlaybackErrorEvent.ERROR_DRM_SYSTEM_ERROR;
    }
  }

  private static final class ErrorInfo {

    public final int errorCode;
    public final int subErrorCode;

    public ErrorInfo(int errorCode, int subErrorCode) {
      this.errorCode = errorCode;
      this.subErrorCode = subErrorCode;
    }
  }

  private static final class PendingFormatUpdate {

    public final Format format;
    public final @C.SelectionReason int selectionReason;
    public final String sessionId;

    public PendingFormatUpdate(
        Format format, @C.SelectionReason int selectionReason, String sessionId) {
      this.format = format;
      this.selectionReason = selectionReason;
      this.sessionId = sessionId;
    }
  }
}