diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics')
8 files changed, 3951 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsCollector.java new file mode 100644 index 0000000000..6bdb4c7727 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -0,0 +1,881 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.view.Surface; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Period; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DefaultDrmSessionEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Data collector which is able to forward analytics events to {@link AnalyticsListener}s by + * listening to all available ExoPlayer listeners. + */ +public class AnalyticsCollector + implements Player.EventListener, + MetadataOutput, + AudioRendererEventListener, + VideoRendererEventListener, + MediaSourceEventListener, + BandwidthMeter.EventListener, + DefaultDrmSessionEventListener, + VideoListener, + AudioListener { + + private final CopyOnWriteArraySet<AnalyticsListener> listeners; + private final Clock clock; + private final Window window; + private final MediaPeriodQueueTracker mediaPeriodQueueTracker; + + private @MonotonicNonNull Player player; + + /** + * Creates an analytics collector. + * + * @param clock A {@link Clock} used to generate timestamps. + */ + public AnalyticsCollector(Clock clock) { + this.clock = Assertions.checkNotNull(clock); + listeners = new CopyOnWriteArraySet<>(); + mediaPeriodQueueTracker = new MediaPeriodQueueTracker(); + window = new Window(); + } + + /** + * Adds a listener for analytics events. + * + * @param listener The listener to add. + */ + public void addListener(AnalyticsListener listener) { + listeners.add(listener); + } + + /** + * Removes a previously added analytics event listener. + * + * @param listener The listener to remove. + */ + public void removeListener(AnalyticsListener listener) { + listeners.remove(listener); + } + + /** + * Sets the player for which data will be collected. Must only be called if no player has been set + * yet or the current player is idle. + * + * @param player The {@link Player} for which data will be collected. + */ + public void setPlayer(Player player) { + Assertions.checkState( + this.player == null || mediaPeriodQueueTracker.mediaPeriodInfoQueue.isEmpty()); + this.player = Assertions.checkNotNull(player); + } + + // External events. + + /** + * Notify analytics collector that a seek operation will start. Should be called before the player + * adjusts its state and position to the seek. + */ + public final void notifySeekStarted() { + if (!mediaPeriodQueueTracker.isSeeking()) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + mediaPeriodQueueTracker.onSeekStarted(); + for (AnalyticsListener listener : listeners) { + listener.onSeekStarted(eventTime); + } + } + } + + /** + * Resets the analytics collector for a new media source. Should be called before the player is + * prepared with a new media source. + */ + public final void resetForNewMediaSource() { + // Copying the list is needed because onMediaPeriodReleased will modify the list. + List<MediaPeriodInfo> mediaPeriodInfos = + new ArrayList<>(mediaPeriodQueueTracker.mediaPeriodInfoQueue); + for (MediaPeriodInfo mediaPeriodInfo : mediaPeriodInfos) { + onMediaPeriodReleased(mediaPeriodInfo.windowIndex, mediaPeriodInfo.mediaPeriodId); + } + } + + // MetadataOutput implementation. + + @Override + public final void onMetadata(Metadata metadata) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onMetadata(eventTime, metadata); + } + } + + // AudioRendererEventListener implementation. + + @Override + public final void onAudioEnabled(DecoderCounters counters) { + // The renderers are only enabled after we changed the playing media period. + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_AUDIO, counters); + } + } + + @Override + public final void onAudioDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInitialized( + eventTime, C.TRACK_TYPE_AUDIO, decoderName, initializationDurationMs); + } + } + + @Override + public final void onAudioInputFormatChanged(Format format) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_AUDIO, format); + } + } + + @Override + public final void onAudioSinkUnderrun( + int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioUnderrun(eventTime, bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + } + + @Override + public final void onAudioDisabled(DecoderCounters counters) { + // The renderers are disabled after we changed the playing media period on the playback thread + // but before this change is reported to the app thread. + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_AUDIO, counters); + } + } + + // AudioListener implementation. + + @Override + public final void onAudioSessionId(int audioSessionId) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioSessionId(eventTime, audioSessionId); + } + } + + @Override + public void onAudioAttributesChanged(AudioAttributes audioAttributes) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioAttributesChanged(eventTime, audioAttributes); + } + } + + @Override + public void onVolumeChanged(float audioVolume) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onVolumeChanged(eventTime, audioVolume); + } + } + + // VideoRendererEventListener implementation. + + @Override + public final void onVideoEnabled(DecoderCounters counters) { + // The renderers are only enabled after we changed the playing media period. + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_VIDEO, counters); + } + } + + @Override + public final void onVideoDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInitialized( + eventTime, C.TRACK_TYPE_VIDEO, decoderName, initializationDurationMs); + } + } + + @Override + public final void onVideoInputFormatChanged(Format format) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_VIDEO, format); + } + } + + @Override + public final void onDroppedFrames(int count, long elapsedMs) { + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDroppedVideoFrames(eventTime, count, elapsedMs); + } + } + + @Override + public final void onVideoDisabled(DecoderCounters counters) { + // The renderers are disabled after we changed the playing media period on the playback thread + // but before this change is reported to the app thread. + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_VIDEO, counters); + } + } + + @Override + public final void onRenderedFirstFrame(@Nullable Surface surface) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onRenderedFirstFrame(eventTime, surface); + } + } + + // VideoListener implementation. + + @Override + public final void onRenderedFirstFrame() { + // Do nothing. Already reported in VideoRendererEventListener.onRenderedFirstFrame. + } + + @Override + public final void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onVideoSizeChanged( + eventTime, width, height, unappliedRotationDegrees, pixelWidthHeightRatio); + } + } + + @Override + public void onSurfaceSizeChanged(int width, int height) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onSurfaceSizeChanged(eventTime, width, height); + } + } + + // MediaSourceEventListener implementation. + + @Override + public final void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + mediaPeriodQueueTracker.onMediaPeriodCreated(windowIndex, mediaPeriodId); + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onMediaPeriodCreated(eventTime); + } + } + + @Override + public final void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + if (mediaPeriodQueueTracker.onMediaPeriodReleased(mediaPeriodId)) { + for (AnalyticsListener listener : listeners) { + listener.onMediaPeriodReleased(eventTime); + } + } + } + + @Override + public final void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadStarted(eventTime, loadEventInfo, mediaLoadData); + } + } + + @Override + public final void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadCompleted(eventTime, loadEventInfo, mediaLoadData); + } + } + + @Override + public final void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadCanceled(eventTime, loadEventInfo, mediaLoadData); + } + } + + @Override + public final void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadError(eventTime, loadEventInfo, mediaLoadData, error, wasCanceled); + } + } + + @Override + public final void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { + mediaPeriodQueueTracker.onReadingStarted(mediaPeriodId); + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onReadingStarted(eventTime); + } + } + + @Override + public final void onUpstreamDiscarded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onUpstreamDiscarded(eventTime, mediaLoadData); + } + } + + @Override + public final void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onDownstreamFormatChanged(eventTime, mediaLoadData); + } + } + + // Player.EventListener implementation. + + // TODO: Add onFinishedReportingChanges to Player.EventListener to know when a set of simultaneous + // callbacks finished. This helps to assign exactly the same EventTime to all of them instead of + // having slightly different real times. + + @Override + public final void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + mediaPeriodQueueTracker.onTimelineChanged(timeline); + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onTimelineChanged(eventTime, reason); + } + } + + @Override + public final void onTracksChanged( + TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onTracksChanged(eventTime, trackGroups, trackSelections); + } + } + + @Override + public final void onLoadingChanged(boolean isLoading) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onLoadingChanged(eventTime, isLoading); + } + } + + @Override + public final void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlayerStateChanged(eventTime, playWhenReady, playbackState); + } + } + + @Override + public void onPlaybackSuppressionReasonChanged( + @PlaybackSuppressionReason int playbackSuppressionReason) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlaybackSuppressionReasonChanged(eventTime, playbackSuppressionReason); + } + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onIsPlayingChanged(eventTime, isPlaying); + } + } + + @Override + public final void onRepeatModeChanged(@Player.RepeatMode int repeatMode) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onRepeatModeChanged(eventTime, repeatMode); + } + } + + @Override + public final void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onShuffleModeChanged(eventTime, shuffleModeEnabled); + } + } + + @Override + public final void onPlayerError(ExoPlaybackException error) { + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlayerError(eventTime, error); + } + } + + @Override + public final void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + mediaPeriodQueueTracker.onPositionDiscontinuity(reason); + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPositionDiscontinuity(eventTime, reason); + } + } + + @Override + public final void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlaybackParametersChanged(eventTime, playbackParameters); + } + } + + @Override + public final void onSeekProcessed() { + if (mediaPeriodQueueTracker.isSeeking()) { + mediaPeriodQueueTracker.onSeekProcessed(); + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onSeekProcessed(eventTime); + } + } + } + + // BandwidthMeter.Listener implementation. + + @Override + public final void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { + EventTime eventTime = generateLoadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onBandwidthEstimate(eventTime, elapsedMs, bytes, bitrate); + } + } + + // DefaultDrmSessionManager.EventListener implementation. + + @Override + public final void onDrmSessionAcquired() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmSessionAcquired(eventTime); + } + } + + @Override + public final void onDrmKeysLoaded() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmKeysLoaded(eventTime); + } + } + + @Override + public final void onDrmSessionManagerError(Exception error) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmSessionManagerError(eventTime, error); + } + } + + @Override + public final void onDrmKeysRestored() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmKeysRestored(eventTime); + } + } + + @Override + public final void onDrmKeysRemoved() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmKeysRemoved(eventTime); + } + } + + @Override + public final void onDrmSessionReleased() { + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmSessionReleased(eventTime); + } + } + + // Internal methods. + + /** Returns read-only set of registered listeners. */ + protected Set<AnalyticsListener> getListeners() { + return Collections.unmodifiableSet(listeners); + } + + /** Returns a new {@link EventTime} for the specified timeline, window and media period id. */ + @RequiresNonNull("player") + protected EventTime generateEventTime( + Timeline timeline, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (timeline.isEmpty()) { + // Ensure media period id is only reported together with a valid timeline. + mediaPeriodId = null; + } + long realtimeMs = clock.elapsedRealtime(); + long eventPositionMs; + boolean isInCurrentWindow = + timeline == player.getCurrentTimeline() && windowIndex == player.getCurrentWindowIndex(); + if (mediaPeriodId != null && mediaPeriodId.isAd()) { + boolean isCurrentAd = + isInCurrentWindow + && player.getCurrentAdGroupIndex() == mediaPeriodId.adGroupIndex + && player.getCurrentAdIndexInAdGroup() == mediaPeriodId.adIndexInAdGroup; + // Assume start position of 0 for future ads. + eventPositionMs = isCurrentAd ? player.getCurrentPosition() : 0; + } else if (isInCurrentWindow) { + eventPositionMs = player.getContentPosition(); + } else { + // Assume default start position for future content windows. If timeline is not available yet, + // assume start position of 0. + eventPositionMs = + timeline.isEmpty() ? 0 : timeline.getWindow(windowIndex, window).getDefaultPositionMs(); + } + return new EventTime( + realtimeMs, + timeline, + windowIndex, + mediaPeriodId, + eventPositionMs, + player.getCurrentPosition(), + player.getTotalBufferedDuration()); + } + + private EventTime generateEventTime(@Nullable MediaPeriodInfo mediaPeriodInfo) { + Assertions.checkNotNull(player); + if (mediaPeriodInfo == null) { + int windowIndex = player.getCurrentWindowIndex(); + mediaPeriodInfo = mediaPeriodQueueTracker.tryResolveWindowIndex(windowIndex); + if (mediaPeriodInfo == null) { + Timeline timeline = player.getCurrentTimeline(); + boolean windowIsInTimeline = windowIndex < timeline.getWindowCount(); + return generateEventTime( + windowIsInTimeline ? timeline : Timeline.EMPTY, windowIndex, /* mediaPeriodId= */ null); + } + } + return generateEventTime( + mediaPeriodInfo.timeline, mediaPeriodInfo.windowIndex, mediaPeriodInfo.mediaPeriodId); + } + + private EventTime generateLastReportedPlayingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getLastReportedPlayingMediaPeriod()); + } + + private EventTime generatePlayingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getPlayingMediaPeriod()); + } + + private EventTime generateReadingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getReadingMediaPeriod()); + } + + private EventTime generateLoadingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getLoadingMediaPeriod()); + } + + private EventTime generateMediaPeriodEventTime( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + Assertions.checkNotNull(player); + if (mediaPeriodId != null) { + MediaPeriodInfo mediaPeriodInfo = mediaPeriodQueueTracker.getMediaPeriodInfo(mediaPeriodId); + return mediaPeriodInfo != null + ? generateEventTime(mediaPeriodInfo) + : generateEventTime(Timeline.EMPTY, windowIndex, mediaPeriodId); + } + Timeline timeline = player.getCurrentTimeline(); + boolean windowIsInTimeline = windowIndex < timeline.getWindowCount(); + return generateEventTime( + windowIsInTimeline ? timeline : Timeline.EMPTY, windowIndex, /* mediaPeriodId= */ null); + } + + /** Keeps track of the active media periods and currently playing and reading media period. */ + private static final class MediaPeriodQueueTracker { + + // TODO: Investigate reporting MediaPeriodId in renderer events and adding a listener of queue + // changes, which would hopefully remove the need to track the queue here. + + private final ArrayList<MediaPeriodInfo> mediaPeriodInfoQueue; + private final HashMap<MediaPeriodId, MediaPeriodInfo> mediaPeriodIdToInfo; + private final Period period; + + @Nullable private MediaPeriodInfo lastPlayingMediaPeriod; + @Nullable private MediaPeriodInfo lastReportedPlayingMediaPeriod; + @Nullable private MediaPeriodInfo readingMediaPeriod; + private Timeline timeline; + private boolean isSeeking; + + public MediaPeriodQueueTracker() { + mediaPeriodInfoQueue = new ArrayList<>(); + mediaPeriodIdToInfo = new HashMap<>(); + period = new Period(); + timeline = Timeline.EMPTY; + } + + /** + * Returns the {@link MediaPeriodInfo} of the media period in the front of the queue. This is + * the playing media period unless the player hasn't started playing yet (in which case it is + * the loading media period or null). While the player is seeking or preparing, this method will + * always return null to reflect the uncertainty about the current playing period. May also be + * null, if the timeline is empty or no media period is active yet. + */ + @Nullable + public MediaPeriodInfo getPlayingMediaPeriod() { + return mediaPeriodInfoQueue.isEmpty() || timeline.isEmpty() || isSeeking + ? null + : mediaPeriodInfoQueue.get(0); + } + + /** + * Returns the {@link MediaPeriodInfo} of the currently playing media period. This is the + * publicly reported period which should always match {@link Player#getCurrentPeriodIndex()} + * unless the player is currently seeking or being prepared in which case the previous period is + * reported until the seek or preparation is processed. May be null, if no media period is + * active yet. + */ + @Nullable + public MediaPeriodInfo getLastReportedPlayingMediaPeriod() { + return lastReportedPlayingMediaPeriod; + } + + /** + * Returns the {@link MediaPeriodInfo} of the media period currently being read by the player. + * May be null, if the player is not reading a media period. + */ + @Nullable + public MediaPeriodInfo getReadingMediaPeriod() { + return readingMediaPeriod; + } + + /** + * Returns the {@link MediaPeriodInfo} of the media period at the end of the queue which is + * currently loading or will be the next one loading. May be null, if no media period is active + * yet. + */ + @Nullable + public MediaPeriodInfo getLoadingMediaPeriod() { + return mediaPeriodInfoQueue.isEmpty() + ? null + : mediaPeriodInfoQueue.get(mediaPeriodInfoQueue.size() - 1); + } + + /** Returns the {@link MediaPeriodInfo} for the given {@link MediaPeriodId}. */ + @Nullable + public MediaPeriodInfo getMediaPeriodInfo(MediaPeriodId mediaPeriodId) { + return mediaPeriodIdToInfo.get(mediaPeriodId); + } + + /** Returns whether the player is currently seeking. */ + public boolean isSeeking() { + return isSeeking; + } + + /** + * Tries to find an existing media period info from the specified window index. Only returns a + * non-null media period info if there is a unique, unambiguous match. + */ + @Nullable + public MediaPeriodInfo tryResolveWindowIndex(int windowIndex) { + MediaPeriodInfo match = null; + for (int i = 0; i < mediaPeriodInfoQueue.size(); i++) { + MediaPeriodInfo info = mediaPeriodInfoQueue.get(i); + int periodIndex = timeline.getIndexOfPeriod(info.mediaPeriodId.periodUid); + if (periodIndex != C.INDEX_UNSET + && timeline.getPeriod(periodIndex, period).windowIndex == windowIndex) { + if (match != null) { + // Ambiguous match. + return null; + } + match = info; + } + } + return match; + } + + /** Updates the queue with a reported position discontinuity . */ + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + + /** Updates the queue with a reported timeline change. */ + public void onTimelineChanged(Timeline timeline) { + for (int i = 0; i < mediaPeriodInfoQueue.size(); i++) { + MediaPeriodInfo newMediaPeriodInfo = + updateMediaPeriodInfoToNewTimeline(mediaPeriodInfoQueue.get(i), timeline); + mediaPeriodInfoQueue.set(i, newMediaPeriodInfo); + mediaPeriodIdToInfo.put(newMediaPeriodInfo.mediaPeriodId, newMediaPeriodInfo); + } + if (readingMediaPeriod != null) { + readingMediaPeriod = updateMediaPeriodInfoToNewTimeline(readingMediaPeriod, timeline); + } + this.timeline = timeline; + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + + /** Updates the queue with a reported start of seek. */ + public void onSeekStarted() { + isSeeking = true; + } + + /** Updates the queue with a reported processed seek. */ + public void onSeekProcessed() { + isSeeking = false; + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + + /** Updates the queue with a newly created media period. */ + public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + boolean isInTimeline = periodIndex != C.INDEX_UNSET; + MediaPeriodInfo mediaPeriodInfo = + new MediaPeriodInfo( + mediaPeriodId, + isInTimeline ? timeline : Timeline.EMPTY, + isInTimeline ? timeline.getPeriod(periodIndex, period).windowIndex : windowIndex); + mediaPeriodInfoQueue.add(mediaPeriodInfo); + mediaPeriodIdToInfo.put(mediaPeriodId, mediaPeriodInfo); + lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); + if (mediaPeriodInfoQueue.size() == 1 && !timeline.isEmpty()) { + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + } + + /** + * Updates the queue with a released media period. Returns whether the media period was still in + * the queue. + */ + public boolean onMediaPeriodReleased(MediaPeriodId mediaPeriodId) { + MediaPeriodInfo mediaPeriodInfo = mediaPeriodIdToInfo.remove(mediaPeriodId); + if (mediaPeriodInfo == null) { + // The media period has already been removed from the queue in resetForNewMediaSource(). + return false; + } + mediaPeriodInfoQueue.remove(mediaPeriodInfo); + if (readingMediaPeriod != null && mediaPeriodId.equals(readingMediaPeriod.mediaPeriodId)) { + readingMediaPeriod = mediaPeriodInfoQueue.isEmpty() ? null : mediaPeriodInfoQueue.get(0); + } + if (!mediaPeriodInfoQueue.isEmpty()) { + lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); + } + return true; + } + + /** Update the queue with a change in the reading media period. */ + public void onReadingStarted(MediaPeriodId mediaPeriodId) { + readingMediaPeriod = mediaPeriodIdToInfo.get(mediaPeriodId); + } + + private MediaPeriodInfo updateMediaPeriodInfoToNewTimeline( + MediaPeriodInfo info, Timeline newTimeline) { + int newPeriodIndex = newTimeline.getIndexOfPeriod(info.mediaPeriodId.periodUid); + if (newPeriodIndex == C.INDEX_UNSET) { + // Media period is not yet or no longer available in the new timeline. Keep it as it is. + return info; + } + int newWindowIndex = newTimeline.getPeriod(newPeriodIndex, period).windowIndex; + return new MediaPeriodInfo(info.mediaPeriodId, newTimeline, newWindowIndex); + } + } + + /** Information about a media period and its associated timeline. */ + private static final class MediaPeriodInfo { + + /** The {@link MediaPeriodId} of the media period. */ + public final MediaPeriodId mediaPeriodId; + /** + * The {@link Timeline} in which the media period can be found. Or {@link Timeline#EMPTY} if the + * media period is not part of a known timeline yet. + */ + public final Timeline timeline; + /** + * The window index of the media period in the timeline. If the timeline is empty, this is the + * prospective window index. + */ + public final int windowIndex; + + public MediaPeriodInfo(MediaPeriodId mediaPeriodId, Timeline timeline, int windowIndex) { + this.mediaPeriodId = mediaPeriodId; + this.timeline = timeline; + this.windowIndex = windowIndex; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsListener.java new file mode 100644 index 0000000000..a265268c19 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -0,0 +1,514 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.view.Surface; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.TimelineChangeReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import java.io.IOException; + +/** + * A listener for analytics events. + * + * <p>All events are recorded with an {@link EventTime} specifying the elapsed real time and media + * time at the time of the event. + * + * <p>All methods have no-op default implementations to allow selective overrides. + */ +public interface AnalyticsListener { + + /** Time information of an event. */ + final class EventTime { + + /** + * Elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} at the time of the + * event, in milliseconds. + */ + public final long realtimeMs; + + /** Timeline at the time of the event. */ + public final Timeline timeline; + + /** + * Window index in the {@link #timeline} this event belongs to, or the prospective window index + * if the timeline is not yet known and empty. + */ + public final int windowIndex; + + /** + * Media period identifier for the media period this event belongs to, or {@code null} if the + * event is not associated with a specific media period. + */ + @Nullable public final MediaPeriodId mediaPeriodId; + + /** + * Position in the window or ad this event belongs to at the time of the event, in milliseconds. + */ + public final long eventPlaybackPositionMs; + + /** + * Position in the current timeline window ({@link Player#getCurrentWindowIndex()}) or the + * currently playing ad at the time of the event, in milliseconds. + */ + public final long currentPlaybackPositionMs; + + /** + * Total buffered duration from {@link #currentPlaybackPositionMs} at the time of the event, in + * milliseconds. This includes pre-buffered data for subsequent ads and windows. + */ + public final long totalBufferedDurationMs; + + /** + * @param realtimeMs Elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} at + * the time of the event, in milliseconds. + * @param timeline Timeline at the time of the event. + * @param windowIndex Window index in the {@link #timeline} this event belongs to, or the + * prospective window index if the timeline is not yet known and empty. + * @param mediaPeriodId Media period identifier for the media period this event belongs to, or + * {@code null} if the event is not associated with a specific media period. + * @param eventPlaybackPositionMs Position in the window or ad this event belongs to at the time + * of the event, in milliseconds. + * @param currentPlaybackPositionMs Position in the current timeline window ({@link + * Player#getCurrentWindowIndex()}) or the currently playing ad at the time of the event, in + * milliseconds. + * @param totalBufferedDurationMs Total buffered duration from {@link + * #currentPlaybackPositionMs} at the time of the event, in milliseconds. This includes + * pre-buffered data for subsequent ads and windows. + */ + public EventTime( + long realtimeMs, + Timeline timeline, + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + long eventPlaybackPositionMs, + long currentPlaybackPositionMs, + long totalBufferedDurationMs) { + this.realtimeMs = realtimeMs; + this.timeline = timeline; + this.windowIndex = windowIndex; + this.mediaPeriodId = mediaPeriodId; + this.eventPlaybackPositionMs = eventPlaybackPositionMs; + this.currentPlaybackPositionMs = currentPlaybackPositionMs; + this.totalBufferedDurationMs = totalBufferedDurationMs; + } + } + + /** + * Called when the player state changed. + * + * @param eventTime The event time. + * @param playWhenReady Whether the playback will proceed when ready. + * @param playbackState The new {@link Player.State playback state}. + */ + default void onPlayerStateChanged( + EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) {} + + /** + * Called when playback suppression reason changed. + * + * @param eventTime The event time. + * @param playbackSuppressionReason The new {@link PlaybackSuppressionReason}. + */ + default void onPlaybackSuppressionReasonChanged( + EventTime eventTime, @PlaybackSuppressionReason int playbackSuppressionReason) {} + + /** + * Called when the player starts or stops playing. + * + * @param eventTime The event time. + * @param isPlaying Whether the player is playing. + */ + default void onIsPlayingChanged(EventTime eventTime, boolean isPlaying) {} + + /** + * Called when the timeline changed. + * + * @param eventTime The event time. + * @param reason The reason for the timeline change. + */ + default void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int reason) {} + + /** + * Called when a position discontinuity occurred. + * + * @param eventTime The event time. + * @param reason The reason for the position discontinuity. + */ + default void onPositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason) {} + + /** + * Called when a seek operation started. + * + * @param eventTime The event time. + */ + default void onSeekStarted(EventTime eventTime) {} + + /** + * Called when a seek operation was processed. + * + * @param eventTime The event time. + */ + default void onSeekProcessed(EventTime eventTime) {} + + /** + * Called when the playback parameters changed. + * + * @param eventTime The event time. + * @param playbackParameters The new playback parameters. + */ + default void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) {} + + /** + * Called when the repeat mode changed. + * + * @param eventTime The event time. + * @param repeatMode The new repeat mode. + */ + default void onRepeatModeChanged(EventTime eventTime, @Player.RepeatMode int repeatMode) {} + + /** + * Called when the shuffle mode changed. + * + * @param eventTime The event time. + * @param shuffleModeEnabled Whether the shuffle mode is enabled. + */ + default void onShuffleModeChanged(EventTime eventTime, boolean shuffleModeEnabled) {} + + /** + * Called when the player starts or stops loading data from a source. + * + * @param eventTime The event time. + * @param isLoading Whether the player is loading. + */ + default void onLoadingChanged(EventTime eventTime, boolean isLoading) {} + + /** + * Called when a fatal player error occurred. + * + * @param eventTime The event time. + * @param error The error. + */ + default void onPlayerError(EventTime eventTime, ExoPlaybackException error) {} + + /** + * Called when the available or selected tracks for the renderers changed. + * + * @param eventTime The event time. + * @param trackGroups The available tracks. May be empty. + * @param trackSelections The track selections for each renderer. May contain null elements. + */ + default void onTracksChanged( + EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {} + + /** + * Called when a media source started loading data. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadStarted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source completed loading data. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCompleted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source canceled loading data. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCanceled( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source loading error occurred. These errors are just for informational + * purposes and the player may recover. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + * @param error The load error. + * @param wasCanceled Whether the load was canceled as a result of the error. + */ + default void onLoadError( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) {} + + /** + * Called when the downstream format sent to the renderers changed. + * + * @param eventTime The event time. + * @param mediaLoadData The {@link MediaLoadData} defining the newly selected media data. + */ + default void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {} + + /** + * Called when data is removed from the back of a media buffer, typically so that it can be + * re-buffered in a different format. + * + * @param eventTime The event time. + * @param mediaLoadData The {@link MediaLoadData} defining the media being discarded. + */ + default void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source created a media period. + * + * @param eventTime The event time. + */ + default void onMediaPeriodCreated(EventTime eventTime) {} + + /** + * Called when a media source released a media period. + * + * @param eventTime The event time. + */ + default void onMediaPeriodReleased(EventTime eventTime) {} + + /** + * Called when the player started reading a media period. + * + * @param eventTime The event time. + */ + default void onReadingStarted(EventTime eventTime) {} + + /** + * Called when the bandwidth estimate for the current data source has been updated. + * + * @param eventTime The event time. + * @param totalLoadTimeMs The total time spend loading this update is based on, in milliseconds. + * @param totalBytesLoaded The total bytes loaded this update is based on. + * @param bitrateEstimate The bandwidth estimate, in bits per second. + */ + default void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {} + + /** + * Called when the output surface size changed. + * + * @param eventTime The event time. + * @param width The surface width in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if the + * video is not rendered onto a surface. + * @param height The surface height in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if + * the video is not rendered onto a surface. + */ + default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} + + /** + * Called when there is {@link Metadata} associated with the current playback time. + * + * @param eventTime The event time. + * @param metadata The metadata. + */ + default void onMetadata(EventTime eventTime, Metadata metadata) {} + + /** + * Called when an audio or video decoder has been enabled. + * + * @param eventTime The event time. + * @param trackType The track type of the enabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or + * {@link C#TRACK_TYPE_VIDEO}. + * @param decoderCounters The accumulated event counters associated with this decoder. + */ + default void onDecoderEnabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + + /** + * Called when an audio or video decoder has been initialized. + * + * @param eventTime The event time. + * @param trackType The track type of the initialized decoder. Either {@link C#TRACK_TYPE_AUDIO} + * or {@link C#TRACK_TYPE_VIDEO}. + * @param decoderName The decoder that was created. + * @param initializationDurationMs Time taken to initialize the decoder, in milliseconds. + */ + default void onDecoderInitialized( + EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {} + + /** + * Called when an audio or video decoder input format changed. + * + * @param eventTime The event time. + * @param trackType The track type of the decoder whose format changed. Either {@link + * C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. + * @param format The new input format for the decoder. + */ + default void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {} + + /** + * Called when an audio or video decoder has been disabled. + * + * @param eventTime The event time. + * @param trackType The track type of the disabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or + * {@link C#TRACK_TYPE_VIDEO}. + * @param decoderCounters The accumulated event counters associated with this decoder. + */ + default void onDecoderDisabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + + /** + * Called when the audio session id is set. + * + * @param eventTime The event time. + * @param audioSessionId The audio session id. + */ + default void onAudioSessionId(EventTime eventTime, int audioSessionId) {} + + /** + * Called when the audio attributes change. + * + * @param eventTime The event time. + * @param audioAttributes The audio attributes. + */ + default void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audioAttributes) {} + + /** + * Called when the volume changes. + * + * @param eventTime The event time. + * @param volume The new volume, with 0 being silence and 1 being unity gain. + */ + default void onVolumeChanged(EventTime eventTime, float volume) {} + + /** + * Called when an audio underrun occurred. + * + * @param eventTime The event time. + * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. + * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is + * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, + * as the buffered media can have a variable bitrate so the duration may be unknown. + * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + */ + default void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + + /** + * Called after video frames have been dropped. + * + * @param eventTime The event time. + * @param droppedFrames The number of dropped frames since the last call to this method. + * @param elapsedMs The duration in milliseconds over which the frames were dropped. This duration + * is timed from when the renderer was started or from when dropped frames were last reported + * (whichever was more recent), and not from when the first of the reported drops occurred. + */ + default void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {} + + /** + * Called before a frame is rendered for the first time since setting the surface, and each time + * there's a change in the size or pixel aspect ratio of the video being rendered. + * + * @param eventTime The event time. + * @param width The width of the video. + * @param height The height of the video. + * @param unappliedRotationDegrees For videos that require a rotation, this is the clockwise + * rotation in degrees that the application should apply for the video for it to be rendered + * in the correct orientation. This value will always be zero on API levels 21 and above, + * since the renderer will apply all necessary rotations internally. + * @param pixelWidthHeightRatio The width to height ratio of each pixel. + */ + default void onVideoSizeChanged( + EventTime eventTime, + int width, + int height, + int unappliedRotationDegrees, + float pixelWidthHeightRatio) {} + + /** + * Called when a frame is rendered for the first time since setting the surface, and when a frame + * is rendered for the first time since the renderer was reset. + * + * @param eventTime The event time. + * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if + * the renderer renders to something that isn't a {@link Surface}. + */ + default void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) {} + + /** + * Called each time a drm session is acquired. + * + * @param eventTime The event time. + */ + default void onDrmSessionAcquired(EventTime eventTime) {} + + /** + * Called each time drm keys are loaded. + * + * @param eventTime The event time. + */ + default void onDrmKeysLoaded(EventTime eventTime) {} + + /** + * Called when a drm error occurs. These errors are just for informational purposes and the player + * may recover. + * + * @param eventTime The event time. + * @param error The error. + */ + default void onDrmSessionManagerError(EventTime eventTime, Exception error) {} + + /** + * Called each time offline drm keys are restored. + * + * @param eventTime The event time. + */ + default void onDrmKeysRestored(EventTime eventTime) {} + + /** + * Called each time offline drm keys are removed. + * + * @param eventTime The event time. + */ + default void onDrmKeysRemoved(EventTime eventTime) {} + + /** + * Called each time a drm session is released. + * + * @param eventTime The event time. + */ + default void onDrmSessionReleased(EventTime eventTime) {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultAnalyticsListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultAnalyticsListener.java new file mode 100644 index 0000000000..f56ac3fef0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultAnalyticsListener.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +/** + * @deprecated Use {@link AnalyticsListener} directly for selective overrides as all methods are + * implemented as no-op default methods. + */ +@Deprecated +public abstract class DefaultAnalyticsListener implements AnalyticsListener {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java new file mode 100644 index 0000000000..710934bd36 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -0,0 +1,355 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.util.Base64; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Random; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Default {@link PlaybackSessionManager} which instantiates a new session for each window in the + * timeline and also for each ad within the windows. + * + * <p>Sessions are identified by Base64-encoded, URL-safe, random strings. + */ +public final class DefaultPlaybackSessionManager implements PlaybackSessionManager { + + private static final Random RANDOM = new Random(); + private static final int SESSION_ID_LENGTH = 12; + + private final Timeline.Window window; + private final Timeline.Period period; + private final HashMap<String, SessionDescriptor> sessions; + + private @MonotonicNonNull Listener listener; + private Timeline currentTimeline; + @Nullable private MediaPeriodId currentMediaPeriodId; + @Nullable private String activeSessionId; + + /** Creates session manager. */ + public DefaultPlaybackSessionManager() { + window = new Timeline.Window(); + period = new Timeline.Period(); + sessions = new HashMap<>(); + currentTimeline = Timeline.EMPTY; + } + + @Override + public void setListener(Listener listener) { + this.listener = listener; + } + + @Override + public synchronized String getSessionForMediaPeriodId( + Timeline timeline, MediaPeriodId mediaPeriodId) { + int windowIndex = timeline.getPeriodByUid(mediaPeriodId.periodUid, period).windowIndex; + return getOrAddSession(windowIndex, mediaPeriodId).sessionId; + } + + @Override + public synchronized boolean belongsToSession(EventTime eventTime, String sessionId) { + SessionDescriptor sessionDescriptor = sessions.get(sessionId); + if (sessionDescriptor == null) { + return false; + } + sessionDescriptor.maybeSetWindowSequenceNumber(eventTime.windowIndex, eventTime.mediaPeriodId); + return sessionDescriptor.belongsToSession(eventTime.windowIndex, eventTime.mediaPeriodId); + } + + @Override + public synchronized void updateSessions(EventTime eventTime) { + boolean isObviouslyFinished = + eventTime.mediaPeriodId != null + && currentMediaPeriodId != null + && eventTime.mediaPeriodId.windowSequenceNumber + < currentMediaPeriodId.windowSequenceNumber; + if (!isObviouslyFinished) { + SessionDescriptor descriptor = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (!descriptor.isCreated) { + descriptor.isCreated = true; + Assertions.checkNotNull(listener).onSessionCreated(eventTime, descriptor.sessionId); + if (activeSessionId == null) { + updateActiveSession(eventTime, descriptor); + } + } + } + } + + @Override + public synchronized void handleTimelineUpdate(EventTime eventTime) { + Assertions.checkNotNull(listener); + Timeline previousTimeline = currentTimeline; + currentTimeline = eventTime.timeline; + Iterator<SessionDescriptor> iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + if (!session.tryResolvingToNewTimeline(previousTimeline, currentTimeline)) { + iterator.remove(); + if (session.isCreated) { + if (session.sessionId.equals(activeSessionId)) { + activeSessionId = null; + } + listener.onSessionFinished( + eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); + } + } + } + handlePositionDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); + } + + @Override + public synchronized void handlePositionDiscontinuity( + EventTime eventTime, @DiscontinuityReason int reason) { + Assertions.checkNotNull(listener); + boolean hasAutomaticTransition = + reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION + || reason == Player.DISCONTINUITY_REASON_AD_INSERTION; + Iterator<SessionDescriptor> iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + if (session.isFinishedAtEventTime(eventTime)) { + iterator.remove(); + if (session.isCreated) { + boolean isRemovingActiveSession = session.sessionId.equals(activeSessionId); + boolean isAutomaticTransition = hasAutomaticTransition && isRemovingActiveSession; + if (isRemovingActiveSession) { + activeSessionId = null; + } + listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition); + } + } + } + SessionDescriptor activeSessionDescriptor = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (eventTime.mediaPeriodId != null + && eventTime.mediaPeriodId.isAd() + && (currentMediaPeriodId == null + || currentMediaPeriodId.windowSequenceNumber + != eventTime.mediaPeriodId.windowSequenceNumber + || currentMediaPeriodId.adGroupIndex != eventTime.mediaPeriodId.adGroupIndex + || currentMediaPeriodId.adIndexInAdGroup != eventTime.mediaPeriodId.adIndexInAdGroup)) { + // New ad playback started. Find corresponding content session and notify ad playback started. + MediaPeriodId contentMediaPeriodId = + new MediaPeriodId( + eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); + SessionDescriptor contentSession = + getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); + if (contentSession.isCreated && activeSessionDescriptor.isCreated) { + listener.onAdPlaybackStarted( + eventTime, contentSession.sessionId, activeSessionDescriptor.sessionId); + } + } + updateActiveSession(eventTime, activeSessionDescriptor); + } + + private SessionDescriptor getOrAddSession( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + // There should only be one matching session if mediaPeriodId is non-null. If mediaPeriodId is + // null, there may be multiple matching sessions with different window sequence numbers or + // adMediaPeriodIds. The best match is the one with the smaller window sequence number, and for + // windows with ads, the content session is preferred over ad sessions. + SessionDescriptor bestMatch = null; + long bestMatchWindowSequenceNumber = Long.MAX_VALUE; + for (SessionDescriptor sessionDescriptor : sessions.values()) { + sessionDescriptor.maybeSetWindowSequenceNumber(windowIndex, mediaPeriodId); + if (sessionDescriptor.belongsToSession(windowIndex, mediaPeriodId)) { + long windowSequenceNumber = sessionDescriptor.windowSequenceNumber; + if (windowSequenceNumber == C.INDEX_UNSET + || windowSequenceNumber < bestMatchWindowSequenceNumber) { + bestMatch = sessionDescriptor; + bestMatchWindowSequenceNumber = windowSequenceNumber; + } else if (windowSequenceNumber == bestMatchWindowSequenceNumber + && Util.castNonNull(bestMatch).adMediaPeriodId != null + && sessionDescriptor.adMediaPeriodId != null) { + bestMatch = sessionDescriptor; + } + } + } + if (bestMatch == null) { + String sessionId = generateSessionId(); + bestMatch = new SessionDescriptor(sessionId, windowIndex, mediaPeriodId); + sessions.put(sessionId, bestMatch); + } + return bestMatch; + } + + @RequiresNonNull("listener") + private void updateActiveSession(EventTime eventTime, SessionDescriptor sessionDescriptor) { + currentMediaPeriodId = eventTime.mediaPeriodId; + if (sessionDescriptor.isCreated) { + activeSessionId = sessionDescriptor.sessionId; + if (!sessionDescriptor.isActive) { + sessionDescriptor.isActive = true; + listener.onSessionActive(eventTime, sessionDescriptor.sessionId); + } + } + } + + private static String generateSessionId() { + byte[] randomBytes = new byte[SESSION_ID_LENGTH]; + RANDOM.nextBytes(randomBytes); + return Base64.encodeToString(randomBytes, Base64.URL_SAFE | Base64.NO_WRAP); + } + + /** + * Descriptor for a session. + * + * <p>The session may be described in one of three ways: + * + * <ul> + * <li>A window index with unset window sequence number and a null ad media period id + * <li>A content window with index and sequence number, but a null ad media period id. + * <li>An ad with all values set. + * </ul> + */ + private final class SessionDescriptor { + + private final String sessionId; + + private int windowIndex; + private long windowSequenceNumber; + private @MonotonicNonNull MediaPeriodId adMediaPeriodId; + + private boolean isCreated; + private boolean isActive; + + public SessionDescriptor( + String sessionId, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + this.sessionId = sessionId; + this.windowIndex = windowIndex; + this.windowSequenceNumber = + mediaPeriodId == null ? C.INDEX_UNSET : mediaPeriodId.windowSequenceNumber; + if (mediaPeriodId != null && mediaPeriodId.isAd()) { + this.adMediaPeriodId = mediaPeriodId; + } + } + + public boolean tryResolvingToNewTimeline(Timeline oldTimeline, Timeline newTimeline) { + windowIndex = resolveWindowIndexToNewTimeline(oldTimeline, newTimeline, windowIndex); + if (windowIndex == C.INDEX_UNSET) { + return false; + } + if (adMediaPeriodId == null) { + return true; + } + int newPeriodIndex = newTimeline.getIndexOfPeriod(adMediaPeriodId.periodUid); + return newPeriodIndex != C.INDEX_UNSET; + } + + public boolean belongsToSession( + int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { + if (eventMediaPeriodId == null) { + // Events without concrete media period id are for all sessions of the same window. + return eventWindowIndex == windowIndex; + } + if (adMediaPeriodId == null) { + // If this is a content session, only events for content with the same window sequence + // number belong to this session. + return !eventMediaPeriodId.isAd() + && eventMediaPeriodId.windowSequenceNumber == windowSequenceNumber; + } + // If this is an ad session, only events for this ad belong to the session. + return eventMediaPeriodId.windowSequenceNumber == adMediaPeriodId.windowSequenceNumber + && eventMediaPeriodId.adGroupIndex == adMediaPeriodId.adGroupIndex + && eventMediaPeriodId.adIndexInAdGroup == adMediaPeriodId.adIndexInAdGroup; + } + + public void maybeSetWindowSequenceNumber( + int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { + if (windowSequenceNumber == C.INDEX_UNSET + && eventWindowIndex == windowIndex + && eventMediaPeriodId != null + && !eventMediaPeriodId.isAd()) { + // Set window sequence number for this session as soon as we have one. + windowSequenceNumber = eventMediaPeriodId.windowSequenceNumber; + } + } + + public boolean isFinishedAtEventTime(EventTime eventTime) { + if (windowSequenceNumber == C.INDEX_UNSET) { + // Sessions with unspecified window sequence number are kept until we know more. + return false; + } + if (eventTime.mediaPeriodId == null) { + // For event times without media period id (e.g. after seek to new window), we only keep + // sessions of this window. + return windowIndex != eventTime.windowIndex; + } + if (eventTime.mediaPeriodId.windowSequenceNumber > windowSequenceNumber) { + // All past window sequence numbers are finished. + return true; + } + if (adMediaPeriodId == null) { + // Current or future content is not finished. + return false; + } + int eventPeriodIndex = eventTime.timeline.getIndexOfPeriod(eventTime.mediaPeriodId.periodUid); + int adPeriodIndex = eventTime.timeline.getIndexOfPeriod(adMediaPeriodId.periodUid); + if (eventTime.mediaPeriodId.windowSequenceNumber < adMediaPeriodId.windowSequenceNumber + || eventPeriodIndex < adPeriodIndex) { + // Ads in future windows or periods are not finished. + return false; + } + if (eventPeriodIndex > adPeriodIndex) { + // Ads in past periods are finished. + return true; + } + if (eventTime.mediaPeriodId.isAd()) { + int eventAdGroup = eventTime.mediaPeriodId.adGroupIndex; + int eventAdIndex = eventTime.mediaPeriodId.adIndexInAdGroup; + // Finished if event is for an ad after this one in the same period. + return eventAdGroup > adMediaPeriodId.adGroupIndex + || (eventAdGroup == adMediaPeriodId.adGroupIndex + && eventAdIndex > adMediaPeriodId.adIndexInAdGroup); + } else { + // Finished if the event is for content after this ad. + return eventTime.mediaPeriodId.nextAdGroupIndex == C.INDEX_UNSET + || eventTime.mediaPeriodId.nextAdGroupIndex > adMediaPeriodId.adGroupIndex; + } + } + + private int resolveWindowIndexToNewTimeline( + Timeline oldTimeline, Timeline newTimeline, int windowIndex) { + if (windowIndex >= oldTimeline.getWindowCount()) { + return windowIndex < newTimeline.getWindowCount() ? windowIndex : C.INDEX_UNSET; + } + oldTimeline.getWindow(windowIndex, window); + for (int periodIndex = window.firstPeriodIndex; + periodIndex <= window.lastPeriodIndex; + periodIndex++) { + Object periodUid = oldTimeline.getUidOfPeriod(periodIndex); + int newPeriodIndex = newTimeline.getIndexOfPeriod(periodUid); + if (newPeriodIndex != C.INDEX_UNSET) { + return newTimeline.getPeriod(newPeriodIndex, period).windowIndex; + } + } + return C.INDEX_UNSET; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java new file mode 100644 index 0000000000..d3c6f7dd20 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -0,0 +1,120 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; + +/** + * Manager for active playback sessions. + * + * <p>The manager keeps track of the association between window index and/or media period id to + * session identifier. + */ +public interface PlaybackSessionManager { + + /** A listener for session updates. */ + interface Listener { + + /** + * Called when a new session is created as a result of {@link #updateSessions(EventTime)}. + * + * @param eventTime The {@link EventTime} at which the session is created. + * @param sessionId The identifier of the new session. + */ + void onSessionCreated(EventTime eventTime, String sessionId); + + /** + * Called when a session becomes active, i.e. playing in the foreground. + * + * @param eventTime The {@link EventTime} at which the session becomes active. + * @param sessionId The identifier of the session. + */ + void onSessionActive(EventTime eventTime, String sessionId); + + /** + * Called when a session is interrupted by ad playback. + * + * @param eventTime The {@link EventTime} at which the ad playback starts. + * @param contentSessionId The session identifier of the content session. + * @param adSessionId The identifier of the ad session. + */ + void onAdPlaybackStarted(EventTime eventTime, String contentSessionId, String adSessionId); + + /** + * Called when a session is permanently finished. + * + * @param eventTime The {@link EventTime} at which the session finished. + * @param sessionId The identifier of the finished session. + * @param automaticTransitionToNextPlayback Whether the session finished because of an automatic + * transition to the next playback item. + */ + void onSessionFinished( + EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback); + } + + /** + * Sets the listener to be notified of session updates. Must be called before the session manager + * is used. + * + * @param listener The {@link Listener} to be notified of session updates. + */ + void setListener(Listener listener); + + /** + * Returns the session identifier for the given media period id. + * + * <p>Note that this will reserve a new session identifier if it doesn't exist yet, but will not + * call any {@link Listener} callbacks. + * + * @param timeline The timeline, {@code mediaPeriodId} is part of. + * @param mediaPeriodId A {@link MediaPeriodId}. + */ + String getSessionForMediaPeriodId(Timeline timeline, MediaPeriodId mediaPeriodId); + + /** + * Returns whether an event time belong to a session. + * + * @param eventTime The {@link EventTime}. + * @param sessionId A session identifier. + * @return Whether the event belongs to the specified session. + */ + boolean belongsToSession(EventTime eventTime, String sessionId); + + /** + * Updates or creates sessions based on a player {@link EventTime}. + * + * @param eventTime The {@link EventTime}. + */ + void updateSessions(EventTime eventTime); + + /** + * Updates the session associations to a new timeline. + * + * @param eventTime The event time with the timeline change. + */ + void handleTimelineUpdate(EventTime eventTime); + + /** + * Handles a position discontinuity. + * + * @param eventTime The event time of the position discontinuity. + * @param reason The {@link DiscontinuityReason}. + */ + void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStats.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStats.java new file mode 100644 index 0000000000..eef0f6e7ce --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStats.java @@ -0,0 +1,980 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Statistics about playbacks. */ +public final class PlaybackStats { + + /** + * State of a playback. One of {@link #PLAYBACK_STATE_NOT_STARTED}, {@link + * #PLAYBACK_STATE_JOINING_FOREGROUND}, {@link #PLAYBACK_STATE_JOINING_BACKGROUND}, {@link + * #PLAYBACK_STATE_PLAYING}, {@link #PLAYBACK_STATE_PAUSED}, {@link #PLAYBACK_STATE_SEEKING}, + * {@link #PLAYBACK_STATE_BUFFERING}, {@link #PLAYBACK_STATE_PAUSED_BUFFERING}, {@link + * #PLAYBACK_STATE_SEEK_BUFFERING}, {@link #PLAYBACK_STATE_SUPPRESSED}, {@link + * #PLAYBACK_STATE_SUPPRESSED_BUFFERING}, {@link #PLAYBACK_STATE_ENDED}, {@link + * #PLAYBACK_STATE_STOPPED}, {@link #PLAYBACK_STATE_FAILED}, {@link + * #PLAYBACK_STATE_INTERRUPTED_BY_AD} or {@link #PLAYBACK_STATE_ABANDONED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) + @IntDef({ + PLAYBACK_STATE_NOT_STARTED, + PLAYBACK_STATE_JOINING_BACKGROUND, + PLAYBACK_STATE_JOINING_FOREGROUND, + PLAYBACK_STATE_PLAYING, + PLAYBACK_STATE_PAUSED, + PLAYBACK_STATE_SEEKING, + PLAYBACK_STATE_BUFFERING, + PLAYBACK_STATE_PAUSED_BUFFERING, + PLAYBACK_STATE_SEEK_BUFFERING, + PLAYBACK_STATE_SUPPRESSED, + PLAYBACK_STATE_SUPPRESSED_BUFFERING, + PLAYBACK_STATE_ENDED, + PLAYBACK_STATE_STOPPED, + PLAYBACK_STATE_FAILED, + PLAYBACK_STATE_INTERRUPTED_BY_AD, + PLAYBACK_STATE_ABANDONED + }) + @interface PlaybackState {} + /** Playback has not started (initial state). */ + public static final int PLAYBACK_STATE_NOT_STARTED = 0; + /** Playback is buffering in the background for initial playback start. */ + public static final int PLAYBACK_STATE_JOINING_BACKGROUND = 1; + /** Playback is buffering in the foreground for initial playback start. */ + public static final int PLAYBACK_STATE_JOINING_FOREGROUND = 2; + /** Playback is actively playing. */ + public static final int PLAYBACK_STATE_PLAYING = 3; + /** Playback is paused but ready to play. */ + public static final int PLAYBACK_STATE_PAUSED = 4; + /** Playback is handling a seek. */ + public static final int PLAYBACK_STATE_SEEKING = 5; + /** Playback is buffering to resume active playback. */ + public static final int PLAYBACK_STATE_BUFFERING = 6; + /** Playback is buffering while paused. */ + public static final int PLAYBACK_STATE_PAUSED_BUFFERING = 7; + /** Playback is buffering after a seek. */ + public static final int PLAYBACK_STATE_SEEK_BUFFERING = 8; + /** Playback is suppressed (e.g. due to audio focus loss). */ + public static final int PLAYBACK_STATE_SUPPRESSED = 9; + /** Playback is suppressed (e.g. due to audio focus loss) while buffering to resume a playback. */ + public static final int PLAYBACK_STATE_SUPPRESSED_BUFFERING = 10; + /** Playback has reached the end of the media. */ + public static final int PLAYBACK_STATE_ENDED = 11; + /** Playback is stopped and can be restarted. */ + public static final int PLAYBACK_STATE_STOPPED = 12; + /** Playback is stopped due a fatal error and can be retried. */ + public static final int PLAYBACK_STATE_FAILED = 13; + /** Playback is interrupted by an ad. */ + public static final int PLAYBACK_STATE_INTERRUPTED_BY_AD = 14; + /** Playback is abandoned before reaching the end of the media. */ + public static final int PLAYBACK_STATE_ABANDONED = 15; + /** Total number of playback states. */ + /* package */ static final int PLAYBACK_STATE_COUNT = 16; + + /** Empty playback stats. */ + public static final PlaybackStats EMPTY = merge(/* nothing */ ); + + /** + * Returns the combined {@link PlaybackStats} for all input {@link PlaybackStats}. + * + * <p>Note that the full history of events is not kept as the history only makes sense in the + * context of a single playback. + * + * @param playbackStats Array of {@link PlaybackStats} to combine. + * @return The combined {@link PlaybackStats}. + */ + public static PlaybackStats merge(PlaybackStats... playbackStats) { + int playbackCount = 0; + long[] playbackStateDurationsMs = new long[PLAYBACK_STATE_COUNT]; + long firstReportedTimeMs = C.TIME_UNSET; + int foregroundPlaybackCount = 0; + int abandonedBeforeReadyCount = 0; + int endedCount = 0; + int backgroundJoiningCount = 0; + long totalValidJoinTimeMs = C.TIME_UNSET; + int validJoinTimeCount = 0; + int totalPauseCount = 0; + int totalPauseBufferCount = 0; + int totalSeekCount = 0; + int totalRebufferCount = 0; + long maxRebufferTimeMs = C.TIME_UNSET; + int adPlaybackCount = 0; + long totalVideoFormatHeightTimeMs = 0; + long totalVideoFormatHeightTimeProduct = 0; + long totalVideoFormatBitrateTimeMs = 0; + long totalVideoFormatBitrateTimeProduct = 0; + long totalAudioFormatTimeMs = 0; + long totalAudioFormatBitrateTimeProduct = 0; + int initialVideoFormatHeightCount = 0; + int initialVideoFormatBitrateCount = 0; + int totalInitialVideoFormatHeight = C.LENGTH_UNSET; + long totalInitialVideoFormatBitrate = C.LENGTH_UNSET; + int initialAudioFormatBitrateCount = 0; + long totalInitialAudioFormatBitrate = C.LENGTH_UNSET; + long totalBandwidthTimeMs = 0; + long totalBandwidthBytes = 0; + long totalDroppedFrames = 0; + long totalAudioUnderruns = 0; + int fatalErrorPlaybackCount = 0; + int fatalErrorCount = 0; + int nonFatalErrorCount = 0; + for (PlaybackStats stats : playbackStats) { + playbackCount += stats.playbackCount; + for (int i = 0; i < PLAYBACK_STATE_COUNT; i++) { + playbackStateDurationsMs[i] += stats.playbackStateDurationsMs[i]; + } + if (firstReportedTimeMs == C.TIME_UNSET) { + firstReportedTimeMs = stats.firstReportedTimeMs; + } else if (stats.firstReportedTimeMs != C.TIME_UNSET) { + firstReportedTimeMs = Math.min(firstReportedTimeMs, stats.firstReportedTimeMs); + } + foregroundPlaybackCount += stats.foregroundPlaybackCount; + abandonedBeforeReadyCount += stats.abandonedBeforeReadyCount; + endedCount += stats.endedCount; + backgroundJoiningCount += stats.backgroundJoiningCount; + if (totalValidJoinTimeMs == C.TIME_UNSET) { + totalValidJoinTimeMs = stats.totalValidJoinTimeMs; + } else if (stats.totalValidJoinTimeMs != C.TIME_UNSET) { + totalValidJoinTimeMs += stats.totalValidJoinTimeMs; + } + validJoinTimeCount += stats.validJoinTimeCount; + totalPauseCount += stats.totalPauseCount; + totalPauseBufferCount += stats.totalPauseBufferCount; + totalSeekCount += stats.totalSeekCount; + totalRebufferCount += stats.totalRebufferCount; + if (maxRebufferTimeMs == C.TIME_UNSET) { + maxRebufferTimeMs = stats.maxRebufferTimeMs; + } else if (stats.maxRebufferTimeMs != C.TIME_UNSET) { + maxRebufferTimeMs = Math.max(maxRebufferTimeMs, stats.maxRebufferTimeMs); + } + adPlaybackCount += stats.adPlaybackCount; + totalVideoFormatHeightTimeMs += stats.totalVideoFormatHeightTimeMs; + totalVideoFormatHeightTimeProduct += stats.totalVideoFormatHeightTimeProduct; + totalVideoFormatBitrateTimeMs += stats.totalVideoFormatBitrateTimeMs; + totalVideoFormatBitrateTimeProduct += stats.totalVideoFormatBitrateTimeProduct; + totalAudioFormatTimeMs += stats.totalAudioFormatTimeMs; + totalAudioFormatBitrateTimeProduct += stats.totalAudioFormatBitrateTimeProduct; + initialVideoFormatHeightCount += stats.initialVideoFormatHeightCount; + initialVideoFormatBitrateCount += stats.initialVideoFormatBitrateCount; + if (totalInitialVideoFormatHeight == C.LENGTH_UNSET) { + totalInitialVideoFormatHeight = stats.totalInitialVideoFormatHeight; + } else if (stats.totalInitialVideoFormatHeight != C.LENGTH_UNSET) { + totalInitialVideoFormatHeight += stats.totalInitialVideoFormatHeight; + } + if (totalInitialVideoFormatBitrate == C.LENGTH_UNSET) { + totalInitialVideoFormatBitrate = stats.totalInitialVideoFormatBitrate; + } else if (stats.totalInitialVideoFormatBitrate != C.LENGTH_UNSET) { + totalInitialVideoFormatBitrate += stats.totalInitialVideoFormatBitrate; + } + initialAudioFormatBitrateCount += stats.initialAudioFormatBitrateCount; + if (totalInitialAudioFormatBitrate == C.LENGTH_UNSET) { + totalInitialAudioFormatBitrate = stats.totalInitialAudioFormatBitrate; + } else if (stats.totalInitialAudioFormatBitrate != C.LENGTH_UNSET) { + totalInitialAudioFormatBitrate += stats.totalInitialAudioFormatBitrate; + } + totalBandwidthTimeMs += stats.totalBandwidthTimeMs; + totalBandwidthBytes += stats.totalBandwidthBytes; + totalDroppedFrames += stats.totalDroppedFrames; + totalAudioUnderruns += stats.totalAudioUnderruns; + fatalErrorPlaybackCount += stats.fatalErrorPlaybackCount; + fatalErrorCount += stats.fatalErrorCount; + nonFatalErrorCount += stats.nonFatalErrorCount; + } + return new PlaybackStats( + playbackCount, + playbackStateDurationsMs, + /* playbackStateHistory */ Collections.emptyList(), + /* mediaTimeHistory= */ Collections.emptyList(), + firstReportedTimeMs, + foregroundPlaybackCount, + abandonedBeforeReadyCount, + endedCount, + backgroundJoiningCount, + totalValidJoinTimeMs, + validJoinTimeCount, + totalPauseCount, + totalPauseBufferCount, + totalSeekCount, + totalRebufferCount, + maxRebufferTimeMs, + adPlaybackCount, + /* videoFormatHistory= */ Collections.emptyList(), + /* audioFormatHistory= */ Collections.emptyList(), + totalVideoFormatHeightTimeMs, + totalVideoFormatHeightTimeProduct, + totalVideoFormatBitrateTimeMs, + totalVideoFormatBitrateTimeProduct, + totalAudioFormatTimeMs, + totalAudioFormatBitrateTimeProduct, + initialVideoFormatHeightCount, + initialVideoFormatBitrateCount, + totalInitialVideoFormatHeight, + totalInitialVideoFormatBitrate, + initialAudioFormatBitrateCount, + totalInitialAudioFormatBitrate, + totalBandwidthTimeMs, + totalBandwidthBytes, + totalDroppedFrames, + totalAudioUnderruns, + fatalErrorPlaybackCount, + fatalErrorCount, + nonFatalErrorCount, + /* fatalErrorHistory= */ Collections.emptyList(), + /* nonFatalErrorHistory= */ Collections.emptyList()); + } + + /** The number of individual playbacks for which these stats were collected. */ + public final int playbackCount; + + // Playback state stats. + + /** + * The playback state history as ordered pairs of the {@link EventTime} at which a state became + * active and the {@link PlaybackState}. + */ + public final List<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory; + /** + * The media time history as an ordered list of long[2] arrays with [0] being the realtime as + * returned by {@code SystemClock.elapsedRealtime()} and [1] being the media time at this + * realtime, in milliseconds. + */ + public final List<long[]> mediaTimeHistory; + /** + * The elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} of the first + * reported playback event, or {@link C#TIME_UNSET} if no event has been reported. + */ + public final long firstReportedTimeMs; + /** The number of playbacks which were the active foreground playback at some point. */ + public final int foregroundPlaybackCount; + /** The number of playbacks which were abandoned before they were ready to play. */ + public final int abandonedBeforeReadyCount; + /** The number of playbacks which reached the ended state at least once. */ + public final int endedCount; + /** The number of playbacks which were pre-buffered in the background. */ + public final int backgroundJoiningCount; + /** + * The total time spent joining the playback, in milliseconds, or {@link C#TIME_UNSET} if no valid + * join time could be determined. + * + * <p>Note that this does not include background joining time. A join time may be invalid if the + * playback never reached {@link #PLAYBACK_STATE_PLAYING} or {@link #PLAYBACK_STATE_PAUSED}, or + * joining was interrupted by a seek, stop, or error state. + */ + public final long totalValidJoinTimeMs; + /** + * The number of playbacks with a valid join time as documented in {@link #totalValidJoinTimeMs}. + */ + public final int validJoinTimeCount; + /** The total number of times a playback has been paused. */ + public final int totalPauseCount; + /** The total number of times a playback has been paused while rebuffering. */ + public final int totalPauseBufferCount; + /** + * The total number of times a seek occurred. This includes seeks happening before playback + * resumed after another seek. + */ + public final int totalSeekCount; + /** + * The total number of times a rebuffer occurred. This excludes initial joining and buffering + * after seek. + */ + public final int totalRebufferCount; + /** + * The maximum time spent during a single rebuffer, in milliseconds, or {@link C#TIME_UNSET} if no + * rebuffer occurred. + */ + public final long maxRebufferTimeMs; + /** The number of ad playbacks. */ + public final int adPlaybackCount; + + // Format stats. + + /** + * The video format history as ordered pairs of the {@link EventTime} at which a format started + * being used and the {@link Format}. The {@link Format} may be null if no video format was used. + */ + public final List<Pair<EventTime, @NullableType Format>> videoFormatHistory; + /** + * The audio format history as ordered pairs of the {@link EventTime} at which a format started + * being used and the {@link Format}. The {@link Format} may be null if no audio format was used. + */ + public final List<Pair<EventTime, @NullableType Format>> audioFormatHistory; + /** The total media time for which video format height data is available, in milliseconds. */ + public final long totalVideoFormatHeightTimeMs; + /** + * The accumulated sum of all video format heights, in pixels, times the time the format was used + * for playback, in milliseconds. + */ + public final long totalVideoFormatHeightTimeProduct; + /** The total media time for which video format bitrate data is available, in milliseconds. */ + public final long totalVideoFormatBitrateTimeMs; + /** + * The accumulated sum of all video format bitrates, in bits per second, times the time the format + * was used for playback, in milliseconds. + */ + public final long totalVideoFormatBitrateTimeProduct; + /** The total media time for which audio format data is available, in milliseconds. */ + public final long totalAudioFormatTimeMs; + /** + * The accumulated sum of all audio format bitrates, in bits per second, times the time the format + * was used for playback, in milliseconds. + */ + public final long totalAudioFormatBitrateTimeProduct; + /** The number of playbacks with initial video format height data. */ + public final int initialVideoFormatHeightCount; + /** The number of playbacks with initial video format bitrate data. */ + public final int initialVideoFormatBitrateCount; + /** + * The total initial video format height for all playbacks, in pixels, or {@link C#LENGTH_UNSET} + * if no initial video format data is available. + */ + public final int totalInitialVideoFormatHeight; + /** + * The total initial video format bitrate for all playbacks, in bits per second, or {@link + * C#LENGTH_UNSET} if no initial video format data is available. + */ + public final long totalInitialVideoFormatBitrate; + /** The number of playbacks with initial audio format bitrate data. */ + public final int initialAudioFormatBitrateCount; + /** + * The total initial audio format bitrate for all playbacks, in bits per second, or {@link + * C#LENGTH_UNSET} if no initial audio format data is available. + */ + public final long totalInitialAudioFormatBitrate; + + // Bandwidth stats. + + /** The total time for which bandwidth measurement data is available, in milliseconds. */ + public final long totalBandwidthTimeMs; + /** The total bytes transferred during {@link #totalBandwidthTimeMs}. */ + public final long totalBandwidthBytes; + + // Renderer quality stats. + + /** The total number of dropped video frames. */ + public final long totalDroppedFrames; + /** The total number of audio underruns. */ + public final long totalAudioUnderruns; + + // Error stats. + + /** + * The total number of playback with at least one fatal error. Errors are fatal if playback + * stopped due to this error. + */ + public final int fatalErrorPlaybackCount; + /** The total number of fatal errors. Errors are fatal if playback stopped due to this error. */ + public final int fatalErrorCount; + /** + * The total number of non-fatal errors. Error are non-fatal if playback can recover from the + * error without stopping. + */ + public final int nonFatalErrorCount; + /** + * The history of fatal errors as ordered pairs of the {@link EventTime} at which an error + * occurred and the error. Errors are fatal if playback stopped due to this error. + */ + public final List<Pair<EventTime, Exception>> fatalErrorHistory; + /** + * The history of non-fatal errors as ordered pairs of the {@link EventTime} at which an error + * occurred and the error. Error are non-fatal if playback can recover from the error without + * stopping. + */ + public final List<Pair<EventTime, Exception>> nonFatalErrorHistory; + + private final long[] playbackStateDurationsMs; + + /* package */ PlaybackStats( + int playbackCount, + long[] playbackStateDurationsMs, + List<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory, + List<long[]> mediaTimeHistory, + long firstReportedTimeMs, + int foregroundPlaybackCount, + int abandonedBeforeReadyCount, + int endedCount, + int backgroundJoiningCount, + long totalValidJoinTimeMs, + int validJoinTimeCount, + int totalPauseCount, + int totalPauseBufferCount, + int totalSeekCount, + int totalRebufferCount, + long maxRebufferTimeMs, + int adPlaybackCount, + List<Pair<EventTime, @NullableType Format>> videoFormatHistory, + List<Pair<EventTime, @NullableType Format>> audioFormatHistory, + long totalVideoFormatHeightTimeMs, + long totalVideoFormatHeightTimeProduct, + long totalVideoFormatBitrateTimeMs, + long totalVideoFormatBitrateTimeProduct, + long totalAudioFormatTimeMs, + long totalAudioFormatBitrateTimeProduct, + int initialVideoFormatHeightCount, + int initialVideoFormatBitrateCount, + int totalInitialVideoFormatHeight, + long totalInitialVideoFormatBitrate, + int initialAudioFormatBitrateCount, + long totalInitialAudioFormatBitrate, + long totalBandwidthTimeMs, + long totalBandwidthBytes, + long totalDroppedFrames, + long totalAudioUnderruns, + int fatalErrorPlaybackCount, + int fatalErrorCount, + int nonFatalErrorCount, + List<Pair<EventTime, Exception>> fatalErrorHistory, + List<Pair<EventTime, Exception>> nonFatalErrorHistory) { + this.playbackCount = playbackCount; + this.playbackStateDurationsMs = playbackStateDurationsMs; + this.playbackStateHistory = Collections.unmodifiableList(playbackStateHistory); + this.mediaTimeHistory = Collections.unmodifiableList(mediaTimeHistory); + this.firstReportedTimeMs = firstReportedTimeMs; + this.foregroundPlaybackCount = foregroundPlaybackCount; + this.abandonedBeforeReadyCount = abandonedBeforeReadyCount; + this.endedCount = endedCount; + this.backgroundJoiningCount = backgroundJoiningCount; + this.totalValidJoinTimeMs = totalValidJoinTimeMs; + this.validJoinTimeCount = validJoinTimeCount; + this.totalPauseCount = totalPauseCount; + this.totalPauseBufferCount = totalPauseBufferCount; + this.totalSeekCount = totalSeekCount; + this.totalRebufferCount = totalRebufferCount; + this.maxRebufferTimeMs = maxRebufferTimeMs; + this.adPlaybackCount = adPlaybackCount; + this.videoFormatHistory = Collections.unmodifiableList(videoFormatHistory); + this.audioFormatHistory = Collections.unmodifiableList(audioFormatHistory); + this.totalVideoFormatHeightTimeMs = totalVideoFormatHeightTimeMs; + this.totalVideoFormatHeightTimeProduct = totalVideoFormatHeightTimeProduct; + this.totalVideoFormatBitrateTimeMs = totalVideoFormatBitrateTimeMs; + this.totalVideoFormatBitrateTimeProduct = totalVideoFormatBitrateTimeProduct; + this.totalAudioFormatTimeMs = totalAudioFormatTimeMs; + this.totalAudioFormatBitrateTimeProduct = totalAudioFormatBitrateTimeProduct; + this.initialVideoFormatHeightCount = initialVideoFormatHeightCount; + this.initialVideoFormatBitrateCount = initialVideoFormatBitrateCount; + this.totalInitialVideoFormatHeight = totalInitialVideoFormatHeight; + this.totalInitialVideoFormatBitrate = totalInitialVideoFormatBitrate; + this.initialAudioFormatBitrateCount = initialAudioFormatBitrateCount; + this.totalInitialAudioFormatBitrate = totalInitialAudioFormatBitrate; + this.totalBandwidthTimeMs = totalBandwidthTimeMs; + this.totalBandwidthBytes = totalBandwidthBytes; + this.totalDroppedFrames = totalDroppedFrames; + this.totalAudioUnderruns = totalAudioUnderruns; + this.fatalErrorPlaybackCount = fatalErrorPlaybackCount; + this.fatalErrorCount = fatalErrorCount; + this.nonFatalErrorCount = nonFatalErrorCount; + this.fatalErrorHistory = Collections.unmodifiableList(fatalErrorHistory); + this.nonFatalErrorHistory = Collections.unmodifiableList(nonFatalErrorHistory); + } + + /** + * Returns the total time spent in a given {@link PlaybackState}, in milliseconds. + * + * @param playbackState A {@link PlaybackState}. + * @return Total spent in the given playback state, in milliseconds + */ + public long getPlaybackStateDurationMs(@PlaybackState int playbackState) { + return playbackStateDurationsMs[playbackState]; + } + + /** + * Returns the {@link PlaybackState} at the given time. + * + * @param realtimeMs The time as returned by {@link SystemClock#elapsedRealtime()}. + * @return The {@link PlaybackState} at that time, or {@link #PLAYBACK_STATE_NOT_STARTED} if the + * given time is before the first known playback state in the history. + */ + public @PlaybackState int getPlaybackStateAtTime(long realtimeMs) { + @PlaybackState int state = PLAYBACK_STATE_NOT_STARTED; + for (Pair<EventTime, @PlaybackState Integer> timeAndState : playbackStateHistory) { + if (timeAndState.first.realtimeMs > realtimeMs) { + break; + } + state = timeAndState.second; + } + return state; + } + + /** + * Returns the estimated media time at the given realtime, in milliseconds, or {@link + * C#TIME_UNSET} if the media time history is unknown. + * + * @param realtimeMs The realtime as returned by {@link SystemClock#elapsedRealtime()}. + * @return The estimated media time in milliseconds at this realtime, {@link C#TIME_UNSET} if no + * estimate can be given. + */ + public long getMediaTimeMsAtRealtimeMs(long realtimeMs) { + if (mediaTimeHistory.isEmpty()) { + return C.TIME_UNSET; + } + int nextIndex = 0; + while (nextIndex < mediaTimeHistory.size() + && mediaTimeHistory.get(nextIndex)[0] <= realtimeMs) { + nextIndex++; + } + if (nextIndex == 0) { + return mediaTimeHistory.get(0)[1]; + } + if (nextIndex == mediaTimeHistory.size()) { + return mediaTimeHistory.get(mediaTimeHistory.size() - 1)[1]; + } + long prevRealtimeMs = mediaTimeHistory.get(nextIndex - 1)[0]; + long prevMediaTimeMs = mediaTimeHistory.get(nextIndex - 1)[1]; + long nextRealtimeMs = mediaTimeHistory.get(nextIndex)[0]; + long nextMediaTimeMs = mediaTimeHistory.get(nextIndex)[1]; + long realtimeDurationMs = nextRealtimeMs - prevRealtimeMs; + if (realtimeDurationMs == 0) { + return prevMediaTimeMs; + } + float fraction = (float) (realtimeMs - prevRealtimeMs) / realtimeDurationMs; + return prevMediaTimeMs + (long) ((nextMediaTimeMs - prevMediaTimeMs) * fraction); + } + + /** + * Returns the mean time spent joining the playback, in milliseconds, or {@link C#TIME_UNSET} if + * no valid join time is available. Only includes playbacks with valid join times as documented in + * {@link #totalValidJoinTimeMs}. + */ + public long getMeanJoinTimeMs() { + return validJoinTimeCount == 0 ? C.TIME_UNSET : totalValidJoinTimeMs / validJoinTimeCount; + } + + /** + * Returns the total time spent joining the playback in foreground, in milliseconds. This does + * include invalid join times where the playback never reached {@link #PLAYBACK_STATE_PLAYING} or + * {@link #PLAYBACK_STATE_PAUSED}, or joining was interrupted by a seek, stop, or error state. + */ + public long getTotalJoinTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_JOINING_FOREGROUND); + } + + /** Returns the total time spent actively playing, in milliseconds. */ + public long getTotalPlayTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_PLAYING); + } + + /** + * Returns the mean time spent actively playing per foreground playback, in milliseconds, or + * {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanPlayTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalPlayTimeMs() / foregroundPlaybackCount; + } + + /** Returns the total time spent in a paused state, in milliseconds. */ + public long getTotalPausedTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_PAUSED) + + getPlaybackStateDurationMs(PLAYBACK_STATE_PAUSED_BUFFERING); + } + + /** + * Returns the mean time spent in a paused state per foreground playback, in milliseconds, or + * {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanPausedTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalPausedTimeMs() / foregroundPlaybackCount; + } + + /** + * Returns the total time spent rebuffering, in milliseconds. This excludes initial join times, + * buffer times after a seek and buffering while paused. + */ + public long getTotalRebufferTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING); + } + + /** + * Returns the mean time spent rebuffering per foreground playback, in milliseconds, or {@link + * C#TIME_UNSET} if no playback has been in foreground. This excludes initial join times, buffer + * times after a seek and buffering while paused. + */ + public long getMeanRebufferTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalRebufferTimeMs() / foregroundPlaybackCount; + } + + /** + * Returns the mean time spent during a single rebuffer, in milliseconds, or {@link C#TIME_UNSET} + * if no rebuffer was recorded. This excludes initial join times and buffer times after a seek. + */ + public long getMeanSingleRebufferTimeMs() { + return totalRebufferCount == 0 + ? C.TIME_UNSET + : (getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_PAUSED_BUFFERING)) + / totalRebufferCount; + } + + /** + * Returns the total time spent from the start of a seek until playback is ready again, in + * milliseconds. + */ + public long getTotalSeekTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING); + } + + /** + * Returns the mean time spent per foreground playback from the start of a seek until playback is + * ready again, in milliseconds, or {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanSeekTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalSeekTimeMs() / foregroundPlaybackCount; + } + + /** + * Returns the mean time spent from the start of a single seek until playback is ready again, in + * milliseconds, or {@link C#TIME_UNSET} if no seek occurred. + */ + public long getMeanSingleSeekTimeMs() { + return totalSeekCount == 0 ? C.TIME_UNSET : getTotalSeekTimeMs() / totalSeekCount; + } + + /** + * Returns the total time spent actively waiting for playback, in milliseconds. This includes all + * join times, rebuffer times and seek times, but excludes times without user intention to play, + * e.g. all paused states. + */ + public long getTotalWaitTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_JOINING_FOREGROUND) + + getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING); + } + + /** + * Returns the mean time spent actively waiting for playback per foreground playback, in + * milliseconds, or {@link C#TIME_UNSET} if no playback has been in foreground. This includes all + * join times, rebuffer times and seek times, but excludes times without user intention to play, + * e.g. all paused states. + */ + public long getMeanWaitTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalWaitTimeMs() / foregroundPlaybackCount; + } + + /** Returns the total time spent playing or actively waiting for playback, in milliseconds. */ + public long getTotalPlayAndWaitTimeMs() { + return getTotalPlayTimeMs() + getTotalWaitTimeMs(); + } + + /** + * Returns the mean time spent playing or actively waiting for playback per foreground playback, + * in milliseconds, or {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanPlayAndWaitTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalPlayAndWaitTimeMs() / foregroundPlaybackCount; + } + + /** Returns the total time covered by any playback state, in milliseconds. */ + public long getTotalElapsedTimeMs() { + long totalTimeMs = 0; + for (int i = 0; i < PLAYBACK_STATE_COUNT; i++) { + totalTimeMs += playbackStateDurationsMs[i]; + } + return totalTimeMs; + } + + /** + * Returns the mean time covered by any playback state per playback, in milliseconds, or {@link + * C#TIME_UNSET} if no playback was recorded. + */ + public long getMeanElapsedTimeMs() { + return playbackCount == 0 ? C.TIME_UNSET : getTotalElapsedTimeMs() / playbackCount; + } + + /** + * Returns the ratio of foreground playbacks which were abandoned before they were ready to play, + * or {@code 0.0} if no playback has been in foreground. + */ + public float getAbandonedBeforeReadyRatio() { + int foregroundAbandonedBeforeReady = + abandonedBeforeReadyCount - (playbackCount - foregroundPlaybackCount); + return foregroundPlaybackCount == 0 + ? 0f + : (float) foregroundAbandonedBeforeReady / foregroundPlaybackCount; + } + + /** + * Returns the ratio of foreground playbacks which reached the ended state at least once, or + * {@code 0.0} if no playback has been in foreground. + */ + public float getEndedRatio() { + return foregroundPlaybackCount == 0 ? 0f : (float) endedCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a playback has been paused per foreground playback, or {@code + * 0.0} if no playback has been in foreground. + */ + public float getMeanPauseCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) totalPauseCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a playback has been paused while rebuffering per foreground + * playback, or {@code 0.0} if no playback has been in foreground. + */ + public float getMeanPauseBufferCount() { + return foregroundPlaybackCount == 0 + ? 0f + : (float) totalPauseBufferCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a seek occurred per foreground playback, or {@code 0.0} if no + * playback has been in foreground. This includes seeks happening before playback resumed after + * another seek. + */ + public float getMeanSeekCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) totalSeekCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a rebuffer occurred per foreground playback, or {@code 0.0} if + * no playback has been in foreground. This excludes initial joining and buffering after seek. + */ + public float getMeanRebufferCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) totalRebufferCount / foregroundPlaybackCount; + } + + /** + * Returns the ratio of wait times to the total time spent playing and waiting, or {@code 0.0} if + * no time was spend playing or waiting. This is equivalent to {@link #getTotalWaitTimeMs()} / + * {@link #getTotalPlayAndWaitTimeMs()} and also to {@link #getJoinTimeRatio()} + {@link + * #getRebufferTimeRatio()} + {@link #getSeekTimeRatio()}. + */ + public float getWaitTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalWaitTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the ratio of foreground join time to the total time spent playing and waiting, or + * {@code 0.0} if no time was spend playing or waiting. This is equivalent to {@link + * #getTotalJoinTimeMs()} / {@link #getTotalPlayAndWaitTimeMs()}. + */ + public float getJoinTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalJoinTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the ratio of rebuffer time to the total time spent playing and waiting, or {@code 0.0} + * if no time was spend playing or waiting. This is equivalent to {@link + * #getTotalRebufferTimeMs()} / {@link #getTotalPlayAndWaitTimeMs()}. + */ + public float getRebufferTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalRebufferTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the ratio of seek time to the total time spent playing and waiting, or {@code 0.0} if + * no time was spend playing or waiting. This is equivalent to {@link #getTotalSeekTimeMs()} / + * {@link #getTotalPlayAndWaitTimeMs()}. + */ + public float getSeekTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalSeekTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the rate of rebuffer events, in rebuffers per play time second, or {@code 0.0} if no + * time was spend playing. This is equivalent to 1.0 / {@link #getMeanTimeBetweenRebuffers()}. + */ + public float getRebufferRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * totalRebufferCount / playTimeMs; + } + + /** + * Returns the mean play time between rebuffer events, in seconds. This is equivalent to 1.0 / + * {@link #getRebufferRate()}. Note that this may return {@link Float#POSITIVE_INFINITY}. + */ + public float getMeanTimeBetweenRebuffers() { + return 1f / getRebufferRate(); + } + + /** + * Returns the mean initial video format height, in pixels, or {@link C#LENGTH_UNSET} if no video + * format data is available. + */ + public int getMeanInitialVideoFormatHeight() { + return initialVideoFormatHeightCount == 0 + ? C.LENGTH_UNSET + : totalInitialVideoFormatHeight / initialVideoFormatHeightCount; + } + + /** + * Returns the mean initial video format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if + * no video format data is available. + */ + public int getMeanInitialVideoFormatBitrate() { + return initialVideoFormatBitrateCount == 0 + ? C.LENGTH_UNSET + : (int) (totalInitialVideoFormatBitrate / initialVideoFormatBitrateCount); + } + + /** + * Returns the mean initial audio format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if + * no audio format data is available. + */ + public int getMeanInitialAudioFormatBitrate() { + return initialAudioFormatBitrateCount == 0 + ? C.LENGTH_UNSET + : (int) (totalInitialAudioFormatBitrate / initialAudioFormatBitrateCount); + } + + /** + * Returns the mean video format height, in pixels, or {@link C#LENGTH_UNSET} if no video format + * data is available. This is a weighted average taking the time the format was used for playback + * into account. + */ + public int getMeanVideoFormatHeight() { + return totalVideoFormatHeightTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalVideoFormatHeightTimeProduct / totalVideoFormatHeightTimeMs); + } + + /** + * Returns the mean video format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if no + * video format data is available. This is a weighted average taking the time the format was used + * for playback into account. + */ + public int getMeanVideoFormatBitrate() { + return totalVideoFormatBitrateTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalVideoFormatBitrateTimeProduct / totalVideoFormatBitrateTimeMs); + } + + /** + * Returns the mean audio format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if no + * audio format data is available. This is a weighted average taking the time the format was used + * for playback into account. + */ + public int getMeanAudioFormatBitrate() { + return totalAudioFormatTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalAudioFormatBitrateTimeProduct / totalAudioFormatTimeMs); + } + + /** + * Returns the mean network bandwidth based on transfer measurements, in bits per second, or + * {@link C#LENGTH_UNSET} if no transfer data is available. + */ + public int getMeanBandwidth() { + return totalBandwidthTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalBandwidthBytes * 8000 / totalBandwidthTimeMs); + } + + /** + * Returns the mean rate at which video frames are dropped, in dropped frames per play time + * second, or {@code 0.0} if no time was spent playing. + */ + public float getDroppedFramesRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * totalDroppedFrames / playTimeMs; + } + + /** + * Returns the mean rate at which audio underruns occurred, in underruns per play time second, or + * {@code 0.0} if no time was spent playing. + */ + public float getAudioUnderrunRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * totalAudioUnderruns / playTimeMs; + } + + /** + * Returns the ratio of foreground playbacks which experienced fatal errors, or {@code 0.0} if no + * playback has been in foreground. + */ + public float getFatalErrorRatio() { + return foregroundPlaybackCount == 0 + ? 0f + : (float) fatalErrorPlaybackCount / foregroundPlaybackCount; + } + + /** + * Returns the rate of fatal errors, in errors per play time second, or {@code 0.0} if no time was + * spend playing. This is equivalent to 1.0 / {@link #getMeanTimeBetweenFatalErrors()}. + */ + public float getFatalErrorRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * fatalErrorCount / playTimeMs; + } + + /** + * Returns the mean play time between fatal errors, in seconds. This is equivalent to 1.0 / {@link + * #getFatalErrorRate()}. Note that this may return {@link Float#POSITIVE_INFINITY}. + */ + public float getMeanTimeBetweenFatalErrors() { + return 1f / getFatalErrorRate(); + } + + /** + * Returns the mean number of non-fatal errors per foreground playback, or {@code 0.0} if no + * playback has been in foreground. + */ + public float getMeanNonFatalErrorCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) nonFatalErrorCount / foregroundPlaybackCount; + } + + /** + * Returns the rate of non-fatal errors, in errors per play time second, or {@code 0.0} if no time + * was spend playing. This is equivalent to 1.0 / {@link #getMeanTimeBetweenNonFatalErrors()}. + */ + public float getNonFatalErrorRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * nonFatalErrorCount / playTimeMs; + } + + /** + * Returns the mean play time between non-fatal errors, in seconds. This is equivalent to 1.0 / + * {@link #getNonFatalErrorRate()}. Note that this may return {@link Float#POSITIVE_INFINITY}. + */ + public float getMeanTimeBetweenNonFatalErrors() { + return 1f / getNonFatalErrorRate(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java new file mode 100644 index 0000000000..058a3a97c1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -0,0 +1,1059 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Period; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.PlaybackStats.PlaybackState; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.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 each 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 (!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}); + } + } + } + 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; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/package-info.java new file mode 100644 index 0000000000..08556b00b0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; |