aboutsummaryrefslogtreecommitdiff
path: root/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java')
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java644
1 files changed, 644 insertions, 0 deletions
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
new file mode 100644
index 00000000..eb596e93
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
@@ -0,0 +1,644 @@
+/*
+ * Copyright (C) 2015 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.android.tv.tuner.exoplayer.buffer;
+
+import android.media.MediaFormat;
+import android.os.ConditionVariable;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Pair;
+
+import com.google.android.exoplayer.SampleHolder;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Manages {@link SampleChunk} objects.
+ * <p>
+ * The buffer manager can be disabled, while running, if the write throughput to the associated
+ * external storage is detected to be lower than a threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}".
+ * This leads to restarting playback flow.
+ */
+public class BufferManager {
+ private static final String TAG = "BufferManager";
+ private static final boolean DEBUG = false;
+
+ // Constants for the disk write speed checking
+ private static final long MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK =
+ 10L * 1024 * 1024; // Checks for every 10M disk write
+ private static final int MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK = 15 * 1024;
+ private static final int MAXIMUM_SPEED_CHECK_COUNT = 5; // Checks only 5 times
+ private static final int MINIMUM_DISK_WRITE_SPEED_MBPS = 3; // 3 Megabytes per second
+
+ private final SampleChunk.SampleChunkCreator mSampleChunkCreator;
+ // Maps from track name to a map which maps from starting position to {@link SampleChunk}.
+ private final Map<String, SortedMap<Long, SampleChunk>> mChunkMap = new ArrayMap<>();
+ private final Map<String, Long> mStartPositionMap = new ArrayMap<>();
+ private final Map<String, ChunkEvictedListener> mEvictListeners = new ArrayMap<>();
+ private final StorageManager mStorageManager;
+ private long mBufferSize = 0;
+ private final EvictChunkQueueMap mPendingDelete = new EvictChunkQueueMap();
+ private final SampleChunk.ChunkCallback mChunkCallback = new SampleChunk.ChunkCallback() {
+ @Override
+ public void onChunkWrite(SampleChunk chunk) {
+ mBufferSize += chunk.getSize();
+ }
+
+ @Override
+ public void onChunkDelete(SampleChunk chunk) {
+ mBufferSize -= chunk.getSize();
+ }
+ };
+
+ private volatile boolean mClosed = false;
+ private int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK;
+ private long mTotalWriteSize;
+ private long mTotalWriteTimeNs;
+ private float mWriteBandwidth = 0.0f;
+ private volatile int mSpeedCheckCount;
+ private boolean mDisabled = false;
+
+ public interface ChunkEvictedListener {
+ void onChunkEvicted(String id, long createdTimeMs);
+ }
+ /**
+ * Handles I/O
+ * between BufferManager and {@link SampleExtractor}.
+ */
+ public interface SampleBuffer {
+
+ /**
+ * Initializes SampleBuffer.
+ * @param Ids track identifiers for storage read/write.
+ * @param mediaFormats meta-data for each track.
+ * @throws IOException
+ */
+ void init(@NonNull List<String> Ids,
+ @NonNull List<com.google.android.exoplayer.MediaFormat> mediaFormats)
+ throws IOException;
+
+ /**
+ * Selects the track {@code index} for reading sample data.
+ */
+ void selectTrack(int index);
+
+ /**
+ * Deselects the track at {@code index},
+ * so that no more samples will be read from the track.
+ */
+ void deselectTrack(int index);
+
+ /**
+ * Writes sample to storage.
+ *
+ * @param index track index
+ * @param sample sample to write at storage
+ * @param conditionVariable notifies the completion of writing sample.
+ * @throws IOException
+ */
+ void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException;
+
+ /**
+ * Checks whether storage write speed is slow.
+ */
+ boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs);
+
+ /**
+ * Handles when write speed is slow.
+ * @throws IOException
+ */
+ void handleWriteSpeedSlow() throws IOException;
+
+ /**
+ * Sets the flag when EoS was reached.
+ */
+ void setEos();
+
+ /**
+ * Reads the next sample in the track at index {@code track} into {@code sampleHolder},
+ * returning {@link com.google.android.exoplayer.SampleSource#SAMPLE_READ}
+ * if it is available.
+ * If the next sample is not available,
+ * returns {@link com.google.android.exoplayer.SampleSource#NOTHING_READ}.
+ */
+ int readSample(int index, SampleHolder outSample);
+
+ /**
+ * Seeks to the specified time in microseconds.
+ */
+ void seekTo(long positionUs);
+
+ /**
+ * Returns an estimate of the position up to which data is buffered.
+ */
+ long getBufferedPositionUs();
+
+ /**
+ * Returns whether there is buffered data.
+ */
+ boolean continueBuffering(long positionUs);
+
+ /**
+ * Cleans up and releases everything.
+ * @throws IOException
+ */
+ void release() throws IOException;
+ }
+
+ /**
+ * Storage configuration and policy manager for {@link BufferManager}
+ */
+ public interface StorageManager {
+
+ /**
+ * Provides eligible storage directory for {@link BufferManager}.
+ *
+ * @return a directory to save buffer(chunks) and meta files
+ */
+ File getBufferDir();
+
+ /**
+ * Cleans up storage.
+ */
+ void clearStorage();
+
+ /**
+ * Informs whether the storage is used for persistent use. (eg. dvr recording/play)
+ *
+ * @return {@code true} if stored files are persistent
+ */
+ boolean isPersistent();
+
+ /**
+ * Informs whether the storage usage exceeds pre-determined size.
+ *
+ * @param bufferSize the current total usage of Storage in bytes.
+ * @param pendingDelete the current storage usage which will be deleted in near future by
+ * bytes
+ * @return {@code true} if it reached pre-determined max size
+ */
+ boolean reachedStorageMax(long bufferSize, long pendingDelete);
+
+ /**
+ * Informs whether the storage has enough remained space.
+ *
+ * @param pendingDelete the current storage usage which will be deleted in near future by
+ * bytes
+ * @return {@code true} if it has enough space
+ */
+ boolean hasEnoughBuffer(long pendingDelete);
+
+ /**
+ * Reads track name & {@link MediaFormat} from storage.
+ *
+ * @param isAudio {@code true} if it is for audio track
+ * @return {@link Pair} of track name & {@link MediaFormat}
+ * @throws IOException
+ */
+ Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException;
+
+ /**
+ * Reads sample indexes for each written sample from storage.
+ *
+ * @param trackId track name
+ * @return indexes of the specified track
+ * @throws IOException
+ */
+ ArrayList<Long> readIndexFile(String trackId) throws IOException;
+
+ /**
+ * Writes track information to storage.
+ *
+ * @param trackId track name
+ * @param format {@link android.media.MediaFormat} of the track
+ * @param isAudio {@code true} if it is for audio track
+ * @throws IOException
+ */
+ void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio)
+ throws IOException;
+
+ /**
+ * Writes index file to storage.
+ *
+ * @param trackName track name
+ * @param index {@link SampleChunk} container
+ * @throws IOException
+ */
+ void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index)
+ throws IOException;
+ }
+
+ private static class EvictChunkQueueMap {
+ private final Map<String, LinkedList<SampleChunk>> mEvictMap = new ArrayMap<>();
+ private long mSize;
+
+ private void init(String key) {
+ mEvictMap.put(key, new LinkedList<>());
+ }
+
+ private void add(String key, SampleChunk chunk) {
+ LinkedList<SampleChunk> queue = mEvictMap.get(key);
+ if (queue != null) {
+ mSize += chunk.getSize();
+ queue.add(chunk);
+ }
+ }
+
+ private SampleChunk poll(String key, long startPositionUs) {
+ LinkedList<SampleChunk> queue = mEvictMap.get(key);
+ if (queue != null) {
+ SampleChunk chunk = queue.peek();
+ if (chunk != null && chunk.getStartPositionUs() < startPositionUs) {
+ mSize -= chunk.getSize();
+ return queue.poll();
+ }
+ }
+ return null;
+ }
+
+ private long getSize() {
+ return mSize;
+ }
+
+ private void release() {
+ for (Map.Entry<String, LinkedList<SampleChunk>> entry : mEvictMap.entrySet()) {
+ for (SampleChunk chunk : entry.getValue()) {
+ SampleChunk.IoState.release(chunk, true);
+ }
+ }
+ mEvictMap.clear();
+ mSize = 0;
+ }
+ }
+
+ public BufferManager(StorageManager storageManager) {
+ this(storageManager, new SampleChunk.SampleChunkCreator());
+ }
+
+ public BufferManager(StorageManager storageManager,
+ SampleChunk.SampleChunkCreator sampleChunkCreator) {
+ mStorageManager = storageManager;
+ mSampleChunkCreator = sampleChunkCreator;
+ clearBuffer(true);
+ }
+
+ public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) {
+ mEvictListeners.put(id, listener);
+ }
+
+ public void unregisterChunkEvictedListener(String id) {
+ mEvictListeners.remove(id);
+ }
+
+ private void clearBuffer(boolean deleteFiles) {
+ mChunkMap.clear();
+ if (deleteFiles) {
+ mStorageManager.clearStorage();
+ }
+ mBufferSize = 0;
+ }
+
+ private static String getFileName(String id, long positionUs) {
+ return String.format(Locale.ENGLISH, "%s_%016x.chunk", id, positionUs);
+ }
+
+ /**
+ * Creates a new {@link SampleChunk} for caching samples.
+ *
+ * @param id the name of the track
+ * @param positionUs starting position of the {@link SampleChunk} in micro seconds.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @return returns the created {@link SampleChunk}.
+ * @throws IOException
+ */
+ public SampleChunk createNewWriteFile(String id, long positionUs,
+ SamplePool samplePool) throws IOException {
+ if (!maybeEvictChunk()) {
+ throw new IOException("Not enough storage space");
+ }
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(id);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(id, map);
+ mStartPositionMap.put(id, positionUs);
+ mPendingDelete.init(id);
+ }
+ File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs));
+ SampleChunk sampleChunk = mSampleChunkCreator.createSampleChunk(samplePool, file,
+ positionUs, mChunkCallback);
+ map.put(positionUs, sampleChunk);
+ return sampleChunk;
+ }
+
+ /**
+ * Loads a track using {@link BufferManager.StorageManager}.
+ *
+ * @param trackId the name of the track.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @throws IOException
+ */
+ public void loadTrackFromStorage(String trackId, SamplePool samplePool) throws IOException {
+ ArrayList<Long> keyPositions = mStorageManager.readIndexFile(trackId);
+ long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0) : 0;
+
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(trackId);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(trackId, map);
+ mStartPositionMap.put(trackId, startPositionUs);
+ mPendingDelete.init(trackId);
+ }
+ SampleChunk chunk = null;
+ for (long positionUs: keyPositions) {
+ chunk = mSampleChunkCreator.loadSampleChunkFromFile(samplePool,
+ mStorageManager.getBufferDir(), getFileName(trackId, positionUs), positionUs,
+ mChunkCallback, chunk);
+ map.put(positionUs, chunk);
+ }
+ }
+
+ /**
+ * Finds a {@link SampleChunk} for the specified track name and the position.
+ *
+ * @param id the name of the track.
+ * @param positionUs the position.
+ * @return returns the found {@link SampleChunk}.
+ */
+ public SampleChunk getReadFile(String id, long positionUs) {
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(id);
+ if (map == null) {
+ return null;
+ }
+ SampleChunk sampleChunk;
+ SortedMap<Long, SampleChunk> headMap = map.headMap(positionUs + 1);
+ if (!headMap.isEmpty()) {
+ sampleChunk = headMap.get(headMap.lastKey());
+ } else {
+ sampleChunk = map.get(map.firstKey());
+ }
+ return sampleChunk;
+ }
+
+ /**
+ * Evicts chunks which are ready to be evicted for the specified track
+ *
+ * @param id the specified track
+ * @param earlierThanPositionUs the start position of the {@link SampleChunk}
+ * should be earlier than
+ */
+ public void evictChunks(String id, long earlierThanPositionUs) {
+ SampleChunk chunk = null;
+ while ((chunk = mPendingDelete.poll(id, earlierThanPositionUs)) != null) {
+ SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent()) ;
+ }
+ }
+
+ /**
+ * Returns the start position of the specified track in micro seconds.
+ *
+ * @param id the specified track
+ */
+ public long getStartPositionUs(String id) {
+ Long ret = mStartPositionMap.get(id);
+ return ret == null ? 0 : ret;
+ }
+
+ private boolean maybeEvictChunk() {
+ long pendingDelete = mPendingDelete.getSize();
+ while (mStorageManager.reachedStorageMax(mBufferSize, pendingDelete)
+ || !mStorageManager.hasEnoughBuffer(pendingDelete)) {
+ if (mStorageManager.isPersistent()) {
+ // Since chunks are persistent, we cannot evict chunks.
+ return false;
+ }
+ SortedMap<Long, SampleChunk> earliestChunkMap = null;
+ SampleChunk earliestChunk = null;
+ String earliestChunkId = null;
+ for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
+ SortedMap<Long, SampleChunk> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ SampleChunk chunk = map.get(map.firstKey());
+ if (earliestChunk == null
+ || chunk.getCreatedTimeMs() < earliestChunk.getCreatedTimeMs()) {
+ earliestChunkMap = map;
+ earliestChunk = chunk;
+ earliestChunkId = entry.getKey();
+ }
+ }
+ if (earliestChunk == null) {
+ break;
+ }
+ mPendingDelete.add(earliestChunkId, earliestChunk);
+ earliestChunkMap.remove(earliestChunk.getStartPositionUs());
+ if (DEBUG) {
+ Log.d(TAG, String.format("bufferSize = %d; pendingDelete = %b; "
+ + "earliestChunk size = %d; %s@%d (%s)",
+ mBufferSize, pendingDelete, earliestChunk.getSize(), earliestChunkId,
+ earliestChunk.getStartPositionUs(),
+ Utils.toIsoDateTimeString(earliestChunk.getCreatedTimeMs())));
+ }
+ ChunkEvictedListener listener = mEvictListeners.get(earliestChunkId);
+ if (listener != null) {
+ listener.onChunkEvicted(earliestChunkId, earliestChunk.getCreatedTimeMs());
+ }
+ pendingDelete = mPendingDelete.getSize();
+ }
+ for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
+ SortedMap<Long, SampleChunk> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ mStartPositionMap.put(entry.getKey(), map.firstKey());
+ }
+ return true;
+ }
+
+ /**
+ * Reads track information which includes {@link MediaFormat}.
+ *
+ * @return returns all track information which is found by {@link BufferManager.StorageManager}.
+ * @throws IOException
+ */
+ public ArrayList<Pair<String, MediaFormat>> readTrackInfoFiles() throws IOException {
+ ArrayList<Pair<String, MediaFormat>> trackInfos = new ArrayList<>();
+ try {
+ trackInfos.add(mStorageManager.readTrackInfoFile(false));
+ } catch (FileNotFoundException e) {
+ // There can be a single track only recording. (eg. audio-only, video-only)
+ // So the exception should not stop the read.
+ }
+ try {
+ trackInfos.add(mStorageManager.readTrackInfoFile(true));
+ } catch (FileNotFoundException e) {
+ // See above catch block.
+ }
+ return trackInfos;
+ }
+
+ /**
+ * Writes track information and index information for all tracks.
+ *
+ * @param audio audio information.
+ * @param video video information.
+ * @throws IOException
+ */
+ public void writeMetaFiles(Pair<String, MediaFormat> audio, Pair<String, MediaFormat> video)
+ throws IOException {
+ if (audio != null) {
+ mStorageManager.writeTrackInfoFile(audio.first, audio.second, true);
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(audio.first);
+ if (map == null) {
+ throw new IOException("Audio track index missing");
+ }
+ mStorageManager.writeIndexFile(audio.first, map);
+ }
+ if (video != null) {
+ mStorageManager.writeTrackInfoFile(video.first, video.second, false);
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(video.first);
+ if (map == null) {
+ throw new IOException("Video track index missing");
+ }
+ mStorageManager.writeIndexFile(video.first, map);
+ }
+ }
+
+ /**
+ * Marks it is closed and it is not used anymore.
+ */
+ public void close() {
+ // Clean-up may happen after this is called.
+ mClosed = true;
+ }
+
+ /**
+ * Releases all the resources.
+ */
+ public void release() {
+ mPendingDelete.release();
+ for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
+ for (SampleChunk chunk : entry.getValue().values()) {
+ SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent());
+ }
+ }
+ mChunkMap.clear();
+ if (mClosed) {
+ clearBuffer(!mStorageManager.isPersistent());
+ }
+ }
+
+ private void resetWriteStat(float writeBandwidth) {
+ mWriteBandwidth = writeBandwidth;
+ mTotalWriteSize = 0;
+ mTotalWriteTimeNs = 0;
+ }
+
+ /**
+ * Adds a disk write sample size to calculate the average disk write bandwidth.
+ */
+ public void addWriteStat(long size, long timeNs) {
+ if (size >= mMinSampleSizeForSpeedCheck) {
+ mTotalWriteSize += size;
+ mTotalWriteTimeNs += timeNs;
+ }
+ }
+
+ /**
+ * Returns if the average disk write bandwidth is slower than
+ * threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}.
+ */
+ public boolean isWriteSlow() {
+ if (mTotalWriteSize < MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK) {
+ return false;
+ }
+
+ // Checks write speed for only MAXIMUM_SPEED_CHECK_COUNT times to ignore outliers
+ // by temporary system overloading during the playback.
+ if (mSpeedCheckCount > MAXIMUM_SPEED_CHECK_COUNT) {
+ return false;
+ }
+ mSpeedCheckCount++;
+ float megabytePerSecond = calculateWriteBandwidth();
+ resetWriteStat(megabytePerSecond);
+ if (DEBUG) {
+ Log.d(TAG, "Measured disk write performance: " + megabytePerSecond + "MBps");
+ }
+ return megabytePerSecond < MINIMUM_DISK_WRITE_SPEED_MBPS;
+ }
+
+ /**
+ * Returns recent write bandwidth in MBps. If recent bandwidth is not available,
+ * returns {float -1.0f}.
+ */
+ public float getWriteBandwidth() {
+ return mWriteBandwidth == 0.0f ? -1.0f : mWriteBandwidth;
+ }
+
+ private float calculateWriteBandwidth() {
+ if (mTotalWriteTimeNs == 0) {
+ return -1;
+ }
+ return ((float) mTotalWriteSize * 1000 / mTotalWriteTimeNs);
+ }
+
+ /**
+ * Marks {@link BufferManager} object disabled to prevent it from the future use.
+ */
+ public void disable() {
+ mDisabled = true;
+ }
+
+ /**
+ * Returns if {@link BufferManager} object is disabled.
+ */
+ public boolean isDisabled() {
+ return mDisabled;
+ }
+
+ /**
+ * Returns if {@link BufferManager} has checked the write speed,
+ * which is suitable for Trickplay.
+ */
+ @VisibleForTesting
+ public boolean hasSpeedCheckDone() {
+ return mSpeedCheckCount > 0;
+ }
+
+ /**
+ * Sets minimum sample size for write speed check.
+ * @param sampleSize minimum sample size for write speed check.
+ */
+ @VisibleForTesting
+ public void setMinimumSampleSizeForSpeedCheck(int sampleSize) {
+ mMinSampleSizeForSpeedCheck = sampleSize;
+ }
+}