PlaybackStatsListener.java

/*
 * Copyright (C) 2019 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 java.lang.Math.max;

import android.os.SystemClock;
import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.Timeline.Period;
import androidx.media3.common.TracksInfo;
import androidx.media3.common.VideoSize;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.analytics.PlaybackStats.EventTimeAndException;
import androidx.media3.exoplayer.analytics.PlaybackStats.EventTimeAndFormat;
import androidx.media3.exoplayer.analytics.PlaybackStats.EventTimeAndPlaybackState;
import androidx.media3.exoplayer.analytics.PlaybackStats.PlaybackState;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * {@link AnalyticsListener} to gather {@link PlaybackStats} from the player.
 *
 * <p>For accurate measurements, the listener should be added to the player before loading media,
 * i.e., {@link Player#getPlaybackState()} should be {@link Player#STATE_IDLE}.
 *
 * <p>Playback stats are gathered separately for each playback session, i.e. each window in the
 * {@link Timeline} and each single ad.
 */
@UnstableApi
public final class PlaybackStatsListener
    implements AnalyticsListener, PlaybackSessionManager.Listener {

  /** A listener for {@link PlaybackStats} updates. */
  public interface Callback {

    /**
     * Called when a playback session ends and its {@link PlaybackStats} are ready.
     *
     * @param eventTime The {@link EventTime} at which the playback session started. Can be used to
     *     identify the playback session.
     * @param playbackStats The {@link PlaybackStats} for the ended playback session.
     */
    void onPlaybackStatsReady(EventTime eventTime, PlaybackStats playbackStats);
  }

  private final PlaybackSessionManager sessionManager;
  private final Map<String, PlaybackStatsTracker> playbackStatsTrackers;
  private final Map<String, EventTime> sessionStartEventTimes;
  @Nullable private final Callback callback;
  private final boolean keepHistory;
  private final Period period;

  private PlaybackStats finishedPlaybackStats;

  @Nullable private String discontinuityFromSession;
  private long discontinuityFromPositionMs;
  @Player.DiscontinuityReason private int discontinuityReason;
  private int droppedFrames;
  @Nullable private Exception nonFatalException;
  private long bandwidthTimeMs;
  private long bandwidthBytes;
  @Nullable private Format videoFormat;
  @Nullable private Format audioFormat;
  private VideoSize videoSize;

  /**
   * Creates listener for playback stats.
   *
   * @param keepHistory Whether the reported {@link PlaybackStats} should keep the full history of
   *     events.
   * @param callback An optional callback for finished {@link PlaybackStats}.
   */
  public PlaybackStatsListener(boolean keepHistory, @Nullable Callback callback) {
    this.callback = callback;
    this.keepHistory = keepHistory;
    sessionManager = new DefaultPlaybackSessionManager();
    playbackStatsTrackers = new HashMap<>();
    sessionStartEventTimes = new HashMap<>();
    finishedPlaybackStats = PlaybackStats.EMPTY;
    period = new Period();
    videoSize = VideoSize.UNKNOWN;
    sessionManager.setListener(this);
  }

  /**
   * Returns the combined {@link PlaybackStats} for all playback sessions this listener was and is
   * listening to.
   *
   * <p>Note that these {@link PlaybackStats} will not contain the full history of events.
   *
   * @return The combined {@link PlaybackStats} for all playback sessions.
   */
  public PlaybackStats getCombinedPlaybackStats() {
    PlaybackStats[] allPendingPlaybackStats = new PlaybackStats[playbackStatsTrackers.size() + 1];
    allPendingPlaybackStats[0] = finishedPlaybackStats;
    int index = 1;
    for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) {
      allPendingPlaybackStats[index++] = tracker.build(/* isFinal= */ false);
    }
    return PlaybackStats.merge(allPendingPlaybackStats);
  }

  /**
   * Returns the {@link PlaybackStats} for the currently playback session, or null if no session is
   * active.
   *
   * @return {@link PlaybackStats} for the current playback session.
   */
  @Nullable
  public PlaybackStats getPlaybackStats() {
    @Nullable String activeSessionId = sessionManager.getActiveSessionId();
    @Nullable
    PlaybackStatsTracker activeStatsTracker =
        activeSessionId == null ? null : playbackStatsTrackers.get(activeSessionId);
    return activeStatsTracker == null ? null : activeStatsTracker.build(/* isFinal= */ false);
  }

  // PlaybackSessionManager.Listener implementation.

  @Override
  public void onSessionCreated(EventTime eventTime, String sessionId) {
    PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime);
    playbackStatsTrackers.put(sessionId, tracker);
    sessionStartEventTimes.put(sessionId, eventTime);
  }

  @Override
  public void onSessionActive(EventTime eventTime, String sessionId) {
    checkNotNull(playbackStatsTrackers.get(sessionId)).onForeground();
  }

  @Override
  public void onAdPlaybackStarted(
      EventTime eventTime, String contentSessionId, String adSessionId) {
    checkNotNull(playbackStatsTrackers.get(contentSessionId)).onInterruptedByAd();
  }

  @Override
  public void onSessionFinished(
      EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback) {
    PlaybackStatsTracker tracker = checkNotNull(playbackStatsTrackers.remove(sessionId));
    EventTime startEventTime = checkNotNull(sessionStartEventTimes.remove(sessionId));
    long discontinuityFromPositionMs =
        sessionId.equals(discontinuityFromSession)
            ? this.discontinuityFromPositionMs
            : C.TIME_UNSET;
    tracker.onFinished(eventTime, automaticTransitionToNextPlayback, discontinuityFromPositionMs);
    PlaybackStats playbackStats = tracker.build(/* isFinal= */ true);
    finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats);
    if (callback != null) {
      callback.onPlaybackStatsReady(startEventTime, playbackStats);
    }
  }

  // AnalyticsListener implementation.

  @Override
  public void onPositionDiscontinuity(
      EventTime eventTime,
      Player.PositionInfo oldPosition,
      Player.PositionInfo newPosition,
      @Player.DiscontinuityReason int reason) {
    if (discontinuityFromSession == null) {
      discontinuityFromSession = sessionManager.getActiveSessionId();
      discontinuityFromPositionMs = oldPosition.positionMs;
    }
    discontinuityReason = reason;
  }

  @Override
  public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {
    this.droppedFrames = droppedFrames;
  }

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

  @Override
  public void onDrmSessionManagerError(EventTime eventTime, Exception error) {
    nonFatalException = error;
  }

  @Override
  public void onBandwidthEstimate(
      EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
    bandwidthTimeMs = totalLoadTimeMs;
    bandwidthBytes = totalBytesLoaded;
  }

  @Override
  public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
    if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO
        || mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) {
      videoFormat = mediaLoadData.trackFormat;
    } else if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) {
      audioFormat = mediaLoadData.trackFormat;
    }
  }

  @Override
  public void onVideoSizeChanged(EventTime eventTime, VideoSize videoSize) {
    this.videoSize = videoSize;
  }

  @Override
  public void onEvents(Player player, Events events) {
    if (events.size() == 0) {
      return;
    }
    maybeAddSessions(events);
    for (String session : playbackStatsTrackers.keySet()) {
      Pair<EventTime, Boolean> eventTimeAndBelongsToPlayback = findBestEventTime(events, session);
      PlaybackStatsTracker tracker = playbackStatsTrackers.get(session);
      boolean hasDiscontinuityToPlayback = hasEvent(events, session, EVENT_POSITION_DISCONTINUITY);
      boolean hasDroppedFrames = hasEvent(events, session, EVENT_DROPPED_VIDEO_FRAMES);
      boolean hasAudioUnderrun = hasEvent(events, session, EVENT_AUDIO_UNDERRUN);
      boolean startedLoading = hasEvent(events, session, EVENT_LOAD_STARTED);
      boolean hasFatalError = hasEvent(events, session, EVENT_PLAYER_ERROR);
      boolean hasNonFatalException =
          hasEvent(events, session, EVENT_LOAD_ERROR)
              || hasEvent(events, session, EVENT_DRM_SESSION_MANAGER_ERROR);
      boolean hasBandwidthData = hasEvent(events, session, EVENT_BANDWIDTH_ESTIMATE);
      boolean hasFormatData = hasEvent(events, session, EVENT_DOWNSTREAM_FORMAT_CHANGED);
      boolean hasVideoSize = hasEvent(events, session, EVENT_VIDEO_SIZE_CHANGED);
      tracker.onEvents(
          player,
          /* eventTime= */ eventTimeAndBelongsToPlayback.first,
          /* belongsToPlayback= */ eventTimeAndBelongsToPlayback.second,
          session.equals(discontinuityFromSession) ? discontinuityFromPositionMs : C.TIME_UNSET,
          hasDiscontinuityToPlayback,
          hasDroppedFrames ? droppedFrames : 0,
          hasAudioUnderrun,
          startedLoading,
          hasFatalError ? player.getPlayerError() : null,
          hasNonFatalException ? nonFatalException : null,
          hasBandwidthData ? bandwidthTimeMs : 0,
          hasBandwidthData ? bandwidthBytes : 0,
          hasFormatData ? videoFormat : null,
          hasFormatData ? audioFormat : null,
          hasVideoSize ? videoSize : null);
    }
    videoFormat = null;
    audioFormat = null;
    discontinuityFromSession = null;
    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 Pair<EventTime, Boolean> findBestEventTime(Events events, String session) {
    @Nullable EventTime eventTime = null;
    boolean belongsToPlayback = false;
    for (int i = 0; i < events.size(); i++) {
      @EventFlags int event = events.get(i);
      EventTime newEventTime = events.getEventTime(event);
      boolean newBelongsToPlayback = sessionManager.belongsToSession(newEventTime, session);
      if (eventTime == null
          || (newBelongsToPlayback && !belongsToPlayback)
          || (newBelongsToPlayback == belongsToPlayback
              && newEventTime.realtimeMs > eventTime.realtimeMs)) {
        // Prefer event times for the current playback and prefer later timestamps.
        eventTime = newEventTime;
        belongsToPlayback = newBelongsToPlayback;
      }
    }
    checkNotNull(eventTime);
    if (!belongsToPlayback && eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
      // Replace ad event time with content event time unless it's for the ad playback itself.
      long contentPeriodPositionUs =
          eventTime
              .timeline
              .getPeriodByUid(eventTime.mediaPeriodId.periodUid, period)
              .getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex);
      if (contentPeriodPositionUs == C.TIME_END_OF_SOURCE) {
        contentPeriodPositionUs = period.durationUs;
      }
      long contentWindowPositionUs = contentPeriodPositionUs + period.getPositionInWindowUs();
      eventTime =
          new EventTime(
              eventTime.realtimeMs,
              eventTime.timeline,
              eventTime.windowIndex,
              new MediaPeriodId(
                  eventTime.mediaPeriodId.periodUid,
                  eventTime.mediaPeriodId.windowSequenceNumber,
                  eventTime.mediaPeriodId.adGroupIndex),
              /* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs),
              eventTime.timeline,
              eventTime.currentWindowIndex,
              eventTime.currentMediaPeriodId,
              eventTime.currentPlaybackPositionMs,
              eventTime.totalBufferedDurationMs);
      belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
    }
    return Pair.create(eventTime, belongsToPlayback);
  }

  private boolean hasEvent(Events events, String session, @EventFlags int event) {
    return events.contains(event)
        && sessionManager.belongsToSession(events.getEventTime(event), session);
  }

  /** Tracker for playback stats of a single playback. */
  private static final class PlaybackStatsTracker {

    // Final stats.
    private final boolean keepHistory;
    private final long[] playbackStateDurationsMs;
    private final List<EventTimeAndPlaybackState> playbackStateHistory;
    private final List<long[]> mediaTimeHistory;
    private final List<EventTimeAndFormat> videoFormatHistory;
    private final List<EventTimeAndFormat> audioFormatHistory;
    private final List<EventTimeAndException> fatalErrorHistory;
    private final List<EventTimeAndException> nonFatalErrorHistory;
    private final boolean isAd;

    private long firstReportedTimeMs;
    private boolean hasBeenReady;
    private boolean hasEnded;
    private boolean isJoinTimeInvalid;
    private int pauseCount;
    private int pauseBufferCount;
    private int seekCount;
    private int rebufferCount;
    private long maxRebufferTimeMs;
    private int initialVideoFormatHeight;
    private long initialVideoFormatBitrate;
    private long initialAudioFormatBitrate;
    private long videoFormatHeightTimeMs;
    private long videoFormatHeightTimeProduct;
    private long videoFormatBitrateTimeMs;
    private long videoFormatBitrateTimeProduct;
    private long audioFormatTimeMs;
    private long audioFormatBitrateTimeProduct;
    private long bandwidthTimeMs;
    private long bandwidthBytes;
    private long droppedFrames;
    private long audioUnderruns;
    private int fatalErrorCount;
    private int nonFatalErrorCount;

    // Current player state tracking.
    private @PlaybackState int currentPlaybackState;
    private long currentPlaybackStateStartTimeMs;
    private boolean isSeeking;
    private boolean isForeground;
    private boolean isInterruptedByAd;
    private boolean hasFatalError;
    private boolean startedLoading;
    private long lastRebufferStartTimeMs;
    @Nullable private Format currentVideoFormat;
    @Nullable private Format currentAudioFormat;
    private long lastVideoFormatStartTimeMs;
    private long lastAudioFormatStartTimeMs;
    private float currentPlaybackSpeed;

    /**
     * Creates a tracker for playback stats.
     *
     * @param keepHistory Whether to keep a full history of events.
     * @param startTime The {@link EventTime} at which the playback stats start.
     */
    public PlaybackStatsTracker(boolean keepHistory, EventTime startTime) {
      this.keepHistory = keepHistory;
      playbackStateDurationsMs = new long[PlaybackStats.PLAYBACK_STATE_COUNT];
      playbackStateHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      mediaTimeHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      videoFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      audioFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      fatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      nonFatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      currentPlaybackState = PlaybackStats.PLAYBACK_STATE_NOT_STARTED;
      currentPlaybackStateStartTimeMs = startTime.realtimeMs;
      firstReportedTimeMs = C.TIME_UNSET;
      maxRebufferTimeMs = C.TIME_UNSET;
      isAd = startTime.mediaPeriodId != null && startTime.mediaPeriodId.isAd();
      initialAudioFormatBitrate = C.LENGTH_UNSET;
      initialVideoFormatBitrate = C.LENGTH_UNSET;
      initialVideoFormatHeight = C.LENGTH_UNSET;
      currentPlaybackSpeed = 1f;
    }

    /** Notifies the tracker that the current playback became the active foreground playback. */
    public void onForeground() {
      isForeground = true;
    }

    /** Notifies the tracker that the current playback is interrupted by an ad. */
    public void onInterruptedByAd() {
      isInterruptedByAd = true;
      isSeeking = false;
    }

    /**
     * Notifies the tracker that the current playback has finished.
     *
     * @param eventTime The {@link EventTime}. Does not belong to this playback.
     * @param automaticTransition Whether the playback finished because of an automatic transition
     *     to the next playback item.
     * @param discontinuityFromPositionMs The position before the discontinuity from this playback,
     *     {@link C#TIME_UNSET} if no discontinuity started from this playback.
     */
    public void onFinished(
        EventTime eventTime, boolean automaticTransition, long discontinuityFromPositionMs) {
      // Simulate state change to ENDED to record natural ending of playback.
      @PlaybackState
      int finalPlaybackState =
          currentPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED || automaticTransition
              ? PlaybackStats.PLAYBACK_STATE_ENDED
              : PlaybackStats.PLAYBACK_STATE_ABANDONED;
      maybeUpdateMediaTimeHistory(eventTime.realtimeMs, discontinuityFromPositionMs);
      maybeRecordVideoFormatTime(eventTime.realtimeMs);
      maybeRecordAudioFormatTime(eventTime.realtimeMs);
      updatePlaybackState(finalPlaybackState, eventTime);
    }

    /**
     * Notifies the tracker of new events.
     *
     * @param player The {@link Player}.
     * @param eventTime The {@link EventTime} of the events.
     * @param belongsToPlayback Whether the {@code eventTime} belongs to this playback.
     * @param discontinuityFromPositionMs The position before the discontinuity from this playback,
     *     or {@link C#TIME_UNSET} if no discontinuity started from this playback.
     * @param hasDiscontinuity Whether a discontinuity to this playback occurred.
     * @param droppedFrameCount The number of newly dropped frames for this playback.
     * @param hasAudioUnderun Whether a new audio underrun occurred for this playback.
     * @param startedLoading Whether this playback started loading.
     * @param fatalError A fatal error for this playback, or null.
     * @param nonFatalException A non-fatal exception for this playback, or null.
     * @param bandwidthTimeMs The time in milliseconds spent loading for this playback.
     * @param bandwidthBytes The number of bytes loaded for this playback.
     * @param videoFormat A reported downstream video format for this playback, or null.
     * @param audioFormat A reported downstream audio format for this playback, or null.
     * @param videoSize The reported video size for this playback, or null.
     */
    public void onEvents(
        Player player,
        EventTime eventTime,
        boolean belongsToPlayback,
        long discontinuityFromPositionMs,
        boolean hasDiscontinuity,
        int droppedFrameCount,
        boolean hasAudioUnderun,
        boolean startedLoading,
        @Nullable PlaybackException fatalError,
        @Nullable Exception nonFatalException,
        long bandwidthTimeMs,
        long bandwidthBytes,
        @Nullable Format videoFormat,
        @Nullable Format audioFormat,
        @Nullable VideoSize videoSize) {
      if (discontinuityFromPositionMs != C.TIME_UNSET) {
        maybeUpdateMediaTimeHistory(eventTime.realtimeMs, discontinuityFromPositionMs);
        isSeeking = true;
      }
      if (player.getPlaybackState() != Player.STATE_BUFFERING) {
        isSeeking = false;
      }
      int playerPlaybackState = player.getPlaybackState();
      if (playerPlaybackState == Player.STATE_IDLE
          || playerPlaybackState == Player.STATE_ENDED
          || hasDiscontinuity) {
        isInterruptedByAd = false;
      }
      if (fatalError != null) {
        hasFatalError = true;
        fatalErrorCount++;
        if (keepHistory) {
          fatalErrorHistory.add(new EventTimeAndException(eventTime, fatalError));
        }
      } else if (player.getPlayerError() == null) {
        hasFatalError = false;
      }
      if (isForeground && !isInterruptedByAd) {
        TracksInfo currentTracksInfo = player.getCurrentTracksInfo();
        if (!currentTracksInfo.isTypeSelected(C.TRACK_TYPE_VIDEO)) {
          maybeUpdateVideoFormat(eventTime, /* newFormat= */ null);
        }
        if (!currentTracksInfo.isTypeSelected(C.TRACK_TYPE_AUDIO)) {
          maybeUpdateAudioFormat(eventTime, /* newFormat= */ null);
        }
      }
      if (videoFormat != null) {
        maybeUpdateVideoFormat(eventTime, videoFormat);
      }
      if (audioFormat != null) {
        maybeUpdateAudioFormat(eventTime, audioFormat);
      }
      if (currentVideoFormat != null
          && currentVideoFormat.height == Format.NO_VALUE
          && videoSize != null) {
        Format formatWithHeightAndWidth =
            currentVideoFormat
                .buildUpon()
                .setWidth(videoSize.width)
                .setHeight(videoSize.height)
                .build();
        maybeUpdateVideoFormat(eventTime, formatWithHeightAndWidth);
      }
      if (startedLoading) {
        this.startedLoading = true;
      }
      if (hasAudioUnderun) {
        audioUnderruns++;
      }
      this.droppedFrames += droppedFrameCount;
      this.bandwidthTimeMs += bandwidthTimeMs;
      this.bandwidthBytes += bandwidthBytes;
      if (nonFatalException != null) {
        nonFatalErrorCount++;
        if (keepHistory) {
          nonFatalErrorHistory.add(new EventTimeAndException(eventTime, nonFatalException));
        }
      }

      @PlaybackState int newPlaybackState = resolveNewPlaybackState(player);
      float newPlaybackSpeed = player.getPlaybackParameters().speed;
      if (currentPlaybackState != newPlaybackState || currentPlaybackSpeed != newPlaybackSpeed) {
        maybeUpdateMediaTimeHistory(
            eventTime.realtimeMs,
            belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET);
        maybeRecordVideoFormatTime(eventTime.realtimeMs);
        maybeRecordAudioFormatTime(eventTime.realtimeMs);
      }
      currentPlaybackSpeed = newPlaybackSpeed;
      if (currentPlaybackState != newPlaybackState) {
        updatePlaybackState(newPlaybackState, eventTime);
      }
    }

    /**
     * Builds the playback stats.
     *
     * @param isFinal Whether this is the final build and no further events are expected.
     */
    public PlaybackStats build(boolean isFinal) {
      long[] playbackStateDurationsMs = this.playbackStateDurationsMs;
      List<long[]> mediaTimeHistory = this.mediaTimeHistory;
      if (!isFinal) {
        long buildTimeMs = SystemClock.elapsedRealtime();
        playbackStateDurationsMs =
            Arrays.copyOf(this.playbackStateDurationsMs, PlaybackStats.PLAYBACK_STATE_COUNT);
        long lastStateDurationMs = max(0, buildTimeMs - currentPlaybackStateStartTimeMs);
        playbackStateDurationsMs[currentPlaybackState] += lastStateDurationMs;
        maybeUpdateMaxRebufferTimeMs(buildTimeMs);
        maybeRecordVideoFormatTime(buildTimeMs);
        maybeRecordAudioFormatTime(buildTimeMs);
        mediaTimeHistory = new ArrayList<>(this.mediaTimeHistory);
        if (keepHistory && currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING) {
          mediaTimeHistory.add(guessMediaTimeBasedOnElapsedRealtime(buildTimeMs));
        }
      }
      boolean isJoinTimeInvalid = this.isJoinTimeInvalid || !hasBeenReady;
      long validJoinTimeMs =
          isJoinTimeInvalid
              ? C.TIME_UNSET
              : playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND];
      boolean hasBackgroundJoin =
          playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND] > 0;
      List<EventTimeAndFormat> videoHistory =
          isFinal ? videoFormatHistory : new ArrayList<>(videoFormatHistory);
      List<EventTimeAndFormat> audioHistory =
          isFinal ? audioFormatHistory : new ArrayList<>(audioFormatHistory);
      return new PlaybackStats(
          /* playbackCount= */ 1,
          playbackStateDurationsMs,
          isFinal ? playbackStateHistory : new ArrayList<>(playbackStateHistory),
          mediaTimeHistory,
          firstReportedTimeMs,
          /* foregroundPlaybackCount= */ isForeground ? 1 : 0,
          /* abandonedBeforeReadyCount= */ hasBeenReady ? 0 : 1,
          /* endedCount= */ hasEnded ? 1 : 0,
          /* backgroundJoiningCount= */ hasBackgroundJoin ? 1 : 0,
          validJoinTimeMs,
          /* validJoinTimeCount= */ isJoinTimeInvalid ? 0 : 1,
          pauseCount,
          pauseBufferCount,
          seekCount,
          rebufferCount,
          maxRebufferTimeMs,
          /* adPlaybackCount= */ isAd ? 1 : 0,
          videoHistory,
          audioHistory,
          videoFormatHeightTimeMs,
          videoFormatHeightTimeProduct,
          videoFormatBitrateTimeMs,
          videoFormatBitrateTimeProduct,
          audioFormatTimeMs,
          audioFormatBitrateTimeProduct,
          /* initialVideoFormatHeightCount= */ initialVideoFormatHeight == C.LENGTH_UNSET ? 0 : 1,
          /* initialVideoFormatBitrateCount= */ initialVideoFormatBitrate == C.LENGTH_UNSET ? 0 : 1,
          initialVideoFormatHeight,
          initialVideoFormatBitrate,
          /* initialAudioFormatBitrateCount= */ initialAudioFormatBitrate == C.LENGTH_UNSET ? 0 : 1,
          initialAudioFormatBitrate,
          bandwidthTimeMs,
          bandwidthBytes,
          droppedFrames,
          audioUnderruns,
          /* fatalErrorPlaybackCount= */ fatalErrorCount > 0 ? 1 : 0,
          fatalErrorCount,
          nonFatalErrorCount,
          fatalErrorHistory,
          nonFatalErrorHistory);
    }

    private void updatePlaybackState(@PlaybackState int newPlaybackState, EventTime eventTime) {
      Assertions.checkArgument(eventTime.realtimeMs >= currentPlaybackStateStartTimeMs);
      long stateDurationMs = eventTime.realtimeMs - currentPlaybackStateStartTimeMs;
      playbackStateDurationsMs[currentPlaybackState] += stateDurationMs;
      if (firstReportedTimeMs == C.TIME_UNSET) {
        firstReportedTimeMs = eventTime.realtimeMs;
      }
      isJoinTimeInvalid |= isInvalidJoinTransition(currentPlaybackState, newPlaybackState);
      hasBeenReady |= isReadyState(newPlaybackState);
      hasEnded |= newPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED;
      if (!isPausedState(currentPlaybackState) && isPausedState(newPlaybackState)) {
        pauseCount++;
      }
      if (newPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING) {
        seekCount++;
      }
      if (!isRebufferingState(currentPlaybackState) && isRebufferingState(newPlaybackState)) {
        rebufferCount++;
        lastRebufferStartTimeMs = eventTime.realtimeMs;
      }
      if (isRebufferingState(currentPlaybackState)
          && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING
          && newPlaybackState == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING) {
        pauseBufferCount++;
      }
      maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs);

      currentPlaybackState = newPlaybackState;
      currentPlaybackStateStartTimeMs = eventTime.realtimeMs;
      if (keepHistory) {
        playbackStateHistory.add(new EventTimeAndPlaybackState(eventTime, currentPlaybackState));
      }
    }

    private @PlaybackState int resolveNewPlaybackState(Player player) {
      @Player.State int playerPlaybackState = player.getPlaybackState();
      if (isSeeking && isForeground) {
        // Seeking takes precedence over errors such that we report a seek while in error state.
        return PlaybackStats.PLAYBACK_STATE_SEEKING;
      } else if (hasFatalError) {
        return PlaybackStats.PLAYBACK_STATE_FAILED;
      } else if (!isForeground) {
        // Before the playback becomes foreground, only report background joining and not started.
        return startedLoading
            ? PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
            : PlaybackStats.PLAYBACK_STATE_NOT_STARTED;
      } else if (isInterruptedByAd) {
        return PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD;
      } else if (playerPlaybackState == Player.STATE_ENDED) {
        return PlaybackStats.PLAYBACK_STATE_ENDED;
      } else if (playerPlaybackState == Player.STATE_BUFFERING) {
        if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_NOT_STARTED
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) {
          return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND;
        }
        if (!player.getPlayWhenReady()) {
          return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING;
        }
        return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
            ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING
            : PlaybackStats.PLAYBACK_STATE_BUFFERING;
      } else if (playerPlaybackState == Player.STATE_READY) {
        if (!player.getPlayWhenReady()) {
          return PlaybackStats.PLAYBACK_STATE_PAUSED;
        }
        return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
            ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED
            : PlaybackStats.PLAYBACK_STATE_PLAYING;
      } else if (playerPlaybackState == Player.STATE_IDLE
          && currentPlaybackState != PlaybackStats.PLAYBACK_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 PlaybackStats.PLAYBACK_STATE_STOPPED;
      }
      return currentPlaybackState;
    }

    private void maybeUpdateMaxRebufferTimeMs(long nowMs) {
      if (isRebufferingState(currentPlaybackState)) {
        long rebufferDurationMs = nowMs - lastRebufferStartTimeMs;
        if (maxRebufferTimeMs == C.TIME_UNSET || rebufferDurationMs > maxRebufferTimeMs) {
          maxRebufferTimeMs = rebufferDurationMs;
        }
      }
    }

    private void maybeUpdateMediaTimeHistory(long realtimeMs, long mediaTimeMs) {
      if (!keepHistory) {
        return;
      }
      if (currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PLAYING) {
        if (mediaTimeMs == C.TIME_UNSET) {
          return;
        }
        if (!mediaTimeHistory.isEmpty()) {
          long previousMediaTimeMs = mediaTimeHistory.get(mediaTimeHistory.size() - 1)[1];
          if (previousMediaTimeMs != mediaTimeMs) {
            mediaTimeHistory.add(new long[] {realtimeMs, previousMediaTimeMs});
          }
        }
      }

      if (mediaTimeMs != C.TIME_UNSET) {
        mediaTimeHistory.add(new long[] {realtimeMs, mediaTimeMs});
      } else if (!mediaTimeHistory.isEmpty()) {
        mediaTimeHistory.add(guessMediaTimeBasedOnElapsedRealtime(realtimeMs));
      }
    }

    private long[] guessMediaTimeBasedOnElapsedRealtime(long realtimeMs) {
      long[] previousKnownMediaTimeHistory = mediaTimeHistory.get(mediaTimeHistory.size() - 1);
      long previousRealtimeMs = previousKnownMediaTimeHistory[0];
      long previousMediaTimeMs = previousKnownMediaTimeHistory[1];
      long elapsedMediaTimeEstimateMs =
          (long) ((realtimeMs - previousRealtimeMs) * currentPlaybackSpeed);
      long mediaTimeEstimateMs = previousMediaTimeMs + elapsedMediaTimeEstimateMs;
      return new long[] {realtimeMs, mediaTimeEstimateMs};
    }

    private void maybeUpdateVideoFormat(EventTime eventTime, @Nullable Format newFormat) {
      if (Util.areEqual(currentVideoFormat, newFormat)) {
        return;
      }
      maybeRecordVideoFormatTime(eventTime.realtimeMs);
      if (newFormat != null) {
        if (initialVideoFormatHeight == C.LENGTH_UNSET && newFormat.height != Format.NO_VALUE) {
          initialVideoFormatHeight = newFormat.height;
        }
        if (initialVideoFormatBitrate == C.LENGTH_UNSET && newFormat.bitrate != Format.NO_VALUE) {
          initialVideoFormatBitrate = newFormat.bitrate;
        }
      }
      currentVideoFormat = newFormat;
      if (keepHistory) {
        videoFormatHistory.add(new EventTimeAndFormat(eventTime, currentVideoFormat));
      }
    }

    private void maybeUpdateAudioFormat(EventTime eventTime, @Nullable Format newFormat) {
      if (Util.areEqual(currentAudioFormat, newFormat)) {
        return;
      }
      maybeRecordAudioFormatTime(eventTime.realtimeMs);
      if (newFormat != null
          && initialAudioFormatBitrate == C.LENGTH_UNSET
          && newFormat.bitrate != Format.NO_VALUE) {
        initialAudioFormatBitrate = newFormat.bitrate;
      }
      currentAudioFormat = newFormat;
      if (keepHistory) {
        audioFormatHistory.add(new EventTimeAndFormat(eventTime, currentAudioFormat));
      }
    }

    private void maybeRecordVideoFormatTime(long nowMs) {
      if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING
          && currentVideoFormat != null) {
        long mediaDurationMs = (long) ((nowMs - lastVideoFormatStartTimeMs) * currentPlaybackSpeed);
        if (currentVideoFormat.height != Format.NO_VALUE) {
          videoFormatHeightTimeMs += mediaDurationMs;
          videoFormatHeightTimeProduct += mediaDurationMs * currentVideoFormat.height;
        }
        if (currentVideoFormat.bitrate != Format.NO_VALUE) {
          videoFormatBitrateTimeMs += mediaDurationMs;
          videoFormatBitrateTimeProduct += mediaDurationMs * currentVideoFormat.bitrate;
        }
      }
      lastVideoFormatStartTimeMs = nowMs;
    }

    private void maybeRecordAudioFormatTime(long nowMs) {
      if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING
          && currentAudioFormat != null
          && currentAudioFormat.bitrate != Format.NO_VALUE) {
        long mediaDurationMs = (long) ((nowMs - lastAudioFormatStartTimeMs) * currentPlaybackSpeed);
        audioFormatTimeMs += mediaDurationMs;
        audioFormatBitrateTimeProduct += mediaDurationMs * currentAudioFormat.bitrate;
      }
      lastAudioFormatStartTimeMs = nowMs;
    }

    private static boolean isReadyState(@PlaybackState int state) {
      return state == PlaybackStats.PLAYBACK_STATE_PLAYING
          || state == PlaybackStats.PLAYBACK_STATE_PAUSED
          || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED;
    }

    private static boolean isPausedState(@PlaybackState int state) {
      return state == PlaybackStats.PLAYBACK_STATE_PAUSED
          || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING;
    }

    private static boolean isRebufferingState(@PlaybackState int state) {
      return state == PlaybackStats.PLAYBACK_STATE_BUFFERING
          || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING
          || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING;
    }

    private static boolean isInvalidJoinTransition(
        @PlaybackState int oldState, @PlaybackState int newState) {
      if (oldState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
          && oldState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND
          && oldState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) {
        return false;
      }
      return newState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
          && newState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND
          && newState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD
          && newState != PlaybackStats.PLAYBACK_STATE_PLAYING
          && newState != PlaybackStats.PLAYBACK_STATE_PAUSED
          && newState != PlaybackStats.PLAYBACK_STATE_SUPPRESSED
          && newState != PlaybackStats.PLAYBACK_STATE_ENDED;
    }
  }
}