diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/TimelineTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/TimelineTest.java index 8b0504253b..699c620da9 100644 --- a/library/core/src/androidTest/java/com/google/android/exoplayer2/TimelineTest.java +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/TimelineTest.java @@ -63,7 +63,7 @@ public class TimelineTest extends TestCase { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { - return period.set(new int[] { id, periodIndex }, null, 0, WINDOW_DURATION_US, 0); + return period.set(periodIndex, null, 0, WINDOW_DURATION_US, 0); } @Override @@ -148,12 +148,33 @@ public class TimelineTest extends TestCase { this.timeline = timeline; } - public TimelineVerifier assertWindowIds(int... expectedWindowIds) { + public TimelineVerifier assertEmpty() { + assertWindowIds(); + assertPeriodCounts(); + return this; + } + + /** + * @param expectedWindowIds A list of expected window IDs. If an ID is unknown or not important + * {@code null} can be passed to skip this window. + */ + public TimelineVerifier assertWindowIds(Object... expectedWindowIds) { Window window = new Window(); assertEquals(expectedWindowIds.length, timeline.getWindowCount()); for (int i = 0; i < timeline.getWindowCount(); i++) { timeline.getWindow(i, window, true); - assertEquals(expectedWindowIds[i], window.id); + if (expectedWindowIds[i] != null) { + assertEquals(expectedWindowIds[i], window.id); + } + } + return this; + } + + public TimelineVerifier assertWindowIsDynamic(boolean... windowIsDynamic) { + Window window = new Window(); + for (int i = 0; i < timeline.getWindowCount(); i++) { + timeline.getWindow(i, window, true); + assertEquals(windowIsDynamic[i], window.isDynamic); } return this; } @@ -199,7 +220,6 @@ public class TimelineTest extends TestCase { expectedWindowIndex++; } assertEquals(expectedWindowIndex, period.windowIndex); - assertEquals(i - accumulatedPeriodCounts[expectedWindowIndex], ((int[]) period.id)[1]); if (i < accumulatedPeriodCounts[expectedWindowIndex + 1] - 1) { assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, ExoPlayer.REPEAT_MODE_OFF)); @@ -233,9 +253,7 @@ public class TimelineTest extends TestCase { } public void testEmptyTimeline() { - new TimelineVerifier(Timeline.EMPTY) - .assertWindowIds() - .assertPeriodCounts(); + new TimelineVerifier(Timeline.EMPTY).assertEmpty(); } public void testSinglePeriodTimeline() { diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/source/DynamicConcatenatingMediaSourceTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/source/DynamicConcatenatingMediaSourceTest.java new file mode 100644 index 0000000000..0f63201964 --- /dev/null +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/source/DynamicConcatenatingMediaSourceTest.java @@ -0,0 +1,532 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.TimelineTest; +import com.google.android.exoplayer2.TimelineTest.FakeTimeline; +import com.google.android.exoplayer2.TimelineTest.StubMediaSource; +import com.google.android.exoplayer2.TimelineTest.TimelineVerifier; +import com.google.android.exoplayer2.source.MediaSource.Listener; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.Allocator; +import java.io.IOException; +import java.util.Arrays; +import junit.framework.TestCase; + +/** + * Unit tests for {@link DynamicConcatenatingMediaSource} + */ +public final class DynamicConcatenatingMediaSourceTest extends TestCase { + + private static final int TIMEOUT_MS = 10000; + + private Timeline timeline; + private boolean timelineUpdated; + + public void testPlaylistChangesAfterPreparation() throws InterruptedException { + timeline = null; + TimelineTest.StubMediaSource[] childSources = createMediaSources(7); + DynamicConcatenatingMediaSource mediaSource = new DynamicConcatenatingMediaSource(); + prepareAndListenToTimelineUpdates(mediaSource); + waitForTimelineUpdate(); + new TimelineVerifier(timeline).assertEmpty(); + + // Add first source. + mediaSource.addMediaSource(childSources[0]); + waitForTimelineUpdate(); + assertNotNull(timeline); + new TimelineVerifier(timeline) + .assertPeriodCounts(1) + .assertWindowIds(111); + + // Add at front of queue. + mediaSource.addMediaSource(0, childSources[1]); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(2, 1) + .assertWindowIds(222, 111); + + // Add at back of queue. + mediaSource.addMediaSource(childSources[2]); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(2, 1, 3) + .assertWindowIds(222, 111, 333); + + // Add in the middle. + mediaSource.addMediaSource(1, childSources[3]); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(2, 4, 1, 3) + .assertWindowIds(222, 444, 111, 333); + + // Add bulk. + mediaSource.addMediaSources(3, Arrays.asList((MediaSource) childSources[4], + (MediaSource) childSources[5], (MediaSource) childSources[6])); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(2, 4, 1, 5, 6, 7, 3) + .assertWindowIds(222, 444, 111, 555, 666, 777, 333); + + // Remove in the middle. + mediaSource.removeMediaSource(3); + waitForTimelineUpdate(); + mediaSource.removeMediaSource(3); + waitForTimelineUpdate(); + mediaSource.removeMediaSource(3); + waitForTimelineUpdate(); + mediaSource.removeMediaSource(1); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(2, 1, 3) + .assertWindowIds(222, 111, 333); + for (int i = 3; i <= 6; i++) { + childSources[i].assertReleased(); + } + + // Remove at front of queue. + mediaSource.removeMediaSource(0); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(1, 3) + .assertWindowIds(111, 333); + childSources[1].assertReleased(); + + // Remove at back of queue. + mediaSource.removeMediaSource(1); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(1) + .assertWindowIds(111); + childSources[2].assertReleased(); + + // Remove last source. + mediaSource.removeMediaSource(0); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts() + .assertWindowIds(); + childSources[3].assertReleased(); + } + + public void testPlaylistChangesBeforePreparation() throws InterruptedException { + timeline = null; + TimelineTest.StubMediaSource[] childSources = createMediaSources(4); + DynamicConcatenatingMediaSource mediaSource = new DynamicConcatenatingMediaSource(); + mediaSource.addMediaSource(childSources[0]); + mediaSource.addMediaSource(childSources[1]); + mediaSource.addMediaSource(0, childSources[2]); + mediaSource.removeMediaSource(1); + mediaSource.addMediaSource(1, childSources[3]); + assertNull(timeline); + + prepareAndListenToTimelineUpdates(mediaSource); + waitForTimelineUpdate(); + assertNotNull(timeline); + new TimelineVerifier(timeline) + .assertPeriodCounts(3, 4, 2) + .assertWindowIds(333, 444, 222); + + mediaSource.releaseSource(); + for (int i = 1; i < 4; i++) { + childSources[i].assertReleased(); + } + } + + public void testPlaylistWithLazyMediaSource() throws InterruptedException { + timeline = null; + TimelineTest.StubMediaSource[] childSources = createMediaSources(2); + LazyMediaSource[] lazySources = new LazyMediaSource[4]; + for (int i = 0; i < 4; i++) { + lazySources[i] = new LazyMediaSource(); + } + + //Add lazy sources before preparation + DynamicConcatenatingMediaSource mediaSource = new DynamicConcatenatingMediaSource(); + mediaSource.addMediaSource(lazySources[0]); + mediaSource.addMediaSource(0, childSources[0]); + mediaSource.removeMediaSource(1); + mediaSource.addMediaSource(1, lazySources[1]); + assertNull(timeline); + prepareAndListenToTimelineUpdates(mediaSource); + waitForTimelineUpdate(); + assertNotNull(timeline); + new TimelineVerifier(timeline) + .assertPeriodCounts(1, 1) + .assertWindowIds(111, null) + .assertWindowIsDynamic(false, true); + + lazySources[1].triggerTimelineUpdate(new FakeTimeline(9, 999)); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(1, 9) + .assertWindowIds(111, 999) + .assertWindowIsDynamic(false, false); + + //Add lazy sources after preparation + mediaSource.addMediaSource(1, lazySources[2]); + waitForTimelineUpdate(); + mediaSource.addMediaSource(2, childSources[1]); + waitForTimelineUpdate(); + mediaSource.addMediaSource(0, lazySources[3]); + waitForTimelineUpdate(); + mediaSource.removeMediaSource(2); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(1, 1, 2, 9) + .assertWindowIds(null, 111, 222, 999) + .assertWindowIsDynamic(true, false, false, false); + + lazySources[3].triggerTimelineUpdate(new FakeTimeline(8, 888)); + waitForTimelineUpdate(); + new TimelineVerifier(timeline) + .assertPeriodCounts(8, 1, 2, 9) + .assertWindowIds(888, 111, 222, 999) + .assertWindowIsDynamic(false, false, false, false); + + mediaSource.releaseSource(); + childSources[0].assertReleased(); + childSources[1].assertReleased(); + } + + public void testIllegalArguments() { + DynamicConcatenatingMediaSource mediaSource = new DynamicConcatenatingMediaSource(); + MediaSource validSource = new StubMediaSource(new FakeTimeline(1, 1)); + + // Null sources. + try { + mediaSource.addMediaSource(null); + fail("Null mediaSource not allowed."); + } catch (NullPointerException e) { + // Expected. + } + + MediaSource[] mediaSources = { validSource, null }; + try { + mediaSource.addMediaSources(Arrays.asList(mediaSources)); + fail("Null mediaSource not allowed."); + } catch (NullPointerException e) { + // Expected. + } + + // Duplicate sources. + mediaSource.addMediaSource(validSource); + try { + mediaSource.addMediaSource(validSource); + fail("Duplicate mediaSource not allowed."); + } catch (IllegalArgumentException e) { + // Expected. + } + + mediaSources = new MediaSource[] { new StubMediaSource(new FakeTimeline(1, 1)), validSource}; + try { + mediaSource.addMediaSources(Arrays.asList(mediaSources)); + fail("Duplicate mediaSource not allowed."); + } catch (IllegalArgumentException e) { + // Expected. + } + } + + private void prepareAndListenToTimelineUpdates(MediaSource mediaSource) { + mediaSource.prepareSource(new StubExoPlayer(), true, new Listener() { + @Override + public void onSourceInfoRefreshed(Timeline newTimeline, Object manifest) { + timeline = newTimeline; + synchronized (DynamicConcatenatingMediaSourceTest.this) { + timelineUpdated = true; + DynamicConcatenatingMediaSourceTest.this.notify(); + } + } + }); + } + + private synchronized void waitForTimelineUpdate() throws InterruptedException { + long timeoutMs = System.currentTimeMillis() + TIMEOUT_MS; + while (!timelineUpdated) { + wait(TIMEOUT_MS); + if (System.currentTimeMillis() >= timeoutMs) { + fail("No timeline update occurred within timeout."); + } + } + timelineUpdated = false; + } + + private TimelineTest.StubMediaSource[] createMediaSources(int count) { + TimelineTest.StubMediaSource[] sources = new TimelineTest.StubMediaSource[count]; + for (int i = 0; i < count; i++) { + sources[i] = new TimelineTest.StubMediaSource(new FakeTimeline(i + 1, (i + 1) * 111)); + } + return sources; + } + + private static class LazyMediaSource implements MediaSource { + + private Listener listener; + + public void triggerTimelineUpdate(Timeline timeline) { + listener.onSourceInfoRefreshed(timeline, null); + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + this.listener = listener; + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + return null; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + } + + @Override + public void releaseSource() { + } + + } + + /** + * Stub ExoPlayer which only accepts custom messages and runs them on a separate handler thread. + */ + private static class StubExoPlayer implements ExoPlayer, Handler.Callback { + + private final Handler handler; + + public StubExoPlayer() { + HandlerThread handlerThread = new HandlerThread("StubExoPlayerThread"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper(), this); + } + + @Override + public Looper getPlaybackLooper() { + throw new UnsupportedOperationException(); + } + + @Override + public void addListener(EventListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeListener(EventListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int getPlaybackState() { + throw new UnsupportedOperationException(); + } + + @Override + public void prepare(MediaSource mediaSource) { + throw new UnsupportedOperationException(); + } + + @Override + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + throw new UnsupportedOperationException(); + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getPlayWhenReady() { + throw new UnsupportedOperationException(); + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + throw new UnsupportedOperationException(); + } + + @Override + public int getRepeatMode() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isLoading() { + throw new UnsupportedOperationException(); + } + + @Override + public void seekToDefaultPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public void seekToDefaultPosition(int windowIndex) { + throw new UnsupportedOperationException(); + } + + @Override + public void seekTo(long positionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + throw new UnsupportedOperationException(); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + throw new UnsupportedOperationException(); + } + + @Override + public void stop() { + throw new UnsupportedOperationException(); + } + + @Override + public void release() { + throw new UnsupportedOperationException(); + } + + @Override + public void sendMessages(ExoPlayerMessage... messages) { + handler.obtainMessage(0, messages).sendToTarget(); + } + + @Override + public void blockingSendMessages(ExoPlayerMessage... messages) { + throw new UnsupportedOperationException(); + } + + @Override + public int getRendererCount() { + throw new UnsupportedOperationException(); + } + + @Override + public int getRendererType(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + throw new UnsupportedOperationException(); + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + throw new UnsupportedOperationException(); + } + + @Override + public Object getCurrentManifest() { + throw new UnsupportedOperationException(); + } + + @Override + public Timeline getCurrentTimeline() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentPeriodIndex() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentWindowIndex() { + throw new UnsupportedOperationException(); + } + + @Override + public long getDuration() { + throw new UnsupportedOperationException(); + } + + @Override + public long getCurrentPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public long getBufferedPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public int getBufferedPercentage() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isCurrentWindowDynamic() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isCurrentWindowSeekable() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isPlayingAd() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentAdGroupIndex() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentAdIndexInAdGroup() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean handleMessage(Message msg) { + ExoPlayerMessage[] messages = (ExoPlayerMessage[]) msg.obj; + for (ExoPlayerMessage message : messages) { + try { + message.target.handleMessage(message.messageType, message.message); + } catch (ExoPlaybackException e) { + fail("Unexpected ExoPlaybackException."); + } + } + return true; + } + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DynamicConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DynamicConcatenatingMediaSource.java new file mode 100644 index 0000000000..a9e478a67f --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DynamicConcatenatingMediaSource.java @@ -0,0 +1,587 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import android.util.Pair; +import android.util.SparseIntArray; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.ExoPlayer.ExoPlayerComponent; +import com.google.android.exoplayer2.ExoPlayer.ExoPlayerMessage; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified + * during playback. Access to this class is thread-safe. + */ +public final class DynamicConcatenatingMediaSource implements MediaSource, ExoPlayerComponent { + + private static final int MSG_ADD = 0; + private static final int MSG_ADD_MULTIPLE = 1; + private static final int MSG_REMOVE = 2; + + // Accessed on the app thread. + private final List mediaSourcesPublic; + + // Accessed on the playback thread. + private final List mediaSourceHolders; + private final MediaSourceHolder query; + private final Map mediaSourceByMediaPeriod; + private final List deferredMediaPeriods; + + private ExoPlayer player; + private Listener listener; + private boolean preventListenerNotification; + private int windowCount; + private int periodCount; + + public DynamicConcatenatingMediaSource() { + this.mediaSourceByMediaPeriod = new IdentityHashMap<>(); + this.mediaSourcesPublic = new ArrayList<>(); + this.mediaSourceHolders = new ArrayList<>(); + this.deferredMediaPeriods = new ArrayList<>(1); + this.query = new MediaSourceHolder(null, null, -1, -1, -1); + } + + /** + * Appends a {@link MediaSource} to the playlist. + * + * @param mediaSource The {@link MediaSource} to be added to the list. + */ + public synchronized void addMediaSource(MediaSource mediaSource) { + addMediaSource(mediaSourcesPublic.size(), mediaSource); + } + + /** + * Adds a {@link MediaSource} to the playlist. + * + * @param index The index at which the new {@link MediaSource} will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSource The {@link MediaSource} to be added to the list. + */ + public synchronized void addMediaSource(int index, MediaSource mediaSource) { + Assertions.checkNotNull(mediaSource); + Assertions.checkArgument(!mediaSourcesPublic.contains(mediaSource)); + mediaSourcesPublic.add(index, mediaSource); + if (player != null) { + player.sendMessages(new ExoPlayerMessage(this, MSG_ADD, Pair.create(index, mediaSource))); + } + } + + /** + * Appends multiple {@link MediaSource}s to the playlist. + * + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + */ + public synchronized void addMediaSources(Collection mediaSources) { + addMediaSources(mediaSourcesPublic.size(), mediaSources); + } + + /** + * Adds multiple {@link MediaSource}s to the playlist. + * + * @param index The index at which the new {@link MediaSource}s will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + */ + public synchronized void addMediaSources(int index, Collection mediaSources) { + for (MediaSource mediaSource : mediaSources) { + Assertions.checkNotNull(mediaSource); + Assertions.checkArgument(!mediaSourcesPublic.contains(mediaSource)); + } + mediaSourcesPublic.addAll(index, mediaSources); + if (player != null && !mediaSources.isEmpty()) { + player.sendMessages(new ExoPlayerMessage(this, MSG_ADD_MULTIPLE, + Pair.create(index, mediaSources))); + } + } + + /** + * Removes a {@link MediaSource} from the playlist. + * + * @param index The index at which the media source will be removed. + */ + public synchronized void removeMediaSource(int index) { + mediaSourcesPublic.remove(index); + if (player != null) { + player.sendMessages(new ExoPlayerMessage(this, MSG_REMOVE, index)); + } + } + + /** + * Returns the number of media sources in the playlist. + */ + public synchronized int getSize() { + return mediaSourcesPublic.size(); + } + + /** + * Returns the {@link MediaSource} at a specified index. + * + * @param index A index in the range of 0 <= index <= {@link #getSize()}. + * @return The {@link MediaSource} at this index. + */ + public synchronized MediaSource getMediaSource(int index) { + return mediaSourcesPublic.get(index); + } + + @Override + public synchronized void prepareSource(ExoPlayer player, boolean isTopLevelSource, + Listener listener) { + this.player = player; + this.listener = listener; + preventListenerNotification = true; + addMediaSourcesInternal(0, mediaSourcesPublic); + preventListenerNotification = false; + maybeNotifyListener(); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + mediaSourceHolder.mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + int mediaSourceHolderIndex = findMediaSourceHolderByPeriodIndex(id.periodIndex); + MediaSourceHolder holder = mediaSourceHolders.get(mediaSourceHolderIndex); + MediaPeriodId idInSource = new MediaPeriodId(id.periodIndex - holder.firstPeriodIndexInChild); + MediaPeriod mediaPeriod; + if (!holder.isPrepared) { + mediaPeriod = new DeferredMediaPeriod(holder.mediaSource, idInSource, allocator); + deferredMediaPeriods.add((DeferredMediaPeriod) mediaPeriod); + } else { + mediaPeriod = holder.mediaSource.createPeriod(idInSource, allocator); + } + mediaSourceByMediaPeriod.put(mediaPeriod, holder.mediaSource); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MediaSource mediaSource = mediaSourceByMediaPeriod.get(mediaPeriod); + mediaSourceByMediaPeriod.remove(mediaPeriod); + if (mediaPeriod instanceof DeferredMediaPeriod) { + deferredMediaPeriods.remove(mediaPeriod); + ((DeferredMediaPeriod) mediaPeriod).releasePeriod(); + } else { + mediaSource.releasePeriod(mediaPeriod); + } + } + + @Override + public void releaseSource() { + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + mediaSourceHolder.mediaSource.releaseSource(); + } + } + + @Override + @SuppressWarnings("unchecked") + public void handleMessage(int messageType, Object message) throws ExoPlaybackException { + preventListenerNotification = true; + switch (messageType) { + case MSG_ADD: { + Pair messageData = (Pair) message; + addMediaSourceInternal(messageData.first, messageData.second); + break; + } + case MSG_ADD_MULTIPLE: { + Pair> messageData = + (Pair>) message; + addMediaSourcesInternal(messageData.first, messageData.second); + break; + } + case MSG_REMOVE: { + removeMediaSourceInternal((Integer) message); + break; + } + default: { + throw new IllegalStateException(); + } + } + preventListenerNotification = false; + maybeNotifyListener(); + } + + private void maybeNotifyListener() { + if (!preventListenerNotification) { + listener.onSourceInfoRefreshed( + new ConcatenatedTimeline(mediaSourceHolders, windowCount, periodCount), null); + } + } + + private void addMediaSourceInternal(int newIndex, MediaSource newMediaSource) { + final MediaSourceHolder newMediaSourceHolder; + Object newUid = System.identityHashCode(newMediaSource); + DeferredTimeline newTimeline = new DeferredTimeline(); + if (newIndex > 0) { + MediaSourceHolder previousHolder = mediaSourceHolders.get(newIndex - 1); + newMediaSourceHolder = new MediaSourceHolder(newMediaSource, newTimeline, + previousHolder.firstWindowIndexInChild + previousHolder.timeline.getWindowCount(), + previousHolder.firstPeriodIndexInChild + previousHolder.timeline.getPeriodCount(), + newUid); + } else { + newMediaSourceHolder = new MediaSourceHolder(newMediaSource, newTimeline, 0, 0, newUid); + } + correctOffsets(newIndex, newTimeline.getWindowCount(), newTimeline.getPeriodCount()); + mediaSourceHolders.add(newIndex, newMediaSourceHolder); + newMediaSourceHolder.mediaSource.prepareSource(player, false, new Listener() { + @Override + public void onSourceInfoRefreshed(Timeline newTimeline, Object manifest) { + updateMediaSourceInternal(newMediaSourceHolder, newTimeline); + } + }); + } + + private void addMediaSourcesInternal(int index, Collection mediaSources) { + for (MediaSource mediaSource : mediaSources) { + addMediaSourceInternal(index++, mediaSource); + } + } + + private void updateMediaSourceInternal(MediaSourceHolder mediaSourceHolder, Timeline timeline) { + if (mediaSourceHolder == null) { + throw new IllegalArgumentException(); + } + DeferredTimeline deferredTimeline = mediaSourceHolder.timeline; + if (deferredTimeline.getTimeline() == timeline) { + return; + } + int windowOffsetUpdate = timeline.getWindowCount() - deferredTimeline.getWindowCount(); + int periodOffsetUpdate = timeline.getPeriodCount() - deferredTimeline.getPeriodCount(); + if (windowOffsetUpdate != 0 || periodOffsetUpdate != 0) { + int index = findMediaSourceHolderByPeriodIndex(mediaSourceHolder.firstPeriodIndexInChild); + correctOffsets(index + 1, windowOffsetUpdate, periodOffsetUpdate); + } + mediaSourceHolder.timeline = deferredTimeline.cloneWithNewTimeline(timeline); + if (!mediaSourceHolder.isPrepared) { + for (int i = deferredMediaPeriods.size() - 1; i >= 0; i--) { + if (deferredMediaPeriods.get(i).mediaSource == mediaSourceHolder.mediaSource) { + deferredMediaPeriods.get(i).createPeriod(); + deferredMediaPeriods.remove(i); + } + } + } + mediaSourceHolder.isPrepared = true; + maybeNotifyListener(); + } + + private void removeMediaSourceInternal(int index) { + MediaSourceHolder holder = mediaSourceHolders.get(index); + mediaSourceHolders.remove(index); + Timeline oldTimeline = holder.timeline; + correctOffsets(index, -oldTimeline.getWindowCount(), -oldTimeline.getPeriodCount()); + holder.mediaSource.releaseSource(); + } + + private void correctOffsets(int startIndex, int windowOffsetUpdate, int periodOffsetUpdate) { + windowCount += windowOffsetUpdate; + periodCount += periodOffsetUpdate; + for (int i = startIndex; i < mediaSourceHolders.size(); i++) { + mediaSourceHolders.get(i).firstWindowIndexInChild += windowOffsetUpdate; + mediaSourceHolders.get(i).firstPeriodIndexInChild += periodOffsetUpdate; + } + } + + private int findMediaSourceHolderByPeriodIndex(int periodIndex) { + query.firstPeriodIndexInChild = periodIndex; + int index = Collections.binarySearch(mediaSourceHolders, query); + return index >= 0 ? index : -index - 2; + } + + private static final class MediaSourceHolder implements Comparable { + + public final MediaSource mediaSource; + public final Object uid; + + public DeferredTimeline timeline; + public int firstWindowIndexInChild; + public int firstPeriodIndexInChild; + public boolean isPrepared; + + public MediaSourceHolder(MediaSource mediaSource, DeferredTimeline timeline, int window, + int period, Object uid) { + this.mediaSource = mediaSource; + this.timeline = timeline; + this.firstWindowIndexInChild = window; + this.firstPeriodIndexInChild = period; + this.uid = uid; + } + + @Override + public int compareTo(MediaSourceHolder other) { + return this.firstPeriodIndexInChild - other.firstPeriodIndexInChild; + } + } + + private static final class ConcatenatedTimeline extends AbstractConcatenatedTimeline { + + private final int windowCount; + private final int periodCount; + private final int[] firstPeriodInChildIndices; + private final int[] firstWindowInChildIndices; + private final Timeline[] timelines; + private final int[] uids; + private final SparseIntArray childIndexByUid; + + public ConcatenatedTimeline(Collection mediaSourceHolders, int windowCount, + int periodCount) { + this.windowCount = windowCount; + this.periodCount = periodCount; + int childCount = mediaSourceHolders.size(); + firstPeriodInChildIndices = new int[childCount]; + firstWindowInChildIndices = new int[childCount]; + timelines = new Timeline[childCount]; + uids = new int[childCount]; + childIndexByUid = new SparseIntArray(); + int index = 0; + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + timelines[index] = mediaSourceHolder.timeline; + firstPeriodInChildIndices[index] = mediaSourceHolder.firstPeriodIndexInChild; + firstWindowInChildIndices[index] = mediaSourceHolder.firstWindowIndexInChild; + uids[index] = (int) mediaSourceHolder.uid; + childIndexByUid.put(uids[index], index++); + } + } + + @Override + protected void getChildDataByPeriodIndex(int periodIndex, ChildDataHolder childDataHolder) { + int index = Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex, true, false); + setChildData(index, childDataHolder); + } + + @Override + protected void getChildDataByWindowIndex(int windowIndex, ChildDataHolder childDataHolder) { + int index = Util.binarySearchFloor(firstWindowInChildIndices, windowIndex, true, false); + setChildData(index, childDataHolder); + } + + @Override + protected boolean getChildDataByChildUid(Object childUid, ChildDataHolder childDataHolder) { + if (!(childUid instanceof Integer)) { + return false; + } + int index = childIndexByUid.get((int) childUid, -1); + if (index == -1) { + return false; + } + setChildData(index, childDataHolder); + return true; + } + + @Override + public int getWindowCount() { + return windowCount; + } + + @Override + public int getPeriodCount() { + return periodCount; + } + + private void setChildData(int srcIndex, ChildDataHolder dest) { + dest.setData(timelines[srcIndex], firstPeriodInChildIndices[srcIndex], + firstWindowInChildIndices[srcIndex], uids[srcIndex]); + } + } + + private static final class DeferredTimeline extends Timeline { + + private static final Object DUMMY_ID = new Object(); + private static final Period period = new Period(); + + private final Timeline timeline; + private final Object replacedID; + + public DeferredTimeline() { + timeline = null; + replacedID = null; + } + + private DeferredTimeline(Timeline timeline, Object replacedID) { + this.timeline = timeline; + this.replacedID = replacedID; + } + + public DeferredTimeline cloneWithNewTimeline(Timeline timeline) { + return new DeferredTimeline(timeline, replacedID == null && timeline.getPeriodCount() > 0 + ? timeline.getPeriod(0, period, true).uid : replacedID); + } + + public Timeline getTimeline() { + return timeline; + } + + @Override + public int getWindowCount() { + return timeline == null ? 1 : timeline.getWindowCount(); + } + + @Override + public Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs) { + return timeline == null + // Dynamic window to indicate pending timeline updates. + ? window.set(setIds ? DUMMY_ID : null, C.TIME_UNSET, C.TIME_UNSET, false, true, 0, + C.TIME_UNSET, 0, 0, 0) + : timeline.getWindow(windowIndex, window, setIds, defaultPositionProjectionUs); + } + + @Override + public int getPeriodCount() { + return timeline == null ? 1 : timeline.getPeriodCount(); + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + if (timeline == null) { + return period.set(setIds ? DUMMY_ID : null, setIds ? DUMMY_ID : null, 0, C.TIME_UNSET, + C.TIME_UNSET); + } + timeline.getPeriod(periodIndex, period, setIds); + if (period.uid == replacedID) { + period.uid = DUMMY_ID; + } + return period; + } + + @Override + public int getIndexOfPeriod(Object uid) { + return timeline == null ? (uid == DUMMY_ID ? 0 : C.INDEX_UNSET) + : timeline.getIndexOfPeriod(uid == DUMMY_ID ? replacedID : uid); + } + + } + + private static final class DeferredMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + public final MediaSource mediaSource; + + private final MediaPeriodId id; + private final Allocator allocator; + + private MediaPeriod mediaPeriod; + private Callback callback; + private long preparePositionUs; + + public DeferredMediaPeriod(MediaSource mediaSource, MediaPeriodId id, Allocator allocator) { + this.id = id; + this.allocator = allocator; + this.mediaSource = mediaSource; + } + + public void createPeriod() { + mediaPeriod = mediaSource.createPeriod(id, allocator); + if (callback != null) { + mediaPeriod.prepare(this, preparePositionUs); + } + } + + public void releasePeriod() { + if (mediaPeriod != null) { + mediaSource.releasePeriod(mediaPeriod); + } + } + + @Override + public void prepare(Callback callback, long preparePositionUs) { + this.callback = callback; + this.preparePositionUs = preparePositionUs; + if (mediaPeriod != null) { + mediaPeriod.prepare(this, preparePositionUs); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + if (mediaPeriod != null) { + mediaPeriod.maybeThrowPrepareError(); + } else { + mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return mediaPeriod.getTrackGroups(); + } + + @Override + public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, + SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { + return mediaPeriod.selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, + positionUs); + } + + @Override + public void discardBuffer(long positionUs) { + mediaPeriod.discardBuffer(positionUs); + } + + @Override + public long readDiscontinuity() { + return mediaPeriod.readDiscontinuity(); + } + + @Override + public long getBufferedPositionUs() { + return mediaPeriod.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + return mediaPeriod.seekToUs(positionUs); + } + + @Override + public long getNextLoadPositionUs() { + return mediaPeriod.getNextLoadPositionUs(); + } + + @Override + public boolean continueLoading(long positionUs) { + return mediaPeriod != null && mediaPeriod.continueLoading(positionUs); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + callback.onContinueLoadingRequested(this); + } + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + callback.onPrepared(this); + } + } + +}