mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
SABR
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
class CombinedQueryString implements UrlQueryString {
|
||||
private final List<UrlQueryString> mQueryStrings = new ArrayList<>();
|
||||
|
||||
public CombinedQueryString(String url) {
|
||||
UrlQueryString urlQueryString = UrlEncodedQueryString.parse(url);
|
||||
|
||||
if (urlQueryString.isValid()) {
|
||||
mQueryStrings.add(urlQueryString);
|
||||
}
|
||||
|
||||
UrlQueryString pathQueryString = PathQueryString.parse(url);
|
||||
|
||||
if (pathQueryString.isValid()) {
|
||||
mQueryStrings.add(pathQueryString);
|
||||
}
|
||||
|
||||
if (mQueryStrings.isEmpty()) {
|
||||
mQueryStrings.add(NullQueryString.parse(url));
|
||||
}
|
||||
}
|
||||
|
||||
public static UrlQueryString parse(String url) {
|
||||
return new CombinedQueryString(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
String value = queryString.get(key);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
float value = queryString.getFloat(key);
|
||||
if (value != 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
return queryString.isEmpty();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
return queryString.isValid();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
boolean contains = queryString.contains(key);
|
||||
if (contains) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
return queryString.toString();
|
||||
}
|
||||
|
||||
return super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import static androidx.media3.common.util.Util.addWithOverflowDefault;
|
||||
import static androidx.media3.common.util.Util.subtractWithOverflowDefault;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.CheckResult;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.exoplayer.LoadingInfo;
|
||||
import androidx.media3.exoplayer.SeekParameters;
|
||||
import androidx.media3.exoplayer.source.chunk.BundledChunkExtractor;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkExtractor;
|
||||
import androidx.media3.extractor.ChunkIndex;
|
||||
import androidx.media3.extractor.Extractor;
|
||||
import androidx.media3.extractor.SeekMap;
|
||||
import androidx.media3.extractor.TrackOutput;
|
||||
import androidx.media3.exoplayer.source.BehindLiveWindowException;
|
||||
import androidx.media3.exoplayer.source.chunk.BaseMediaChunkIterator;
|
||||
import androidx.media3.exoplayer.source.chunk.Chunk;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkHolder;
|
||||
import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk;
|
||||
import androidx.media3.exoplayer.source.chunk.InitializationChunk;
|
||||
import androidx.media3.exoplayer.source.chunk.MediaChunk;
|
||||
import androidx.media3.exoplayer.source.chunk.MediaChunkIterator;
|
||||
import androidx.media3.exoplayer.source.chunk.SingleSampleMediaChunk;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
|
||||
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
|
||||
import com.futo.platformplayer.sabr.manifest.RangedUri;
|
||||
import com.futo.platformplayer.sabr.manifest.Representation;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import com.futo.platformplayer.sabr.parser.SabrExtractor;
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@UnstableApi
|
||||
public class DefaultSabrChunkSource implements SabrChunkSource {
|
||||
public static final class Factory implements SabrChunkSource.Factory {
|
||||
|
||||
private final DataSource.Factory dataSourceFactory;
|
||||
private final int maxSegmentsPerLoad;
|
||||
|
||||
public Factory(DataSource.Factory dataSourceFactory) {
|
||||
this(dataSourceFactory, 1);
|
||||
}
|
||||
|
||||
public Factory(DataSource.Factory dataSourceFactory, int maxSegmentsPerLoad) {
|
||||
this.dataSourceFactory = dataSourceFactory;
|
||||
this.maxSegmentsPerLoad = maxSegmentsPerLoad;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrChunkSource createSabrChunkSource(
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
int[] adaptationSetIndices,
|
||||
ExoTrackSelection trackSelection,
|
||||
int type,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
@Nullable PlayerTrackEmsgHandler playerEmsgHandler,
|
||||
@Nullable TransferListener transferListener) {
|
||||
DataSource dataSource = dataSourceFactory.createDataSource();
|
||||
if (transferListener != null) {
|
||||
dataSource.addTransferListener(transferListener);
|
||||
}
|
||||
return new DefaultSabrChunkSource(
|
||||
manifestLoaderErrorThrower,
|
||||
manifest,
|
||||
periodIndex,
|
||||
adaptationSetIndices,
|
||||
trackSelection,
|
||||
type,
|
||||
dataSource,
|
||||
elapsedRealtimeOffsetMs,
|
||||
maxSegmentsPerLoad,
|
||||
enableEventMessageTrack,
|
||||
closedCaptionFormats,
|
||||
playerEmsgHandler);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final LoaderErrorThrower manifestLoaderErrorThrower;
|
||||
private final int[] adaptationSetIndices;
|
||||
private final int trackType;
|
||||
private final DataSource dataSource;
|
||||
private final long elapsedRealtimeOffsetMs;
|
||||
private final int maxSegmentsPerLoad;
|
||||
@Nullable private final PlayerTrackEmsgHandler playerTrackEmsgHandler;
|
||||
|
||||
protected final RepresentationHolder[] representationHolders;
|
||||
|
||||
private ExoTrackSelection trackSelection;
|
||||
private SabrManifest manifest;
|
||||
private int periodIndex;
|
||||
private IOException fatalError;
|
||||
private boolean missingLastSegment;
|
||||
private long liveEdgeTimeUs;
|
||||
|
||||
/**
|
||||
* @param manifestLoaderErrorThrower Throws errors affecting loading of manifests.
|
||||
* @param manifest The initial manifest.
|
||||
* @param periodIndex The index of the period in the manifest.
|
||||
* @param adaptationSetIndices The indices of the adaptation sets in the period.
|
||||
* @param trackSelection The track selection.
|
||||
* @param trackType The type of the tracks in the selection.
|
||||
* @param dataSource A {@link DataSource} suitable for loading the media data.
|
||||
* @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. If unknown, set to 0.
|
||||
* @param maxSegmentsPerLoad The maximum number of segments to combine into a single request. Note
|
||||
* that segments will only be combined if their {@link Uri}s are the same and if their data
|
||||
* ranges are adjacent.
|
||||
* @param enableEventMessageTrack Whether to output an event message track.
|
||||
* @param closedCaptionFormats The {@link Format Formats} of closed caption tracks to be output.
|
||||
* @param playerTrackEmsgHandler The {@link PlayerTrackEmsgHandler} instance to handle emsg
|
||||
* messages targeting the player. Maybe null if this is not necessary.
|
||||
*/
|
||||
public DefaultSabrChunkSource(
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
int[] adaptationSetIndices,
|
||||
ExoTrackSelection trackSelection,
|
||||
int trackType,
|
||||
DataSource dataSource,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
int maxSegmentsPerLoad,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
@Nullable PlayerTrackEmsgHandler playerTrackEmsgHandler) {
|
||||
this.manifestLoaderErrorThrower = manifestLoaderErrorThrower;
|
||||
this.manifest = manifest;
|
||||
this.adaptationSetIndices = adaptationSetIndices;
|
||||
this.trackSelection = trackSelection;
|
||||
this.trackType = trackType;
|
||||
this.dataSource = dataSource;
|
||||
this.periodIndex = periodIndex;
|
||||
this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs;
|
||||
this.maxSegmentsPerLoad = maxSegmentsPerLoad;
|
||||
this.playerTrackEmsgHandler = playerTrackEmsgHandler;
|
||||
|
||||
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
liveEdgeTimeUs = C.TIME_UNSET;
|
||||
|
||||
List<Representation> representations = getRepresentations();
|
||||
representationHolders = new RepresentationHolder[trackSelection.length()];
|
||||
for (int i = 0; i < representationHolders.length; i++) {
|
||||
Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i));
|
||||
representationHolders[i] =
|
||||
new RepresentationHolder(
|
||||
periodDurationUs,
|
||||
trackType,
|
||||
representation,
|
||||
enableEventMessageTrack,
|
||||
closedCaptionFormats,
|
||||
playerTrackEmsgHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateManifest(SabrManifest newManifest, int newPeriodIndex) {
|
||||
try {
|
||||
manifest = newManifest;
|
||||
periodIndex = newPeriodIndex;
|
||||
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
List<Representation> representations = getRepresentations();
|
||||
for (int i = 0; i < representationHolders.length; i++) {
|
||||
Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i));
|
||||
representationHolders[i] =
|
||||
representationHolders[i].copyWithNewRepresentation(periodDurationUs, representation);
|
||||
}
|
||||
} catch (BehindLiveWindowException e) {
|
||||
fatalError = e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTrackSelection(ExoTrackSelection trackSelection) {
|
||||
this.trackSelection = trackSelection;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolves a seek given the requested seek position, a {@link SeekParameters} and two candidate
|
||||
* sync points.
|
||||
*
|
||||
* @param positionUs The requested seek position, in microseocnds.
|
||||
* @param seekParameters The {@link SeekParameters}.
|
||||
* @param firstSyncUs The first candidate seek point, in micrseconds.
|
||||
* @param secondSyncUs The second candidate seek point, in microseconds. May equal {@code
|
||||
* firstSyncUs} if there's only one candidate.
|
||||
* @return The resolved seek position, in microseconds.
|
||||
*/
|
||||
public static long resolveSeekPositionUs(
|
||||
long positionUs, SeekParameters seekParameters, long firstSyncUs, long secondSyncUs) {
|
||||
if (SeekParameters.EXACT.equals(seekParameters)) {
|
||||
return positionUs;
|
||||
}
|
||||
long minPositionUs = subtractWithOverflowDefault(positionUs, seekParameters.toleranceBeforeUs, Long.MIN_VALUE);
|
||||
long maxPositionUs = addWithOverflowDefault(positionUs, seekParameters.toleranceAfterUs, Long.MAX_VALUE);
|
||||
boolean firstSyncPositionValid = minPositionUs <= firstSyncUs && firstSyncUs <= maxPositionUs;
|
||||
boolean secondSyncPositionValid =
|
||||
minPositionUs <= secondSyncUs && secondSyncUs <= maxPositionUs;
|
||||
if (firstSyncPositionValid && secondSyncPositionValid) {
|
||||
if (Math.abs(firstSyncUs - positionUs) <= Math.abs(secondSyncUs - positionUs)) {
|
||||
return firstSyncUs;
|
||||
} else {
|
||||
return secondSyncUs;
|
||||
}
|
||||
} else if (firstSyncPositionValid) {
|
||||
return firstSyncUs;
|
||||
} else if (secondSyncPositionValid) {
|
||||
return secondSyncUs;
|
||||
} else {
|
||||
return minPositionUs;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
|
||||
// Segments are aligned across representations, so any segment index will do.
|
||||
for (RepresentationHolder representationHolder : representationHolders) {
|
||||
if (representationHolder.segmentIndex != null) {
|
||||
long segmentNum = representationHolder.getSegmentNum(positionUs);
|
||||
long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum);
|
||||
long secondSyncUs =
|
||||
firstSyncUs < positionUs && segmentNum < representationHolder.getSegmentCount() - 1
|
||||
? representationHolder.getSegmentStartTimeUs(segmentNum + 1)
|
||||
: firstSyncUs;
|
||||
return resolveSeekPositionUs(positionUs, seekParameters, firstSyncUs, secondSyncUs);
|
||||
}
|
||||
}
|
||||
// We don't have a segment index to adjust the seek position with yet.
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowError() throws IOException {
|
||||
if (fatalError != null) {
|
||||
throw fatalError;
|
||||
} else {
|
||||
manifestLoaderErrorThrower.maybeThrowError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPreferredQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue) {
|
||||
if (fatalError != null || trackSelection.length() < 2) {
|
||||
return queue.size();
|
||||
}
|
||||
return trackSelection.evaluateQueueSize(playbackPositionUs, queue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getNextChunk(LoadingInfo loadingInfo, long loadPositionUs, List<? extends MediaChunk> queue, ChunkHolder out) {
|
||||
//public void getNextChunk(long playbackPositionUs, long loadPositionUs, List<? extends MediaChunk> queue, ChunkHolder out) {
|
||||
if (fatalError != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long bufferedDurationUs = loadPositionUs - loadingInfo.playbackPositionUs;
|
||||
long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(loadingInfo.playbackPositionUs);
|
||||
long presentationPositionUs = C.msToUs(manifest.availabilityStartTimeMs)
|
||||
+ C.msToUs(manifest.getPeriod(periodIndex).startMs)
|
||||
+ loadPositionUs;
|
||||
|
||||
if (playerTrackEmsgHandler != null
|
||||
&& playerTrackEmsgHandler.maybeRefreshManifestBeforeLoadingNextChunk(
|
||||
presentationPositionUs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
long nowUnixTimeUs = getNowUnixTimeUs();
|
||||
MediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1);
|
||||
MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()];
|
||||
for (int i = 0; i < chunkIterators.length; i++) {
|
||||
RepresentationHolder representationHolder = representationHolders[i];
|
||||
if (representationHolder.segmentIndex == null) {
|
||||
chunkIterators[i] = MediaChunkIterator.EMPTY;
|
||||
} else {
|
||||
long firstAvailableSegmentNum =
|
||||
representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
long lastAvailableSegmentNum =
|
||||
representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
long segmentNum =
|
||||
getSegmentNum(
|
||||
representationHolder,
|
||||
previous,
|
||||
loadPositionUs,
|
||||
firstAvailableSegmentNum,
|
||||
lastAvailableSegmentNum);
|
||||
if (segmentNum < firstAvailableSegmentNum) {
|
||||
chunkIterators[i] = MediaChunkIterator.EMPTY;
|
||||
} else {
|
||||
chunkIterators[i] =
|
||||
new RepresentationSegmentIterator(
|
||||
representationHolder, segmentNum, lastAvailableSegmentNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackSelection.updateSelectedTrack(
|
||||
loadingInfo.playbackPositionUs, bufferedDurationUs, timeToLiveEdgeUs, queue, chunkIterators);
|
||||
|
||||
RepresentationHolder representationHolder =
|
||||
representationHolders[trackSelection.getSelectedIndex()];
|
||||
|
||||
if (representationHolder.extractorWrapper != null) {
|
||||
Representation selectedRepresentation = representationHolder.representation;
|
||||
RangedUri pendingInitializationUri = null;
|
||||
RangedUri pendingIndexUri = null;
|
||||
if (representationHolder.extractorWrapper.getSampleFormats() == 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.
|
||||
out.chunk = newInitializationChunk(representationHolder, dataSource,
|
||||
trackSelection.getSelectedFormat(), trackSelection.getSelectionReason(),
|
||||
trackSelection.getSelectionData(), pendingInitializationUri, pendingIndexUri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
long periodDurationUs = representationHolder.periodDurationUs;
|
||||
boolean periodEnded = periodDurationUs != C.TIME_UNSET;
|
||||
|
||||
if (representationHolder.getSegmentCount() == 0) {
|
||||
// The index doesn't define any segments.
|
||||
out.endOfStream = periodEnded;
|
||||
return;
|
||||
}
|
||||
|
||||
long firstAvailableSegmentNum =
|
||||
representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
long lastAvailableSegmentNum =
|
||||
representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
|
||||
updateLiveEdgeTimeUs(representationHolder, lastAvailableSegmentNum);
|
||||
|
||||
long segmentNum =
|
||||
getSegmentNum(
|
||||
representationHolder,
|
||||
previous,
|
||||
loadPositionUs,
|
||||
firstAvailableSegmentNum,
|
||||
lastAvailableSegmentNum);
|
||||
if (segmentNum < firstAvailableSegmentNum) {
|
||||
// This is before the first chunk in the current manifest.
|
||||
fatalError = new BehindLiveWindowException();
|
||||
return;
|
||||
}
|
||||
|
||||
if (segmentNum > lastAvailableSegmentNum
|
||||
|| (missingLastSegment && segmentNum >= lastAvailableSegmentNum)) {
|
||||
// The segment is beyond the end of the period.
|
||||
out.endOfStream = periodEnded;
|
||||
return;
|
||||
}
|
||||
|
||||
if (periodEnded && representationHolder.getSegmentStartTimeUs(segmentNum) >= periodDurationUs) {
|
||||
// The period duration clips the period to a position before the segment.
|
||||
out.endOfStream = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int maxSegmentCount =
|
||||
(int) Math.min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1);
|
||||
if (periodDurationUs != C.TIME_UNSET) {
|
||||
while (maxSegmentCount > 1
|
||||
&& representationHolder.getSegmentStartTimeUs(segmentNum + maxSegmentCount - 1)
|
||||
>= periodDurationUs) {
|
||||
// The period duration clips the period to a position before the last segment in the range
|
||||
// [segmentNum, segmentNum + maxSegmentCount - 1]. Reduce maxSegmentCount.
|
||||
maxSegmentCount--;
|
||||
}
|
||||
}
|
||||
|
||||
long seekTimeUs = queue.isEmpty() ? loadPositionUs : C.TIME_UNSET;
|
||||
out.chunk =
|
||||
newMediaChunk(
|
||||
representationHolder,
|
||||
dataSource,
|
||||
trackType,
|
||||
trackSelection.getSelectedFormat(),
|
||||
trackSelection.getSelectionReason(),
|
||||
trackSelection.getSelectionData(),
|
||||
segmentNum,
|
||||
maxSegmentCount,
|
||||
seekTimeUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldCancelLoad(
|
||||
long playbackPositionUs, Chunk loadingChunk, List<? extends MediaChunk> queue) {
|
||||
if (fatalError != null || trackSelection.length() < 2) {
|
||||
return false;
|
||||
}
|
||||
// Let the selection decide (Media3 exposes this).
|
||||
return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, (List<MediaChunk>) queue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChunkLoadCompleted(Chunk chunk) {
|
||||
// If the init chunk just finished, try to grab a parsed ChunkIndex from the extractor.
|
||||
if (chunk instanceof InitializationChunk) {
|
||||
final int trackIndex = trackSelection.indexOf(chunk.trackFormat);
|
||||
if (trackIndex != C.INDEX_UNSET) {
|
||||
RepresentationHolder holder = representationHolders[trackIndex];
|
||||
|
||||
// Don't overwrite a manifest-defined index. Only adopt stream-provided index if needed.
|
||||
if (holder.segmentIndex == null && holder.extractorWrapper != null) {
|
||||
// Media3 exposes the parsed index via ChunkExtractor.getChunkIndex() now.
|
||||
ChunkIndex chunkIndex = holder.extractorWrapper.getChunkIndex();
|
||||
if (chunkIndex != null) {
|
||||
representationHolders[trackIndex] =
|
||||
holder.copyWithNewSegmentIndex(
|
||||
new SabrWrappingSegmentIndex(
|
||||
chunkIndex,
|
||||
holder.representation.presentationTimeOffsetUs));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playerTrackEmsgHandler != null) {
|
||||
playerTrackEmsgHandler.onChunkLoadCompleted(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onChunkLoadError(
|
||||
Chunk chunk,
|
||||
boolean cancelable,
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo,
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
|
||||
if (!cancelable) return false;
|
||||
|
||||
// Manifest-driven refresh (same behavior you had before).
|
||||
if (playerTrackEmsgHandler != null && playerTrackEmsgHandler.maybeRefreshManifestOnLoadingError(chunk)) {
|
||||
return true; // cancel & re-resolve next chunk
|
||||
}
|
||||
|
||||
// Workaround for a missing last segment on VOD (404) — unchanged logic, updated signature.
|
||||
if (!manifest.dynamic
|
||||
&& chunk instanceof MediaChunk
|
||||
&& loadErrorInfo.exception instanceof InvalidResponseCodeException
|
||||
&& ((InvalidResponseCodeException) loadErrorInfo.exception).responseCode == 404) {
|
||||
RepresentationHolder holder = representationHolders[trackSelection.indexOf(chunk.trackFormat)];
|
||||
int count = holder.getSegmentCount();
|
||||
if (count != SabrSegmentIndex.INDEX_UNBOUNDED && count != 0) {
|
||||
long lastAvailable = holder.getFirstSegmentNum() + count - 1;
|
||||
if (((MediaChunk) chunk).getNextChunkIndex() > lastAvailable) {
|
||||
missingLastSegment = true;
|
||||
return true; // cancel; we’ll end the period gracefully
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modern fallback track exclusion using LoadErrorHandlingPolicy
|
||||
int excluded = 0;
|
||||
long nowMs = SystemClock.elapsedRealtime();
|
||||
for (int i = 0; i < trackSelection.length(); i++) {
|
||||
if (trackSelection.isTrackExcluded(i, nowMs)) excluded++;
|
||||
}
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions options =
|
||||
new androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions(
|
||||
/* numberOfLocations= */ 1, /* numberOfExcludedLocations= */ 0,
|
||||
/* numberOfTracks= */ trackSelection.length(), /* numberOfExcludedTracks= */ excluded);
|
||||
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackSelection sel =
|
||||
loadErrorHandlingPolicy.getFallbackSelectionFor(options, loadErrorInfo);
|
||||
|
||||
if (sel != null
|
||||
&& sel.type == androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
|
||||
int trackIdx = trackSelection.indexOf(chunk.trackFormat);
|
||||
return trackSelection.excludeTrack(trackIdx, sel.exclusionDurationMs);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private ArrayList<Representation> getRepresentations() {
|
||||
List<AdaptationSet> manifestAdaptationSets = manifest.getPeriod(periodIndex).adaptationSets;
|
||||
ArrayList<Representation> representations = new ArrayList<>();
|
||||
for (int adaptationSetIndex : adaptationSetIndices) {
|
||||
representations.addAll(manifestAdaptationSets.get(adaptationSetIndex).representations);
|
||||
}
|
||||
return representations;
|
||||
}
|
||||
|
||||
private long resolveTimeToLiveEdgeUs(long playbackPositionUs) {
|
||||
boolean resolveTimeToLiveEdgePossible = manifest.dynamic && liveEdgeTimeUs != C.TIME_UNSET;
|
||||
return resolveTimeToLiveEdgePossible ? liveEdgeTimeUs - playbackPositionUs : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
private long getNowUnixTimeUs() {
|
||||
if (elapsedRealtimeOffsetMs != 0) {
|
||||
return (SystemClock.elapsedRealtime() + elapsedRealtimeOffsetMs) * 1000;
|
||||
} else {
|
||||
return System.currentTimeMillis() * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
// Forward-looking: free extractor resources if needed.
|
||||
for (RepresentationHolder h : representationHolders) {
|
||||
if (h != null && h.extractorWrapper != null) {
|
||||
h.extractorWrapper.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long getSegmentNum(
|
||||
RepresentationHolder representationHolder,
|
||||
@Nullable MediaChunk previousChunk,
|
||||
long loadPositionUs,
|
||||
long firstAvailableSegmentNum,
|
||||
long lastAvailableSegmentNum) {
|
||||
return previousChunk != null
|
||||
? previousChunk.getNextChunkIndex()
|
||||
: Util.constrainValue(representationHolder.getSegmentNum(loadPositionUs), firstAvailableSegmentNum, lastAvailableSegmentNum);
|
||||
}
|
||||
|
||||
private void updateLiveEdgeTimeUs(
|
||||
RepresentationHolder representationHolder, long lastAvailableSegmentNum) {
|
||||
liveEdgeTimeUs = manifest.dynamic
|
||||
? representationHolder.getSegmentEndTimeUs(lastAvailableSegmentNum) : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
protected Chunk newInitializationChunk(
|
||||
RepresentationHolder representationHolder,
|
||||
DataSource dataSource,
|
||||
Format trackFormat,
|
||||
int trackSelectionReason,
|
||||
Object trackSelectionData,
|
||||
RangedUri initializationUri,
|
||||
RangedUri indexUri) {
|
||||
RangedUri requestUri;
|
||||
String baseUrl = representationHolder.representation.baseUrl;
|
||||
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, baseUrl);
|
||||
if (requestUri == null) {
|
||||
requestUri = initializationUri;
|
||||
}
|
||||
} else {
|
||||
requestUri = indexUri;
|
||||
}
|
||||
// TODO: first protobuf request (before the video start off)
|
||||
DataSpec dataSpec = new DataSpec(requestUri.resolveUri(baseUrl), requestUri.start,
|
||||
requestUri.length, representationHolder.representation.getCacheKey());
|
||||
return new InitializationChunk(dataSource, dataSpec, trackFormat,
|
||||
trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper);
|
||||
}
|
||||
|
||||
protected Chunk newMediaChunk(
|
||||
RepresentationHolder representationHolder,
|
||||
DataSource dataSource,
|
||||
int trackType,
|
||||
Format trackFormat,
|
||||
int trackSelectionReason,
|
||||
Object trackSelectionData,
|
||||
long firstSegmentNum,
|
||||
int maxSegmentCount,
|
||||
long seekTimeUs) {
|
||||
Representation representation = representationHolder.representation;
|
||||
long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum);
|
||||
RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum);
|
||||
String baseUrl = representation.baseUrl;
|
||||
if (representationHolder.extractorWrapper == null) {
|
||||
long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum);
|
||||
DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl),
|
||||
segmentUri.start, segmentUri.length, representation.getCacheKey());
|
||||
return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason,
|
||||
trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, trackType, trackFormat);
|
||||
} else {
|
||||
int segmentCount = 1;
|
||||
for (int i = 1; i < maxSegmentCount; i++) {
|
||||
RangedUri nextSegmentUri = representationHolder.getSegmentUrl(firstSegmentNum + i);
|
||||
RangedUri mergedSegmentUri = segmentUri.attemptMerge(nextSegmentUri, baseUrl);
|
||||
if (mergedSegmentUri == null) {
|
||||
// Unable to merge segment fetches because the URIs do not merge.
|
||||
break;
|
||||
}
|
||||
segmentUri = mergedSegmentUri;
|
||||
segmentCount++;
|
||||
}
|
||||
long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum + segmentCount - 1);
|
||||
long periodDurationUs = representationHolder.periodDurationUs;
|
||||
long clippedEndTimeUs =
|
||||
periodDurationUs != C.TIME_UNSET && periodDurationUs <= endTimeUs
|
||||
? periodDurationUs
|
||||
: C.TIME_UNSET;
|
||||
// TODO: next protobuf requests (during the playback)
|
||||
DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl),
|
||||
segmentUri.start, segmentUri.length, representation.getCacheKey());
|
||||
long sampleOffsetUs = -representation.presentationTimeOffsetUs;
|
||||
return new ContainerMediaChunk(
|
||||
dataSource,
|
||||
dataSpec,
|
||||
trackFormat,
|
||||
trackSelectionReason,
|
||||
trackSelectionData,
|
||||
startTimeUs,
|
||||
endTimeUs,
|
||||
seekTimeUs,
|
||||
clippedEndTimeUs,
|
||||
firstSegmentNum,
|
||||
segmentCount,
|
||||
sampleOffsetUs,
|
||||
representationHolder.extractorWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link MediaChunkIterator} wrapping a {@link RepresentationHolder}. */
|
||||
protected static final class RepresentationSegmentIterator extends BaseMediaChunkIterator {
|
||||
|
||||
private final RepresentationHolder representationHolder;
|
||||
|
||||
/**
|
||||
* Creates iterator.
|
||||
*
|
||||
* @param representation The {@link RepresentationHolder} to wrap.
|
||||
* @param firstAvailableSegmentNum The number of the first available segment.
|
||||
* @param lastAvailableSegmentNum The number of the last available segment.
|
||||
*/
|
||||
public RepresentationSegmentIterator(
|
||||
RepresentationHolder representation,
|
||||
long firstAvailableSegmentNum,
|
||||
long lastAvailableSegmentNum) {
|
||||
super(/* fromIndex= */ firstAvailableSegmentNum, /* toIndex= */ lastAvailableSegmentNum);
|
||||
this.representationHolder = representation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSpec getDataSpec() {
|
||||
checkInBounds();
|
||||
Representation representation = representationHolder.representation;
|
||||
RangedUri segmentUri = representationHolder.getSegmentUrl(getCurrentIndex());
|
||||
Uri resolvedUri = segmentUri.resolveUri(representation.baseUrl);
|
||||
String cacheKey = representation.getCacheKey();
|
||||
return new DataSpec(resolvedUri, segmentUri.start, segmentUri.length, cacheKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getChunkStartTimeUs() {
|
||||
checkInBounds();
|
||||
return representationHolder.getSegmentStartTimeUs(getCurrentIndex());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getChunkEndTimeUs() {
|
||||
checkInBounds();
|
||||
return representationHolder.getSegmentEndTimeUs(getCurrentIndex());
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds information about a snapshot of a single {@link Representation}. */
|
||||
protected static final class RepresentationHolder {
|
||||
|
||||
/* package */ final @Nullable ChunkExtractor extractorWrapper;
|
||||
|
||||
public final Representation representation;
|
||||
public final @Nullable SabrSegmentIndex segmentIndex;
|
||||
|
||||
private final long periodDurationUs;
|
||||
private final long segmentNumShift;
|
||||
|
||||
/* package */ RepresentationHolder(
|
||||
long periodDurationUs,
|
||||
int trackType,
|
||||
Representation representation,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
TrackOutput playerEmsgTrackOutput) {
|
||||
this(
|
||||
periodDurationUs,
|
||||
representation,
|
||||
createExtractorWrapper(
|
||||
trackType,
|
||||
representation,
|
||||
enableEventMessageTrack,
|
||||
closedCaptionFormats,
|
||||
playerEmsgTrackOutput),
|
||||
/* segmentNumShift= */ 0,
|
||||
representation.getIndex());
|
||||
}
|
||||
|
||||
private RepresentationHolder(
|
||||
long periodDurationUs,
|
||||
Representation representation,
|
||||
@Nullable ChunkExtractor extractorWrapper,
|
||||
long segmentNumShift,
|
||||
@Nullable SabrSegmentIndex segmentIndex) {
|
||||
this.periodDurationUs = periodDurationUs;
|
||||
this.representation = representation;
|
||||
this.segmentNumShift = segmentNumShift;
|
||||
this.extractorWrapper = extractorWrapper;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
/* package */ RepresentationHolder copyWithNewRepresentation(
|
||||
long newPeriodDurationUs, Representation newRepresentation)
|
||||
throws BehindLiveWindowException {
|
||||
SabrSegmentIndex oldIndex = representation.getIndex();
|
||||
SabrSegmentIndex newIndex = newRepresentation.getIndex();
|
||||
|
||||
if (oldIndex == null) {
|
||||
// Segment numbers cannot shift if the index isn't defined by the manifest.
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, oldIndex);
|
||||
}
|
||||
|
||||
if (!oldIndex.isExplicit()) {
|
||||
// Segment numbers cannot shift if the index isn't explicit.
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex);
|
||||
}
|
||||
|
||||
int oldIndexSegmentCount = oldIndex.getSegmentCount(newPeriodDurationUs);
|
||||
if (oldIndexSegmentCount == 0) {
|
||||
// Segment numbers cannot shift if the old index was empty.
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex);
|
||||
}
|
||||
|
||||
long oldIndexFirstSegmentNum = oldIndex.getFirstSegmentNum();
|
||||
long oldIndexStartTimeUs = oldIndex.getTimeUs(oldIndexFirstSegmentNum);
|
||||
long oldIndexLastSegmentNum = oldIndexFirstSegmentNum + oldIndexSegmentCount - 1;
|
||||
long oldIndexEndTimeUs =
|
||||
oldIndex.getTimeUs(oldIndexLastSegmentNum)
|
||||
+ oldIndex.getDurationUs(oldIndexLastSegmentNum, newPeriodDurationUs);
|
||||
long newIndexFirstSegmentNum = newIndex.getFirstSegmentNum();
|
||||
long newIndexStartTimeUs = newIndex.getTimeUs(newIndexFirstSegmentNum);
|
||||
long newSegmentNumShift = segmentNumShift;
|
||||
if (oldIndexEndTimeUs == newIndexStartTimeUs) {
|
||||
// The new index continues where the old one ended, with no overlap.
|
||||
newSegmentNumShift += oldIndexLastSegmentNum + 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 if (newIndexStartTimeUs < oldIndexStartTimeUs) {
|
||||
// The new index overlaps with (but does not have a start position contained within) the old
|
||||
// index. This can only happen if extra segments have been added to the start of the index.
|
||||
newSegmentNumShift -=
|
||||
newIndex.getSegmentNum(oldIndexStartTimeUs, newPeriodDurationUs)
|
||||
- oldIndexFirstSegmentNum;
|
||||
} else {
|
||||
// The new index overlaps with (and has a start position contained within) the old index.
|
||||
newSegmentNumShift +=
|
||||
oldIndex.getSegmentNum(newIndexStartTimeUs, newPeriodDurationUs)
|
||||
- newIndexFirstSegmentNum;
|
||||
}
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, newSegmentNumShift, newIndex);
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
/* package */ RepresentationHolder copyWithNewSegmentIndex(SabrSegmentIndex segmentIndex) {
|
||||
return new RepresentationHolder(
|
||||
periodDurationUs, representation, extractorWrapper, segmentNumShift, segmentIndex);
|
||||
}
|
||||
|
||||
public long getFirstSegmentNum() {
|
||||
return segmentIndex.getFirstSegmentNum() + segmentNumShift;
|
||||
}
|
||||
|
||||
public int getSegmentCount() {
|
||||
return segmentIndex.getSegmentCount(periodDurationUs);
|
||||
}
|
||||
|
||||
public long getSegmentStartTimeUs(long segmentNum) {
|
||||
return segmentIndex.getTimeUs(segmentNum - segmentNumShift);
|
||||
}
|
||||
|
||||
public long getSegmentEndTimeUs(long segmentNum) {
|
||||
return getSegmentStartTimeUs(segmentNum)
|
||||
+ segmentIndex.getDurationUs(segmentNum - segmentNumShift, periodDurationUs);
|
||||
}
|
||||
|
||||
public long getSegmentNum(long positionUs) {
|
||||
return segmentIndex.getSegmentNum(positionUs, periodDurationUs) + segmentNumShift;
|
||||
}
|
||||
|
||||
public RangedUri getSegmentUrl(long segmentNum) {
|
||||
return segmentIndex.getSegmentUrl(segmentNum - segmentNumShift);
|
||||
}
|
||||
|
||||
public long getFirstAvailableSegmentNum(
|
||||
SabrManifest manifest, int periodIndex, long nowUnixTimeUs) {
|
||||
if (getSegmentCount() == SabrSegmentIndex.INDEX_UNBOUNDED
|
||||
&& manifest.timeShiftBufferDepthMs != C.TIME_UNSET) {
|
||||
// The index is itself unbounded. We need to use the current time to calculate the range of
|
||||
// available segments.
|
||||
long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs);
|
||||
long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs);
|
||||
long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs;
|
||||
long bufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs);
|
||||
return Math.max(
|
||||
getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs));
|
||||
}
|
||||
return getFirstSegmentNum();
|
||||
}
|
||||
|
||||
public long getLastAvailableSegmentNum(
|
||||
SabrManifest manifest, int periodIndex, long nowUnixTimeUs) {
|
||||
int availableSegmentCount = getSegmentCount();
|
||||
if (availableSegmentCount == SabrSegmentIndex.INDEX_UNBOUNDED) {
|
||||
// The index is itself unbounded. We need to use the current time to calculate the range of
|
||||
// available segments.
|
||||
long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs);
|
||||
long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs);
|
||||
long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs;
|
||||
// getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet, so subtract one to get
|
||||
// the index of the last completed segment.
|
||||
return getSegmentNum(liveEdgeTimeInPeriodUs) - 1;
|
||||
}
|
||||
return getFirstSegmentNum() + availableSegmentCount - 1;
|
||||
}
|
||||
|
||||
private static boolean mimeTypeIsWebm(String mimeType) {
|
||||
return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM)
|
||||
|| mimeType.startsWith(MimeTypes.APPLICATION_WEBM);
|
||||
}
|
||||
|
||||
private static boolean mimeTypeIsRawText(String mimeType) {
|
||||
return MimeTypes.isText(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType);
|
||||
}
|
||||
|
||||
|
||||
private static @Nullable ChunkExtractor createExtractorWrapper(
|
||||
int trackType,
|
||||
Representation representation,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
TrackOutput playerEmsgTrackOutput) {
|
||||
|
||||
String containerMimeType = representation.format.containerMimeType;
|
||||
if (mimeTypeIsRawText(containerMimeType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Extractor extractor = new SabrExtractor(trackType, representation.format);
|
||||
return new BundledChunkExtractor(extractor, trackType, representation.format);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.FormatHolder;
|
||||
import androidx.media3.decoder.DecoderInputBuffer;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessageEncoder;
|
||||
import androidx.media3.exoplayer.source.SampleStream;
|
||||
import com.futo.platformplayer.sabr.manifest.EventStream;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* A {@link SampleStream} consisting of serialized {@link EventMessage}s read from an
|
||||
* {@link EventStream}.
|
||||
*/
|
||||
@UnstableApi
|
||||
/* package */ final class EventSampleStream implements SampleStream {
|
||||
|
||||
private final Format upstreamFormat;
|
||||
private final EventMessageEncoder eventMessageEncoder;
|
||||
|
||||
private long[] eventTimesUs;
|
||||
private boolean eventStreamAppendable;
|
||||
private EventStream eventStream;
|
||||
|
||||
private boolean isFormatSentDownstream;
|
||||
private int currentIndex;
|
||||
private long pendingSeekPositionUs;
|
||||
|
||||
public EventSampleStream(
|
||||
EventStream eventStream, Format upstreamFormat, boolean eventStreamAppendable) {
|
||||
this.upstreamFormat = upstreamFormat;
|
||||
this.eventStream = eventStream;
|
||||
eventMessageEncoder = new EventMessageEncoder();
|
||||
pendingSeekPositionUs = C.TIME_UNSET;
|
||||
eventTimesUs = eventStream.presentationTimesUs;
|
||||
updateEventStream(eventStream, eventStreamAppendable);
|
||||
}
|
||||
|
||||
public String eventStreamId() {
|
||||
return eventStream.id();
|
||||
}
|
||||
|
||||
public void updateEventStream(EventStream eventStream, boolean eventStreamAppendable) {
|
||||
long lastReadPositionUs = currentIndex == 0 ? C.TIME_UNSET : eventTimesUs[currentIndex - 1];
|
||||
|
||||
this.eventStreamAppendable = eventStreamAppendable;
|
||||
this.eventStream = eventStream;
|
||||
this.eventTimesUs = eventStream.presentationTimesUs;
|
||||
if (pendingSeekPositionUs != C.TIME_UNSET) {
|
||||
seekToUs(pendingSeekPositionUs);
|
||||
} else if (lastReadPositionUs != C.TIME_UNSET) {
|
||||
currentIndex =
|
||||
Util.binarySearchCeil(
|
||||
eventTimesUs, lastReadPositionUs, /* inclusive= */ false, /* stayInBounds= */ false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks to the specified position in microseconds.
|
||||
*
|
||||
* @param positionUs The seek position in microseconds.
|
||||
*/
|
||||
public void seekToUs(long positionUs) {
|
||||
currentIndex =
|
||||
Util.binarySearchCeil(
|
||||
eventTimesUs, positionUs, /* inclusive= */ true, /* stayInBounds= */ false);
|
||||
boolean isPendingSeek = eventStreamAppendable && currentIndex == eventTimesUs.length;
|
||||
pendingSeekPositionUs = isPendingSeek ? positionUs : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowError() throws IOException {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readData(
|
||||
FormatHolder formatHolder,
|
||||
DecoderInputBuffer buffer,
|
||||
@SampleStream.ReadFlags int readFlags) {
|
||||
|
||||
final boolean requireFormat = (readFlags & SampleStream.FLAG_REQUIRE_FORMAT) != 0;
|
||||
final boolean omitSampleData = (readFlags & SampleStream.FLAG_OMIT_SAMPLE_DATA) != 0;
|
||||
|
||||
if (requireFormat || !isFormatSentDownstream) {
|
||||
formatHolder.format = upstreamFormat;
|
||||
isFormatSentDownstream = true;
|
||||
return C.RESULT_FORMAT_READ;
|
||||
}
|
||||
|
||||
if (currentIndex == eventTimesUs.length) {
|
||||
if (!eventStreamAppendable) {
|
||||
buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
|
||||
return C.RESULT_BUFFER_READ;
|
||||
} else {
|
||||
return C.RESULT_NOTHING_READ;
|
||||
}
|
||||
}
|
||||
|
||||
final int sampleIndex = currentIndex++;
|
||||
final byte[] serializedEvent = eventMessageEncoder.encode(eventStream.events[sampleIndex]);
|
||||
if (serializedEvent == null) {
|
||||
return C.RESULT_NOTHING_READ;
|
||||
}
|
||||
|
||||
buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME);
|
||||
buffer.timeUs = eventTimesUs[sampleIndex];
|
||||
|
||||
if (!omitSampleData) {
|
||||
buffer.ensureSpaceForWrite(serializedEvent.length);
|
||||
buffer.data.put(serializedEvent);
|
||||
}
|
||||
|
||||
return C.RESULT_BUFFER_READ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int skipData(long positionUs) {
|
||||
int newIndex =
|
||||
Math.max(currentIndex, Util.binarySearchCeil(eventTimesUs, positionUs, true, false));
|
||||
int skipped = newIndex - currentIndex;
|
||||
currentIndex = newIndex;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
class NullQueryString implements UrlQueryString {
|
||||
private final String mUrl;
|
||||
|
||||
private NullQueryString(String url) {
|
||||
mUrl = url;
|
||||
}
|
||||
|
||||
public static UrlQueryString parse(String url) {
|
||||
return new NullQueryString(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return mUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Example: http://myurl.com/key1/value1/key2/value2/key3/value3<br/>
|
||||
* Should contain at least one key/value pair: http://myurl.com/key/value/<br/>
|
||||
* Regex: \/key\/([^\/]*)
|
||||
*/
|
||||
class PathQueryString implements UrlQueryString {
|
||||
private static final Pattern VALIDATION_PATTERN = Pattern.compile("\\/[^\\/]+\\/[^\\/]+\\/[^\\/]+");
|
||||
private static final Pattern ENDING_PATTERN = Pattern.compile("\\?.*");
|
||||
private String mUrl;
|
||||
|
||||
public static String replace(String content, Pattern oldVal, String newVal) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return oldVal.matcher(content).replaceFirst(newVal);
|
||||
}
|
||||
|
||||
public PathQueryString(String url) {
|
||||
mUrl = replace(url, ENDING_PATTERN, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
if (mUrl == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String template = "\\/%s\\/([^\\/]*)";
|
||||
Pattern pattern = Pattern.compile(String.format(template, key));
|
||||
Matcher matcher = pattern.matcher(mUrl);
|
||||
boolean result = matcher.find();
|
||||
return result ? matcher.group(1) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
String val = get(key);
|
||||
return val != null ? Float.parseFloat(val) : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
if (mUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!replace(key, value)) {
|
||||
String pattern = mUrl.endsWith("/") ? "%s/%s" : "/%s/%s";
|
||||
mUrl += String.format(pattern, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
private boolean replace(String key, String newValue) {
|
||||
if (mUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String originUrl = mUrl;
|
||||
|
||||
final String template = "\\/%s\\/[^\\/]*";
|
||||
mUrl = mUrl.replaceAll(
|
||||
String.format(template, key),
|
||||
String.format("\\/%s\\/%s", key, newValue));
|
||||
|
||||
return !mUrl.equals(originUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
if (mUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String template = "\\/%s\\/[^\\/]*";
|
||||
mUrl = mUrl.replaceAll(String.format(template, key), "");
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return mUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return mUrl == null || mUrl.isEmpty();
|
||||
}
|
||||
|
||||
public static PathQueryString parse(String url) {
|
||||
return new PathQueryString(url);
|
||||
}
|
||||
|
||||
public static boolean matchAll(String input, Pattern... patterns) {
|
||||
for (Pattern pattern : patterns) {
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
if (mUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchAll(mUrl, VALIDATION_PATTERN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return get(key) != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.DataReader;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.FormatHolder;
|
||||
import androidx.media3.common.ParserException;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import androidx.media3.extractor.TrackOutput;
|
||||
import androidx.media3.common.Metadata;
|
||||
import androidx.media3.extractor.metadata.MetadataInputBuffer;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessageDecoder;
|
||||
import androidx.media3.exoplayer.source.SampleQueue;
|
||||
import androidx.media3.exoplayer.source.chunk.Chunk;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.common.util.ParsableByteArray;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.TreeMap;
|
||||
|
||||
@UnstableApi
|
||||
public final class PlayerEmsgHandler implements Handler.Callback {
|
||||
/** Callbacks for player emsg events encountered during DASH live stream. */
|
||||
public interface PlayerEmsgCallback {
|
||||
|
||||
/** Called when the current manifest should be refreshed. */
|
||||
void onDashManifestRefreshRequested();
|
||||
|
||||
/**
|
||||
* Called when the manifest with the publish time has been expired.
|
||||
*
|
||||
* @param expiredManifestPublishTimeUs The manifest publish time that has been expired.
|
||||
*/
|
||||
void onDashManifestPublishTimeExpired(long expiredManifestPublishTimeUs);
|
||||
}
|
||||
|
||||
private final Allocator allocator;
|
||||
private final PlayerEmsgCallback playerEmsgCallback;
|
||||
private final EventMessageDecoder decoder;
|
||||
private SabrManifest manifest;
|
||||
private final Handler handler;
|
||||
private final TreeMap<Long, Long> manifestPublishTimeToExpiryTimeUs;
|
||||
|
||||
private long expiredManifestPublishTimeUs;
|
||||
private long lastLoadedChunkEndTimeUs;
|
||||
private long lastLoadedChunkEndTimeBeforeRefreshUs;
|
||||
private boolean isWaitingForManifestRefresh;
|
||||
private boolean released;
|
||||
|
||||
/**
|
||||
* @param manifest The initial manifest.
|
||||
* @param playerEmsgCallback The callback that this event handler can invoke when handling emsg
|
||||
* messages that generate DASH media source events.
|
||||
* @param allocator An {@link Allocator} from which allocations can be obtained.
|
||||
*/
|
||||
public PlayerEmsgHandler(
|
||||
SabrManifest manifest, PlayerEmsgCallback playerEmsgCallback, Allocator allocator) {
|
||||
this.manifest = manifest;
|
||||
this.playerEmsgCallback = playerEmsgCallback;
|
||||
this.allocator = allocator;
|
||||
|
||||
manifestPublishTimeToExpiryTimeUs = new TreeMap<>();
|
||||
handler = Util.createHandlerForCurrentLooper(/* callback= */ this);
|
||||
decoder = new EventMessageDecoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the {@link SabrManifest} that this handler works on.
|
||||
*
|
||||
* @param newManifest The updated manifest.
|
||||
*/
|
||||
public void updateManifest(SabrManifest newManifest) {
|
||||
isWaitingForManifestRefresh = false;
|
||||
expiredManifestPublishTimeUs = C.TIME_UNSET;
|
||||
this.manifest = newManifest;
|
||||
removePreviouslyExpiredManifestPublishTimeValues();
|
||||
}
|
||||
|
||||
/* package */ boolean maybeRefreshManifestBeforeLoadingNextChunk(long presentationPositionUs) {
|
||||
if (!manifest.dynamic) {
|
||||
return false;
|
||||
}
|
||||
if (isWaitingForManifestRefresh) {
|
||||
return true;
|
||||
}
|
||||
boolean manifestRefreshNeeded = false;
|
||||
// Find the smallest publishTime (greater than or equal to the current manifest's publish time)
|
||||
// that has a corresponding expiry time.
|
||||
Map.Entry<Long, Long> expiredEntry = ceilingExpiryEntryForPublishTime(manifest.publishTimeMs);
|
||||
if (expiredEntry != null) {
|
||||
long expiredPointUs = expiredEntry.getValue();
|
||||
if (expiredPointUs < presentationPositionUs) {
|
||||
expiredManifestPublishTimeUs = expiredEntry.getKey();
|
||||
notifyManifestPublishTimeExpired();
|
||||
manifestRefreshNeeded = true;
|
||||
}
|
||||
}
|
||||
if (manifestRefreshNeeded) {
|
||||
maybeNotifyDashManifestRefreshNeeded();
|
||||
}
|
||||
return manifestRefreshNeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* For live streaming with emsg event stream, forward seeking can seek pass the emsg messages that
|
||||
* signals end-of-stream or Manifest expiry, which results in load error. In this case, we should
|
||||
* notify the Dash media source to refresh its manifest.
|
||||
*
|
||||
* @param chunk The chunk whose load encountered the error.
|
||||
* @return True if manifest refresh has been requested, false otherwise.
|
||||
*/
|
||||
/* package */ boolean maybeRefreshManifestOnLoadingError(Chunk chunk) {
|
||||
if (!manifest.dynamic) {
|
||||
return false;
|
||||
}
|
||||
if (isWaitingForManifestRefresh) {
|
||||
return true;
|
||||
}
|
||||
boolean isAfterForwardSeek =
|
||||
lastLoadedChunkEndTimeUs != C.TIME_UNSET && lastLoadedChunkEndTimeUs < chunk.startTimeUs;
|
||||
if (isAfterForwardSeek) {
|
||||
// if we are after a forward seek, and the playback is dynamic with embedded emsg stream,
|
||||
// there's a chance that we have seek over the emsg messages, in which case we should ask
|
||||
// media source for a refresh.
|
||||
maybeNotifyDashManifestRefreshNeeded();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the a new chunk in the current media stream has been loaded.
|
||||
*
|
||||
* @param chunk The chunk whose load has been completed.
|
||||
*/
|
||||
/* package */ void onChunkLoadCompleted(Chunk chunk) {
|
||||
if (lastLoadedChunkEndTimeUs == C.TIME_UNSET || chunk.endTimeUs > lastLoadedChunkEndTimeUs) {
|
||||
lastLoadedChunkEndTimeUs = chunk.endTimeUs;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable Map.Entry<Long, Long> ceilingExpiryEntryForPublishTime(long publishTimeMs) {
|
||||
return manifestPublishTimeToExpiryTimeUs.ceilingEntry(publishTimeMs);
|
||||
}
|
||||
|
||||
private void removePreviouslyExpiredManifestPublishTimeValues() {
|
||||
for (Iterator<Entry<Long, Long>> it =
|
||||
manifestPublishTimeToExpiryTimeUs.entrySet().iterator();
|
||||
it.hasNext(); ) {
|
||||
Map.Entry<Long, Long> entry = it.next();
|
||||
long expiredManifestPublishTime = entry.getKey();
|
||||
if (expiredManifestPublishTime < manifest.publishTimeMs) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyManifestPublishTimeExpired() {
|
||||
playerEmsgCallback.onDashManifestPublishTimeExpired(expiredManifestPublishTimeUs);
|
||||
}
|
||||
|
||||
/** Requests DASH media manifest to be refreshed if necessary. */
|
||||
private void maybeNotifyDashManifestRefreshNeeded() {
|
||||
if (lastLoadedChunkEndTimeBeforeRefreshUs != C.TIME_UNSET
|
||||
&& lastLoadedChunkEndTimeBeforeRefreshUs == lastLoadedChunkEndTimeUs) {
|
||||
// Already requested manifest refresh.
|
||||
return;
|
||||
}
|
||||
isWaitingForManifestRefresh = true;
|
||||
lastLoadedChunkEndTimeBeforeRefreshUs = lastLoadedChunkEndTimeUs;
|
||||
playerEmsgCallback.onDashManifestRefreshRequested();
|
||||
}
|
||||
|
||||
/** Returns a {@link TrackOutput} that emsg messages could be written to. */
|
||||
public PlayerTrackEmsgHandler newPlayerTrackEmsgHandler() {
|
||||
return new PlayerTrackEmsgHandler(SampleQueue.createWithoutDrm(allocator));
|
||||
}
|
||||
|
||||
/** Release this emsg handler. It should not be reused after this call. */
|
||||
public void release() {
|
||||
released = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleMessage(Message message) {
|
||||
if (released) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an event with given schemeIdUri and value is a DASH emsg event targeting the
|
||||
* player.
|
||||
*/
|
||||
public static boolean isPlayerEmsgEvent(String schemeIdUri, String value) {
|
||||
return "urn:mpeg:sabr:event:2025".equals(schemeIdUri)
|
||||
&& ("1".equals(value) || "2".equals(value) || "3".equals(value));
|
||||
}
|
||||
|
||||
/** Handles emsg messages for a specific track for the player. */
|
||||
public final class PlayerTrackEmsgHandler implements TrackOutput {
|
||||
private final SampleQueue sampleQueue;
|
||||
private final FormatHolder formatHolder;
|
||||
private final MetadataInputBuffer buffer;
|
||||
|
||||
private long maxLoadedChunkEndTimeUs;
|
||||
|
||||
public PlayerTrackEmsgHandler(SampleQueue sampleQueue) {
|
||||
this.sampleQueue = sampleQueue;
|
||||
this.formatHolder = new FormatHolder();
|
||||
this.buffer = new MetadataInputBuffer();
|
||||
this.maxLoadedChunkEndTimeUs = C.TIME_UNSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void format(Format format) {
|
||||
sampleQueue.format(format);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int sampleData(
|
||||
DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
|
||||
throws IOException {
|
||||
return sampleQueue.sampleData(input, length, allowEndOfInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
|
||||
sampleQueue.sampleData(data, length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sampleMetadata(
|
||||
long timeUs, int flags, int size, int offset, @Nullable CryptoData encryptionData) {
|
||||
sampleQueue.sampleMetadata(timeUs, flags, size, offset, encryptionData);
|
||||
parseAndDiscardSamples();
|
||||
}
|
||||
|
||||
/** For live streaming: check expiry before loading the next chunk. */
|
||||
public boolean maybeRefreshManifestBeforeLoadingNextChunk(long presentationPositionUs) {
|
||||
return PlayerEmsgHandler.this.maybeRefreshManifestBeforeLoadingNextChunk(presentationPositionUs);
|
||||
}
|
||||
|
||||
/** Called when a new chunk finished loading. */
|
||||
public void onChunkLoadCompleted(Chunk chunk) {
|
||||
if (maxLoadedChunkEndTimeUs == C.TIME_UNSET || chunk.endTimeUs > maxLoadedChunkEndTimeUs) {
|
||||
maxLoadedChunkEndTimeUs = chunk.endTimeUs;
|
||||
}
|
||||
PlayerEmsgHandler.this.onChunkLoadCompleted(chunk);
|
||||
}
|
||||
|
||||
/** Called when a chunk load errored; may trigger a manifest refresh. */
|
||||
public boolean maybeRefreshManifestOnLoadingError(Chunk chunk) {
|
||||
return PlayerEmsgHandler.this.maybeRefreshManifestOnLoadingError(chunk);
|
||||
}
|
||||
|
||||
/** Release this track emsg handler. It should not be reused after this call. */
|
||||
public void release() {
|
||||
sampleQueue.release();
|
||||
}
|
||||
|
||||
private void parseAndDiscardSamples() {
|
||||
while (sampleQueue.isReady(/* loadingFinished= */ false)) {
|
||||
MetadataInputBuffer inputBuffer = dequeueSample();
|
||||
if (inputBuffer == null) {
|
||||
continue;
|
||||
}
|
||||
long eventTimeUs = inputBuffer.timeUs;
|
||||
Metadata metadata = decoder.decode(inputBuffer);
|
||||
if (metadata == null) {
|
||||
continue;
|
||||
}
|
||||
EventMessage eventMessage = (EventMessage) metadata.get(0);
|
||||
if (isPlayerEmsgEvent(eventMessage.schemeIdUri, eventMessage.value)) {
|
||||
parsePlayerEmsgEvent(eventTimeUs, eventMessage);
|
||||
}
|
||||
}
|
||||
sampleQueue.discardToRead();
|
||||
}
|
||||
|
||||
private void parsePlayerEmsgEvent(long eventTimeUs, EventMessage eventMessage) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private MetadataInputBuffer dequeueSample() {
|
||||
buffer.clear();
|
||||
int result = sampleQueue.read(
|
||||
formatHolder, buffer, /* readFlags= */ 0, /* loadingFinished= */ false);
|
||||
if (result == C.RESULT_BUFFER_READ) {
|
||||
buffer.flip();
|
||||
return buffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds information related to a manifest expiry event. */
|
||||
private static final class ManifestExpiryEventInfo {
|
||||
|
||||
public final long eventTimeUs;
|
||||
public final long manifestPublishTimeMsInEmsg;
|
||||
|
||||
public ManifestExpiryEventInfo(long eventTimeUs, long manifestPublishTimeMsInEmsg) {
|
||||
this.eventTimeUs = eventTimeUs;
|
||||
this.manifestPublishTimeMsInEmsg = manifestPublishTimeMsInEmsg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr;
|
||||
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkSource;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An {@link ChunkSource} for DASH streams.
|
||||
*/
|
||||
@UnstableApi
|
||||
public interface SabrChunkSource extends ChunkSource {
|
||||
|
||||
/** Factory for {@link SabrChunkSource}s. */
|
||||
interface Factory {
|
||||
|
||||
/**
|
||||
* @param manifestLoaderErrorThrower Throws errors affecting loading of manifests.
|
||||
* @param manifest The initial manifest.
|
||||
* @param periodIndex The index of the corresponding period in the manifest.
|
||||
* @param adaptationSetIndices The indices of the corresponding adaptation sets in the period.
|
||||
* @param trackSelection The track selection.
|
||||
* @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. If unknown, set to 0.
|
||||
* @param enableEventMessageTrack Whether to output an event message track.
|
||||
* @param closedCaptionFormats The {@link Format Formats} of closed caption tracks to be output.
|
||||
* @param transferListener The transfer listener which should be informed of any data transfers.
|
||||
* May be null if no listener is available.
|
||||
* @return The created {@link SabrChunkSource}.
|
||||
*/
|
||||
SabrChunkSource createSabrChunkSource(
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
int[] adaptationSetIndices,
|
||||
ExoTrackSelection trackSelection,
|
||||
int type,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
@Nullable PlayerTrackEmsgHandler playerEmsgHandler,
|
||||
@Nullable TransferListener transferListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the manifest.
|
||||
*
|
||||
* @param newManifest The new manifest.
|
||||
*/
|
||||
void updateManifest(SabrManifest newManifest, int periodIndex);
|
||||
|
||||
/**
|
||||
* Updates the track selection.
|
||||
*
|
||||
* @param trackSelection The new track selection instance. Must be equivalent to the previous one.
|
||||
*/
|
||||
void updateTrackSelection(ExoTrackSelection trackSelection);
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.util.Pair;
|
||||
import android.util.SparseIntArray;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.SeekParameters;
|
||||
import androidx.media3.exoplayer.source.CompositeSequenceableLoaderFactory;
|
||||
import androidx.media3.exoplayer.source.EmptySampleStream;
|
||||
import androidx.media3.exoplayer.source.MediaPeriod;
|
||||
import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher;
|
||||
import androidx.media3.exoplayer.source.SampleStream;
|
||||
import androidx.media3.exoplayer.source.SequenceableLoader;
|
||||
import androidx.media3.common.TrackGroup;
|
||||
import androidx.media3.exoplayer.source.TrackGroupArray;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkSampleStream;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkSampleStream.EmbeddedSampleStream;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerEmsgCallback;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
|
||||
import com.futo.platformplayer.sabr.SabrChunkSource.Factory;
|
||||
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
|
||||
import com.futo.platformplayer.sabr.manifest.EventStream;
|
||||
import com.futo.platformplayer.sabr.manifest.Period;
|
||||
import com.futo.platformplayer.sabr.manifest.Representation;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
@UnstableApi
|
||||
final class SabrMediaPeriod implements MediaPeriod, SequenceableLoader.Callback<ChunkSampleStream<SabrChunkSource>>, ChunkSampleStream.ReleaseCallback<SabrChunkSource> {
|
||||
/* package */ final int id;
|
||||
private final Factory chunkSourceFactory;
|
||||
@Nullable
|
||||
private final TransferListener transferListener;
|
||||
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||
private final EventDispatcher eventDispatcher;
|
||||
private final long elapsedRealtimeOffsetMs;
|
||||
private final LoaderErrorThrower manifestLoaderErrorThrower;
|
||||
private final TrackGroupArray trackGroups;
|
||||
private final TrackGroupInfo[] trackGroupInfos;
|
||||
private final Allocator allocator;
|
||||
private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
|
||||
private final PlayerEmsgHandler playerEmsgHandler;
|
||||
private final IdentityHashMap<ChunkSampleStream<SabrChunkSource>, PlayerTrackEmsgHandler>
|
||||
trackEmsgHandlerBySampleStream;
|
||||
|
||||
private @Nullable Callback callback;
|
||||
private ChunkSampleStream<SabrChunkSource>[] sampleStreams;
|
||||
private SequenceableLoader compositeSequenceableLoader;
|
||||
private EventSampleStream[] eventSampleStreams;
|
||||
private SabrManifest manifest;
|
||||
private int periodIndex;
|
||||
private List<EventStream> eventStreams;
|
||||
private boolean notifiedReadingStarted;
|
||||
|
||||
public SabrMediaPeriod(
|
||||
int id,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
SabrChunkSource.Factory chunkSourceFactory,
|
||||
@Nullable TransferListener transferListener,
|
||||
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
|
||||
EventDispatcher eventDispatcher,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
Allocator allocator,
|
||||
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
|
||||
PlayerEmsgCallback playerEmsgCallback) {
|
||||
this.id = id;
|
||||
this.manifest = manifest;
|
||||
this.periodIndex = periodIndex;
|
||||
this.chunkSourceFactory = chunkSourceFactory;
|
||||
this.transferListener = transferListener;
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||
this.eventDispatcher = eventDispatcher;
|
||||
this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs;
|
||||
this.manifestLoaderErrorThrower = manifestLoaderErrorThrower;
|
||||
this.allocator = allocator;
|
||||
this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
|
||||
playerEmsgHandler = new PlayerEmsgHandler(manifest, playerEmsgCallback, allocator);
|
||||
sampleStreams = newSampleStreamArray(0);
|
||||
eventSampleStreams = new EventSampleStream[0];
|
||||
trackEmsgHandlerBySampleStream = new IdentityHashMap<>();
|
||||
compositeSequenceableLoader =
|
||||
compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(sampleStreams);
|
||||
Period period = manifest.getPeriod(periodIndex);
|
||||
Pair<TrackGroupArray, TrackGroupInfo[]> result = buildTrackGroups(period.adaptationSets);
|
||||
trackGroups = result.first;
|
||||
trackGroupInfos = result.second;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepare(Callback callback, long positionUs) {
|
||||
this.callback = callback;
|
||||
callback.onPrepared(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowPrepareError() throws IOException {
|
||||
manifestLoaderErrorThrower.maybeThrowError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrackGroupArray getTrackGroups() {
|
||||
return trackGroups;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long selectTracks(
|
||||
@Nullable ExoTrackSelection[] selections,
|
||||
boolean[] mayRetainStreamFlags,
|
||||
@Nullable SampleStream[] streams,
|
||||
boolean[] streamResetFlags,
|
||||
long positionUs) {
|
||||
int[] streamIndexToTrackGroupIndex = getStreamIndexToTrackGroupIndex(selections);
|
||||
releaseDisabledStreams(selections, mayRetainStreamFlags, streams);
|
||||
releaseOrphanEmbeddedStreams(selections, streams, streamIndexToTrackGroupIndex);
|
||||
selectNewStreams(
|
||||
selections, streams, streamResetFlags, positionUs, streamIndexToTrackGroupIndex);
|
||||
|
||||
ArrayList<ChunkSampleStream<SabrChunkSource>> sampleStreamList = new ArrayList<>();
|
||||
ArrayList<EventSampleStream> eventSampleStreamList = new ArrayList<>();
|
||||
for (SampleStream sampleStream : streams) {
|
||||
if (sampleStream instanceof ChunkSampleStream) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ChunkSampleStream<SabrChunkSource> stream =
|
||||
(ChunkSampleStream<SabrChunkSource>) sampleStream;
|
||||
sampleStreamList.add(stream);
|
||||
} else if (sampleStream instanceof EventSampleStream) {
|
||||
eventSampleStreamList.add((EventSampleStream) sampleStream);
|
||||
}
|
||||
}
|
||||
sampleStreams = newSampleStreamArray(sampleStreamList.size());
|
||||
sampleStreamList.toArray(sampleStreams);
|
||||
eventSampleStreams = new EventSampleStream[eventSampleStreamList.size()];
|
||||
eventSampleStreamList.toArray(eventSampleStreams);
|
||||
|
||||
compositeSequenceableLoader =
|
||||
compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(sampleStreams);
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void discardBuffer(long positionUs, boolean toKeyframe) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.discardBuffer(positionUs, toKeyframe);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long readDiscontinuity() {
|
||||
if (!notifiedReadingStarted) {
|
||||
eventDispatcher.readingStarted();
|
||||
notifiedReadingStarted = true;
|
||||
}
|
||||
return C.TIME_UNSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long seekToUs(long positionUs) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.seekToUs(positionUs);
|
||||
}
|
||||
for (EventSampleStream sampleStream : eventSampleStreams) {
|
||||
sampleStream.seekToUs(positionUs);
|
||||
}
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
if (sampleStream.primaryTrackType == C.TRACK_TYPE_VIDEO) {
|
||||
return sampleStream.getAdjustedSeekPositionUs(positionUs, seekParameters);
|
||||
}
|
||||
}
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBufferedPositionUs() {
|
||||
return compositeSequenceableLoader.getBufferedPositionUs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextLoadPositionUs() {
|
||||
return compositeSequenceableLoader.getNextLoadPositionUs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean continueLoading(long positionUs) {
|
||||
return compositeSequenceableLoader.continueLoading(positionUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reevaluateBuffer(long positionUs) {
|
||||
compositeSequenceableLoader.reevaluateBuffer(positionUs);
|
||||
}
|
||||
|
||||
// SequenceableLoader.Callback implementation.
|
||||
@Override
|
||||
public void onContinueLoadingRequested(ChunkSampleStream<SabrChunkSource> source) {
|
||||
callback.onContinueLoadingRequested(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onSampleStreamReleased(ChunkSampleStream<SabrChunkSource> stream) {
|
||||
PlayerTrackEmsgHandler trackEmsgHandler = trackEmsgHandlerBySampleStream.remove(stream);
|
||||
if (trackEmsgHandler != null) {
|
||||
trackEmsgHandler.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the {@link SabrManifest} and the index of this period in the manifest.
|
||||
*
|
||||
* @param manifest The updated manifest.
|
||||
* @param periodIndex the new index of this period in the updated manifest.
|
||||
*/
|
||||
public void updateManifest(SabrManifest manifest, int periodIndex) {
|
||||
this.manifest = manifest;
|
||||
this.periodIndex = periodIndex;
|
||||
playerEmsgHandler.updateManifest(manifest);
|
||||
if (sampleStreams != null) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.getChunkSource().updateManifest(manifest, periodIndex);
|
||||
}
|
||||
callback.onContinueLoadingRequested(this);
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
playerEmsgHandler.release();
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.release(this);
|
||||
}
|
||||
callback = null;
|
||||
eventDispatcher.mediaPeriodReleased();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ChunkSampleStream<SabrChunkSource>[] newSampleStreamArray(int length) {
|
||||
return new ChunkSampleStream[length];
|
||||
}
|
||||
|
||||
private static Pair<TrackGroupArray, TrackGroupInfo[]> buildTrackGroups(
|
||||
List<AdaptationSet> adaptationSets) {
|
||||
int[][] groupedAdaptationSetIndices = getGroupedAdaptationSetIndices(adaptationSets);
|
||||
|
||||
int primaryGroupCount = groupedAdaptationSetIndices.length;
|
||||
boolean[] primaryGroupHasEventMessageTrackFlags = new boolean[primaryGroupCount];
|
||||
Format[][] primaryGroupCea608TrackFormats = new Format[primaryGroupCount][];
|
||||
int totalEmbeddedTrackGroupCount =
|
||||
identifyEmbeddedTracks(
|
||||
primaryGroupCount,
|
||||
adaptationSets,
|
||||
groupedAdaptationSetIndices,
|
||||
primaryGroupHasEventMessageTrackFlags,
|
||||
primaryGroupCea608TrackFormats);
|
||||
|
||||
int totalGroupCount = primaryGroupCount + totalEmbeddedTrackGroupCount;
|
||||
TrackGroup[] trackGroups = new TrackGroup[totalGroupCount];
|
||||
TrackGroupInfo[] trackGroupInfos = new TrackGroupInfo[totalGroupCount];
|
||||
|
||||
int trackGroupCount =
|
||||
buildPrimaryAndEmbeddedTrackGroupInfos(
|
||||
adaptationSets,
|
||||
groupedAdaptationSetIndices,
|
||||
primaryGroupCount,
|
||||
primaryGroupHasEventMessageTrackFlags,
|
||||
primaryGroupCea608TrackFormats,
|
||||
trackGroups,
|
||||
trackGroupInfos);
|
||||
|
||||
return Pair.create(new TrackGroupArray(trackGroups), trackGroupInfos);
|
||||
}
|
||||
|
||||
private static int[][] getGroupedAdaptationSetIndices(List<AdaptationSet> adaptationSets) {
|
||||
int adaptationSetCount = adaptationSets.size();
|
||||
SparseIntArray idToIndexMap = new SparseIntArray(adaptationSetCount);
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
idToIndexMap.put(adaptationSets.get(i).id, i);
|
||||
}
|
||||
|
||||
int[][] groupedAdaptationSetIndices = new int[adaptationSetCount][];
|
||||
boolean[] adaptationSetUsedFlags = new boolean[adaptationSetCount];
|
||||
|
||||
int groupCount = 0;
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
if (adaptationSetUsedFlags[i]) {
|
||||
// This adaptation set has already been included in a group.
|
||||
continue;
|
||||
}
|
||||
adaptationSetUsedFlags[i] = true;
|
||||
groupedAdaptationSetIndices[groupCount++] = new int[] {i};
|
||||
}
|
||||
|
||||
return groupCount < adaptationSetCount
|
||||
? Arrays.copyOf(groupedAdaptationSetIndices, groupCount) : groupedAdaptationSetIndices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through list of primary track groups and identifies embedded tracks.
|
||||
*
|
||||
* @param primaryGroupCount The number of primary track groups.
|
||||
* @param adaptationSets The list of {@link AdaptationSet} of the current DASH period.
|
||||
* @param groupedAdaptationSetIndices The indices of {@link AdaptationSet} that belongs to the
|
||||
* same primary group, grouped in primary track groups order.
|
||||
* @param primaryGroupHasEventMessageTrackFlags An output array to be filled with flags indicating
|
||||
* whether each of the primary track groups contains an embedded event message track.
|
||||
* @param primaryGroupCea608TrackFormats An output array to be filled with track formats for
|
||||
* CEA-608 tracks embedded in each of the primary track groups.
|
||||
* @return Total number of embedded track groups.
|
||||
*/
|
||||
private static int identifyEmbeddedTracks(
|
||||
int primaryGroupCount,
|
||||
List<AdaptationSet> adaptationSets,
|
||||
int[][] groupedAdaptationSetIndices,
|
||||
boolean[] primaryGroupHasEventMessageTrackFlags,
|
||||
Format[][] primaryGroupCea608TrackFormats) {
|
||||
int numEmbeddedTrackGroups = 0;
|
||||
for (int i = 0; i < primaryGroupCount; i++) {
|
||||
primaryGroupCea608TrackFormats[i] =
|
||||
getCea608TrackFormats(adaptationSets, groupedAdaptationSetIndices[i]);
|
||||
if (primaryGroupCea608TrackFormats[i].length != 0) {
|
||||
numEmbeddedTrackGroups++;
|
||||
}
|
||||
}
|
||||
return numEmbeddedTrackGroups;
|
||||
}
|
||||
|
||||
private static int buildPrimaryAndEmbeddedTrackGroupInfos(
|
||||
List<AdaptationSet> adaptationSets,
|
||||
int[][] groupedAdaptationSetIndices,
|
||||
int primaryGroupCount,
|
||||
boolean[] primaryGroupHasEventMessageTrackFlags,
|
||||
Format[][] primaryGroupCea608TrackFormats,
|
||||
TrackGroup[] trackGroups,
|
||||
TrackGroupInfo[] trackGroupInfos) {
|
||||
int trackGroupCount = 0;
|
||||
for (int i = 0; i < primaryGroupCount; i++) {
|
||||
int[] adaptationSetIndices = groupedAdaptationSetIndices[i];
|
||||
List<Representation> representations = new ArrayList<>();
|
||||
for (int adaptationSetIndex : adaptationSetIndices) {
|
||||
representations.addAll(adaptationSets.get(adaptationSetIndex).representations);
|
||||
}
|
||||
Format[] formats = new Format[representations.size()];
|
||||
for (int j = 0; j < formats.length; j++) {
|
||||
formats[j] = representations.get(j).format;
|
||||
}
|
||||
|
||||
AdaptationSet firstAdaptationSet = adaptationSets.get(adaptationSetIndices[0]);
|
||||
int primaryTrackGroupIndex = trackGroupCount++;
|
||||
int eventMessageTrackGroupIndex =
|
||||
primaryGroupHasEventMessageTrackFlags[i] ? trackGroupCount++ : C.INDEX_UNSET;
|
||||
int cea608TrackGroupIndex =
|
||||
primaryGroupCea608TrackFormats[i].length != 0 ? trackGroupCount++ : C.INDEX_UNSET;
|
||||
|
||||
trackGroups[primaryTrackGroupIndex] = new TrackGroup(formats);
|
||||
trackGroupInfos[primaryTrackGroupIndex] =
|
||||
TrackGroupInfo.primaryTrack(
|
||||
firstAdaptationSet.type,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
eventMessageTrackGroupIndex,
|
||||
cea608TrackGroupIndex);
|
||||
if (eventMessageTrackGroupIndex != C.INDEX_UNSET) {
|
||||
Format format = Format.createSampleFormat(firstAdaptationSet.id + ":emsg",
|
||||
MimeTypes.APPLICATION_EMSG, null, Format.NO_VALUE, null);
|
||||
trackGroups[eventMessageTrackGroupIndex] = new TrackGroup(format);
|
||||
trackGroupInfos[eventMessageTrackGroupIndex] =
|
||||
TrackGroupInfo.embeddedEmsgTrack(adaptationSetIndices, primaryTrackGroupIndex);
|
||||
}
|
||||
if (cea608TrackGroupIndex != C.INDEX_UNSET) {
|
||||
trackGroups[cea608TrackGroupIndex] = new TrackGroup(primaryGroupCea608TrackFormats[i]);
|
||||
trackGroupInfos[cea608TrackGroupIndex] =
|
||||
TrackGroupInfo.embeddedCea608Track(adaptationSetIndices, primaryTrackGroupIndex);
|
||||
}
|
||||
}
|
||||
return trackGroupCount;
|
||||
}
|
||||
|
||||
private static Format[] getCea608TrackFormats(
|
||||
List<AdaptationSet> adaptationSets, int[] adaptationSetIndices) {
|
||||
return new Format[0];
|
||||
}
|
||||
|
||||
private int[] getStreamIndexToTrackGroupIndex(ExoTrackSelection[] selections) {
|
||||
int[] streamIndexToTrackGroupIndex = new int[selections.length];
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (selections[i] != null) {
|
||||
streamIndexToTrackGroupIndex[i] = trackGroups.indexOf(selections[i].getTrackGroup());
|
||||
} else {
|
||||
streamIndexToTrackGroupIndex[i] = C.INDEX_UNSET;
|
||||
}
|
||||
}
|
||||
return streamIndexToTrackGroupIndex;
|
||||
}
|
||||
|
||||
private void releaseDisabledStreams(ExoTrackSelection[] selections, boolean[] mayRetainStreamFlags, SampleStream[] streams) {
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (selections[i] == null || !mayRetainStreamFlags[i]) {
|
||||
if (streams[i] instanceof ChunkSampleStream) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ChunkSampleStream<SabrChunkSource> stream =
|
||||
(ChunkSampleStream<SabrChunkSource>) streams[i];
|
||||
stream.release(this);
|
||||
} else if (streams[i] instanceof ChunkSampleStream.EmbeddedSampleStream) {
|
||||
((EmbeddedSampleStream) streams[i]).release();
|
||||
}
|
||||
streams[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseOrphanEmbeddedStreams(ExoTrackSelection[] selections, SampleStream[] streams, int[] streamIndexToTrackGroupIndex) {
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (streams[i] instanceof EmptySampleStream || streams[i] instanceof EmbeddedSampleStream) {
|
||||
// We need to release an embedded stream if the corresponding primary stream is released.
|
||||
int primaryStreamIndex = getPrimaryStreamIndex(i, streamIndexToTrackGroupIndex);
|
||||
boolean mayRetainStream;
|
||||
if (primaryStreamIndex == C.INDEX_UNSET) {
|
||||
// If the corresponding primary stream is not selected, we may retain an existing
|
||||
// EmptySampleStream.
|
||||
mayRetainStream = streams[i] instanceof EmptySampleStream;
|
||||
} else {
|
||||
// If the corresponding primary stream is selected, we may retain the embedded stream if
|
||||
// the stream's parent still matches.
|
||||
mayRetainStream =
|
||||
(streams[i] instanceof EmbeddedSampleStream)
|
||||
&& ((EmbeddedSampleStream) streams[i]).parent == streams[primaryStreamIndex];
|
||||
}
|
||||
if (!mayRetainStream) {
|
||||
if (streams[i] instanceof EmbeddedSampleStream) {
|
||||
((EmbeddedSampleStream) streams[i]).release();
|
||||
}
|
||||
streams[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int getPrimaryStreamIndex(int embeddedStreamIndex, int[] streamIndexToTrackGroupIndex) {
|
||||
int embeddedTrackGroupIndex = streamIndexToTrackGroupIndex[embeddedStreamIndex];
|
||||
if (embeddedTrackGroupIndex == C.INDEX_UNSET) {
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
int primaryTrackGroupIndex = trackGroupInfos[embeddedTrackGroupIndex].primaryTrackGroupIndex;
|
||||
for (int i = 0; i < streamIndexToTrackGroupIndex.length; i++) {
|
||||
int trackGroupIndex = streamIndexToTrackGroupIndex[i];
|
||||
if (trackGroupIndex == primaryTrackGroupIndex
|
||||
&& trackGroupInfos[trackGroupIndex].trackGroupCategory
|
||||
== TrackGroupInfo.CATEGORY_PRIMARY) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
|
||||
private void selectNewStreams(ExoTrackSelection[] selections, SampleStream[] streams, boolean[] streamResetFlags, long positionUs, int[] streamIndexToTrackGroupIndex) {
|
||||
// Create newly selected primary and event streams.
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
ExoTrackSelection selection = selections[i];
|
||||
if (selection == null) {
|
||||
continue;
|
||||
}
|
||||
if (streams[i] == null) {
|
||||
// Create new stream for selection.
|
||||
streamResetFlags[i] = true;
|
||||
int trackGroupIndex = streamIndexToTrackGroupIndex[i];
|
||||
TrackGroupInfo trackGroupInfo = trackGroupInfos[trackGroupIndex];
|
||||
if (trackGroupInfo.trackGroupCategory == TrackGroupInfo.CATEGORY_PRIMARY) {
|
||||
streams[i] = buildSampleStream(trackGroupInfo, selection, positionUs);
|
||||
} else if (trackGroupInfo.trackGroupCategory == TrackGroupInfo.CATEGORY_MANIFEST_EVENTS) {
|
||||
EventStream eventStream = eventStreams.get(trackGroupInfo.eventStreamGroupIndex);
|
||||
Format format = selection.getTrackGroup().getFormat(0);
|
||||
streams[i] = new EventSampleStream(eventStream, format, manifest.dynamic);
|
||||
}
|
||||
} else if (streams[i] instanceof ChunkSampleStream) {
|
||||
// Update selection in existing stream.
|
||||
@SuppressWarnings("unchecked")
|
||||
ChunkSampleStream<SabrChunkSource> stream = (ChunkSampleStream<SabrChunkSource>) streams[i];
|
||||
stream.getChunkSource().updateTrackSelection(selection);
|
||||
}
|
||||
}
|
||||
// Create newly selected embedded streams from the corresponding primary stream. Note that this
|
||||
// second pass is needed because the primary stream may not have been created yet in a first
|
||||
// pass if the index of the primary stream is greater than the index of the embedded stream.
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (streams[i] == null && selections[i] != null) {
|
||||
int trackGroupIndex = streamIndexToTrackGroupIndex[i];
|
||||
TrackGroupInfo trackGroupInfo = trackGroupInfos[trackGroupIndex];
|
||||
if (trackGroupInfo.trackGroupCategory == TrackGroupInfo.CATEGORY_EMBEDDED) {
|
||||
int primaryStreamIndex = getPrimaryStreamIndex(i, streamIndexToTrackGroupIndex);
|
||||
if (primaryStreamIndex == C.INDEX_UNSET) {
|
||||
// If an embedded track is selected without the corresponding primary track, create an
|
||||
// empty sample stream instead.
|
||||
streams[i] = new EmptySampleStream();
|
||||
} else {
|
||||
streams[i] =
|
||||
((ChunkSampleStream) streams[primaryStreamIndex])
|
||||
.selectEmbeddedTrack(positionUs, trackGroupInfo.trackType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkSampleStream<SabrChunkSource> buildSampleStream(TrackGroupInfo trackGroupInfo, ExoTrackSelection selection, long positionUs) {
|
||||
int embeddedTrackCount = 0;
|
||||
boolean enableEventMessageTrack =
|
||||
trackGroupInfo.embeddedEventMessageTrackGroupIndex != C.INDEX_UNSET;
|
||||
TrackGroup embeddedEventMessageTrackGroup = null;
|
||||
if (enableEventMessageTrack) {
|
||||
embeddedEventMessageTrackGroup =
|
||||
trackGroups.get(trackGroupInfo.embeddedEventMessageTrackGroupIndex);
|
||||
embeddedTrackCount++;
|
||||
}
|
||||
boolean enableCea608Tracks = trackGroupInfo.embeddedCea608TrackGroupIndex != C.INDEX_UNSET;
|
||||
TrackGroup embeddedCea608TrackGroup = null;
|
||||
if (enableCea608Tracks) {
|
||||
embeddedCea608TrackGroup = trackGroups.get(trackGroupInfo.embeddedCea608TrackGroupIndex);
|
||||
embeddedTrackCount += embeddedCea608TrackGroup.length;
|
||||
}
|
||||
|
||||
Format[] embeddedTrackFormats = new Format[embeddedTrackCount];
|
||||
int[] embeddedTrackTypes = new int[embeddedTrackCount];
|
||||
embeddedTrackCount = 0;
|
||||
if (enableEventMessageTrack) {
|
||||
embeddedTrackFormats[embeddedTrackCount] = embeddedEventMessageTrackGroup.getFormat(0);
|
||||
embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_METADATA;
|
||||
embeddedTrackCount++;
|
||||
}
|
||||
List<Format> embeddedCea608TrackFormats = new ArrayList<>();
|
||||
if (enableCea608Tracks) {
|
||||
for (int i = 0; i < embeddedCea608TrackGroup.length; i++) {
|
||||
embeddedTrackFormats[embeddedTrackCount] = embeddedCea608TrackGroup.getFormat(i);
|
||||
embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_TEXT;
|
||||
embeddedCea608TrackFormats.add(embeddedTrackFormats[embeddedTrackCount]);
|
||||
embeddedTrackCount++;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerTrackEmsgHandler trackPlayerEmsgHandler = manifest.dynamic && enableEventMessageTrack ? playerEmsgHandler.newPlayerTrackEmsgHandler() : null;
|
||||
SabrChunkSource chunkSource = chunkSourceFactory.createSabrChunkSource(manifestLoaderErrorThrower, manifest, periodIndex, trackGroupInfo.adaptationSetIndices, selection, trackGroupInfo.trackType, elapsedRealtimeOffsetMs, enableEventMessageTrack, embeddedCea608TrackFormats, trackPlayerEmsgHandler, transferListener);
|
||||
ChunkSampleStream<SabrChunkSource> stream = new ChunkSampleStream<>(trackGroupInfo.trackType, embeddedTrackTypes, embeddedTrackFormats, chunkSource, this, allocator, positionUs, loadErrorHandlingPolicy, eventDispatcher);
|
||||
synchronized (this) {
|
||||
// The map is also accessed on the loading thread so synchronize access.
|
||||
trackEmsgHandlerBySampleStream.put(stream, trackPlayerEmsgHandler);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static final class TrackGroupInfo {
|
||||
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@IntDef({CATEGORY_PRIMARY, CATEGORY_EMBEDDED, CATEGORY_MANIFEST_EVENTS})
|
||||
public @interface TrackGroupCategory {}
|
||||
|
||||
/**
|
||||
* A normal track group that has its samples drawn from the stream.
|
||||
* For example: a video Track Group or an audio Track Group.
|
||||
*/
|
||||
private static final int CATEGORY_PRIMARY = 0;
|
||||
|
||||
/**
|
||||
* A track group whose samples are embedded within one of the primary streams. For example: an
|
||||
* EMSG track has its sample embedded in emsg atoms in one of the primary streams.
|
||||
*/
|
||||
private static final int CATEGORY_EMBEDDED = 1;
|
||||
|
||||
/**
|
||||
* A track group that has its samples listed explicitly in the DASH manifest file.
|
||||
* For example: an EventStream track has its sample (Events) included directly in the DASH
|
||||
* manifest file.
|
||||
*/
|
||||
private static final int CATEGORY_MANIFEST_EVENTS = 2;
|
||||
|
||||
public final int[] adaptationSetIndices;
|
||||
public final int trackType;
|
||||
@TrackGroupCategory public final int trackGroupCategory;
|
||||
|
||||
public final int eventStreamGroupIndex;
|
||||
public final int primaryTrackGroupIndex;
|
||||
public final int embeddedEventMessageTrackGroupIndex;
|
||||
public final int embeddedCea608TrackGroupIndex;
|
||||
|
||||
public static TrackGroupInfo primaryTrack(
|
||||
int trackType,
|
||||
int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex,
|
||||
int embeddedEventMessageTrackGroupIndex,
|
||||
int embeddedCea608TrackGroupIndex) {
|
||||
return new TrackGroupInfo(
|
||||
trackType,
|
||||
CATEGORY_PRIMARY,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
embeddedEventMessageTrackGroupIndex,
|
||||
embeddedCea608TrackGroupIndex,
|
||||
/* eventStreamGroupIndex= */ -1);
|
||||
}
|
||||
|
||||
public static TrackGroupInfo embeddedEmsgTrack(int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex) {
|
||||
return new TrackGroupInfo(
|
||||
C.TRACK_TYPE_METADATA,
|
||||
CATEGORY_EMBEDDED,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
C.INDEX_UNSET,
|
||||
C.INDEX_UNSET,
|
||||
/* eventStreamGroupIndex= */ -1);
|
||||
}
|
||||
|
||||
public static TrackGroupInfo embeddedCea608Track(int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex) {
|
||||
return new TrackGroupInfo(
|
||||
C.TRACK_TYPE_TEXT,
|
||||
CATEGORY_EMBEDDED,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
C.INDEX_UNSET,
|
||||
C.INDEX_UNSET,
|
||||
/* eventStreamGroupIndex= */ -1);
|
||||
}
|
||||
|
||||
public static TrackGroupInfo mpdEventTrack(int eventStreamIndex) {
|
||||
return new TrackGroupInfo(
|
||||
C.TRACK_TYPE_METADATA,
|
||||
CATEGORY_MANIFEST_EVENTS,
|
||||
new int[0],
|
||||
/* primaryTrackGroupIndex= */ -1,
|
||||
C.INDEX_UNSET,
|
||||
C.INDEX_UNSET,
|
||||
eventStreamIndex);
|
||||
}
|
||||
|
||||
private TrackGroupInfo(
|
||||
int trackType,
|
||||
@TrackGroupCategory int trackGroupCategory,
|
||||
int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex,
|
||||
int embeddedEventMessageTrackGroupIndex,
|
||||
int embeddedCea608TrackGroupIndex,
|
||||
int eventStreamGroupIndex) {
|
||||
this.trackType = trackType;
|
||||
this.adaptationSetIndices = adaptationSetIndices;
|
||||
this.trackGroupCategory = trackGroupCategory;
|
||||
this.primaryTrackGroupIndex = primaryTrackGroupIndex;
|
||||
this.embeddedEventMessageTrackGroupIndex = embeddedEventMessageTrackGroupIndex;
|
||||
this.embeddedCea608TrackGroupIndex = embeddedCea608TrackGroupIndex;
|
||||
this.eventStreamGroupIndex = eventStreamGroupIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.SystemClock;
|
||||
import android.util.SparseArray;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Timeline;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.source.BaseMediaSource;
|
||||
import androidx.media3.exoplayer.source.CompositeSequenceableLoaderFactory;
|
||||
import androidx.media3.exoplayer.source.DefaultCompositeSequenceableLoaderFactory;
|
||||
import androidx.media3.exoplayer.source.MediaPeriod;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.exoplayer.source.MediaSourceEventListener;
|
||||
import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher;
|
||||
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerEmsgCallback;
|
||||
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy;
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
|
||||
import androidx.media3.exoplayer.upstream.Loader;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
import androidx.media3.common.util.Assertions;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@UnstableApi
|
||||
public final class SabrMediaSource extends BaseMediaSource {
|
||||
/**
|
||||
* The interval in milliseconds between invocations of {@link
|
||||
* SourceInfoRefreshListener#onSourceInfoRefreshed(MediaSource, Timeline, Object)} when the
|
||||
* source's {@link Timeline} is changing dynamically (for example, for incomplete live streams).
|
||||
*/
|
||||
private static final int NOTIFY_MANIFEST_INTERVAL_MS = 5000;
|
||||
/**
|
||||
* The minimum default start position for live streams, relative to the start of the live window.
|
||||
*/
|
||||
private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000;
|
||||
private final SabrManifest manifest;
|
||||
private final SabrChunkSource.Factory chunkSourceFactory;
|
||||
private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
|
||||
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||
private @Nullable TransferListener mediaTransferListener;
|
||||
private final LoaderErrorThrower manifestLoadErrorThrower;
|
||||
private final PlayerEmsgCallback playerEmsgCallback;
|
||||
private Loader loader;
|
||||
private IOException manifestFatalError;
|
||||
private final long livePresentationDelayMs;
|
||||
private final SparseArray<SabrMediaPeriod> periodsById;
|
||||
private final @Nullable Object tag;
|
||||
private long elapsedRealtimeOffsetMs;
|
||||
private int firstPeriodId;
|
||||
private final boolean livePresentationDelayOverridesManifest;
|
||||
|
||||
/**
|
||||
* The default presentation delay for live streams. The presentation delay is the duration by
|
||||
* which the default start position precedes the end of the live window.
|
||||
*/
|
||||
private static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30000;
|
||||
|
||||
private SabrMediaSource(
|
||||
SabrManifest manifest,
|
||||
SabrChunkSource.Factory chunkSourceFactory,
|
||||
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
|
||||
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
|
||||
long livePresentationDelayMs,
|
||||
boolean livePresentationDelayOverridesManifest,
|
||||
@Nullable Object tag
|
||||
) {
|
||||
this.manifest = manifest;
|
||||
this.chunkSourceFactory = chunkSourceFactory;
|
||||
this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||
this.livePresentationDelayMs = livePresentationDelayMs;
|
||||
this.livePresentationDelayOverridesManifest = livePresentationDelayOverridesManifest;
|
||||
this.tag = tag;
|
||||
periodsById = new SparseArray<>();
|
||||
playerEmsgCallback = new DefaultPlayerEmsgCallback();
|
||||
manifestLoadErrorThrower = new ManifestLoadErrorThrower();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
|
||||
this.mediaTransferListener = mediaTransferListener;
|
||||
loader = new Loader("Loader:SabrMediaSource");
|
||||
processManifest();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void releaseSourceInternal() {
|
||||
if (loader != null) {
|
||||
loader.release();
|
||||
loader = null;
|
||||
}
|
||||
elapsedRealtimeOffsetMs = 0;
|
||||
manifestFatalError = null;
|
||||
firstPeriodId = 0;
|
||||
periodsById.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
||||
manifestLoadErrorThrower.maybeThrowError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaPeriod createPeriod(MediaPeriodId periodId, Allocator allocator, long startPositionUs) {
|
||||
int periodIndex = (Integer) periodId.periodUid - firstPeriodId;
|
||||
EventDispatcher periodEventDispatcher =
|
||||
createEventDispatcher(periodId, manifest.getPeriod(periodIndex).startMs);
|
||||
SabrMediaPeriod mediaPeriod = new SabrMediaPeriod(
|
||||
firstPeriodId + periodIndex,
|
||||
manifest,
|
||||
periodIndex,
|
||||
chunkSourceFactory,
|
||||
mediaTransferListener,
|
||||
loadErrorHandlingPolicy,
|
||||
periodEventDispatcher,
|
||||
elapsedRealtimeOffsetMs,
|
||||
manifestLoadErrorThrower,
|
||||
allocator,
|
||||
compositeSequenceableLoaderFactory,
|
||||
playerEmsgCallback);
|
||||
periodsById.put(mediaPeriod.id, mediaPeriod);
|
||||
return mediaPeriod;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releasePeriod(MediaPeriod mediaPeriod) {
|
||||
SabrMediaPeriod sabrMediaPeriod = (SabrMediaPeriod) mediaPeriod;
|
||||
sabrMediaPeriod.release();
|
||||
periodsById.remove(sabrMediaPeriod.id);
|
||||
}
|
||||
|
||||
private void processManifest() {
|
||||
// Update any periods.
|
||||
for (int i = 0; i < periodsById.size(); i++) {
|
||||
int id = periodsById.keyAt(i);
|
||||
if (id >= firstPeriodId) {
|
||||
periodsById.valueAt(i).updateManifest(manifest, id - firstPeriodId);
|
||||
} else {
|
||||
// This period has been removed from the manifest so it doesn't need to be updated.
|
||||
}
|
||||
}
|
||||
// Update the window.
|
||||
boolean windowChangingImplicitly = false;
|
||||
int lastPeriodIndex = manifest.getPeriodCount() - 1;
|
||||
PeriodSeekInfo firstPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(manifest.getPeriod(0),
|
||||
manifest.getPeriodDurationUs(0));
|
||||
PeriodSeekInfo lastPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(
|
||||
manifest.getPeriod(lastPeriodIndex), manifest.getPeriodDurationUs(lastPeriodIndex));
|
||||
// Get the period-relative start/end times.
|
||||
long currentStartTimeUs = firstPeriodSeekInfo.availableStartTimeUs;
|
||||
long currentEndTimeUs = lastPeriodSeekInfo.availableEndTimeUs;
|
||||
if (manifest.dynamic && !lastPeriodSeekInfo.isIndexExplicit) {
|
||||
// The manifest describes an incomplete live stream. Update the start/end times to reflect the
|
||||
// live stream duration and the manifest's time shift buffer depth.
|
||||
long liveStreamDurationUs = getNowUnixTimeUs() - C.msToUs(manifest.availabilityStartTimeMs);
|
||||
long liveStreamEndPositionInLastPeriodUs = liveStreamDurationUs
|
||||
- C.msToUs(manifest.getPeriod(lastPeriodIndex).startMs);
|
||||
currentEndTimeUs = Math.min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs);
|
||||
if (manifest.timeShiftBufferDepthMs != C.TIME_UNSET) {
|
||||
long timeShiftBufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs);
|
||||
long offsetInPeriodUs = currentEndTimeUs - timeShiftBufferDepthUs;
|
||||
int periodIndex = lastPeriodIndex;
|
||||
while (offsetInPeriodUs < 0 && periodIndex > 0) {
|
||||
offsetInPeriodUs += manifest.getPeriodDurationUs(--periodIndex);
|
||||
}
|
||||
if (periodIndex == 0) {
|
||||
currentStartTimeUs = Math.max(currentStartTimeUs, offsetInPeriodUs);
|
||||
} else {
|
||||
// The time shift buffer starts after the earliest period.
|
||||
// TODO: Does this ever happen?
|
||||
currentStartTimeUs = manifest.getPeriodDurationUs(0);
|
||||
}
|
||||
}
|
||||
windowChangingImplicitly = true;
|
||||
}
|
||||
long windowDurationUs = currentEndTimeUs - currentStartTimeUs;
|
||||
for (int i = 0; i < manifest.getPeriodCount() - 1; i++) {
|
||||
windowDurationUs += manifest.getPeriodDurationUs(i);
|
||||
}
|
||||
long windowDefaultStartPositionUs = 0;
|
||||
if (manifest.dynamic) {
|
||||
long presentationDelayForManifestMs = livePresentationDelayMs;
|
||||
if (!livePresentationDelayOverridesManifest
|
||||
&& manifest.suggestedPresentationDelayMs != C.TIME_UNSET) {
|
||||
presentationDelayForManifestMs = manifest.suggestedPresentationDelayMs;
|
||||
}
|
||||
// Snap the default position to the start of the segment containing it.
|
||||
windowDefaultStartPositionUs = windowDurationUs - C.msToUs(presentationDelayForManifestMs);
|
||||
if (windowDefaultStartPositionUs < MIN_LIVE_DEFAULT_START_POSITION_US) {
|
||||
// The default start position is too close to the start of the live window. Set it to the
|
||||
// minimum default start position provided the window is at least twice as big. Else set
|
||||
// it to the middle of the window.
|
||||
windowDefaultStartPositionUs = Math.min(MIN_LIVE_DEFAULT_START_POSITION_US,
|
||||
windowDurationUs / 2);
|
||||
}
|
||||
}
|
||||
long windowStartTimeMs = manifest.availabilityStartTimeMs
|
||||
+ manifest.getPeriod(0).startMs + C.usToMs(currentStartTimeUs);
|
||||
SabrTimeline timeline =
|
||||
new SabrTimeline(
|
||||
manifest.availabilityStartTimeMs,
|
||||
windowStartTimeMs,
|
||||
firstPeriodId,
|
||||
currentStartTimeUs,
|
||||
windowDurationUs,
|
||||
windowDefaultStartPositionUs,
|
||||
manifest,
|
||||
tag);
|
||||
refreshSourceInfo(timeline, manifest);
|
||||
}
|
||||
|
||||
private long getNowUnixTimeUs() {
|
||||
if (elapsedRealtimeOffsetMs != 0) {
|
||||
return C.msToUs(SystemClock.elapsedRealtime() + elapsedRealtimeOffsetMs);
|
||||
} else {
|
||||
return C.msToUs(System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements MediaSource.Factory {
|
||||
private final SabrChunkSource.Factory chunkSourceFactory;
|
||||
@Nullable private final DataSource.Factory manifestDataSourceFactory;
|
||||
private final DefaultLoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||
private final DefaultCompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
|
||||
private long livePresentationDelayMs;
|
||||
private boolean livePresentationDelayOverridesManifest;
|
||||
private boolean isCreateCalled;
|
||||
@Nullable private Object tag;
|
||||
|
||||
/**
|
||||
* Creates a new factory for {@link SabrMediaSource}s.
|
||||
*
|
||||
* @param chunkSourceFactory A factory for {@link SabrChunkSource} instances.
|
||||
* @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used
|
||||
* to load (and refresh) the manifest. May be {@code null} if the factory will only ever be
|
||||
* used to create create media sources with sideloaded manifests via {@link
|
||||
* #createMediaSource(SabrManifest, Handler, MediaSourceEventListener)}.
|
||||
*/
|
||||
public Factory(
|
||||
SabrChunkSource.Factory chunkSourceFactory,
|
||||
@Nullable DataSource.Factory manifestDataSourceFactory) {
|
||||
this.chunkSourceFactory = chunkSourceFactory;
|
||||
this.manifestDataSourceFactory = manifestDataSourceFactory;
|
||||
loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy();
|
||||
livePresentationDelayMs = DEFAULT_LIVE_PRESENTATION_DELAY_MS;
|
||||
compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaSource createMediaSource(Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new {@link SabrMediaSource} using the current parameters and the specified
|
||||
* sideloaded manifest.
|
||||
*
|
||||
* @param manifest The manifest.
|
||||
* @return The new {@link SabrMediaSource}.
|
||||
*/
|
||||
public SabrMediaSource createMediaSource(SabrManifest manifest) {
|
||||
isCreateCalled = true;
|
||||
return new SabrMediaSource(
|
||||
manifest,
|
||||
chunkSourceFactory,
|
||||
compositeSequenceableLoaderFactory,
|
||||
loadErrorHandlingPolicy,
|
||||
livePresentationDelayMs,
|
||||
livePresentationDelayOverridesManifest,
|
||||
tag
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link #createMediaSource(SabrManifest)} and {@link
|
||||
* #addEventListener(Handler, MediaSourceEventListener)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public SabrMediaSource createMediaSource(
|
||||
SabrManifest manifest,
|
||||
@Nullable Handler eventHandler,
|
||||
@Nullable MediaSourceEventListener eventListener) {
|
||||
isCreateCalled = true;
|
||||
SabrMediaSource mediaSource = createMediaSource(manifest);
|
||||
if (eventHandler != null && eventListener != null) {
|
||||
mediaSource.addEventListener(eventHandler, eventListener);
|
||||
}
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] getSupportedTypes() {
|
||||
return new int[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link
|
||||
* DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}.
|
||||
*
|
||||
* <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}.
|
||||
*
|
||||
* @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
*/
|
||||
public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
|
||||
//Assertions.checkState(!isCreateCalled);
|
||||
//this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum number of times to retry if a loading error occurs. See {@link
|
||||
* #setLoadErrorHandlingPolicy} for the default value.
|
||||
*
|
||||
* <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with
|
||||
* {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int)
|
||||
* DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)}
|
||||
*
|
||||
* @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
* @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public Factory setMinLoadableRetryCount(int minLoadableRetryCount) {
|
||||
return setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a tag for the media source which will be published in the {@link
|
||||
* androidx.media3.Timeline} of the source as {@link
|
||||
* androidx.media3.Timeline.Window#tag}.
|
||||
*
|
||||
* @param tag A tag for the media source.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
*/
|
||||
public Factory setTag(Object tag) {
|
||||
Assertions.checkState(!isCreateCalled);
|
||||
this.tag = tag;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration in milliseconds by which the default start position should precede the end
|
||||
* of the live window for live playbacks. The {@code overridesManifest} parameter specifies
|
||||
* whether the value is used in preference to one in the manifest, if present. The default value
|
||||
* is {@link #DEFAULT_LIVE_PRESENTATION_DELAY_MS}, and by default {@code overridesManifest} is
|
||||
* false.
|
||||
*
|
||||
* @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the
|
||||
* default start position should precede the end of the live window.
|
||||
* @param overridesManifest Whether the value is used in preference to one in the manifest, if
|
||||
* present.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
*/
|
||||
public Factory setLivePresentationDelayMs(
|
||||
long livePresentationDelayMs, boolean overridesManifest) {
|
||||
Assertions.checkState(!isCreateCalled);
|
||||
this.livePresentationDelayMs = livePresentationDelayMs;
|
||||
this.livePresentationDelayOverridesManifest = overridesManifest;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link LoaderErrorThrower} that throws fatal {@link IOException} that has occurred during
|
||||
* manifest loading from the manifest {@code loader}, or exception with the loaded manifest.
|
||||
*/
|
||||
/* package */ final class ManifestLoadErrorThrower implements LoaderErrorThrower {
|
||||
|
||||
@Override
|
||||
public void maybeThrowError() throws IOException {
|
||||
loader.maybeThrowError();
|
||||
maybeThrowManifestError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowError(int minRetryCount) throws IOException {
|
||||
loader.maybeThrowError(minRetryCount);
|
||||
maybeThrowManifestError();
|
||||
}
|
||||
|
||||
private void maybeThrowManifestError() throws IOException {
|
||||
if (manifestFatalError != null) {
|
||||
throw manifestFatalError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DefaultPlayerEmsgCallback implements PlayerEmsgCallback {
|
||||
@Override
|
||||
public void onDashManifestRefreshRequested() {
|
||||
//SabrMediaSource.this.onDashManifestRefreshRequested();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDashManifestPublishTimeExpired(long expiredManifestPublishTimeUs) {
|
||||
//SabrMediaSource.this.onDashManifestPublishTimeExpired(expiredManifestPublishTimeUs);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SabrTimeline extends Timeline {
|
||||
|
||||
private final long presentationStartTimeMs;
|
||||
private final long windowStartTimeMs;
|
||||
|
||||
private final int firstPeriodId;
|
||||
private final long offsetInFirstPeriodUs;
|
||||
private final long windowDurationUs;
|
||||
private final long windowDefaultStartPositionUs;
|
||||
private final SabrManifest manifest;
|
||||
private final @Nullable Object windowTag;
|
||||
|
||||
public SabrTimeline(
|
||||
long presentationStartTimeMs,
|
||||
long windowStartTimeMs,
|
||||
int firstPeriodId,
|
||||
long offsetInFirstPeriodUs,
|
||||
long windowDurationUs,
|
||||
long windowDefaultStartPositionUs,
|
||||
SabrManifest manifest,
|
||||
@Nullable Object windowTag) {
|
||||
this.presentationStartTimeMs = presentationStartTimeMs;
|
||||
this.windowStartTimeMs = windowStartTimeMs;
|
||||
this.firstPeriodId = firstPeriodId;
|
||||
this.offsetInFirstPeriodUs = offsetInFirstPeriodUs;
|
||||
this.windowDurationUs = windowDurationUs;
|
||||
this.windowDefaultStartPositionUs = windowDefaultStartPositionUs;
|
||||
this.manifest = manifest;
|
||||
this.windowTag = windowTag;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPeriodCount() {
|
||||
return manifest.getPeriodCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Period getPeriod(int periodIndex, Period period, boolean setIdentifiers) {
|
||||
Assertions.checkIndex(periodIndex, 0, getPeriodCount());
|
||||
Object id = setIdentifiers ? manifest.getPeriod(periodIndex).id : null;
|
||||
Object uid = setIdentifiers ? (firstPeriodId + periodIndex) : null;
|
||||
return period.set(id, uid, 0, manifest.getPeriodDurationUs(periodIndex),
|
||||
C.msToUs(manifest.getPeriod(periodIndex).startMs - manifest.getPeriod(0).startMs)
|
||||
- offsetInFirstPeriodUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getWindowCount() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Window getWindow(
|
||||
int windowIndex, Window window, boolean setTag, long defaultPositionProjectionUs) {
|
||||
Assertions.checkIndex(windowIndex, 0, 1);
|
||||
long windowDefaultStartPositionUs = getAdjustedWindowDefaultStartPositionUs(
|
||||
defaultPositionProjectionUs);
|
||||
Object tag = setTag ? windowTag : null;
|
||||
boolean isDynamic =
|
||||
manifest.dynamic
|
||||
&& manifest.minUpdatePeriodMs != C.TIME_UNSET
|
||||
&& manifest.durationMs == C.TIME_UNSET;
|
||||
return window.set(
|
||||
tag,
|
||||
presentationStartTimeMs,
|
||||
windowStartTimeMs,
|
||||
/* isSeekable= */ true,
|
||||
isDynamic,
|
||||
windowDefaultStartPositionUs,
|
||||
windowDurationUs,
|
||||
/* firstPeriodIndex= */ 0,
|
||||
/* lastPeriodIndex= */ getPeriodCount() - 1,
|
||||
offsetInFirstPeriodUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIndexOfPeriod(Object uid) {
|
||||
if (!(uid instanceof Integer)) {
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
int periodId = (int) uid;
|
||||
int periodIndex = periodId - firstPeriodId;
|
||||
return periodIndex < 0 || periodIndex >= getPeriodCount() ? C.INDEX_UNSET : periodIndex;
|
||||
}
|
||||
|
||||
private long getAdjustedWindowDefaultStartPositionUs(long defaultPositionProjectionUs) {
|
||||
long windowDefaultStartPositionUs = this.windowDefaultStartPositionUs;
|
||||
if (!manifest.dynamic) {
|
||||
return windowDefaultStartPositionUs;
|
||||
}
|
||||
if (defaultPositionProjectionUs > 0) {
|
||||
windowDefaultStartPositionUs += defaultPositionProjectionUs;
|
||||
if (windowDefaultStartPositionUs > windowDurationUs) {
|
||||
// The projection takes us beyond the end of the live window.
|
||||
return C.TIME_UNSET;
|
||||
}
|
||||
}
|
||||
// Attempt to snap to the start of the corresponding video segment.
|
||||
int periodIndex = 0;
|
||||
long defaultStartPositionInPeriodUs = offsetInFirstPeriodUs + windowDefaultStartPositionUs;
|
||||
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
while (periodIndex < manifest.getPeriodCount() - 1
|
||||
&& defaultStartPositionInPeriodUs >= periodDurationUs) {
|
||||
defaultStartPositionInPeriodUs -= periodDurationUs;
|
||||
periodIndex++;
|
||||
periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
}
|
||||
com.futo.platformplayer.sabr.manifest.Period period =
|
||||
manifest.getPeriod(periodIndex);
|
||||
int videoAdaptationSetIndex = period.getAdaptationSetIndex(C.TRACK_TYPE_VIDEO);
|
||||
if (videoAdaptationSetIndex == C.INDEX_UNSET) {
|
||||
// No video adaptation set for snapping.
|
||||
return windowDefaultStartPositionUs;
|
||||
}
|
||||
// If there are multiple video adaptation sets with unaligned segments, the initial time may
|
||||
// not correspond to the start of a segment in both, but this is an edge case.
|
||||
SabrSegmentIndex snapIndex = period.adaptationSets.get(videoAdaptationSetIndex)
|
||||
.representations.get(0).getIndex();
|
||||
if (snapIndex == null || snapIndex.getSegmentCount(periodDurationUs) == 0) {
|
||||
// Video adaptation set does not include a non-empty index for snapping.
|
||||
return windowDefaultStartPositionUs;
|
||||
}
|
||||
long segmentNum = snapIndex.getSegmentNum(defaultStartPositionInPeriodUs, periodDurationUs);
|
||||
return windowDefaultStartPositionUs + snapIndex.getTimeUs(segmentNum)
|
||||
- defaultStartPositionInPeriodUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getUidOfPeriod(int periodIndex) {
|
||||
Assertions.checkIndex(periodIndex, 0, getPeriodCount());
|
||||
return firstPeriodId + periodIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PeriodSeekInfo {
|
||||
|
||||
public static PeriodSeekInfo createPeriodSeekInfo(
|
||||
com.futo.platformplayer.sabr.manifest.Period period, long durationUs) {
|
||||
int adaptationSetCount = period.adaptationSets.size();
|
||||
long availableStartTimeUs = 0;
|
||||
long availableEndTimeUs = Long.MAX_VALUE;
|
||||
boolean isIndexExplicit = false;
|
||||
boolean seenEmptyIndex = false;
|
||||
|
||||
boolean haveAudioVideoAdaptationSets = false;
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
int type = period.adaptationSets.get(i).type;
|
||||
if (type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO) {
|
||||
haveAudioVideoAdaptationSets = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
AdaptationSet adaptationSet = period.adaptationSets.get(i);
|
||||
// Exclude text adaptation sets from duration calculations, if we have at least one audio
|
||||
// or video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029
|
||||
if (haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SabrSegmentIndex index = adaptationSet.representations.get(0).getIndex();
|
||||
if (index == null) {
|
||||
return new PeriodSeekInfo(true, 0, durationUs);
|
||||
}
|
||||
isIndexExplicit |= index.isExplicit();
|
||||
int segmentCount = index.getSegmentCount(durationUs);
|
||||
if (segmentCount == 0) {
|
||||
seenEmptyIndex = true;
|
||||
availableStartTimeUs = 0;
|
||||
availableEndTimeUs = 0;
|
||||
} else if (!seenEmptyIndex) {
|
||||
long firstSegmentNum = index.getFirstSegmentNum();
|
||||
long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstSegmentNum);
|
||||
availableStartTimeUs = Math.max(availableStartTimeUs, adaptationSetAvailableStartTimeUs);
|
||||
if (segmentCount != SabrSegmentIndex.INDEX_UNBOUNDED) {
|
||||
long lastSegmentNum = firstSegmentNum + segmentCount - 1;
|
||||
long adaptationSetAvailableEndTimeUs = index.getTimeUs(lastSegmentNum)
|
||||
+ index.getDurationUs(lastSegmentNum, durationUs);
|
||||
availableEndTimeUs = Math.min(availableEndTimeUs, adaptationSetAvailableEndTimeUs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new PeriodSeekInfo(isIndexExplicit, availableStartTimeUs, availableEndTimeUs);
|
||||
}
|
||||
|
||||
public final boolean isIndexExplicit;
|
||||
public final long availableStartTimeUs;
|
||||
public final long availableEndTimeUs;
|
||||
|
||||
private PeriodSeekInfo(boolean isIndexExplicit, long availableStartTimeUs,
|
||||
long availableEndTimeUs) {
|
||||
this.isIndexExplicit = isIndexExplicit;
|
||||
this.availableStartTimeUs = availableStartTimeUs;
|
||||
this.availableEndTimeUs = availableEndTimeUs;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import com.futo.platformplayer.sabr.manifest.RangedUri;
|
||||
|
||||
/**
|
||||
* Indexes the segments within a media stream.
|
||||
*/
|
||||
public interface SabrSegmentIndex {
|
||||
|
||||
int INDEX_UNBOUNDED = -1;
|
||||
|
||||
/**
|
||||
* Returns {@code getFirstSegmentNum()} if the index has no segments or if the given media time is
|
||||
* earlier than the start of the first segment. Returns {@code getFirstSegmentNum() +
|
||||
* getSegmentCount() - 1} if the given media time is later than the end of the last segment.
|
||||
* Otherwise, returns the segment number of the segment containing the given media time.
|
||||
*
|
||||
* @param timeUs The time in microseconds.
|
||||
* @param periodDurationUs The duration of the enclosing period in microseconds, or {@link
|
||||
* C#TIME_UNSET} if the period's duration is not yet known.
|
||||
* @return The segment number of the corresponding segment.
|
||||
*/
|
||||
long getSegmentNum(long timeUs, long periodDurationUs);
|
||||
|
||||
/**
|
||||
* Returns the start time of a segment.
|
||||
*
|
||||
* @param segmentNum The segment number.
|
||||
* @return The corresponding start time in microseconds.
|
||||
*/
|
||||
long getTimeUs(long segmentNum);
|
||||
|
||||
/**
|
||||
* Returns the duration of a segment.
|
||||
*
|
||||
* @param segmentNum The segment number.
|
||||
* @param periodDurationUs The duration of the enclosing period in microseconds, or {@link
|
||||
* C#TIME_UNSET} if the period's duration is not yet known.
|
||||
* @return The duration of the segment, in microseconds.
|
||||
*/
|
||||
long getDurationUs(long segmentNum, long periodDurationUs);
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of a segment.
|
||||
*
|
||||
* @param segmentNum The segment number.
|
||||
* @return The {@link RangedUri} defining the location of the data.
|
||||
*/
|
||||
RangedUri getSegmentUrl(long segmentNum);
|
||||
|
||||
/**
|
||||
* Returns the segment number of the first segment.
|
||||
*
|
||||
* @return The segment number of the first segment.
|
||||
*/
|
||||
long getFirstSegmentNum();
|
||||
|
||||
/**
|
||||
* Returns the number of segments in the index, or {@link #INDEX_UNBOUNDED}.
|
||||
* <p>
|
||||
* An unbounded index occurs if a dynamic manifest uses SegmentTemplate elements without a
|
||||
* SegmentTimeline element, and if the period duration is not yet known. In this case the caller
|
||||
* must manually determine the window of currently available segments.
|
||||
*
|
||||
* @param periodDurationUs The duration of the enclosing period in microseconds, or
|
||||
* {@link C#TIME_UNSET} if the period's duration is not yet known.
|
||||
* @return The number of segments in the index, or {@link #INDEX_UNBOUNDED}.
|
||||
*/
|
||||
int getSegmentCount(long periodDurationUs);
|
||||
|
||||
/**
|
||||
* Returns true if segments are defined explicitly by the index.
|
||||
* <p>
|
||||
* If true is returned, each segment is defined explicitly by the index data, and all of the
|
||||
* listed segments are guaranteed to be available at the time when the index was obtained.
|
||||
* <p>
|
||||
* If false is returned then segment information was derived from properties such as a fixed
|
||||
* segment duration. If the presentation is dynamic, it's possible that only a subset of the
|
||||
* segments are available.
|
||||
*
|
||||
* @return Whether segments are defined explicitly by the index.
|
||||
*/
|
||||
boolean isExplicit();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ChunkIndex;
|
||||
import com.futo.platformplayer.sabr.manifest.RangedUri;
|
||||
|
||||
/**
|
||||
* An implementation of {@link SabrSegmentIndex} that wraps a {@link ChunkIndex} parsed from a
|
||||
* media stream.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final class SabrWrappingSegmentIndex implements SabrSegmentIndex {
|
||||
|
||||
private final ChunkIndex chunkIndex;
|
||||
private final long timeOffsetUs;
|
||||
|
||||
/**
|
||||
* @param chunkIndex The {@link ChunkIndex} to wrap.
|
||||
* @param timeOffsetUs An offset to subtract from the times in the wrapped index, in microseconds.
|
||||
*/
|
||||
public SabrWrappingSegmentIndex(ChunkIndex chunkIndex, long timeOffsetUs) {
|
||||
this.chunkIndex = chunkIndex;
|
||||
this.timeOffsetUs = timeOffsetUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFirstSegmentNum() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return chunkIndex.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeUs(long segmentNum) {
|
||||
return chunkIndex.timesUs[(int) segmentNum] - timeOffsetUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs(long segmentNum, long periodDurationUs) {
|
||||
return chunkIndex.durationsUs[(int) segmentNum];
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(long segmentNum) {
|
||||
return new RangedUri(
|
||||
null, chunkIndex.offsets[(int) segmentNum], chunkIndex.sizes[(int) segmentNum]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
return chunkIndex.getChunkIndex(timeUs + timeOffsetUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
/*
|
||||
* Copyright (C) 2016 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.
|
||||
*/
|
||||
|
||||
import static java.lang.Math.min;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.google.common.base.Ascii;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Utility methods for manipulating URIs. */
|
||||
@UnstableApi
|
||||
public final class UriUtil {
|
||||
|
||||
/** The length of arrays returned by {@link #getUriIndices(String)}. */
|
||||
private static final int INDEX_COUNT = 4;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the ':' after the scheme. Equals -1
|
||||
* if the URI is a relative reference (no scheme). The hier-part starts at (schemeColon + 1),
|
||||
* including when the URI has no scheme.
|
||||
*/
|
||||
private static final int SCHEME_COLON = 0;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the path part. Equals (schemeColon +
|
||||
* 1) if no authority part, (schemeColon + 3) if the authority part consists of just "//", and
|
||||
* (query) if no path part. The characters starting at this index can be "//" only if the
|
||||
* authority part is non-empty (in this case the double-slash means the first segment is empty).
|
||||
*/
|
||||
private static final int PATH = 1;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the query part, including the '?'
|
||||
* before the query. Equals fragment if no query part, and (fragment - 1) if the query part is a
|
||||
* single '?' with no data.
|
||||
*/
|
||||
private static final int QUERY = 2;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the fragment part, including the '#'
|
||||
* before the fragment. Equal to the length of the URI if no fragment part, and (length - 1) if
|
||||
* the fragment part is a single '#' with no data.
|
||||
*/
|
||||
private static final int FRAGMENT = 3;
|
||||
|
||||
private UriUtil() {}
|
||||
|
||||
/**
|
||||
* Like {@link #resolve(String, String)}, but returns a {@link Uri} instead of a {@link String}.
|
||||
*
|
||||
* @param baseUri The base URI.
|
||||
* @param referenceUri The reference URI to resolve.
|
||||
*/
|
||||
public static Uri resolveToUri(@Nullable String baseUri, @Nullable String referenceUri) {
|
||||
return Uri.parse(resolve(baseUri, referenceUri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs relative resolution of a {@code referenceUri} with respect to a {@code baseUri}.
|
||||
*
|
||||
* <p>The resolution is performed as specified by RFC-3986.
|
||||
*
|
||||
* @param baseUri The base URI.
|
||||
* @param referenceUri The reference URI to resolve.
|
||||
*/
|
||||
public static String resolve(@Nullable String baseUri, @Nullable String referenceUri) {
|
||||
StringBuilder uri = new StringBuilder();
|
||||
|
||||
// Map null onto empty string, to make the following logic simpler.
|
||||
baseUri = baseUri == null ? "" : baseUri;
|
||||
referenceUri = referenceUri == null ? "" : referenceUri;
|
||||
|
||||
int[] refIndices = getUriIndices(referenceUri);
|
||||
if (refIndices[SCHEME_COLON] != -1) {
|
||||
// The reference is absolute. The target Uri is the reference.
|
||||
uri.append(referenceUri);
|
||||
removeDotSegments(uri, refIndices[PATH], refIndices[QUERY]);
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
int[] baseIndices = getUriIndices(baseUri);
|
||||
if (refIndices[FRAGMENT] == 0) {
|
||||
// The reference is empty or contains just the fragment part, then the target Uri is the
|
||||
// concatenation of the base Uri without its fragment, and the reference.
|
||||
return uri.append(baseUri, 0, baseIndices[FRAGMENT]).append(referenceUri).toString();
|
||||
}
|
||||
|
||||
if (refIndices[QUERY] == 0) {
|
||||
// The reference starts with the query part. The target is the base up to (but excluding) the
|
||||
// query, plus the reference.
|
||||
return uri.append(baseUri, 0, baseIndices[QUERY]).append(referenceUri).toString();
|
||||
}
|
||||
|
||||
if (refIndices[PATH] != 0) {
|
||||
// The reference has authority. The target is the base scheme plus the reference.
|
||||
int baseLimit = baseIndices[SCHEME_COLON] + 1;
|
||||
uri.append(baseUri, 0, baseLimit).append(referenceUri);
|
||||
return removeDotSegments(uri, baseLimit + refIndices[PATH], baseLimit + refIndices[QUERY]);
|
||||
}
|
||||
|
||||
if (referenceUri.charAt(refIndices[PATH]) == '/') {
|
||||
// The reference path is rooted. The target is the base scheme and authority (if any), plus
|
||||
// the reference.
|
||||
uri.append(baseUri, 0, baseIndices[PATH]).append(referenceUri);
|
||||
return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY]);
|
||||
}
|
||||
|
||||
// The target Uri is the concatenation of the base Uri up to (but excluding) the last segment,
|
||||
// and the reference. This can be split into 2 cases:
|
||||
if (baseIndices[SCHEME_COLON] + 2 < baseIndices[PATH]
|
||||
&& baseIndices[PATH] == baseIndices[QUERY]) {
|
||||
// Case 1: The base hier-part is just the authority, with an empty path. An additional '/' is
|
||||
// needed after the authority, before appending the reference.
|
||||
uri.append(baseUri, 0, baseIndices[PATH]).append('/').append(referenceUri);
|
||||
return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] + 1);
|
||||
} else {
|
||||
// Case 2: Otherwise, find the last '/' in the base hier-part and append the reference after
|
||||
// it. If base hier-part has no '/', it could only mean that it is completely empty or
|
||||
// contains only one segment, in which case the whole hier-part is excluded and the reference
|
||||
// is appended right after the base scheme colon without an added '/'.
|
||||
int lastSlashIndex = baseUri.lastIndexOf('/', baseIndices[QUERY] - 1);
|
||||
int baseLimit = lastSlashIndex == -1 ? baseIndices[PATH] : lastSlashIndex + 1;
|
||||
uri.append(baseUri, 0, baseLimit).append(referenceUri);
|
||||
return removeDotSegments(uri, baseIndices[PATH], baseLimit + refIndices[QUERY]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the URI is starting with a scheme component, false otherwise. */
|
||||
public static boolean isAbsolute(@Nullable String uri) {
|
||||
return uri != null && getUriIndices(uri)[SCHEME_COLON] != -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes query parameter from a URI, if present.
|
||||
*
|
||||
* @param uri The URI.
|
||||
* @param queryParameterName The name of the query parameter.
|
||||
* @return The URI without the query parameter.
|
||||
*/
|
||||
public static Uri removeQueryParameter(Uri uri, String queryParameterName) {
|
||||
Uri.Builder builder = uri.buildUpon();
|
||||
builder.clearQuery();
|
||||
for (String key : uri.getQueryParameterNames()) {
|
||||
if (!key.equals(queryParameterName)) {
|
||||
for (String value : uri.getQueryParameters(key)) {
|
||||
builder.appendQueryParameter(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes dot segments from the path of a URI.
|
||||
*
|
||||
* @param uri A {@link StringBuilder} containing the URI.
|
||||
* @param offset The index of the start of the path in {@code uri}.
|
||||
* @param limit The limit (exclusive) of the path in {@code uri}.
|
||||
*/
|
||||
private static String removeDotSegments(StringBuilder uri, int offset, int limit) {
|
||||
if (offset >= limit) {
|
||||
// Nothing to do.
|
||||
return uri.toString();
|
||||
}
|
||||
if (uri.charAt(offset) == '/') {
|
||||
// If the path starts with a /, always retain it.
|
||||
offset++;
|
||||
}
|
||||
// The first character of the current path segment.
|
||||
int segmentStart = offset;
|
||||
int i = offset;
|
||||
while (i <= limit) {
|
||||
int nextSegmentStart;
|
||||
if (i == limit) {
|
||||
nextSegmentStart = i;
|
||||
} else if (uri.charAt(i) == '/') {
|
||||
nextSegmentStart = i + 1;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// We've encountered the end of a segment or the end of the path. If the final segment was
|
||||
// "." or "..", remove the appropriate segments of the path.
|
||||
if (i == segmentStart + 1 && uri.charAt(segmentStart) == '.') {
|
||||
// Given "abc/def/./ghi", remove "./" to get "abc/def/ghi".
|
||||
uri.delete(segmentStart, nextSegmentStart);
|
||||
limit -= nextSegmentStart - segmentStart;
|
||||
i = segmentStart;
|
||||
} else if (i == segmentStart + 2
|
||||
&& uri.charAt(segmentStart) == '.'
|
||||
&& uri.charAt(segmentStart + 1) == '.') {
|
||||
// Given "abc/def/../ghi", remove "def/../" to get "abc/ghi".
|
||||
int prevSegmentStart = uri.lastIndexOf("/", segmentStart - 2) + 1;
|
||||
int removeFrom = prevSegmentStart > offset ? prevSegmentStart : offset;
|
||||
uri.delete(removeFrom, nextSegmentStart);
|
||||
limit -= nextSegmentStart - removeFrom;
|
||||
segmentStart = prevSegmentStart;
|
||||
i = prevSegmentStart;
|
||||
} else {
|
||||
i++;
|
||||
segmentStart = i;
|
||||
}
|
||||
}
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates indices of the constituent components of a URI.
|
||||
*
|
||||
* @param uriString The URI as a string.
|
||||
* @return The corresponding indices.
|
||||
*/
|
||||
private static int[] getUriIndices(String uriString) {
|
||||
int[] indices = new int[INDEX_COUNT];
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
indices[SCHEME_COLON] = -1;
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Determine outer structure from right to left.
|
||||
// Uri = scheme ":" hier-part [ "?" query ] [ "#" fragment ]
|
||||
int length = uriString.length();
|
||||
int fragmentIndex = uriString.indexOf('#');
|
||||
if (fragmentIndex == -1) {
|
||||
fragmentIndex = length;
|
||||
}
|
||||
int queryIndex = uriString.indexOf('?');
|
||||
if (queryIndex == -1 || queryIndex > fragmentIndex) {
|
||||
// '#' before '?': '?' is within the fragment.
|
||||
queryIndex = fragmentIndex;
|
||||
}
|
||||
// Slashes are allowed only in hier-part so any colon after the first slash is part of the
|
||||
// hier-part, not the scheme colon separator.
|
||||
int schemeIndexLimit = uriString.indexOf('/');
|
||||
if (schemeIndexLimit == -1 || schemeIndexLimit > queryIndex) {
|
||||
schemeIndexLimit = queryIndex;
|
||||
}
|
||||
int schemeIndex = uriString.indexOf(':');
|
||||
if (schemeIndex > schemeIndexLimit) {
|
||||
// '/' before ':'
|
||||
schemeIndex = -1;
|
||||
}
|
||||
|
||||
// Determine hier-part structure: hier-part = "//" authority path / path
|
||||
// This block can also cope with schemeIndex == -1.
|
||||
boolean hasAuthority =
|
||||
schemeIndex + 2 < queryIndex
|
||||
&& uriString.charAt(schemeIndex + 1) == '/'
|
||||
&& uriString.charAt(schemeIndex + 2) == '/';
|
||||
int pathIndex;
|
||||
if (hasAuthority) {
|
||||
pathIndex = uriString.indexOf('/', schemeIndex + 3); // find first '/' after "://"
|
||||
if (pathIndex == -1 || pathIndex > queryIndex) {
|
||||
pathIndex = queryIndex;
|
||||
}
|
||||
} else {
|
||||
pathIndex = schemeIndex + 1;
|
||||
}
|
||||
|
||||
indices[SCHEME_COLON] = schemeIndex;
|
||||
indices[PATH] = pathIndex;
|
||||
indices[QUERY] = queryIndex;
|
||||
indices[FRAGMENT] = fragmentIndex;
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the relative path from a base URI to a target URI.
|
||||
*
|
||||
* @return The relative path from the base URI to the target URI, or {@code targetUri} if the URIs
|
||||
* have different schemes or authorities.
|
||||
*/
|
||||
@UnstableApi
|
||||
public static String getRelativePath(Uri baseUri, Uri targetUri) {
|
||||
if (baseUri.isOpaque() || targetUri.isOpaque()) {
|
||||
return targetUri.toString();
|
||||
}
|
||||
|
||||
String baseUriScheme = baseUri.getScheme();
|
||||
String targetUriScheme = targetUri.getScheme();
|
||||
boolean isSameScheme =
|
||||
baseUriScheme == null
|
||||
? targetUriScheme == null
|
||||
: targetUriScheme != null && Ascii.equalsIgnoreCase(baseUriScheme, targetUriScheme);
|
||||
if (!isSameScheme || !Objects.equals(baseUri.getAuthority(), targetUri.getAuthority())) {
|
||||
// Different schemes or authorities, cannot find relative path, return targetUri.
|
||||
return targetUri.toString();
|
||||
}
|
||||
|
||||
List<String> basePathSegments = baseUri.getPathSegments();
|
||||
List<String> targetPathSegments = targetUri.getPathSegments();
|
||||
|
||||
int commonPrefixCount = 0;
|
||||
int minSize = min(basePathSegments.size(), targetPathSegments.size());
|
||||
|
||||
for (int i = 0; i < minSize; i++) {
|
||||
if (!basePathSegments.get(i).equals(targetPathSegments.get(i))) {
|
||||
break;
|
||||
}
|
||||
commonPrefixCount++;
|
||||
}
|
||||
|
||||
StringBuilder relativePath = new StringBuilder();
|
||||
for (int i = commonPrefixCount; i < basePathSegments.size(); i++) {
|
||||
relativePath.append("../");
|
||||
}
|
||||
|
||||
for (int i = commonPrefixCount; i < targetPathSegments.size(); i++) {
|
||||
relativePath.append(targetPathSegments.get(i));
|
||||
if (i < targetPathSegments.size() - 1) {
|
||||
relativePath.append("/");
|
||||
}
|
||||
}
|
||||
|
||||
return relativePath.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
class UrlEncodedQueryString implements UrlQueryString {
|
||||
private static final Pattern VALIDATION_PATTERN = Pattern.compile("[^\\/?&]+=[^\\/&]+");
|
||||
private static final Pattern URL_PREFIX = Pattern.compile("^[a-z.]+://.+$");
|
||||
@Nullable
|
||||
private String mQueryPrefix;
|
||||
@Nullable
|
||||
private UrlEncodedQueryStringBase mQueryString;
|
||||
private String mUrl;
|
||||
|
||||
|
||||
public static boolean isValidUrl(String url) {
|
||||
if (url == null || url.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Matcher m = URL_PREFIX.matcher(url);
|
||||
return m.matches();
|
||||
}
|
||||
|
||||
private UrlEncodedQueryString(String url) {
|
||||
if (url == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mUrl = url;
|
||||
|
||||
if (isValidUrl(url)) {
|
||||
URI parsedUrl = getURI(url);
|
||||
if (parsedUrl != null) {
|
||||
mQueryPrefix = String.format("%s://%s%s", parsedUrl.getScheme(), parsedUrl.getHost(), parsedUrl.getPath());
|
||||
mQueryString = UrlEncodedQueryStringBase.parse(parsedUrl);
|
||||
}
|
||||
} else { // Only query
|
||||
mQueryString = UrlEncodedQueryStringBase.parse(url);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private URI getURI(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fix illegal character exception. E.g.
|
||||
// https://www.youtube.com/results?search_query=Джентльмены удачи
|
||||
// https://www.youtube.com/results?search_query=|FR|+Mrs.+Doubtfire
|
||||
// https://youtu.be/wTw-jreMgCk\ (last char isn't valid)
|
||||
// https://m.youtube.com/watch?v=JsY3_Va6uqI&feature=emb_title###&Urj7svfj=&Rkj2f3jk=&Czj1i9k6= (# isn't valid)
|
||||
return new URI(url.length() > 100 ? // OOM fix: don't replace long string
|
||||
url : url
|
||||
.replace(" ", "+")
|
||||
.replace("|", "%7C")
|
||||
.replace("\\", "/")
|
||||
.replace("#", "")
|
||||
);
|
||||
} catch (URISyntaxException e) {
|
||||
//throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static UrlEncodedQueryString parse(String url) {
|
||||
return new UrlEncodedQueryString(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
if (mQueryString != null) {
|
||||
mQueryString.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
return mQueryString != null ? mQueryString.get(key) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
String val = get(key);
|
||||
return val != null ? Float.parseFloat(val) : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
if (mQueryString != null) {
|
||||
mQueryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
if (mQueryString == null) {
|
||||
return mUrl != null ? mUrl : "";
|
||||
}
|
||||
|
||||
return mQueryPrefix != null ? String.format("%s?%s", mQueryPrefix, mQueryString) : mQueryString.toString();
|
||||
}
|
||||
|
||||
public static boolean matchAll(String input, Pattern... patterns) {
|
||||
for (Pattern pattern : patterns) {
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean matchAll(String input, String... regex) {
|
||||
for (String reg : regex) {
|
||||
Pattern pattern = Pattern.compile(reg);
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check query string
|
||||
*/
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
if (mUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchAll(mUrl, VALIDATION_PATTERN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return mUrl == null || mUrl.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return mQueryString != null && mQueryString.contains(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Represents a www-form-urlencoded query string containing an (ordered) list of parameters.
|
||||
* <p>
|
||||
* An instance of this class represents a query string encoded using the
|
||||
* <code>www-form-urlencoded</code> encoding scheme, as defined by <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01 Specification:
|
||||
* application/x-www-form-urlencoded</a>, and <a
|
||||
* href="http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.2.2">HTML 4.01
|
||||
* Specification: Ampersands in URI attribute values</a>. This is a common encoding scheme of the
|
||||
* query component of a URI, though the <a href="http://www.ietf.org/rfc/rfc2396.txt">RFC 2396 URI
|
||||
* specification</a> itself does not define a specific format for the query component.
|
||||
* <p>
|
||||
* This class provides static methods for <a href="#create()">creating</a> UrlEncodedQueryString
|
||||
* instances by <a href="#parse(java.lang.CharSequence)">parsing</a> URI and string forms. It can
|
||||
* then be used to create, retrieve, update and delete parameters, and to re-compareAndApply the query string
|
||||
* back to an existing URI.
|
||||
* <p>
|
||||
* <h4>Encoding and decoding</h4> UrlEncodedQueryString automatically encodes and decodes parameter
|
||||
* names and values to and from <code>www-form-urlencoded</code> encoding by using
|
||||
* <code>java.net.URLEncoder</code> and <code>java.net.URLDecoder</code>, which follow the <a
|
||||
* href="http://www.w3.org/TR/html40/appendix/notes.html#non-ascii-chars"> HTML 4.01 Specification:
|
||||
* Non-ASCII characters in URI attribute values</a> recommendation.
|
||||
* <h4>Multivalued parameters</h4> Often, parameter names are unique across the name/value pairs of
|
||||
* a <code>www-form-urlencoded</code> query string. However, it is permitted for the same parameter
|
||||
* name to appear in multiple name/value pairs, denoting that a single parameter has multiple
|
||||
* values. This less common use case can lead to ambiguity when adding parameters - is the 'add' a
|
||||
* 'replace' (of an existing parameter, if one with the same name already exists) or an 'append'
|
||||
* (potentially creating a multivalued parameter, if one with the same name already exists)?
|
||||
* <p>
|
||||
* This requirement significantly shapes the <code>UrlEncodedQueryString</code> API. In particular
|
||||
* there are:
|
||||
* <ul>
|
||||
* <li><code>set</code> methods for setting a parameter, potentially replacing an existing value
|
||||
* <li><code>append</code> methods for adding a parameter, potentially creating a multivalued
|
||||
* parameter
|
||||
* <li><code>get</code> methods for returning a single value, even if the parameter has multiple
|
||||
* values
|
||||
* <li><code>getValues</code> methods for returning multiple values
|
||||
* </ul>
|
||||
* <h4>Retrieving parameters</h4> UrlEncodedQueryString can be used to parse and retrieve parameters
|
||||
* from a query string by passing either a URI or a query string:
|
||||
* <p>
|
||||
* <code>
|
||||
* URI uri = new URI("http://java.sun.com?forum=2");<br/>
|
||||
* UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);<br/>
|
||||
* System.out.println(queryString.get("forum"));<br/>
|
||||
* </code>
|
||||
* <h4>Modifying parameters</h4> UrlEncodedQueryString can be used to set, append or remove
|
||||
* parameters from a query string:
|
||||
* <p>
|
||||
* <code>
|
||||
* URI uri = new URI("/forum/article.jsp?id=2&para=4");<br/>
|
||||
* UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);<br/>
|
||||
* queryString.set("id", 3);<br/>
|
||||
* queryString.remove("para");<br/>
|
||||
* System.out.println(queryString);<br/>
|
||||
* </code>
|
||||
* <p>
|
||||
* When modifying parameters, the ordering of existing parameters is maintained. Parameters are
|
||||
* <code>set</code> and <code>removed</code> in-place, while <code>appended</code> parameters are
|
||||
* added to the end of the query string.
|
||||
* <h4>Applying the Query</h4> UrlEncodedQueryString can be used to compareAndApply a modified query string
|
||||
* back to a URI, creating a new URI:
|
||||
* <p>
|
||||
* <code>
|
||||
* URI uri = new URI("/forum/article.jsp?id=2");<br/>
|
||||
* UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);<br/>
|
||||
* queryString.set("id", 3);<br/>
|
||||
* uri = queryString.compareAndApply(uri);<br/>
|
||||
* </code>
|
||||
* <p>
|
||||
* When reconstructing query strings, there are two valid separator parameters defined by the W3C
|
||||
* (ampersand "&" and semicolon ";"), with ampersand being the most common. The
|
||||
* <code>compareAndApply</code> and <code>toString</code> methods both default to using an ampersand, with
|
||||
* overloaded forms for using a semicolon.
|
||||
* <h4>Thread Safety</h4> This implementation is not synchronized. If multiple threads access a
|
||||
* query string concurrently, and at least one of the threads modifies the query string, it must be
|
||||
* synchronized externally. This is typically accomplished by synchronizing on some object that
|
||||
* naturally encapsulates the query string.
|
||||
*
|
||||
* @author Richard Kennard
|
||||
* @version 1.2
|
||||
*/
|
||||
|
||||
class UrlEncodedQueryStringBase {
|
||||
|
||||
//
|
||||
// Public statics
|
||||
//
|
||||
|
||||
/**
|
||||
* Enumeration of recommended www-form-urlencoded separators.
|
||||
* <p>
|
||||
* Recommended separators are defined by <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a> and <a
|
||||
* href="http://www.w3.org/TR/html401/appendix/notes.html#h-B.2.2">HTML 4.01 Specification:
|
||||
* Ampersands in URI attribute values</a>.
|
||||
* <p>
|
||||
* <em>All</em> separators are recognised when parsing query strings. <em>One</em> separator may
|
||||
* be passed to <code>toString</code> and <code>compareAndApply</code> when outputting query strings.
|
||||
*/
|
||||
|
||||
public static enum Separator {
|
||||
/**
|
||||
* An ampersand <code>&</code> - the separator recommended by <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a>.
|
||||
*/
|
||||
|
||||
AMPERSAND {
|
||||
|
||||
/**
|
||||
* Returns a String representation of this Separator.
|
||||
* <p>
|
||||
* The String representation matches that defined by the <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a>.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return "&";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A semicolon <code>;</code> - the separator recommended by <a
|
||||
* href="http://www.w3.org/TR/html401/appendix/notes.html#h-B.2.2">HTML 4.01 Specification:
|
||||
* Ampersands in URI attribute values</a>.
|
||||
*/
|
||||
|
||||
SEMICOLON {
|
||||
|
||||
/**
|
||||
* Returns a String representation of this Separator.
|
||||
* <p>
|
||||
* The String representation matches that defined by the <a
|
||||
* href="http://www.w3.org/TR/html401/appendix/notes.html#h-B.2.2">HTML 4.01
|
||||
* Specification: Ampersands in URI attribute values</a>.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return ";";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty UrlEncodedQueryString.
|
||||
* <p>
|
||||
* Calling <code>toString()</code> on the created instance will return an empty String.
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase create() {
|
||||
|
||||
return new UrlEncodedQueryStringBase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UrlEncodedQueryString from the given Map.
|
||||
* <p>
|
||||
* The order the parameters are created in corresponds to the iteration order of the Map.
|
||||
*
|
||||
* @param parameterMap
|
||||
* <code>Map</code> containing parameter names and values.
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase create(Map<String, List<String>> parameterMap ) {
|
||||
|
||||
UrlEncodedQueryStringBase queryString = new UrlEncodedQueryStringBase();
|
||||
|
||||
// Defensively copy the List<String>'s
|
||||
|
||||
for ( Map.Entry<String, List<String>> entry : parameterMap.entrySet() ) {
|
||||
queryString.queryMap.put( entry.getKey(), new ArrayList<String>( entry.getValue() ) );
|
||||
}
|
||||
|
||||
return queryString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UrlEncodedQueryString by parsing the given query string.
|
||||
* <p>
|
||||
* This method assumes the given string is the <code>www-form-urlencoded</code> query component
|
||||
* of a URI. When parsing, all <a href="UrlEncodedQueryString.Separator.html">Separators</a> are
|
||||
* recognised.
|
||||
* <p>
|
||||
* The result of calling this method with a string that is not <code>www-form-urlencoded</code>
|
||||
* (eg. passing an entire URI, not just its query string) will likely be mismatched parameter
|
||||
* names.
|
||||
*
|
||||
* @param query
|
||||
* query string to be parsed
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase parse(final CharSequence query ) {
|
||||
|
||||
UrlEncodedQueryStringBase queryString = new UrlEncodedQueryStringBase();
|
||||
|
||||
// Note: import to call appendOrSet with 'true', in
|
||||
// case the given query contains multi-valued parameters
|
||||
|
||||
queryString.appendOrSet( query, true );
|
||||
|
||||
return queryString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UrlEncodedQueryString by extracting and parsing the query component from the given
|
||||
* URI.
|
||||
* <p>
|
||||
* This method assumes the query component is <code>www-form-urlencoded</code>. When parsing,
|
||||
* all separators from the Separators enum are recognised.
|
||||
* <p>
|
||||
* The result of calling this method with a query component that is not
|
||||
* <code>www-form-urlencoded</code> will likely be mismatched parameter names.
|
||||
*
|
||||
* @param uri
|
||||
* URI to be parsed
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase parse(final URI uri ) {
|
||||
|
||||
// Note: use uri.getRawQuery, not uri.getQuery, in case the
|
||||
// query parameters contain encoded ampersands (%26)
|
||||
|
||||
return parse( uri.getRawQuery() );
|
||||
}
|
||||
|
||||
//
|
||||
// Private statics
|
||||
//
|
||||
|
||||
/**
|
||||
* Separators to honour when parsing query strings.
|
||||
* <p>
|
||||
* <em>All</em> Separators are recognized when parsing parameters, regardless of what the user
|
||||
* later nominates as their <code>toString</code> output parameter.
|
||||
*/
|
||||
|
||||
private static final String PARSE_PARAMETER_SEPARATORS = String.valueOf( Separator.AMPERSAND ) + Separator.SEMICOLON;
|
||||
|
||||
//
|
||||
// Private members
|
||||
//
|
||||
|
||||
/**
|
||||
* Map of query parameters.
|
||||
*/
|
||||
|
||||
// Note: we initialize this Map upon object creation because, realistically, it
|
||||
// is always going to be needed (eg. there is little point lazy-initializing it)
|
||||
private final Map<String, List<String>> queryMap = new LinkedHashMap<String, List<String>>();
|
||||
|
||||
//
|
||||
// Public methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns the value of the named parameter as a String. Returns <code>null</code> if the
|
||||
* parameter does not exist, or exists but has a <code>null</code> value (see {@link #contains
|
||||
* contains}).
|
||||
* <p>
|
||||
* You should only use this method when you are sure the parameter has only one value. If the
|
||||
* parameter might have more than one value, use <a
|
||||
* href="#getValues(java.lang.String)">getValues</a>.
|
||||
* <p>
|
||||
* If you use this method with a multivalued parameter, the value returned is equal to the first
|
||||
* value in the List returned by <a href="#getValues(java.lang.String)">getValues</a>.
|
||||
*
|
||||
* @param name
|
||||
* <code>String</code> specifying the name of the parameter
|
||||
* @return <code>String</code> representing the single value of the parameter, or
|
||||
* <code>null</code> if the parameter does not exist or exists but with a null value
|
||||
* (see {@link #contains contains}).
|
||||
*/
|
||||
|
||||
public String get( final String name ) {
|
||||
|
||||
List<String> parameters = getValues( name );
|
||||
|
||||
if ( parameters == null || parameters.isEmpty() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parameters.get( 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the named parameter exists.
|
||||
* <p>
|
||||
* This can be useful to distinguish between a parameter not existing, and a parameter existing
|
||||
* but with a <code>null</code> value (eg. <code>foo=1&bar</code>). This is distinct from a
|
||||
* parameter existing with a value of the empty String (eg. <code>foo=1&bar=</code>).
|
||||
*/
|
||||
|
||||
public boolean contains( final String name ) {
|
||||
|
||||
return this.queryMap.containsKey( name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an <code>Iterator</code> of <code>String</code> objects containing the names of the
|
||||
* parameters. If there are no parameters, the method returns an empty Iterator. For names with
|
||||
* multiple values, only one copy of the name is returned.
|
||||
*
|
||||
* @return an <code>Iterator</code> of <code>String</code> objects, each String containing the
|
||||
* name of a parameter; or an empty Iterator if there are no parameters
|
||||
*/
|
||||
|
||||
public Iterator<String> getNames() {
|
||||
|
||||
return this.queryMap.keySet().iterator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a List of <code>String</code> objects containing all of the values the named
|
||||
* parameter has, or <code>null</code> if the parameter does not exist.
|
||||
* <p>
|
||||
* If the parameter has a single value, the List has a size of 1.
|
||||
*
|
||||
* @param name
|
||||
* name of the parameter to retrieve
|
||||
* @return a List of String objects containing the parameter's values, or <code>null</code> if
|
||||
* the paramater does not exist
|
||||
*/
|
||||
|
||||
public List<String> getValues( final String name ) {
|
||||
|
||||
return this.queryMap.get( name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a mutable <code>Map</code> of the query parameters.
|
||||
*
|
||||
* @return <code>Map</code> containing parameter names as keys and parameter values as map
|
||||
* values. The keys in the parameter map are of type <code>String</code>. The values in
|
||||
* the parameter map are Lists of type <code>String</code>, and their ordering is
|
||||
* consistent with their ordering in the query string. Will never return
|
||||
* <code>null</code>.
|
||||
*/
|
||||
|
||||
public Map<String, List<String>> getMap() {
|
||||
|
||||
LinkedHashMap<String, List<String>> map = new LinkedHashMap<String, List<String>>();
|
||||
|
||||
// Defensively copy the List<String>'s
|
||||
|
||||
for ( Map.Entry<String, List<String>> entry : this.queryMap.entrySet() ) {
|
||||
List<String> listValues = entry.getValue();
|
||||
map.put( entry.getKey(), new ArrayList<String>( listValues ) );
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, they will be replaced with a single
|
||||
* parameter with the given value. If no such parameters exist, one will be added.
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, the parameter is removed
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase set(final String name, final String value ) {
|
||||
|
||||
appendOrSet( name, value, false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, they will be replaced with a single
|
||||
* parameter with the given value. If no such parameters exist, one will be added.
|
||||
* <p>
|
||||
* This version of <code>set</code> accepts a <code>Number</code> suitable for auto-boxing. For
|
||||
* example:
|
||||
* <p>
|
||||
* <code>
|
||||
* queryString.set( "id", 3 );<br/>
|
||||
* </code>
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, the parameter is removed
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase set(final String name, final Number value ) {
|
||||
|
||||
if ( value == null ) {
|
||||
remove( name );
|
||||
return this;
|
||||
}
|
||||
|
||||
appendOrSet( name, value.toString(), false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets query parameters from a <code>www-form-urlencoded</code> string.
|
||||
* <p>
|
||||
* The given string is assumed to be in <code>www-form-urlencoded</code> format. The result of
|
||||
* passing a string not in <code>www-form-urlencoded</code> format (eg. passing an entire URI,
|
||||
* not just its query string) will likely be mismatched parameter names.
|
||||
* <p>
|
||||
* The given string is parsed into named parameters, and each is added to the existing
|
||||
* parameters. If a parameter with the same name already exists, it is replaced with a single
|
||||
* parameter with the given value. If the same parameter name appears more than once in the
|
||||
* given string, it is stored as a multivalued parameter. When parsing, all <a
|
||||
* href="UrlEncodedQueryString.Separator.html">Separators</a> are recognised.
|
||||
*
|
||||
* @param query
|
||||
* <code>www-form-urlencoded</code> string. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase set(final String query ) {
|
||||
|
||||
appendOrSet( query, false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, their value will be preserved and the
|
||||
* given value will be stored as a multivalued parameter. If no such parameters exist, one will
|
||||
* be added.
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase append(final String name, final String value ) {
|
||||
|
||||
appendOrSet( name, value, true );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, their value will be preserved and the
|
||||
* given value will be stored as a multivalued parameter. If no such parameters exist, one will
|
||||
* be added.
|
||||
* <p>
|
||||
* This version of <code>append</code> accepts a <code>Number</code> suitable for auto-boxing.
|
||||
* For example:
|
||||
* <p>
|
||||
* <code>
|
||||
* queryString.append( "id", 3 );<br/>
|
||||
* </code>
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase append(final String name, final Number value ) {
|
||||
|
||||
appendOrSet( name, value.toString(), true );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends query parameters from a <code>www-form-urlencoded</code> string.
|
||||
* <p>
|
||||
* The given string is assumed to be in <code>www-form-urlencoded</code> format. The result of
|
||||
* passing a string not in <code>www-form-urlencoded</code> format (eg. passing an entire URI,
|
||||
* not just its query string) will likely be mismatched parameter names.
|
||||
* <p>
|
||||
* The given string is parsed into named parameters, and appended to the existing parameters. If
|
||||
* a parameter with the same name already exists, or if the same parameter name appears more
|
||||
* than once in the given string, it is stored as a multivalued parameter. When parsing, all <a
|
||||
* href="UrlEncodedQueryString.Separator.html">Separators</a> are recognised.
|
||||
*
|
||||
* @param query
|
||||
* <code>www-form-urlencoded</code> string. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase append(final String query ) {
|
||||
|
||||
appendOrSet( query, true );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the query string is empty.
|
||||
*
|
||||
* @return true if the query string has no parameters
|
||||
*/
|
||||
|
||||
public boolean isEmpty() {
|
||||
|
||||
return queryMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the named query parameter.
|
||||
* <p>
|
||||
* If the parameter has multiple values, all its values are removed.
|
||||
*
|
||||
* @param name
|
||||
* name of the parameter to remove
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase remove(final String name ) {
|
||||
|
||||
appendOrSet( name, null, false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the query string to the given URI.
|
||||
* <p>
|
||||
* A copy of the given URI is taken and its existing query string, if there is one, is replaced.
|
||||
* The query string parameters are separated by <code>Separator.Ampersand</code>.
|
||||
*
|
||||
* @param uri
|
||||
* URI to copy and update
|
||||
* @return a copy of the given URI, with an updated query string
|
||||
*/
|
||||
|
||||
public URI apply( URI uri ) {
|
||||
|
||||
return apply( uri, Separator.AMPERSAND );
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the query string to the given URI, using the given separator between parameters.
|
||||
* <p>
|
||||
* A copy of the given URI is taken and its existing query string, if there is one, is replaced.
|
||||
* The query string parameters are separated using the given <code>Separator</code>.
|
||||
*
|
||||
* @param uri
|
||||
* URI to copy and update
|
||||
* @param separator
|
||||
* separator to use between parameters
|
||||
* @return a copy of the given URI, with an updated query string
|
||||
*/
|
||||
|
||||
public URI apply( URI uri, Separator separator ) {
|
||||
|
||||
// Note this code is essentially a copy of 'java.net.URI.defineString',
|
||||
// which is private. We cannot use the 'new URI( scheme, userInfo, ... )' or
|
||||
// 'new URI( scheme, authority, ... )' constructors because they double
|
||||
// encode the query string using 'java.net.URI.quote'
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if ( uri.getScheme() != null ) {
|
||||
builder.append( uri.getScheme() );
|
||||
builder.append( ':' );
|
||||
}
|
||||
if ( uri.getHost() != null ) {
|
||||
builder.append( "//" );
|
||||
if ( uri.getUserInfo() != null ) {
|
||||
builder.append( uri.getUserInfo() );
|
||||
builder.append( '@' );
|
||||
}
|
||||
builder.append( uri.getHost() );
|
||||
if ( uri.getPort() != -1 ) {
|
||||
builder.append( ':' );
|
||||
builder.append( uri.getPort() );
|
||||
}
|
||||
} else if ( uri.getAuthority() != null ) {
|
||||
builder.append( "//" );
|
||||
builder.append( uri.getAuthority() );
|
||||
}
|
||||
if ( uri.getPath() != null ) {
|
||||
builder.append( uri.getPath() );
|
||||
}
|
||||
|
||||
String query = toString( separator );
|
||||
if ( query.length() != 0 ) {
|
||||
builder.append( '?' );
|
||||
builder.append( query );
|
||||
}
|
||||
if ( uri.getFragment() != null ) {
|
||||
builder.append( '#' );
|
||||
builder.append( uri.getFragment() );
|
||||
}
|
||||
|
||||
try {
|
||||
return new URI( builder.toString() );
|
||||
} catch ( URISyntaxException e ) {
|
||||
// Can never happen, as the given URI will always be valid,
|
||||
// and getQuery() will always return a valid query string
|
||||
|
||||
throw new RuntimeException( e );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the specified object with this UrlEncodedQueryString for equality.
|
||||
* <p>
|
||||
* Returns <code>true</code> if the given object is also a UrlEncodedQueryString and the two
|
||||
* UrlEncodedQueryStrings have the same parameters. More formally, two UrlEncodedQueryStrings
|
||||
* <code>t1</code> and <code>t2</code> represent the same UrlEncodedQueryString if
|
||||
* <code>t1.toString().equals(t2.toString())</code>. This ensures that the <code>equals</code>
|
||||
* method checks the ordering, as well as the existence, of every parameter.
|
||||
* <p>
|
||||
* Clients interested only in the existence, not the ordering, of parameters are recommended to
|
||||
* use <code>getMap().equals</code>.
|
||||
* <p>
|
||||
* This implementation first checks if the specified object is this UrlEncodedQueryString; if so
|
||||
* it returns <code>true</code>. Then, it checks if the specified object is a
|
||||
* UrlEncodedQueryString whose toString() is identical to the toString() of this
|
||||
* UrlEncodedQueryString; if not, it returns <code>false</code>. Otherwise, it returns
|
||||
* <code>true</code>
|
||||
*
|
||||
* @param obj
|
||||
* object to be compared for equality with this UrlEncodedQueryString.
|
||||
* @return <code>true</code> if the specified object is equal to this UrlEncodedQueryString.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public boolean equals( Object obj ) {
|
||||
|
||||
if ( obj == this ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( !( obj instanceof UrlEncodedQueryStringBase) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String query = toString();
|
||||
String thatQuery = ( (UrlEncodedQueryStringBase) obj ).toString();
|
||||
|
||||
return query.equals( thatQuery );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a hash code value for the UrlEncodedQueryString.
|
||||
* <p>
|
||||
* The hash code of the UrlEncodedQueryString is defined to be the hash code of the
|
||||
* <code>String</code> returned by toString(). This ensures the ordering, as well as the
|
||||
* existence, of parameters is taken into account.
|
||||
* <p>
|
||||
* Clients interested only in the existence, not the ordering, of parameters are recommended to
|
||||
* use <code>getMap().hashCode</code>.
|
||||
*
|
||||
* @return a hash code value for this UrlEncodedQueryString.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
|
||||
return toString().hashCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a <code>www-form-urlencoded</code> string of the query parameters.
|
||||
* <p>
|
||||
* The HTML specification recommends two parameter separators in <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a> and <a
|
||||
* href="http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.2.2">HTML 4.01
|
||||
* Specification: Ampersands in URI attribute values</a>. Of those, the ampersand is the more
|
||||
* commonly used and this method defaults to that.
|
||||
*
|
||||
* @return <code>www-form-urlencoded</code> string, or <code>null</code> if there are no
|
||||
* parameters.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return toString( Separator.AMPERSAND );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a <code>www-form-urlencoded</code> string of the query parameters, using the given
|
||||
* separator between parameters.
|
||||
*
|
||||
* @param separator
|
||||
* separator to use between parameters
|
||||
* @return <code>www-form-urlencoded</code> string, or an empty String if there are no
|
||||
* parameters
|
||||
*/
|
||||
|
||||
// Note: this method takes a Separator, not just any String. Taking any String may
|
||||
// be useful in some circumstances (eg. you could pass '&' to generate query
|
||||
// strings for use in HTML pages) but would break the implied contract between
|
||||
// toString() and parse() (eg. you can always parse() what you toString() ).
|
||||
//
|
||||
// It was thought better to leave it to the user to explictly break this contract
|
||||
// (eg. toString().replaceAll( '&', '&' ))
|
||||
public String toString( Separator separator ) {
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for ( String name : this.queryMap.keySet() ) {
|
||||
for ( String value : this.queryMap.get( name ) ) {
|
||||
if ( builder.length() != 0 ) {
|
||||
builder.append( separator );
|
||||
}
|
||||
|
||||
// Encode names and values. Do this in toString(), rather than
|
||||
// append/set, so that the Map always contains the
|
||||
// raw, unencoded values
|
||||
|
||||
try {
|
||||
builder.append( URLEncoder.encode( name, "UTF-8" ) );
|
||||
|
||||
if ( value != null ) {
|
||||
builder.append( '=' );
|
||||
builder.append( URLEncoder.encode( value, "UTF-8" ) );
|
||||
}
|
||||
} catch ( UnsupportedEncodingException e ) {
|
||||
// Should never happen. UTF-8 should always be available
|
||||
// according to Java spec
|
||||
|
||||
throw new RuntimeException( e );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
//
|
||||
// Private methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Private constructor.
|
||||
* <p>
|
||||
* Clients should use one of the <code>create</code> or <code>parse</code> methods to create a
|
||||
* <code>UrlEncodedQueryString</code>.
|
||||
*/
|
||||
|
||||
private UrlEncodedQueryStringBase() {
|
||||
|
||||
// Can never be called
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for append and set
|
||||
*
|
||||
* @param name
|
||||
* the parameter's name
|
||||
* @param value
|
||||
* the parameter's value
|
||||
* @param append
|
||||
* whether to append (or set)
|
||||
*/
|
||||
|
||||
private void appendOrSet( final String name, final String value, final boolean append ) {
|
||||
|
||||
if ( name == null ) {
|
||||
throw new NullPointerException( "name" );
|
||||
}
|
||||
|
||||
// If we're appending, and there's an existing parameter...
|
||||
|
||||
if ( append ) {
|
||||
List<String> listValues = this.queryMap.get( name );
|
||||
|
||||
// ...add to it
|
||||
|
||||
if ( listValues != null ) {
|
||||
listValues.add( value );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ...otherwise, if we're setting and the value is null...
|
||||
|
||||
else if ( value == null ) {
|
||||
// ...remove it
|
||||
|
||||
this.queryMap.remove( name );
|
||||
return;
|
||||
}
|
||||
|
||||
// ...otherwise, create a new one
|
||||
|
||||
List<String> listValues = new ArrayList<String>();
|
||||
listValues.add( value );
|
||||
|
||||
this.queryMap.put( name, listValues );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for append and set
|
||||
*
|
||||
* @param parameters
|
||||
* <code>www-form-urlencoded</code> string
|
||||
* @param append
|
||||
* whether to append (or set)
|
||||
*/
|
||||
|
||||
private void appendOrSet( final CharSequence parameters, final boolean append ) {
|
||||
|
||||
// Nothing to do?
|
||||
|
||||
if ( parameters == null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Note we always parse using PARSE_PARAMETER_SEPARATORS, regardless
|
||||
// of what the user later nominates as their output parameter
|
||||
// separator using toString()
|
||||
|
||||
StringTokenizer tokenizer = new StringTokenizer( parameters.toString(), PARSE_PARAMETER_SEPARATORS );
|
||||
|
||||
Set<String> setAlreadyParsed = null;
|
||||
|
||||
while ( tokenizer.hasMoreTokens() ) {
|
||||
String parameter = tokenizer.nextToken();
|
||||
|
||||
int indexOf = parameter.indexOf( '=' );
|
||||
|
||||
String name;
|
||||
String value;
|
||||
|
||||
try {
|
||||
if ( indexOf == -1 ) {
|
||||
name = parameter;
|
||||
value = null;
|
||||
} else {
|
||||
name = parameter.substring( 0, indexOf );
|
||||
value = parameter.substring( indexOf + 1 );
|
||||
}
|
||||
|
||||
// Decode the name if necessary (i.e. %70age=1 becomes page=1)
|
||||
|
||||
name = URLDecoder.decode( name, "UTF-8" );
|
||||
|
||||
// When not appending, the first time we see a given
|
||||
// name it is important to remove it from the existing
|
||||
// parameters
|
||||
|
||||
if ( !append ) {
|
||||
if ( setAlreadyParsed == null ) {
|
||||
setAlreadyParsed = new HashSet<String>();
|
||||
}
|
||||
|
||||
if ( !setAlreadyParsed.contains( name ) ) {
|
||||
remove( name );
|
||||
}
|
||||
|
||||
setAlreadyParsed.add( name );
|
||||
}
|
||||
|
||||
if ( value != null ) {
|
||||
value = URLDecoder.decode( value, "UTF-8" );
|
||||
}
|
||||
|
||||
appendOrSet( name, value, true );
|
||||
} catch ( UnsupportedEncodingException e ) {
|
||||
// Should never happen. UTF-8 should always be available
|
||||
// according to Java spec
|
||||
|
||||
throw new RuntimeException( e );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
public interface UrlQueryString {
|
||||
void remove(String key);
|
||||
String get(String key);
|
||||
float getFloat(String key);
|
||||
void set(String key, String value);
|
||||
void set(String key, int value);
|
||||
void set(String key, float value);
|
||||
boolean isEmpty();
|
||||
boolean isValid();
|
||||
boolean contains(String key);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
|
||||
public class UrlQueryStringFactory {
|
||||
public static UrlQueryString parse(Uri url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parse(url.toString());
|
||||
}
|
||||
|
||||
public static String toString(InputStream in) {
|
||||
try {
|
||||
int bufsize = 8196;
|
||||
char[] cbuf = new char[bufsize];
|
||||
StringBuilder buf = new StringBuilder(bufsize);
|
||||
InputStreamReader reader = new InputStreamReader(in, "UTF-8");
|
||||
|
||||
int readBytes;
|
||||
while ((readBytes = reader.read(cbuf, 0, bufsize)) != -1) {
|
||||
buf.append(cbuf, 0, readBytes);
|
||||
}
|
||||
|
||||
return buf.toString();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
Log.e("UrlQueryStringFactory", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static UrlQueryString parse(InputStream urlContent) {
|
||||
return parse(toString(urlContent));
|
||||
}
|
||||
|
||||
//public static UrlQueryString parse(String url) {
|
||||
// UrlQueryString pathQueryString = PathQueryString.parse(url);
|
||||
//
|
||||
// if (pathQueryString.isValid()) {
|
||||
// return pathQueryString;
|
||||
// }
|
||||
//
|
||||
// UrlQueryString urlQueryString = UrlEncodedQueryString.parse(url);
|
||||
//
|
||||
// if (urlQueryString.isValid()) {
|
||||
// return urlQueryString;
|
||||
// }
|
||||
//
|
||||
// return NullQueryString.parse(url);
|
||||
//}
|
||||
|
||||
public static UrlQueryString parse(String url) {
|
||||
return CombinedQueryString.parse(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a set of interchangeable encoded versions of a media content component.
|
||||
*/
|
||||
public class AdaptationSet {
|
||||
|
||||
/**
|
||||
* Value of {@link #id} indicating no value is set.=
|
||||
*/
|
||||
public static final int ID_UNSET = -1;
|
||||
|
||||
/**
|
||||
* A non-negative identifier for the adaptation set that's unique in the scope of its containing
|
||||
* period, or {@link #ID_UNSET} if not specified.
|
||||
*/
|
||||
public final int id;
|
||||
|
||||
/**
|
||||
* The type of the adaptation set. One of the {@link androidx.media3.C}
|
||||
* {@code TRACK_TYPE_*} constants.
|
||||
*/
|
||||
public final int type;
|
||||
|
||||
/**
|
||||
* {@link Representation}s in the adaptation set.
|
||||
*/
|
||||
public final List<Representation> representations;
|
||||
|
||||
/**
|
||||
* @param id A non-negative identifier for the adaptation set that's unique in the scope of its
|
||||
* containing period, or {@link #ID_UNSET} if not specified.
|
||||
* @param type The type of the adaptation set. One of the {@link androidx.media3.C}
|
||||
* {@code TRACK_TYPE_*} constants.
|
||||
* @param representations {@link Representation}s in the adaptation set.
|
||||
*/
|
||||
public AdaptationSet(int id, int type, List<Representation> representations) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.representations = Collections.unmodifiableList(representations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||
|
||||
/**
|
||||
* A DASH in-MPD EventStream element, as defined by ISO/IEC 23009-1, 2nd edition, section 5.10.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final class EventStream {
|
||||
|
||||
/**
|
||||
* {@link EventMessage}s in the event stream.
|
||||
*/
|
||||
public final EventMessage[] events;
|
||||
|
||||
/**
|
||||
* Presentation time of the events in microsecond, sorted in ascending order.
|
||||
*/
|
||||
public final long[] presentationTimesUs;
|
||||
|
||||
/**
|
||||
* The scheme URI.
|
||||
*/
|
||||
public final String schemeIdUri;
|
||||
|
||||
/**
|
||||
* The value of the event stream. Use empty string if not defined in manifest.
|
||||
*/
|
||||
public final String value;
|
||||
|
||||
/**
|
||||
* The timescale in units per seconds, as defined in the manifest.
|
||||
*/
|
||||
public final long timescale;
|
||||
|
||||
public EventStream(String schemeIdUri, String value, long timescale, long[] presentationTimesUs,
|
||||
EventMessage[] events) {
|
||||
this.schemeIdUri = schemeIdUri;
|
||||
this.value = value;
|
||||
this.timescale = timescale;
|
||||
this.presentationTimesUs = presentationTimesUs;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* A constructed id of this {@link EventStream}. Equal to {@code schemeIdUri + "/" + value}.
|
||||
*/
|
||||
public String id() {
|
||||
return schemeIdUri + "/" + value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Encapsulates media content components over a contiguous period of time.
|
||||
*/
|
||||
public class Period {
|
||||
/**
|
||||
* The period identifier, if one exists.
|
||||
*/
|
||||
@Nullable
|
||||
public final String id;
|
||||
|
||||
/**
|
||||
* The start time of the period in milliseconds.
|
||||
*/
|
||||
public final long startMs;
|
||||
|
||||
/**
|
||||
* The adaptation sets belonging to the period.
|
||||
*/
|
||||
public final List<AdaptationSet> adaptationSets;
|
||||
|
||||
/**
|
||||
* @param id The period identifier. May be null.
|
||||
* @param startMs The start time of the period in milliseconds.
|
||||
* @param adaptationSets The adaptation sets belonging to the period.
|
||||
*/
|
||||
public Period(@Nullable String id, long startMs, List<AdaptationSet> adaptationSets) {
|
||||
this.id = id;
|
||||
this.startMs = startMs;
|
||||
this.adaptationSets = Collections.unmodifiableList(adaptationSets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first adaptation set of a given type, or {@link C#INDEX_UNSET} if no
|
||||
* adaptation set of the specified type exists.
|
||||
*
|
||||
* @param type An adaptation set type.
|
||||
* @return The index of the first adaptation set of the specified type, or {@link C#INDEX_UNSET}.
|
||||
*/
|
||||
public int getAdaptationSetIndex(int type) {
|
||||
int adaptationCount = adaptationSets.size();
|
||||
for (int i = 0; i < adaptationCount; i++) {
|
||||
if (adaptationSets.get(i).type == type) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.futo.platformplayer.sabr.UriUtil;
|
||||
|
||||
/**
|
||||
* Defines a range of data located at a reference uri.
|
||||
*/
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public final class RangedUri {
|
||||
|
||||
/**
|
||||
* The (zero based) index of the first byte of the range.
|
||||
*/
|
||||
public final long start;
|
||||
|
||||
/**
|
||||
* The length of the range, or {@link C#LENGTH_UNSET} to indicate that the range is unbounded.
|
||||
*/
|
||||
public final long length;
|
||||
|
||||
private final String referenceUri;
|
||||
|
||||
private int hashCode;
|
||||
|
||||
/**
|
||||
* Constructs an ranged uri.
|
||||
*
|
||||
* @param referenceUri The reference uri.
|
||||
* @param start The (zero based) index of the first byte of the range.
|
||||
* @param length The length of the range, or {@link C#LENGTH_UNSET} to indicate that the range is
|
||||
* unbounded.
|
||||
*/
|
||||
public RangedUri(@Nullable String referenceUri, long start, long length) {
|
||||
this.referenceUri = referenceUri == null ? "" : referenceUri;
|
||||
this.start = start;
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved {@link Uri} represented by the instance.
|
||||
*
|
||||
* @param baseUri The base Uri.
|
||||
* @return The {@link Uri} represented by the instance.
|
||||
*/
|
||||
public Uri resolveUri(String baseUri) {
|
||||
return UriUtil.resolveToUri(baseUri, referenceUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved uri represented by the instance as a string.
|
||||
*
|
||||
* @param baseUri The base Uri.
|
||||
* @return The uri represented by the instance.
|
||||
*/
|
||||
public String resolveUriString(String baseUri) {
|
||||
return UriUtil.resolve(baseUri, referenceUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to merge this {@link RangedUri} with another and an optional common base uri.
|
||||
*
|
||||
* <p>A merge is successful if both instances define the same {@link Uri} after resolution with
|
||||
* the base uri, and if one starts the byte after the other ends, forming a contiguous region with
|
||||
* no overlap.
|
||||
*
|
||||
* <p>If {@code other} is null then the merge is considered unsuccessful, and null is returned.
|
||||
*
|
||||
* @param other The {@link RangedUri} to merge.
|
||||
* @param baseUri The optional base Uri.
|
||||
* @return The merged {@link RangedUri} if the merge was successful. Null otherwise.
|
||||
*/
|
||||
public @Nullable RangedUri attemptMerge(@Nullable RangedUri other, String baseUri) {
|
||||
final String resolvedUri = resolveUriString(baseUri);
|
||||
if (other == null || !resolvedUri.equals(other.resolveUriString(baseUri))) {
|
||||
return null;
|
||||
} else if (length != C.LENGTH_UNSET && start + length == other.start) {
|
||||
return new RangedUri(resolvedUri, start,
|
||||
other.length == C.LENGTH_UNSET ? C.LENGTH_UNSET : length + other.length);
|
||||
} else if (other.length != C.LENGTH_UNSET && other.start + other.length == start) {
|
||||
return new RangedUri(resolvedUri, other.start,
|
||||
length == C.LENGTH_UNSET ? C.LENGTH_UNSET : other.length + length);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
if (hashCode == 0) {
|
||||
int result = 17;
|
||||
result = 31 * result + (int) start;
|
||||
result = 31 * result + (int) length;
|
||||
result = 31 * result + referenceUri.hashCode();
|
||||
hashCode = result;
|
||||
}
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null || getClass() != obj.getClass()) {
|
||||
return false;
|
||||
}
|
||||
RangedUri other = (RangedUri) obj;
|
||||
return this.start == other.start
|
||||
&& this.length == other.length
|
||||
&& referenceUri.equals(other.referenceUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RangedUri("
|
||||
+ "referenceUri="
|
||||
+ referenceUri
|
||||
+ ", start="
|
||||
+ start
|
||||
+ ", length="
|
||||
+ length
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import com.futo.platformplayer.sabr.SabrSegmentIndex;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.MultiSegmentBase;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SingleSegmentBase;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A SABR representation.
|
||||
*/
|
||||
public abstract class Representation {
|
||||
/**
|
||||
* A default value for {@link #revisionId}.
|
||||
*/
|
||||
public static final long REVISION_ID_DEFAULT = -1;
|
||||
|
||||
/**
|
||||
* Identifies the revision of the media contained within the representation. If the media can
|
||||
* change over time (e.g. as a result of it being re-encoded), then this identifier can be set to
|
||||
* uniquely identify the revision of the media. The timestamp at which the media was encoded is
|
||||
* often a suitable.
|
||||
*/
|
||||
public final long revisionId;
|
||||
/**
|
||||
* The format of the representation.
|
||||
*/
|
||||
public final Format format;
|
||||
/**
|
||||
* The base URL of the representation.
|
||||
*/
|
||||
public final String baseUrl;
|
||||
/**
|
||||
* The offset of the presentation timestamps in the media stream relative to media time.
|
||||
*/
|
||||
public final long presentationTimeOffsetUs;
|
||||
|
||||
private final RangedUri initializationUri;
|
||||
|
||||
public static Representation newInstance(
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase) {
|
||||
return newInstance(REVISION_ID_DEFAULT, format, baseUrl, segmentBase, null);
|
||||
}
|
||||
|
||||
public static Representation newInstance(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase) {
|
||||
return newInstance(revisionId, format, baseUrl, segmentBase, null);
|
||||
}
|
||||
|
||||
public static Representation newInstance(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase,
|
||||
String cacheKey) {
|
||||
if (segmentBase instanceof SingleSegmentBase) {
|
||||
return new SingleSegmentRepresentation(
|
||||
revisionId,
|
||||
format,
|
||||
baseUrl,
|
||||
(SingleSegmentBase) segmentBase,
|
||||
cacheKey,
|
||||
C.LENGTH_UNSET);
|
||||
} else if (segmentBase instanceof MultiSegmentBase) {
|
||||
return new MultiSegmentRepresentation(
|
||||
revisionId, format, baseUrl, (MultiSegmentBase) segmentBase);
|
||||
} else {
|
||||
throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or "
|
||||
+ "MultiSegmentBase");
|
||||
}
|
||||
}
|
||||
|
||||
private Representation(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase) {
|
||||
this.revisionId = revisionId;
|
||||
this.format = format;
|
||||
this.baseUrl = baseUrl;
|
||||
initializationUri = segmentBase.getInitialization(this);
|
||||
presentationTimeOffsetUs = segmentBase.getPresentationTimeOffsetUs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of the representation's initialization data,
|
||||
* or null if no initialization data exists.
|
||||
*/
|
||||
public RangedUri getInitializationUri() {
|
||||
return initializationUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of the representation's segment index, or
|
||||
* null if the representation provides an index directly.
|
||||
*/
|
||||
public abstract RangedUri getIndexUri();
|
||||
|
||||
/**
|
||||
* Returns an index if the representation provides one directly, or null otherwise.
|
||||
*/
|
||||
public abstract SabrSegmentIndex getIndex();
|
||||
|
||||
/** Returns a cache key for the representation if set, or null. */
|
||||
public abstract String getCacheKey();
|
||||
|
||||
/**
|
||||
* A DASH representation consisting of a single segment.
|
||||
*/
|
||||
public static class SingleSegmentRepresentation extends Representation {
|
||||
|
||||
/**
|
||||
* The uri of the single segment.
|
||||
*/
|
||||
public final Uri uri;
|
||||
|
||||
/**
|
||||
* The content length, or {@link C#LENGTH_UNSET} if unknown.
|
||||
*/
|
||||
public final long contentLength;
|
||||
|
||||
private final String cacheKey;
|
||||
private final RangedUri indexUri;
|
||||
private final SingleSegmentIndex segmentIndex;
|
||||
|
||||
public static SingleSegmentRepresentation newInstance(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String uri,
|
||||
long initializationStart,
|
||||
long initializationEnd,
|
||||
long indexStart,
|
||||
long indexEnd,
|
||||
String cacheKey,
|
||||
long contentLength) {
|
||||
RangedUri rangedUri = new RangedUri(null, initializationStart,
|
||||
initializationEnd - initializationStart + 1);
|
||||
SingleSegmentBase segmentBase = new SingleSegmentBase(rangedUri, 1, 0, indexStart,
|
||||
indexEnd - indexStart + 1);
|
||||
return new SingleSegmentRepresentation(
|
||||
revisionId, format, uri, segmentBase, cacheKey, contentLength);
|
||||
}
|
||||
|
||||
public SingleSegmentRepresentation(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SingleSegmentBase segmentBase,
|
||||
String cacheKey,
|
||||
long contentLength) {
|
||||
super(revisionId, format, baseUrl, segmentBase);
|
||||
this.uri = Uri.parse(baseUrl);
|
||||
this.indexUri = segmentBase.getIndex();
|
||||
this.cacheKey = cacheKey;
|
||||
this.contentLength = contentLength;
|
||||
// If we have an index uri then the index is defined externally, and we shouldn't return one
|
||||
// directly. If we don't, then we can't do better than an index defining a single segment.
|
||||
segmentIndex = indexUri != null ? null
|
||||
: new SingleSegmentIndex(new RangedUri(null, 0, contentLength));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getIndexUri() {
|
||||
return indexUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrSegmentIndex getIndex() {
|
||||
return segmentIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCacheKey() {
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A DASH representation consisting of multiple segments.
|
||||
*/
|
||||
public static class MultiSegmentRepresentation extends Representation
|
||||
implements SabrSegmentIndex {
|
||||
|
||||
private final MultiSegmentBase segmentBase;
|
||||
|
||||
/**
|
||||
* @param revisionId Identifies the revision of the content.
|
||||
* @param format The format of the representation.
|
||||
* @param baseUrl The base URL of the representation.
|
||||
* @param segmentBase The segment base underlying the representation.
|
||||
*/
|
||||
public MultiSegmentRepresentation(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
MultiSegmentBase segmentBase) {
|
||||
super(revisionId, format, baseUrl, segmentBase);
|
||||
this.segmentBase = segmentBase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getIndexUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrSegmentIndex getIndex() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCacheKey() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// DashSegmentIndex implementation.
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(long segmentIndex) {
|
||||
return segmentBase.getSegmentUrl(this, segmentIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
return segmentBase.getSegmentNum(timeUs, periodDurationUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeUs(long segmentIndex) {
|
||||
return segmentBase.getSegmentTimeUs(segmentIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs(long segmentIndex, long periodDurationUs) {
|
||||
return segmentBase.getSegmentDurationUs(segmentIndex, periodDurationUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFirstSegmentNum() {
|
||||
return segmentBase.getFirstSegmentNum();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return segmentBase.getSegmentCount(periodDurationUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return segmentBase.isExplicit();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.offline.FilterableManifest;
|
||||
import androidx.media3.common.StreamKey;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a SABR media presentation
|
||||
*/
|
||||
@UnstableApi
|
||||
public class SabrManifest implements FilterableManifest<SabrManifest> {
|
||||
/**
|
||||
* The {@code availabilityStartTime} value in milliseconds since epoch, or {@link C#TIME_UNSET} if
|
||||
* not present.
|
||||
*/
|
||||
public final long availabilityStartTimeMs;
|
||||
|
||||
/**
|
||||
* The duration of the presentation in milliseconds, or {@link C#TIME_UNSET} if not applicable.
|
||||
*/
|
||||
public final long durationMs;
|
||||
|
||||
/**
|
||||
* The {@code minBufferTime} value in milliseconds, or {@link C#TIME_UNSET} if not present.
|
||||
*/
|
||||
public final long minBufferTimeMs;
|
||||
|
||||
/**
|
||||
* The {@code timeShiftBufferDepth} value in milliseconds, or {@link C#TIME_UNSET} if not
|
||||
* present.
|
||||
*/
|
||||
public final long timeShiftBufferDepthMs;
|
||||
|
||||
/**
|
||||
* The {@code suggestedPresentationDelay} value in milliseconds, or {@link C#TIME_UNSET} if not
|
||||
* present.
|
||||
*/
|
||||
public final long suggestedPresentationDelayMs;
|
||||
|
||||
/**
|
||||
* The {@code publishTime} value in milliseconds since epoch, or {@link C#TIME_UNSET} if
|
||||
* not present.
|
||||
*/
|
||||
public final long publishTimeMs;
|
||||
|
||||
public final List<Period> periods;
|
||||
|
||||
/**
|
||||
* Whether the manifest has value "dynamic" for the {@code type} attribute.
|
||||
*/
|
||||
public final boolean dynamic;
|
||||
|
||||
/**
|
||||
* The {@code minimumUpdatePeriod} value in milliseconds, or {@link C#TIME_UNSET} if not
|
||||
* applicable.
|
||||
*/
|
||||
public final long minUpdatePeriodMs;
|
||||
|
||||
public SabrManifest(
|
||||
long availabilityStartTimeMs,
|
||||
long durationMs,
|
||||
long minBufferTimeMs,
|
||||
boolean dynamic,
|
||||
long minUpdatePeriodMs,
|
||||
long timeShiftBufferDepthMs,
|
||||
long suggestedPresentationDelayMs,
|
||||
long publishTimeMs,
|
||||
List<Period> periods) {
|
||||
this.availabilityStartTimeMs = availabilityStartTimeMs;
|
||||
this.durationMs = durationMs;
|
||||
this.minBufferTimeMs = minBufferTimeMs;
|
||||
this.dynamic = dynamic;
|
||||
this.minUpdatePeriodMs = minUpdatePeriodMs;
|
||||
this.timeShiftBufferDepthMs = timeShiftBufferDepthMs;
|
||||
this.suggestedPresentationDelayMs = suggestedPresentationDelayMs;
|
||||
this.publishTimeMs = publishTimeMs;
|
||||
this.periods = periods;
|
||||
}
|
||||
|
||||
public final int getPeriodCount() {
|
||||
return periods.size();
|
||||
}
|
||||
|
||||
public final Period getPeriod(int index) {
|
||||
return periods.get(index);
|
||||
}
|
||||
|
||||
public final long getPeriodDurationMs(int index) {
|
||||
return index == periods.size() - 1
|
||||
? (durationMs == C.TIME_UNSET ? C.TIME_UNSET : (durationMs - periods.get(index).startMs))
|
||||
: (periods.get(index + 1).startMs - periods.get(index).startMs);
|
||||
}
|
||||
|
||||
public final long getPeriodDurationUs(int index) {
|
||||
return C.msToUs(getPeriodDurationMs(index));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrManifest copy(List<StreamKey> streamKeys) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.DrmInitData;
|
||||
import androidx.media3.common.DrmInitData.SchemeData;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SegmentList;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SegmentTemplate;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SegmentTimelineElement;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SingleSegmentBase;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import com.liskovsoft.mediaserviceinterfaces.data.MediaFormat;
|
||||
import com.liskovsoft.mediaserviceinterfaces.data.MediaItemFormatInfo;
|
||||
import com.liskovsoft.mediaserviceinterfaces.data.MediaSubtitle;
|
||||
import com.liskovsoft.sharedutils.helpers.Helpers;
|
||||
import com.liskovsoft.sharedutils.mylogger.Log;
|
||||
import com.liskovsoft.youtubeapi.formatbuilders.mpdbuilder.MediaFormatComparator;
|
||||
import com.liskovsoft.youtubeapi.formatbuilders.utils.ITagUtils;
|
||||
import com.liskovsoft.youtubeapi.formatbuilders.utils.MediaFormatUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public class SabrManifestParser {
|
||||
private static final String TAG = SabrManifestParser.class.getSimpleName();
|
||||
private int mId;
|
||||
private static final String NULL_INDEX_RANGE = "0-0";
|
||||
private static final String NULL_CONTENT_LENGTH = "0";
|
||||
private static final int MAX_DURATION_SEC = 48 * 60 * 60;
|
||||
private MediaItemFormatInfo mFormatInfo;
|
||||
private Set<MediaFormat> mMP4Videos;
|
||||
private Set<MediaFormat> mWEBMVideos;
|
||||
private Map<String, Set<MediaFormat>> mMP4Audios;
|
||||
private Map<String, Set<MediaFormat>> mWEBMAudios;
|
||||
private List<MediaSubtitle> mSubs;
|
||||
|
||||
public SabrManifest parse(@NonNull MediaItemFormatInfo formatInfo) {
|
||||
mFormatInfo = formatInfo;
|
||||
MediaFormatComparator comp = new MediaFormatComparator();
|
||||
mMP4Videos = new TreeSet<>(comp);
|
||||
mWEBMVideos = new TreeSet<>(comp);
|
||||
mMP4Audios = new HashMap<>();
|
||||
mWEBMAudios = new HashMap<>();
|
||||
mSubs = new ArrayList<>();
|
||||
return parseSabrManifest(formatInfo);
|
||||
}
|
||||
|
||||
private SabrManifest parseSabrManifest(MediaItemFormatInfo formatInfo) {
|
||||
long availabilityStartTime = C.TIME_UNSET;
|
||||
long durationMs = getDurationMs(formatInfo);
|
||||
long minBufferTimeMs = 1500; // "PT1.500S"
|
||||
long timeShiftBufferDepthMs = C.TIME_UNSET;
|
||||
long suggestedPresentationDelayMs = C.TIME_UNSET;
|
||||
long publishTimeMs = C.TIME_UNSET;
|
||||
boolean dynamic = false;
|
||||
long minUpdateTimeMs = C.TIME_UNSET; // 3155690800000L, "P100Y" no refresh (there is no dash url)
|
||||
|
||||
List<Period> periods = new ArrayList<>();
|
||||
|
||||
Pair<Period, Long> periodWithDurationMs = parsePeriod(formatInfo);
|
||||
if (periodWithDurationMs != null) {
|
||||
Period period = periodWithDurationMs.first;
|
||||
periods.add(period);
|
||||
}
|
||||
|
||||
return new SabrManifest(
|
||||
availabilityStartTime,
|
||||
durationMs,
|
||||
minBufferTimeMs,
|
||||
dynamic,
|
||||
minUpdateTimeMs,
|
||||
timeShiftBufferDepthMs,
|
||||
suggestedPresentationDelayMs,
|
||||
publishTimeMs,
|
||||
periods);
|
||||
}
|
||||
|
||||
private static long getDurationMs(MediaItemFormatInfo formatInfo) {
|
||||
long lenSeconds = Helpers.parseLong(formatInfo.getLengthSeconds());
|
||||
return lenSeconds > 0 ? lenSeconds * 1_000 : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
private Pair<Period, Long> parsePeriod(MediaItemFormatInfo formatInfo) {
|
||||
String id = formatInfo.getVideoId();
|
||||
long startMs = 0; // Should add real start time or make it unset?
|
||||
long durationMs = getDurationMs(formatInfo);
|
||||
List<AdaptationSet> adaptationSets = new ArrayList<>();
|
||||
|
||||
for (MediaFormat format : formatInfo.getAdaptiveFormats()) {
|
||||
append(format);
|
||||
}
|
||||
|
||||
if (formatInfo.getSubtitles() != null) {
|
||||
append(formatInfo.getSubtitles());
|
||||
}
|
||||
|
||||
// MXPlayer fix: write high quality formats first
|
||||
if (!mMP4Videos.isEmpty()) {
|
||||
adaptationSets.add(parseAdaptationSet(mMP4Videos));
|
||||
}
|
||||
if (!mWEBMVideos.isEmpty()) {
|
||||
adaptationSets.add(parseAdaptationSet(mWEBMVideos));
|
||||
}
|
||||
|
||||
for (Set<MediaFormat> formats : mMP4Audios.values()) {
|
||||
adaptationSets.add(parseAdaptationSet(formats));
|
||||
}
|
||||
|
||||
for (Set<MediaFormat> formats : mWEBMAudios.values()) {
|
||||
adaptationSets.add(parseAdaptationSet(formats));
|
||||
}
|
||||
|
||||
for (MediaSubtitle subtitle : mSubs) {
|
||||
adaptationSets.add(parseAdaptationSet(Collections.singletonList(subtitle)));
|
||||
}
|
||||
|
||||
return Pair.create(new Period(id, startMs, adaptationSets), durationMs);
|
||||
}
|
||||
|
||||
private AdaptationSet parseAdaptationSet(Set<MediaFormat> formats) {
|
||||
int id = mId++;
|
||||
int contentType = C.TRACK_TYPE_UNKNOWN;
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
List<RepresentationInfo> representationInfos = new ArrayList<>();
|
||||
|
||||
for (MediaFormat format : formats) {
|
||||
RepresentationInfo representationInfo = parseRepresentation(format);
|
||||
if (contentType == C.TRACK_TYPE_UNKNOWN) {
|
||||
contentType = getContentType(representationInfo.format);
|
||||
}
|
||||
representationInfos.add(representationInfo);
|
||||
}
|
||||
|
||||
// Build the representations.
|
||||
List<Representation> representations = new ArrayList<>(representationInfos.size());
|
||||
for (int i = 0; i < representationInfos.size(); i++) {
|
||||
representations.add(
|
||||
buildRepresentation(
|
||||
representationInfos.get(i),
|
||||
label,
|
||||
drmSchemeType,
|
||||
drmSchemeDatas));
|
||||
}
|
||||
|
||||
return new AdaptationSet(id, contentType, representations);
|
||||
}
|
||||
|
||||
private AdaptationSet parseAdaptationSet(List<MediaSubtitle> formats) {
|
||||
int id = mId++;
|
||||
int contentType = C.TRACK_TYPE_UNKNOWN;
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
List<RepresentationInfo> representationInfos = new ArrayList<>();
|
||||
|
||||
for (MediaSubtitle format : formats) {
|
||||
RepresentationInfo representationInfo = parseRepresentation(format);
|
||||
if (contentType == C.TRACK_TYPE_UNKNOWN) {
|
||||
contentType = getContentType(representationInfo.format);
|
||||
}
|
||||
representationInfos.add(representationInfo);
|
||||
}
|
||||
|
||||
// Build the representations.
|
||||
List<Representation> representations = new ArrayList<>(representationInfos.size());
|
||||
for (int i = 0; i < representationInfos.size(); i++) {
|
||||
representations.add(
|
||||
buildRepresentation(
|
||||
representationInfos.get(i),
|
||||
label,
|
||||
drmSchemeType,
|
||||
drmSchemeDatas));
|
||||
}
|
||||
|
||||
return new AdaptationSet(id, contentType, representations);
|
||||
}
|
||||
|
||||
private SegmentTemplate parseSegmentTemplate(MediaFormat format) {
|
||||
int unitsPerSecond = 1_000_000;
|
||||
|
||||
// Present on live streams only.
|
||||
int segmentDurationUs = mFormatInfo.getSegmentDurationUs();
|
||||
|
||||
if (segmentDurationUs <= 0) {
|
||||
// Inaccurate. Present on past (!) live streams.
|
||||
segmentDurationUs = Integer.parseInt(format.getTargetDurationSec()) * 1_000_000;
|
||||
}
|
||||
|
||||
int lengthSeconds = Integer.parseInt(mFormatInfo.getLengthSeconds());
|
||||
|
||||
if (mFormatInfo.isLive() || lengthSeconds <= 0) {
|
||||
// For premiere streams (length > 0) or regular streams (length == 0) set window that exceeds normal limits - 48hrs
|
||||
lengthSeconds = MAX_DURATION_SEC;
|
||||
}
|
||||
|
||||
// To make long streams (12hrs) seekable we should decrease size of the segment a bit
|
||||
//long segmentDurationUnits = (long) targetDurationSec * unitsPerSecond * 9999 / 10000;
|
||||
int segmentDurationUnits = (int)(segmentDurationUs * (long) unitsPerSecond / 1_000_000);
|
||||
// Increase count a bit to compensate previous tweak
|
||||
//long segmentCount = (long) lengthSeconds / targetDurationSec * 10000 / 9999;
|
||||
//int segmentCount = (int)(lengthSeconds * (long) unitsPerSecond / segmentDurationUnits);
|
||||
int segmentCount = (int) Math.ceil(lengthSeconds * (double) unitsPerSecond / segmentDurationUnits);
|
||||
// Increase offset a bit to compensate previous tweaks
|
||||
// Streams to check:
|
||||
// https://www.youtube.com/watch?v=drdemkJpgao
|
||||
long offsetUnits = (long) segmentDurationUnits * mFormatInfo.getStartSegmentNum();
|
||||
|
||||
long timescale = unitsPerSecond;
|
||||
long presentationTimeOffset = offsetUnits;
|
||||
long duration = segmentDurationUnits;
|
||||
long startNumber = mFormatInfo.getStartSegmentNum();
|
||||
long endNumber = C.INDEX_UNSET;
|
||||
UrlTemplate mediaTemplate = UrlTemplate.compile(format.getUrl() + "&sq=$Number$");
|
||||
//UrlTemplate initializationTemplate = UrlTemplate.compile(format.getOtfInitUrl()); // ?
|
||||
UrlTemplate initializationTemplate = null; // ?
|
||||
|
||||
RangedUri initialization = parseRangedUrl(format.getSourceUrl(), format.getInit());
|
||||
|
||||
List<SegmentTimelineElement> timeline = parseSegmentTimeline(offsetUnits, segmentDurationUnits, segmentCount);
|
||||
|
||||
return new SegmentTemplate(
|
||||
initialization,
|
||||
timescale,
|
||||
presentationTimeOffset,
|
||||
startNumber,
|
||||
endNumber,
|
||||
duration,
|
||||
timeline,
|
||||
initializationTemplate,
|
||||
mediaTemplate);
|
||||
}
|
||||
|
||||
private SegmentList parseSegmentList(MediaFormat format) {
|
||||
long timescale = 1;
|
||||
long presentationTimeOffset = 0;
|
||||
long duration = C.TIME_UNSET;
|
||||
long startNumber = 1;
|
||||
|
||||
RangedUri initialization = parseRangedUrl(format.getSourceUrl(), format.getInit());
|
||||
|
||||
List<SegmentTimelineElement> timeline = parseSegmentTimeline(format);
|
||||
|
||||
List<RangedUri> segments = parseSegmentUrl(format);
|
||||
|
||||
return new SegmentList(initialization, timescale, presentationTimeOffset,
|
||||
startNumber, duration, timeline, segments);
|
||||
}
|
||||
|
||||
private RangedUri parseRangedUrl(String urlText, String rangeText) {
|
||||
long rangeStart = 0;
|
||||
long rangeLength = C.LENGTH_UNSET;
|
||||
if (rangeText != null) {
|
||||
String[] rangeTextArray = rangeText.split("-");
|
||||
rangeStart = Long.parseLong(rangeTextArray[0]);
|
||||
if (rangeTextArray.length == 2) {
|
||||
rangeLength = Long.parseLong(rangeTextArray[1]) - rangeStart + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new RangedUri(urlText, rangeStart, rangeLength);
|
||||
}
|
||||
|
||||
private List<SegmentTimelineElement> parseSegmentTimeline(MediaFormat format) {
|
||||
List<SegmentTimelineElement> timeline = new ArrayList<>();
|
||||
|
||||
if (format.getGlobalSegmentList() == null) {
|
||||
return timeline;
|
||||
}
|
||||
|
||||
// From writeGlobalSegmentList
|
||||
long elapsedTime = 0;
|
||||
|
||||
// SegmentURL tag
|
||||
for (String segment : format.getGlobalSegmentList()) {
|
||||
long duration = Helpers.parseLong(segment, C.TIME_UNSET);
|
||||
int count = 1;
|
||||
for (int i = 0; i < count; i++) {
|
||||
timeline.add(new SegmentTimelineElement(elapsedTime, duration));
|
||||
elapsedTime += duration;
|
||||
}
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private List<SegmentTimelineElement> parseSegmentTimeline(long elapsedTime, long duration, int segmentCount) {
|
||||
List<SegmentTimelineElement> timeline = new ArrayList<>();
|
||||
|
||||
// From writeLiveMediaSegmentList
|
||||
int count = 1 + segmentCount;
|
||||
for (int i = 0; i < count; i++) {
|
||||
timeline.add(new SegmentTimelineElement(elapsedTime, duration));
|
||||
elapsedTime += duration;
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private List<RangedUri> parseSegmentUrl(MediaFormat format) {
|
||||
List<RangedUri> segments = new ArrayList<>();
|
||||
|
||||
if (format.getSegmentUrlList() == null) {
|
||||
return segments;
|
||||
}
|
||||
|
||||
// SegmentURL tag
|
||||
for (String url : format.getSegmentUrlList()) {
|
||||
segments.add(parseRangedUrl(url, null));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private SingleSegmentBase parseSegmentBase(MediaFormat format) {
|
||||
long timescale = 1000;
|
||||
long presentationTimeOffset = 0;
|
||||
|
||||
long indexStart = 0;
|
||||
long indexLength = 0;
|
||||
String indexRangeText = format.getIndex();
|
||||
if (indexRangeText != null) {
|
||||
String[] indexRange = indexRangeText.split("-");
|
||||
indexStart = Long.parseLong(indexRange[0]);
|
||||
indexLength = Long.parseLong(indexRange[1]) - indexStart + 1;
|
||||
}
|
||||
|
||||
RangedUri initialization = parseRangedUrl(format.getSourceUrl(), format.getInit());
|
||||
|
||||
|
||||
return new SingleSegmentBase(initialization, timescale, presentationTimeOffset, indexStart,
|
||||
indexLength);
|
||||
}
|
||||
|
||||
private RepresentationInfo parseRepresentation(MediaFormat mediaFormat) {
|
||||
int roleFlags = C.ROLE_FLAG_MAIN;
|
||||
int selectionFlags = C.SELECTION_FLAG_DEFAULT;
|
||||
String id = mediaFormat.isDrc() ? mediaFormat.getITag() + "-drc" : mediaFormat.getITag();
|
||||
int bandwidth = Helpers.parseInt(mediaFormat.getBitrate(), Format.NO_VALUE);
|
||||
String mimeType = MediaFormatUtils.extractMimeType(mediaFormat);
|
||||
String codecs = MediaFormatUtils.extractCodecs(mediaFormat);
|
||||
int width = mediaFormat.getWidth();
|
||||
int height = mediaFormat.getHeight();
|
||||
float frameRate = Helpers.parseFloat(mediaFormat.getFps(), Format.NO_VALUE);
|
||||
int audioChannels = Format.NO_VALUE;
|
||||
int audioSamplingRate = Helpers.parseInt(ITagUtils.getAudioRateByTag(mediaFormat.getITag()), Format.NO_VALUE);
|
||||
String language = mediaFormat.getLanguage();
|
||||
String baseUrl = mediaFormat.getUrl();
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
|
||||
Format format =
|
||||
buildFormat(
|
||||
id,
|
||||
mimeType,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
audioChannels,
|
||||
audioSamplingRate,
|
||||
bandwidth,
|
||||
language,
|
||||
roleFlags,
|
||||
selectionFlags,
|
||||
codecs);
|
||||
|
||||
SegmentBase segmentBase = null;
|
||||
|
||||
if (MediaFormatUtils.isLiveMedia(mediaFormat)) {
|
||||
segmentBase = parseSegmentTemplate(mediaFormat);
|
||||
} else if (mediaFormat.getSegmentUrlList() != null) {
|
||||
segmentBase = parseSegmentList(mediaFormat);
|
||||
} else if (mediaFormat.getIndex() != null &&
|
||||
!mediaFormat.getIndex().equals(NULL_INDEX_RANGE)) { // json mediaFormat fix: index is null
|
||||
segmentBase = parseSegmentBase(mediaFormat);
|
||||
}
|
||||
|
||||
segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase();
|
||||
|
||||
return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeType, drmSchemeDatas, Representation.REVISION_ID_DEFAULT);
|
||||
}
|
||||
|
||||
private RepresentationInfo parseRepresentation(MediaSubtitle sub) {
|
||||
int roleFlags = C.ROLE_FLAG_SUBTITLE;
|
||||
int selectionFlags = 0;
|
||||
String id = String.valueOf(mId++);
|
||||
int bandwidth = 268;
|
||||
String mimeType = sub.getMimeType();
|
||||
String codecs = sub.getCodecs();
|
||||
int width = Format.NO_VALUE;
|
||||
int height = Format.NO_VALUE;
|
||||
float frameRate = Format.NO_VALUE;
|
||||
int audioChannels = Format.NO_VALUE;
|
||||
int audioSamplingRate = Format.NO_VALUE;
|
||||
String language = sub.getName() == null ? sub.getLanguageCode() : sub.getName();
|
||||
String baseUrl = sub.getBaseUrl();
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
|
||||
Format format =
|
||||
buildFormat(
|
||||
id,
|
||||
mimeType,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
audioChannels,
|
||||
audioSamplingRate,
|
||||
bandwidth,
|
||||
language,
|
||||
roleFlags,
|
||||
selectionFlags,
|
||||
codecs);
|
||||
|
||||
SegmentBase segmentBase = new SingleSegmentBase();
|
||||
|
||||
return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeType, drmSchemeDatas, Representation.REVISION_ID_DEFAULT);
|
||||
}
|
||||
|
||||
protected Representation buildRepresentation(
|
||||
RepresentationInfo representationInfo,
|
||||
String label,
|
||||
String extraDrmSchemeType,
|
||||
ArrayList<SchemeData> extraDrmSchemeDatas) {
|
||||
Format format = representationInfo.format;
|
||||
if (label != null) {
|
||||
format = format.copyWithLabel(label);
|
||||
}
|
||||
String drmSchemeType = representationInfo.drmSchemeType != null
|
||||
? representationInfo.drmSchemeType : extraDrmSchemeType;
|
||||
ArrayList<SchemeData> drmSchemeDatas = representationInfo.drmSchemeDatas;
|
||||
drmSchemeDatas.addAll(extraDrmSchemeDatas);
|
||||
if (!drmSchemeDatas.isEmpty()) {
|
||||
filterRedundantIncompleteSchemeDatas(drmSchemeDatas);
|
||||
DrmInitData drmInitData = new DrmInitData(drmSchemeType, drmSchemeDatas);
|
||||
format = format.copyWithDrmInitData(drmInitData);
|
||||
}
|
||||
return Representation.newInstance(
|
||||
representationInfo.revisionId,
|
||||
format,
|
||||
representationInfo.baseUrl,
|
||||
representationInfo.segmentBase);
|
||||
}
|
||||
|
||||
protected Format buildFormat(
|
||||
String id,
|
||||
String containerMimeType,
|
||||
int width,
|
||||
int height,
|
||||
float frameRate,
|
||||
int audioChannels,
|
||||
int audioSamplingRate,
|
||||
int bitrate,
|
||||
String language,
|
||||
@C.RoleFlags int roleFlags,
|
||||
@C.SelectionFlags int selectionFlags,
|
||||
String codecs) {
|
||||
String sampleMimeType = getSampleMimeType(containerMimeType, codecs);
|
||||
if (sampleMimeType != null) {
|
||||
if (MimeTypes.isVideo(sampleMimeType)) {
|
||||
return Format.createVideoContainerFormat(
|
||||
id,
|
||||
/* label= */ null,
|
||||
containerMimeType,
|
||||
sampleMimeType,
|
||||
codecs,
|
||||
/* metadata= */ null,
|
||||
bitrate,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
/* initializationData= */ null,
|
||||
selectionFlags,
|
||||
roleFlags);
|
||||
} else if (MimeTypes.isAudio(sampleMimeType)) {
|
||||
return Format.createAudioContainerFormat(
|
||||
id,
|
||||
/* label= */ null,
|
||||
containerMimeType,
|
||||
sampleMimeType,
|
||||
codecs,
|
||||
/* metadata= */ null,
|
||||
bitrate,
|
||||
audioChannels,
|
||||
audioSamplingRate,
|
||||
/* initializationData= */ null,
|
||||
selectionFlags,
|
||||
roleFlags,
|
||||
language);
|
||||
} else if (mimeTypeIsRawText(sampleMimeType)) {
|
||||
return Format.createTextContainerFormat(
|
||||
id,
|
||||
/* label= */ null,
|
||||
containerMimeType,
|
||||
sampleMimeType,
|
||||
codecs,
|
||||
bitrate,
|
||||
selectionFlags,
|
||||
roleFlags,
|
||||
language,
|
||||
Format.NO_VALUE);
|
||||
}
|
||||
}
|
||||
return Format.createContainerFormat(
|
||||
id,
|
||||
/* label= */ null,
|
||||
containerMimeType,
|
||||
sampleMimeType,
|
||||
codecs,
|
||||
bitrate,
|
||||
selectionFlags,
|
||||
roleFlags,
|
||||
language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a sample mimeType from a container mimeType and codecs attribute.
|
||||
*
|
||||
* @param containerMimeType The mimeType of the container.
|
||||
* @param codecs The codecs attribute.
|
||||
* @return The derived sample mimeType, or null if it could not be derived.
|
||||
*/
|
||||
private static String getSampleMimeType(String containerMimeType, String codecs) {
|
||||
if (MimeTypes.isAudio(containerMimeType)) {
|
||||
return MimeTypes.getAudioMediaMimeType(codecs);
|
||||
} else if (MimeTypes.isVideo(containerMimeType)) {
|
||||
return MimeTypes.getVideoMediaMimeType(codecs);
|
||||
} else if (mimeTypeIsRawText(containerMimeType)) {
|
||||
return containerMimeType;
|
||||
} else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) {
|
||||
if (codecs != null) {
|
||||
if (codecs.startsWith("stpp")) {
|
||||
return MimeTypes.APPLICATION_TTML;
|
||||
} else if (codecs.startsWith("wvtt")) {
|
||||
return MimeTypes.APPLICATION_MP4VTT;
|
||||
}
|
||||
}
|
||||
} else if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) {
|
||||
if (codecs != null) {
|
||||
if (codecs.contains("cea708")) {
|
||||
return MimeTypes.APPLICATION_CEA708;
|
||||
} else if (codecs.contains("eia608") || codecs.contains("cea608")) {
|
||||
return MimeTypes.APPLICATION_CEA608;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a mimeType is a text sample mimeType.
|
||||
*
|
||||
* @param mimeType The mimeType.
|
||||
* @return Whether the mimeType is a text sample mimeType.
|
||||
*/
|
||||
private static boolean mimeTypeIsRawText(String mimeType) {
|
||||
return MimeTypes.isText(mimeType)
|
||||
|| MimeTypes.APPLICATION_TTML.equals(mimeType)
|
||||
|| MimeTypes.APPLICATION_MP4VTT.equals(mimeType)
|
||||
|| MimeTypes.APPLICATION_CEA708.equals(mimeType)
|
||||
|| MimeTypes.APPLICATION_CEA608.equals(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes unnecessary {@link SchemeData}s with null {@link SchemeData#data}.
|
||||
*/
|
||||
private static void filterRedundantIncompleteSchemeDatas(ArrayList<SchemeData> schemeDatas) {
|
||||
for (int i = schemeDatas.size() - 1; i >= 0; i--) {
|
||||
SchemeData schemeData = schemeDatas.get(i);
|
||||
if (!schemeData.hasData()) {
|
||||
for (int j = 0; j < schemeDatas.size(); j++) {
|
||||
if (schemeDatas.get(j).canReplace(schemeData)) {
|
||||
// schemeData is incomplete, but there is another matching SchemeData which does contain
|
||||
// data, so we remove the incomplete one.
|
||||
schemeDatas.remove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void append(List<MediaSubtitle> subs) {
|
||||
mSubs.addAll(subs);
|
||||
}
|
||||
|
||||
private void append(MediaSubtitle sub) {
|
||||
mSubs.add(sub);
|
||||
}
|
||||
|
||||
private void append(MediaFormat mediaItem) {
|
||||
if (!MediaFormatUtils.checkMediaUrl(mediaItem)) {
|
||||
Log.e(TAG, "Media item doesn't contain required url field!");
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: FORMAT_STREAM_TYPE_OTF not supported
|
||||
if (!MediaFormatUtils.isDash(mediaItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//fixOTF(mediaItem);
|
||||
|
||||
Set<MediaFormat> placeholder = null;
|
||||
String mimeType = MediaFormatUtils.extractMimeType(mediaItem);
|
||||
if (mimeType != null) {
|
||||
switch (mimeType) {
|
||||
case MediaFormatUtils.MIME_MP4_VIDEO:
|
||||
placeholder = mMP4Videos;
|
||||
break;
|
||||
case MediaFormatUtils.MIME_WEBM_VIDEO:
|
||||
placeholder = mWEBMVideos;
|
||||
break;
|
||||
case MediaFormatUtils.MIME_MP4_AUDIO:
|
||||
placeholder = getMP4Audios(mediaItem.getLanguage());
|
||||
break;
|
||||
case MediaFormatUtils.MIME_WEBM_AUDIO:
|
||||
placeholder = getWEBMAudios(mediaItem.getLanguage());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder != null) {
|
||||
placeholder.add(mediaItem); // NOTE: reverse order
|
||||
}
|
||||
}
|
||||
|
||||
private Set<MediaFormat> getMP4Audios(String language) {
|
||||
return getFormats(mMP4Audios, language);
|
||||
}
|
||||
|
||||
private Set<MediaFormat> getWEBMAudios(String language) {
|
||||
return getFormats(mWEBMAudios, language);
|
||||
}
|
||||
|
||||
private static Set<MediaFormat> getFormats(Map<String, Set<MediaFormat>> formatMap, String language) {
|
||||
if (language == null) {
|
||||
language = "default";
|
||||
}
|
||||
|
||||
Set<MediaFormat> mediaFormats = formatMap.get(language);
|
||||
|
||||
if (mediaFormats == null) {
|
||||
mediaFormats = new TreeSet<>(new MediaFormatComparator());
|
||||
formatMap.put(language, mediaFormats);
|
||||
}
|
||||
|
||||
return mediaFormats;
|
||||
}
|
||||
|
||||
protected int getContentType(Format format) {
|
||||
String sampleMimeType = format.sampleMimeType;
|
||||
if (TextUtils.isEmpty(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_UNKNOWN;
|
||||
} else if (MimeTypes.isVideo(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_VIDEO;
|
||||
} else if (MimeTypes.isAudio(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_AUDIO;
|
||||
} else if (mimeTypeIsRawText(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_TEXT;
|
||||
}
|
||||
return C.TRACK_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
/** A parsed Representation element. */
|
||||
protected static final class RepresentationInfo {
|
||||
|
||||
public final Format format;
|
||||
public final String baseUrl;
|
||||
public final SegmentBase segmentBase;
|
||||
public final String drmSchemeType;
|
||||
public final ArrayList<SchemeData> drmSchemeDatas;
|
||||
public final long revisionId;
|
||||
|
||||
public RepresentationInfo(Format format, String baseUrl, SegmentBase segmentBase,
|
||||
String drmSchemeType, ArrayList<SchemeData> drmSchemeDatas,
|
||||
long revisionId) {
|
||||
this.format = format;
|
||||
this.baseUrl = baseUrl;
|
||||
this.segmentBase = segmentBase;
|
||||
this.drmSchemeType = drmSchemeType;
|
||||
this.drmSchemeDatas = drmSchemeDatas;
|
||||
this.revisionId = revisionId;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import com.futo.platformplayer.sabr.SabrSegmentIndex;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An approximate representation of a SegmentBase manifest element.
|
||||
*/
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public abstract class SegmentBase {
|
||||
|
||||
/* package */ final RangedUri initialization;
|
||||
/* package */ final long timescale;
|
||||
/* package */ final long presentationTimeOffset;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
*/
|
||||
public SegmentBase(RangedUri initialization, long timescale, long presentationTimeOffset) {
|
||||
this.initialization = initialization;
|
||||
this.timescale = timescale;
|
||||
this.presentationTimeOffset = presentationTimeOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link RangedUri} defining the location of initialization data for a given
|
||||
* representation, or null if no initialization data exists.
|
||||
*
|
||||
* @param representation The {@link Representation} for which initialization data is required.
|
||||
* @return A {@link RangedUri} defining the location of the initialization data, or null.
|
||||
*/
|
||||
public RangedUri getInitialization(Representation representation) {
|
||||
return initialization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the presentation time offset, in microseconds.
|
||||
*/
|
||||
public long getPresentationTimeOffsetUs() {
|
||||
return Util.scaleLargeTimestamp(presentationTimeOffset, C.MICROS_PER_SECOND, timescale);
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SegmentBase} that defines a single segment.
|
||||
*/
|
||||
public static class SingleSegmentBase extends SegmentBase {
|
||||
|
||||
/* package */ final long indexStart;
|
||||
/* package */ final long indexLength;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param indexStart The byte offset of the index data in the segment.
|
||||
* @param indexLength The length of the index data in bytes.
|
||||
*/
|
||||
public SingleSegmentBase(RangedUri initialization, long timescale, long presentationTimeOffset,
|
||||
long indexStart, long indexLength) {
|
||||
super(initialization, timescale, presentationTimeOffset);
|
||||
this.indexStart = indexStart;
|
||||
this.indexLength = indexLength;
|
||||
}
|
||||
|
||||
public SingleSegmentBase() {
|
||||
this(null, 1, 0, 0, 0);
|
||||
}
|
||||
|
||||
public RangedUri getIndex() {
|
||||
return indexLength <= 0 ? null : new RangedUri(null, indexStart, indexLength);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SegmentBase} that consists of multiple segments.
|
||||
*/
|
||||
public abstract static class MultiSegmentBase extends SegmentBase {
|
||||
|
||||
/* package */ final long startNumber;
|
||||
/* package */ final long duration;
|
||||
/* package */ final List<SegmentTimelineElement> segmentTimeline;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param startNumber The sequence number of the first segment.
|
||||
* @param duration The duration of each segment in the case of fixed duration segments. The
|
||||
* value in seconds is the division of this value and {@code timescale}. If {@code
|
||||
* segmentTimeline} is non-null then this parameter is ignored.
|
||||
* @param segmentTimeline A segment timeline corresponding to the segments. If null, then
|
||||
* segments are assumed to be of fixed duration as specified by the {@code duration}
|
||||
* parameter.
|
||||
*/
|
||||
public MultiSegmentBase(
|
||||
RangedUri initialization,
|
||||
long timescale,
|
||||
long presentationTimeOffset,
|
||||
long startNumber,
|
||||
long duration,
|
||||
List<SegmentTimelineElement> segmentTimeline) {
|
||||
super(initialization, timescale, presentationTimeOffset);
|
||||
this.startNumber = startNumber;
|
||||
this.duration = duration;
|
||||
this.segmentTimeline = segmentTimeline;
|
||||
}
|
||||
|
||||
/** @see SabrSegmentIndex#getSegmentNum(long, long) */
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
final long firstSegmentNum = getFirstSegmentNum();
|
||||
final long segmentCount = getSegmentCount(periodDurationUs);
|
||||
if (segmentCount == 0) {
|
||||
return firstSegmentNum;
|
||||
}
|
||||
if (segmentTimeline == null) {
|
||||
// All segments are of equal duration (with the possible exception of the last one).
|
||||
long durationUs = (duration * C.MICROS_PER_SECOND) / timescale;
|
||||
long segmentNum = startNumber + timeUs / durationUs;
|
||||
// Ensure we stay within bounds.
|
||||
return segmentNum < firstSegmentNum ? firstSegmentNum
|
||||
: segmentCount == SabrSegmentIndex.INDEX_UNBOUNDED ? segmentNum
|
||||
: Math.min(segmentNum, firstSegmentNum + segmentCount - 1);
|
||||
} else {
|
||||
// The index cannot be unbounded. Identify the segment using binary search.
|
||||
long lowIndex = firstSegmentNum;
|
||||
long highIndex = firstSegmentNum + segmentCount - 1;
|
||||
while (lowIndex <= highIndex) {
|
||||
long midIndex = lowIndex + (highIndex - lowIndex) / 2;
|
||||
long midTimeUs = getSegmentTimeUs(midIndex);
|
||||
if (midTimeUs < timeUs) {
|
||||
lowIndex = midIndex + 1;
|
||||
} else if (midTimeUs > timeUs) {
|
||||
highIndex = midIndex - 1;
|
||||
} else {
|
||||
return midIndex;
|
||||
}
|
||||
}
|
||||
return lowIndex == firstSegmentNum ? lowIndex : highIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/** @see SabrSegmentIndex#getDurationUs(long, long) */
|
||||
public final long getSegmentDurationUs(long sequenceNumber, long periodDurationUs) {
|
||||
if (segmentTimeline != null) {
|
||||
long duration = segmentTimeline.get((int) (sequenceNumber - startNumber)).duration;
|
||||
return (duration * C.MICROS_PER_SECOND) / timescale;
|
||||
} else {
|
||||
int segmentCount = getSegmentCount(periodDurationUs);
|
||||
return segmentCount != SabrSegmentIndex.INDEX_UNBOUNDED
|
||||
&& sequenceNumber == (getFirstSegmentNum() + segmentCount - 1)
|
||||
? (periodDurationUs - getSegmentTimeUs(sequenceNumber))
|
||||
: ((duration * C.MICROS_PER_SECOND) / timescale);
|
||||
}
|
||||
}
|
||||
|
||||
/** @see SabrSegmentIndex#getTimeUs(long) */
|
||||
public final long getSegmentTimeUs(long sequenceNumber) {
|
||||
long unscaledSegmentTime;
|
||||
if (segmentTimeline != null) {
|
||||
unscaledSegmentTime =
|
||||
segmentTimeline.get((int) (sequenceNumber - startNumber)).startTime
|
||||
- presentationTimeOffset;
|
||||
} else {
|
||||
unscaledSegmentTime = (sequenceNumber - startNumber) * duration;
|
||||
}
|
||||
return Util.scaleLargeTimestamp(unscaledSegmentTime, C.MICROS_PER_SECOND, timescale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of a segment for the given index in the
|
||||
* given representation.
|
||||
*
|
||||
* @see SabrSegmentIndex#getSegmentUrl(long)
|
||||
*/
|
||||
public abstract RangedUri getSegmentUrl(Representation representation, long index);
|
||||
|
||||
/** @see SabrSegmentIndex#getFirstSegmentNum() */
|
||||
public long getFirstSegmentNum() {
|
||||
return startNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see SabrSegmentIndex#getSegmentCount(long)
|
||||
*/
|
||||
public abstract int getSegmentCount(long periodDurationUs);
|
||||
|
||||
/**
|
||||
* @see SabrSegmentIndex#isExplicit()
|
||||
*/
|
||||
public boolean isExplicit() {
|
||||
return segmentTimeline != null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link MultiSegmentBase} that uses a SegmentList to define its segments.
|
||||
*/
|
||||
public static class SegmentList extends MultiSegmentBase {
|
||||
|
||||
/* package */ final List<RangedUri> mediaSegments;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param startNumber The sequence number of the first segment.
|
||||
* @param duration The duration of each segment in the case of fixed duration segments. The
|
||||
* value in seconds is the division of this value and {@code timescale}. If {@code
|
||||
* segmentTimeline} is non-null then this parameter is ignored.
|
||||
* @param segmentTimeline A segment timeline corresponding to the segments. If null, then
|
||||
* segments are assumed to be of fixed duration as specified by the {@code duration}
|
||||
* parameter.
|
||||
* @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments.
|
||||
*/
|
||||
public SegmentList(
|
||||
RangedUri initialization,
|
||||
long timescale,
|
||||
long presentationTimeOffset,
|
||||
long startNumber,
|
||||
long duration,
|
||||
List<SegmentTimelineElement> segmentTimeline,
|
||||
List<RangedUri> mediaSegments) {
|
||||
super(initialization, timescale, presentationTimeOffset, startNumber, duration,
|
||||
segmentTimeline);
|
||||
this.mediaSegments = mediaSegments;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(Representation representation, long sequenceNumber) {
|
||||
return mediaSegments.get((int) (sequenceNumber - startNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return mediaSegments.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link MultiSegmentBase} that uses a SegmentTemplate to define its segments.
|
||||
*/
|
||||
public static class SegmentTemplate extends MultiSegmentBase {
|
||||
|
||||
/* package */ final UrlTemplate initializationTemplate;
|
||||
/* package */ final UrlTemplate mediaTemplate;
|
||||
/* package */ final long endNumber;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists. The value of this parameter is ignored if {@code initializationTemplate} is
|
||||
* non-null.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param startNumber The sequence number of the first segment.
|
||||
* @param endNumber The sequence number of the last segment as specified by the
|
||||
* SupplementalProperty with schemeIdUri="http://dashif.org/guidelines/last-segment-number",
|
||||
* or {@link C#INDEX_UNSET}.
|
||||
* @param duration The duration of each segment in the case of fixed duration segments. The
|
||||
* value in seconds is the division of this value and {@code timescale}. If {@code
|
||||
* segmentTimeline} is non-null then this parameter is ignored.
|
||||
* @param segmentTimeline A segment timeline corresponding to the segments. If null, then
|
||||
* segments are assumed to be of fixed duration as specified by the {@code duration}
|
||||
* parameter.
|
||||
* @param initializationTemplate A template defining the location of initialization data, if
|
||||
* such data exists. If non-null then the {@code initialization} parameter is ignored. If
|
||||
* null then {@code initialization} will be used.
|
||||
* @param mediaTemplate A template defining the location of each media segment.
|
||||
*/
|
||||
public SegmentTemplate(
|
||||
RangedUri initialization,
|
||||
long timescale,
|
||||
long presentationTimeOffset,
|
||||
long startNumber,
|
||||
long endNumber,
|
||||
long duration,
|
||||
List<SegmentTimelineElement> segmentTimeline,
|
||||
UrlTemplate initializationTemplate,
|
||||
UrlTemplate mediaTemplate) {
|
||||
super(
|
||||
initialization,
|
||||
timescale,
|
||||
presentationTimeOffset,
|
||||
startNumber,
|
||||
duration,
|
||||
segmentTimeline);
|
||||
this.initializationTemplate = initializationTemplate;
|
||||
this.mediaTemplate = mediaTemplate;
|
||||
this.endNumber = endNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getInitialization(Representation representation) {
|
||||
if (initializationTemplate != null) {
|
||||
String urlString = initializationTemplate.buildUri(representation.format.id, 0,
|
||||
representation.format.bitrate, 0);
|
||||
return new RangedUri(urlString, 0, C.LENGTH_UNSET);
|
||||
} else {
|
||||
return super.getInitialization(representation);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(Representation representation, long sequenceNumber) {
|
||||
long time;
|
||||
if (segmentTimeline != null) {
|
||||
time = segmentTimeline.get((int) (sequenceNumber - startNumber)).startTime;
|
||||
} else {
|
||||
time = (sequenceNumber - startNumber) * duration;
|
||||
}
|
||||
String uriString = mediaTemplate.buildUri(representation.format.id, sequenceNumber,
|
||||
representation.format.bitrate, time);
|
||||
return new RangedUri(uriString, 0, C.LENGTH_UNSET);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
if (segmentTimeline != null) {
|
||||
return segmentTimeline.size();
|
||||
} else if (endNumber != C.INDEX_UNSET) {
|
||||
return (int) (endNumber - startNumber + 1);
|
||||
} else if (periodDurationUs != C.TIME_UNSET) {
|
||||
long durationUs = (duration * C.MICROS_PER_SECOND) / timescale;
|
||||
return (int) Util.ceilDivide(periodDurationUs, durationUs);
|
||||
} else {
|
||||
return SabrSegmentIndex.INDEX_UNBOUNDED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a timeline segment from the MPD's SegmentTimeline list.
|
||||
*/
|
||||
public static class SegmentTimelineElement {
|
||||
|
||||
/* package */ final long startTime;
|
||||
/* package */ final long duration;
|
||||
|
||||
/**
|
||||
* @param startTime The start time of the element. The value in seconds is the division of this
|
||||
* value and the {@code timescale} of the enclosing element.
|
||||
* @param duration The duration of the element. The value in seconds is the division of this
|
||||
* value and the {@code timescale} of the enclosing element.
|
||||
*/
|
||||
public SegmentTimelineElement(long startTime, long duration) {
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import com.futo.platformplayer.sabr.SabrSegmentIndex;
|
||||
|
||||
/**
|
||||
* A {@link SabrSegmentIndex} that defines a single segment.
|
||||
*/
|
||||
/* package */ final class SingleSegmentIndex implements SabrSegmentIndex {
|
||||
|
||||
private final RangedUri uri;
|
||||
|
||||
/**
|
||||
* @param uri A {@link RangedUri} defining the location of the segment data.
|
||||
*/
|
||||
public SingleSegmentIndex(RangedUri uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeUs(long segmentNum) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs(long segmentNum, long periodDurationUs) {
|
||||
return periodDurationUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(long segmentNum) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFirstSegmentNum() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright (C) 2016 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.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A template from which URLs can be built.
|
||||
* <p>
|
||||
* URLs are built according to the substitution rules defined in ISO/IEC 23009-1:2014 5.3.9.4.4.
|
||||
*/
|
||||
public final class UrlTemplate {
|
||||
|
||||
private static final String REPRESENTATION = "RepresentationID";
|
||||
private static final String NUMBER = "Number";
|
||||
private static final String BANDWIDTH = "Bandwidth";
|
||||
private static final String TIME = "Time";
|
||||
private static final String ESCAPED_DOLLAR = "$$";
|
||||
private static final String DEFAULT_FORMAT_TAG = "%01d";
|
||||
|
||||
private static final int REPRESENTATION_ID = 1;
|
||||
private static final int NUMBER_ID = 2;
|
||||
private static final int BANDWIDTH_ID = 3;
|
||||
private static final int TIME_ID = 4;
|
||||
|
||||
private final String[] urlPieces;
|
||||
private final int[] identifiers;
|
||||
private final String[] identifierFormatTags;
|
||||
private final int identifierCount;
|
||||
|
||||
/**
|
||||
* Compile an instance from the provided template string.
|
||||
*
|
||||
* @param template The template.
|
||||
* @return The compiled instance.
|
||||
* @throws IllegalArgumentException If the template string is malformed.
|
||||
*/
|
||||
public static UrlTemplate compile(String template) {
|
||||
// These arrays are sizes assuming each of the four possible identifiers will be present at
|
||||
// most once in the template, which seems like a reasonable assumption.
|
||||
String[] urlPieces = new String[5];
|
||||
int[] identifiers = new int[4];
|
||||
String[] identifierFormatTags = new String[4];
|
||||
int identifierCount = parseTemplate(template, urlPieces, identifiers, identifierFormatTags);
|
||||
return new UrlTemplate(urlPieces, identifiers, identifierFormatTags, identifierCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal constructor. Use {@link #compile(String)} to build instances of this class.
|
||||
*/
|
||||
private UrlTemplate(String[] urlPieces, int[] identifiers, String[] identifierFormatTags,
|
||||
int identifierCount) {
|
||||
this.urlPieces = urlPieces;
|
||||
this.identifiers = identifiers;
|
||||
this.identifierFormatTags = identifierFormatTags;
|
||||
this.identifierCount = identifierCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a Uri from the template, substituting in the provided arguments.
|
||||
*
|
||||
* <p>Arguments whose corresponding identifiers are not present in the template will be ignored.
|
||||
*
|
||||
* @param representationId The representation identifier.
|
||||
* @param segmentNumber The segment number.
|
||||
* @param bandwidth The bandwidth.
|
||||
* @param time The time as specified by the segment timeline.
|
||||
* @return The built Uri.
|
||||
*/
|
||||
public String buildUri(String representationId, long segmentNumber, int bandwidth, long time) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < identifierCount; i++) {
|
||||
builder.append(urlPieces[i]);
|
||||
if (identifiers[i] == REPRESENTATION_ID) {
|
||||
builder.append(representationId);
|
||||
} else if (identifiers[i] == NUMBER_ID) {
|
||||
builder.append(String.format(Locale.US, identifierFormatTags[i], segmentNumber));
|
||||
} else if (identifiers[i] == BANDWIDTH_ID) {
|
||||
builder.append(String.format(Locale.US, identifierFormatTags[i], bandwidth));
|
||||
} else if (identifiers[i] == TIME_ID) {
|
||||
builder.append(String.format(Locale.US, identifierFormatTags[i], time));
|
||||
}
|
||||
}
|
||||
builder.append(urlPieces[identifierCount]);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses {@code template}, placing the decomposed components into the provided arrays.
|
||||
* <p>
|
||||
* If the return value is N, {@code urlPieces} will contain (N+1) strings that must be
|
||||
* interleaved with N arguments in order to construct a url. The N identifiers that correspond to
|
||||
* the required arguments, together with the tags that define their required formatting, are
|
||||
* returned in {@code identifiers} and {@code identifierFormatTags} respectively.
|
||||
*
|
||||
* @param template The template to parse.
|
||||
* @param urlPieces A holder for pieces of url parsed from the template.
|
||||
* @param identifiers A holder for identifiers parsed from the template.
|
||||
* @param identifierFormatTags A holder for format tags corresponding to the parsed identifiers.
|
||||
* @return The number of identifiers in the template url.
|
||||
* @throws IllegalArgumentException If the template string is malformed.
|
||||
*/
|
||||
private static int parseTemplate(String template, String[] urlPieces, int[] identifiers,
|
||||
String[] identifierFormatTags) {
|
||||
urlPieces[0] = "";
|
||||
int templateIndex = 0;
|
||||
int identifierCount = 0;
|
||||
while (templateIndex < template.length()) {
|
||||
int dollarIndex = template.indexOf("$", templateIndex);
|
||||
if (dollarIndex == -1) {
|
||||
urlPieces[identifierCount] += template.substring(templateIndex);
|
||||
templateIndex = template.length();
|
||||
} else if (dollarIndex != templateIndex) {
|
||||
urlPieces[identifierCount] += template.substring(templateIndex, dollarIndex);
|
||||
templateIndex = dollarIndex;
|
||||
} else if (template.startsWith(ESCAPED_DOLLAR, templateIndex)) {
|
||||
urlPieces[identifierCount] += "$";
|
||||
templateIndex += 2;
|
||||
} else {
|
||||
int secondIndex = template.indexOf("$", templateIndex + 1);
|
||||
String identifier = template.substring(templateIndex + 1, secondIndex);
|
||||
if (identifier.equals(REPRESENTATION)) {
|
||||
identifiers[identifierCount] = REPRESENTATION_ID;
|
||||
} else {
|
||||
int formatTagIndex = identifier.indexOf("%0");
|
||||
String formatTag = DEFAULT_FORMAT_TAG;
|
||||
if (formatTagIndex != -1) {
|
||||
formatTag = identifier.substring(formatTagIndex);
|
||||
// Allowed conversions are decimal integer (which is the only conversion allowed by the
|
||||
// DASH specification) and hexadecimal integer (due to existing content that uses it).
|
||||
// Else we assume that the conversion is missing, and that it should be decimal integer.
|
||||
if (!formatTag.endsWith("d") && !formatTag.endsWith("x")) {
|
||||
formatTag += "d";
|
||||
}
|
||||
identifier = identifier.substring(0, formatTagIndex);
|
||||
}
|
||||
switch (identifier) {
|
||||
case NUMBER:
|
||||
identifiers[identifierCount] = NUMBER_ID;
|
||||
break;
|
||||
case BANDWIDTH:
|
||||
identifiers[identifierCount] = BANDWIDTH_ID;
|
||||
break;
|
||||
case TIME:
|
||||
identifiers[identifierCount] = TIME_ID;
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Invalid template: " + template);
|
||||
}
|
||||
identifierFormatTags[identifierCount] = formatTag;
|
||||
}
|
||||
identifierCount++;
|
||||
urlPieces[identifierCount] = "";
|
||||
templateIndex = secondIndex + 1;
|
||||
}
|
||||
}
|
||||
return identifierCount;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,499 @@
|
||||
package com.futo.platformplayer.sabr.parser;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
|
||||
import com.futo.platformplayer.sabr.UrlQueryString;
|
||||
import com.futo.platformplayer.sabr.UrlQueryStringFactory;
|
||||
import com.futo.platformplayer.sabr.parser.exceptions.MediaSegmentMismatchError;
|
||||
import com.futo.platformplayer.sabr.parser.exceptions.SabrStreamError;
|
||||
import com.futo.platformplayer.sabr.parser.models.AudioSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.CaptionSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.VideoSelector;
|
||||
import com.futo.platformplayer.sabr.parser.parts.FormatInitializedSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentDataSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentEndSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentInitSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.PoTokenStatusSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.RefreshPlayerResponseSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.SabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessFormatInitializationMetadataResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessMediaEndResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessMediaHeaderResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessMediaResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessStreamProtectionStatusResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.SabrProcessor;
|
||||
import com.futo.platformplayer.sabr.parser.ump.UMPDecoder;
|
||||
import com.futo.platformplayer.sabr.parser.ump.UMPPart;
|
||||
import com.futo.platformplayer.sabr.parser.ump.UMPPartId;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ClientAbrState;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ClientInfo;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatInitializationMetadata;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.LiveMetadata;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.NextRequestPolicy;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.MediaHeader;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrRedirect;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.StreamProtectionStatus;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrSeek;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrError;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrContextUpdate;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrContextSendingPolicy;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ReloadPlayerResponse;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@UnstableApi
|
||||
public class SabrStream {
|
||||
private static final String TAG = SabrStream.class.getSimpleName();
|
||||
private final int[] KNOWN_PARTS = {
|
||||
UMPPartId.MEDIA_HEADER,
|
||||
UMPPartId.MEDIA,
|
||||
UMPPartId.MEDIA_END,
|
||||
UMPPartId.STREAM_PROTECTION_STATUS,
|
||||
UMPPartId.SABR_REDIRECT,
|
||||
UMPPartId.FORMAT_INITIALIZATION_METADATA,
|
||||
UMPPartId.NEXT_REQUEST_POLICY,
|
||||
UMPPartId.LIVE_METADATA,
|
||||
UMPPartId.SABR_SEEK,
|
||||
UMPPartId.SABR_ERROR,
|
||||
UMPPartId.SABR_CONTEXT_UPDATE,
|
||||
UMPPartId.SABR_CONTEXT_SENDING_POLICY,
|
||||
UMPPartId.RELOAD_PLAYER_RESPONSE
|
||||
};
|
||||
private final int[] IGNORED_PARTS = {
|
||||
UMPPartId.REQUEST_IDENTIFIER,
|
||||
UMPPartId.REQUEST_CANCELLATION_POLICY,
|
||||
UMPPartId.PLAYBACK_START_POLICY,
|
||||
UMPPartId.ALLOWED_CACHED_FORMATS,
|
||||
UMPPartId.PAUSE_BW_SAMPLING_HINT,
|
||||
UMPPartId.START_BW_SAMPLING_HINT,
|
||||
UMPPartId.REQUEST_PIPELINING,
|
||||
UMPPartId.SELECTABLE_FORMATS,
|
||||
UMPPartId.PREWARM_CONNECTION,
|
||||
};
|
||||
private final UMPDecoder decoder;
|
||||
private final SabrProcessor processor;
|
||||
private final NoSegmentsTracker noNewSegmentsTracker;
|
||||
private final Set<Integer> unknownPartTypes;
|
||||
private int sqMismatchForwardCount;
|
||||
private int sqMismatchBacktrackCount;
|
||||
private boolean receivedNewSegments;
|
||||
private String url;
|
||||
private List<? extends SabrPart> multiResult = null;
|
||||
|
||||
private static class NoSegmentsTracker { // TODO: move to the SABR request builder
|
||||
public int consecutiveRequests = 0;
|
||||
public float timestampStarted = -1;
|
||||
public int liveHeadSegmentStarted = -1;
|
||||
|
||||
public void reset() {
|
||||
consecutiveRequests = 0;
|
||||
timestampStarted = -1;
|
||||
liveHeadSegmentStarted = -1;
|
||||
}
|
||||
|
||||
public void increment(int liveHeadSegment) {
|
||||
if (consecutiveRequests == 0) {
|
||||
timestampStarted = System.currentTimeMillis() * 1_000;
|
||||
liveHeadSegmentStarted = liveHeadSegment;
|
||||
}
|
||||
consecutiveRequests += 1;
|
||||
}
|
||||
}
|
||||
|
||||
public SabrStream(
|
||||
@NonNull String serverAbrStreamingUrl,
|
||||
@NonNull String videoPlaybackUstreamerConfig,
|
||||
@NonNull ClientInfo clientInfo,
|
||||
AudioSelector audioSelection,
|
||||
VideoSelector videoSelection,
|
||||
CaptionSelector captionSelection,
|
||||
int liveSegmentTargetDurationSec,
|
||||
int liveSegmentTargetDurationToleranceMs,
|
||||
long startTimeMs,
|
||||
String poToken,
|
||||
boolean postLive,
|
||||
String videoId
|
||||
) {
|
||||
decoder = new UMPDecoder();
|
||||
processor = new SabrProcessor(
|
||||
videoPlaybackUstreamerConfig,
|
||||
clientInfo,
|
||||
audioSelection,
|
||||
videoSelection,
|
||||
captionSelection,
|
||||
liveSegmentTargetDurationSec,
|
||||
liveSegmentTargetDurationToleranceMs,
|
||||
startTimeMs,
|
||||
poToken,
|
||||
postLive,
|
||||
videoId
|
||||
);
|
||||
url = serverAbrStreamingUrl;
|
||||
|
||||
// Whether we got any new (not consumed) segments in the request
|
||||
noNewSegmentsTracker = new NoSegmentsTracker();
|
||||
unknownPartTypes = new HashSet<>();
|
||||
|
||||
sqMismatchBacktrackCount = 0;
|
||||
sqMismatchForwardCount = 0;
|
||||
}
|
||||
|
||||
public SabrPart parse(@NonNull ExtractorInput extractorInput) {
|
||||
SabrPart result = null;
|
||||
|
||||
while (result == null && (multiResult == null || multiResult.isEmpty())) {
|
||||
UMPPart part = nextKnownUMPPart(extractorInput);
|
||||
|
||||
if (part == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
result = parsePart(part);
|
||||
|
||||
if (result == null) {
|
||||
multiResult = parseMultiPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
return result != null ? result : multiResult != null && !multiResult.isEmpty() ? multiResult.remove(0) : null;
|
||||
}
|
||||
|
||||
private SabrPart parsePart(UMPPart part) {
|
||||
switch (part.partId) {
|
||||
case UMPPartId.MEDIA_HEADER:
|
||||
return processMediaHeader(part);
|
||||
case UMPPartId.MEDIA:
|
||||
return processMedia(part);
|
||||
case UMPPartId.MEDIA_END:
|
||||
return processMediaEnd(part);
|
||||
case UMPPartId.STREAM_PROTECTION_STATUS:
|
||||
return processStreamProtectionStatus(part);
|
||||
case UMPPartId.SABR_REDIRECT:
|
||||
processSabrRedirect(part);
|
||||
return null;
|
||||
case UMPPartId.FORMAT_INITIALIZATION_METADATA:
|
||||
return processFormatInitializationMetadata(part);
|
||||
case UMPPartId.NEXT_REQUEST_POLICY:
|
||||
processNextRequestPolicy(part);
|
||||
return null;
|
||||
case UMPPartId.SABR_ERROR:
|
||||
processSabrError(part);
|
||||
return null;
|
||||
case UMPPartId.SABR_CONTEXT_UPDATE:
|
||||
processSabrContextUpdate(part);
|
||||
return null;
|
||||
case UMPPartId.SABR_CONTEXT_SENDING_POLICY:
|
||||
processSabrContextSendingPolicy(part);
|
||||
return null;
|
||||
case UMPPartId.RELOAD_PLAYER_RESPONSE:
|
||||
return processReloadPlayerResponse(part);
|
||||
}
|
||||
|
||||
if (!contains(IGNORED_PARTS, part.partId)) {
|
||||
unknownPartTypes.add(part.partId);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Unhandled part type %s", part.partId));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<? extends SabrPart> parseMultiPart(UMPPart part) {
|
||||
switch (part.partId) {
|
||||
case UMPPartId.LIVE_METADATA:
|
||||
return processLiveMetadata(part);
|
||||
case UMPPartId.SABR_SEEK:
|
||||
return processSabrSeek(part);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private MediaSegmentInitSabrPart processMediaHeader(UMPPart part) {
|
||||
MediaHeader mediaHeader;
|
||||
|
||||
try {
|
||||
mediaHeader = MediaHeader.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
ProcessMediaHeaderResult result = processor.processMediaHeader(mediaHeader);
|
||||
|
||||
return result.sabrPart;
|
||||
} catch (MediaSegmentMismatchError e) {
|
||||
// For livestreams, the server may not know the exact segment for a given player time.
|
||||
// For segments near stream head, it estimates using segment duration, which can cause off-by-one segment mismatches.
|
||||
// If a segment is much longer or shorter than expected, the server may return a segment ahead or behind.
|
||||
// In such cases, retry with an adjusted player time to resync.
|
||||
if (processor.isLive() && e.receivedSequenceNumber == e.expectedSequenceNumber - 1) {
|
||||
// The segment before the previous segment was possibly longer than expected.
|
||||
// Move the player time forward to try to adjust for this.
|
||||
ClientAbrState state = processor.getClientAbrState().toBuilder()
|
||||
.setPlayerTimeMs(processor.getClientAbrState().getPlayerTimeMs() + processor.getLiveSegmentTargetDurationToleranceMs())
|
||||
.build();
|
||||
processor.setClientAbrState(state);
|
||||
sqMismatchForwardCount += 1;
|
||||
return null;
|
||||
} else if (processor.isLive() && e.receivedSequenceNumber == e.expectedSequenceNumber + 2) {
|
||||
// The previous segment was possibly shorter than expected
|
||||
// Move the player time backwards to try to adjust for this.
|
||||
ClientAbrState state = processor.getClientAbrState().toBuilder()
|
||||
.setPlayerTimeMs(Math.max(0, processor.getClientAbrState().getPlayerTimeMs() - processor.getLiveSegmentTargetDurationToleranceMs()))
|
||||
.build();
|
||||
processor.setClientAbrState(state);
|
||||
sqMismatchBacktrackCount += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private MediaSegmentDataSabrPart processMedia(UMPPart part) {
|
||||
try {
|
||||
long position = part.data.getPosition();
|
||||
long headerId = decoder.readVarInt(part.data);
|
||||
long offset = part.data.getPosition() - position;
|
||||
int contentLength = part.size - (int) offset;
|
||||
|
||||
ProcessMediaResult result = processor.processMedia(headerId, contentLength, part.data);
|
||||
|
||||
return result.sabrPart;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private MediaSegmentEndSabrPart processMediaEnd(UMPPart part) {
|
||||
try {
|
||||
long headerId = decoder.readVarInt(part.data);
|
||||
Log.d(TAG, String.format("Header ID: %s", headerId));
|
||||
|
||||
ProcessMediaEndResult result = processor.processMediaEnd(headerId);
|
||||
|
||||
if (result.isNewSegment) {
|
||||
receivedNewSegments = true;
|
||||
}
|
||||
|
||||
return result.sabrPart;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private PoTokenStatusSabrPart processStreamProtectionStatus(UMPPart part) {
|
||||
StreamProtectionStatus sps;
|
||||
|
||||
try {
|
||||
sps = StreamProtectionStatus.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process StreamProtectionStatus: %s", sps));
|
||||
ProcessStreamProtectionStatusResult result = processor.processStreamProtectionStatus(sps);
|
||||
|
||||
return result.sabrPart;
|
||||
}
|
||||
|
||||
private void processSabrRedirect(UMPPart part) {
|
||||
SabrRedirect sabrRedirect;
|
||||
|
||||
try {
|
||||
sabrRedirect = SabrRedirect.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrRedirect: %s", sabrRedirect));
|
||||
|
||||
if (!sabrRedirect.hasRedirectUrl()) {
|
||||
Log.d(TAG, "Server requested to redirect to an invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setUrl(sabrRedirect.getRedirectUrl());
|
||||
}
|
||||
|
||||
private FormatInitializedSabrPart processFormatInitializationMetadata(UMPPart part) {
|
||||
FormatInitializationMetadata fmtInitMetadata;
|
||||
|
||||
try {
|
||||
fmtInitMetadata = FormatInitializationMetadata.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process FormatInitializationMetadata: %s", fmtInitMetadata));
|
||||
ProcessFormatInitializationMetadataResult result = processor.processFormatInitializationMetadata(fmtInitMetadata);
|
||||
|
||||
return result.sabrPart;
|
||||
}
|
||||
|
||||
private void processNextRequestPolicy(UMPPart part) {
|
||||
NextRequestPolicy nextRequestPolicy;
|
||||
|
||||
try {
|
||||
nextRequestPolicy = NextRequestPolicy.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process NextRequestPolicy: %s", nextRequestPolicy));
|
||||
processor.processNextRequestPolicy(nextRequestPolicy);
|
||||
}
|
||||
|
||||
private void processSabrError(UMPPart part) {
|
||||
SabrError sabrError;
|
||||
|
||||
try {
|
||||
sabrError = SabrError.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrError: %s", sabrError));
|
||||
throw new SabrStreamError(String.format("SABR Protocol Error: %s", sabrError));
|
||||
}
|
||||
|
||||
private void processSabrContextUpdate(UMPPart part) {
|
||||
SabrContextUpdate sabrCtxUpdate;
|
||||
|
||||
try {
|
||||
sabrCtxUpdate = SabrContextUpdate.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrContextUpdate: %s", sabrCtxUpdate));
|
||||
processor.processSabrContextUpdate(sabrCtxUpdate);
|
||||
}
|
||||
|
||||
private void processSabrContextSendingPolicy(UMPPart part) {
|
||||
SabrContextSendingPolicy sabrCtxSendingPolicy;
|
||||
|
||||
try {
|
||||
sabrCtxSendingPolicy = SabrContextSendingPolicy.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrContextSendingPolicy: %s", sabrCtxSendingPolicy));
|
||||
processor.processSabrContextSendingPolicy(sabrCtxSendingPolicy);
|
||||
}
|
||||
|
||||
private RefreshPlayerResponseSabrPart processReloadPlayerResponse(UMPPart part) {
|
||||
ReloadPlayerResponse reloadPlayerResponse;
|
||||
|
||||
try {
|
||||
reloadPlayerResponse = ReloadPlayerResponse.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process ReloadPlayerResponse: %s", reloadPlayerResponse));
|
||||
return new RefreshPlayerResponseSabrPart(
|
||||
RefreshPlayerResponseSabrPart.Reason.SABR_RELOAD_PLAYER_RESPONSE,
|
||||
reloadPlayerResponse.hasReloadPlaybackParams() && reloadPlayerResponse.getReloadPlaybackParams().hasToken()
|
||||
? reloadPlayerResponse.getReloadPlaybackParams().getToken() : null
|
||||
);
|
||||
}
|
||||
|
||||
private List<MediaSeekSabrPart> processLiveMetadata(UMPPart part) {
|
||||
LiveMetadata liveMetadata;
|
||||
|
||||
try {
|
||||
liveMetadata = LiveMetadata.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process LiveMetadata: %s", liveMetadata));
|
||||
return processor.processLiveMetadata(liveMetadata).seekSabrParts;
|
||||
}
|
||||
|
||||
private List<MediaSeekSabrPart> processSabrSeek(UMPPart part) {
|
||||
SabrSeek sabrSeek;
|
||||
|
||||
try {
|
||||
sabrSeek = SabrSeek.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrSeek: %s", sabrSeek));
|
||||
return processor.processSabrSeek(sabrSeek).seekSabrParts;
|
||||
}
|
||||
|
||||
public static boolean contains(int[] array, int value) {
|
||||
for (int num : array) {
|
||||
if (num == value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private UMPPart nextKnownUMPPart(@NonNull ExtractorInput extractorInput) {
|
||||
UMPPart part;
|
||||
|
||||
while (true) {
|
||||
part = decoder.decode(extractorInput);
|
||||
|
||||
if (part == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (contains(KNOWN_PARTS, part.partId)) {
|
||||
break;
|
||||
} else {
|
||||
Log.d(TAG, String.format("Unknown part encountered: %s", part.partId));
|
||||
}
|
||||
}
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
private String getUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public static boolean equals(Object first, Object second) {
|
||||
if (first == null && second == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
private void setUrl(String url) {
|
||||
Log.d(TAG, String.format("New URL: %s", url));
|
||||
UrlQueryString newQueryString = UrlQueryStringFactory.parse(url);
|
||||
UrlQueryString oldQueryString = UrlQueryStringFactory.parse(this.url);
|
||||
String bn = newQueryString.get("id");
|
||||
String bc = oldQueryString.get("id");
|
||||
if (processor.isLive() && this.url != null && !equals(bn, bc)) {
|
||||
throw new SabrStreamError(String.format("Broadcast ID changed from %s to %s. The download will need to be restarted.", bc, bn));
|
||||
}
|
||||
this.url = url;
|
||||
if (equals(newQueryString.get("source"), "yt_live_broadcast")) {
|
||||
processor.setLive(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSegmentMismatchError extends SabrStreamError {
|
||||
public final long expectedSequenceNumber;
|
||||
public final long receivedSequenceNumber;
|
||||
|
||||
public MediaSegmentMismatchError(FormatId formatId, long expectedSequenceNumber, long receivedSequenceNumber) {
|
||||
super(String.format(
|
||||
"Segment sequence number mismatch for format %s: expected %s, received %s",
|
||||
formatId,
|
||||
expectedSequenceNumber,
|
||||
receivedSequenceNumber
|
||||
));
|
||||
this.receivedSequenceNumber = receivedSequenceNumber;
|
||||
this.expectedSequenceNumber = expectedSequenceNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class PoTokenError extends SabrStreamError {
|
||||
public PoTokenError(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class SabrStreamConsumedError extends Exception {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class SabrStreamError extends RuntimeException {
|
||||
public SabrStreamError(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class AudioSelector extends FormatSelector {
|
||||
public AudioSelector(String displayName, boolean discardMedia) {
|
||||
super(displayName, discardMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimePrefix() {
|
||||
return "audio";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class CaptionSelector extends FormatSelector {
|
||||
public CaptionSelector(String displayName, boolean discardMedia) {
|
||||
super(displayName, discardMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimePrefix() {
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class ConsumedRange {
|
||||
public long startSequenceNumber;
|
||||
public long endSequenceNumber;
|
||||
public long startTimeMs;
|
||||
public long durationMs;
|
||||
|
||||
public ConsumedRange(long startTimeMs, long durationMs, long startSequenceNumber, long endSequenceNumber) {
|
||||
this.startTimeMs = startTimeMs;
|
||||
this.durationMs = durationMs;
|
||||
this.startSequenceNumber = startSequenceNumber;
|
||||
this.endSequenceNumber = endSequenceNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class FormatSelector {
|
||||
public final String displayName;
|
||||
public final List<FormatId> formatIds = new ArrayList<>();
|
||||
public final boolean discardMedia;
|
||||
|
||||
public FormatSelector(String displayName, boolean discardMedia) {
|
||||
this.displayName = displayName;
|
||||
this.discardMedia = discardMedia;
|
||||
}
|
||||
|
||||
public String getMimePrefix() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean match(FormatId formatId, String mimeType) {
|
||||
return formatIds.contains(formatId)
|
||||
|| (formatIds.isEmpty() && getMimePrefix() != null && mimeType != null && mimeType.toLowerCase().startsWith(getMimePrefix()));
|
||||
}
|
||||
|
||||
public boolean isDiscardMedia() {
|
||||
return discardMedia;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class InitializedFormat {
|
||||
public final FormatId formatId;
|
||||
public final int durationMs;
|
||||
public final int endTimeMs;
|
||||
public final String mimeType;
|
||||
public final String videoId;
|
||||
public final FormatSelector formatSelector;
|
||||
public int totalSegments;
|
||||
public final boolean discard;
|
||||
public int sequenceLmt = -1;
|
||||
public Segment currentSegment;
|
||||
public Segment initSegment;
|
||||
public final List<ConsumedRange> consumedRanges = new ArrayList<>();
|
||||
|
||||
public InitializedFormat(
|
||||
FormatId formatId,
|
||||
int durationMs,
|
||||
int endTimeMs,
|
||||
String mimeType,
|
||||
String videoId,
|
||||
FormatSelector formatSelector,
|
||||
int totalSegments,
|
||||
boolean discard
|
||||
) {
|
||||
this.formatId = formatId;
|
||||
this.durationMs = durationMs;
|
||||
this.endTimeMs = endTimeMs;
|
||||
this.mimeType = mimeType;
|
||||
this.videoId = videoId;
|
||||
this.formatSelector = formatSelector;
|
||||
this.totalSegments = totalSegments;
|
||||
this.discard = discard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class Segment {
|
||||
public final FormatId formatId;
|
||||
public final boolean isInitSegment;
|
||||
public final int durationMs;
|
||||
public final int startDataRange;
|
||||
public long sequenceNumber;
|
||||
public final long contentLength;
|
||||
public final boolean contentLengthEstimated;
|
||||
public final int startMs;
|
||||
public final InitializedFormat initializedFormat;
|
||||
public final boolean durationEstimated;
|
||||
public final boolean discard;
|
||||
public final boolean consumed;
|
||||
public final int sequenceLmt;
|
||||
public int receivedDataLength;
|
||||
|
||||
public Segment(FormatId formatId,
|
||||
boolean isInitSegment,
|
||||
int durationMs,
|
||||
int startDataRange,
|
||||
long sequenceNumber,
|
||||
long contentLength,
|
||||
boolean contentLengthEstimated,
|
||||
int startMs,
|
||||
InitializedFormat initializedFormat,
|
||||
boolean durationEstimated,
|
||||
boolean discard,
|
||||
boolean consumed,
|
||||
int sequenceLmt) {
|
||||
this.formatId = formatId;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.durationMs = durationMs;
|
||||
this.startDataRange = startDataRange;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.contentLength = contentLength;
|
||||
this.contentLengthEstimated = contentLengthEstimated;
|
||||
this.startMs = startMs;
|
||||
this.initializedFormat = initializedFormat;
|
||||
this.durationEstimated = durationEstimated;
|
||||
this.discard = discard;
|
||||
this.consumed = consumed;
|
||||
this.sequenceLmt = sequenceLmt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class VideoSelector extends FormatSelector {
|
||||
public VideoSelector(String displayName, boolean discardMedia) {
|
||||
super(displayName, discardMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimePrefix() {
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class FormatInitializedSabrPart implements SabrPart {
|
||||
public final FormatId formatId;
|
||||
public final FormatSelector formatSelector;
|
||||
|
||||
public FormatInitializedSabrPart(FormatId formatId, FormatSelector formatSelector) {
|
||||
this.formatId = formatId;
|
||||
this.formatSelector = formatSelector;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSeekSabrPart implements SabrPart {
|
||||
public Reason reason;
|
||||
public FormatId formatId;
|
||||
public FormatSelector formatSelector;
|
||||
|
||||
public MediaSeekSabrPart(Reason reason, FormatId formatId, FormatSelector formatSelector) {
|
||||
this.reason = reason;
|
||||
this.formatId = formatId;
|
||||
this.formatSelector = formatSelector;
|
||||
}
|
||||
|
||||
// Lets the consumer know the media sequence for a format may change
|
||||
public enum Reason {
|
||||
UNKNOWN,
|
||||
SERVER_SEEK, // SABR_SEEK from server
|
||||
CONSUMED_SEEK // Seeking as next fragment is already buffered
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
@UnstableApi
|
||||
public class MediaSegmentDataSabrPart implements SabrPart {
|
||||
public final FormatSelector formatSelector;
|
||||
public final FormatId formatId;
|
||||
public final long sequenceNumber;
|
||||
public final boolean isInitSegment;
|
||||
public final int totalSegments;
|
||||
public final ExtractorInput data;
|
||||
public final int contentLength;
|
||||
public final int segmentStartBytes;
|
||||
|
||||
public MediaSegmentDataSabrPart(
|
||||
FormatSelector formatSelector,
|
||||
FormatId formatId,
|
||||
long sequenceNumber,
|
||||
boolean isInitSegment,
|
||||
int totalSegments,
|
||||
ExtractorInput data,
|
||||
int contentLength,
|
||||
int segmentStartBytes) {
|
||||
this.formatSelector = formatSelector;
|
||||
this.formatId = formatId;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.totalSegments = totalSegments;
|
||||
this.data = data;
|
||||
this.contentLength = contentLength;
|
||||
this.segmentStartBytes = segmentStartBytes;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSegmentEndSabrPart implements SabrPart {
|
||||
public final FormatSelector formatSelector;
|
||||
public final FormatId formatId;
|
||||
public final long sequenceNumber;
|
||||
public final boolean isInitSegment;
|
||||
public final long totalSegments;
|
||||
|
||||
public MediaSegmentEndSabrPart(
|
||||
FormatSelector formatSelector,
|
||||
FormatId formatId,
|
||||
long sequenceNumber,
|
||||
boolean isInitSegment,
|
||||
long totalSegments) {
|
||||
this.formatSelector = formatSelector;
|
||||
this.formatId = formatId;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.totalSegments = totalSegments;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSegmentInitSabrPart implements SabrPart {
|
||||
public final FormatSelector formatSelector;
|
||||
public final FormatId formatId;
|
||||
public final long playerTimeMs;
|
||||
public final long sequenceNumber;
|
||||
public final long totalSegments;
|
||||
public final int durationMs;
|
||||
public final boolean durationEstimated;
|
||||
public final int startBytes;
|
||||
public final int startTimeMs;
|
||||
public final boolean isInitSegment;
|
||||
public final long contentLength;
|
||||
public final boolean contentLengthEstimate;
|
||||
|
||||
public MediaSegmentInitSabrPart(
|
||||
FormatSelector formatSelector,
|
||||
FormatId formatId,
|
||||
long playerTimeMs,
|
||||
long sequenceNumber,
|
||||
long totalSegments,
|
||||
int durationMs,
|
||||
boolean durationEstimated,
|
||||
int startBytes,
|
||||
int startTimeMs,
|
||||
boolean isInitSegment,
|
||||
long contentLength,
|
||||
boolean contentLengthEstimate) {
|
||||
this.formatSelector = formatSelector;
|
||||
this.formatId = formatId;
|
||||
this.playerTimeMs = playerTimeMs;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.totalSegments = totalSegments;
|
||||
this.durationMs = durationMs;
|
||||
this.durationEstimated = durationEstimated;
|
||||
this.startBytes = startBytes;
|
||||
this.startTimeMs = startTimeMs;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.contentLength = contentLength;
|
||||
this.contentLengthEstimate = contentLengthEstimate;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
public class PoTokenStatusSabrPart implements SabrPart {
|
||||
public final PoTokenStatus status;
|
||||
|
||||
public PoTokenStatusSabrPart(PoTokenStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public enum PoTokenStatus {
|
||||
OK, // PO Token is provided and valid
|
||||
MISSING, // PO Token is not provided, and is required. A PO Token should be provided ASAP
|
||||
INVALID, // PO Token is provided, but is invalid. A new one should be generated ASAP
|
||||
PENDING, // PO Token is provided, but probably only a cold start token. A full PO Token should be provided ASAP
|
||||
NOT_REQUIRED, // PO Token is not provided, and is not required
|
||||
PENDING_MISSING // PO Token is not provided, but is pending. A full PO Token should be (probably) provided ASAP
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
public class RefreshPlayerResponseSabrPart implements SabrPart {
|
||||
public final Reason reason;
|
||||
public final String reloadPlaybackToken;
|
||||
|
||||
public RefreshPlayerResponseSabrPart(Reason reason, String reloadPlaybackToken) {
|
||||
this.reason = reason;
|
||||
this.reloadPlaybackToken = reloadPlaybackToken;
|
||||
}
|
||||
|
||||
public enum Reason {
|
||||
UNKNOWN,
|
||||
SABR_URL_EXPIRY,
|
||||
SABR_RELOAD_PLAYER_RESPONSE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
public interface SabrPart {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.FormatInitializedSabrPart;
|
||||
|
||||
public class ProcessFormatInitializationMetadataResult {
|
||||
public FormatInitializedSabrPart sabrPart;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ProcessLiveMetadataResult {
|
||||
public final List<MediaSeekSabrPart> seekSabrParts = new ArrayList<>();
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentEndSabrPart;
|
||||
|
||||
public class ProcessMediaEndResult {
|
||||
public MediaSegmentEndSabrPart sabrPart;
|
||||
public boolean isNewSegment; // TODO: better name
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentInitSabrPart;
|
||||
|
||||
public class ProcessMediaHeaderResult {
|
||||
public MediaSegmentInitSabrPart sabrPart;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentDataSabrPart;
|
||||
|
||||
public class ProcessMediaResult {
|
||||
public MediaSegmentDataSabrPart sabrPart;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ProcessSabrSeekResult {
|
||||
public final List<MediaSeekSabrPart> seekSabrParts = new ArrayList<>();
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.PoTokenStatusSabrPart;
|
||||
|
||||
public class ProcessStreamProtectionStatusResult {
|
||||
public PoTokenStatusSabrPart sabrPart;
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import com.futo.platformplayer.sabr.parser.exceptions.MediaSegmentMismatchError;
|
||||
import com.futo.platformplayer.sabr.parser.exceptions.SabrStreamError;
|
||||
import com.futo.platformplayer.sabr.parser.models.AudioSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.CaptionSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.ConsumedRange;
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.InitializedFormat;
|
||||
import com.futo.platformplayer.sabr.parser.models.Segment;
|
||||
import com.futo.platformplayer.sabr.parser.models.VideoSelector;
|
||||
import com.futo.platformplayer.sabr.parser.parts.FormatInitializedSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentDataSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentEndSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentInitSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.PoTokenStatusSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.PoTokenStatusSabrPart.PoTokenStatus;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ClientAbrState;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ClientInfo;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatInitializationMetadata;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.LiveMetadata;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.MediaHeader;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.NextRequestPolicy;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrContextSendingPolicy;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrContextUpdate;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrSeek;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.StreamProtectionStatus;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.StreamProtectionStatus.Status;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.TimeRange;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@UnstableApi
|
||||
public class SabrProcessor {
|
||||
private static final String TAG = SabrProcessor.class.getSimpleName();
|
||||
public static final int NO_VALUE = -1;
|
||||
private final String videoPlaybackUstreamerConfig;
|
||||
private final ClientInfo clientInfo;
|
||||
private VideoSelector videoFormatSelector;
|
||||
private AudioSelector audioFormatSelector;
|
||||
private CaptionSelector captionFormatSelector;
|
||||
private final int liveSegmentTargetDurationToleranceMs;
|
||||
private final int liveSegmentTargetDurationSec;
|
||||
private final long startTimeMs;
|
||||
private final String poToken;
|
||||
private final boolean postLive;
|
||||
private final String videoId;
|
||||
private ClientAbrState clientAbrState;
|
||||
private final Map<Long, Segment> partialSegments;
|
||||
private final Map<String, InitializedFormat> initializedFormats;
|
||||
private Status streamProtectionStatus;
|
||||
private boolean isLive;
|
||||
private LiveMetadata liveMetadata;
|
||||
private long totalDurationMs;
|
||||
private NextRequestPolicy nextRequestPolicy;
|
||||
private final Map<Integer, SabrContextUpdate> sabrContextUpdates;
|
||||
private final Set<Integer> sabrContextsToSend;
|
||||
private List<FormatId> selectedAudioFormatIds;
|
||||
private List<FormatId> selectedVideoFormatIds;
|
||||
private List<FormatId> selectedCaptionFormatIds;
|
||||
|
||||
public SabrProcessor(
|
||||
@NonNull String videoPlaybackUstreamerConfig,
|
||||
@NonNull ClientInfo clientInfo,
|
||||
AudioSelector audioSelection,
|
||||
VideoSelector videoSelection,
|
||||
CaptionSelector captionSelection,
|
||||
int liveSegmentTargetDurationSec,
|
||||
int liveSegmentTargetDurationToleranceMs,
|
||||
long startTimeMs,
|
||||
String poToken,
|
||||
boolean postLive,
|
||||
String videoId
|
||||
) {
|
||||
this.videoPlaybackUstreamerConfig = videoPlaybackUstreamerConfig;
|
||||
this.poToken = poToken;
|
||||
this.clientInfo = clientInfo;
|
||||
this.liveSegmentTargetDurationSec = liveSegmentTargetDurationSec != NO_VALUE ? liveSegmentTargetDurationSec : 5;
|
||||
this.liveSegmentTargetDurationToleranceMs = liveSegmentTargetDurationToleranceMs != NO_VALUE ? liveSegmentTargetDurationToleranceMs : 100;
|
||||
if (this.liveSegmentTargetDurationToleranceMs >= (this.liveSegmentTargetDurationSec * 1_000) / 2) {
|
||||
throw new IllegalArgumentException("liveSegmentTargetDurationToleranceMs must be less than half of liveSegmentTargetDurationSec in milliseconds");
|
||||
}
|
||||
this.startTimeMs = startTimeMs != NO_VALUE ? startTimeMs : 0;
|
||||
if (this.startTimeMs < 0) {
|
||||
throw new IllegalArgumentException("start_time_ms must be greater than or equal to 0");
|
||||
}
|
||||
|
||||
this.postLive = postLive;
|
||||
isLive = false;
|
||||
this.videoId = videoId;
|
||||
|
||||
audioFormatSelector = audioSelection;
|
||||
videoFormatSelector = videoSelection;
|
||||
captionFormatSelector = captionSelection;
|
||||
|
||||
// IMPORTANT: initialized formats is assumed to contain only ACTIVE formats
|
||||
initializedFormats = new HashMap<>();
|
||||
|
||||
partialSegments = new HashMap<>();
|
||||
totalDurationMs = NO_VALUE;
|
||||
sabrContextsToSend = new HashSet<>();
|
||||
sabrContextUpdates = new HashMap<>();
|
||||
initializeClientAbrState();
|
||||
}
|
||||
|
||||
private void initializeClientAbrState() {
|
||||
if (videoFormatSelector == null) {
|
||||
videoFormatSelector = new VideoSelector("video_ignore", true);
|
||||
}
|
||||
|
||||
if (audioFormatSelector == null) {
|
||||
audioFormatSelector = new AudioSelector("audio_ignore", true);
|
||||
}
|
||||
|
||||
if (captionFormatSelector == null) {
|
||||
captionFormatSelector = new CaptionSelector("caption_ignore", true);
|
||||
}
|
||||
|
||||
int enabledTrackTypesBitfield = 0; // Audio+Video
|
||||
|
||||
if (videoFormatSelector.discardMedia) {
|
||||
enabledTrackTypesBitfield = 1; // Audio only
|
||||
}
|
||||
|
||||
if (!captionFormatSelector.discardMedia) {
|
||||
// SABR does not support caption-only or audio+captions only - can only get audio+video with captions
|
||||
// If audio or video is not selected, the tracks will be initialized but marked as buffered.
|
||||
enabledTrackTypesBitfield = 7;
|
||||
}
|
||||
|
||||
selectedAudioFormatIds = audioFormatSelector.formatIds;
|
||||
selectedVideoFormatIds = videoFormatSelector.formatIds;
|
||||
selectedCaptionFormatIds = captionFormatSelector.formatIds;
|
||||
|
||||
Log.d(TAG, "Starting playback at: " + startTimeMs + "ms");
|
||||
clientAbrState = ClientAbrState.newBuilder()
|
||||
.setPlayerTimeMs(startTimeMs)
|
||||
.setEnabledTrackTypesBitfield(enabledTrackTypesBitfield)
|
||||
.setDrcEnabled(true) // Required to stream DRC formats
|
||||
.build();
|
||||
}
|
||||
|
||||
public static String findFirst(String input, Pattern pattern) {
|
||||
String regExpVal = null;
|
||||
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (matcher.find()) {
|
||||
if (matcher.groupCount() >= 1) {
|
||||
regExpVal = matcher.group(1);
|
||||
} else {
|
||||
regExpVal = matcher.group(0); // all match
|
||||
}
|
||||
}
|
||||
|
||||
return regExpVal;
|
||||
}
|
||||
|
||||
public static boolean equals(Object first, Object second) {
|
||||
if (first == null && second == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
public ProcessMediaHeaderResult processMediaHeader(MediaHeader mediaHeader) {
|
||||
if (mediaHeader.hasVideoId() && videoId != null && !equals(mediaHeader.getVideoId(), videoId)) {
|
||||
throw new SabrStreamError(
|
||||
String.format("Received unexpected MediaHeader for video %s (expecting %s)", mediaHeader.getVideoId(), videoId));
|
||||
}
|
||||
|
||||
if (!mediaHeader.hasFormatId()) {
|
||||
throw new SabrStreamError(String.format("FormatId not found in MediaHeader (media_header=%s)", mediaHeader));
|
||||
}
|
||||
|
||||
// Guard. This should not happen, except if we don't clear partial segments
|
||||
if (partialSegments.containsKey(Utils.toLong(mediaHeader.getHeaderId()))) {
|
||||
throw new SabrStreamError(String.format("Header ID %s already exists", mediaHeader.getHeaderId()));
|
||||
}
|
||||
|
||||
InitializedFormat initializedFormat = initializedFormats.get(mediaHeader.getFormatId().toString());
|
||||
|
||||
if (initializedFormat == null) {
|
||||
throw new SabrStreamError(String.format("Initialized format not found for %s", mediaHeader.getFormatId()));
|
||||
}
|
||||
|
||||
if (mediaHeader.hasCompression()) {
|
||||
// Unknown when this is used, but it is not supported currently
|
||||
throw new SabrStreamError(String.format("Compression not supported in MediaHeader (media_header=%s)", mediaHeader));
|
||||
}
|
||||
|
||||
long sequenceNumber = mediaHeader.hasSequenceNumber() ? mediaHeader.getSequenceNumber() : NO_VALUE;
|
||||
boolean isInitSegment = mediaHeader.getIsInitSegment();
|
||||
|
||||
if (sequenceNumber == NO_VALUE && !isInitSegment) {
|
||||
throw new SabrStreamError(String.format("Sequence number not found in MediaHeader (media_header=%s)", mediaHeader));
|
||||
}
|
||||
|
||||
initializedFormat.sequenceLmt = mediaHeader.hasSequenceLmt() ? mediaHeader.getSequenceLmt() : NO_VALUE;
|
||||
|
||||
// Need to keep track of if we discard due to be consumed or not
|
||||
// for processing down the line (MediaEnd)
|
||||
boolean consumed = false;
|
||||
boolean discard = initializedFormat.discard;
|
||||
|
||||
// Guard: Check if sequence number is within any existing consumed range
|
||||
// The server should not send us any segments that are already consumed
|
||||
// However, if retrying a request, we may get the same segment again
|
||||
if (!isInitSegment && findFirst(initializedFormat.consumedRanges, cr -> cr.startSequenceNumber <= sequenceNumber && sequenceNumber <= cr.endSequenceNumber) == null) {
|
||||
Log.d(TAG, initializedFormat.formatId + " segment " + sequenceNumber + " already consumed, marking segment as consumed");
|
||||
consumed = true;
|
||||
}
|
||||
|
||||
// Validate that the segment is in order.
|
||||
// Note: If the format is to be discarded, we do not care about the order
|
||||
// and can expect uncommanded seeks as the consumer does not know about it.
|
||||
// Note: previous segment should never be an init segment.
|
||||
Segment previousSegment = initializedFormat.currentSegment;
|
||||
if (previousSegment != null && !isInitSegment && !previousSegment.discard && !discard
|
||||
&& !consumed && sequenceNumber != previousSegment.sequenceNumber + 1) {
|
||||
// Bail out as the segment is not in order when it is expected to be
|
||||
throw new MediaSegmentMismatchError(mediaHeader.getFormatId(), previousSegment.sequenceNumber + 1, sequenceNumber);
|
||||
}
|
||||
|
||||
if (initializedFormat.initSegment != null && isInitSegment) {
|
||||
Log.d(TAG, String.format("Init segment %s already seen for format %s, marking segment as consumed",
|
||||
sequenceNumber, initializedFormat.formatId));
|
||||
consumed = true;
|
||||
}
|
||||
|
||||
TimeRange timeRange = mediaHeader.hasTimeRange() ? mediaHeader.getTimeRange() : null;
|
||||
int startMs = mediaHeader.hasStartMs() ? mediaHeader.getStartMs()
|
||||
: timeRange != null && timeRange.hasStartTicks() && timeRange.hasTimescale()
|
||||
? Utils.ticksToMs(timeRange.getStartTicks(), timeRange.getTimescale())
|
||||
: 0;
|
||||
|
||||
// Calculate duration of this segment
|
||||
// For videos, either duration_ms or time_range should be present
|
||||
// For live streams, calculate segment duration based on live metadata target segment duration
|
||||
int actualDurationMs = mediaHeader.hasDurationMs() ? mediaHeader.getDurationMs()
|
||||
: timeRange != null && timeRange.hasDurationTicks() && timeRange.hasTimescale()
|
||||
? Utils.ticksToMs(timeRange.getDurationTicks(), timeRange.getTimescale())
|
||||
: NO_VALUE;
|
||||
|
||||
int estimatedDurationMs = NO_VALUE;
|
||||
if (isLive()) {
|
||||
// Underestimate the duration of the segment slightly as
|
||||
// the real duration may be slightly shorter than the target duration.
|
||||
estimatedDurationMs = (getLiveSegmentTargetDurationSec() * 1_000) - getLiveSegmentTargetDurationToleranceMs();
|
||||
} else if (isInitSegment) {
|
||||
estimatedDurationMs = 0;
|
||||
}
|
||||
|
||||
int durationMs = actualDurationMs != NO_VALUE ? actualDurationMs : estimatedDurationMs;
|
||||
|
||||
// Guard: Bail out if we cannot determine the duration, which we need to progress.
|
||||
if (durationMs == NO_VALUE) {
|
||||
throw new SabrStreamError(
|
||||
String.format("Cannot determine duration of segment %s (media_header=%s)", sequenceNumber, mediaHeader));
|
||||
}
|
||||
|
||||
long estimatedContentLength = NO_VALUE;
|
||||
if (isLive() && !mediaHeader.hasContentLength() && mediaHeader.hasBitrateBps()) {
|
||||
estimatedContentLength = (long) Math.ceil(mediaHeader.getBitrateBps() * ((double) durationMs / 1_000));
|
||||
}
|
||||
|
||||
Segment segment = new Segment(
|
||||
mediaHeader.getFormatId(),
|
||||
isInitSegment,
|
||||
durationMs,
|
||||
mediaHeader.hasStartDataRange() ? mediaHeader.getStartDataRange() : NO_VALUE,
|
||||
sequenceNumber,
|
||||
mediaHeader.hasContentLength() ? mediaHeader.getContentLength() : estimatedContentLength,
|
||||
estimatedContentLength != NO_VALUE,
|
||||
startMs,
|
||||
initializedFormat,
|
||||
actualDurationMs == 0 || actualDurationMs == NO_VALUE,
|
||||
discard || consumed,
|
||||
consumed,
|
||||
mediaHeader.hasSequenceLmt() ? mediaHeader.getSequenceLmt() : NO_VALUE
|
||||
);
|
||||
|
||||
partialSegments.put(Utils.toLong(mediaHeader.getHeaderId()), segment);
|
||||
|
||||
ProcessMediaHeaderResult result = new ProcessMediaHeaderResult();
|
||||
|
||||
if (!segment.discard) {
|
||||
result.sabrPart = new MediaSegmentInitSabrPart(
|
||||
segment.initializedFormat.formatSelector,
|
||||
segment.formatId,
|
||||
clientAbrState.hasPlayerTimeMs() ? clientAbrState.getPlayerTimeMs() : NO_VALUE,
|
||||
segment.sequenceNumber,
|
||||
segment.initializedFormat.totalSegments,
|
||||
segment.durationMs,
|
||||
segment.durationEstimated,
|
||||
segment.startDataRange,
|
||||
segment.startMs,
|
||||
segment.isInitSegment,
|
||||
segment.contentLength,
|
||||
segment.contentLengthEstimated
|
||||
);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Initialized Media Header " + Utils.toLong(mediaHeader.getHeaderId()) + " for sequence " + sequenceNumber + ". Segment: " + segment);
|
||||
return result;
|
||||
}
|
||||
|
||||
public ProcessMediaResult processMedia(long headerId, int contentLength, ExtractorInput data) {
|
||||
Segment segment = partialSegments.get(headerId);
|
||||
if (segment == null) {
|
||||
Log.d(TAG, "Header ID " + headerId + " not found");
|
||||
throw new SabrStreamError(String.format("Header ID %s not found in partial segments", headerId));
|
||||
}
|
||||
|
||||
int segmentStartBytes = segment.receivedDataLength;
|
||||
segment.receivedDataLength += contentLength;
|
||||
|
||||
ProcessMediaResult result = new ProcessMediaResult();
|
||||
|
||||
if (!segment.discard) {
|
||||
result.sabrPart = new MediaSegmentDataSabrPart(
|
||||
segment.initializedFormat.formatSelector,
|
||||
segment.formatId,
|
||||
segment.sequenceNumber,
|
||||
segment.isInitSegment,
|
||||
segment.initializedFormat.totalSegments,
|
||||
data,
|
||||
contentLength,
|
||||
segmentStartBytes
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ProcessMediaEndResult processMediaEnd(long headerId) {
|
||||
Segment segment = partialSegments.remove(headerId);
|
||||
if (segment == null) {
|
||||
Log.d(TAG, String.format("Header ID %s not found", headerId));
|
||||
throw new SabrStreamError(String.format("Header ID %s not found in partial segments", headerId));
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("MediaEnd for %s (sequence %s, data length = %s)", segment.formatId, segment.sequenceNumber, segment.receivedDataLength));
|
||||
|
||||
if (segment.contentLength != -1 && segment.receivedDataLength != segment.contentLength) {
|
||||
if (segment.contentLengthEstimated) {
|
||||
Log.d(TAG, String.format("Content length for %s (sequence %s) was estimated, estimated %s bytes, got %s bytes", segment.formatId, segment.sequenceNumber, segment.contentLength, segment.receivedDataLength));
|
||||
} else {
|
||||
throw new SabrStreamError(String.format("Content length mismatch for %s (sequence %s): " +
|
||||
"expected %s bytes, got %s bytes",
|
||||
segment.formatId, segment.sequenceNumber, segment.contentLength, segment.receivedDataLength));
|
||||
}
|
||||
}
|
||||
|
||||
ProcessMediaEndResult result = new ProcessMediaEndResult();
|
||||
|
||||
// Only count received segments as new segments if they are not consumed.
|
||||
// Discarded segments that are not consumed are considered new segments.
|
||||
if (!segment.consumed) {
|
||||
result.isNewSegment = true;
|
||||
}
|
||||
|
||||
// Return the segment here instead of during MEDIA part(s) because:
|
||||
// 1. We can validate that we received the correct data length
|
||||
// 2. In the case of a retry during segment media, the partial data is not sent to the consumer
|
||||
if (!segment.discard) {
|
||||
// This needs to be yielded AFTER we have processed the segment
|
||||
// So the consumer can see the updated consumed ranges and use them for e.g. syncing between concurrent streams
|
||||
result.sabrPart = new MediaSegmentEndSabrPart(
|
||||
segment.initializedFormat.formatSelector,
|
||||
segment.formatId,
|
||||
segment.sequenceNumber,
|
||||
segment.isInitSegment,
|
||||
segment.initializedFormat.totalSegments
|
||||
);
|
||||
} else {
|
||||
Log.d(TAG, String.format("Discarding media for %s", segment.initializedFormat.formatId));
|
||||
}
|
||||
|
||||
if (segment.isInitSegment) {
|
||||
segment.initializedFormat.initSegment = segment;
|
||||
// Do not create a consumed range for init segments
|
||||
return result;
|
||||
}
|
||||
|
||||
if (segment.initializedFormat.currentSegment != null && isLive()) {
|
||||
Segment previousSegment = segment.initializedFormat.currentSegment;
|
||||
Log.d(TAG, String.format("Previous segment %s for format %s " +
|
||||
"estimated duration difference from this segment (%s): %sms",
|
||||
previousSegment.sequenceNumber, segment.formatId, segment.sequenceNumber,
|
||||
segment.startMs - (previousSegment.startMs + previousSegment.durationMs)));
|
||||
}
|
||||
|
||||
segment.initializedFormat.currentSegment = segment;
|
||||
|
||||
if (segment.consumed) {
|
||||
// Segment is already consumed, do not create a new consumed range. It was probably discarded.
|
||||
// This can be expected to happen in the case of video-only, where we discard the audio track (and mark it as entirely buffered)
|
||||
// We still want to create/update consumed range for discarded media IF it is not already consumed
|
||||
Log.d(TAG, String.format("%s} segment %s already consumed, not creating or updating consumed range (discard=%s)",
|
||||
segment.formatId, segment.sequenceNumber, segment.discard));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try to find a consumed range for this segment in sequence
|
||||
ConsumedRange consumedRange = findFirst(segment.initializedFormat.consumedRanges, cr -> cr.endSequenceNumber == segment.sequenceNumber - 1);
|
||||
if (consumedRange == null) {
|
||||
// Create a new consumed range starting from this segment
|
||||
segment.initializedFormat.consumedRanges.add(new ConsumedRange(
|
||||
segment.startMs,
|
||||
segment.durationMs,
|
||||
segment.sequenceNumber,
|
||||
segment.sequenceNumber
|
||||
));
|
||||
Log.d(TAG, String.format("Created new consumed range for %s %s",
|
||||
segment.initializedFormat.formatId, segment.initializedFormat.consumedRanges.get(segment.initializedFormat.consumedRanges.size() - 1)));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Update the existing consumed range to include this segment
|
||||
consumedRange.endSequenceNumber = segment.sequenceNumber;
|
||||
consumedRange.durationMs = (segment.startMs - consumedRange.startTimeMs) + segment.durationMs;
|
||||
|
||||
// TODO: Conduct a seek on consumed ranges
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ProcessStreamProtectionStatusResult processStreamProtectionStatus(StreamProtectionStatus streamProtectionStatus) {
|
||||
this.streamProtectionStatus = streamProtectionStatus.hasStatus() ? streamProtectionStatus.getStatus() : null;
|
||||
Status status = streamProtectionStatus.getStatus();
|
||||
String poToken = this.poToken;
|
||||
PoTokenStatus resultStatus = null;
|
||||
|
||||
if (status == StreamProtectionStatus.Status.OK) {
|
||||
resultStatus = poToken != null ? PoTokenStatusSabrPart.PoTokenStatus.OK : PoTokenStatusSabrPart.PoTokenStatus.NOT_REQUIRED;
|
||||
} else if (status == StreamProtectionStatus.Status.ATTESTATION_PENDING) {
|
||||
resultStatus = poToken != null ? PoTokenStatusSabrPart.PoTokenStatus.PENDING : PoTokenStatusSabrPart.PoTokenStatus.PENDING_MISSING;
|
||||
} else if (status == StreamProtectionStatus.Status.ATTESTATION_REQUIRED) {
|
||||
resultStatus = poToken != null ? PoTokenStatusSabrPart.PoTokenStatus.INVALID : PoTokenStatusSabrPart.PoTokenStatus.MISSING;
|
||||
} else {
|
||||
Log.w(TAG, String.format("Received an unknown StreamProtectionStatus: %s", streamProtectionStatus));
|
||||
}
|
||||
|
||||
ProcessStreamProtectionStatusResult result = new ProcessStreamProtectionStatusResult();
|
||||
|
||||
if (resultStatus != null) {
|
||||
result.sabrPart = new PoTokenStatusSabrPart(resultStatus);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ProcessFormatInitializationMetadataResult processFormatInitializationMetadata(FormatInitializationMetadata formatInitMetadata) {
|
||||
ProcessFormatInitializationMetadataResult result = new ProcessFormatInitializationMetadataResult();
|
||||
|
||||
if (formatInitMetadata.hasFormatId() && initializedFormats.containsKey(formatInitMetadata.getFormatId().toString())) {
|
||||
Log.d(TAG, String.format("Format %s already initialized", formatInitMetadata.getFormatId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (formatInitMetadata.hasVideoId() && videoId != null && !formatInitMetadata.getVideoId().equals(videoId)) {
|
||||
throw new SabrStreamError(String.format("Received unexpected Format Initialization Metadata for video" +
|
||||
" %s (expecting %s)", formatInitMetadata.getVideoId(), videoId));
|
||||
}
|
||||
|
||||
FormatSelector formatSelector = matchFormatSelector(formatInitMetadata);
|
||||
|
||||
if (formatSelector == null) {
|
||||
// Should not happen. If we ignored the format the server may refuse to send us any more data
|
||||
throw new SabrStreamError(String.format("Received format %s but it does not match any format selector", formatInitMetadata.getFormatId()));
|
||||
}
|
||||
|
||||
// Guard: Check if the format selector is already in use by another initialized format.
|
||||
// This can happen when the server changes the format to use (e.g. changing quality).
|
||||
//
|
||||
// Changing a format will require adding some logic to handle inactive formats.
|
||||
// Given we only provide one FormatId currently, and this should not occur in this case,
|
||||
// we will mark this as not currently supported and bail.
|
||||
for (InitializedFormat izf : initializedFormats.values()) {
|
||||
if (izf.formatSelector == formatSelector) {
|
||||
throw new SabrStreamError("Server changed format. Changing formats is not currently supported");
|
||||
}
|
||||
}
|
||||
|
||||
int durationMs = Utils.ticksToMs(
|
||||
formatInitMetadata.hasDurationTicks() ? formatInitMetadata.getDurationTicks() : -1,
|
||||
formatInitMetadata.hasDurationTimescale() ? formatInitMetadata.getDurationTimescale() : -1
|
||||
);
|
||||
|
||||
int totalSegments = formatInitMetadata.hasTotalSegments() ? formatInitMetadata.getTotalSegments() : -1;
|
||||
|
||||
if (totalSegments == -1 && liveMetadata != null && liveMetadata.hasHeadSequenceNumber()) {
|
||||
totalSegments = liveMetadata.getHeadSequenceNumber();
|
||||
}
|
||||
|
||||
InitializedFormat initializedFormat = new InitializedFormat(
|
||||
formatInitMetadata.hasFormatId() ? formatInitMetadata.getFormatId() : null,
|
||||
durationMs,
|
||||
formatInitMetadata.hasEndTimeMs() ? formatInitMetadata.getEndTimeMs() : -1,
|
||||
formatInitMetadata.hasMimeType() ? formatInitMetadata.getMimeType() : null,
|
||||
formatInitMetadata.hasVideoId() ? formatInitMetadata.getVideoId() : null,
|
||||
formatSelector,
|
||||
totalSegments,
|
||||
formatSelector.isDiscardMedia()
|
||||
);
|
||||
|
||||
totalDurationMs = Math.max(
|
||||
totalDurationMs != -1 ? totalDurationMs : 0,
|
||||
Math.max(formatInitMetadata.hasEndTimeMs() ? formatInitMetadata.getEndTimeMs() : 0, durationMs != -1 ? durationMs : 0)
|
||||
);
|
||||
|
||||
if (initializedFormat.discard) {
|
||||
// Mark the entire format as buffered into oblivion if we plan to discard all media.
|
||||
// This stops the server sending us any more data for this format.
|
||||
// Note: Using JS_MAX_SAFE_INTEGER but could use any maximum value as long as the server accepts it.
|
||||
initializedFormat.consumedRanges.clear();
|
||||
initializedFormat.consumedRanges.add(new ConsumedRange(
|
||||
0,
|
||||
((long) Math.pow(2, 53)) - 1,
|
||||
0,
|
||||
((long) Math.pow(2, 53)) - 1
|
||||
));
|
||||
}
|
||||
|
||||
if (formatInitMetadata.hasFormatId()) {
|
||||
initializedFormats.put(formatInitMetadata.getFormatId().toString(), initializedFormat);
|
||||
Log.d(TAG, String.format("Initialized Format: %s", initializedFormat));
|
||||
}
|
||||
|
||||
if (!initializedFormat.discard) {
|
||||
result.sabrPart = new FormatInitializedSabrPart(
|
||||
formatInitMetadata.hasFormatId() ? formatInitMetadata.getFormatId() : null,
|
||||
formatSelector
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void processNextRequestPolicy(NextRequestPolicy nextRequestPolicy) {
|
||||
this.nextRequestPolicy = nextRequestPolicy;
|
||||
Log.d(TAG, String.format("Registered new NextRequestPolicy: %s", nextRequestPolicy));
|
||||
}
|
||||
|
||||
public ProcessLiveMetadataResult processLiveMetadata(LiveMetadata liveMetadata) {
|
||||
this.liveMetadata = liveMetadata;
|
||||
|
||||
if (liveMetadata.hasHeadSequenceTimeMs()) {
|
||||
totalDurationMs = liveMetadata.getHeadSequenceTimeMs();
|
||||
}
|
||||
|
||||
// If we have a head sequence number, we need to update the total sequences for each initialized format
|
||||
// For livestreams, it is not available in the format initialization metadata
|
||||
if (liveMetadata.hasHeadSequenceNumber()) {
|
||||
for (InitializedFormat izf : initializedFormats.values()) {
|
||||
izf.totalSegments = liveMetadata.getHeadSequenceNumber();
|
||||
}
|
||||
}
|
||||
|
||||
ProcessLiveMetadataResult result = new ProcessLiveMetadataResult();
|
||||
|
||||
// If the current player time is less than the min dvr time, simulate a server seek to the min dvr time.
|
||||
// The server SHOULD send us a SABR_SEEK part in this case, but it does not always happen (e.g. ANDROID_VR)
|
||||
// The server SHOULD NOT send us segments before the min dvr time, so we should assume that the player time is correct.
|
||||
int minSeekableTimeMs = Utils.ticksToMs(liveMetadata.hasMinSeekableTimeTicks() ? liveMetadata.getMinSeekableTimeTicks() : -1,
|
||||
liveMetadata.hasMinSeekableTimescale() ? liveMetadata.getMinSeekableTimescale() : -1);
|
||||
if (minSeekableTimeMs != -1 && clientAbrState.hasPlayerTimeMs() && clientAbrState.getPlayerTimeMs() < minSeekableTimeMs) {
|
||||
Log.d(TAG, String.format("Player time %s is less than min seekable time %s, simulating server seek",
|
||||
clientAbrState.getPlayerTimeMs(), minSeekableTimeMs));
|
||||
clientAbrState = clientAbrState.toBuilder().setPlayerTimeMs(minSeekableTimeMs).build();
|
||||
for (InitializedFormat izf : initializedFormats.values()) {
|
||||
izf.currentSegment = null; // Clear the current segment as we expect segments to no longer be in order.
|
||||
result.seekSabrParts.add(
|
||||
new MediaSeekSabrPart(
|
||||
MediaSeekSabrPart.Reason.SERVER_SEEK,
|
||||
izf.formatId,
|
||||
izf.formatSelector
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ProcessSabrSeekResult processSabrSeek(SabrSeek sabrSeek) {
|
||||
int seekTo = Utils.ticksToMs(sabrSeek.hasSeekTimeTicks() ? sabrSeek.getSeekTimeTicks() : -1, sabrSeek.hasTimescale() ? sabrSeek.getTimescale() : -1);
|
||||
if (seekTo == -1) {
|
||||
throw new SabrStreamError(String.format("Server sent a SabrSeek part that is missing required seek data: %s", sabrSeek));
|
||||
}
|
||||
Log.d(TAG, String.format("Seeking to %sms", seekTo));
|
||||
clientAbrState = clientAbrState.toBuilder().setPlayerTimeMs(seekTo).build();
|
||||
|
||||
ProcessSabrSeekResult result = new ProcessSabrSeekResult();
|
||||
|
||||
// Clear latest segment of each initialized format
|
||||
// as we expect them to no longer be in order.
|
||||
for (InitializedFormat initializedFormat : initializedFormats.values()) {
|
||||
initializedFormat.currentSegment = null;
|
||||
result.seekSabrParts.add(
|
||||
new MediaSeekSabrPart(
|
||||
MediaSeekSabrPart.Reason.SERVER_SEEK,
|
||||
initializedFormat.formatId,
|
||||
initializedFormat.formatSelector
|
||||
)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void processSabrContextUpdate(SabrContextUpdate sabrCtxUpdate) {
|
||||
if (!sabrCtxUpdate.hasType() || !sabrCtxUpdate.hasValue() || !sabrCtxUpdate.hasWritePolicy()) {
|
||||
Log.w(TAG, "Received an invalid SabrContextUpdate, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sabrCtxUpdate.getWritePolicy() == SabrContextUpdate.SabrContextWritePolicy.SABR_CONTEXT_WRITE_POLICY_KEEP_EXISTING
|
||||
&& sabrContextUpdates.containsKey(sabrCtxUpdate.getType())) {
|
||||
Log.d(TAG, "Received a SABR Context Update with write_policy=KEEP_EXISTING" +
|
||||
" matching an existing SABR Context Update. Ignoring update");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.w(TAG, "Received a SABR Context Update. YouTube is likely trying to force ads on the client. " +
|
||||
"This may cause issues with playback.");
|
||||
|
||||
sabrContextUpdates.put(sabrCtxUpdate.getType(), sabrCtxUpdate);
|
||||
if (sabrCtxUpdate.hasSendByDefault()) {
|
||||
sabrContextsToSend.add(sabrCtxUpdate.getType());
|
||||
}
|
||||
Log.d(TAG, String.format("Registered SabrContextUpdate %s", sabrCtxUpdate));
|
||||
}
|
||||
|
||||
public void processSabrContextSendingPolicy(SabrContextSendingPolicy sabrCtxSendingPolicy) {
|
||||
for (int startType : sabrCtxSendingPolicy.getStartPolicyList()) {
|
||||
if (!sabrContextsToSend.contains(startType)) {
|
||||
Log.d(TAG, String.format("Server requested to enable SABR Context Update for type %s", startType));
|
||||
sabrContextsToSend.add(startType);
|
||||
}
|
||||
}
|
||||
|
||||
for (int stopType : sabrCtxSendingPolicy.getStopPolicyList()) {
|
||||
if (!sabrContextsToSend.contains(stopType)) {
|
||||
Log.d(TAG, String.format("Server requested to disable SABR Context Update for type %s", stopType));
|
||||
sabrContextsToSend.remove(stopType);
|
||||
}
|
||||
}
|
||||
|
||||
for (int discardType : sabrCtxSendingPolicy.getDiscardPolicyList()) {
|
||||
if (!sabrContextsToSend.contains(discardType)) {
|
||||
Log.d(TAG, String.format("Server requested to discard SABR Context Update for type %s", discardType));
|
||||
sabrContextUpdates.remove(discardType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLive() {
|
||||
return liveMetadata != null || isLive;
|
||||
}
|
||||
|
||||
public void setLive(boolean isLive) {
|
||||
this.isLive = isLive;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public ClientAbrState getClientAbrState() {
|
||||
return clientAbrState;
|
||||
}
|
||||
|
||||
public void setClientAbrState(@NonNull ClientAbrState state) {
|
||||
clientAbrState = state;
|
||||
}
|
||||
|
||||
public int getLiveSegmentTargetDurationToleranceMs() {
|
||||
return liveSegmentTargetDurationToleranceMs;
|
||||
}
|
||||
|
||||
public int getLiveSegmentTargetDurationSec() {
|
||||
return liveSegmentTargetDurationSec;
|
||||
}
|
||||
|
||||
private FormatSelector matchFormatSelector(FormatInitializationMetadata formatInitMetadata) {
|
||||
for (FormatSelector formatSelector : new FormatSelector[]{videoFormatSelector, audioFormatSelector, captionFormatSelector}) {
|
||||
if (formatSelector == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (formatSelector.match(formatInitMetadata.getFormatId(), formatInitMetadata.getMimeType())) {
|
||||
return formatSelector;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public class Utils {
|
||||
public static int ticksToMs(long timeTicks, int timescale) {
|
||||
if (timeTicks == -1 || timescale == -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return (int) Math.ceil(((double) timeTicks / timescale) * 1_000);
|
||||
}
|
||||
|
||||
public static byte[] readAllBytes(ByteArrayInputStream is) {
|
||||
int streamLength = is.available();
|
||||
byte[] result = new byte[streamLength];
|
||||
|
||||
is.read(result, 0, streamLength);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public static byte[] readExactBytes(ExtractorInput input, int length) throws IOException, InterruptedException {
|
||||
byte[] result = new byte[length];
|
||||
input.readFully(result, 0, length);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static long toLong(int value) {
|
||||
return Integer.toUnsignedLong(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.futo.platformplayer.sabr.parser.ump;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
@UnstableApi
|
||||
public class UMPDecoder {
|
||||
public UMPPart decode(@NonNull ExtractorInput extractorInput) {
|
||||
try {
|
||||
int partType = (int) readVarInt(extractorInput);
|
||||
if (partType == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int partSize = (int) readVarInt(extractorInput);
|
||||
if (partSize == -1) {
|
||||
throw new IllegalStateException("Unexpected EOF while reading part size");
|
||||
}
|
||||
|
||||
return new UMPPart(partType, partSize, extractorInput);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static int[] range(int start, int end, int step) {
|
||||
int size = (end - start) / step + 1;
|
||||
int[] result = new int[size];
|
||||
int value = start;
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
result[i] = value;
|
||||
value += step;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private long readVarInt(StreamWrapper input) throws IOException, InterruptedException {
|
||||
// https://web.archive.org/web/20250430054327/https://github.com/gsuberland/UMP_Format/blob/main/UMP_Format.md
|
||||
// https://web.archive.org/web/20250429151021/https://github.com/davidzeng0/innertube/blob/main/googlevideo/ump.md
|
||||
byte[] buffer = new byte[1];
|
||||
boolean success = input.readFully(buffer, 0, 1, true);
|
||||
if (!success) {
|
||||
// Expected EOF
|
||||
return -1;
|
||||
}
|
||||
|
||||
long byteInt = buffer[0] & 0xFF; // convert to unsigned (0..255)
|
||||
int size = varIntSize(byteInt);
|
||||
long result = 0;
|
||||
int shift = 0;
|
||||
|
||||
if (size != 5) {
|
||||
shift = 8 - size;
|
||||
int mask = (1 << shift) - 1;
|
||||
result |= byteInt & mask;
|
||||
}
|
||||
|
||||
for (int i : range(1, size, 1)) {
|
||||
success = input.readFully(buffer, 0, 1, true);
|
||||
if (!success) {
|
||||
return -1;
|
||||
}
|
||||
byteInt = buffer[0] & 0xFF; // convert to unsigned (0..255)
|
||||
result |= byteInt << shift;
|
||||
shift += 8;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public long readVarInt(ExtractorInput input) throws IOException, InterruptedException {
|
||||
return readVarInt(input::readFully);
|
||||
}
|
||||
|
||||
public long readVarInt(ByteArrayInputStream inputStream) throws IOException, InterruptedException {
|
||||
return readVarInt((target, offset, length, allowEndOfInput) -> {
|
||||
int numRead = inputStream.read(target, offset, length);
|
||||
return numRead != -1;
|
||||
});
|
||||
}
|
||||
|
||||
private int varIntSize(long byteInt) {
|
||||
return byteInt < 128 ? 1 : byteInt < 192 ? 2 : byteInt < 224 ? 3 : byteInt < 240 ? 4 : 5;
|
||||
}
|
||||
|
||||
private interface StreamWrapper {
|
||||
boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput)
|
||||
throws IOException, InterruptedException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.ump;
|
||||
|
||||
public class UMPEncoder {
|
||||
public void encode(UMPPart part) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.futo.platformplayer.sabr.parser.ump;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
@UnstableApi
|
||||
public class UMPInputStream extends InputStream {
|
||||
private final UMPPart part;
|
||||
private int position = 0; // bytes read so far
|
||||
|
||||
public UMPInputStream(UMPPart part) {
|
||||
this.part = part;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if (position >= part.size) return -1;
|
||||
|
||||
byte[] buffer = new byte[1];
|
||||
int read;
|
||||
read = part.data.read(buffer, 0, 1);
|
||||
|
||||
if (read == C.RESULT_END_OF_INPUT) return -1;
|
||||
position += read;
|
||||
return buffer[0] & 0xFF;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (position >= part.size) return -1;
|
||||
|
||||
int toRead = Math.min(len, part.size - position);
|
||||
int read;
|
||||
read = part.data.read(b, off, toRead);
|
||||
|
||||
if (read == C.RESULT_END_OF_INPUT) return -1;
|
||||
position += read;
|
||||
return read;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(long n) throws IOException {
|
||||
int toSkip = (int) Math.min(n, part.size - position);
|
||||
int skipped;
|
||||
skipped = part.data.skip(toSkip);
|
||||
position += skipped;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() {
|
||||
return part.size - position;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.futo.platformplayer.sabr.parser.ump;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
|
||||
@UnstableApi
|
||||
public class UMPPart {
|
||||
public final int partId;
|
||||
public final int size;
|
||||
public final ExtractorInput data;
|
||||
|
||||
public UMPPart(int partId, int size, ExtractorInput data) {
|
||||
this.partId = partId;
|
||||
this.size = size;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public UMPInputStream toStream() {
|
||||
return new UMPInputStream(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer.sabr.parser.ump;
|
||||
|
||||
public class UMPPartId {
|
||||
public static final int UNKNOWN = -1;
|
||||
public static final int ONESIE_HEADER = 10;
|
||||
public static final int ONESIE_DATA = 11;
|
||||
public static final int ONESIE_ENCRYPTED_MEDIA = 12;
|
||||
public static final int MEDIA_HEADER = 20;
|
||||
public static final int MEDIA = 21;
|
||||
public static final int MEDIA_END = 22;
|
||||
public static final int LIVE_METADATA = 31;
|
||||
public static final int HOSTNAME_CHANGE_HINT = 32;
|
||||
public static final int LIVE_METADATA_PROMISE = 33;
|
||||
public static final int LIVE_METADATA_PROMISE_CANCELLATION = 34;
|
||||
public static final int NEXT_REQUEST_POLICY = 35;
|
||||
public static final int USTREAMER_VIDEO_AND_FORMAT_DATA = 36;
|
||||
public static final int FORMAT_SELECTION_CONFIG = 37;
|
||||
public static final int USTREAMER_SELECTED_MEDIA_STREAM = 38;
|
||||
public static final int FORMAT_INITIALIZATION_METADATA = 42;
|
||||
public static final int SABR_REDIRECT = 43;
|
||||
public static final int SABR_ERROR = 44;
|
||||
public static final int SABR_SEEK = 45;
|
||||
public static final int RELOAD_PLAYER_RESPONSE = 46;
|
||||
public static final int PLAYBACK_START_POLICY = 47;
|
||||
public static final int ALLOWED_CACHED_FORMATS = 48;
|
||||
public static final int START_BW_SAMPLING_HINT = 49;
|
||||
public static final int PAUSE_BW_SAMPLING_HINT = 50;
|
||||
public static final int SELECTABLE_FORMATS = 51;
|
||||
public static final int REQUEST_IDENTIFIER = 52;
|
||||
public static final int REQUEST_CANCELLATION_POLICY = 53;
|
||||
public static final int ONESIE_PREFETCH_REJECTION = 54;
|
||||
public static final int TIMELINE_CONTEXT = 55;
|
||||
public static final int REQUEST_PIPELINING = 56;
|
||||
public static final int SABR_CONTEXT_UPDATE = 57;
|
||||
public static final int STREAM_PROTECTION_STATUS = 58;
|
||||
public static final int SABR_CONTEXT_SENDING_POLICY = 59;
|
||||
public static final int LAWNMOWER_POLICY = 60;
|
||||
public static final int SABR_ACK = 61;
|
||||
public static final int END_OF_TRACK = 62;
|
||||
public static final int CACHE_LOAD_POLICY = 63;
|
||||
public static final int LAWNMOWER_MESSAGING_POLICY = 64;
|
||||
public static final int PREWARM_CONNECTION = 65;
|
||||
public static final int PLAYBACK_DEBUG_INFO = 66;
|
||||
public static final int SNACKBAR_MESSAGE = 67;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "sabr/videostreaming/format_id.proto";
|
||||
import "sabr/videostreaming/time_range.proto";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message BufferedRange {
|
||||
optional FormatId format_id = 1;
|
||||
optional int64 start_time_ms = 2;
|
||||
optional int64 duration_ms = 3;
|
||||
optional int32 start_segment_index = 4;
|
||||
optional int32 end_segment_index = 5;
|
||||
optional TimeRange time_range = 6;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message ClientAbrState {
|
||||
optional int64 player_time_ms = 28;
|
||||
optional int32 enabled_track_types_bitfield = 40;
|
||||
bool drc_enabled = 46;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message ClientInfo {
|
||||
optional string hl = 1;
|
||||
optional string gl = 2;
|
||||
optional string remote_host = 4;
|
||||
|
||||
optional string device_make = 12;
|
||||
optional string device_model = 13;
|
||||
optional string visitor_data = 14;
|
||||
optional string user_agent = 15;
|
||||
optional ClientName client_name = 16;
|
||||
optional string client_version = 17;
|
||||
optional string os_name = 18;
|
||||
optional string os_version = 19;
|
||||
}
|
||||
|
||||
enum ClientName {
|
||||
UNKNOWN_INTERFACE = 0;
|
||||
WEB = 1;
|
||||
MWEB = 2;
|
||||
ANDROID = 3;
|
||||
IOS = 5;
|
||||
TVHTML5 = 7;
|
||||
TVLITE = 8;
|
||||
TVANDROID = 10;
|
||||
XBOX = 11;
|
||||
CLIENTX = 12;
|
||||
XBOXONEGUIDE = 13;
|
||||
ANDROID_CREATOR = 14;
|
||||
IOS_CREATOR = 15;
|
||||
TVAPPLE = 16;
|
||||
IOS_INSTANT = 17;
|
||||
ANDROID_KIDS = 18;
|
||||
IOS_KIDS = 19;
|
||||
ANDROID_INSTANT = 20;
|
||||
ANDROID_MUSIC = 21;
|
||||
IOS_TABLOID = 22;
|
||||
ANDROID_TV = 23;
|
||||
ANDROID_GAMING = 24;
|
||||
IOS_GAMING = 25;
|
||||
IOS_MUSIC = 26;
|
||||
MWEB_TIER_2 = 27;
|
||||
ANDROID_VR = 28;
|
||||
ANDROID_UNPLUGGED = 29;
|
||||
ANDROID_TESTSUITE = 30;
|
||||
WEB_MUSIC_ANALYTICS = 31;
|
||||
WEB_GAMING = 32;
|
||||
IOS_UNPLUGGED = 33;
|
||||
ANDROID_WITNESS = 34;
|
||||
IOS_WITNESS = 35;
|
||||
ANDROID_SPORTS = 36;
|
||||
IOS_SPORTS = 37;
|
||||
ANDROID_LITE = 38;
|
||||
IOS_EMBEDDED_PLAYER = 39;
|
||||
IOS_DIRECTOR = 40;
|
||||
WEB_UNPLUGGED = 41;
|
||||
WEB_EXPERIMENTS = 42;
|
||||
TVHTML5_CAST = 43;
|
||||
WEB_EMBEDDED_PLAYER = 56;
|
||||
TVHTML5_AUDIO = 57;
|
||||
TV_UNPLUGGED_CAST = 58;
|
||||
TVHTML5_KIDS = 59;
|
||||
WEB_HEROES = 60;
|
||||
WEB_MUSIC = 61;
|
||||
WEB_CREATOR = 62;
|
||||
TV_UNPLUGGED_ANDROID = 63;
|
||||
IOS_LIVE_CREATION_EXTENSION = 64;
|
||||
TVHTML5_UNPLUGGED = 65;
|
||||
IOS_MESSAGES_EXTENSION = 66;
|
||||
WEB_REMIX = 67;
|
||||
IOS_UPTIME = 68;
|
||||
WEB_UNPLUGGED_ONBOARDING = 69;
|
||||
WEB_UNPLUGGED_OPS = 70;
|
||||
WEB_UNPLUGGED_PUBLIC = 71;
|
||||
TVHTML5_VR = 72;
|
||||
WEB_LIVE_STREAMING = 73;
|
||||
ANDROID_TV_KIDS = 74;
|
||||
TVHTML5_SIMPLY = 75;
|
||||
WEB_KIDS = 76;
|
||||
MUSIC_INTEGRATIONS = 77;
|
||||
TVHTML5_YONGLE = 80;
|
||||
GOOGLE_ASSISTANT = 84;
|
||||
TVHTML5_SIMPLY_EMBEDDED_PLAYER = 85;
|
||||
WEB_MUSIC_EMBEDDED_PLAYER = 86;
|
||||
WEB_INTERNAL_ANALYTICS = 87;
|
||||
WEB_PARENT_TOOLS = 88;
|
||||
GOOGLE_MEDIA_ACTIONS = 89;
|
||||
WEB_PHONE_VERIFICATION = 90;
|
||||
ANDROID_PRODUCER = 91;
|
||||
IOS_PRODUCER = 92;
|
||||
TVHTML5_FOR_KIDS = 93;
|
||||
GOOGLE_LIST_RECS = 94;
|
||||
MEDIA_CONNECT_FRONTEND = 95;
|
||||
WEB_EFFECT_MAKER = 98;
|
||||
WEB_SHOPPING_EXTENSION = 99;
|
||||
WEB_PLAYABLES_PORTAL = 100;
|
||||
VISIONOS = 101;
|
||||
WEB_LIVE_APPS = 102;
|
||||
WEB_MUSIC_INTEGRATIONS = 103;
|
||||
ANDROID_MUSIC_AOSP = 104;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
enum CompressionAlgorithm {
|
||||
COMPRESSION_ALGORITHM_UNKNOWN = 0;
|
||||
COMPRESSION_ALGORITHM_NONE = 1;
|
||||
COMPRESSION_ALGORITHM_GZIP = 2;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message FormatId {
|
||||
optional int32 itag = 1;
|
||||
optional uint64 lmt = 2;
|
||||
optional string xtags = 3;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
import "sabr/videostreaming/format_id.proto";
|
||||
import "sabr/videostreaming/range.proto";
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message FormatInitializationMetadata {
|
||||
optional string video_id = 1;
|
||||
optional FormatId format_id = 2;
|
||||
optional int32 end_time_ms = 3;
|
||||
optional int32 total_segments = 4;
|
||||
optional string mime_type = 5;
|
||||
optional Range init_range = 6;
|
||||
optional Range index_range = 7;
|
||||
optional int32 duration_ticks = 9;
|
||||
optional int32 duration_timescale = 10;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message LiveMetadata {
|
||||
optional int32 head_sequence_number = 3;
|
||||
optional int64 head_sequence_time_ms = 4;
|
||||
optional int64 wall_time_ms = 5;
|
||||
optional string video_id = 6;
|
||||
optional string source = 7;
|
||||
|
||||
optional int64 min_seekable_time_ticks = 12;
|
||||
optional int32 min_seekable_timescale = 13;
|
||||
optional int64 max_seekable_time_ticks = 14;
|
||||
optional int32 max_seekable_timescale = 15;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "sabr/videostreaming/compression_algorithm.proto";
|
||||
import "sabr/videostreaming/format_id.proto";
|
||||
import "sabr/videostreaming/time_range.proto";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message MediaHeader {
|
||||
optional uint32 header_id = 1;
|
||||
optional string video_id = 2;
|
||||
optional int32 itag = 3;
|
||||
optional uint64 last_modified = 4;
|
||||
optional string xtags = 5;
|
||||
optional int32 start_data_range = 6;
|
||||
optional CompressionAlgorithm compression = 7;
|
||||
optional bool is_init_segment = 8;
|
||||
optional int64 sequence_number = 9;
|
||||
optional int64 bitrate_bps = 10;
|
||||
optional int32 start_ms = 11;
|
||||
optional int32 duration_ms = 12;
|
||||
optional FormatId format_id = 13;
|
||||
optional int64 content_length = 14;
|
||||
optional TimeRange time_range = 15;
|
||||
optional int32 sequence_lmt = 16;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message NextRequestPolicy {
|
||||
optional int32 target_audio_readahead_ms = 1;
|
||||
optional int32 target_video_readahead_ms = 2;
|
||||
optional int32 max_time_since_last_request_ms = 3;
|
||||
optional int32 backoff_time_ms = 4;
|
||||
optional int32 min_audio_readahead_ms = 5;
|
||||
optional int32 min_video_readahead_ms = 6;
|
||||
optional bytes playback_cookie = 7;
|
||||
optional string video_id = 8;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message Range {
|
||||
optional int64 start = 1;
|
||||
optional int64 end = 2;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message ReloadPlaybackParams {
|
||||
optional string token = 1;
|
||||
}
|
||||
|
||||
message ReloadPlayerResponse {
|
||||
optional ReloadPlaybackParams reload_playback_params = 1;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message SabrContextSendingPolicy {
|
||||
// Start sending the SabrContextUpdates of this type
|
||||
repeated int32 start_policy = 1;
|
||||
|
||||
// Stop sending the SabrContextUpdates of this type
|
||||
repeated int32 stop_policy = 2;
|
||||
|
||||
// Stop and discard the SabrContextUpdates of this type
|
||||
repeated int32 discard_policy = 3;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message SabrContextUpdate {
|
||||
enum SabrContextScope {
|
||||
SABR_CONTEXT_SCOPE_UNKNOWN = 0;
|
||||
SABR_CONTEXT_SCOPE_PLAYBACK = 1;
|
||||
SABR_CONTEXT_SCOPE_REQUEST = 2;
|
||||
SABR_CONTEXT_SCOPE_WATCH_ENDPOINT = 3;
|
||||
SABR_CONTEXT_SCOPE_CONTENT_ADS = 4;
|
||||
}
|
||||
|
||||
enum SabrContextWritePolicy {
|
||||
SABR_CONTEXT_WRITE_POLICY_UNSPECIFIED = 0;
|
||||
SABR_CONTEXT_WRITE_POLICY_OVERWRITE = 1;
|
||||
SABR_CONTEXT_WRITE_POLICY_KEEP_EXISTING = 2;
|
||||
}
|
||||
|
||||
optional int32 type = 1;
|
||||
optional SabrContextScope scope = 2;
|
||||
optional bytes value = 3;
|
||||
optional bool send_by_default = 4;
|
||||
optional SabrContextWritePolicy write_policy = 5;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message Error {
|
||||
optional int32 status_code = 1;
|
||||
optional int32 type = 4;
|
||||
}
|
||||
|
||||
message SabrError {
|
||||
optional string type = 1;
|
||||
optional int32 action = 2;
|
||||
optional Error error = 3;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message SabrRedirect {
|
||||
optional string redirect_url = 1;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message SabrSeek {
|
||||
optional int32 seek_time_ticks = 1;
|
||||
optional int32 timescale = 2;
|
||||
optional SeekSource seek_source = 3;
|
||||
}
|
||||
|
||||
enum SeekSource {
|
||||
SEEK_SOURCE_UNKNOWN = 0;
|
||||
SEEK_SOURCE_SABR_PARTIAL_CHUNK = 9;
|
||||
SEEK_SOURCE_SABR_SEEK_TO_HEAD = 10;
|
||||
SEEK_SOURCE_SABR_LIVE_DVR_USER_SEEK = 11;
|
||||
SEEK_SOURCE_SABR_SEEK_TO_DVR_LOWER_BOUND = 12;
|
||||
SEEK_SOURCE_SABR_SEEK_TO_DVR_UPPER_BOUND = 13;
|
||||
SEEK_SOURCE_SABR_ACCURATE_SEEK = 17;
|
||||
SEEK_SOURCE_SABR_INGESTION_WALL_TIME_SEEK = 29;
|
||||
SEEK_SOURCE_SABR_SEEK_TO_CLOSEST_KEYFRAME = 59;
|
||||
SEEK_SOURCE_SABR_RELOAD_PLAYER_RESPONSE_TOKEN_SEEK = 106;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message StreamProtectionStatus {
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
OK = 1;
|
||||
ATTESTATION_PENDING = 2;
|
||||
ATTESTATION_REQUIRED = 3;
|
||||
}
|
||||
optional Status status = 1;
|
||||
optional int32 max_retries = 2;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "sabr/videostreaming/client_info.proto";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message SabrContext {
|
||||
// Type and Value from a SabrContextUpdate
|
||||
optional int32 type = 1;
|
||||
optional bytes value = 2;
|
||||
}
|
||||
|
||||
message StreamerContext {
|
||||
optional ClientInfo client_info = 1;
|
||||
optional bytes po_token = 2;
|
||||
optional bytes playback_cookie = 3;
|
||||
repeated SabrContext sabr_contexts = 5;
|
||||
repeated int32 unsent_sabr_contexts = 6;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message TimeRange {
|
||||
optional int64 start_ticks = 1;
|
||||
optional int64 duration_ticks = 2;
|
||||
optional int32 timescale = 3;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "sabr/videostreaming/client_abr_state.proto";
|
||||
import "sabr/videostreaming/format_id.proto";
|
||||
import "sabr/videostreaming/buffered_range.proto";
|
||||
import "sabr/videostreaming/streamer_context.proto";
|
||||
|
||||
package sabr.videostreaming;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.futo.platformplayer.sabr.protos.videostreaming";
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
message VideoPlaybackAbrRequest {
|
||||
ClientAbrState client_abr_state = 1;
|
||||
repeated FormatId initialized_format_ids = 2;
|
||||
repeated BufferedRange buffered_ranges = 3;
|
||||
optional int64 player_time_ms = 4;
|
||||
optional bytes video_playback_ustreamer_config = 5;
|
||||
|
||||
repeated FormatId selected_audio_format_ids = 16;
|
||||
repeated FormatId selected_video_format_ids = 17;
|
||||
repeated FormatId selected_caption_format_ids = 18;
|
||||
StreamerContext streamer_context = 19;
|
||||
}
|
||||
Reference in New Issue
Block a user