This commit is contained in:
Koen J
2025-11-03 11:01:52 +01:00
parent 7d19c2357c
commit 3b359ad4a7
81 changed files with 10389 additions and 0 deletions
@@ -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; well 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&amp;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 "&amp;" 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>&amp;</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 '&amp;' 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( '&', '&amp;' ))
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);
}
}
}
@@ -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);
}
}
@@ -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";
}
}
@@ -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
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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
}
}
@@ -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 {
}
@@ -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;
}
@@ -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<>();
}
@@ -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
}
@@ -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;
}
@@ -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;
}
@@ -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<>();
}
@@ -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;
}