/* * Copyright (C) 2014 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.exoplayer.dash; import com.google.android.exoplayer.BehindLiveWindowException; import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.TimeRange; import com.google.android.exoplayer.TimeRange.DynamicTimeRange; import com.google.android.exoplayer.TimeRange.StaticTimeRange; import com.google.android.exoplayer.chunk.Chunk; import com.google.android.exoplayer.chunk.ChunkExtractorWrapper; import com.google.android.exoplayer.chunk.ChunkOperationHolder; import com.google.android.exoplayer.chunk.ChunkSource; import com.google.android.exoplayer.chunk.ContainerMediaChunk; import com.google.android.exoplayer.chunk.Format; import com.google.android.exoplayer.chunk.Format.DecreasingBandwidthComparator; import com.google.android.exoplayer.chunk.FormatEvaluator; import com.google.android.exoplayer.chunk.FormatEvaluator.Evaluation; import com.google.android.exoplayer.chunk.InitializationChunk; import com.google.android.exoplayer.chunk.MediaChunk; import com.google.android.exoplayer.chunk.SingleSampleMediaChunk; import com.google.android.exoplayer.dash.DashTrackSelector.Output; import com.google.android.exoplayer.dash.mpd.AdaptationSet; import com.google.android.exoplayer.dash.mpd.ContentProtection; import com.google.android.exoplayer.dash.mpd.MediaPresentationDescription; import com.google.android.exoplayer.dash.mpd.Period; import com.google.android.exoplayer.dash.mpd.RangedUri; import com.google.android.exoplayer.dash.mpd.Representation; import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.extractor.ChunkIndex; import com.google.android.exoplayer.extractor.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer.extractor.webm.WebmExtractor; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.util.Clock; import com.google.android.exoplayer.util.ManifestFetcher; import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.SystemClock; import android.os.Handler; import android.text.TextUtils; import android.util.Log; import android.util.SparseArray; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; /** * An {@link ChunkSource} for DASH streams. * <p> * This implementation currently supports fMP4, webm, webvtt and ttml. * <p> * This implementation makes the following assumptions about multi-period manifests: * <ol> * <li>that new periods will contain the same representations as previous periods (i.e. no new or * missing representations) and</li> * <li>that representations are contiguous across multiple periods</li> * </ol> */ // TODO: handle cases where the above assumption are false public class DashChunkSource implements ChunkSource, Output { /** * Interface definition for a callback to be notified of {@link DashChunkSource} events. */ public interface EventListener { /** * Invoked when the available seek range of the stream has changed. * * @param availableRange The range which specifies available content that can be seeked to. */ public void onAvailableRangeChanged(TimeRange availableRange); } /** * Thrown when an AdaptationSet is missing from the MPD. */ public static class NoAdaptationSetException extends IOException { public NoAdaptationSetException(String message) { super(message); } } private static final String TAG = "DashChunkSource"; private final Handler eventHandler; private final EventListener eventListener; private final DataSource dataSource; private final FormatEvaluator adaptiveFormatEvaluator; private final Evaluation evaluation; private final ManifestFetcher<MediaPresentationDescription> manifestFetcher; private final DashTrackSelector trackSelector; private final ArrayList<ExposedTrack> tracks; private final SparseArray<PeriodHolder> periodHolders; private final Clock systemClock; private final long liveEdgeLatencyUs; private final long elapsedRealtimeOffsetUs; private final long[] availableRangeValues; private final boolean live; private MediaPresentationDescription currentManifest; private ExposedTrack enabledTrack; private int nextPeriodHolderIndex; private TimeRange availableRange; private boolean prepareCalled; private boolean startAtLiveEdge; private boolean lastChunkWasInitialization; private IOException fatalError; /** * Lightweight constructor to use for fixed duration content. * * @param dataSource A {@link DataSource} suitable for loading the media data. * @param adaptiveFormatEvaluator For adaptive tracks, selects from the available formats. * @param durationMs The duration of the content. * @param adaptationSetType The type of the adaptation set to which the representations belong. * One of {@link AdaptationSet#TYPE_AUDIO}, {@link AdaptationSet#TYPE_VIDEO} and * {@link AdaptationSet#TYPE_TEXT}. * @param representations The representations to be considered by the source. */ public DashChunkSource(DataSource dataSource, FormatEvaluator adaptiveFormatEvaluator, long durationMs, int adaptationSetType, Representation... representations) { this(dataSource, adaptiveFormatEvaluator, durationMs, adaptationSetType, Arrays.asList(representations)); } /** * Lightweight constructor to use for fixed duration content. * * @param dataSource A {@link DataSource} suitable for loading the media data. * @param adaptiveFormatEvaluator For adaptive tracks, selects from the available formats. * @param durationMs The duration of the content. * @param adaptationSetType The type of the adaptation set to which the representations belong. * One of {@link AdaptationSet#TYPE_AUDIO}, {@link AdaptationSet#TYPE_VIDEO} and * {@link AdaptationSet#TYPE_TEXT}. * @param representations The representations to be considered by the source. */ public DashChunkSource(DataSource dataSource, FormatEvaluator adaptiveFormatEvaluator, long durationMs, int adaptationSetType, List<Representation> representations) { this(buildManifest(durationMs, adaptationSetType, representations), DefaultDashTrackSelector.newVideoInstance(null, false, false), dataSource, adaptiveFormatEvaluator); } /** * Constructor to use for fixed duration content. * * @param manifest The manifest. * @param trackSelector Selects tracks from manifest periods to be exposed by this source. * @param dataSource A {@link DataSource} suitable for loading the media data. * @param adaptiveFormatEvaluator For adaptive tracks, selects from the available formats. */ public DashChunkSource(MediaPresentationDescription manifest, DashTrackSelector trackSelector, DataSource dataSource, FormatEvaluator adaptiveFormatEvaluator) { this(null, manifest, trackSelector, dataSource, adaptiveFormatEvaluator, new SystemClock(), 0, 0, false, null, null); } /** * Constructor to use for live streaming. * <p> * May also be used for fixed duration content, in which case the call is equivalent to calling * the other constructor, passing {@code manifestFetcher.getManifest()} is the first argument. * * @param manifestFetcher A fetcher for the manifest, which must have already successfully * completed an initial load. * @param trackSelector Selects tracks from manifest periods to be exposed by this source. * @param dataSource A {@link DataSource} suitable for loading the media data. * @param adaptiveFormatEvaluator For adaptive tracks, selects from the available formats. * @param liveEdgeLatencyMs For live streams, the number of milliseconds that the playback should * lag behind the "live edge" (i.e. the end of the most recently defined media in the * manifest). Choosing a small value will minimize latency introduced by the player, however * note that the value sets an upper bound on the length of media that the player can buffer. * Hence a small value may increase the probability of rebuffering and playback failures. * @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between * server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds, specified * as the server's unix time minus the local elapsed time. It unknown, set to 0. * @param eventHandler A handler to use when delivering events to {@code EventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. */ public DashChunkSource(ManifestFetcher<MediaPresentationDescription> manifestFetcher, DashTrackSelector trackSelector, DataSource dataSource, FormatEvaluator adaptiveFormatEvaluator, long liveEdgeLatencyMs, long elapsedRealtimeOffsetMs, Handler eventHandler, EventListener eventListener) { this(manifestFetcher, manifestFetcher.getManifest(), trackSelector, dataSource, adaptiveFormatEvaluator, new SystemClock(), liveEdgeLatencyMs * 1000, elapsedRealtimeOffsetMs * 1000, true, eventHandler, eventListener); } /** * Constructor to use for live DVR streaming. * * @param manifestFetcher A fetcher for the manifest, which must have already successfully * completed an initial load. * @param trackSelector Selects tracks from manifest periods to be exposed by this source. * @param dataSource A {@link DataSource} suitable for loading the media data. * @param adaptiveFormatEvaluator For adaptive tracks, selects from the available formats. * @param liveEdgeLatencyMs For live streams, the number of milliseconds that the playback should * lag behind the "live edge" (i.e. the end of the most recently defined media in the * manifest). Choosing a small value will minimize latency introduced by the player, however * note that the value sets an upper bound on the length of media that the player can buffer. * Hence a small value may increase the probability of rebuffering and playback failures. * @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between * server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds, specified * as the server's unix time minus the local elapsed time. It unknown, set to 0. * @param startAtLiveEdge True if the stream should start at the live edge; false if it should * at the beginning of the live window. * @param eventHandler A handler to use when delivering events to {@code EventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. */ public DashChunkSource(ManifestFetcher<MediaPresentationDescription> manifestFetcher, DashTrackSelector trackSelector, DataSource dataSource, FormatEvaluator adaptiveFormatEvaluator, long liveEdgeLatencyMs, long elapsedRealtimeOffsetMs, boolean startAtLiveEdge, Handler eventHandler, EventListener eventListener) { this(manifestFetcher, manifestFetcher.getManifest(), trackSelector, dataSource, adaptiveFormatEvaluator, new SystemClock(), liveEdgeLatencyMs * 1000, elapsedRealtimeOffsetMs * 1000, startAtLiveEdge, eventHandler, eventListener); } /* package */ DashChunkSource(ManifestFetcher<MediaPresentationDescription> manifestFetcher, MediaPresentationDescription initialManifest, DashTrackSelector trackSelector, DataSource dataSource, FormatEvaluator adaptiveFormatEvaluator, Clock systemClock, long liveEdgeLatencyUs, long elapsedRealtimeOffsetUs, boolean startAtLiveEdge, Handler eventHandler, EventListener eventListener) { this.manifestFetcher = manifestFetcher; this.currentManifest = initialManifest; this.trackSelector = trackSelector; this.dataSource = dataSource; this.adaptiveFormatEvaluator = adaptiveFormatEvaluator; this.systemClock = systemClock; this.liveEdgeLatencyUs = liveEdgeLatencyUs; this.elapsedRealtimeOffsetUs = elapsedRealtimeOffsetUs; this.startAtLiveEdge = startAtLiveEdge; this.eventHandler = eventHandler; this.eventListener = eventListener; this.evaluation = new Evaluation(); this.availableRangeValues = new long[2]; periodHolders = new SparseArray<>(); tracks = new ArrayList<>(); live = initialManifest.dynamic; } // ChunkSource implementation. @Override public void maybeThrowError() throws IOException { if (fatalError != null) { throw fatalError; } else if (manifestFetcher != null) { manifestFetcher.maybeThrowError(); } } @Override public boolean prepare() { if (!prepareCalled) { prepareCalled = true; try { trackSelector.selectTracks(currentManifest, 0, this); } catch (IOException e) { fatalError = e; } } return fatalError == null; } @Override public int getTrackCount() { return tracks.size(); } @Override public final MediaFormat getFormat(int track) { return tracks.get(track).trackFormat; } @Override public void enable(int track) { enabledTrack = tracks.get(track); processManifest(currentManifest); if (enabledTrack.isAdaptive()) { adaptiveFormatEvaluator.enable(); } if (manifestFetcher != null) { manifestFetcher.enable(); } } @Override public void continueBuffering(long playbackPositionUs) { if (manifestFetcher == null || !currentManifest.dynamic || fatalError != null) { return; } MediaPresentationDescription newManifest = manifestFetcher.getManifest(); if (currentManifest != newManifest && newManifest != null) { processManifest(newManifest); } // TODO: This is a temporary hack to avoid constantly refreshing the MPD in cases where // minUpdatePeriod is set to 0. In such cases we shouldn't refresh unless there is explicit // signaling in the stream, according to: // http://azure.microsoft.com/blog/2014/09/13/dash-live-streaming-with-azure-media-service/ long minUpdatePeriod = currentManifest.minUpdatePeriod; if (minUpdatePeriod == 0) { minUpdatePeriod = 5000; } if (android.os.SystemClock.elapsedRealtime() > manifestFetcher.getManifestLoadStartTimestamp() + minUpdatePeriod) { manifestFetcher.requestRefresh(); } } @Override public final void getChunkOperation(List<? extends MediaChunk> queue, long seekPositionUs, long playbackPositionUs, ChunkOperationHolder out) { if (fatalError != null) { out.chunk = null; return; } evaluation.queueSize = queue.size(); if (evaluation.format == null || !lastChunkWasInitialization) { if (enabledTrack.isAdaptive()) { adaptiveFormatEvaluator.evaluate(queue, playbackPositionUs, enabledTrack.adaptiveFormats, evaluation); } else { evaluation.format = enabledTrack.fixedFormat; evaluation.trigger = Chunk.TRIGGER_MANUAL; } } Format selectedFormat = evaluation.format; out.queueSize = evaluation.queueSize; if (selectedFormat == null) { out.chunk = null; return; } else if (out.queueSize == queue.size() && out.chunk != null && out.chunk.format.equals(selectedFormat)) { // We already have a chunk, and the evaluation hasn't changed either the format or the size // of the queue. Leave unchanged. return; } // In all cases where we return before instantiating a new chunk, we want out.chunk to be null. out.chunk = null; boolean startingNewPeriod; PeriodHolder periodHolder; availableRange.getCurrentBoundsUs(availableRangeValues); if (queue.isEmpty()) { if (live) { if (startAtLiveEdge) { // We want live streams to start at the live edge instead of the beginning of the // manifest seekPositionUs = Math.max(availableRangeValues[0], availableRangeValues[1] - liveEdgeLatencyUs); } else { // we subtract 1 from the upper bound because it's exclusive for that bound seekPositionUs = Math.min(seekPositionUs, availableRangeValues[1] - 1); seekPositionUs = Math.max(seekPositionUs, availableRangeValues[0]); } } periodHolder = findPeriodHolder(seekPositionUs); startingNewPeriod = true; } else { if (startAtLiveEdge) { // now that we know the player is consuming media chunks (since the queue isn't empty), // set startAtLiveEdge to false so that the user can perform seek operations startAtLiveEdge = false; } MediaChunk previous = queue.get(out.queueSize - 1); long nextSegmentStartTimeUs = previous.endTimeUs; if (live && nextSegmentStartTimeUs < availableRangeValues[0]) { // This is before the first chunk in the current manifest. fatalError = new BehindLiveWindowException(); return; } else if (currentManifest.dynamic && nextSegmentStartTimeUs >= availableRangeValues[1]) { // This chunk is beyond the last chunk in the current manifest. If the index is bounded // we'll need to wait until it's refreshed. If it's unbounded we just need to wait for a // while before attempting to load the chunk. return; } else if (!currentManifest.dynamic) { // The current manifest isn't dynamic, so check whether we've reached the end of the stream. PeriodHolder lastPeriodHolder = periodHolders.valueAt(periodHolders.size() - 1); if (previous.parentId == lastPeriodHolder.localIndex) { RepresentationHolder representationHolder = lastPeriodHolder.representationHolders.get(previous.format.id); if (representationHolder.isLastSegment(previous.chunkIndex)) { out.endOfStream = true; return; } } } startingNewPeriod = false; periodHolder = periodHolders.get(previous.parentId); if (periodHolder == null) { // The previous chunk was from a period that's no longer on the manifest, therefore the // next chunk must be the first one in the first period that's still on the manifest // (note that we can't actually update the segmentNum yet because the new period might // have a different sequence and it's segmentIndex might not have been loaded yet). periodHolder = periodHolders.valueAt(0); startingNewPeriod = true; } else if (!periodHolder.isIndexUnbounded()) { RepresentationHolder representationHolder = periodHolder.representationHolders.get(previous.format.id); if (representationHolder.isLastSegment(previous.chunkIndex)) { // We reached the end of a period. Start the next one. periodHolder = periodHolders.get(previous.parentId + 1); startingNewPeriod = true; } } } RepresentationHolder representationHolder = periodHolder.representationHolders.get(selectedFormat.id); Representation selectedRepresentation = representationHolder.representation; RangedUri pendingInitializationUri = null; RangedUri pendingIndexUri = null; MediaFormat mediaFormat = representationHolder.mediaFormat; if (mediaFormat == null) { pendingInitializationUri = selectedRepresentation.getInitializationUri(); } if (representationHolder.segmentIndex == null) { pendingIndexUri = selectedRepresentation.getIndexUri(); } if (pendingInitializationUri != null || pendingIndexUri != null) { // We have initialization and/or index requests to make. Chunk initializationChunk = newInitializationChunk(pendingInitializationUri, pendingIndexUri, selectedRepresentation, representationHolder.extractorWrapper, dataSource, periodHolder.localIndex, evaluation.trigger); lastChunkWasInitialization = true; out.chunk = initializationChunk; return; } int segmentNum = queue.isEmpty() ? representationHolder.getSegmentNum(seekPositionUs) : startingNewPeriod ? representationHolder.getFirstAvailableSegmentNum() : queue.get(out.queueSize - 1).chunkIndex + 1; Chunk nextMediaChunk = newMediaChunk(periodHolder, representationHolder, dataSource, mediaFormat, segmentNum, evaluation.trigger); lastChunkWasInitialization = false; out.chunk = nextMediaChunk; } @Override public void onChunkLoadCompleted(Chunk chunk) { if (chunk instanceof InitializationChunk) { InitializationChunk initializationChunk = (InitializationChunk) chunk; String formatId = initializationChunk.format.id; PeriodHolder periodHolder = periodHolders.get(initializationChunk.parentId); if (periodHolder == null) { // period for this initialization chunk may no longer be on the manifest return; } RepresentationHolder representationHolder = periodHolder.representationHolders.get(formatId); if (initializationChunk.hasFormat()) { representationHolder.mediaFormat = initializationChunk.getFormat(); } if (initializationChunk.hasSeekMap()) { representationHolder.segmentIndex = new DashWrappingSegmentIndex( (ChunkIndex) initializationChunk.getSeekMap(), initializationChunk.dataSpec.uri.toString()); } // The null check avoids overwriting drmInitData obtained from the manifest with drmInitData // obtained from the stream, as per DASH IF Interoperability Recommendations V3.0, 7.5.3. if (periodHolder.drmInitData == null && initializationChunk.hasDrmInitData()) { periodHolder.drmInitData = initializationChunk.getDrmInitData(); } } } @Override public void onChunkLoadError(Chunk chunk, Exception e) { // Do nothing. } @Override public void disable(List<? extends MediaChunk> queue) { if (enabledTrack.isAdaptive()) { adaptiveFormatEvaluator.disable(); } if (manifestFetcher != null) { manifestFetcher.disable(); } periodHolders.clear(); evaluation.format = null; availableRange = null; fatalError = null; enabledTrack = null; } // DashTrackSelector.Output implementation. @Override public void adaptiveTrack(MediaPresentationDescription manifest, int periodIndex, int adaptationSetIndex, int[] representationIndices) { if (adaptiveFormatEvaluator == null) { Log.w(TAG, "Skipping adaptive track (missing format evaluator)"); return; } AdaptationSet adaptationSet = manifest.getPeriod(periodIndex).adaptationSets.get( adaptationSetIndex); int maxWidth = 0; int maxHeight = 0; Format maxHeightRepresentationFormat = null; Format[] representationFormats = new Format[representationIndices.length]; for (int i = 0; i < representationFormats.length; i++) { Format format = adaptationSet.representations.get(representationIndices[i]).format; if (maxHeightRepresentationFormat == null || format.height > maxHeight) { maxHeightRepresentationFormat = format; } maxWidth = Math.max(maxWidth, format.width); maxHeight = Math.max(maxHeight, format.height); representationFormats[i] = format; } Arrays.sort(representationFormats, new DecreasingBandwidthComparator()); long trackDurationUs = live ? C.UNKNOWN_TIME_US : manifest.duration * 1000; String mediaMimeType = getMediaMimeType(maxHeightRepresentationFormat); if (mediaMimeType == null) { Log.w(TAG, "Skipped adaptive track (unknown media mime type)"); return; } MediaFormat trackFormat = getTrackFormat(adaptationSet.type, maxHeightRepresentationFormat, mediaMimeType, trackDurationUs); if (trackFormat == null) { Log.w(TAG, "Skipped adaptive track (unknown media format)"); return; } tracks.add(new ExposedTrack(trackFormat.copyAsAdaptive(), adaptationSetIndex, representationFormats, maxWidth, maxHeight)); } @Override public void fixedTrack(MediaPresentationDescription manifest, int periodIndex, int adaptationSetIndex, int representationIndex) { List<AdaptationSet> adaptationSets = manifest.getPeriod(periodIndex).adaptationSets; AdaptationSet adaptationSet = adaptationSets.get(adaptationSetIndex); Format representationFormat = adaptationSet.representations.get(representationIndex).format; String mediaMimeType = getMediaMimeType(representationFormat); if (mediaMimeType == null) { Log.w(TAG, "Skipped track " + representationFormat.id + " (unknown media mime type)"); return; } MediaFormat trackFormat = getTrackFormat(adaptationSet.type, representationFormat, mediaMimeType, manifest.dynamic ? C.UNKNOWN_TIME_US : manifest.duration * 1000); if (trackFormat == null) { Log.w(TAG, "Skipped track " + representationFormat.id + " (unknown media format)"); return; } tracks.add(new ExposedTrack(trackFormat, adaptationSetIndex, representationFormat)); } // Private methods. // Visible for testing. /* package */ TimeRange getAvailableRange() { return availableRange; } private static MediaPresentationDescription buildManifest(long durationMs, int adaptationSetType, List<Representation> representations) { AdaptationSet adaptationSet = new AdaptationSet(0, adaptationSetType, representations); Period period = new Period(null, 0, Collections.singletonList(adaptationSet)); return new MediaPresentationDescription(-1, durationMs, -1, false, -1, -1, null, null, Collections.singletonList(period)); } private static MediaFormat getTrackFormat(int adaptationSetType, Format format, String mediaMimeType, long durationUs) { switch (adaptationSetType) { case AdaptationSet.TYPE_VIDEO: return MediaFormat.createVideoFormat(mediaMimeType, format.bitrate, MediaFormat.NO_VALUE, durationUs, format.width, format.height, 0, null); case AdaptationSet.TYPE_AUDIO: return MediaFormat.createAudioFormat(mediaMimeType, format.bitrate, MediaFormat.NO_VALUE, durationUs, format.audioChannels, format.audioSamplingRate, null); case AdaptationSet.TYPE_TEXT: return MediaFormat.createTextFormat(mediaMimeType, format.bitrate, format.language, durationUs); default: return null; } } private static String getMediaMimeType(Format format) { String formatMimeType = format.mimeType; if (MimeTypes.isAudio(formatMimeType)) { return getAudioMediaMimeType(format); } else if (MimeTypes.isVideo(formatMimeType)) { return getVideoMediaMimeType(format); } else if (mimeTypeIsRawText(formatMimeType)) { return formatMimeType; } else if (MimeTypes.APPLICATION_MP4.equals(formatMimeType) && "stpp".equals(format.codecs)) { return MimeTypes.APPLICATION_TTML; } else { return null; } } private static String getVideoMediaMimeType(Format format) { String codecs = format.codecs; if (TextUtils.isEmpty(codecs)) { Log.w(TAG, "Codecs attribute missing: " + format.id); return MimeTypes.VIDEO_UNKNOWN; } else if (codecs.startsWith("avc1") || codecs.startsWith("avc3")) { return MimeTypes.VIDEO_H264; } else if (codecs.startsWith("hev1") || codecs.startsWith("hvc1")) { return MimeTypes.VIDEO_H265; } else if (codecs.startsWith("vp9")) { return MimeTypes.VIDEO_VP9; } else if (codecs.startsWith("vp8")) { return MimeTypes.VIDEO_VP8; } Log.w(TAG, "Failed to parse mime from codecs: " + format.id + ", " + codecs); return MimeTypes.VIDEO_UNKNOWN; } private static String getAudioMediaMimeType(Format format) { String codecs = format.codecs; if (TextUtils.isEmpty(codecs)) { Log.w(TAG, "Codecs attribute missing: " + format.id); return MimeTypes.AUDIO_UNKNOWN; } else if (codecs.startsWith("mp4a")) { return MimeTypes.AUDIO_AAC; } else if (codecs.startsWith("ac-3") || codecs.startsWith("dac3")) { return MimeTypes.AUDIO_AC3; } else if (codecs.startsWith("ec-3") || codecs.startsWith("dec3")) { return MimeTypes.AUDIO_EC3; } else if (codecs.startsWith("dtsc") || codecs.startsWith("dtse")) { return MimeTypes.AUDIO_DTS; } else if (codecs.startsWith("dtsh") || codecs.startsWith("dtsl")) { return MimeTypes.AUDIO_DTS_HD; } else if (codecs.startsWith("opus")) { return MimeTypes.AUDIO_OPUS; } Log.w(TAG, "Failed to parse mime from codecs: " + format.id + ", " + codecs); return MimeTypes.AUDIO_UNKNOWN; } /* package */ static boolean mimeTypeIsWebm(String mimeType) { return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM) || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); } /* package */ static boolean mimeTypeIsRawText(String mimeType) { return MimeTypes.TEXT_VTT.equals(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType); } private Chunk newInitializationChunk(RangedUri initializationUri, RangedUri indexUri, Representation representation, ChunkExtractorWrapper extractor, DataSource dataSource, int manifestIndex, int trigger) { RangedUri requestUri; if (initializationUri != null) { // It's common for initialization and index data to be stored adjacently. Attempt to merge // the two requests together to request both at once. requestUri = initializationUri.attemptMerge(indexUri); if (requestUri == null) { requestUri = initializationUri; } } else { requestUri = indexUri; } DataSpec dataSpec = new DataSpec(requestUri.getUri(), requestUri.start, requestUri.length, representation.getCacheKey()); return new InitializationChunk(dataSource, dataSpec, trigger, representation.format, extractor, manifestIndex); } private Chunk newMediaChunk(PeriodHolder periodHolder, RepresentationHolder representationHolder, DataSource dataSource, MediaFormat mediaFormat, int segmentNum, int trigger) { Representation representation = representationHolder.representation; Format format = representation.format; long startTimeUs = representationHolder.getSegmentStartTimeUs(segmentNum); long endTimeUs = representationHolder.getSegmentEndTimeUs(segmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(segmentNum); DataSpec dataSpec = new DataSpec(segmentUri.getUri(), segmentUri.start, segmentUri.length, representation.getCacheKey()); long sampleOffsetUs = periodHolder.startTimeUs - representation.presentationTimeOffsetUs; if (mimeTypeIsRawText(format.mimeType)) { return new SingleSampleMediaChunk(dataSource, dataSpec, Chunk.TRIGGER_INITIAL, format, startTimeUs, endTimeUs, segmentNum, MediaFormat.createTextFormat(format.mimeType, MediaFormat.NO_VALUE, format.language), null, periodHolder.localIndex); } else { boolean isMediaFormatFinal = (mediaFormat != null); return new ContainerMediaChunk(dataSource, dataSpec, trigger, format, startTimeUs, endTimeUs, segmentNum, sampleOffsetUs, representationHolder.extractorWrapper, mediaFormat, enabledTrack.adaptiveMaxWidth, enabledTrack.adaptiveMaxHeight, periodHolder.drmInitData, isMediaFormatFinal, periodHolder.localIndex); } } private long getNowUnixTimeUs() { if (elapsedRealtimeOffsetUs != 0) { return (systemClock.elapsedRealtime() * 1000) + elapsedRealtimeOffsetUs; } else { return System.currentTimeMillis() * 1000; } } private PeriodHolder findPeriodHolder(long positionUs) { // if positionUs is before the first period, return the first period if (positionUs < periodHolders.valueAt(0).getAvailableStartTimeUs()) { return periodHolders.valueAt(0); } for (int i = 0; i < periodHolders.size() - 1; i++) { PeriodHolder periodHolder = periodHolders.valueAt(i); if (positionUs < periodHolder.getAvailableEndTimeUs()) { return periodHolder; } } // positionUs is within or after the last period return periodHolders.valueAt(periodHolders.size() - 1); } private void processManifest(MediaPresentationDescription manifest) { // Remove old periods. Period firstPeriod = manifest.getPeriod(0); while (periodHolders.size() > 0 && periodHolders.valueAt(0).startTimeUs < firstPeriod.startMs * 1000) { PeriodHolder periodHolder = periodHolders.valueAt(0); // TODO: Use periodHolders.removeAt(0) if the minimum API level is ever increased to 11. periodHolders.remove(periodHolder.localIndex); } // Update existing periods. Only the first and last periods can change. try { int periodHolderCount = periodHolders.size(); if (periodHolderCount > 0) { periodHolders.valueAt(0).updatePeriod(manifest, 0, enabledTrack); if (periodHolderCount > 1) { int lastIndex = periodHolderCount - 1; periodHolders.valueAt(lastIndex).updatePeriod(manifest, lastIndex, enabledTrack); } } } catch (BehindLiveWindowException e) { fatalError = e; return; } // Add new periods. for (int i = periodHolders.size(); i < manifest.getPeriodCount(); i++) { PeriodHolder holder = new PeriodHolder(nextPeriodHolderIndex, manifest, i, enabledTrack); periodHolders.put(nextPeriodHolderIndex, holder); nextPeriodHolderIndex++; } // Update the available range. TimeRange newAvailableRange = getAvailableRange(getNowUnixTimeUs()); if (availableRange == null || !availableRange.equals(newAvailableRange)) { availableRange = newAvailableRange; notifyAvailableRangeChanged(availableRange); } currentManifest = manifest; } private TimeRange getAvailableRange(long nowUnixTimeUs) { PeriodHolder firstPeriod = periodHolders.valueAt(0); PeriodHolder lastPeriod = periodHolders.valueAt(periodHolders.size() - 1); if (!currentManifest.dynamic || lastPeriod.isIndexExplicit()) { return new StaticTimeRange(firstPeriod.getAvailableStartTimeUs(), lastPeriod.getAvailableEndTimeUs()); } long minStartPositionUs = firstPeriod.getAvailableStartTimeUs(); long maxEndPositionUs = lastPeriod.isIndexUnbounded() ? Long.MAX_VALUE : lastPeriod.getAvailableEndTimeUs(); long elapsedRealtimeAtZeroUs = (systemClock.elapsedRealtime() * 1000) - (nowUnixTimeUs - (currentManifest.availabilityStartTime * 1000)); long timeShiftBufferDepthUs = currentManifest.timeShiftBufferDepth == -1 ? -1 : currentManifest.timeShiftBufferDepth * 1000; return new DynamicTimeRange(minStartPositionUs, maxEndPositionUs, elapsedRealtimeAtZeroUs, timeShiftBufferDepthUs, systemClock); } private void notifyAvailableRangeChanged(final TimeRange seekRange) { if (eventHandler != null && eventListener != null) { eventHandler.post(new Runnable() { @Override public void run() { eventListener.onAvailableRangeChanged(seekRange); } }); } } // Private classes. private static final class ExposedTrack { public final MediaFormat trackFormat; private final int adaptationSetIndex; // Non-adaptive track variables. private final Format fixedFormat; // Adaptive track variables. private final Format[] adaptiveFormats; private final int adaptiveMaxWidth; private final int adaptiveMaxHeight; public ExposedTrack(MediaFormat trackFormat, int adaptationSetIndex, Format fixedFormat) { this.trackFormat = trackFormat; this.adaptationSetIndex = adaptationSetIndex; this.fixedFormat = fixedFormat; this.adaptiveFormats = null; this.adaptiveMaxWidth = -1; this.adaptiveMaxHeight = -1; } public ExposedTrack(MediaFormat trackFormat, int adaptationSetIndex, Format[] adaptiveFormats, int maxWidth, int maxHeight) { this.trackFormat = trackFormat; this.adaptationSetIndex = adaptationSetIndex; this.adaptiveFormats = adaptiveFormats; this.adaptiveMaxWidth = maxWidth; this.adaptiveMaxHeight = maxHeight; this.fixedFormat = null; } public boolean isAdaptive() { return adaptiveFormats != null; } } private static final class RepresentationHolder { public final ChunkExtractorWrapper extractorWrapper; public Representation representation; public DashSegmentIndex segmentIndex; public MediaFormat mediaFormat; private final long periodStartTimeUs; private long periodDurationUs; private int segmentNumShift; public RepresentationHolder(long periodStartTimeUs, long periodDurationUs, Representation representation) { this.periodStartTimeUs = periodStartTimeUs; this.periodDurationUs = periodDurationUs; this.representation = representation; String mimeType = representation.format.mimeType; extractorWrapper = mimeTypeIsRawText(mimeType) ? null : new ChunkExtractorWrapper( mimeTypeIsWebm(mimeType) ? new WebmExtractor() : new FragmentedMp4Extractor()); segmentIndex = representation.getIndex(); } public void updateRepresentation(long newPeriodDurationUs, Representation newRepresentation) throws BehindLiveWindowException{ DashSegmentIndex oldIndex = representation.getIndex(); DashSegmentIndex newIndex = newRepresentation.getIndex(); periodDurationUs = newPeriodDurationUs; representation = newRepresentation; if (oldIndex == null) { // Segment numbers cannot shift if the index isn't defined by the manifest. return; } segmentIndex = newIndex; if (!oldIndex.isExplicit()) { // Segment numbers cannot shift if the index isn't explicit. return; } int oldIndexLastSegmentNum = oldIndex.getLastSegmentNum(periodDurationUs); long oldIndexEndTimeUs = oldIndex.getTimeUs(oldIndexLastSegmentNum) + oldIndex.getDurationUs(oldIndexLastSegmentNum, periodDurationUs); int newIndexFirstSegmentNum = newIndex.getFirstSegmentNum(); long newIndexStartTimeUs = newIndex.getTimeUs(newIndexFirstSegmentNum); if (oldIndexEndTimeUs == newIndexStartTimeUs) { // The new index continues where the old one ended, with no overlap. segmentNumShift += oldIndex.getLastSegmentNum(periodDurationUs) + 1 - newIndexFirstSegmentNum; } else if (oldIndexEndTimeUs < newIndexStartTimeUs) { // There's a gap between the old index and the new one which means we've slipped behind the // live window and can't proceed. throw new BehindLiveWindowException(); } else { // The new index overlaps with the old one. segmentNumShift += oldIndex.getSegmentNum(newIndexStartTimeUs, periodDurationUs) - newIndexFirstSegmentNum; } } public int getSegmentNum(long positionUs) { return segmentIndex.getSegmentNum(positionUs - periodStartTimeUs, periodDurationUs) + segmentNumShift; } public long getSegmentStartTimeUs(int segmentNum) { return segmentIndex.getTimeUs(segmentNum - segmentNumShift) + periodStartTimeUs; } public long getSegmentEndTimeUs(int segmentNum) { return getSegmentStartTimeUs(segmentNum) + segmentIndex.getDurationUs(segmentNum - segmentNumShift, periodDurationUs); } public boolean isLastSegment(int segmentNum) { int lastSegmentNum = segmentIndex.getLastSegmentNum(periodDurationUs); return lastSegmentNum == DashSegmentIndex.INDEX_UNBOUNDED ? false : segmentNum == (lastSegmentNum + segmentNumShift); } public int getFirstAvailableSegmentNum() { return segmentIndex.getFirstSegmentNum() + segmentNumShift; } public RangedUri getSegmentUrl(int segmentNum) { return segmentIndex.getSegmentUrl(segmentNum - segmentNumShift); } } private static final class PeriodHolder { public final int localIndex; public final long startTimeUs; public final HashMap<String, RepresentationHolder> representationHolders; private final int[] representationIndices; private DrmInitData drmInitData; private boolean indexIsUnbounded; private boolean indexIsExplicit; private long availableStartTimeUs; private long availableEndTimeUs; public PeriodHolder(int localIndex, MediaPresentationDescription manifest, int manifestIndex, ExposedTrack selectedTrack) { this.localIndex = localIndex; Period period = manifest.getPeriod(manifestIndex); long periodDurationUs = getPeriodDurationUs(manifest, manifestIndex); AdaptationSet adaptationSet = period.adaptationSets.get(selectedTrack.adaptationSetIndex); List<Representation> representations = adaptationSet.representations; startTimeUs = period.startMs * 1000; drmInitData = getDrmInitData(adaptationSet); if (!selectedTrack.isAdaptive()) { representationIndices = new int[] { getRepresentationIndex(representations, selectedTrack.fixedFormat.id)}; } else { representationIndices = new int[selectedTrack.adaptiveFormats.length]; for (int j = 0; j < selectedTrack.adaptiveFormats.length; j++) { representationIndices[j] = getRepresentationIndex( representations, selectedTrack.adaptiveFormats[j].id); } } representationHolders = new HashMap<>(); for (int i = 0; i < representationIndices.length; i++) { Representation representation = representations.get(representationIndices[i]); RepresentationHolder representationHolder = new RepresentationHolder(startTimeUs, periodDurationUs, representation); representationHolders.put(representation.format.id, representationHolder); } updateRepresentationIndependentProperties(periodDurationUs, representations.get(representationIndices[0])); } public void updatePeriod(MediaPresentationDescription manifest, int manifestIndex, ExposedTrack selectedTrack) throws BehindLiveWindowException { Period period = manifest.getPeriod(manifestIndex); long periodDurationUs = getPeriodDurationUs(manifest, manifestIndex); List<Representation> representations = period.adaptationSets .get(selectedTrack.adaptationSetIndex).representations; for (int j = 0; j < representationIndices.length; j++) { Representation representation = representations.get(representationIndices[j]); representationHolders.get(representation.format.id).updateRepresentation(periodDurationUs, representation); } updateRepresentationIndependentProperties(periodDurationUs, representations.get(representationIndices[0])); } public long getAvailableStartTimeUs() { return availableStartTimeUs; } public long getAvailableEndTimeUs() { if (isIndexUnbounded()) { throw new IllegalStateException("Period has unbounded index"); } return availableEndTimeUs; } public boolean isIndexUnbounded() { return indexIsUnbounded; } public boolean isIndexExplicit() { return indexIsExplicit; } // Private methods. private void updateRepresentationIndependentProperties(long periodDurationUs, Representation arbitaryRepresentation) { DashSegmentIndex segmentIndex = arbitaryRepresentation.getIndex(); if (segmentIndex != null) { int firstSegmentNum = segmentIndex.getFirstSegmentNum(); int lastSegmentNum = segmentIndex.getLastSegmentNum(periodDurationUs); indexIsUnbounded = lastSegmentNum == DashSegmentIndex.INDEX_UNBOUNDED; indexIsExplicit = segmentIndex.isExplicit(); availableStartTimeUs = startTimeUs + segmentIndex.getTimeUs(firstSegmentNum); if (!indexIsUnbounded) { availableEndTimeUs = startTimeUs + segmentIndex.getTimeUs(lastSegmentNum) + segmentIndex.getDurationUs(lastSegmentNum, periodDurationUs); } } else { indexIsUnbounded = false; indexIsExplicit = true; availableStartTimeUs = startTimeUs; availableEndTimeUs = startTimeUs + periodDurationUs; } } private static int getRepresentationIndex(List<Representation> representations, String formatId) { for (int i = 0; i < representations.size(); i++) { Representation representation = representations.get(i); if (formatId.equals(representation.format.id)) { return i; } } throw new IllegalStateException("Missing format id: " + formatId); } private static DrmInitData getDrmInitData(AdaptationSet adaptationSet) { String drmInitMimeType = mimeTypeIsWebm(adaptationSet.representations.get(0).format.mimeType) ? MimeTypes.VIDEO_WEBM : MimeTypes.VIDEO_MP4; if (adaptationSet.contentProtections.isEmpty()) { return null; } else { DrmInitData.Mapped drmInitData = null; for (int i = 0; i < adaptationSet.contentProtections.size(); i++) { ContentProtection contentProtection = adaptationSet.contentProtections.get(i); if (contentProtection.uuid != null && contentProtection.data != null) { if (drmInitData == null) { drmInitData = new DrmInitData.Mapped(drmInitMimeType); } drmInitData.put(contentProtection.uuid, contentProtection.data); } } return drmInitData; } } private static long getPeriodDurationUs(MediaPresentationDescription manifest, int index) { long durationMs = manifest.getPeriodDuration(index); if (durationMs == -1) { return C.UNKNOWN_TIME_US; } else { return durationMs * 1000; } } } }