diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java new file mode 100644 index 0000000000..0ebad570b5 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java @@ -0,0 +1,847 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.analytics; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkStateNotNull; +import static androidx.media3.common.util.Util.castNonNull; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.media.DeniedByServerException; +import android.media.MediaCodec; +import android.media.MediaDrm; +import android.media.MediaDrmResetException; +import android.media.NotProvisionedException; +import android.media.metrics.LogSessionId; +import android.media.metrics.MediaMetricsManager; +import android.media.metrics.NetworkEvent; +import android.media.metrics.PlaybackErrorEvent; +import android.media.metrics.PlaybackMetrics; +import android.media.metrics.PlaybackSession; +import android.media.metrics.PlaybackStateEvent; +import android.media.metrics.TrackChangeEvent; +import android.os.SystemClock; +import android.system.ErrnoException; +import android.system.OsConstants; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.media3.common.C; +import androidx.media3.common.DrmInitData; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaLibraryInfo; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TracksInfo; +import androidx.media3.common.TracksInfo.TrackGroupInfo; +import androidx.media3.common.VideoSize; +import androidx.media3.common.util.NetworkTypeObserver; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.FileDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.UdpDataSource; +import androidx.media3.exoplayer.DecoderCounters; +import androidx.media3.exoplayer.ExoPlaybackException; +import androidx.media3.exoplayer.audio.AudioSink; +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager; +import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.UnsupportedDrmException; +import androidx.media3.exoplayer.mediacodec.MediaCodecDecoderException; +import androidx.media3.exoplayer.mediacodec.MediaCodecRenderer; +import androidx.media3.exoplayer.source.LoadEventInfo; +import androidx.media3.exoplayer.source.MediaLoadData; +import androidx.media3.exoplayer.source.MediaSource; +import com.google.common.collect.ImmutableList; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.UUID; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * An {@link AnalyticsListener} that interacts with the Android {@link MediaMetricsManager}. + * + *
It listens to playback events and forwards them to a {@link PlaybackSession}. The {@link
+ * LogSessionId} of the playback session can be obtained with {@link #getLogSessionId()}.
+ */
+@UnstableApi
+@RequiresApi(31)
+public final class MediaMetricsListener
+ implements AnalyticsListener, PlaybackSessionManager.Listener {
+
+ private final Context context;
+ private final PlaybackSessionManager sessionManager;
+ private final PlaybackSession playbackSession;
+ private final long startTimeMs;
+ private final Timeline.Window window;
+ private final Timeline.Period period;
+
+ @Nullable private PlaybackMetrics.Builder metricsBuilder;
+ @Player.DiscontinuityReason private int discontinuityReason;
+ private int currentPlaybackState;
+ private int currentNetworkType;
+ @Nullable private PlaybackException pendingPlayerError;
+ @Nullable private PendingFormatUpdate pendingVideoFormat;
+ @Nullable private PendingFormatUpdate pendingAudioFormat;
+ @Nullable private PendingFormatUpdate pendingTextFormat;
+ @Nullable private Format currentVideoFormat;
+ @Nullable private Format currentAudioFormat;
+ @Nullable private Format currentTextFormat;
+ private boolean isSeeking;
+ private int ioErrorType;
+ private boolean hasFatalError;
+ private int droppedFrames;
+ private int playedFrames;
+ private long bandwidthTimeMs;
+ private long bandwidthBytes;
+ private int audioUnderruns;
+
+ /**
+ * Creates the listener.
+ *
+ * @param context A {@link Context}.
+ */
+ public MediaMetricsListener(Context context) {
+ context = context.getApplicationContext();
+ this.context = context;
+ window = new Timeline.Window();
+ period = new Timeline.Period();
+ MediaMetricsManager mediaMetricsManager =
+ checkStateNotNull(
+ (MediaMetricsManager) context.getSystemService(Context.MEDIA_METRICS_SERVICE));
+ playbackSession = mediaMetricsManager.createPlaybackSession();
+ startTimeMs = SystemClock.elapsedRealtime();
+ currentPlaybackState = PlaybackStateEvent.STATE_NOT_STARTED;
+ currentNetworkType = NetworkEvent.NETWORK_TYPE_UNKNOWN;
+ sessionManager = new DefaultPlaybackSessionManager();
+ sessionManager.setListener(this);
+ }
+
+ /** Returns the {@link LogSessionId} used by this listener. */
+ public LogSessionId getLogSessionId() {
+ return playbackSession.getSessionId();
+ }
+
+ // PlaybackSessionManager.Listener implementation.
+
+ @Override
+ public void onSessionCreated(EventTime eventTime, String sessionId) {}
+
+ @Override
+ public void onSessionActive(EventTime eventTime, String sessionId) {
+ if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
+ // Ignore ad sessions.
+ return;
+ }
+ finishCurrentSession();
+ metricsBuilder =
+ new PlaybackMetrics.Builder()
+ .setPlayerName(MediaLibraryInfo.TAG)
+ .setPlayerVersion(MediaLibraryInfo.VERSION);
+ maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId);
+ }
+
+ @Override
+ public void onAdPlaybackStarted(
+ EventTime eventTime, String contentSessionId, String adSessionId) {}
+
+ @Override
+ public void onSessionFinished(
+ EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback) {
+ if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
+ // Ignore ad sessions.
+ return;
+ }
+ finishCurrentSession();
+ }
+
+ // AnalyticsListener implementation.
+
+ @Override
+ public void onPositionDiscontinuity(
+ EventTime eventTime,
+ Player.PositionInfo oldPosition,
+ Player.PositionInfo newPosition,
+ @Player.DiscontinuityReason int reason) {
+ if (reason == Player.DISCONTINUITY_REASON_SEEK) {
+ isSeeking = true;
+ }
+ discontinuityReason = reason;
+ }
+
+ @Override
+ public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) {
+ // TODO(b/181122234): DecoderCounters are not re-reported at period boundaries.
+ droppedFrames += decoderCounters.droppedBufferCount;
+ playedFrames += decoderCounters.renderedOutputBufferCount;
+ }
+
+ @Override
+ public void onBandwidthEstimate(
+ EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
+ bandwidthTimeMs += totalLoadTimeMs;
+ bandwidthBytes += totalBytesLoaded;
+ }
+
+ @Override
+ public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
+ PendingFormatUpdate update =
+ new PendingFormatUpdate(
+ checkNotNull(mediaLoadData.trackFormat),
+ mediaLoadData.trackSelectionReason,
+ sessionManager.getSessionForMediaPeriodId(
+ eventTime.timeline, checkNotNull(eventTime.mediaPeriodId)));
+ switch (mediaLoadData.trackType) {
+ case C.TRACK_TYPE_VIDEO:
+ case C.TRACK_TYPE_DEFAULT:
+ pendingVideoFormat = update;
+ break;
+ case C.TRACK_TYPE_AUDIO:
+ pendingAudioFormat = update;
+ break;
+ case C.TRACK_TYPE_TEXT:
+ pendingTextFormat = update;
+ break;
+ default:
+ // Other track type. Ignore.
+ }
+ }
+
+ @Override
+ public void onVideoSizeChanged(EventTime eventTime, VideoSize videoSize) {
+ @Nullable PendingFormatUpdate pendingVideoFormat = this.pendingVideoFormat;
+ if (pendingVideoFormat != null && pendingVideoFormat.format.height == Format.NO_VALUE) {
+ Format formatWithHeightAndWidth =
+ pendingVideoFormat
+ .format
+ .buildUpon()
+ .setWidth(videoSize.width)
+ .setHeight(videoSize.height)
+ .build();
+ this.pendingVideoFormat =
+ new PendingFormatUpdate(
+ formatWithHeightAndWidth,
+ pendingVideoFormat.selectionReason,
+ pendingVideoFormat.sessionId);
+ }
+ }
+
+ @Override
+ public void onLoadError(
+ EventTime eventTime,
+ LoadEventInfo loadEventInfo,
+ MediaLoadData mediaLoadData,
+ IOException error,
+ boolean wasCanceled) {
+ ioErrorType = mediaLoadData.dataType;
+ }
+
+ @Override
+ public void onPlayerError(EventTime eventTime, PlaybackException error) {
+ pendingPlayerError = error;
+ }
+
+ @Override
+ public void onEvents(Player player, Events events) {
+ if (events.size() == 0) {
+ return;
+ }
+ maybeAddSessions(events);
+
+ long realtimeMs = SystemClock.elapsedRealtime();
+ maybeUpdateMetricsBuilderValues(player, events);
+ maybeReportPlaybackError(realtimeMs);
+ maybeReportTrackChanges(player, events, realtimeMs);
+ maybeReportNetworkChange(realtimeMs);
+ maybeReportPlaybackStateChange(player, events, realtimeMs);
+
+ if (events.contains(AnalyticsListener.EVENT_PLAYER_RELEASED)) {
+ sessionManager.finishAllSessions(events.getEventTime(EVENT_PLAYER_RELEASED));
+ }
+ }
+
+ private void maybeAddSessions(Events events) {
+ for (int i = 0; i < events.size(); i++) {
+ @EventFlags int event = events.get(i);
+ EventTime eventTime = events.getEventTime(event);
+ if (event == EVENT_TIMELINE_CHANGED) {
+ sessionManager.updateSessionsWithTimelineChange(eventTime);
+ } else if (event == EVENT_POSITION_DISCONTINUITY) {
+ sessionManager.updateSessionsWithDiscontinuity(eventTime, discontinuityReason);
+ } else {
+ sessionManager.updateSessions(eventTime);
+ }
+ }
+ }
+
+ private void maybeUpdateMetricsBuilderValues(Player player, Events events) {
+ if (events.contains(EVENT_TIMELINE_CHANGED)) {
+ EventTime eventTime = events.getEventTime(EVENT_TIMELINE_CHANGED);
+ if (metricsBuilder != null) {
+ maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId);
+ }
+ }
+ if (events.contains(EVENT_TRACKS_CHANGED) && metricsBuilder != null) {
+ @Nullable
+ DrmInitData drmInitData = getDrmInitData(player.getCurrentTracksInfo().getTrackGroupInfos());
+ if (drmInitData != null) {
+ castNonNull(metricsBuilder).setDrmType(getDrmType(drmInitData));
+ }
+ }
+ if (events.contains(EVENT_AUDIO_UNDERRUN)) {
+ audioUnderruns++;
+ }
+ }
+
+ private void maybeReportPlaybackError(long realtimeMs) {
+ @Nullable PlaybackException error = pendingPlayerError;
+ if (error == null) {
+ return;
+ }
+ ErrorInfo errorInfo =
+ getErrorInfo(
+ error, context, /* lastIoErrorForManifest= */ ioErrorType == C.DATA_TYPE_MANIFEST);
+ playbackSession.reportPlaybackErrorEvent(
+ new PlaybackErrorEvent.Builder()
+ .setTimeSinceCreatedMillis(realtimeMs - startTimeMs)
+ .setErrorCode(errorInfo.errorCode)
+ .setSubErrorCode(errorInfo.subErrorCode)
+ .setException(error)
+ .build());
+ pendingPlayerError = null;
+ }
+
+ private void maybeReportTrackChanges(Player player, Events events, long realtimeMs) {
+ if (events.contains(EVENT_TRACKS_CHANGED)) {
+ TracksInfo tracksInfo = player.getCurrentTracksInfo();
+ boolean isVideoSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_VIDEO);
+ boolean isAudioSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_AUDIO);
+ boolean isTextSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_TEXT);
+ if (isVideoSelected || isAudioSelected || isTextSelected) {
+ // Ignore updates with insufficient information where no tracks are selected.
+ if (!isVideoSelected) {
+ maybeUpdateVideoFormat(realtimeMs, /* videoFormat= */ null, C.SELECTION_REASON_UNKNOWN);
+ }
+ if (!isAudioSelected) {
+ maybeUpdateAudioFormat(realtimeMs, /* audioFormat= */ null, C.SELECTION_REASON_UNKNOWN);
+ }
+ if (!isTextSelected) {
+ maybeUpdateTextFormat(realtimeMs, /* textFormat= */ null, C.SELECTION_REASON_UNKNOWN);
+ }
+ }
+ }
+ if (canReportPendingFormatUpdate(pendingVideoFormat)
+ && pendingVideoFormat.format.height != Format.NO_VALUE) {
+ maybeUpdateVideoFormat(
+ realtimeMs, pendingVideoFormat.format, pendingVideoFormat.selectionReason);
+ pendingVideoFormat = null;
+ }
+ if (canReportPendingFormatUpdate(pendingAudioFormat)) {
+ maybeUpdateAudioFormat(
+ realtimeMs, pendingAudioFormat.format, pendingAudioFormat.selectionReason);
+ pendingAudioFormat = null;
+ }
+ if (canReportPendingFormatUpdate(pendingTextFormat)) {
+ maybeUpdateTextFormat(
+ realtimeMs, pendingTextFormat.format, pendingTextFormat.selectionReason);
+ pendingTextFormat = null;
+ }
+ }
+
+ @EnsuresNonNullIf(result = true, expression = "#1")
+ private boolean canReportPendingFormatUpdate(@Nullable PendingFormatUpdate pendingFormatUpdate) {
+ return pendingFormatUpdate != null
+ && pendingFormatUpdate.sessionId.equals(sessionManager.getActiveSessionId());
+ }
+
+ private void maybeReportNetworkChange(long realtimeMs) {
+ int networkType = getNetworkType(context);
+ if (networkType != currentNetworkType) {
+ currentNetworkType = networkType;
+ playbackSession.reportNetworkEvent(
+ new NetworkEvent.Builder()
+ .setNetworkType(networkType)
+ .setTimeSinceCreatedMillis(realtimeMs - startTimeMs)
+ .build());
+ }
+ }
+
+ private void maybeReportPlaybackStateChange(Player player, Events events, long realtimeMs) {
+ if (player.getPlaybackState() != Player.STATE_BUFFERING) {
+ isSeeking = false;
+ }
+ if (player.getPlayerError() == null) {
+ hasFatalError = false;
+ } else if (events.contains(EVENT_PLAYER_ERROR)) {
+ hasFatalError = true;
+ }
+ int newPlaybackState = resolveNewPlaybackState(player);
+ if (currentPlaybackState != newPlaybackState) {
+ currentPlaybackState = newPlaybackState;
+ playbackSession.reportPlaybackStateEvent(
+ new PlaybackStateEvent.Builder()
+ .setState(currentPlaybackState)
+ .setTimeSinceCreatedMillis(realtimeMs - startTimeMs)
+ .build());
+ }
+ }
+
+ private int resolveNewPlaybackState(Player player) {
+ @Player.State int playerPlaybackState = player.getPlaybackState();
+ if (isSeeking) {
+ // Seeking takes precedence over errors such that we report a seek while in error state.
+ return PlaybackStateEvent.STATE_SEEKING;
+ } else if (hasFatalError) {
+ return PlaybackStateEvent.STATE_FAILED;
+ } else if (playerPlaybackState == Player.STATE_ENDED) {
+ return PlaybackStateEvent.STATE_ENDED;
+ } else if (playerPlaybackState == Player.STATE_BUFFERING) {
+ if (currentPlaybackState == PlaybackStateEvent.STATE_NOT_STARTED
+ || currentPlaybackState == PlaybackStateEvent.STATE_JOINING_FOREGROUND) {
+ return PlaybackStateEvent.STATE_JOINING_FOREGROUND;
+ }
+ if (!player.getPlayWhenReady()) {
+ return PlaybackStateEvent.STATE_PAUSED_BUFFERING;
+ }
+ return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
+ ? PlaybackStateEvent.STATE_SUPPRESSED_BUFFERING
+ : PlaybackStateEvent.STATE_BUFFERING;
+ } else if (playerPlaybackState == Player.STATE_READY) {
+ if (!player.getPlayWhenReady()) {
+ return PlaybackStateEvent.STATE_PAUSED;
+ }
+ return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
+ ? PlaybackStateEvent.STATE_SUPPRESSED
+ : PlaybackStateEvent.STATE_PLAYING;
+ } else if (playerPlaybackState == Player.STATE_IDLE
+ && currentPlaybackState != PlaybackStateEvent.STATE_NOT_STARTED) {
+ // This case only applies for calls to player.stop(). All other IDLE cases are handled by
+ // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored.
+ return PlaybackStateEvent.STATE_STOPPED;
+ }
+ return currentPlaybackState;
+ }
+
+ private void maybeUpdateVideoFormat(
+ long realtimeMs, @Nullable Format videoFormat, @C.SelectionReason int trackSelectionReason) {
+ if (Util.areEqual(currentVideoFormat, videoFormat)) {
+ return;
+ }
+ if (currentVideoFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) {
+ trackSelectionReason = C.SELECTION_REASON_INITIAL;
+ }
+ currentVideoFormat = videoFormat;
+ reportTrackChangeEvent(
+ TrackChangeEvent.TRACK_TYPE_VIDEO, realtimeMs, videoFormat, trackSelectionReason);
+ }
+
+ private void maybeUpdateAudioFormat(
+ long realtimeMs, @Nullable Format audioFormat, @C.SelectionReason int trackSelectionReason) {
+ if (Util.areEqual(currentAudioFormat, audioFormat)) {
+ return;
+ }
+ if (currentAudioFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) {
+ trackSelectionReason = C.SELECTION_REASON_INITIAL;
+ }
+ currentAudioFormat = audioFormat;
+ reportTrackChangeEvent(
+ TrackChangeEvent.TRACK_TYPE_AUDIO, realtimeMs, audioFormat, trackSelectionReason);
+ }
+
+ private void maybeUpdateTextFormat(
+ long realtimeMs, @Nullable Format textFormat, @C.SelectionReason int trackSelectionReason) {
+ if (Util.areEqual(currentTextFormat, textFormat)) {
+ return;
+ }
+ if (currentTextFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) {
+ trackSelectionReason = C.SELECTION_REASON_INITIAL;
+ }
+ currentTextFormat = textFormat;
+ reportTrackChangeEvent(
+ TrackChangeEvent.TRACK_TYPE_TEXT, realtimeMs, textFormat, trackSelectionReason);
+ }
+
+ private void reportTrackChangeEvent(
+ int type,
+ long realtimeMs,
+ @Nullable Format format,
+ @C.SelectionReason int trackSelectionReason) {
+ TrackChangeEvent.Builder builder =
+ new TrackChangeEvent.Builder(type).setTimeSinceCreatedMillis(realtimeMs - startTimeMs);
+ if (format != null) {
+ builder.setTrackState(TrackChangeEvent.TRACK_STATE_ON);
+ builder.setTrackChangeReason(getTrackChangeReason(trackSelectionReason));
+ if (format.containerMimeType != null) {
+ // TODO(b/181121074): Progressive container mime type is not filled in by MediaSource.
+ builder.setContainerMimeType(format.containerMimeType);
+ }
+ if (format.sampleMimeType != null) {
+ builder.setSampleMimeType(format.sampleMimeType);
+ }
+ if (format.codecs != null) {
+ builder.setCodecName(format.codecs);
+ }
+ if (format.bitrate != Format.NO_VALUE) {
+ builder.setBitrate(format.bitrate);
+ }
+ if (format.width != Format.NO_VALUE) {
+ builder.setWidth(format.width);
+ }
+ if (format.height != Format.NO_VALUE) {
+ builder.setHeight(format.height);
+ }
+ if (format.channelCount != Format.NO_VALUE) {
+ builder.setChannelCount(format.channelCount);
+ }
+ if (format.sampleRate != Format.NO_VALUE) {
+ builder.setAudioSampleRate(format.sampleRate);
+ }
+ if (format.language != null) {
+ Pair