diff --git a/app/src/main/java/com/futo/platformplayer/sabr/CombinedQueryString.java b/app/src/main/java/com/futo/platformplayer/sabr/CombinedQueryString.java new file mode 100644 index 00000000..0c46ee56 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/CombinedQueryString.java @@ -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 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(); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/DefaultSabrChunkSource.java b/app/src/main/java/com/futo/platformplayer/sabr/DefaultSabrChunkSource.java new file mode 100644 index 00000000..b7097260 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/DefaultSabrChunkSource.java @@ -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 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 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 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 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 queue) { + if (fatalError != null || trackSelection.length() < 2) { + return queue.size(); + } + return trackSelection.evaluateQueueSize(playbackPositionUs, queue); + } + + @Override + public void getNextChunk(LoadingInfo loadingInfo, long loadPositionUs, List queue, ChunkHolder out) { + //public void getNextChunk(long playbackPositionUs, long loadPositionUs, List 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 queue) { + if (fatalError != null || trackSelection.length() < 2) { + return false; + } + // Let the selection decide (Media3 exposes this). + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, (List) queue); + } + + @Override + public void onChunkLoadCompleted(Chunk chunk) { + // If the init chunk just finished, try to grab a parsed ChunkIndex from the extractor. + if (chunk instanceof InitializationChunk) { + final int trackIndex = trackSelection.indexOf(chunk.trackFormat); + if (trackIndex != C.INDEX_UNSET) { + RepresentationHolder holder = representationHolders[trackIndex]; + + // Don't overwrite a manifest-defined index. Only adopt stream-provided index if needed. + if (holder.segmentIndex == null && holder.extractorWrapper != null) { + // Media3 exposes the parsed index via ChunkExtractor.getChunkIndex() now. + ChunkIndex chunkIndex = holder.extractorWrapper.getChunkIndex(); + if (chunkIndex != null) { + representationHolders[trackIndex] = + holder.copyWithNewSegmentIndex( + new SabrWrappingSegmentIndex( + chunkIndex, + holder.representation.presentationTimeOffsetUs)); + } + } + } + } + + if (playerTrackEmsgHandler != null) { + playerTrackEmsgHandler.onChunkLoadCompleted(chunk); + } + } + + @Override + public boolean onChunkLoadError( + Chunk chunk, + boolean cancelable, + androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo, + androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + if (!cancelable) return false; + + // Manifest-driven refresh (same behavior you had before). + if (playerTrackEmsgHandler != null && playerTrackEmsgHandler.maybeRefreshManifestOnLoadingError(chunk)) { + return true; // cancel & re-resolve next chunk + } + + // Workaround for a missing last segment on VOD (404) — unchanged logic, updated signature. + if (!manifest.dynamic + && chunk instanceof MediaChunk + && loadErrorInfo.exception instanceof InvalidResponseCodeException + && ((InvalidResponseCodeException) loadErrorInfo.exception).responseCode == 404) { + RepresentationHolder holder = representationHolders[trackSelection.indexOf(chunk.trackFormat)]; + int count = holder.getSegmentCount(); + if (count != SabrSegmentIndex.INDEX_UNBOUNDED && count != 0) { + long lastAvailable = holder.getFirstSegmentNum() + count - 1; + if (((MediaChunk) chunk).getNextChunkIndex() > lastAvailable) { + missingLastSegment = true; + return true; // cancel; we’ll end the period gracefully + } + } + } + + // Modern fallback track exclusion using LoadErrorHandlingPolicy + int excluded = 0; + long nowMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < trackSelection.length(); i++) { + if (trackSelection.isTrackExcluded(i, nowMs)) excluded++; + } + androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions options = + new androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions( + /* numberOfLocations= */ 1, /* numberOfExcludedLocations= */ 0, + /* numberOfTracks= */ trackSelection.length(), /* numberOfExcludedTracks= */ excluded); + + androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackSelection sel = + loadErrorHandlingPolicy.getFallbackSelectionFor(options, loadErrorInfo); + + if (sel != null + && sel.type == androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) { + int trackIdx = trackSelection.indexOf(chunk.trackFormat); + return trackSelection.excludeTrack(trackIdx, sel.exclusionDurationMs); + } + + return false; + } + + private ArrayList getRepresentations() { + List manifestAdaptationSets = manifest.getPeriod(periodIndex).adaptationSets; + ArrayList 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 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 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); + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/EventSampleStream.java b/app/src/main/java/com/futo/platformplayer/sabr/EventSampleStream.java new file mode 100644 index 00000000..2cbbccd0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/EventSampleStream.java @@ -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; + } + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/NullQueryString.java b/app/src/main/java/com/futo/platformplayer/sabr/NullQueryString.java new file mode 100644 index 00000000..4b10ff61 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/NullQueryString.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/PathQueryString.java b/app/src/main/java/com/futo/platformplayer/sabr/PathQueryString.java new file mode 100644 index 00000000..fc298b2c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/PathQueryString.java @@ -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
+ * Should contain at least one key/value pair: http://myurl.com/key/value/
+ * 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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/PlayerEmsgHandler.java b/app/src/main/java/com/futo/platformplayer/sabr/PlayerEmsgHandler.java new file mode 100644 index 00000000..7ef28b94 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/PlayerEmsgHandler.java @@ -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 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 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 ceilingExpiryEntryForPublishTime(long publishTimeMs) { + return manifestPublishTimeToExpiryTimeUs.ceilingEntry(publishTimeMs); + } + + private void removePreviouslyExpiredManifestPublishTimeValues() { + for (Iterator> it = + manifestPublishTimeToExpiryTimeUs.entrySet().iterator(); + it.hasNext(); ) { + Map.Entry 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; + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/SabrChunkSource.java b/app/src/main/java/com/futo/platformplayer/sabr/SabrChunkSource.java new file mode 100644 index 00000000..bdb9b6e3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/SabrChunkSource.java @@ -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 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); +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/SabrMediaPeriod.java b/app/src/main/java/com/futo/platformplayer/sabr/SabrMediaPeriod.java new file mode 100644 index 00000000..8caa94dd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/SabrMediaPeriod.java @@ -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.ReleaseCallback { + /* 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, PlayerTrackEmsgHandler> + trackEmsgHandlerBySampleStream; + + private @Nullable Callback callback; + private ChunkSampleStream[] sampleStreams; + private SequenceableLoader compositeSequenceableLoader; + private EventSampleStream[] eventSampleStreams; + private SabrManifest manifest; + private int periodIndex; + private List 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 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> sampleStreamList = new ArrayList<>(); + ArrayList eventSampleStreamList = new ArrayList<>(); + for (SampleStream sampleStream : streams) { + if (sampleStream instanceof ChunkSampleStream) { + @SuppressWarnings("unchecked") + ChunkSampleStream stream = + (ChunkSampleStream) 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 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 sampleStream : sampleStreams) { + sampleStream.seekToUs(positionUs); + } + for (EventSampleStream sampleStream : eventSampleStreams) { + sampleStream.seekToUs(positionUs); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + for (ChunkSampleStream 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 source) { + callback.onContinueLoadingRequested(this); + } + + @Override + public synchronized void onSampleStreamReleased(ChunkSampleStream 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 sampleStream : sampleStreams) { + sampleStream.getChunkSource().updateManifest(manifest, periodIndex); + } + callback.onContinueLoadingRequested(this); + } + } + + public void release() { + playerEmsgHandler.release(); + for (ChunkSampleStream sampleStream : sampleStreams) { + sampleStream.release(this); + } + callback = null; + eventDispatcher.mediaPeriodReleased(); + } + + @SuppressWarnings("unchecked") + private static ChunkSampleStream[] newSampleStreamArray(int length) { + return new ChunkSampleStream[length]; + } + + private static Pair buildTrackGroups( + List 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 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 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 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 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 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 stream = + (ChunkSampleStream) 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 stream = (ChunkSampleStream) 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 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 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 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; + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/SabrMediaSource.java b/app/src/main/java/com/futo/platformplayer/sabr/SabrMediaSource.java new file mode 100644 index 00000000..77ddc4ba --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/SabrMediaSource.java @@ -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 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()}. + * + *

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. + * + *

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; + } + + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/SabrSegmentIndex.java b/app/src/main/java/com/futo/platformplayer/sabr/SabrSegmentIndex.java new file mode 100644 index 00000000..5152a38d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/SabrSegmentIndex.java @@ -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}. + *

+ * 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. + *

+ * 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. + *

+ * 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(); + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/SabrWrappingSegmentIndex.java b/app/src/main/java/com/futo/platformplayer/sabr/SabrWrappingSegmentIndex.java new file mode 100644 index 00000000..b9a1f1ab --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/SabrWrappingSegmentIndex.java @@ -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; + } + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/UriUtil.java b/app/src/main/java/com/futo/platformplayer/sabr/UriUtil.java new file mode 100644 index 00000000..00f7e908 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/UriUtil.java @@ -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)}. + * + *

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)}. + * + *

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)}. + * + *

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)}. + * + *

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}. + * + *

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 basePathSegments = baseUri.getPathSegments(); + List 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(); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/UrlEncodedQueryString.java b/app/src/main/java/com/futo/platformplayer/sabr/UrlEncodedQueryString.java new file mode 100644 index 00000000..697fc43f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/UrlEncodedQueryString.java @@ -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); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/UrlEncodedQueryStringBase.java b/app/src/main/java/com/futo/platformplayer/sabr/UrlEncodedQueryStringBase.java new file mode 100644 index 00000000..e5de33ec --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/UrlEncodedQueryStringBase.java @@ -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. + *

+ * An instance of this class represents a query string encoded using the + * www-form-urlencoded encoding scheme, as defined by HTML 4.01 Specification: + * application/x-www-form-urlencoded, and HTML 4.01 + * Specification: Ampersands in URI attribute values. This is a common encoding scheme of the + * query component of a URI, though the RFC 2396 URI + * specification itself does not define a specific format for the query component. + *

+ * This class provides static methods for creating UrlEncodedQueryString + * instances by parsing 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. + *

+ *

Encoding and decoding

UrlEncodedQueryString automatically encodes and decodes parameter + * names and values to and from www-form-urlencoded encoding by using + * java.net.URLEncoder and java.net.URLDecoder, which follow the HTML 4.01 Specification: + * Non-ASCII characters in URI attribute values recommendation. + *

Multivalued parameters

Often, parameter names are unique across the name/value pairs of + * a www-form-urlencoded 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)? + *

+ * This requirement significantly shapes the UrlEncodedQueryString API. In particular + * there are: + *

    + *
  • set methods for setting a parameter, potentially replacing an existing value + *
  • append methods for adding a parameter, potentially creating a multivalued + * parameter + *
  • get methods for returning a single value, even if the parameter has multiple + * values + *
  • getValues methods for returning multiple values + *
+ *

Retrieving parameters

UrlEncodedQueryString can be used to parse and retrieve parameters + * from a query string by passing either a URI or a query string: + *

+ * + * URI uri = new URI("http://java.sun.com?forum=2");
+ * UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);
+ * System.out.println(queryString.get("forum"));
+ *
+ *

Modifying parameters

UrlEncodedQueryString can be used to set, append or remove + * parameters from a query string: + *

+ * + * URI uri = new URI("/forum/article.jsp?id=2&para=4");
+ * UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);
+ * queryString.set("id", 3);
+ * queryString.remove("para");
+ * System.out.println(queryString);
+ *
+ *

+ * When modifying parameters, the ordering of existing parameters is maintained. Parameters are + * set and removed in-place, while appended parameters are + * added to the end of the query string. + *

Applying the Query

UrlEncodedQueryString can be used to compareAndApply a modified query string + * back to a URI, creating a new URI: + *

+ * + * URI uri = new URI("/forum/article.jsp?id=2");
+ * UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);
+ * queryString.set("id", 3);
+ * uri = queryString.compareAndApply(uri);
+ *
+ *

+ * When reconstructing query strings, there are two valid separator parameters defined by the W3C + * (ampersand "&" and semicolon ";"), with ampersand being the most common. The + * compareAndApply and toString methods both default to using an ampersand, with + * overloaded forms for using a semicolon. + *

Thread Safety

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. + *

+ * Recommended separators are defined by HTML 4.01 + * Specification: application/x-www-form-urlencoded and HTML 4.01 Specification: + * Ampersands in URI attribute values. + *

+ * All separators are recognised when parsing query strings. One separator may + * be passed to toString and compareAndApply when outputting query strings. + */ + + public static enum Separator { + /** + * An ampersand & - the separator recommended by HTML 4.01 + * Specification: application/x-www-form-urlencoded. + */ + + AMPERSAND { + + /** + * Returns a String representation of this Separator. + *

+ * The String representation matches that defined by the HTML 4.01 + * Specification: application/x-www-form-urlencoded. + */ + + @Override + public String toString() { + + return "&"; + } + }, + + /** + * A semicolon ; - the separator recommended by HTML 4.01 Specification: + * Ampersands in URI attribute values. + */ + + SEMICOLON { + + /** + * Returns a String representation of this Separator. + *

+ * The String representation matches that defined by the HTML 4.01 + * Specification: Ampersands in URI attribute values. + */ + + @Override + public String toString() { + + return ";"; + } + }; + } + + /** + * Creates an empty UrlEncodedQueryString. + *

+ * Calling toString() on the created instance will return an empty String. + */ + + public static UrlEncodedQueryStringBase create() { + + return new UrlEncodedQueryStringBase(); + } + + /** + * Creates a UrlEncodedQueryString from the given Map. + *

+ * The order the parameters are created in corresponds to the iteration order of the Map. + * + * @param parameterMap + * Map containing parameter names and values. + */ + + public static UrlEncodedQueryStringBase create(Map> parameterMap ) { + + UrlEncodedQueryStringBase queryString = new UrlEncodedQueryStringBase(); + + // Defensively copy the List's + + for ( Map.Entry> entry : parameterMap.entrySet() ) { + queryString.queryMap.put( entry.getKey(), new ArrayList( entry.getValue() ) ); + } + + return queryString; + } + + /** + * Creates a UrlEncodedQueryString by parsing the given query string. + *

+ * This method assumes the given string is the www-form-urlencoded query component + * of a URI. When parsing, all Separators are + * recognised. + *

+ * The result of calling this method with a string that is not www-form-urlencoded + * (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. + *

+ * This method assumes the query component is www-form-urlencoded. When parsing, + * all separators from the Separators enum are recognised. + *

+ * The result of calling this method with a query component that is not + * www-form-urlencoded 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. + *

+ * All Separators are recognized when parsing parameters, regardless of what the user + * later nominates as their toString 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> queryMap = new LinkedHashMap>(); + + // + // Public methods + // + + /** + * Returns the value of the named parameter as a String. Returns null if the + * parameter does not exist, or exists but has a null value (see {@link #contains + * contains}). + *

+ * 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 getValues. + *

+ * If you use this method with a multivalued parameter, the value returned is equal to the first + * value in the List returned by getValues. + * + * @param name + * String specifying the name of the parameter + * @return String representing the single value of the parameter, or + * null if the parameter does not exist or exists but with a null value + * (see {@link #contains contains}). + */ + + public String get( final String name ) { + + List parameters = getValues( name ); + + if ( parameters == null || parameters.isEmpty() ) { + return null; + } + + return parameters.get( 0 ); + } + + /** + * Returns whether the named parameter exists. + *

+ * This can be useful to distinguish between a parameter not existing, and a parameter existing + * but with a null value (eg. foo=1&bar). This is distinct from a + * parameter existing with a value of the empty String (eg. foo=1&bar=). + */ + + public boolean contains( final String name ) { + + return this.queryMap.containsKey( name ); + } + + /** + * Returns an Iterator of String 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 Iterator of String objects, each String containing the + * name of a parameter; or an empty Iterator if there are no parameters + */ + + public Iterator getNames() { + + return this.queryMap.keySet().iterator(); + } + + /** + * Returns a List of String objects containing all of the values the named + * parameter has, or null if the parameter does not exist. + *

+ * 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 null if + * the paramater does not exist + */ + + public List getValues( final String name ) { + + return this.queryMap.get( name ); + } + + /** + * Returns a mutable Map of the query parameters. + * + * @return Map containing parameter names as keys and parameter values as map + * values. The keys in the parameter map are of type String. The values in + * the parameter map are Lists of type String, and their ordering is + * consistent with their ordering in the query string. Will never return + * null. + */ + + public Map> getMap() { + + LinkedHashMap> map = new LinkedHashMap>(); + + // Defensively copy the List's + + for ( Map.Entry> entry : this.queryMap.entrySet() ) { + List listValues = entry.getValue(); + map.put( entry.getKey(), new ArrayList( listValues ) ); + } + + return map; + } + + /** + * Sets a query parameter. + *

+ * 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 null, 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. + *

+ * 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. + *

+ * This version of set accepts a Number suitable for auto-boxing. For + * example: + *

+ * + * queryString.set( "id", 3 );
+ *
+ * + * @param name + * name of the query parameter + * @param value + * value of the query parameter. If null, 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 www-form-urlencoded string. + *

+ * The given string is assumed to be in www-form-urlencoded format. The result of + * passing a string not in www-form-urlencoded format (eg. passing an entire URI, + * not just its query string) will likely be mismatched parameter names. + *

+ * 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 Separators are recognised. + * + * @param query + * www-form-urlencoded string. If null, does nothing + * @return a reference to this object + */ + + public UrlEncodedQueryStringBase set(final String query ) { + + appendOrSet( query, false ); + return this; + } + + /** + * Appends a query parameter. + *

+ * 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 null, 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. + *

+ * 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. + *

+ * This version of append accepts a Number suitable for auto-boxing. + * For example: + *

+ * + * queryString.append( "id", 3 );
+ *
+ * + * @param name + * name of the query parameter + * @param value + * value of the query parameter. If null, 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 www-form-urlencoded string. + *

+ * The given string is assumed to be in www-form-urlencoded format. The result of + * passing a string not in www-form-urlencoded format (eg. passing an entire URI, + * not just its query string) will likely be mismatched parameter names. + *

+ * 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 Separators are recognised. + * + * @param query + * www-form-urlencoded string. If null, 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. + *

+ * 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. + *

+ * 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 Separator.Ampersand. + * + * @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. + *

+ * 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 Separator. + * + * @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. + *

+ * Returns true if the given object is also a UrlEncodedQueryString and the two + * UrlEncodedQueryStrings have the same parameters. More formally, two UrlEncodedQueryStrings + * t1 and t2 represent the same UrlEncodedQueryString if + * t1.toString().equals(t2.toString()). This ensures that the equals + * method checks the ordering, as well as the existence, of every parameter. + *

+ * Clients interested only in the existence, not the ordering, of parameters are recommended to + * use getMap().equals. + *

+ * This implementation first checks if the specified object is this UrlEncodedQueryString; if so + * it returns true. Then, it checks if the specified object is a + * UrlEncodedQueryString whose toString() is identical to the toString() of this + * UrlEncodedQueryString; if not, it returns false. Otherwise, it returns + * true + * + * @param obj + * object to be compared for equality with this UrlEncodedQueryString. + * @return true 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. + *

+ * The hash code of the UrlEncodedQueryString is defined to be the hash code of the + * String returned by toString(). This ensures the ordering, as well as the + * existence, of parameters is taken into account. + *

+ * Clients interested only in the existence, not the ordering, of parameters are recommended to + * use getMap().hashCode. + * + * @return a hash code value for this UrlEncodedQueryString. + */ + + @Override + public int hashCode() { + + return toString().hashCode(); + } + + /** + * Returns a www-form-urlencoded string of the query parameters. + *

+ * The HTML specification recommends two parameter separators in HTML 4.01 + * Specification: application/x-www-form-urlencoded and HTML 4.01 + * Specification: Ampersands in URI attribute values. Of those, the ampersand is the more + * commonly used and this method defaults to that. + * + * @return www-form-urlencoded string, or null if there are no + * parameters. + */ + + @Override + public String toString() { + + return toString( Separator.AMPERSAND ); + } + + /** + * Returns a www-form-urlencoded string of the query parameters, using the given + * separator between parameters. + * + * @param separator + * separator to use between parameters + * @return www-form-urlencoded string, or an empty String if there are no + * parameters + */ + + // Note: this method takes a Separator, not just any String. Taking any String may + // be useful in some circumstances (eg. you could pass '&' to generate query + // strings for use in HTML pages) but would break the implied contract between + // toString() and parse() (eg. you can always parse() what you toString() ). + // + // It was thought better to leave it to the user to explictly break this contract + // (eg. toString().replaceAll( '&', '&' )) + public String toString( Separator separator ) { + + StringBuilder builder = new StringBuilder(); + + for ( String name : this.queryMap.keySet() ) { + for ( String value : this.queryMap.get( name ) ) { + if ( builder.length() != 0 ) { + builder.append( separator ); + } + + // Encode names and values. Do this in toString(), rather than + // append/set, so that the Map always contains the + // raw, unencoded values + + try { + builder.append( URLEncoder.encode( name, "UTF-8" ) ); + + if ( value != null ) { + builder.append( '=' ); + builder.append( URLEncoder.encode( value, "UTF-8" ) ); + } + } catch ( UnsupportedEncodingException e ) { + // Should never happen. UTF-8 should always be available + // according to Java spec + + throw new RuntimeException( e ); + } + } + } + + return builder.toString(); + } + + // + // Private methods + // + + /** + * Private constructor. + *

+ * Clients should use one of the create or parse methods to create a + * UrlEncodedQueryString. + */ + + 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 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 listValues = new ArrayList(); + listValues.add( value ); + + this.queryMap.put( name, listValues ); + } + + /** + * Helper method for append and set + * + * @param parameters + * www-form-urlencoded 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 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(); + } + + 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 ); + } + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/UrlQueryString.java b/app/src/main/java/com/futo/platformplayer/sabr/UrlQueryString.java new file mode 100644 index 00000000..8644b412 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/UrlQueryString.java @@ -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); +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/UrlQueryStringFactory.java b/app/src/main/java/com/futo/platformplayer/sabr/UrlQueryStringFactory.java new file mode 100644 index 00000000..22b1b4a8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/UrlQueryStringFactory.java @@ -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); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/AdaptationSet.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/AdaptationSet.java new file mode 100644 index 00000000..7c9b4493 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/AdaptationSet.java @@ -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 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 representations) { + this.id = id; + this.type = type; + this.representations = Collections.unmodifiableList(representations); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/EventStream.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/EventStream.java new file mode 100644 index 00000000..44ddaa16 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/EventStream.java @@ -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; + } + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/Period.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/Period.java new file mode 100644 index 00000000..8fcd8a6f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/Period.java @@ -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 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 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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/RangedUri.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/RangedUri.java new file mode 100644 index 00000000..407235b7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/RangedUri.java @@ -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. + * + *

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. + * + *

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 + + ")"; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/Representation.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/Representation.java new file mode 100644 index 00000000..697d21bc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/Representation.java @@ -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(); + } + + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/SabrManifest.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SabrManifest.java new file mode 100644 index 00000000..bddd8a82 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SabrManifest.java @@ -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 { + /** + * 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 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 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 streamKeys) { + return null; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/SabrManifestParser.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SabrManifestParser.java new file mode 100644 index 00000000..11f1906e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SabrManifestParser.java @@ -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 mMP4Videos; + private Set mWEBMVideos; + private Map> mMP4Audios; + private Map> mWEBMAudios; + private List 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 periods = new ArrayList<>(); + + Pair 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 parsePeriod(MediaItemFormatInfo formatInfo) { + String id = formatInfo.getVideoId(); + long startMs = 0; // Should add real start time or make it unset? + long durationMs = getDurationMs(formatInfo); + List 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 formats : mMP4Audios.values()) { + adaptationSets.add(parseAdaptationSet(formats)); + } + + for (Set 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 formats) { + int id = mId++; + int contentType = C.TRACK_TYPE_UNKNOWN; + String label = null; + String drmSchemeType = null; + ArrayList drmSchemeDatas = new ArrayList<>(); + List 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 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 formats) { + int id = mId++; + int contentType = C.TRACK_TYPE_UNKNOWN; + String label = null; + String drmSchemeType = null; + ArrayList drmSchemeDatas = new ArrayList<>(); + List 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 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 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 timeline = parseSegmentTimeline(format); + + List 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 parseSegmentTimeline(MediaFormat format) { + List 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 parseSegmentTimeline(long elapsedTime, long duration, int segmentCount) { + List 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 parseSegmentUrl(MediaFormat format) { + List 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 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 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 extraDrmSchemeDatas) { + Format format = representationInfo.format; + if (label != null) { + format = format.copyWithLabel(label); + } + String drmSchemeType = representationInfo.drmSchemeType != null + ? representationInfo.drmSchemeType : extraDrmSchemeType; + ArrayList 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 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 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 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 getMP4Audios(String language) { + return getFormats(mMP4Audios, language); + } + + private Set getWEBMAudios(String language) { + return getFormats(mWEBMAudios, language); + } + + private static Set getFormats(Map> formatMap, String language) { + if (language == null) { + language = "default"; + } + + Set 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 drmSchemeDatas; + public final long revisionId; + + public RepresentationInfo(Format format, String baseUrl, SegmentBase segmentBase, + String drmSchemeType, ArrayList drmSchemeDatas, + long revisionId) { + this.format = format; + this.baseUrl = baseUrl; + this.segmentBase = segmentBase; + this.drmSchemeType = drmSchemeType; + this.drmSchemeDatas = drmSchemeDatas; + this.revisionId = revisionId; + } + + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/SegmentBase.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SegmentBase.java new file mode 100644 index 00000000..de89db32 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SegmentBase.java @@ -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 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 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 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 segmentTimeline, + List 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 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; + } + + } + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/SingleSegmentIndex.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SingleSegmentIndex.java new file mode 100644 index 00000000..a897050c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/SingleSegmentIndex.java @@ -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; + } + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/manifest/UrlTemplate.java b/app/src/main/java/com/futo/platformplayer/sabr/manifest/UrlTemplate.java new file mode 100644 index 00000000..40cb189e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/manifest/UrlTemplate.java @@ -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. + *

+ * 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. + * + *

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. + *

+ * 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; + } + +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/SabrExtractor.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/SabrExtractor.java new file mode 100644 index 00000000..d791d9d5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/SabrExtractor.java @@ -0,0 +1,1263 @@ +package com.futo.platformplayer.sabr.parser; + +import android.util.Pair; +import android.util.SparseArray; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.ParserException; +import androidx.media3.common.DrmInitData; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.ChunkIndex; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.MpegAudioUtil; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.TrackOutput; +import com.futo.platformplayer.sabr.parser.parts.FormatInitializedSabrPart; +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.SabrPart; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.LongArray; +import androidx.media3.common.MimeTypes; +import androidx.media3.container.NalUnitUtil; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.AvcConfig; +import androidx.media3.common.ColorInfo; +import androidx.media3.extractor.HevcConfig; + +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +@UnstableApi +public class SabrExtractor implements Extractor { + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_DISABLE_SEEK_FOR_CUES}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_SEEK_FOR_CUES}) + public @interface Flags {} + /** + * Flag to disable seeking for cues. + *

+ * Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its + * position is specified in the seek head and if it's after the first cluster. Setting this flag + * disables seeking to the cues element. If the cues element is after the first cluster then the + * media is treated as being unseekable. + */ + public static final int FLAG_DISABLE_SEEK_FOR_CUES = 1; + + private static final String TAG = SabrExtractor.class.getSimpleName(); + + private static final int VORBIS_MAX_INPUT_SIZE = 8192; + private static final int OPUS_MAX_INPUT_SIZE = 5760; + private static final int ENCRYPTION_IV_SIZE = 8; + private static final int TRACK_TYPE_AUDIO = 2; + + private static final int BLOCK_STATE_START = 0; + private static final int BLOCK_STATE_HEADER = 1; + private static final int BLOCK_STATE_DATA = 2; + + private static final String CODEC_ID_VP8 = "V_VP8"; + private static final String CODEC_ID_VP9 = "V_VP9"; + private static final String CODEC_ID_AV1 = "V_AV1"; + private static final String CODEC_ID_MPEG2 = "V_MPEG2"; + private static final String CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP"; + private static final String CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP"; + private static final String CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP"; + private static final String CODEC_ID_H264 = "V_MPEG4/ISO/AVC"; + private static final String CODEC_ID_H265 = "V_MPEGH/ISO/HEVC"; + private static final String CODEC_ID_FOURCC = "V_MS/VFW/FOURCC"; + private static final String CODEC_ID_THEORA = "V_THEORA"; + private static final String CODEC_ID_VORBIS = "A_VORBIS"; + private static final String CODEC_ID_OPUS = "A_OPUS"; + private static final String CODEC_ID_AAC = "A_AAC"; + private static final String CODEC_ID_MP2 = "A_MPEG/L2"; + private static final String CODEC_ID_MP3 = "A_MPEG/L3"; + private static final String CODEC_ID_AC3 = "A_AC3"; + private static final String CODEC_ID_E_AC3 = "A_EAC3"; + private static final String CODEC_ID_TRUEHD = "A_TRUEHD"; + private static final String CODEC_ID_DTS = "A_DTS"; + private static final String CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS"; + private static final String CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS"; + private static final String CODEC_ID_FLAC = "A_FLAC"; + private static final String CODEC_ID_ACM = "A_MS/ACM"; + private static final String CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT"; + private static final String CODEC_ID_SUBRIP = "S_TEXT/UTF8"; + private static final String CODEC_ID_ASS = "S_TEXT/ASS"; + private static final String CODEC_ID_VOBSUB = "S_VOBSUB"; + private static final String CODEC_ID_PGS = "S_HDMV/PGS"; + private static final String CODEC_ID_DVBSUB = "S_DVBSUB"; + + private static final int FOURCC_COMPRESSION_DIVX = 0x58564944; + private static final int FOURCC_COMPRESSION_H263 = 0x33363248; + private static final int FOURCC_COMPRESSION_VC1 = 0x31435657; + + /** + * A template for the prefix that must be added to each subrip sample. The 12 byte end timecode + * starting at {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be + * replaced with the duration of the subtitle. + *

+ * Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". + */ + private static final byte[] SUBRIP_PREFIX = new byte[] {49, 10, 48, 48, 58, 48, 48, 58, 48, 48, + 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 10}; + /** + * The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. + */ + private static final int SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19; + /** + * A special end timecode indicating that a subrip subtitle should be displayed until the next + * subtitle, or until the end of the media in the case of the last subtitle. + *

+ * Equivalent to the UTF-8 string: " ". + */ + private static final byte[] SUBRIP_TIMECODE_EMPTY = + new byte[] {32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}; + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a subrip timecode (milliseconds). + */ + private static final long SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; + /** + * The format of a subrip timecode. + */ + private static final String SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d"; + + /** + * Matroska specific format line for SSA subtitles. + */ + private static final byte[] SSA_DIALOGUE_FORMAT = Util.getUtf8Bytes("Format: Start, End, " + + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); + /** + * A template for the prefix that must be added to each SSA sample. The 10 byte end timecode + * starting at {@link #SSA_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be + * replaced with the duration of the subtitle. + *

+ * Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". + */ + private static final byte[] SSA_PREFIX = new byte[] {68, 105, 97, 108, 111, 103, 117, 101, 58, 32, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44}; + /** + * The byte offset of the end timecode in {@link #SSA_PREFIX}. + */ + private static final int SSA_PREFIX_END_TIMECODE_OFFSET = 21; + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in an SSA timecode (1/100ths of a second). + */ + private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10000; + /** + * A special end timecode indicating that an SSA subtitle should be displayed until the next + * subtitle, or until the end of the media in the case of the last subtitle. + *

+ * Equivalent to the UTF-8 string: " ". + */ + private static final byte[] SSA_TIMECODE_EMPTY = + new byte[] {32, 32, 32, 32, 32, 32, 32, 32, 32, 32}; + /** + * The format of an SSA timecode. + */ + private static final String SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d"; + + /** + * The length in bytes of a WAVEFORMATEX structure. + */ + private static final int WAVE_FORMAT_SIZE = 18; + /** + * Format tag indicating a WAVEFORMATEXTENSIBLE structure. + */ + private static final int WAVE_FORMAT_EXTENSIBLE = 0xFFFE; + /** + * Format tag for PCM. + */ + private static final int WAVE_FORMAT_PCM = 1; + /** + * Sub format for PCM. + */ + private static final UUID WAVE_SUBFORMAT_PCM = new UUID(0x0100000000001000L, 0x800000AA00389B71L); + + //private final EbmlReader reader; + //private final VarintReader varintReader; + private final SabrStream sabrStream; + private final SparseArray tracks; + private final boolean seekForCuesEnabled; + private final Format format; + private final int trackType; + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalLength; + private final ParsableByteArray scratch; + private final ParsableByteArray vorbisNumPageSamples; + private final ParsableByteArray seekEntryIdBytes; + private final ParsableByteArray sampleStrippedBytes; + private ParsableByteArray subtitleSample; + private final ParsableByteArray encryptionInitializationVector; + private final ParsableByteArray encryptionSubsampleData; + private ByteBuffer encryptionSubsampleDataBuffer; + + private long segmentContentSize; + private long segmentContentPosition = C.POSITION_UNSET; + private long timecodeScale = C.TIME_UNSET; + private long durationTimecode = C.TIME_UNSET; + private long durationUs = C.TIME_UNSET; + + // The track corresponding to the current TrackEntry element, or null. + private Track currentTrack; + + // Whether a seek map has been sent to the output. + private boolean sentSeekMap; + + // Master seek entry related elements. + private int seekEntryId; + private long seekEntryPosition; + + // Cue related elements. + private boolean seekForCues; + private long cuesContentPosition = C.POSITION_UNSET; + private long seekPositionAfterBuildingCues = C.POSITION_UNSET; + private long clusterTimecodeUs = C.TIME_UNSET; + private LongArray cueTimesUs; + private LongArray cueClusterPositions; + private boolean seenClusterPositionForCurrentCuePoint; + + // Block reading state. + private int blockState; + private long blockTimeUs; + private long blockDurationUs; + private int blockLacingSampleIndex; + private int blockLacingSampleCount; + private int[] blockLacingSampleSizes; + private int blockTrackNumber; + private int blockTrackNumberLength; + @C.BufferFlags + private int blockFlags; + + // Sample reading state. + private int sampleBytesRead; + private boolean sampleEncodingHandled; + private boolean sampleSignalByteRead; + private boolean sampleInitializationVectorRead; + private boolean samplePartitionCountRead; + private byte sampleSignalByte; + private int samplePartitionCount; + private int sampleCurrentNalBytesRemaining; + private int sampleBytesWritten; + private boolean sampleRead; + private boolean sampleSeenReferenceBlock; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + + /** + * @param trackType The type of the track. Typically one of the {@link androidx.media3.common.C} + * {@code TRACK_TYPE_*} constants. + */ + public SabrExtractor(int trackType, @NonNull Format format) { + this(0, trackType, format); + } + + private SabrExtractor(@Flags int flags, int trackType, @NonNull Format format) { + // TODO: replace nulls with the actual values + sabrStream = new SabrStream( + null, + null, + null, + null, + null, + null, + -1, + -1, + -1, + null, + false, + null + ); + this.format = format; + this.trackType = trackType; + seekForCuesEnabled = (flags & FLAG_DISABLE_SEEK_FOR_CUES) == 0; + tracks = new SparseArray<>(); + scratch = new ParsableByteArray(4); + vorbisNumPageSamples = new ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()); + seekEntryIdBytes = new ParsableByteArray(4); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalLength = new ParsableByteArray(4); + sampleStrippedBytes = new ParsableByteArray(); + subtitleSample = new ParsableByteArray(); + encryptionInitializationVector = new ParsableByteArray(ENCRYPTION_IV_SIZE); + encryptionSubsampleData = new ParsableByteArray(); + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException { + // TODO: not implemented + return true; + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException { + sampleRead = false; + boolean continueReading = true; + while (continueReading && !sampleRead) { + SabrPart sabrPart = sabrStream.parse(input); + continueReading = sabrPart != null; + + if (sabrPart instanceof FormatInitializedSabrPart) { + initializeFormat((FormatInitializedSabrPart) sabrPart); + } else if (sabrPart instanceof MediaSegmentInitSabrPart) { + initializeSegment((MediaSegmentInitSabrPart) sabrPart); + } else if (sabrPart instanceof MediaSegmentDataSabrPart) { + writeSegmentData((MediaSegmentDataSabrPart) sabrPart); + } else if (sabrPart instanceof MediaSegmentEndSabrPart) { + endSegment((MediaSegmentEndSabrPart) sabrPart); + } + + if (continueReading && maybeSeekForCues(seekPosition, input.getPosition())) { + return Extractor.RESULT_SEEK; + } + } + if (!continueReading) { + return Extractor.RESULT_END_OF_INPUT; + } + return Extractor.RESULT_CONTINUE; + } + + @Override + public void seek(long position, long timeUs) { + // TODO: not implemented + //clusterTimecodeUs = C.TIME_UNSET; + //blockState = BLOCK_STATE_START; + //reader.reset(); + //varintReader.reset(); + //resetSample(); + //for (int i = 0; i < tracks.size(); i++) { + // tracks.valueAt(i).reset(); + //} + } + + @Override + public void release() { + // TODO: not implemented + } + + private void initializeFormat(FormatInitializedSabrPart part) throws ParserException { + //startMasterElement + //endMasterElement + + initCurrentTrack(); + + // TODO: init seekMap + + initExtractorOutput(); + } + + private void initializeSegment(MediaSegmentInitSabrPart part) { + // TODO: not implemented + } + + private void writeSegmentData(MediaSegmentDataSabrPart part) throws IOException { + // TODO: not implemented + + // binaryElement + + // init seek segemnt data + + Track track = tracks.get(blockTrackNumber); + + // Ignore the block if we don't know about the track to which it belongs. + if (track == null) { + part.data.skipFully(part.contentLength); + return; + } + + writeSampleData(part.data, track, part.contentLength); + // TODO: improve segment start time calc + long sampleTimeUs = blockTimeUs + + (part.sequenceNumber * track.defaultSampleDurationNs) / 1000; + commitSampleToOutput(track, sampleTimeUs); + } + + private void endSegment(MediaSegmentEndSabrPart part) { + // TODO: not implemented + } + + private void initCurrentTrack() { + currentTrack = new Track(); + + currentTrack.width = format.width; + currentTrack.height = format.height; + currentTrack.number = 1; + currentTrack.type = trackType; // TODO: possibly wrong type + currentTrack.channelCount = format.channelCount; + currentTrack.stereoMode = format.stereoMode; + currentTrack.sampleRate = format.sampleRate; + currentTrack.name = format.label; // TODO: possibly wrong name + currentTrack.codecId = format.codecs; // TODO: possibly wrong codec id + currentTrack.language = format.language; + + if (format.colorInfo != null) { + currentTrack.hasColorInfo = true; + currentTrack.colorSpace = format.colorInfo.colorSpace; + currentTrack.colorTransfer = format.colorInfo.colorTransfer; + currentTrack.colorRange = format.colorInfo.colorRange; + } + + // TODO: init values taken from the sabr parts + currentTrack.defaultSampleDurationNs = -1; + currentTrack.codecDelayNs = 0; + currentTrack.seekPreRollNs = 0; + currentTrack.audioBitDepth = Format.NO_VALUE; + + // TODO: maybe init more fields + } + + private void initExtractorOutput() throws ParserException { + if (isCodecSupported(currentTrack.codecId)) { + currentTrack.initializeOutput(extractorOutput, currentTrack.number); + tracks.put(currentTrack.number, currentTrack); + } + currentTrack = null; + + // We have a single track per SABR stream + if (tracks.size() == 0) { + throw ParserException.createForUnsupportedContainerFeature("No valid tracks were found"); + } + extractorOutput.endTracks(); + } + + private void commitSampleToOutput(Track track, long timeUs) { + track.output.sampleMetadata(timeUs, blockFlags, sampleBytesWritten, 0, track.cryptoData); + sampleRead = true; + resetSample(); + } + + private void resetSample() { + sampleBytesRead = 0; + sampleBytesWritten = 0; + sampleCurrentNalBytesRemaining = 0; + sampleEncodingHandled = false; + sampleSignalByteRead = false; + samplePartitionCountRead = false; + samplePartitionCount = 0; + sampleSignalByte = (byte) 0; + sampleInitializationVectorRead = false; + sampleStrippedBytes.reset(0); + } + + /** + * Ensures {@link #scratch} contains at least {@code requiredLength} bytes of data, reading from + * the extractor input if necessary. + */ + private void readScratch(ExtractorInput input, int requiredLength) + throws IOException, InterruptedException { + if (scratch.limit() >= requiredLength) { + return; + } + if (scratch.capacity() < requiredLength) { + scratch.reset(Arrays.copyOf(scratch.getData(), Math.max(scratch.getData().length * 2, requiredLength)), + scratch.limit()); + } + input.readFully(scratch.getData(), scratch.limit(), requiredLength - scratch.limit()); + scratch.setLimit(requiredLength); + } + + private void writeSampleData(ExtractorInput input, Track track, int size) + throws IOException { + if (CODEC_ID_SUBRIP.equals(track.codecId)) { + writeSubtitleSampleData(input, SUBRIP_PREFIX, size); + return; + } else if (CODEC_ID_ASS.equals(track.codecId)) { + writeSubtitleSampleData(input, SSA_PREFIX, size); + return; + } + + TrackOutput output = track.output; + if (!sampleEncodingHandled) { + if (track.hasContentEncryption) { + // If the sample is encrypted, read its encryption signal byte and set the IV size. + // Clear the encrypted flag. + blockFlags &= ~C.BUFFER_FLAG_ENCRYPTED; + if (!sampleSignalByteRead) { + input.readFully(scratch.getData(), 0, 1); + sampleBytesRead++; + if ((scratch.getData()[0] & 0x80) == 0x80) { + throw ParserException.createForUnsupportedContainerFeature("Extension bit is set in signal byte"); + } + sampleSignalByte = scratch.getData()[0]; + sampleSignalByteRead = true; + } + // TODO: maybe handle an encryption here + } else if (track.sampleStrippedBytes != null) { + // If the sample has header stripping, prepare to read/output the stripped bytes first. + sampleStrippedBytes.reset(track.sampleStrippedBytes, track.sampleStrippedBytes.length); + } + sampleEncodingHandled = true; + } + size += sampleStrippedBytes.limit(); + + if (CODEC_ID_H264.equals(track.codecId) || CODEC_ID_H265.equals(track.codecId)) { + // TODO: Deduplicate with Mp4Extractor. + + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalLengthData = nalLength.getData(); + nalLengthData[0] = 0; + nalLengthData[1] = 0; + nalLengthData[2] = 0; + int nalUnitLengthFieldLength = track.nalUnitLengthFieldLength; + int nalUnitLengthFieldLengthDiff = 4 - track.nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + while (sampleBytesRead < size) { + if (sampleCurrentNalBytesRemaining == 0) { + // Read the NAL length so that we know where we find the next one. + readToTarget(input, nalLengthData, nalUnitLengthFieldLengthDiff, + nalUnitLengthFieldLength); + nalLength.setPosition(0); + sampleCurrentNalBytesRemaining = nalLength.readUnsignedIntToInt(); + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + sampleBytesWritten += 4; + } else { + // Write the payload of the NAL unit. + sampleCurrentNalBytesRemaining -= + readToOutput(input, output, sampleCurrentNalBytesRemaining); + } + } + } else { + while (sampleBytesRead < size) { + readToOutput(input, output, size - sampleBytesRead); + } + } + + if (CODEC_ID_VORBIS.equals(track.codecId)) { + // Vorbis decoder in android MediaCodec [1] expects the last 4 bytes of the sample to be the + // number of samples in the current page. This definition holds good only for Ogg and + // irrelevant for Matroska. So we always set this to -1 (the decoder will ignore this value if + // we set it to -1). The android platform media extractor [2] does the same. + // [1] https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/codecs/vorbis/dec/SoftVorbis.cpp#314 + // [2] https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/NuMediaExtractor.cpp#474 + vorbisNumPageSamples.setPosition(0); + output.sampleData(vorbisNumPageSamples, 4); + sampleBytesWritten += 4; + } + } + + private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) + throws IOException { + int sizeWithPrefix = samplePrefix.length + size; + if (subtitleSample.capacity() < sizeWithPrefix) { + // Initialize subripSample to contain the required prefix and have space to hold a subtitle + // twice as long as this one. + subtitleSample = new ParsableByteArray(Arrays.copyOf(samplePrefix, sizeWithPrefix + size)); + } else { + System.arraycopy(samplePrefix, 0, subtitleSample.getData(), 0, samplePrefix.length); + } + input.readFully(subtitleSample.getData(), samplePrefix.length, size); + subtitleSample.reset(sizeWithPrefix); + // Defer writing the data to the track output. We need to modify the sample data by setting + // the correct end timecode, which we might not have yet. + } + + private void commitSubtitleSample(Track track, String timecodeFormat, int endTimecodeOffset, + long lastTimecodeValueScalingFactor, byte[] emptyTimecode) { + setSampleDuration(subtitleSample.getData(), blockDurationUs, timecodeFormat, endTimecodeOffset, + lastTimecodeValueScalingFactor, emptyTimecode); + // Note: If we ever want to support DRM protected subtitles then we'll need to output the + // appropriate encryption data here. + track.output.sampleData(subtitleSample, subtitleSample.limit()); + sampleBytesWritten += subtitleSample.limit(); + } + + private static void setSampleDuration(byte[] subripSampleData, long durationUs, + String timecodeFormat, int endTimecodeOffset, long lastTimecodeValueScalingFactor, + byte[] emptyTimecode) { + byte[] timeCodeData; + if (durationUs == C.TIME_UNSET) { + timeCodeData = emptyTimecode; + } else { + int hours = (int) (durationUs / (3600 * C.MICROS_PER_SECOND)); + durationUs -= (hours * 3600 * C.MICROS_PER_SECOND); + int minutes = (int) (durationUs / (60 * C.MICROS_PER_SECOND)); + durationUs -= (minutes * 60 * C.MICROS_PER_SECOND); + int seconds = (int) (durationUs / C.MICROS_PER_SECOND); + durationUs -= (seconds * C.MICROS_PER_SECOND); + int lastValue = (int) (durationUs / lastTimecodeValueScalingFactor); + timeCodeData = Util.getUtf8Bytes(String.format(Locale.US, timecodeFormat, hours, minutes, + seconds, lastValue)); + } + System.arraycopy(timeCodeData, 0, subripSampleData, endTimecodeOffset, emptyTimecode.length); + } + + /** + * Writes {@code length} bytes of sample data into {@code target} at {@code offset}, consisting of + * pending {@link #sampleStrippedBytes} and any remaining data read from {@code input}. + */ + private void readToTarget(ExtractorInput input, byte[] target, int offset, int length) + throws IOException { + int pendingStrippedBytes = Math.min(length, sampleStrippedBytes.bytesLeft()); + input.readFully(target, offset + pendingStrippedBytes, length - pendingStrippedBytes); + if (pendingStrippedBytes > 0) { + sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes); + } + sampleBytesRead += length; + } + + /** + * Outputs up to {@code length} bytes of sample data to {@code output}, consisting of either + * {@link #sampleStrippedBytes} or data read from {@code input}. + */ + private int readToOutput(ExtractorInput input, TrackOutput output, int length) + throws IOException { + int bytesRead; + int strippedBytesLeft = sampleStrippedBytes.bytesLeft(); + if (strippedBytesLeft > 0) { + bytesRead = Math.min(length, strippedBytesLeft); + output.sampleData(sampleStrippedBytes, bytesRead); + } else { + bytesRead = output.sampleData(input, length, false); + } + sampleBytesRead += bytesRead; + sampleBytesWritten += bytesRead; + return bytesRead; + } + + /** + * Builds a {@link SeekMap} from the recently gathered Cues information. + * + * @return The built {@link SeekMap}. The returned {@link SeekMap} may be unseekable if cues + * information was missing or incomplete. + */ + private SeekMap buildSeekMap() { + if (segmentContentPosition == C.POSITION_UNSET || durationUs == C.TIME_UNSET + || cueTimesUs == null || cueTimesUs.size() == 0 + || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { + // Cues information is missing or incomplete. + cueTimesUs = null; + cueClusterPositions = null; + return new SeekMap.Unseekable(durationUs); + } + int cuePointsSize = cueTimesUs.size(); + int[] sizes = new int[cuePointsSize]; + long[] offsets = new long[cuePointsSize]; + long[] durationsUs = new long[cuePointsSize]; + long[] timesUs = new long[cuePointsSize]; + for (int i = 0; i < cuePointsSize; i++) { + timesUs[i] = cueTimesUs.get(i); + offsets[i] = segmentContentPosition + cueClusterPositions.get(i); + } + for (int i = 0; i < cuePointsSize - 1; i++) { + sizes[i] = (int) (offsets[i + 1] - offsets[i]); + durationsUs[i] = timesUs[i + 1] - timesUs[i]; + } + sizes[cuePointsSize - 1] = + (int) (segmentContentPosition + segmentContentSize - offsets[cuePointsSize - 1]); + durationsUs[cuePointsSize - 1] = durationUs - timesUs[cuePointsSize - 1]; + cueTimesUs = null; + cueClusterPositions = null; + return new ChunkIndex(sizes, offsets, durationsUs, timesUs); + } + + /** + * Updates the position of the holder to Cues element's position if the extractor configuration + * permits use of master seek entry. After building Cues sets the holder's position back to where + * it was before. + * + * @param seekPosition The holder whose position will be updated. + * @param currentPosition Current position of the input. + * @return Whether the seek position was updated. + */ + private boolean maybeSeekForCues(PositionHolder seekPosition, long currentPosition) { + if (seekForCues) { + seekPositionAfterBuildingCues = currentPosition; + seekPosition.position = cuesContentPosition; + seekForCues = false; + return true; + } + // After parsing Cues, seek back to original position if available. We will not do this unless + // we seeked to get to the Cues in the first place. + if (sentSeekMap && seekPositionAfterBuildingCues != C.POSITION_UNSET) { + seekPosition.position = seekPositionAfterBuildingCues; + seekPositionAfterBuildingCues = C.POSITION_UNSET; + return true; + } + return false; + } + + private long scaleTimecodeToUs(long unscaledTimecode) throws ParserException { + if (timecodeScale == C.TIME_UNSET) { + throw ParserException.createForUnsupportedContainerFeature("Can't scale timecode prior to timecodeScale being set."); + } + return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000); + } + + private static boolean isCodecSupported(String codecId) { + return CODEC_ID_VP8.equals(codecId) + || CODEC_ID_VP9.equals(codecId) + || CODEC_ID_AV1.equals(codecId) + || CODEC_ID_MPEG2.equals(codecId) + || CODEC_ID_MPEG4_SP.equals(codecId) + || CODEC_ID_MPEG4_ASP.equals(codecId) + || CODEC_ID_MPEG4_AP.equals(codecId) + || CODEC_ID_H264.equals(codecId) + || CODEC_ID_H265.equals(codecId) + || CODEC_ID_FOURCC.equals(codecId) + || CODEC_ID_THEORA.equals(codecId) + || CODEC_ID_OPUS.equals(codecId) + || CODEC_ID_VORBIS.equals(codecId) + || CODEC_ID_AAC.equals(codecId) + || CODEC_ID_MP2.equals(codecId) + || CODEC_ID_MP3.equals(codecId) + || CODEC_ID_AC3.equals(codecId) + || CODEC_ID_E_AC3.equals(codecId) + || CODEC_ID_TRUEHD.equals(codecId) + || CODEC_ID_DTS.equals(codecId) + || CODEC_ID_DTS_EXPRESS.equals(codecId) + || CODEC_ID_DTS_LOSSLESS.equals(codecId) + || CODEC_ID_FLAC.equals(codecId) + || CODEC_ID_ACM.equals(codecId) + || CODEC_ID_PCM_INT_LIT.equals(codecId) + || CODEC_ID_SUBRIP.equals(codecId) + || CODEC_ID_ASS.equals(codecId) + || CODEC_ID_VOBSUB.equals(codecId) + || CODEC_ID_PGS.equals(codecId) + || CODEC_ID_DVBSUB.equals(codecId); + } + + /** + * Returns an array that can store (at least) {@code length} elements, which will be either a new + * array or {@code array} if it's not null and large enough. + */ + private static int[] ensureArrayCapacity(int[] array, int length) { + if (array == null) { + return new int[length]; + } else if (array.length >= length) { + return array; + } else { + // Double the size to avoid allocating constantly if the required length increases gradually. + return new int[Math.max(array.length * 2, length)]; + } + } + + private static final class Track { + private static final int DISPLAY_UNIT_PIXELS = 0; + private static final int MAX_CHROMATICITY = 50000; // Defined in CTA-861.3. + /** + * Default max content light level (CLL) that should be encoded into hdrStaticInfo. + */ + private static final int DEFAULT_MAX_CLL = 1000; // nits. + + /** + * Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. + */ + private static final int DEFAULT_MAX_FALL = 200; // nits. + + // Common elements. + public String name; + public String codecId; + public int number; + public int type; + public int defaultSampleDurationNs; + public boolean hasContentEncryption; + public byte[] sampleStrippedBytes; + public TrackOutput.CryptoData cryptoData; + public byte[] codecPrivate; + public DrmInitData drmInitData; + + // Video elements. + public int width = Format.NO_VALUE; + public int height = Format.NO_VALUE; + public int displayWidth = Format.NO_VALUE; + public int displayHeight = Format.NO_VALUE; + public int displayUnit = DISPLAY_UNIT_PIXELS; + @C.Projection public int projectionType = Format.NO_VALUE; + public float projectionPoseYaw = 0f; + public float projectionPosePitch = 0f; + public float projectionPoseRoll = 0f; + public byte[] projectionData = null; + @C.StereoMode + public int stereoMode = Format.NO_VALUE; + public boolean hasColorInfo = false; + @C.ColorSpace + public int colorSpace = Format.NO_VALUE; + @C.ColorTransfer + public int colorTransfer = Format.NO_VALUE; + @C.ColorRange + public int colorRange = Format.NO_VALUE; + public int maxContentLuminance = DEFAULT_MAX_CLL; + public int maxFrameAverageLuminance = DEFAULT_MAX_FALL; + public float primaryRChromaticityX = Format.NO_VALUE; + public float primaryRChromaticityY = Format.NO_VALUE; + public float primaryGChromaticityX = Format.NO_VALUE; + public float primaryGChromaticityY = Format.NO_VALUE; + public float primaryBChromaticityX = Format.NO_VALUE; + public float primaryBChromaticityY = Format.NO_VALUE; + public float whitePointChromaticityX = Format.NO_VALUE; + public float whitePointChromaticityY = Format.NO_VALUE; + public float maxMasteringLuminance = Format.NO_VALUE; + public float minMasteringLuminance = Format.NO_VALUE; + + // Audio elements. Initially set to their default values. + public int channelCount = 1; + public int audioBitDepth = Format.NO_VALUE; + public int sampleRate = 8000; + public long codecDelayNs = 0; + public long seekPreRollNs = 0; + + // Text elements. + public boolean flagForced; + public boolean flagDefault = true; + private String language = "eng"; + + // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. + public TrackOutput output; + public int nalUnitLengthFieldLength; + + /** Initializes the track with an output. */ + public void initializeOutput(ExtractorOutput output, int trackId) throws ParserException { + String mimeType; + int maxInputSize = Format.NO_VALUE; + @C.PcmEncoding int pcmEncoding = Format.NO_VALUE; + List initializationData = null; + + switch (codecId) { + case CODEC_ID_VP8: + mimeType = MimeTypes.VIDEO_VP8; + break; + case CODEC_ID_VP9: + mimeType = MimeTypes.VIDEO_VP9; + break; + case CODEC_ID_AV1: + mimeType = MimeTypes.VIDEO_AV1; + break; + case CODEC_ID_MPEG2: + mimeType = MimeTypes.VIDEO_MPEG2; + break; + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + mimeType = MimeTypes.VIDEO_MP4V; + initializationData = codecPrivate == null ? null : Collections.singletonList(codecPrivate); + break; + case CODEC_ID_H264: { + mimeType = MimeTypes.VIDEO_H264; + AvcConfig avcConfig = AvcConfig.parse(new ParsableByteArray(codecPrivate)); + initializationData = avcConfig.initializationData; + nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; + break; + } + case CODEC_ID_H265: { + mimeType = MimeTypes.VIDEO_H265; + HevcConfig hevcConfig = HevcConfig.parse(new ParsableByteArray(codecPrivate)); + initializationData = hevcConfig.initializationData; + nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength; + break; + } + case CODEC_ID_FOURCC: { + Pair> pair = + parseFourCcPrivate(new ParsableByteArray(codecPrivate)); + mimeType = pair.first; + initializationData = pair.second; + break; + } + case CODEC_ID_THEORA: + // Unknown until proper init data wiring is defined. + mimeType = MimeTypes.VIDEO_UNKNOWN; + break; + case CODEC_ID_VORBIS: + mimeType = MimeTypes.AUDIO_VORBIS; + maxInputSize = VORBIS_MAX_INPUT_SIZE; + initializationData = parseVorbisCodecPrivate(codecPrivate); + break; + case CODEC_ID_OPUS: + mimeType = MimeTypes.AUDIO_OPUS; + maxInputSize = OPUS_MAX_INPUT_SIZE; + initializationData = new ArrayList<>(3); + initializationData.add(codecPrivate); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()) + .putLong(codecDelayNs).array()); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()) + .putLong(seekPreRollNs).array()); + break; + case CODEC_ID_AAC: + mimeType = MimeTypes.AUDIO_AAC; + initializationData = Collections.singletonList(codecPrivate); + break; + case CODEC_ID_MP2: + mimeType = MimeTypes.AUDIO_MPEG_L2; + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_MP3: + mimeType = MimeTypes.AUDIO_MPEG; + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_AC3: + mimeType = MimeTypes.AUDIO_AC3; + break; + case CODEC_ID_E_AC3: + mimeType = MimeTypes.AUDIO_E_AC3; + break; + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + mimeType = MimeTypes.AUDIO_DTS; + break; + case CODEC_ID_DTS_LOSSLESS: + mimeType = MimeTypes.AUDIO_DTS_HD; + break; + case CODEC_ID_FLAC: + mimeType = MimeTypes.AUDIO_FLAC; + initializationData = Collections.singletonList(codecPrivate); + break; + case CODEC_ID_ACM: + mimeType = MimeTypes.AUDIO_RAW; + if (parseMsAcmCodecPrivate(new ParsableByteArray(codecPrivate))) { + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Unsupported PCM bit depth: " + audioBitDepth + + ". Setting mimeType to " + mimeType); + } + } else { + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Non-PCM MS/ACM is unsupported. Setting mimeType to " + mimeType); + } + break; + case CODEC_ID_PCM_INT_LIT: + mimeType = MimeTypes.AUDIO_RAW; + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Unsupported PCM bit depth: " + audioBitDepth + + ". Setting mimeType to " + mimeType); + } + break; + case CODEC_ID_SUBRIP: + mimeType = MimeTypes.APPLICATION_SUBRIP; + break; + case CODEC_ID_ASS: + mimeType = MimeTypes.TEXT_SSA; + break; + case CODEC_ID_VOBSUB: + mimeType = MimeTypes.APPLICATION_VOBSUB; + initializationData = Collections.singletonList(codecPrivate); + break; + case CODEC_ID_PGS: + mimeType = MimeTypes.APPLICATION_PGS; + break; + case CODEC_ID_DVBSUB: + mimeType = MimeTypes.APPLICATION_DVBSUBS; + // Init data: composition_page (2), ancillary_page (2) + initializationData = Collections.singletonList(new byte[] { + codecPrivate[0], codecPrivate[1], codecPrivate[2], codecPrivate[3] + }); + break; + default: + throw ParserException.createForUnsupportedContainerFeature( + "Unrecognized codec identifier."); + } + + int type; + @C.SelectionFlags int selectionFlags = 0; + selectionFlags |= flagDefault ? C.SELECTION_FLAG_DEFAULT : 0; + selectionFlags |= flagForced ? C.SELECTION_FLAG_FORCED : 0; + + final String trackIdStr = Integer.toString(trackId); + + // Base builder with fields common across types. + Format.Builder builder = new Format.Builder() + .setId(trackIdStr) + .setSampleMimeType(mimeType) + .setSelectionFlags(selectionFlags) + .setDrmInitData(drmInitData); + + if (language != null) { + builder.setLanguage(language); + } + if (initializationData != null) { + builder.setInitializationData(initializationData); + } + if (maxInputSize != Format.NO_VALUE) { + builder.setMaxInputSize(maxInputSize); + } + + if (MimeTypes.isAudio(mimeType)) { + type = C.TRACK_TYPE_AUDIO; + if (channelCount != Format.NO_VALUE) { + builder.setChannelCount(channelCount); + } + if (sampleRate != Format.NO_VALUE) { + builder.setSampleRate(sampleRate); + } + if (pcmEncoding != Format.NO_VALUE) { + builder.setPcmEncoding(pcmEncoding); + } + // (Bitrate left as NO_VALUE; set it here if you have it.) + + } else if (MimeTypes.isVideo(mimeType)) { + type = C.TRACK_TYPE_VIDEO; + + if (displayUnit == Track.DISPLAY_UNIT_PIXELS) { + displayWidth = (displayWidth == Format.NO_VALUE) ? width : displayWidth; + displayHeight = (displayHeight == Format.NO_VALUE) ? height : displayHeight; + } + + float pixelWidthHeightRatio = Format.NO_VALUE; + if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { + pixelWidthHeightRatio = + ((float) (height * displayWidth)) / (width * displayHeight); + } + + ColorInfo colorInfo = null; + if (hasColorInfo) { + byte[] hdrStaticInfo = getHdrStaticInfo(); + colorInfo = new ColorInfo(colorSpace, colorRange, colorTransfer, hdrStaticInfo); + } + + int rotationDegrees = Format.NO_VALUE; + // Some HTC devices signal rotation in track names. + if ("htc_video_rotA-000".equals(name)) { + rotationDegrees = 0; + } else if ("htc_video_rotA-090".equals(name)) { + rotationDegrees = 90; + } else if ("htc_video_rotA-180".equals(name)) { + rotationDegrees = 180; + } else if ("htc_video_rotA-270".equals(name)) { + rotationDegrees = 270; + } + + if (projectionType == C.PROJECTION_RECTANGULAR + && Float.compare(projectionPoseYaw, 0f) == 0 + && Float.compare(projectionPosePitch, 0f) == 0) { + // The range of projectionPoseRoll is [-180, 180]. + if (Float.compare(projectionPoseRoll, 0f) == 0) { + rotationDegrees = 0; + } else if (Float.compare(projectionPosePitch, 90f) == 0) { + rotationDegrees = 90; + } else if (Float.compare(projectionPosePitch, -180f) == 0 + || Float.compare(projectionPosePitch, 180f) == 0) { + rotationDegrees = 180; + } else if (Float.compare(projectionPosePitch, -90f) == 0) { + rotationDegrees = 270; + } + } + + if (width != Format.NO_VALUE) builder.setWidth(width); + if (height != Format.NO_VALUE) builder.setHeight(height); + if (rotationDegrees != Format.NO_VALUE) builder.setRotationDegrees(rotationDegrees); + if (pixelWidthHeightRatio != Format.NO_VALUE) { + builder.setPixelWidthHeightRatio(pixelWidthHeightRatio); + } + if (projectionData != null) builder.setProjectionData(projectionData); + if (stereoMode != Format.NO_VALUE) builder.setStereoMode(stereoMode); + if (colorInfo != null) builder.setColorInfo(colorInfo); + + } else if (MimeTypes.APPLICATION_SUBRIP.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + // No extra fields needed for SRT. + + } else if (MimeTypes.TEXT_SSA.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + // Override init data for SSA/ASS: dialogue format header + private data. + List ssaInit = new ArrayList<>(2); + ssaInit.add(SSA_DIALOGUE_FORMAT); + ssaInit.add(codecPrivate); + builder.setInitializationData(ssaInit) + .setSubsampleOffsetUs(Format.OFFSET_SAMPLE_RELATIVE); + + } else if (MimeTypes.APPLICATION_VOBSUB.equals(mimeType) + || MimeTypes.APPLICATION_PGS.equals(mimeType) + || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; // Image-based subs still use TEXT track type in extractors. + + } else { + throw ParserException.createForUnsupportedContainerFeature("Unexpected MIME type."); + } + + Format format = builder.build(); + + this.output = output.track(number, type); + this.output.format(format); + } + + /** Returns the HDR Static Info as defined in CTA-861.3. */ + private byte[] getHdrStaticInfo() { + // Are all fields present. + if (primaryRChromaticityX == Format.NO_VALUE || primaryRChromaticityY == Format.NO_VALUE + || primaryGChromaticityX == Format.NO_VALUE || primaryGChromaticityY == Format.NO_VALUE + || primaryBChromaticityX == Format.NO_VALUE || primaryBChromaticityY == Format.NO_VALUE + || whitePointChromaticityX == Format.NO_VALUE + || whitePointChromaticityY == Format.NO_VALUE || maxMasteringLuminance == Format.NO_VALUE + || minMasteringLuminance == Format.NO_VALUE) { + return null; + } + + byte[] hdrStaticInfoData = new byte[25]; + ByteBuffer hdrStaticInfo = ByteBuffer.wrap(hdrStaticInfoData); + hdrStaticInfo.put((byte) 0); // Type. + hdrStaticInfo.putShort((short) ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) (maxMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) (minMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) maxContentLuminance); + hdrStaticInfo.putShort((short) maxFrameAverageLuminance); + return hdrStaticInfoData; + } + + /** + * Builds initialization data for a {@link Format} from FourCC codec private data. + * + * @return The codec mime type and initialization data. If the compression type is not supported + * then the mime type is set to {@link MimeTypes#VIDEO_UNKNOWN} and the initialization data + * is {@code null}. + * @throws ParserException If the initialization data could not be built. + */ + private static Pair> parseFourCcPrivate(ParsableByteArray buffer) + throws ParserException { + try { + buffer.skipBytes(16); // size(4), width(4), height(4), planes(2), bitcount(2). + long compression = buffer.readLittleEndianUnsignedInt(); + if (compression == FOURCC_COMPRESSION_DIVX) { + return new Pair<>(MimeTypes.VIDEO_DIVX, null); + } else if (compression == FOURCC_COMPRESSION_H263) { + return new Pair<>(MimeTypes.VIDEO_H263, null); + } else if (compression == FOURCC_COMPRESSION_VC1) { + // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 + // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). + int startOffset = buffer.getPosition() + 20; + byte[] bufferData = buffer.getData(); + for (int offset = startOffset; offset < bufferData.length - 4; offset++) { + if (bufferData[offset] == 0x00 + && bufferData[offset + 1] == 0x00 + && bufferData[offset + 2] == 0x01 + && bufferData[offset + 3] == 0x0F) { + // We've found the initialization data. + byte[] initializationData = Arrays.copyOfRange(bufferData, offset, bufferData.length); + return new Pair<>(MimeTypes.VIDEO_VC1, Collections.singletonList(initializationData)); + } + } + throw ParserException.createForUnsupportedContainerFeature("Failed to find FourCC VC1 initialization data"); + } + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing FourCC private data"); + } + + Log.w(TAG, "Unknown FourCC. Setting mimeType to " + MimeTypes.VIDEO_UNKNOWN); + return new Pair<>(MimeTypes.VIDEO_UNKNOWN, null); + } + + /** + * Builds initialization data for a {@link Format} from Vorbis codec private data. + * + * @return The initialization data for the {@link Format}. + * @throws ParserException If the initialization data could not be built. + */ + private static List parseVorbisCodecPrivate(byte[] codecPrivate) + throws ParserException { + try { + if (codecPrivate[0] != 0x02) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing vorbis codec private"); + } + int offset = 1; + int vorbisInfoLength = 0; + while (codecPrivate[offset] == (byte) 0xFF) { + vorbisInfoLength += 0xFF; + offset++; + } + vorbisInfoLength += codecPrivate[offset++]; + + int vorbisSkipLength = 0; + while (codecPrivate[offset] == (byte) 0xFF) { + vorbisSkipLength += 0xFF; + offset++; + } + vorbisSkipLength += codecPrivate[offset++]; + + if (codecPrivate[offset] != 0x01) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing vorbis codec private"); + } + byte[] vorbisInfo = new byte[vorbisInfoLength]; + System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength); + offset += vorbisInfoLength; + if (codecPrivate[offset] != 0x03) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing vorbis codec private"); + } + offset += vorbisSkipLength; + if (codecPrivate[offset] != 0x05) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing vorbis codec private"); + } + byte[] vorbisBooks = new byte[codecPrivate.length - offset]; + System.arraycopy(codecPrivate, offset, vorbisBooks, 0, codecPrivate.length - offset); + List initializationData = new ArrayList<>(2); + initializationData.add(vorbisInfo); + initializationData.add(vorbisBooks); + return initializationData; + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing vorbis codec private"); + } + } + + /** + * Parses an MS/ACM codec private, returning whether it indicates PCM audio. + * + * @return Whether the codec private indicates PCM audio. + * @throws ParserException If a parsing error occurs. + */ + private static boolean parseMsAcmCodecPrivate(ParsableByteArray buffer) throws ParserException { + try { + int formatTag = buffer.readLittleEndianUnsignedShort(); + if (formatTag == WAVE_FORMAT_PCM) { + return true; + } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { + buffer.setPosition(WAVE_FORMAT_SIZE + 6); // unionSamples(2), channelMask(4) + return buffer.readLong() == WAVE_SUBFORMAT_PCM.getMostSignificantBits() + && buffer.readLong() == WAVE_SUBFORMAT_PCM.getLeastSignificantBits(); + } else { + return false; + } + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForUnsupportedContainerFeature("Error parsing MS/ACM codec private"); + } + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/SabrStream.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/SabrStream.java new file mode 100644 index 00000000..7013a66c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/SabrStream.java @@ -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 unknownPartTypes; + private int sqMismatchForwardCount; + private int sqMismatchBacktrackCount; + private boolean receivedNewSegments; + private String url; + private List 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 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 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 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); + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/MediaSegmentMismatchError.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/MediaSegmentMismatchError.java new file mode 100644 index 00000000..1074c27a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/MediaSegmentMismatchError.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/PoTokenError.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/PoTokenError.java new file mode 100644 index 00000000..63a8e0c4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/PoTokenError.java @@ -0,0 +1,7 @@ +package com.futo.platformplayer.sabr.parser.exceptions; + +public class PoTokenError extends SabrStreamError { + public PoTokenError(String msg) { + super(msg); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/SabrStreamConsumedError.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/SabrStreamConsumedError.java new file mode 100644 index 00000000..1743a2ef --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/SabrStreamConsumedError.java @@ -0,0 +1,4 @@ +package com.futo.platformplayer.sabr.parser.exceptions; + +public class SabrStreamConsumedError extends Exception { +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/SabrStreamError.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/SabrStreamError.java new file mode 100644 index 00000000..b999c137 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/exceptions/SabrStreamError.java @@ -0,0 +1,7 @@ +package com.futo.platformplayer.sabr.parser.exceptions; + +public class SabrStreamError extends RuntimeException { + public SabrStreamError(String msg) { + super(msg); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/AudioSelector.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/AudioSelector.java new file mode 100644 index 00000000..03d73321 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/AudioSelector.java @@ -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"; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/CaptionSelector.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/CaptionSelector.java new file mode 100644 index 00000000..f7871fb0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/CaptionSelector.java @@ -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"; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/ConsumedRange.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/ConsumedRange.java new file mode 100644 index 00000000..c6894289 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/ConsumedRange.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/FormatSelector.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/FormatSelector.java new file mode 100644 index 00000000..914327a5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/FormatSelector.java @@ -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 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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/InitializedFormat.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/InitializedFormat.java new file mode 100644 index 00000000..d180d30f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/InitializedFormat.java @@ -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 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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/Segment.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/Segment.java new file mode 100644 index 00000000..00a1a84f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/Segment.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/models/VideoSelector.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/VideoSelector.java new file mode 100644 index 00000000..8f1db64d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/models/VideoSelector.java @@ -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"; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/FormatInitializedSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/FormatInitializedSabrPart.java new file mode 100644 index 00000000..9898d7f9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/FormatInitializedSabrPart.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSeekSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSeekSabrPart.java new file mode 100644 index 00000000..19fba925 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSeekSabrPart.java @@ -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 + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentDataSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentDataSabrPart.java new file mode 100644 index 00000000..21e131b2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentDataSabrPart.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentEndSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentEndSabrPart.java new file mode 100644 index 00000000..abbbf539 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentEndSabrPart.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentInitSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentInitSabrPart.java new file mode 100644 index 00000000..549a17ab --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/MediaSegmentInitSabrPart.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/PoTokenStatusSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/PoTokenStatusSabrPart.java new file mode 100644 index 00000000..a8d063d4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/PoTokenStatusSabrPart.java @@ -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 + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/RefreshPlayerResponseSabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/RefreshPlayerResponseSabrPart.java new file mode 100644 index 00000000..d496cf0d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/RefreshPlayerResponseSabrPart.java @@ -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 + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/SabrPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/SabrPart.java new file mode 100644 index 00000000..6a34c2c9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/parts/SabrPart.java @@ -0,0 +1,4 @@ +package com.futo.platformplayer.sabr.parser.parts; + +public interface SabrPart { +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessFormatInitializationMetadataResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessFormatInitializationMetadataResult.java new file mode 100644 index 00000000..e9fa8d5b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessFormatInitializationMetadataResult.java @@ -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; +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessLiveMetadataResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessLiveMetadataResult.java new file mode 100644 index 00000000..9568f0a8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessLiveMetadataResult.java @@ -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 seekSabrParts = new ArrayList<>(); +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaEndResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaEndResult.java new file mode 100644 index 00000000..d8b6d9e6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaEndResult.java @@ -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 +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaHeaderResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaHeaderResult.java new file mode 100644 index 00000000..cc0e718d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaHeaderResult.java @@ -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; +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaResult.java new file mode 100644 index 00000000..2841cc1a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessMediaResult.java @@ -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; +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessSabrSeekResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessSabrSeekResult.java new file mode 100644 index 00000000..a9fa96d4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessSabrSeekResult.java @@ -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 seekSabrParts = new ArrayList<>(); +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessStreamProtectionStatusResult.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessStreamProtectionStatusResult.java new file mode 100644 index 00000000..a1a8c01d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/ProcessStreamProtectionStatusResult.java @@ -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; +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/SabrProcessor.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/SabrProcessor.java new file mode 100644 index 00000000..0a26ded1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/SabrProcessor.java @@ -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 partialSegments; + private final Map initializedFormats; + private Status streamProtectionStatus; + private boolean isLive; + private LiveMetadata liveMetadata; + private long totalDurationMs; + private NextRequestPolicy nextRequestPolicy; + private final Map sabrContextUpdates; + private final Set sabrContextsToSend; + private List selectedAudioFormatIds; + private List selectedVideoFormatIds; + private List 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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/Utils.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/Utils.java new file mode 100644 index 00000000..30a420ee --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/processor/Utils.java @@ -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); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPDecoder.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPDecoder.java new file mode 100644 index 00000000..a5628c2e --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPDecoder.java @@ -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; + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPEncoder.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPEncoder.java new file mode 100644 index 00000000..240a1706 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPEncoder.java @@ -0,0 +1,7 @@ +package com.futo.platformplayer.sabr.parser.ump; + +public class UMPEncoder { + public void encode(UMPPart part) { + + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPInputStream.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPInputStream.java new file mode 100644 index 00000000..eaca135f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPInputStream.java @@ -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; + } +} + diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPPart.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPPart.java new file mode 100644 index 00000000..50206549 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPPart.java @@ -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); + } +} diff --git a/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPPartId.java b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPPartId.java new file mode 100644 index 00000000..b5e6a5d9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sabr/parser/ump/UMPPartId.java @@ -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; +} diff --git a/app/src/main/proto/sabr/videostreaming/buffered_range.proto b/app/src/main/proto/sabr/videostreaming/buffered_range.proto new file mode 100644 index 00000000..4c913940 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/buffered_range.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/client_abr_state.proto b/app/src/main/proto/sabr/videostreaming/client_abr_state.proto new file mode 100644 index 00000000..e6e6c649 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/client_abr_state.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/client_info.proto b/app/src/main/proto/sabr/videostreaming/client_info.proto new file mode 100644 index 00000000..752d30eb --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/client_info.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/compression_algorithm.proto b/app/src/main/proto/sabr/videostreaming/compression_algorithm.proto new file mode 100644 index 00000000..26f23526 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/compression_algorithm.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/format_id.proto b/app/src/main/proto/sabr/videostreaming/format_id.proto new file mode 100644 index 00000000..d6ae7e91 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/format_id.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/format_initialization_metadata.proto b/app/src/main/proto/sabr/videostreaming/format_initialization_metadata.proto new file mode 100644 index 00000000..56693007 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/format_initialization_metadata.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/live_metadata.proto b/app/src/main/proto/sabr/videostreaming/live_metadata.proto new file mode 100644 index 00000000..fcb552a1 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/live_metadata.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/media_header.proto b/app/src/main/proto/sabr/videostreaming/media_header.proto new file mode 100644 index 00000000..2484b4a8 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/media_header.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/next_request_policy.proto b/app/src/main/proto/sabr/videostreaming/next_request_policy.proto new file mode 100644 index 00000000..2df3003c --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/next_request_policy.proto @@ -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; +} diff --git a/app/src/main/proto/sabr/videostreaming/range.proto b/app/src/main/proto/sabr/videostreaming/range.proto new file mode 100644 index 00000000..1b99843e --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/range.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/reload_player_response.proto b/app/src/main/proto/sabr/videostreaming/reload_player_response.proto new file mode 100644 index 00000000..3b01f20a --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/reload_player_response.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/sabr_context_sending_policy.proto b/app/src/main/proto/sabr/videostreaming/sabr_context_sending_policy.proto new file mode 100644 index 00000000..1f47d6dc --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/sabr_context_sending_policy.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/sabr_context_update.proto b/app/src/main/proto/sabr/videostreaming/sabr_context_update.proto new file mode 100644 index 00000000..8a27d646 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/sabr_context_update.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/sabr_error.proto b/app/src/main/proto/sabr/videostreaming/sabr_error.proto new file mode 100644 index 00000000..c1985796 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/sabr_error.proto @@ -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; +} diff --git a/app/src/main/proto/sabr/videostreaming/sabr_redirect.proto b/app/src/main/proto/sabr/videostreaming/sabr_redirect.proto new file mode 100644 index 00000000..fb49dde9 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/sabr_redirect.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/sabr_seek.proto b/app/src/main/proto/sabr/videostreaming/sabr_seek.proto new file mode 100644 index 00000000..27a7fcae --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/sabr_seek.proto @@ -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; +} diff --git a/app/src/main/proto/sabr/videostreaming/stream_protection_status.proto b/app/src/main/proto/sabr/videostreaming/stream_protection_status.proto new file mode 100644 index 00000000..fb225e28 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/stream_protection_status.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/streamer_context.proto b/app/src/main/proto/sabr/videostreaming/streamer_context.proto new file mode 100644 index 00000000..21bfa835 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/streamer_context.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/time_range.proto b/app/src/main/proto/sabr/videostreaming/time_range.proto new file mode 100644 index 00000000..47201308 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/time_range.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/proto/sabr/videostreaming/video_playback_abr_request.proto b/app/src/main/proto/sabr/videostreaming/video_playback_abr_request.proto new file mode 100644 index 00000000..ec18bee1 --- /dev/null +++ b/app/src/main/proto/sabr/videostreaming/video_playback_abr_request.proto @@ -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; +} \ No newline at end of file