CachedRegionTracker.java
/*
* 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 androidx.media3.exoplayer.upstream;
import androidx.annotation.Nullable;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.cache.Cache;
import androidx.media3.datasource.cache.CacheSpan;
import androidx.media3.extractor.ChunkIndex;
import java.util.Arrays;
import java.util.Iterator;
import java.util.NavigableSet;
import java.util.TreeSet;
/**
* Utility class for efficiently tracking regions of data that are stored in a {@link Cache} for a
* given cache key.
*/
@UnstableApi
public final class CachedRegionTracker implements Cache.Listener {
private static final String TAG = "CachedRegionTracker";
public static final int NOT_CACHED = -1;
public static final int CACHED_TO_END = -2;
private final Cache cache;
private final String cacheKey;
private final ChunkIndex chunkIndex;
private final TreeSet<Region> regions;
private final Region lookupRegion;
public CachedRegionTracker(Cache cache, String cacheKey, ChunkIndex chunkIndex) {
this.cache = cache;
this.cacheKey = cacheKey;
this.chunkIndex = chunkIndex;
this.regions = new TreeSet<>();
this.lookupRegion = new Region(0, 0);
synchronized (this) {
NavigableSet<CacheSpan> cacheSpans = cache.addListener(cacheKey, this);
// Merge the spans into regions. mergeSpan is more efficient when merging from high to low,
// which is why a descending iterator is used here.
Iterator<CacheSpan> spanIterator = cacheSpans.descendingIterator();
while (spanIterator.hasNext()) {
CacheSpan span = spanIterator.next();
mergeSpan(span);
}
}
}
public void release() {
cache.removeListener(cacheKey, this);
}
/**
* When provided with a byte offset, this method locates the cached region within which the offset
* falls, and returns the approximate end position in milliseconds of that region. If the byte
* offset does not fall within a cached region then {@link #NOT_CACHED} is returned. If the cached
* region extends to the end of the stream, {@link #CACHED_TO_END} is returned.
*
* @param byteOffset The byte offset in the underlying stream.
* @return The end position of the corresponding cache region, {@link #NOT_CACHED}, or {@link
* #CACHED_TO_END}.
*/
public synchronized int getRegionEndTimeMs(long byteOffset) {
lookupRegion.startOffset = byteOffset;
@Nullable Region floorRegion = regions.floor(lookupRegion);
if (floorRegion == null
|| byteOffset > floorRegion.endOffset
|| floorRegion.endOffsetIndex == -1) {
return NOT_CACHED;
}
int index = floorRegion.endOffsetIndex;
if (index == chunkIndex.length - 1
&& floorRegion.endOffset == (chunkIndex.offsets[index] + chunkIndex.sizes[index])) {
return CACHED_TO_END;
}
long segmentFractionUs =
(chunkIndex.durationsUs[index] * (floorRegion.endOffset - chunkIndex.offsets[index]))
/ chunkIndex.sizes[index];
return (int) ((chunkIndex.timesUs[index] + segmentFractionUs) / 1000);
}
@Override
public synchronized void onSpanAdded(Cache cache, CacheSpan span) {
mergeSpan(span);
}
@Override
public synchronized void onSpanRemoved(Cache cache, CacheSpan span) {
Region removedRegion = new Region(span.position, span.position + span.length);
// Look up a region this span falls into.
@Nullable Region floorRegion = regions.floor(removedRegion);
if (floorRegion == null) {
Log.e(TAG, "Removed a span we were not aware of");
return;
}
// Remove it.
regions.remove(floorRegion);
// Add new floor and ceiling regions, if necessary.
if (floorRegion.startOffset < removedRegion.startOffset) {
Region newFloorRegion = new Region(floorRegion.startOffset, removedRegion.startOffset);
int index = Arrays.binarySearch(chunkIndex.offsets, newFloorRegion.endOffset);
newFloorRegion.endOffsetIndex = index < 0 ? -index - 2 : index;
regions.add(newFloorRegion);
}
if (floorRegion.endOffset > removedRegion.endOffset) {
Region newCeilingRegion = new Region(removedRegion.endOffset + 1, floorRegion.endOffset);
newCeilingRegion.endOffsetIndex = floorRegion.endOffsetIndex;
regions.add(newCeilingRegion);
}
}
@Override
public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) {
// Do nothing.
}
private void mergeSpan(CacheSpan span) {
Region newRegion = new Region(span.position, span.position + span.length);
@Nullable Region floorRegion = regions.floor(newRegion);
@Nullable Region ceilingRegion = regions.ceiling(newRegion);
boolean floorConnects = regionsConnect(floorRegion, newRegion);
boolean ceilingConnects = regionsConnect(newRegion, ceilingRegion);
if (ceilingConnects) {
if (floorConnects) {
// Extend floorRegion to cover both newRegion and ceilingRegion.
floorRegion.endOffset = ceilingRegion.endOffset;
floorRegion.endOffsetIndex = ceilingRegion.endOffsetIndex;
} else {
// Extend newRegion to cover ceilingRegion. Add it.
newRegion.endOffset = ceilingRegion.endOffset;
newRegion.endOffsetIndex = ceilingRegion.endOffsetIndex;
regions.add(newRegion);
}
regions.remove(ceilingRegion);
} else if (floorConnects) {
// Extend floorRegion to the right to cover newRegion.
floorRegion.endOffset = newRegion.endOffset;
int index = floorRegion.endOffsetIndex;
while (index < chunkIndex.length - 1
&& (chunkIndex.offsets[index + 1] <= floorRegion.endOffset)) {
index++;
}
floorRegion.endOffsetIndex = index;
} else {
// This is a new region.
int index = Arrays.binarySearch(chunkIndex.offsets, newRegion.endOffset);
newRegion.endOffsetIndex = index < 0 ? -index - 2 : index;
regions.add(newRegion);
}
}
private boolean regionsConnect(@Nullable Region lower, @Nullable Region upper) {
return lower != null && upper != null && lower.endOffset == upper.startOffset;
}
private static class Region implements Comparable<Region> {
/** The first byte of the region (inclusive). */
public long startOffset;
/** End offset of the region (exclusive). */
public long endOffset;
/**
* The index in chunkIndex that contains the end offset. May be -1 if the end offset comes
* before the start of the first media chunk (i.e. if the end offset is within the stream
* header).
*/
public int endOffsetIndex;
public Region(long position, long endOffset) {
this.startOffset = position;
this.endOffset = endOffset;
}
@Override
public int compareTo(Region another) {
return Util.compareLong(startOffset, another.startOffset);
}
}
}