/* * 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 com.google.android.exoplayer2.analytics; import android.os.SystemClock; import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.analytics.PlaybackStats.PlaybackState; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; import com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; 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; import org.checkerframework.checker.nullness.compatqual.NullableType; /** * {@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 all playback session, i.e. each window in the * {@link Timeline} and each single ad. */ 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 activeContentPlayback; @Nullable private String activeAdPlayback; private boolean playWhenReady; @Player.State private int playbackState; private boolean isSuppressed; private float playbackSpeed; /** * 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; playWhenReady = false; playbackState = Player.STATE_IDLE; playbackSpeed = 1f; period = new Period(); 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() { PlaybackStatsTracker activeStatsTracker = activeAdPlayback != null ? playbackStatsTrackers.get(activeAdPlayback) : activeContentPlayback != null ? playbackStatsTrackers.get(activeContentPlayback) : null; return activeStatsTracker == null ? null : activeStatsTracker.build(/* isFinal= */ false); } /** * Finishes all pending playback sessions. Should be called when the listener is removed from the * player or when the player is released. */ public void finishAllSessions() { // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with // an actual EventTime. Should also simplify other cases where the listener needs to be released // separately from the player. HashMap<String, PlaybackStatsTracker> trackerCopy = new HashMap<>(playbackStatsTrackers); EventTime dummyEventTime = new EventTime( SystemClock.elapsedRealtime(), Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); for (String session : trackerCopy.keySet()) { onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false); } } // PlaybackSessionManager.Listener implementation. @Override public void onSessionCreated(EventTime eventTime, String session) { PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime); tracker.onPlayerStateChanged( eventTime, playWhenReady, playbackState, /* belongsToPlayback= */ true); tracker.onIsSuppressedChanged(eventTime, isSuppressed, /* belongsToPlayback= */ true); tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); playbackStatsTrackers.put(session, tracker); sessionStartEventTimes.put(session, eventTime); } @Override public void onSessionActive(EventTime eventTime, String session) { Assertions.checkNotNull(playbackStatsTrackers.get(session)).onForeground(eventTime); if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { activeAdPlayback = session; } else { activeContentPlayback = session; } } @Override public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) { Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd()); long contentPositionUs = eventTime .timeline .getPeriodByUid(eventTime.mediaPeriodId.periodUid, period) .getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex); EventTime contentEventTime = new EventTime( eventTime.realtimeMs, eventTime.timeline, eventTime.windowIndex, new MediaPeriodId( eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber, eventTime.mediaPeriodId.adGroupIndex), /* eventPlaybackPositionMs= */ C.usToMs(contentPositionUs), eventTime.currentPlaybackPositionMs, eventTime.totalBufferedDurationMs); Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) .onInterruptedByAd(contentEventTime); } @Override public void onSessionFinished(EventTime eventTime, String session, boolean automaticTransition) { if (session.equals(activeAdPlayback)) { activeAdPlayback = null; } else if (session.equals(activeContentPlayback)) { activeContentPlayback = null; } PlaybackStatsTracker tracker = Assertions.checkNotNull(playbackStatsTrackers.remove(session)); EventTime startEventTime = Assertions.checkNotNull(sessionStartEventTimes.remove(session)); if (automaticTransition) { // Simulate ENDED state to record natural ending of playback. tracker.onPlayerStateChanged( eventTime, /* playWhenReady= */ true, Player.STATE_ENDED, /* belongsToPlayback= */ false); } tracker.onFinished(eventTime); PlaybackStats playbackStats = tracker.build(/* isFinal= */ true); finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats); if (callback != null) { callback.onPlaybackStatsReady(startEventTime, playbackStats); } } // AnalyticsListener implementation. @Override public void onPlayerStateChanged( EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { this.playWhenReady = playWhenReady; this.playbackState = playbackState; sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers .get(session) .onPlayerStateChanged(eventTime, playWhenReady, playbackState, belongsToPlayback); } } @Override public void onPlaybackSuppressionReasonChanged( EventTime eventTime, int playbackSuppressionReason) { isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE; sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers .get(session) .onIsSuppressedChanged(eventTime, isSuppressed, belongsToPlayback); } } @Override public void onTimelineChanged(EventTime eventTime, int reason) { sessionManager.handleTimelineUpdate(eventTime); sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); } } } @Override public void onPositionDiscontinuity(EventTime eventTime, int reason) { sessionManager.handlePositionDiscontinuity(eventTime, reason); sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); } } } @Override public void onSeekStarted(EventTime eventTime) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onSeekStarted(eventTime); } } } @Override public void onSeekProcessed(EventTime eventTime) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onSeekProcessed(eventTime); } } } @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onFatalError(eventTime, error); } } } @Override public void onPlaybackParametersChanged( EventTime eventTime, PlaybackParameters playbackParameters) { playbackSpeed = playbackParameters.speed; sessionManager.updateSessions(eventTime); for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); } } @Override public void onTracksChanged( EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections); } } } @Override public void onLoadStarted( EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onLoadStarted(eventTime); } } } @Override public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData); } } } @Override public void onVideoSizeChanged( EventTime eventTime, int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height); } } } @Override public void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded); } } } @Override public void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onAudioUnderrun(); } } } @Override public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames); } } } @Override public void onLoadError( EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); } } } @Override public void onDrmSessionManagerError(EventTime eventTime, Exception error) { sessionManager.updateSessions(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); } } } /** 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<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory; private final List<long[]> mediaTimeHistory; private final List<Pair<EventTime, @NullableType Format>> videoFormatHistory; private final List<Pair<EventTime, @NullableType Format>> audioFormatHistory; private final List<Pair<EventTime, Exception>> fatalErrorHistory; private final List<Pair<EventTime, Exception>> 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 isFinished; private boolean playWhenReady; @Player.State private int playerPlaybackState; private boolean isSuppressed; 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; playerPlaybackState = Player.STATE_IDLE; 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 of a player state change event, including all player state changes while * the playback is not in the foreground. * * @param eventTime The {@link EventTime}. * @param playWhenReady Whether the playback will proceed when ready. * @param playbackState The current {@link Player.State}. * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback. */ public void onPlayerStateChanged( EventTime eventTime, boolean playWhenReady, @Player.State int playbackState, boolean belongsToPlayback) { this.playWhenReady = playWhenReady; playerPlaybackState = playbackState; if (playbackState != Player.STATE_IDLE) { hasFatalError = false; } if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { isInterruptedByAd = false; } maybeUpdatePlaybackState(eventTime, belongsToPlayback); } /** * Notifies the tracker of a change to the playback suppression (e.g. due to audio focus loss), * including all updates while the playback is not in the foreground. * * @param eventTime The {@link EventTime}. * @param isSuppressed Whether playback is suppressed. * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback. */ public void onIsSuppressedChanged( EventTime eventTime, boolean isSuppressed, boolean belongsToPlayback) { this.isSuppressed = isSuppressed; maybeUpdatePlaybackState(eventTime, belongsToPlayback); } /** * Notifies the tracker of a position discontinuity or timeline update for the current playback. * * @param eventTime The {@link EventTime}. */ public void onPositionDiscontinuity(EventTime eventTime) { isInterruptedByAd = false; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker of the start of a seek in the current playback. * * @param eventTime The {@link EventTime}. */ public void onSeekStarted(EventTime eventTime) { isSeeking = true; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker of a seek has been processed in the current playback. * * @param eventTime The {@link EventTime}. */ public void onSeekProcessed(EventTime eventTime) { isSeeking = false; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker of fatal player error in the current playback. * * @param eventTime The {@link EventTime}. */ public void onFatalError(EventTime eventTime, Exception error) { fatalErrorCount++; if (keepHistory) { fatalErrorHistory.add(Pair.create(eventTime, error)); } hasFatalError = true; isInterruptedByAd = false; isSeeking = false; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker that a load for the current playback has started. * * @param eventTime The {@link EventTime}. */ public void onLoadStarted(EventTime eventTime) { startedLoading = true; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker that the current playback became the active foreground playback. * * @param eventTime The {@link EventTime}. */ public void onForeground(EventTime eventTime) { isForeground = true; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker that the current playback has been interrupted for ad playback. * * @param eventTime The {@link EventTime}. */ public void onInterruptedByAd(EventTime eventTime) { isInterruptedByAd = true; isSeeking = false; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } /** * Notifies the tracker that the current playback has finished. * * @param eventTime The {@link EventTime}. Not guaranteed to belong to the current playback. */ public void onFinished(EventTime eventTime) { isFinished = true; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ false); } /** * Notifies the tracker that the track selection for the current playback changed. * * @param eventTime The {@link EventTime}. * @param trackSelections The new {@link TrackSelectionArray}. */ public void onTracksChanged(EventTime eventTime, TrackSelectionArray trackSelections) { boolean videoEnabled = false; boolean audioEnabled = false; for (TrackSelection trackSelection : trackSelections.getAll()) { if (trackSelection != null && trackSelection.length() > 0) { int trackType = MimeTypes.getTrackType(trackSelection.getFormat(0).sampleMimeType); if (trackType == C.TRACK_TYPE_VIDEO) { videoEnabled = true; } else if (trackType == C.TRACK_TYPE_AUDIO) { audioEnabled = true; } } } if (!videoEnabled) { maybeUpdateVideoFormat(eventTime, /* newFormat= */ null); } if (!audioEnabled) { maybeUpdateAudioFormat(eventTime, /* newFormat= */ null); } } /** * Notifies the tracker that a format being read by the renderers for the current playback * changed. * * @param eventTime The {@link EventTime}. * @param mediaLoadData The {@link MediaLoadData} describing the format change. */ public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO || mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) { maybeUpdateVideoFormat(eventTime, mediaLoadData.trackFormat); } else if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) { maybeUpdateAudioFormat(eventTime, mediaLoadData.trackFormat); } } /** * Notifies the tracker that the video size for the current playback changed. * * @param eventTime The {@link EventTime}. * @param width The video width in pixels. * @param height The video height in pixels. */ public void onVideoSizeChanged(EventTime eventTime, int width, int height) { if (currentVideoFormat != null && currentVideoFormat.height == Format.NO_VALUE) { Format formatWithHeight = currentVideoFormat.copyWithVideoSize(width, height); maybeUpdateVideoFormat(eventTime, formatWithHeight); } } /** * Notifies the tracker of a playback speed change, including all playback speed changes while * the playback is not in the foreground. * * @param eventTime The {@link EventTime}. * @param playbackSpeed The new playback speed. */ public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { maybeUpdateMediaTimeHistory(eventTime.realtimeMs, eventTime.eventPlaybackPositionMs); maybeRecordVideoFormatTime(eventTime.realtimeMs); maybeRecordAudioFormatTime(eventTime.realtimeMs); currentPlaybackSpeed = playbackSpeed; } /** Notifies the builder of an audio underrun for the current playback. */ public void onAudioUnderrun() { audioUnderruns++; } /** * Notifies the tracker of dropped video frames for the current playback. * * @param droppedFrames The number of dropped video frames. */ public void onDroppedVideoFrames(int droppedFrames) { this.droppedFrames += droppedFrames; } /** * Notifies the tracker of bandwidth measurement data for the current playback. * * @param timeMs The time for which bandwidth measurement data is available, in milliseconds. * @param bytes The bytes transferred during {@code timeMs}. */ public void onBandwidthData(long timeMs, long bytes) { bandwidthTimeMs += timeMs; bandwidthBytes += bytes; } /** * Notifies the tracker of a non-fatal error in the current playback. * * @param eventTime The {@link EventTime}. * @param error The error. */ public void onNonFatalError(EventTime eventTime, Exception error) { nonFatalErrorCount++; if (keepHistory) { nonFatalErrorHistory.add(Pair.create(eventTime, error)); } } /** * 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 = Math.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<Pair<EventTime, @NullableType Format>> videoHistory = isFinal ? videoFormatHistory : new ArrayList<>(videoFormatHistory); List<Pair<EventTime, @NullableType Format>> 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 maybeUpdatePlaybackState(EventTime eventTime, boolean belongsToPlayback) { @PlaybackState int newPlaybackState = resolveNewPlaybackState(); if (newPlaybackState == currentPlaybackState) { return; } 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++; } maybeUpdateMediaTimeHistory( eventTime.realtimeMs, /* mediaTimeMs= */ belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET); maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs); maybeRecordVideoFormatTime(eventTime.realtimeMs); maybeRecordAudioFormatTime(eventTime.realtimeMs); currentPlaybackState = newPlaybackState; currentPlaybackStateStartTimeMs = eventTime.realtimeMs; if (keepHistory) { playbackStateHistory.add(Pair.create(eventTime, currentPlaybackState)); } } private @PlaybackState int resolveNewPlaybackState() { if (isFinished) { // Keep VIDEO_STATE_ENDED if playback naturally ended (or progressed to next item). return currentPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED ? PlaybackStats.PLAYBACK_STATE_ENDED : PlaybackStats.PLAYBACK_STATE_ABANDONED; } else if (isSeeking) { // 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 (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING) { return PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING; } if (!playWhenReady) { return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; } return isSuppressed ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING : PlaybackStats.PLAYBACK_STATE_BUFFERING; } else if (playerPlaybackState == Player.STATE_READY) { if (!playWhenReady) { return PlaybackStats.PLAYBACK_STATE_PAUSED; } return isSuppressed ? 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 (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}); } } } mediaTimeHistory.add( mediaTimeMs == C.TIME_UNSET ? guessMediaTimeBasedOnElapsedRealtime(realtimeMs) : new long[] {realtimeMs, mediaTimeMs}); } 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(Pair.create(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(Pair.create(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; } } }