public final class

DefaultPlaybackSessionManager

extends java.lang.Object

implements PlaybackSessionManager

 java.lang.Object

↳androidx.media3.exoplayer.analytics.DefaultPlaybackSessionManager

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-exoplayer', version: '1.0.0-alpha03'

  • groupId: androidx.media3
  • artifactId: media3-exoplayer
  • version: 1.0.0-alpha03

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

Overview

Default PlaybackSessionManager which instantiates a new session for each window in the timeline and also for each ad within the windows.

By default, sessions are identified by Base64-encoded, URL-safe, random strings.

Summary

Fields
public static final <any>DEFAULT_SESSION_ID_GENERATOR

Default generator for unique session ids that are random, Based64-encoded and URL-safe.

Constructors
publicDefaultPlaybackSessionManager()

Creates session manager with a DefaultPlaybackSessionManager.DEFAULT_SESSION_ID_GENERATOR to generate session ids.

publicDefaultPlaybackSessionManager(<any> sessionIdGenerator)

Creates session manager.

Methods
public synchronized booleanbelongsToSession(AnalyticsListener.EventTime eventTime, java.lang.String sessionId)

public synchronized voidfinishAllSessions(AnalyticsListener.EventTime eventTime)

public synchronized java.lang.StringgetActiveSessionId()

public synchronized java.lang.StringgetSessionForMediaPeriodId(Timeline timeline, MediaSource.MediaPeriodId mediaPeriodId)

public voidsetListener(PlaybackSessionManager.Listener listener)

public synchronized voidupdateSessions(AnalyticsListener.EventTime eventTime)

public synchronized voidupdateSessionsWithDiscontinuity(AnalyticsListener.EventTime eventTime, int reason)

public synchronized voidupdateSessionsWithTimelineChange(AnalyticsListener.EventTime eventTime)

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

Fields

public static final <any> DEFAULT_SESSION_ID_GENERATOR

Default generator for unique session ids that are random, Based64-encoded and URL-safe.

Constructors

public DefaultPlaybackSessionManager()

Creates session manager with a DefaultPlaybackSessionManager.DEFAULT_SESSION_ID_GENERATOR to generate session ids.

public DefaultPlaybackSessionManager(<any> sessionIdGenerator)

Creates session manager.

Parameters:

sessionIdGenerator: A generator for new session ids. All generated session ids must be unique.

Methods

public void setListener(PlaybackSessionManager.Listener listener)

public synchronized java.lang.String getSessionForMediaPeriodId(Timeline timeline, MediaSource.MediaPeriodId mediaPeriodId)

public synchronized boolean belongsToSession(AnalyticsListener.EventTime eventTime, java.lang.String sessionId)

public synchronized void updateSessions(AnalyticsListener.EventTime eventTime)

public synchronized void updateSessionsWithTimelineChange(AnalyticsListener.EventTime eventTime)

public synchronized void updateSessionsWithDiscontinuity(AnalyticsListener.EventTime eventTime, int reason)

public synchronized java.lang.String getActiveSessionId()

public synchronized void finishAllSessions(AnalyticsListener.EventTime eventTime)

Source

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

import static java.lang.Math.max;

import android.util.Base64;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Player;
import androidx.media3.common.Player.DiscontinuityReason;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import com.google.common.base.Supplier;
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>By default, sessions are identified by Base64-encoded, URL-safe, random strings.
 */
@UnstableApi
public final class DefaultPlaybackSessionManager implements PlaybackSessionManager {

  /** Default generator for unique session ids that are random, Based64-encoded and URL-safe. */
  public static final Supplier<String> DEFAULT_SESSION_ID_GENERATOR =
      DefaultPlaybackSessionManager::generateDefaultSessionId;

  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 final Supplier<String> sessionIdGenerator;

  private @MonotonicNonNull Listener listener;
  private Timeline currentTimeline;
  @Nullable private String currentSessionId;

  /**
   * Creates session manager with a {@link #DEFAULT_SESSION_ID_GENERATOR} to generate session ids.
   */
  public DefaultPlaybackSessionManager() {
    this(DEFAULT_SESSION_ID_GENERATOR);
  }

  /**
   * Creates session manager.
   *
   * @param sessionIdGenerator A generator for new session ids. All generated session ids must be
   *     unique.
   */
  public DefaultPlaybackSessionManager(Supplier<String> sessionIdGenerator) {
    this.sessionIdGenerator = sessionIdGenerator;
    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) {
    Assertions.checkNotNull(listener);
    if (eventTime.timeline.isEmpty()) {
      // Don't try to create new sessions for empty timelines.
      return;
    }
    @Nullable SessionDescriptor currentSession = sessions.get(currentSessionId);
    if (eventTime.mediaPeriodId != null && currentSession != null) {
      // If we receive an event associated with a media period, then it needs to be either part of
      // the current window if it's the first created media period, or a window that will be played
      // in the future. Otherwise, we know that it belongs to a session that was already finished
      // and we can ignore the event.
      boolean isAlreadyFinished =
          currentSession.windowSequenceNumber == C.INDEX_UNSET
              ? currentSession.windowIndex != eventTime.windowIndex
              : eventTime.mediaPeriodId.windowSequenceNumber < currentSession.windowSequenceNumber;
      if (isAlreadyFinished) {
        return;
      }
    }
    SessionDescriptor eventSession =
        getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId);
    if (currentSessionId == null) {
      currentSessionId = eventSession.sessionId;
    }
    if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
      // Ensure that the content session for an ad session is created first.
      MediaPeriodId contentMediaPeriodId =
          new MediaPeriodId(
              eventTime.mediaPeriodId.periodUid,
              eventTime.mediaPeriodId.windowSequenceNumber,
              eventTime.mediaPeriodId.adGroupIndex);
      SessionDescriptor contentSession =
          getOrAddSession(eventTime.windowIndex, contentMediaPeriodId);
      if (!contentSession.isCreated) {
        contentSession.isCreated = true;
        eventTime.timeline.getPeriodByUid(eventTime.mediaPeriodId.periodUid, period);
        long adGroupPositionMs =
            Util.usToMs(period.getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex))
                + period.getPositionInWindowMs();
        // getAdGroupTimeUs may return 0 for prerolls despite period offset.
        adGroupPositionMs = max(0, adGroupPositionMs);
        EventTime eventTimeForContent =
            new EventTime(
                eventTime.realtimeMs,
                eventTime.timeline,
                eventTime.windowIndex,
                contentMediaPeriodId,
                /* eventPlaybackPositionMs= */ adGroupPositionMs,
                eventTime.currentTimeline,
                eventTime.currentWindowIndex,
                eventTime.currentMediaPeriodId,
                eventTime.currentPlaybackPositionMs,
                eventTime.totalBufferedDurationMs);
        listener.onSessionCreated(eventTimeForContent, contentSession.sessionId);
      }
    }
    if (!eventSession.isCreated) {
      eventSession.isCreated = true;
      listener.onSessionCreated(eventTime, eventSession.sessionId);
    }
    if (eventSession.sessionId.equals(currentSessionId) && !eventSession.isActive) {
      eventSession.isActive = true;
      listener.onSessionActive(eventTime, eventSession.sessionId);
    }
  }

  @Override
  public synchronized void updateSessionsWithTimelineChange(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)
          || session.isFinishedAtEventTime(eventTime)) {
        iterator.remove();
        if (session.isCreated) {
          if (session.sessionId.equals(currentSessionId)) {
            currentSessionId = null;
          }
          listener.onSessionFinished(
              eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false);
        }
      }
    }
    updateCurrentSession(eventTime);
  }

  @Override
  public synchronized void updateSessionsWithDiscontinuity(
      EventTime eventTime, @DiscontinuityReason int reason) {
    Assertions.checkNotNull(listener);
    boolean hasAutomaticTransition = reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION;
    Iterator<SessionDescriptor> iterator = sessions.values().iterator();
    while (iterator.hasNext()) {
      SessionDescriptor session = iterator.next();
      if (session.isFinishedAtEventTime(eventTime)) {
        iterator.remove();
        if (session.isCreated) {
          boolean isRemovingCurrentSession = session.sessionId.equals(currentSessionId);
          boolean isAutomaticTransition =
              hasAutomaticTransition && isRemovingCurrentSession && session.isActive;
          if (isRemovingCurrentSession) {
            currentSessionId = null;
          }
          listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition);
        }
      }
    }
    updateCurrentSession(eventTime);
  }

  @Override
  @Nullable
  public synchronized String getActiveSessionId() {
    return currentSessionId;
  }

  @Override
  public synchronized void finishAllSessions(EventTime eventTime) {
    currentSessionId = null;
    Iterator<SessionDescriptor> iterator = sessions.values().iterator();
    while (iterator.hasNext()) {
      SessionDescriptor session = iterator.next();
      iterator.remove();
      if (session.isCreated && listener != null) {
        listener.onSessionFinished(
            eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false);
      }
    }
  }

  @RequiresNonNull("listener")
  private void updateCurrentSession(EventTime eventTime) {
    if (eventTime.timeline.isEmpty()) {
      // Clear current session if the Timeline is empty.
      currentSessionId = null;
      return;
    }
    @Nullable SessionDescriptor previousSessionDescriptor = sessions.get(currentSessionId);
    SessionDescriptor currentSessionDescriptor =
        getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId);
    currentSessionId = currentSessionDescriptor.sessionId;
    updateSessions(eventTime);
    if (eventTime.mediaPeriodId != null
        && eventTime.mediaPeriodId.isAd()
        && (previousSessionDescriptor == null
            || previousSessionDescriptor.windowSequenceNumber
                != eventTime.mediaPeriodId.windowSequenceNumber
            || previousSessionDescriptor.adMediaPeriodId == null
            || previousSessionDescriptor.adMediaPeriodId.adGroupIndex
                != eventTime.mediaPeriodId.adGroupIndex
            || previousSessionDescriptor.adMediaPeriodId.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);
      listener.onAdPlaybackStarted(
          eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId);
    }
  }

  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 = sessionIdGenerator.get();
      bestMatch = new SessionDescriptor(sessionId, windowIndex, mediaPeriodId);
      sessions.put(sessionId, bestMatch);
    }
    return bestMatch;
  }

  private static String generateDefaultSessionId() {
    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) {
        // 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;
    }
  }
}