QueueTimeline.java

/*
 * 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.session;

import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Util.msToUs;

import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat.QueueItem;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Util;
import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;

/**
 * An immutable class to represent the current {@link Timeline} backed by {@linkplain QueueItem
 * queue items}.
 *
 * <p>This timeline supports the case in which the current {@link MediaMetadataCompat} is not
 * included in the queue of the session. In such a case a fake media item is inserted at the end of
 * the timeline and the size of the timeline is by one larger than the size of the corresponding
 * queue in the session.
 */
/* package */ final class QueueTimeline extends Timeline {

  public static final QueueTimeline DEFAULT =
      new QueueTimeline(ImmutableList.of(), /* fakeQueuedMediaItem= */ null);

  private static final Object FAKE_WINDOW_UID = new Object();

  private final ImmutableList<QueuedMediaItem> queuedMediaItems;
  @Nullable private final QueuedMediaItem fakeQueuedMediaItem;

  private QueueTimeline(
      ImmutableList<QueuedMediaItem> queuedMediaItems,
      @Nullable QueuedMediaItem fakeQueuedMediaItem) {
    this.queuedMediaItems = queuedMediaItems;
    this.fakeQueuedMediaItem = fakeQueuedMediaItem;
  }

  /** Creates a {@link QueueTimeline} from a list of {@linkplain QueueItem queue items}. */
  public static QueueTimeline create(List<QueueItem> queue) {
    ImmutableList.Builder<QueuedMediaItem> queuedMediaItemsBuilder = new ImmutableList.Builder<>();
    for (int i = 0; i < queue.size(); i++) {
      QueueItem queueItem = queue.get(i);
      MediaItem mediaItem = MediaUtils.convertToMediaItem(queueItem);
      queuedMediaItemsBuilder.add(
          new QueuedMediaItem(mediaItem, queueItem.getQueueId(), /* durationMs= */ C.TIME_UNSET));
    }
    return new QueueTimeline(queuedMediaItemsBuilder.build(), /* fakeQueuedMediaItem= */ null);
  }

  /** Returns a copy of the current queue timeline. */
  public QueueTimeline copy() {
    return new QueueTimeline(queuedMediaItems, fakeQueuedMediaItem);
  }

  /**
   * Gets the queue ID of the media item at the given index or {@link QueueItem#UNKNOWN_ID} if not
   * known.
   *
   * @param mediaItemIndex The media item index.
   * @return The corresponding queue ID or {@link QueueItem#UNKNOWN_ID} if not known.
   */
  public long getQueueId(int mediaItemIndex) {
    return mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size()
        ? queuedMediaItems.get(mediaItemIndex).queueId
        : QueueItem.UNKNOWN_ID;
  }

  /**
   * Copies the timeline with the given fake media item.
   *
   * @param fakeMediaItem The fake media item.
   * @param durationMs The duration of the fake media item, in milliseconds, or {@link C#TIME_UNSET}
   *     if unknown.
   * @return A new {@link QueueTimeline} reflecting the update.
   */
  public QueueTimeline copyWithFakeMediaItem(MediaItem fakeMediaItem, long durationMs) {
    return new QueueTimeline(
        queuedMediaItems, new QueuedMediaItem(fakeMediaItem, QueueItem.UNKNOWN_ID, durationMs));
  }

  /** Copies the timeline while clearing any previously set fake media item. */
  public QueueTimeline copyWithClearedFakeMediaItem() {
    return new QueueTimeline(queuedMediaItems, /* fakeQueuedMediaItem= */ null);
  }

  /**
   * Replaces the media item at {@code replaceIndex} with the new media item.
   *
   * @param replaceIndex The index at which to replace the media item.
   * @param newMediaItem The new media item that replaces the old one.
   * @param durationMs The duration of the media item, in milliseconds, or {@link C#TIME_UNSET} if
   *     unknown.
   * @return A new {@link QueueTimeline} reflecting the update.
   */
  public QueueTimeline copyWithNewMediaItem(
      int replaceIndex, MediaItem newMediaItem, long durationMs) {
    checkArgument(
        replaceIndex < queuedMediaItems.size()
            || (replaceIndex == queuedMediaItems.size() && fakeQueuedMediaItem != null));
    if (replaceIndex == queuedMediaItems.size()) {
      return new QueueTimeline(
          queuedMediaItems, new QueuedMediaItem(newMediaItem, QueueItem.UNKNOWN_ID, durationMs));
    }
    long queueId = queuedMediaItems.get(replaceIndex).queueId;
    ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
    queuedItemsBuilder.addAll(queuedMediaItems.subList(0, replaceIndex));
    queuedItemsBuilder.add(new QueuedMediaItem(newMediaItem, queueId, durationMs));
    queuedItemsBuilder.addAll(queuedMediaItems.subList(replaceIndex + 1, queuedMediaItems.size()));
    return new QueueTimeline(queuedItemsBuilder.build(), fakeQueuedMediaItem);
  }

  /**
   * Replaces the media item at the given index with a list of new media items. The timeline grows
   * by one less than the size of the new list of items.
   *
   * @param index The index of the media item to be replaced.
   * @param newMediaItems The list of new {@linkplain MediaItem media items} to insert.
   * @return A new {@link QueueTimeline} reflecting the update.
   */
  public QueueTimeline copyWithNewMediaItems(int index, List<MediaItem> newMediaItems) {
    ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
    queuedItemsBuilder.addAll(queuedMediaItems.subList(0, index));
    for (int i = 0; i < newMediaItems.size(); i++) {
      queuedItemsBuilder.add(
          new QueuedMediaItem(
              newMediaItems.get(i), QueueItem.UNKNOWN_ID, /* durationMs= */ C.TIME_UNSET));
    }
    queuedItemsBuilder.addAll(queuedMediaItems.subList(index, queuedMediaItems.size()));
    return new QueueTimeline(queuedItemsBuilder.build(), fakeQueuedMediaItem);
  }

  /**
   * Removes the range of media items in the current timeline.
   *
   * @param fromIndex The index to start removing items from.
   * @param toIndex The index up to which to remove items (exclusive).
   * @return A new {@link QueueTimeline} reflecting the update.
   */
  public QueueTimeline copyWithRemovedMediaItems(int fromIndex, int toIndex) {
    ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
    queuedItemsBuilder.addAll(queuedMediaItems.subList(0, fromIndex));
    queuedItemsBuilder.addAll(queuedMediaItems.subList(toIndex, queuedMediaItems.size()));
    return new QueueTimeline(queuedItemsBuilder.build(), fakeQueuedMediaItem);
  }

  /**
   * Moves the defined range of media items to a new position.
   *
   * @param fromIndex The start index of the range to be moved.
   * @param toIndex The (exclusive) end index of the range to be moved.
   * @param newIndex The new index to move the first item of the range to.
   * @return A new {@link QueueTimeline} reflecting the update.
   */
  public QueueTimeline copyWithMovedMediaItems(int fromIndex, int toIndex, int newIndex) {
    List<QueuedMediaItem> list = new ArrayList<>(queuedMediaItems);
    Util.moveItems(list, fromIndex, toIndex, newIndex);
    return new QueueTimeline(ImmutableList.copyOf(list), fakeQueuedMediaItem);
  }

  /** Returns whether the timeline contains the given {@link MediaItem}. */
  public boolean contains(MediaItem mediaItem) {
    if (fakeQueuedMediaItem != null && mediaItem.equals(fakeQueuedMediaItem.mediaItem)) {
      return true;
    }
    for (int i = 0; i < queuedMediaItems.size(); i++) {
      if (mediaItem.equals(queuedMediaItems.get(i).mediaItem)) {
        return true;
      }
    }
    return false;
  }

  @Nullable
  public MediaItem getMediaItemAt(int mediaItemIndex) {
    return mediaItemIndex >= getWindowCount() ? null : getQueuedMediaItem(mediaItemIndex).mediaItem;
  }

  @Override
  public int getWindowCount() {
    return queuedMediaItems.size() + ((fakeQueuedMediaItem == null) ? 0 : 1);
  }

  @Override
  public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
    QueuedMediaItem queuedMediaItem = getQueuedMediaItem(windowIndex);
    window.set(
        FAKE_WINDOW_UID,
        queuedMediaItem.mediaItem,
        /* manifest= */ null,
        /* presentationStartTimeMs= */ C.TIME_UNSET,
        /* windowStartTimeMs= */ C.TIME_UNSET,
        /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
        /* isSeekable= */ true,
        /* isDynamic= */ false,
        /* liveConfiguration= */ null,
        /* defaultPositionUs= */ 0,
        /* durationUs= */ msToUs(queuedMediaItem.durationMs),
        /* firstPeriodIndex= */ windowIndex,
        /* lastPeriodIndex= */ windowIndex,
        /* positionInFirstPeriodUs= */ 0);
    return window;
  }

  @Override
  public int getPeriodCount() {
    return getWindowCount();
  }

  @Override
  public Period getPeriod(int periodIndex, Period period, boolean setIds) {
    QueuedMediaItem queuedMediaItem = getQueuedMediaItem(periodIndex);
    period.set(
        /* id= */ queuedMediaItem.queueId,
        /* uid= */ null,
        /* windowIndex= */ periodIndex,
        /* durationUs= */ msToUs(queuedMediaItem.durationMs),
        /* positionInWindowUs= */ 0);
    return period;
  }

  @Override
  public int getIndexOfPeriod(Object uid) {
    throw new UnsupportedOperationException();
  }

  @Override
  public Object getUidOfPeriod(int periodIndex) {
    throw new UnsupportedOperationException();
  }

  @Override
  public boolean equals(@Nullable Object obj) {
    if (this == obj) {
      return true;
    }
    if (!(obj instanceof QueueTimeline)) {
      return false;
    }
    QueueTimeline other = (QueueTimeline) obj;
    return Objects.equal(queuedMediaItems, other.queuedMediaItems)
        && Objects.equal(fakeQueuedMediaItem, other.fakeQueuedMediaItem);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(queuedMediaItems, fakeQueuedMediaItem);
  }

  private QueuedMediaItem getQueuedMediaItem(int index) {
    return index == queuedMediaItems.size() && fakeQueuedMediaItem != null
        ? fakeQueuedMediaItem
        : queuedMediaItems.get(index);
  }

  private static final class QueuedMediaItem {

    public final MediaItem mediaItem;
    public final long queueId;
    public final long durationMs;

    public QueuedMediaItem(MediaItem mediaItem, long queueId, long durationMs) {
      this.mediaItem = mediaItem;
      this.queueId = queueId;
      this.durationMs = durationMs;
    }

    @Override
    public boolean equals(@Nullable Object o) {
      if (this == o) {
        return true;
      }
      if (!(o instanceof QueuedMediaItem)) {
        return false;
      }
      QueuedMediaItem that = (QueuedMediaItem) o;
      return queueId == that.queueId
          && mediaItem.equals(that.mediaItem)
          && durationMs == that.durationMs;
    }

    @Override
    public int hashCode() {
      int result = 7;
      result = 31 * result + (int) (queueId ^ (queueId >>> 32));
      result = 31 * result + mediaItem.hashCode();
      result = 31 * result + (int) (durationMs ^ (durationMs >>> 32));
      return result;
    }
  }
}