aboutsummaryrefslogtreecommitdiff
path: root/tuner/src/com/android/tv/tuner/exoplayer
diff options
context:
space:
mode:
Diffstat (limited to 'tuner/src/com/android/tv/tuner/exoplayer')
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java305
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java41
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java632
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java139
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java672
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java77
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java345
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java195
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java126
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java131
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java94
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java69
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java140
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java174
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java233
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java739
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java94
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java683
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java391
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java303
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java433
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java464
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java67
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java72
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java177
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java145
-rw-r--r--tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java323
27 files changed, 7264 insertions, 0 deletions
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java
new file mode 100644
index 00000000..1f48c45b
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java
@@ -0,0 +1,305 @@
+/*
+ * 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;
+
+import android.util.Log;
+import com.android.tv.tuner.cc.Cea708Parser;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaClock;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.util.Assertions;
+import java.io.IOException;
+
+/** A {@link TrackRenderer} for CEA-708 textual subtitles. */
+public class Cea708TextTrackRenderer extends TrackRenderer
+ implements Cea708Parser.OnCea708ParserListener {
+ private static final String TAG = "Cea708TextTrackRenderer";
+ private static final boolean DEBUG = false;
+
+ public static final int MSG_SERVICE_NUMBER = 1;
+ public static final int MSG_ENABLE_CLOSED_CAPTION = 2;
+
+ // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps.
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 9600 / 8;
+
+ private final SampleSource.SampleSourceReader mSource;
+ private final SampleHolder mSampleHolder;
+ private final MediaFormatHolder mFormatHolder;
+ private int mServiceNumber;
+ private boolean mInputStreamEnded;
+ private long mCurrentPositionUs;
+ private long mPresentationTimeUs;
+ private int mTrackIndex;
+ private boolean mRenderingDisabled;
+ private Cea708Parser mCea708Parser;
+ private CcListener mCcListener;
+
+ public interface CcListener {
+ void emitEvent(CaptionEvent captionEvent);
+
+ void clearCaption();
+
+ void discoverServiceNumber(int serviceNumber);
+ }
+
+ public Cea708TextTrackRenderer(SampleSource source) {
+ mSource = source.register();
+ mTrackIndex = -1;
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
+ mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE);
+ mFormatHolder = new MediaFormatHolder();
+ }
+
+ @Override
+ protected MediaClock getMediaClock() {
+ return null;
+ }
+
+ private boolean handlesMimeType(String mimeType) {
+ return mimeType.equals(MpegTsSampleExtractor.MIMETYPE_TEXT_CEA_708);
+ }
+
+ @Override
+ protected boolean doPrepare(long positionUs) throws ExoPlaybackException {
+ boolean sourcePrepared = mSource.prepare(positionUs);
+ if (!sourcePrepared) {
+ return false;
+ }
+ int trackCount = mSource.getTrackCount();
+ for (int i = 0; i < trackCount; ++i) {
+ MediaFormat trackFormat = mSource.getFormat(i);
+ if (handlesMimeType(trackFormat.mimeType)) {
+ mTrackIndex = i;
+ clearDecodeState();
+ return true;
+ }
+ }
+ // TODO: Check this case. (Source do not have the proper mime type.)
+ return true;
+ }
+
+ @Override
+ protected void onEnabled(int track, long positionUs, boolean joining) {
+ Assertions.checkArgument(mTrackIndex != -1 && track == 0);
+ mSource.enable(mTrackIndex, positionUs);
+ mInputStreamEnded = false;
+ mPresentationTimeUs = positionUs;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+
+ @Override
+ protected void onDisabled() {
+ mSource.disable(mTrackIndex);
+ }
+
+ @Override
+ protected void onReleased() {
+ mSource.release();
+ mCea708Parser = null;
+ }
+
+ @Override
+ protected boolean isEnded() {
+ return mInputStreamEnded;
+ }
+
+ @Override
+ protected boolean isReady() {
+ // Since this track will be fed by {@link VideoTrackRenderer},
+ // it is not required to control transition between ready state and buffering state.
+ return true;
+ }
+
+ @Override
+ protected int getTrackCount() {
+ return mTrackIndex < 0 ? 0 : 1;
+ }
+
+ @Override
+ protected MediaFormat getFormat(int track) {
+ Assertions.checkArgument(mTrackIndex != -1 && track == 0);
+ return mSource.getFormat(mTrackIndex);
+ }
+
+ @Override
+ protected void maybeThrowError() throws ExoPlaybackException {
+ try {
+ mSource.maybeThrowError();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ @Override
+ protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
+ try {
+ mPresentationTimeUs = positionUs;
+ if (!mInputStreamEnded) {
+ processOutput();
+ feedInputBuffer();
+ }
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ private boolean processOutput() {
+ return !mInputStreamEnded
+ && mCea708Parser != null
+ && mCea708Parser.processClosedCaptions(mPresentationTimeUs);
+ }
+
+ private boolean feedInputBuffer() throws IOException, ExoPlaybackException {
+ if (mInputStreamEnded) {
+ return false;
+ }
+ long discontinuity = mSource.readDiscontinuity(mTrackIndex);
+ if (discontinuity != SampleSource.NO_DISCONTINUITY) {
+ if (DEBUG) {
+ Log.d(TAG, "Read discontinuity happened");
+ }
+
+ // TODO: handle input discontinuity for trickplay.
+ clearDecodeState();
+ mPresentationTimeUs = discontinuity;
+ return false;
+ }
+ mSampleHolder.data.clear();
+ mSampleHolder.size = 0;
+ int result =
+ mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, mSampleHolder);
+ switch (result) {
+ case SampleSource.NOTHING_READ:
+ {
+ return false;
+ }
+ case SampleSource.FORMAT_READ:
+ {
+ if (DEBUG) {
+ Log.i(TAG, "Format was read again");
+ }
+ return true;
+ }
+ case SampleSource.END_OF_STREAM:
+ {
+ if (DEBUG) {
+ Log.i(TAG, "End of stream from SampleSource");
+ }
+ mInputStreamEnded = true;
+ return false;
+ }
+ case SampleSource.SAMPLE_READ:
+ {
+ mSampleHolder.data.flip();
+ if (mCea708Parser != null && !mRenderingDisabled) {
+ mCea708Parser.parseClosedCaption(mSampleHolder.data, mSampleHolder.timeUs);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void clearDecodeState() {
+ mCea708Parser = new Cea708Parser();
+ mCea708Parser.setListener(this);
+ mCea708Parser.setListenServiceNumber(mServiceNumber);
+ }
+
+ @Override
+ protected long getDurationUs() {
+ return mSource.getFormat(mTrackIndex).durationUs;
+ }
+
+ @Override
+ protected long getBufferedPositionUs() {
+ return mSource.getBufferedPositionUs();
+ }
+
+ @Override
+ protected void seekTo(long currentPositionUs) throws ExoPlaybackException {
+ mSource.seekToUs(currentPositionUs);
+ mInputStreamEnded = false;
+ mPresentationTimeUs = currentPositionUs;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+
+ @Override
+ protected void onStarted() {
+ // do nothing.
+ }
+
+ @Override
+ protected void onStopped() {
+ // do nothing.
+ }
+
+ private void setServiceNumber(int serviceNumber) {
+ mServiceNumber = serviceNumber;
+ if (mCea708Parser != null) {
+ mCea708Parser.setListenServiceNumber(serviceNumber);
+ }
+ }
+
+ @Override
+ public void emitEvent(CaptionEvent event) {
+ if (mCcListener != null) {
+ mCcListener.emitEvent(event);
+ }
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ if (mCcListener != null) {
+ mCcListener.discoverServiceNumber(serviceNumber);
+ }
+ }
+
+ public void setCcListener(CcListener ccListener) {
+ mCcListener = ccListener;
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ switch (messageType) {
+ case MSG_SERVICE_NUMBER:
+ setServiceNumber((int) message);
+ break;
+ case MSG_ENABLE_CLOSED_CAPTION:
+ boolean renderingDisabled = (Boolean) message == false;
+ if (mRenderingDisabled != renderingDisabled) {
+ mRenderingDisabled = renderingDisabled;
+ if (mRenderingDisabled) {
+ if (mCea708Parser != null) {
+ mCea708Parser.clear();
+ }
+ if (mCcListener != null) {
+ mCcListener.clearCaption();
+ }
+ }
+ }
+ break;
+ default:
+ super.handleMessage(messageType, message);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java
new file mode 100644
index 00000000..dc08c072
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java
@@ -0,0 +1,41 @@
+/*
+ * 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.android.tv.tuner.exoplayer;
+
+import com.google.android.exoplayer2.extractor.Extractor;
+import com.google.android.exoplayer2.extractor.ExtractorsFactory;
+import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory;
+import com.google.android.exoplayer2.extractor.ts.TsExtractor;
+import com.google.android.exoplayer2.util.TimestampAdjuster;
+
+/**
+ * Extractor factory, mainly aim at create TsExtractor with FLAG_ALLOW_NON_IDR_KEYFRAMES flags for
+ * H.264 stream
+ */
+public final class ExoPlayerExtractorsFactory implements ExtractorsFactory {
+ @Override
+ public Extractor[] createExtractors() {
+ // Only create TsExtractor since we only target MPEG2TS stream.
+ Extractor[] extractors = {
+ new TsExtractor(
+ TsExtractor.MODE_SINGLE_PMT,
+ new TimestampAdjuster(0),
+ new DefaultTsPayloadReaderFactory(
+ DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES))
+ };
+ return extractors;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java
new file mode 100644
index 00000000..e10a2991
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java
@@ -0,0 +1,632 @@
+/*
+ * 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;
+
+import android.net.Uri;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+import android.support.annotation.VisibleForTesting;
+import android.util.Pair;
+import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer;
+import com.android.tv.tuner.exoplayer.buffer.SimpleSampleBuffer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.FormatHolder;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import com.google.android.exoplayer2.source.ExtractorMediaSource;
+import com.google.android.exoplayer2.source.ExtractorMediaSource.EventListener;
+import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.SampleStream;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.trackselection.FixedTrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelection;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.DefaultAllocator;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A class that extracts samples from a live broadcast stream while storing the sample on the disk.
+ * For demux, this class relies on {@link com.google.android.exoplayer.extractor.ts.TsExtractor}.
+ */
+public class ExoPlayerSampleExtractor implements SampleExtractor {
+ private static final String TAG = "ExoPlayerSampleExtracto";
+
+ private static final int INVALID_TRACK_INDEX = -1;
+ private final HandlerThread mSourceReaderThread;
+ private final long mId;
+
+ private final Handler.Callback mSourceReaderWorker;
+
+ private BufferManager.SampleBuffer mSampleBuffer;
+ private Handler mSourceReaderHandler;
+ private volatile boolean mPrepared;
+ private AtomicBoolean mOnCompletionCalled = new AtomicBoolean();
+ private IOException mExceptionOnPrepare;
+ private List<MediaFormat> mTrackFormats;
+ private int mVideoTrackIndex = INVALID_TRACK_INDEX;
+ private boolean mVideoTrackMet;
+ private long mBaseSamplePts = Long.MIN_VALUE;
+ private HashMap<Integer, Long> mLastExtractedPositionUsMap = new HashMap<>();
+ private final List<Pair<Integer, SampleHolder>> mPendingSamples = new ArrayList<>();
+ private OnCompletionListener mOnCompletionListener;
+ private Handler mOnCompletionListenerHandler;
+ private IOException mError;
+
+ public ExoPlayerSampleExtractor(
+ Uri uri,
+ final DataSource source,
+ BufferManager bufferManager,
+ PlaybackBufferListener bufferListener,
+ boolean isRecording) {
+ this(
+ uri,
+ source,
+ bufferManager,
+ bufferListener,
+ isRecording,
+ Looper.myLooper(),
+ new HandlerThread("SourceReaderThread"));
+ }
+
+ @VisibleForTesting
+ public ExoPlayerSampleExtractor(
+ Uri uri,
+ DataSource source,
+ BufferManager bufferManager,
+ PlaybackBufferListener bufferListener,
+ boolean isRecording,
+ Looper workerLooper,
+ HandlerThread sourceReaderThread) {
+ // It'll be used as a timeshift file chunk name's prefix.
+ mId = System.currentTimeMillis();
+
+ EventListener eventListener =
+ new EventListener() {
+ @Override
+ public void onLoadError(IOException error) {
+ mError = error;
+ }
+ };
+
+ mSourceReaderThread = sourceReaderThread;
+ mSourceReaderWorker =
+ new SourceReaderWorker(
+ new ExtractorMediaSource(
+ uri,
+ new com.google.android.exoplayer2.upstream.DataSource.Factory() {
+ @Override
+ public com.google.android.exoplayer2.upstream.DataSource
+ createDataSource() {
+ // Returns an adapter implementation for ExoPlayer V2
+ // DataSource interface.
+ return new com.google.android.exoplayer2.upstream
+ .DataSource() {
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ return source.open(
+ new com.google.android.exoplayer.upstream
+ .DataSpec(
+ dataSpec.uri,
+ dataSpec.postBody,
+ dataSpec.absoluteStreamPosition,
+ dataSpec.position,
+ dataSpec.length,
+ dataSpec.key,
+ dataSpec.flags));
+ }
+
+ @Override
+ public int read(
+ byte[] buffer, int offset, int readLength)
+ throws IOException {
+ return source.read(buffer, offset, readLength);
+ }
+
+ @Override
+ public Uri getUri() {
+ return null;
+ }
+
+ @Override
+ public void close() throws IOException {
+ source.close();
+ }
+ };
+ }
+ },
+ new ExoPlayerExtractorsFactory(),
+ new Handler(workerLooper),
+ eventListener));
+ if (isRecording) {
+ mSampleBuffer =
+ new RecordingSampleBuffer(
+ bufferManager,
+ bufferListener,
+ false,
+ RecordingSampleBuffer.BUFFER_REASON_RECORDING);
+ } else {
+ if (bufferManager == null) {
+ mSampleBuffer = new SimpleSampleBuffer(bufferListener);
+ } else {
+ mSampleBuffer =
+ new RecordingSampleBuffer(
+ bufferManager,
+ bufferListener,
+ true,
+ RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK);
+ }
+ }
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {
+ mOnCompletionListener = listener;
+ mOnCompletionListenerHandler = handler;
+ }
+
+ private class SourceReaderWorker implements Handler.Callback, MediaPeriod.Callback {
+ public static final int MSG_PREPARE = 1;
+ public static final int MSG_FETCH_SAMPLES = 2;
+ public static final int MSG_RELEASE = 3;
+ private static final int RETRY_INTERVAL_MS = 50;
+
+ private final MediaSource mSampleSource;
+ private MediaPeriod mMediaPeriod;
+ private SampleStream[] mStreams;
+ private boolean[] mTrackMetEos;
+ private boolean mMetEos = false;
+ private long mCurrentPosition;
+ private DecoderInputBuffer mDecoderInputBuffer;
+ private SampleHolder mSampleHolder;
+ private boolean mPrepareRequested;
+
+ public SourceReaderWorker(MediaSource sampleSource) {
+ mSampleSource = sampleSource;
+ mSampleSource.prepareSource(
+ null,
+ false,
+ new MediaSource.Listener() {
+ @Override
+ public void onSourceInfoRefreshed(
+ MediaSource source, Timeline timeline, Object manifest) {
+ // Dynamic stream change is not supported yet. b/28169263
+ // For now, this will cause EOS and playback reset.
+ }
+ });
+ mDecoderInputBuffer =
+ new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ }
+
+ MediaFormat convertFormat(Format format) {
+ if (format.sampleMimeType.startsWith("audio/")) {
+ return MediaFormat.createAudioFormat(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ format.maxInputSize,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US,
+ format.channelCount,
+ format.sampleRate,
+ format.initializationData,
+ format.language,
+ format.pcmEncoding);
+ } else if (format.sampleMimeType.startsWith("video/")) {
+ return MediaFormat.createVideoFormat(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ format.maxInputSize,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US,
+ format.width,
+ format.height,
+ format.initializationData,
+ format.rotationDegrees,
+ format.pixelWidthHeightRatio,
+ format.projectionData,
+ format.stereoMode,
+ null // colorInfo
+ );
+ } else if (format.sampleMimeType.endsWith("/cea-608")
+ || format.sampleMimeType.startsWith("text/")) {
+ return MediaFormat.createTextFormat(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US,
+ format.language);
+ } else {
+ return MediaFormat.createFormatForMimeType(
+ format.id,
+ format.sampleMimeType,
+ format.bitrate,
+ com.google.android.exoplayer.C.UNKNOWN_TIME_US);
+ }
+ }
+
+ @Override
+ public void onPrepared(MediaPeriod mediaPeriod) {
+ if (mMediaPeriod == null) {
+ // This instance is already released while the extractor is preparing.
+ return;
+ }
+ TrackSelection.Factory selectionFactory = new FixedTrackSelection.Factory();
+ TrackGroupArray trackGroupArray = mMediaPeriod.getTrackGroups();
+ TrackSelection[] selections = new TrackSelection[trackGroupArray.length];
+ for (int i = 0; i < selections.length; ++i) {
+ selections[i] = selectionFactory.createTrackSelection(trackGroupArray.get(i), 0);
+ }
+ boolean[] retain = new boolean[trackGroupArray.length];
+ boolean[] reset = new boolean[trackGroupArray.length];
+ mStreams = new SampleStream[trackGroupArray.length];
+ mMediaPeriod.selectTracks(selections, retain, mStreams, reset, 0);
+ if (mTrackFormats == null) {
+ int trackCount = trackGroupArray.length;
+ mTrackMetEos = new boolean[trackCount];
+ List<MediaFormat> trackFormats = new ArrayList<>();
+ int videoTrackCount = 0;
+ for (int i = 0; i < trackCount; i++) {
+ Format format = trackGroupArray.get(i).getFormat(0);
+ if (format.sampleMimeType.startsWith("video/")) {
+ videoTrackCount++;
+ mVideoTrackIndex = i;
+ }
+ trackFormats.add(convertFormat(format));
+ }
+ if (videoTrackCount > 1) {
+ // Disable dropping samples when there are multiple video tracks.
+ mVideoTrackIndex = INVALID_TRACK_INDEX;
+ }
+ mTrackFormats = trackFormats;
+ List<String> ids = new ArrayList<>();
+ for (int i = 0; i < mTrackFormats.size(); i++) {
+ ids.add(String.format(Locale.ENGLISH, "%s_%x", Long.toHexString(mId), i));
+ }
+ try {
+ mSampleBuffer.init(ids, mTrackFormats);
+ } catch (IOException e) {
+ // In this case, we will not schedule any further operation.
+ // mExceptionOnPrepare will be notified to ExoPlayer, and ExoPlayer will
+ // call release() eventually.
+ mExceptionOnPrepare = e;
+ return;
+ }
+ mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES);
+ mPrepared = true;
+ }
+ }
+
+ @Override
+ public void onContinueLoadingRequested(MediaPeriod source) {
+ source.continueLoading(mCurrentPosition);
+ }
+
+ @Override
+ public boolean handleMessage(Message message) {
+ switch (message.what) {
+ case MSG_PREPARE:
+ if (!mPrepareRequested) {
+ mPrepareRequested = true;
+ mMediaPeriod =
+ mSampleSource.createPeriod(
+ new MediaSource.MediaPeriodId(0),
+ new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE));
+ mMediaPeriod.prepare(this, 0);
+ try {
+ mMediaPeriod.maybeThrowPrepareError();
+ } catch (IOException e) {
+ mError = e;
+ }
+ }
+ return true;
+ case MSG_FETCH_SAMPLES:
+ boolean didSomething = false;
+ ConditionVariable conditionVariable = new ConditionVariable();
+ int trackCount = mStreams.length;
+ for (int i = 0; i < trackCount; ++i) {
+ if (!mTrackMetEos[i]
+ && C.RESULT_NOTHING_READ != fetchSample(i, conditionVariable)) {
+ if (mMetEos) {
+ // If mMetEos was on during fetchSample() due to an error,
+ // fetching from other tracks is not necessary.
+ break;
+ }
+ didSomething = true;
+ }
+ }
+ mMediaPeriod.continueLoading(mCurrentPosition);
+ if (!mMetEos) {
+ if (didSomething) {
+ mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES);
+ } else {
+ mSourceReaderHandler.sendEmptyMessageDelayed(
+ MSG_FETCH_SAMPLES, RETRY_INTERVAL_MS);
+ }
+ } else {
+ notifyCompletionIfNeeded(false);
+ }
+ return true;
+ case MSG_RELEASE:
+ if (mMediaPeriod != null) {
+ mSampleSource.releasePeriod(mMediaPeriod);
+ mSampleSource.releaseSource();
+ mMediaPeriod = null;
+ }
+ cleanUp();
+ mSourceReaderHandler.removeCallbacksAndMessages(null);
+ return true;
+ default: // fall out
+ }
+ return false;
+ }
+
+ private int fetchSample(int track, ConditionVariable conditionVariable) {
+ FormatHolder dummyFormatHolder = new FormatHolder();
+ mDecoderInputBuffer.clear();
+ int ret = mStreams[track].readData(dummyFormatHolder, mDecoderInputBuffer, false);
+ if (ret == C.RESULT_BUFFER_READ
+ // Double-check if the extractor provided the data to prevent NPE. b/33758354
+ && mDecoderInputBuffer.data != null) {
+ if (mCurrentPosition < mDecoderInputBuffer.timeUs) {
+ mCurrentPosition = mDecoderInputBuffer.timeUs;
+ }
+ if (mMediaPeriod != null) {
+ mMediaPeriod.discardBuffer(mCurrentPosition, false);
+ }
+ try {
+ Long lastExtractedPositionUs = mLastExtractedPositionUsMap.get(track);
+ if (lastExtractedPositionUs == null) {
+ mLastExtractedPositionUsMap.put(track, mDecoderInputBuffer.timeUs);
+ } else {
+ mLastExtractedPositionUsMap.put(
+ track,
+ Math.max(lastExtractedPositionUs, mDecoderInputBuffer.timeUs));
+ }
+ queueSample(track, conditionVariable);
+ } catch (IOException e) {
+ mLastExtractedPositionUsMap.clear();
+ mMetEos = true;
+ mSampleBuffer.setEos();
+ }
+ } else if (ret == C.RESULT_END_OF_INPUT) {
+ mTrackMetEos[track] = true;
+ for (int i = 0; i < mTrackMetEos.length; ++i) {
+ if (!mTrackMetEos[i]) {
+ break;
+ }
+ if (i == mTrackMetEos.length - 1) {
+ mMetEos = true;
+ mSampleBuffer.setEos();
+ }
+ }
+ }
+ // TODO: Handle C.RESULT_FORMAT_READ for dynamic resolution change. b/28169263
+ return ret;
+ }
+
+ private void queueSample(int index, ConditionVariable conditionVariable)
+ throws IOException {
+ if (mVideoTrackIndex != INVALID_TRACK_INDEX) {
+ if (!mVideoTrackMet) {
+ if (index != mVideoTrackIndex) {
+ SampleHolder sample =
+ new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ mSampleHolder.flags =
+ (mDecoderInputBuffer.isKeyFrame()
+ ? com.google.android.exoplayer.C.SAMPLE_FLAG_SYNC
+ : 0)
+ | (mDecoderInputBuffer.isDecodeOnly()
+ ? com.google
+ .android
+ .exoplayer
+ .C
+ .SAMPLE_FLAG_DECODE_ONLY
+ : 0);
+ sample.timeUs = mDecoderInputBuffer.timeUs;
+ sample.size = mDecoderInputBuffer.data.position();
+ sample.ensureSpaceForWrite(sample.size);
+ mDecoderInputBuffer.flip();
+ sample.data.position(0);
+ sample.data.put(mDecoderInputBuffer.data);
+ sample.data.flip();
+ mPendingSamples.add(new Pair<>(index, sample));
+ return;
+ }
+ mVideoTrackMet = true;
+ mBaseSamplePts =
+ mDecoderInputBuffer.timeUs
+ - MpegTsDefaultAudioTrackRenderer
+ .INITIAL_AUDIO_BUFFERING_TIME_US;
+ for (Pair<Integer, SampleHolder> pair : mPendingSamples) {
+ if (pair.second.timeUs >= mBaseSamplePts) {
+ mSampleBuffer.writeSample(pair.first, pair.second, conditionVariable);
+ }
+ }
+ mPendingSamples.clear();
+ } else {
+ if (mDecoderInputBuffer.timeUs < mBaseSamplePts && mVideoTrackIndex != index) {
+ return;
+ }
+ }
+ }
+ // Copy the decoder input to the sample holder.
+ mSampleHolder.clearData();
+ mSampleHolder.flags =
+ (mDecoderInputBuffer.isKeyFrame()
+ ? com.google.android.exoplayer.C.SAMPLE_FLAG_SYNC
+ : 0)
+ | (mDecoderInputBuffer.isDecodeOnly()
+ ? com.google.android.exoplayer.C.SAMPLE_FLAG_DECODE_ONLY
+ : 0);
+ mSampleHolder.timeUs = mDecoderInputBuffer.timeUs;
+ mSampleHolder.size = mDecoderInputBuffer.data.position();
+ mSampleHolder.ensureSpaceForWrite(mSampleHolder.size);
+ mDecoderInputBuffer.flip();
+ mSampleHolder.data.position(0);
+ mSampleHolder.data.put(mDecoderInputBuffer.data);
+ mSampleHolder.data.flip();
+ long writeStartTimeNs = SystemClock.elapsedRealtimeNanos();
+ mSampleBuffer.writeSample(index, mSampleHolder, conditionVariable);
+
+ // Checks whether the storage has enough bandwidth for recording samples.
+ if (mSampleBuffer.isWriteSpeedSlow(
+ mSampleHolder.size, SystemClock.elapsedRealtimeNanos() - writeStartTimeNs)) {
+ mSampleBuffer.handleWriteSpeedSlow();
+ }
+ }
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mError != null) {
+ IOException e = mError;
+ mError = null;
+ throw e;
+ }
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ if (!mSourceReaderThread.isAlive()) {
+ mSourceReaderThread.start();
+ mSourceReaderHandler =
+ new Handler(mSourceReaderThread.getLooper(), mSourceReaderWorker);
+ mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_PREPARE);
+ }
+ if (mExceptionOnPrepare != null) {
+ throw mExceptionOnPrepare;
+ }
+ return mPrepared;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ outMediaFormatHolder.format = mTrackFormats.get(track);
+ outMediaFormatHolder.drmInitData = null;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ mSampleBuffer.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ mSampleBuffer.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleBuffer.getBufferedPositionUs();
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleBuffer.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleBuffer.seekTo(positionUs);
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ return mSampleBuffer.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public void release() {
+ if (mSourceReaderThread.isAlive()) {
+ mSourceReaderHandler.removeCallbacksAndMessages(null);
+ mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_RELEASE);
+ mSourceReaderThread.quitSafely();
+ // Return early in this case so that session worker can start working on the next
+ // request as early as it can. The clean up will be done in the reader thread while
+ // handling MSG_RELEASE.
+ } else {
+ cleanUp();
+ }
+ }
+
+ private void cleanUp() {
+ boolean result = true;
+ try {
+ if (mSampleBuffer != null) {
+ mSampleBuffer.release();
+ mSampleBuffer = null;
+ }
+ } catch (IOException e) {
+ result = false;
+ }
+ notifyCompletionIfNeeded(result);
+ setOnCompletionListener(null, null);
+ }
+
+ private void notifyCompletionIfNeeded(final boolean result) {
+ if (!mOnCompletionCalled.getAndSet(true)) {
+ final OnCompletionListener listener = mOnCompletionListener;
+ final long lastExtractedPositionUs = getLastExtractedPositionUs();
+ if (mOnCompletionListenerHandler != null && mOnCompletionListener != null) {
+ mOnCompletionListenerHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ listener.onCompletion(result, lastExtractedPositionUs);
+ }
+ });
+ }
+ }
+ }
+
+ private long getLastExtractedPositionUs() {
+ long lastExtractedPositionUs = Long.MIN_VALUE;
+ for (Map.Entry<Integer, Long> entry : mLastExtractedPositionUsMap.entrySet()) {
+ if (mVideoTrackIndex != entry.getKey()) {
+ lastExtractedPositionUs = Math.max(lastExtractedPositionUs, entry.getValue());
+ }
+ }
+ if (lastExtractedPositionUs == Long.MIN_VALUE) {
+ lastExtractedPositionUs = com.google.android.exoplayer.C.UNKNOWN_TIME_US;
+ }
+ return lastExtractedPositionUs;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java
new file mode 100644
index 00000000..e7224422
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java
@@ -0,0 +1,139 @@
+/*
+ * 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;
+
+import android.os.Handler;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaFormatUtil;
+import com.google.android.exoplayer.SampleHolder;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A class that plays a recorded stream without using {@link android.media.MediaExtractor}, since
+ * all samples are extracted and stored to the permanent storage already.
+ */
+public class FileSampleExtractor implements SampleExtractor {
+ private static final String TAG = "FileSampleExtractor";
+ private static final boolean DEBUG = false;
+
+ private int mTrackCount;
+ private boolean mReleased;
+
+ private final List<MediaFormat> mTrackFormats = new ArrayList<>();
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+ private BufferManager.SampleBuffer mSampleBuffer;
+
+ public FileSampleExtractor(BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ mTrackCount = -1;
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ // Do nothing.
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ List<BufferManager.TrackFormat> trackFormatList = mBufferManager.readTrackInfoFiles();
+ if (trackFormatList == null || trackFormatList.isEmpty()) {
+ throw new IOException("Cannot find meta files for the recording.");
+ }
+ mTrackCount = trackFormatList.size();
+ List<String> ids = new ArrayList<>();
+ mTrackFormats.clear();
+ for (int i = 0; i < mTrackCount; ++i) {
+ BufferManager.TrackFormat trackFormat = trackFormatList.get(i);
+ ids.add(trackFormat.trackId);
+ mTrackFormats.add(MediaFormatUtil.createMediaFormat(trackFormat.format));
+ }
+ mSampleBuffer =
+ new RecordingSampleBuffer(
+ mBufferManager,
+ mBufferListener,
+ true,
+ RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK);
+ mSampleBuffer.init(ids, mTrackFormats);
+ return true;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ outMediaFormatHolder.format = mTrackFormats.get(track);
+ outMediaFormatHolder.drmInitData = null;
+ }
+
+ @Override
+ public void release() {
+ if (!mReleased) {
+ if (mSampleBuffer != null) {
+ try {
+ mSampleBuffer.release();
+ } catch (IOException e) {
+ // Do nothing. Playback ends now.
+ }
+ }
+ }
+ mReleased = true;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ mSampleBuffer.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ mSampleBuffer.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleBuffer.getBufferedPositionUs();
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleBuffer.seekTo(positionUs);
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ return mSampleBuffer.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleBuffer.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {}
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java
new file mode 100644
index 00000000..a49cbfaf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java
@@ -0,0 +1,672 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.media.AudioFormat;
+import android.media.MediaCodec.CryptoException;
+import android.media.PlaybackParams;
+import android.os.Handler;
+import android.support.annotation.IntDef;
+import android.view.Surface;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer;
+import com.android.tv.tuner.exoplayer.audio.MpegTsMediaCodecAudioTrackRenderer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.tvinput.TunerDebug;
+import com.google.android.exoplayer.DummyTrackRenderer;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.ExoPlayer;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaCodecTrackRenderer.DecoderInitializationException;
+import com.google.android.exoplayer.MediaCodecVideoTrackRenderer;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.audio.AudioTrack;
+import com.google.android.exoplayer.upstream.DataSource;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** MPEG-2 TS stream player implementation using ExoPlayer. */
+public class MpegTsPlayer
+ implements ExoPlayer.Listener,
+ MediaCodecVideoTrackRenderer.EventListener,
+ MpegTsDefaultAudioTrackRenderer.EventListener,
+ MpegTsMediaCodecAudioTrackRenderer.Ac3EventListener {
+ private int mCaptionServiceNumber = Cea708Data.EMPTY_SERVICE_NUMBER;
+
+ /** Interface definition for building specific track renderers. */
+ public interface RendererBuilder {
+ void buildRenderers(
+ MpegTsPlayer mpegTsPlayer,
+ DataSource dataSource,
+ boolean hasSoftwareAudioDecoder,
+ RendererBuilderCallback callback);
+ }
+
+ /** Interface definition for {@link RendererBuilder#buildRenderers} to notify the result. */
+ public interface RendererBuilderCallback {
+ void onRenderers(String[][] trackNames, TrackRenderer[] renderers);
+
+ void onRenderersError(Exception e);
+ }
+
+ /** Interface definition for a callback to be notified of changes in player state. */
+ public interface Listener {
+ void onStateChanged(boolean playWhenReady, int playbackState);
+
+ void onError(Exception e);
+
+ void onVideoSizeChanged(int width, int height, float pixelWidthHeightRatio);
+
+ void onDrawnToSurface(MpegTsPlayer player, Surface surface);
+
+ void onAudioUnplayable();
+
+ void onSmoothTrickplayForceStopped();
+ }
+
+ /** Interface definition for a callback to be notified of changes on video display. */
+ public interface VideoEventListener {
+ /** Notifies the caption event. */
+ void onEmitCaptionEvent(CaptionEvent event);
+
+ /** Notifies clearing up whole closed caption event. */
+ void onClearCaptionEvent();
+
+ /** Notifies the discovered caption service number. */
+ void onDiscoverCaptionServiceNumber(int serviceNumber);
+ }
+
+ public static final int RENDERER_COUNT = 3;
+ public static final int MIN_BUFFER_MS = 0;
+ public static final int MIN_REBUFFER_MS = 500;
+
+ @IntDef({TRACK_TYPE_VIDEO, TRACK_TYPE_AUDIO, TRACK_TYPE_TEXT})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface TrackType {}
+
+ public static final int TRACK_TYPE_VIDEO = 0;
+ public static final int TRACK_TYPE_AUDIO = 1;
+ public static final int TRACK_TYPE_TEXT = 2;
+
+ @IntDef({
+ RENDERER_BUILDING_STATE_IDLE,
+ RENDERER_BUILDING_STATE_BUILDING,
+ RENDERER_BUILDING_STATE_BUILT
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface RendererBuildingState {}
+
+ private static final int RENDERER_BUILDING_STATE_IDLE = 1;
+ private static final int RENDERER_BUILDING_STATE_BUILDING = 2;
+ private static final int RENDERER_BUILDING_STATE_BUILT = 3;
+
+ private static final float MAX_SMOOTH_TRICKPLAY_SPEED = 9.0f;
+ private static final float MIN_SMOOTH_TRICKPLAY_SPEED = 0.1f;
+
+ private final RendererBuilder mRendererBuilder;
+ private final ExoPlayer mPlayer;
+ private final Handler mMainHandler;
+ private final AudioCapabilities mAudioCapabilities;
+ private final TsDataSourceManager mSourceManager;
+
+ private Listener mListener;
+ @RendererBuildingState private int mRendererBuildingState;
+
+ private Surface mSurface;
+ private TsDataSource mDataSource;
+ private InternalRendererBuilderCallback mBuilderCallback;
+ private TrackRenderer mVideoRenderer;
+ private TrackRenderer mAudioRenderer;
+ private Cea708TextTrackRenderer mTextRenderer;
+ private final Cea708TextTrackRenderer.CcListener mCcListener;
+ private VideoEventListener mVideoEventListener;
+ private boolean mTrickplayRunning;
+ private float mVolume;
+
+ /**
+ * Creates MPEG2-TS stream player.
+ *
+ * @param rendererBuilder the builder of track renderers
+ * @param handler the handler for the playback events in track renderers
+ * @param sourceManager the manager for {@link DataSource}
+ * @param capabilities the {@link AudioCapabilities} of the current device
+ * @param listener the listener for playback state changes
+ */
+ public MpegTsPlayer(
+ RendererBuilder rendererBuilder,
+ Handler handler,
+ TsDataSourceManager sourceManager,
+ AudioCapabilities capabilities,
+ Listener listener) {
+ mRendererBuilder = rendererBuilder;
+ mPlayer = ExoPlayer.Factory.newInstance(RENDERER_COUNT, MIN_BUFFER_MS, MIN_REBUFFER_MS);
+ mPlayer.addListener(this);
+ mMainHandler = handler;
+ mAudioCapabilities = capabilities;
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ mCcListener = new MpegTsCcListener();
+ mSourceManager = sourceManager;
+ mListener = listener;
+ }
+
+ /**
+ * Sets the video event listener.
+ *
+ * @param videoEventListener the listener for video events
+ */
+ public void setVideoEventListener(VideoEventListener videoEventListener) {
+ mVideoEventListener = videoEventListener;
+ }
+
+ /**
+ * Sets the closed caption service number.
+ *
+ * @param captionServiceNumber the service number of CEA-708 closed caption
+ */
+ public void setCaptionServiceNumber(int captionServiceNumber) {
+ mCaptionServiceNumber = captionServiceNumber;
+ if (mTextRenderer != null) {
+ mPlayer.sendMessage(
+ mTextRenderer,
+ Cea708TextTrackRenderer.MSG_SERVICE_NUMBER,
+ mCaptionServiceNumber);
+ }
+ }
+
+ /**
+ * Sets the surface for the player.
+ *
+ * @param surface the {@link Surface} to render video
+ */
+ public void setSurface(Surface surface) {
+ mSurface = surface;
+ pushSurface(false);
+ }
+
+ /** Returns the current surface of the player. */
+ public Surface getSurface() {
+ return mSurface;
+ }
+
+ /** Clears the surface and waits until the surface is being cleaned. */
+ public void blockingClearSurface() {
+ mSurface = null;
+ pushSurface(true);
+ }
+
+ /**
+ * Creates renderers and {@link DataSource} and initializes player.
+ *
+ * @param context a {@link Context} instance
+ * @param channel to play
+ * @param hasSoftwareAudioDecoder {@code true} if there is connected software decoder
+ * @param eventListener for program information which will be scanned from MPEG2-TS stream
+ * @return true when everything is created and initialized well, false otherwise
+ */
+ public boolean prepare(
+ Context context,
+ TunerChannel channel,
+ boolean hasSoftwareAudioDecoder,
+ EventDetector.EventListener eventListener) {
+ TsDataSource source = null;
+ if (channel != null) {
+ source = mSourceManager.createDataSource(context, channel, eventListener);
+ if (source == null) {
+ return false;
+ }
+ }
+ mDataSource = source;
+ if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILT) {
+ mPlayer.stop();
+ }
+ if (mBuilderCallback != null) {
+ mBuilderCallback.cancel();
+ }
+ mRendererBuildingState = RENDERER_BUILDING_STATE_BUILDING;
+ mBuilderCallback = new InternalRendererBuilderCallback();
+ mRendererBuilder.buildRenderers(this, source, hasSoftwareAudioDecoder, mBuilderCallback);
+ return true;
+ }
+
+ /** Returns {@link TsDataSource} which provides MPEG2-TS stream. */
+ public TsDataSource getDataSource() {
+ return mDataSource;
+ }
+
+ private void onRenderers(TrackRenderer[] renderers) {
+ mBuilderCallback = null;
+ for (int i = 0; i < RENDERER_COUNT; i++) {
+ if (renderers[i] == null) {
+ // Convert a null renderer to a dummy renderer.
+ renderers[i] = new DummyTrackRenderer();
+ }
+ }
+ mVideoRenderer = renderers[TRACK_TYPE_VIDEO];
+ mAudioRenderer = renderers[TRACK_TYPE_AUDIO];
+ mTextRenderer = (Cea708TextTrackRenderer) renderers[TRACK_TYPE_TEXT];
+ mTextRenderer.setCcListener(mCcListener);
+ mPlayer.sendMessage(
+ mTextRenderer, Cea708TextTrackRenderer.MSG_SERVICE_NUMBER, mCaptionServiceNumber);
+ mRendererBuildingState = RENDERER_BUILDING_STATE_BUILT;
+ pushSurface(false);
+ mPlayer.prepare(renderers);
+ pushTrackSelection(TRACK_TYPE_VIDEO, true);
+ pushTrackSelection(TRACK_TYPE_AUDIO, true);
+ pushTrackSelection(TRACK_TYPE_TEXT, true);
+ }
+
+ private void onRenderersError(Exception e) {
+ mBuilderCallback = null;
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ if (mListener != null) {
+ mListener.onError(e);
+ }
+ }
+
+ /**
+ * Sets the player state to pause or play.
+ *
+ * @param playWhenReady sets the player state to being ready to play when {@code true}, sets the
+ * player state to being paused when {@code false}
+ */
+ public void setPlayWhenReady(boolean playWhenReady) {
+ mPlayer.setPlayWhenReady(playWhenReady);
+ stopSmoothTrickplay(false);
+ }
+
+ /** Returns true, if trickplay is supported. */
+ public boolean supportSmoothTrickPlay(float playbackSpeed) {
+ return playbackSpeed > MIN_SMOOTH_TRICKPLAY_SPEED
+ && playbackSpeed < MAX_SMOOTH_TRICKPLAY_SPEED;
+ }
+
+ /**
+ * Starts trickplay. It'll be reset, if {@link #seekTo} or {@link #setPlayWhenReady} is called.
+ */
+ public void startSmoothTrickplay(PlaybackParams playbackParams) {
+ SoftPreconditions.checkState(supportSmoothTrickPlay(playbackParams.getSpeed()));
+ mPlayer.setPlayWhenReady(true);
+ mTrickplayRunning = true;
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MpegTsDefaultAudioTrackRenderer.MSG_SET_PLAYBACK_SPEED,
+ playbackParams.getSpeed());
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS,
+ playbackParams);
+ }
+ }
+
+ private void stopSmoothTrickplay(boolean calledBySeek) {
+ if (mTrickplayRunning) {
+ mTrickplayRunning = false;
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MpegTsDefaultAudioTrackRenderer.MSG_SET_PLAYBACK_SPEED,
+ 1.0f);
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS,
+ new PlaybackParams().setSpeed(1.0f));
+ }
+ if (!calledBySeek) {
+ mPlayer.seekTo(mPlayer.getCurrentPosition());
+ }
+ }
+ }
+
+ /**
+ * Seeks to the specified position of the current playback.
+ *
+ * @param positionMs the specified position in milli seconds.
+ */
+ public void seekTo(long positionMs) {
+ mPlayer.seekTo(positionMs);
+ stopSmoothTrickplay(true);
+ }
+
+ /** Releases the player. */
+ public void release() {
+ if (mDataSource != null) {
+ mSourceManager.releaseDataSource(mDataSource);
+ mDataSource = null;
+ }
+ if (mBuilderCallback != null) {
+ mBuilderCallback.cancel();
+ mBuilderCallback = null;
+ }
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ mSurface = null;
+ mListener = null;
+ mPlayer.release();
+ }
+
+ /** Returns the current status of the player. */
+ public int getPlaybackState() {
+ if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILDING) {
+ return ExoPlayer.STATE_PREPARING;
+ }
+ return mPlayer.getPlaybackState();
+ }
+
+ /** Returns {@code true} when the player is prepared to play, {@code false} otherwise. */
+ public boolean isPrepared() {
+ int state = getPlaybackState();
+ return state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING;
+ }
+
+ /** Returns {@code true} when the player is being ready to play, {@code false} otherwise. */
+ public boolean isPlaying() {
+ int state = getPlaybackState();
+ return (state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING)
+ && mPlayer.getPlayWhenReady();
+ }
+
+ /** Returns {@code true} when the player is buffering, {@code false} otherwise. */
+ public boolean isBuffering() {
+ return getPlaybackState() == ExoPlayer.STATE_BUFFERING;
+ }
+
+ /** Returns the current position of the playback in milli seconds. */
+ public long getCurrentPosition() {
+ return mPlayer.getCurrentPosition();
+ }
+
+ /** Returns the total duration of the playback. */
+ public long getDuration() {
+ return mPlayer.getDuration();
+ }
+
+ /**
+ * Returns {@code true} when the player is being ready to play, {@code false} when the player is
+ * paused.
+ */
+ public boolean getPlayWhenReady() {
+ return mPlayer.getPlayWhenReady();
+ }
+
+ /**
+ * Sets the volume of the audio.
+ *
+ * @param volume see also {@link AudioTrack#setVolume(float)}
+ */
+ public void setVolume(float volume) {
+ mVolume = volume;
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer, MpegTsDefaultAudioTrackRenderer.MSG_SET_VOLUME, volume);
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, volume);
+ }
+ }
+
+ /**
+ * Enables or disables audio and closed caption.
+ *
+ * @param enable enables the audio and closed caption when {@code true}, disables otherwise.
+ */
+ public void setAudioTrackAndClosedCaption(boolean enable) {
+ if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MpegTsDefaultAudioTrackRenderer.MSG_SET_AUDIO_TRACK,
+ enable ? 1 : 0);
+ } else {
+ mPlayer.sendMessage(
+ mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_VOLUME,
+ enable ? mVolume : 0.0f);
+ }
+ mPlayer.sendMessage(
+ mTextRenderer, Cea708TextTrackRenderer.MSG_ENABLE_CLOSED_CAPTION, enable);
+ }
+
+ /** Returns {@code true} when AC3 audio can be played, {@code false} otherwise. */
+ public boolean isAc3Playable() {
+ return mAudioCapabilities != null
+ && mAudioCapabilities.supportsEncoding(AudioFormat.ENCODING_AC3);
+ }
+
+ /** Notifies when the audio cannot be played by the current device. */
+ public void onAudioUnplayable() {
+ if (mListener != null) {
+ mListener.onAudioUnplayable();
+ }
+ }
+
+ /** Returns {@code true} if the player has any video track, {@code false} otherwise. */
+ public boolean hasVideo() {
+ return mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0;
+ }
+
+ /** Returns {@code true} if the player has any audio trock, {@code false} otherwise. */
+ public boolean hasAudio() {
+ return mPlayer.getTrackCount(TRACK_TYPE_AUDIO) > 0;
+ }
+
+ /** Returns the number of tracks exposed by the specified renderer. */
+ public int getTrackCount(int rendererIndex) {
+ return mPlayer.getTrackCount(rendererIndex);
+ }
+
+ /** Selects a track for the specified renderer. */
+ public void setSelectedTrack(int rendererIndex, int trackIndex) {
+ if (trackIndex >= getTrackCount(rendererIndex)) {
+ return;
+ }
+ mPlayer.setSelectedTrack(rendererIndex, trackIndex);
+ }
+
+ /**
+ * Returns the index of the currently selected track for the specified renderer.
+ *
+ * @param rendererIndex The index of the renderer.
+ * @return The selected track. A negative value or a value greater than or equal to the
+ * renderer's track count indicates that the renderer is disabled.
+ */
+ public int getSelectedTrack(int rendererIndex) {
+ return mPlayer.getSelectedTrack(rendererIndex);
+ }
+
+ /**
+ * Returns the format of a track.
+ *
+ * @param rendererIndex The index of the renderer.
+ * @param trackIndex The index of the track.
+ * @return The format of the track.
+ */
+ public MediaFormat getTrackFormat(int rendererIndex, int trackIndex) {
+ return mPlayer.getTrackFormat(rendererIndex, trackIndex);
+ }
+
+ /** Gets the main handler of the player. */
+ /* package */ Handler getMainHandler() {
+ return mMainHandler;
+ }
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, int state) {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onStateChanged(playWhenReady, state);
+ if (state == ExoPlayer.STATE_READY
+ && mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0
+ && playWhenReady) {
+ MediaFormat format = mPlayer.getTrackFormat(TRACK_TYPE_VIDEO, 0);
+ mListener.onVideoSizeChanged(format.width, format.height, format.pixelWidthHeightRatio);
+ }
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException exception) {
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ if (mListener != null) {
+ mListener.onError(exception);
+ }
+ }
+
+ @Override
+ public void onVideoSizeChanged(
+ int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {
+ if (mListener != null) {
+ mListener.onVideoSizeChanged(width, height, pixelWidthHeightRatio);
+ }
+ }
+
+ @Override
+ public void onDecoderInitialized(
+ String decoderName, long elapsedRealtimeMs, long initializationDurationMs) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onDecoderInitializationError(DecoderInitializationException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAudioTrackInitializationError(AudioTrack.InitializationException e) {
+ if (mListener != null) {
+ mListener.onAudioUnplayable();
+ }
+ }
+
+ @Override
+ public void onAudioTrackWriteError(AudioTrack.WriteException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAudioTrackUnderrun(
+ int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onCryptoError(CryptoException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onPlayWhenReadyCommitted() {
+ // Do nothing.
+ }
+
+ @Override
+ public void onDrawnToSurface(Surface surface) {
+ if (mListener != null) {
+ mListener.onDrawnToSurface(this, surface);
+ }
+ }
+
+ @Override
+ public void onDroppedFrames(int count, long elapsed) {
+ TunerDebug.notifyVideoFrameDrop(count, elapsed);
+ if (mTrickplayRunning && mListener != null) {
+ mListener.onSmoothTrickplayForceStopped();
+ }
+ }
+
+ @Override
+ public void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e) {
+ if (mTrickplayRunning && mListener != null) {
+ mListener.onSmoothTrickplayForceStopped();
+ }
+ }
+
+ private void pushSurface(boolean blockForSurfacePush) {
+ if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) {
+ return;
+ }
+
+ if (blockForSurfacePush) {
+ mPlayer.blockingSendMessage(
+ mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface);
+ } else {
+ mPlayer.sendMessage(
+ mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface);
+ }
+ }
+
+ private void pushTrackSelection(@TrackType int type, boolean allowRendererEnable) {
+ if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) {
+ return;
+ }
+ mPlayer.setSelectedTrack(type, allowRendererEnable ? 0 : -1);
+ }
+
+ private class MpegTsCcListener implements Cea708TextTrackRenderer.CcListener {
+
+ @Override
+ public void emitEvent(CaptionEvent captionEvent) {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onEmitCaptionEvent(captionEvent);
+ }
+ }
+
+ @Override
+ public void clearCaption() {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onClearCaptionEvent();
+ }
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onDiscoverCaptionServiceNumber(serviceNumber);
+ }
+ }
+ }
+
+ private class InternalRendererBuilderCallback implements RendererBuilderCallback {
+ private boolean canceled;
+
+ public void cancel() {
+ canceled = true;
+ }
+
+ @Override
+ public void onRenderers(String[][] trackNames, TrackRenderer[] renderers) {
+ if (!canceled) {
+ MpegTsPlayer.this.onRenderers(renderers);
+ }
+ }
+
+ @Override
+ public void onRenderersError(Exception e) {
+ if (!canceled) {
+ MpegTsPlayer.this.onRenderersError(e);
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java
new file mode 100644
index 00000000..774285e9
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java
@@ -0,0 +1,77 @@
+/*
+ * 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;
+
+import android.content.Context;
+import com.android.tv.tuner.TunerFeatures;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilder;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilderCallback;
+import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.upstream.DataSource;
+
+/** Builder for renderer objects for {@link MpegTsPlayer}. */
+public class MpegTsRendererBuilder implements RendererBuilder {
+ private final Context mContext;
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+
+ public MpegTsRendererBuilder(
+ Context context, BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mContext = context;
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ }
+
+ @Override
+ public void buildRenderers(
+ MpegTsPlayer mpegTsPlayer,
+ DataSource dataSource,
+ boolean mHasSoftwareAudioDecoder,
+ RendererBuilderCallback callback) {
+ // Build the video and audio renderers.
+ SampleExtractor extractor =
+ dataSource == null
+ ? new MpegTsSampleExtractor(mBufferManager, mBufferListener)
+ : new MpegTsSampleExtractor(dataSource, mBufferManager, mBufferListener);
+ SampleSource sampleSource = new MpegTsSampleSource(extractor);
+ MpegTsVideoTrackRenderer videoRenderer =
+ new MpegTsVideoTrackRenderer(
+ mContext, sampleSource, mpegTsPlayer.getMainHandler(), mpegTsPlayer);
+ // TODO: Only using MpegTsDefaultAudioTrackRenderer for A/V sync issue. We will use
+ // {@link MpegTsMediaCodecAudioTrackRenderer} when we use ExoPlayer's extractor.
+ TrackRenderer audioRenderer =
+ new MpegTsDefaultAudioTrackRenderer(
+ sampleSource,
+ MediaCodecSelector.DEFAULT,
+ mpegTsPlayer.getMainHandler(),
+ mpegTsPlayer,
+ mHasSoftwareAudioDecoder,
+ !TunerFeatures.AC3_SOFTWARE_DECODE.isEnabled(mContext));
+ Cea708TextTrackRenderer textRenderer = new Cea708TextTrackRenderer(sampleSource);
+
+ TrackRenderer[] renderers = new TrackRenderer[MpegTsPlayer.RENDERER_COUNT];
+ renderers[MpegTsPlayer.TRACK_TYPE_VIDEO] = videoRenderer;
+ renderers[MpegTsPlayer.TRACK_TYPE_AUDIO] = audioRenderer;
+ renderers[MpegTsPlayer.TRACK_TYPE_TEXT] = textRenderer;
+ callback.onRenderers(null, renderers);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java
new file mode 100644
index 00000000..593b576e
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java
@@ -0,0 +1,345 @@
+/*
+ * 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;
+
+import android.net.Uri;
+import android.os.Handler;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.SamplePool;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+/** Extracts samples from {@link DataSource} for MPEG-TS streams. */
+public final class MpegTsSampleExtractor implements SampleExtractor {
+ public static final String MIMETYPE_TEXT_CEA_708 = "text/cea-708";
+
+ private static final int CC_BUFFER_SIZE_IN_BYTES = 9600 / 8;
+
+ private final SampleExtractor mSampleExtractor;
+ private final List<MediaFormat> mTrackFormats = new ArrayList<>();
+ private final List<Boolean> mReachedEos = new ArrayList<>();
+ private int mVideoTrackIndex;
+ private final SamplePool mCcSamplePool = new SamplePool();
+ private final List<SampleHolder> mPendingCcSamples = new LinkedList<>();
+
+ private int mCea708TextTrackIndex;
+ private boolean mCea708TextTrackSelected;
+
+ private CcParser mCcParser;
+
+ private void init() {
+ mVideoTrackIndex = -1;
+ mCea708TextTrackIndex = -1;
+ mCea708TextTrackSelected = false;
+ }
+
+ /**
+ * Creates MpegTsSampleExtractor for {@link DataSource}.
+ *
+ * @param source the {@link DataSource} to extract from
+ * @param bufferManager the manager for reading & writing samples backed by physical storage
+ * @param bufferListener the {@link PlaybackBufferListener} to notify buffer storage status
+ * change
+ */
+ public MpegTsSampleExtractor(
+ DataSource source, BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mSampleExtractor =
+ new ExoPlayerSampleExtractor(
+ Uri.EMPTY, source, bufferManager, bufferListener, false);
+ init();
+ }
+
+ /**
+ * Creates MpegTsSampleExtractor for a recorded program.
+ *
+ * @param bufferManager the samples provider which is stored in physical storage
+ * @param bufferListener the {@link PlaybackBufferListener} to notify buffer storage status
+ * change
+ */
+ public MpegTsSampleExtractor(
+ BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mSampleExtractor = new FileSampleExtractor(bufferManager, bufferListener);
+ init();
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mSampleExtractor != null) {
+ mSampleExtractor.maybeThrowError();
+ }
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ if (!mSampleExtractor.prepare()) {
+ return false;
+ }
+ List<MediaFormat> formats = mSampleExtractor.getTrackFormats();
+ int trackCount = formats.size();
+ mTrackFormats.clear();
+ mReachedEos.clear();
+
+ for (int i = 0; i < trackCount; ++i) {
+ mTrackFormats.add(formats.get(i));
+ mReachedEos.add(false);
+ String mime = formats.get(i).mimeType;
+ if (MimeTypes.isVideo(mime) && mVideoTrackIndex == -1) {
+ mVideoTrackIndex = i;
+ if (android.media.MediaFormat.MIMETYPE_VIDEO_MPEG2.equals(mime)) {
+ mCcParser = new Mpeg2CcParser();
+ } else if (android.media.MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
+ mCcParser = new H264CcParser();
+ }
+ }
+ }
+
+ if (mVideoTrackIndex != -1) {
+ mCea708TextTrackIndex = trackCount;
+ }
+ if (mCea708TextTrackIndex >= 0) {
+ mTrackFormats.add(
+ MediaFormat.createTextFormat(
+ null, MIMETYPE_TEXT_CEA_708, 0, mTrackFormats.get(0).durationUs, ""));
+ }
+ return true;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ if (index == mCea708TextTrackIndex) {
+ mCea708TextTrackSelected = true;
+ return;
+ }
+ mSampleExtractor.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ if (index == mCea708TextTrackIndex) {
+ mCea708TextTrackSelected = false;
+ return;
+ }
+ mSampleExtractor.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleExtractor.getBufferedPositionUs();
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleExtractor.seekTo(positionUs);
+ for (SampleHolder holder : mPendingCcSamples) {
+ mCcSamplePool.releaseSample(holder);
+ }
+ mPendingCcSamples.clear();
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ if (track != mCea708TextTrackIndex) {
+ mSampleExtractor.getTrackMediaFormat(track, outMediaFormatHolder);
+ }
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ if (track == mCea708TextTrackIndex) {
+ if (mCea708TextTrackSelected && !mPendingCcSamples.isEmpty()) {
+ SampleHolder holder = mPendingCcSamples.remove(0);
+ holder.data.flip();
+ sampleHolder.timeUs = holder.timeUs;
+ sampleHolder.data.put(holder.data);
+ mCcSamplePool.releaseSample(holder);
+ return SampleSource.SAMPLE_READ;
+ } else {
+ return mVideoTrackIndex < 0 || mReachedEos.get(mVideoTrackIndex)
+ ? SampleSource.END_OF_STREAM
+ : SampleSource.NOTHING_READ;
+ }
+ }
+
+ int result = mSampleExtractor.readSample(track, sampleHolder);
+ switch (result) {
+ case SampleSource.END_OF_STREAM:
+ {
+ mReachedEos.set(track, true);
+ break;
+ }
+ case SampleSource.SAMPLE_READ:
+ {
+ if (mCea708TextTrackSelected
+ && track == mVideoTrackIndex
+ && sampleHolder.data != null) {
+ mCcParser.mayParseClosedCaption(sampleHolder.data, sampleHolder.timeUs);
+ }
+ break;
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void release() {
+ mSampleExtractor.release();
+ mVideoTrackIndex = -1;
+ mCea708TextTrackIndex = -1;
+ mCea708TextTrackSelected = false;
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleExtractor.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {}
+
+ private abstract class CcParser {
+ // Interim buffer for reduce direct access to ByteBuffer which is expensive. Using
+ // relatively small buffer size in order to minimize memory footprint increase.
+ protected final byte[] mBuffer = new byte[1024];
+
+ abstract void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs);
+
+ protected int parseClosedCaption(ByteBuffer buffer, int offset, long presentationTimeUs) {
+ // For the details of user_data_type_structure, see ATSC A/53 Part 4 - Table 6.9.
+ int pos = offset;
+ if (pos + 2 >= buffer.position()) {
+ return offset;
+ }
+ boolean processCcDataFlag = (buffer.get(pos) & 64) != 0;
+ int ccCount = buffer.get(pos) & 0x1f;
+ pos += 2;
+ if (!processCcDataFlag || pos + 3 * ccCount >= buffer.position() || ccCount == 0) {
+ return offset;
+ }
+ SampleHolder holder = mCcSamplePool.acquireSample(CC_BUFFER_SIZE_IN_BYTES);
+ for (int i = 0; i < 3 * ccCount; i++) {
+ holder.data.put(buffer.get(pos++));
+ }
+ holder.timeUs = presentationTimeUs;
+ mPendingCcSamples.add(holder);
+ return pos;
+ }
+ }
+
+ private class Mpeg2CcParser extends CcParser {
+ private static final int PATTERN_LENGTH = 9;
+
+ @Override
+ public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) {
+ int totalSize = buffer.position();
+ // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with
+ // overlapping to handle the case that the pattern exists in the boundary.
+ for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) {
+ buffer.position(i);
+ int size = Math.min(totalSize - i, mBuffer.length);
+ buffer.get(mBuffer, 0, size);
+ int j = 0;
+ while (j < size - PATTERN_LENGTH) {
+ // Find the start prefix code of private user data.
+ if (mBuffer[j] == 0
+ && mBuffer[j + 1] == 0
+ && mBuffer[j + 2] == 1
+ && (mBuffer[j + 3] & 0xff) == 0xb2) {
+ // ATSC closed caption data embedded in MPEG2VIDEO stream has 'GA94' user
+ // identifier and user data type code 3.
+ if (mBuffer[j + 4] == 'G'
+ && mBuffer[j + 5] == 'A'
+ && mBuffer[j + 6] == '9'
+ && mBuffer[j + 7] == '4'
+ && mBuffer[j + 8] == 3) {
+ j =
+ parseClosedCaption(
+ buffer,
+ i + j + PATTERN_LENGTH,
+ presentationTimeUs)
+ - i;
+ } else {
+ j += PATTERN_LENGTH;
+ }
+ } else {
+ ++j;
+ }
+ }
+ }
+ buffer.position(totalSize);
+ }
+ }
+
+ private class H264CcParser extends CcParser {
+ private static final int PATTERN_LENGTH = 14;
+
+ @Override
+ public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) {
+ int totalSize = buffer.position();
+ // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with
+ // overlapping to handle the case that the pattern exists in the boundary.
+ for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) {
+ buffer.position(i);
+ int size = Math.min(totalSize - i, mBuffer.length);
+ buffer.get(mBuffer, 0, size);
+ int j = 0;
+ while (j < size - PATTERN_LENGTH) {
+ // Find the start prefix code of a NAL Unit.
+ if (mBuffer[j] == 0 && mBuffer[j + 1] == 0 && mBuffer[j + 2] == 1) {
+ int nalType = mBuffer[j + 3] & 0x1f;
+ int payloadType = mBuffer[j + 4] & 0xff;
+
+ // ATSC closed caption data embedded in H264 private user data has NAL type
+ // 6, payload type 4, and 'GA94' user identifier for ATSC.
+ if (nalType == 6
+ && payloadType == 4
+ && mBuffer[j + 9] == 'G'
+ && mBuffer[j + 10] == 'A'
+ && mBuffer[j + 11] == '9'
+ && mBuffer[j + 12] == '4') {
+ j =
+ parseClosedCaption(
+ buffer,
+ i + j + PATTERN_LENGTH,
+ presentationTimeUs)
+ - i;
+ } else {
+ j += 7;
+ }
+ } else {
+ ++j;
+ }
+ }
+ }
+ buffer.position(totalSize);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java
new file mode 100644
index 00000000..3b5d1011
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java
@@ -0,0 +1,195 @@
+/*
+ * 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;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.SampleSource.SampleSourceReader;
+import com.google.android.exoplayer.util.Assertions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** {@link SampleSource} that extracts sample data using a {@link SampleExtractor}. */
+public final class MpegTsSampleSource implements SampleSource, SampleSourceReader {
+
+ private static final int TRACK_STATE_DISABLED = 0;
+ private static final int TRACK_STATE_ENABLED = 1;
+ private static final int TRACK_STATE_FORMAT_SENT = 2;
+
+ private final SampleExtractor mSampleExtractor;
+ private final List<Integer> mTrackStates = new ArrayList<>();
+ private final List<Boolean> mPendingDiscontinuities = new ArrayList<>();
+
+ private boolean mPrepared;
+ private IOException mPreparationError;
+ private int mRemainingReleaseCount;
+
+ private long mLastSeekPositionUs;
+ private long mPendingSeekPositionUs;
+
+ /**
+ * Creates a new sample source that extracts samples using {@code mSampleExtractor}.
+ *
+ * @param sampleExtractor a sample extractor for accessing media samples
+ */
+ public MpegTsSampleSource(SampleExtractor sampleExtractor) {
+ mSampleExtractor = Assertions.checkNotNull(sampleExtractor);
+ }
+
+ @Override
+ public SampleSourceReader register() {
+ mRemainingReleaseCount++;
+ return this;
+ }
+
+ @Override
+ public boolean prepare(long positionUs) {
+ if (!mPrepared) {
+ if (mPreparationError != null) {
+ return false;
+ }
+ try {
+ if (mSampleExtractor.prepare()) {
+ int trackCount = mSampleExtractor.getTrackFormats().size();
+ mTrackStates.clear();
+ mPendingDiscontinuities.clear();
+ for (int i = 0; i < trackCount; ++i) {
+ mTrackStates.add(i, TRACK_STATE_DISABLED);
+ mPendingDiscontinuities.add(i, false);
+ }
+ mPrepared = true;
+ } else {
+ return false;
+ }
+ } catch (IOException e) {
+ mPreparationError = e;
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public int getTrackCount() {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getTrackFormats().size();
+ }
+
+ @Override
+ public MediaFormat getFormat(int track) {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getTrackFormats().get(track);
+ }
+
+ @Override
+ public void enable(int track, long positionUs) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) == TRACK_STATE_DISABLED);
+ mTrackStates.set(track, TRACK_STATE_ENABLED);
+ mSampleExtractor.selectTrack(track);
+ seekToUsInternal(positionUs, positionUs != 0);
+ }
+
+ @Override
+ public void disable(int track) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED);
+ mSampleExtractor.deselectTrack(track);
+ mPendingDiscontinuities.set(track, false);
+ mTrackStates.set(track, TRACK_STATE_DISABLED);
+ }
+
+ @Override
+ public boolean continueBuffering(int track, long positionUs) {
+ return mSampleExtractor.continueBuffering(positionUs);
+ }
+
+ @Override
+ public long readDiscontinuity(int track) {
+ if (mPendingDiscontinuities.get(track)) {
+ mPendingDiscontinuities.set(track, false);
+ return mLastSeekPositionUs;
+ }
+ return NO_DISCONTINUITY;
+ }
+
+ @Override
+ public int readData(
+ int track, long positionUs, MediaFormatHolder formatHolder, SampleHolder sampleHolder) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED);
+ if (mPendingDiscontinuities.get(track)) {
+ return NOTHING_READ;
+ }
+ if (mTrackStates.get(track) != TRACK_STATE_FORMAT_SENT) {
+ mSampleExtractor.getTrackMediaFormat(track, formatHolder);
+ mTrackStates.set(track, TRACK_STATE_FORMAT_SENT);
+ return FORMAT_READ;
+ }
+
+ mPendingSeekPositionUs = C.UNKNOWN_TIME_US;
+ return mSampleExtractor.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mPreparationError != null) {
+ throw mPreparationError;
+ }
+ if (mSampleExtractor != null) {
+ mSampleExtractor.maybeThrowError();
+ }
+ }
+
+ @Override
+ public void seekToUs(long positionUs) {
+ Assertions.checkState(mPrepared);
+ seekToUsInternal(positionUs, false);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getBufferedPositionUs();
+ }
+
+ @Override
+ public void release() {
+ Assertions.checkState(mRemainingReleaseCount > 0);
+ if (--mRemainingReleaseCount == 0) {
+ mSampleExtractor.release();
+ }
+ }
+
+ private void seekToUsInternal(long positionUs, boolean force) {
+ // Unless forced, avoid duplicate calls to the underlying extractor's seek method
+ // in the case that there have been no interleaving calls to readSample.
+ if (force || mPendingSeekPositionUs != positionUs) {
+ mLastSeekPositionUs = positionUs;
+ mPendingSeekPositionUs = positionUs;
+ mSampleExtractor.seekTo(positionUs);
+ for (int i = 0; i < mTrackStates.size(); ++i) {
+ if (mTrackStates.get(i) != TRACK_STATE_DISABLED) {
+ mPendingDiscontinuities.set(i, true);
+ }
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java
new file mode 100644
index 00000000..b136e235
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java
@@ -0,0 +1,126 @@
+/*
+ * 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.android.tv.tuner.exoplayer;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.os.Handler;
+import android.util.Log;
+import com.android.tv.tuner.TunerFeatures;
+import com.google.android.exoplayer.DecoderInfo;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaCodecUtil;
+import com.google.android.exoplayer.MediaCodecVideoTrackRenderer;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaSoftwareCodecUtil;
+import com.google.android.exoplayer.SampleSource;
+import java.lang.reflect.Field;
+
+/** MPEG-2 TS video track renderer */
+public class MpegTsVideoTrackRenderer extends MediaCodecVideoTrackRenderer {
+ private static final String TAG = "MpegTsVideoTrackRender";
+
+ private static final int VIDEO_PLAYBACK_DEADLINE_IN_MS = 5000;
+ // If DROPPED_FRAMES_NOTIFICATION_THRESHOLD frames are consecutively dropped, it'll be notified.
+ private static final int DROPPED_FRAMES_NOTIFICATION_THRESHOLD = 10;
+ private static final int MIN_HD_HEIGHT = 720;
+ private static final String MIMETYPE_MPEG2 = "video/mpeg2";
+ private static Field sRenderedFirstFrameField;
+
+ private final boolean mIsSwCodecEnabled;
+ private boolean mCodecIsSwPreferred;
+ private boolean mSetRenderedFirstFrame;
+
+ static {
+ // Remove the reflection below once b/31223646 is resolved.
+ try {
+ sRenderedFirstFrameField =
+ MediaCodecVideoTrackRenderer.class.getDeclaredField("renderedFirstFrame");
+ sRenderedFirstFrameField.setAccessible(true);
+ } catch (NoSuchFieldException e) {
+ // Null-checking for {@code sRenderedFirstFrameField} will do the error handling.
+ }
+ }
+
+ public MpegTsVideoTrackRenderer(
+ Context context,
+ SampleSource source,
+ Handler handler,
+ MediaCodecVideoTrackRenderer.EventListener listener) {
+ super(
+ context,
+ source,
+ MediaCodecSelector.DEFAULT,
+ MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT,
+ VIDEO_PLAYBACK_DEADLINE_IN_MS,
+ handler,
+ listener,
+ DROPPED_FRAMES_NOTIFICATION_THRESHOLD);
+ mIsSwCodecEnabled = TunerFeatures.USE_SW_CODEC_FOR_SD.isEnabled(context);
+ }
+
+ @Override
+ protected DecoderInfo getDecoderInfo(
+ MediaCodecSelector codecSelector, String mimeType, boolean requiresSecureDecoder)
+ throws MediaCodecUtil.DecoderQueryException {
+ try {
+ if (mIsSwCodecEnabled && mCodecIsSwPreferred) {
+ DecoderInfo swCodec =
+ MediaSoftwareCodecUtil.getSoftwareDecoderInfo(
+ mimeType, requiresSecureDecoder);
+ if (swCodec != null) {
+ return swCodec;
+ }
+ }
+ } catch (MediaSoftwareCodecUtil.DecoderQueryException e) {
+ }
+ return super.getDecoderInfo(codecSelector, mimeType, requiresSecureDecoder);
+ }
+
+ @Override
+ protected void onInputFormatChanged(MediaFormatHolder holder) throws ExoPlaybackException {
+ mCodecIsSwPreferred =
+ MIMETYPE_MPEG2.equalsIgnoreCase(holder.format.mimeType)
+ && holder.format.height < MIN_HD_HEIGHT;
+ super.onInputFormatChanged(holder);
+ }
+
+ @Override
+ protected void onDiscontinuity(long positionUs) throws ExoPlaybackException {
+ super.onDiscontinuity(positionUs);
+ // Disabling pre-rendering of the first frame in order to avoid a frozen picture when
+ // starting the playback. We do this only once, when the renderer is enabled at first, since
+ // we need to pre-render the frame in advance when we do trickplay backed by seeking.
+ if (!mSetRenderedFirstFrame) {
+ setRenderedFirstFrame(true);
+ mSetRenderedFirstFrame = true;
+ }
+ }
+
+ private void setRenderedFirstFrame(boolean renderedFirstFrame) {
+ if (sRenderedFirstFrameField != null) {
+ try {
+ sRenderedFirstFrameField.setBoolean(this, renderedFirstFrame);
+ } catch (IllegalAccessException e) {
+ Log.w(
+ TAG,
+ "renderedFirstFrame is not accessible. Playback may start with a frozen"
+ + " picture.");
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java b/tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java
new file mode 100644
index 00000000..256aea92
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/SampleExtractor.java
@@ -0,0 +1,131 @@
+/*
+ * 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;
+
+import android.os.Handler;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Extractor for reading track metadata and samples stored in tracks.
+ *
+ * <p>Call {@link #prepare} until it returns {@code true}, then access track metadata via {@link
+ * #getTrackFormats} and {@link #getTrackMediaFormat}.
+ *
+ * <p>Pass indices of tracks to read from to {@link #selectTrack}. A track can later be deselected
+ * by calling {@link #deselectTrack}. It is safe to select/deselect tracks after reading sample data
+ * or seeking. Initially, all tracks are deselected.
+ *
+ * <p>Call {@link #release()} when the extractor is no longer needed to free resources.
+ */
+public interface SampleExtractor {
+
+ /**
+ * If the extractor is currently having difficulty preparing or loading samples, then this
+ * method throws the underlying error. Otherwise does nothing.
+ *
+ * @throws IOException The underlying error.
+ */
+ void maybeThrowError() throws IOException;
+
+ /**
+ * Prepares the extractor for reading track metadata and samples.
+ *
+ * @return whether the source is ready; if {@code false}, this method must be called again.
+ * @throws IOException thrown if the source can't be read
+ */
+ boolean prepare() throws IOException;
+
+ /** Returns track information about all tracks that can be selected. */
+ List<MediaFormat> getTrackFormats();
+
+ /** Selects the track at {@code index} for reading sample data. */
+ void selectTrack(int index);
+
+ /** Deselects the track at {@code index}, so no more samples will be read from that track. */
+ void deselectTrack(int index);
+
+ /**
+ * Returns an estimate of the position up to which data is buffered.
+ *
+ * <p>This method should not be called until after the extractor has been successfully prepared.
+ *
+ * @return an estimate of the absolute position in microseconds up to which data is buffered, or
+ * {@link TrackRenderer#END_OF_TRACK_US} if data is buffered to the end of the stream, or
+ * {@link TrackRenderer#UNKNOWN_TIME_US} if no estimate is available.
+ */
+ long getBufferedPositionUs();
+
+ /**
+ * Seeks to the specified time in microseconds.
+ *
+ * <p>This method should not be called until after the extractor has been successfully prepared.
+ *
+ * @param positionUs the seek position in microseconds
+ */
+ void seekTo(long positionUs);
+
+ /** Stores the {@link MediaFormat} of {@code track}. */
+ void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder);
+
+ /**
+ * Reads the next sample in the track at index {@code track} into {@code sampleHolder},
+ * returning {@link SampleSource#SAMPLE_READ} if it is available.
+ *
+ * <p>Advances to the next sample if a sample was read.
+ *
+ * @param track the index of the track from which to read a sample
+ * @param sampleHolder the holder for read sample data, if {@link SampleSource#SAMPLE_READ} is
+ * returned
+ * @return {@link SampleSource#SAMPLE_READ} if a sample was read into {@code sampleHolder}, or
+ * {@link SampleSource#END_OF_STREAM} if the last samples in all tracks have been read, or
+ * {@link SampleSource#NOTHING_READ} if the sample cannot be read immediately as it is not
+ * loaded.
+ */
+ int readSample(int track, SampleHolder sampleHolder);
+
+ /** Releases resources associated with this extractor. */
+ void release();
+
+ /** Indicates to the source that it should still be buffering data. */
+ boolean continueBuffering(long positionUs);
+
+ /**
+ * Sets OnCompletionListener for notifying the completion of SampleExtractor.
+ *
+ * @param listener the OnCompletionListener
+ * @param handler the {@link Handler} for {@link Handler#post(Runnable)} of OnCompletionListener
+ */
+ void setOnCompletionListener(OnCompletionListener listener, Handler handler);
+
+ /** The listener for SampleExtractor being completed. */
+ interface OnCompletionListener {
+
+ /**
+ * Called when sample extraction is completed.
+ *
+ * @param result {@code true} when the extractor is finished without an error, {@code false}
+ * otherwise (storage error, weak signal, being reached at EoS prematurely, etc.)
+ * @param lastExtractedPositionUs the last extracted position when extractor is completed
+ */
+ void onCompletion(boolean result, long lastExtractedPositionUs);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java
new file mode 100644
index 00000000..13eabc3a
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java
@@ -0,0 +1,94 @@
+/*
+ * 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.audio;
+
+import android.os.SystemClock;
+import com.android.tv.common.SoftPreconditions;
+
+/**
+ * Copy of {@link com.google.android.exoplayer.MediaClock}.
+ *
+ * <p>A simple clock for tracking the progression of media time. The clock can be started, stopped
+ * and its time can be set and retrieved. When started, this clock is based on {@link
+ * SystemClock#elapsedRealtime()}.
+ */
+/* package */ class AudioClock {
+ private boolean mStarted;
+
+ /** The media time when the clock was last set or stopped. */
+ private long mPositionUs;
+
+ /**
+ * The difference between {@link SystemClock#elapsedRealtime()} and {@link #mPositionUs} when
+ * the clock was last set or mStarted.
+ */
+ private long mDeltaUs;
+
+ private float mPlaybackSpeed = 1.0f;
+ private long mDeltaUpdatedTimeUs;
+
+ /** Starts the clock. Does nothing if the clock is already started. */
+ public void start() {
+ if (!mStarted) {
+ mStarted = true;
+ mDeltaUs = elapsedRealtimeMinus(mPositionUs);
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ }
+ }
+
+ /** Stops the clock. Does nothing if the clock is already stopped. */
+ public void stop() {
+ if (mStarted) {
+ mPositionUs = elapsedRealtimeMinus(mDeltaUs);
+ mStarted = false;
+ }
+ }
+
+ /** @param timeUs The position to set in microseconds. */
+ public void setPositionUs(long timeUs) {
+ this.mPositionUs = timeUs;
+ mDeltaUs = elapsedRealtimeMinus(timeUs);
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ }
+
+ /** @return The current position in microseconds. */
+ public long getPositionUs() {
+ if (!mStarted) {
+ return mPositionUs;
+ }
+ if (mPlaybackSpeed != 1.0f) {
+ long elapsedTimeFromPlaybackSpeedChanged =
+ SystemClock.elapsedRealtime() * 1000 - mDeltaUpdatedTimeUs;
+ return elapsedRealtimeMinus(mDeltaUs)
+ + (long) ((mPlaybackSpeed - 1.0f) * elapsedTimeFromPlaybackSpeedChanged);
+ } else {
+ return elapsedRealtimeMinus(mDeltaUs);
+ }
+ }
+
+ /** Sets playback speed. {@code speed} should be positive. */
+ public void setPlaybackSpeed(float speed) {
+ SoftPreconditions.checkState(speed > 0);
+ mDeltaUs = elapsedRealtimeMinus(getPositionUs());
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ mPlaybackSpeed = speed;
+ }
+
+ private long elapsedRealtimeMinus(long toSubtractUs) {
+ return SystemClock.elapsedRealtime() * 1000 - toSubtractUs;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java
new file mode 100644
index 00000000..64fe1344
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java
@@ -0,0 +1,69 @@
+/*
+ * 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.android.tv.tuner.exoplayer.audio;
+
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import java.nio.ByteBuffer;
+
+/** A base class for audio decoders. */
+public abstract class AudioDecoder {
+
+ /**
+ * Decodes an audio sample.
+ *
+ * @param sampleHolder a holder that contains the sample data and corresponding metadata
+ */
+ public abstract void decode(SampleHolder sampleHolder);
+
+ /** Returns a decoded sample from decoder. */
+ public abstract ByteBuffer getDecodedSample();
+
+ /** Returns the presentation time for the decoded sample. */
+ public abstract long getDecodedTimeUs();
+
+ /**
+ * Clear previous decode state if any. Prepares to decode samples of the specified encoding.
+ * This method should be called before using decode.
+ *
+ * @param mimeType audio encoding
+ */
+ public abstract void resetDecoderState(String mimeType);
+
+ /** Releases all the resource. */
+ public abstract void release();
+
+ /**
+ * Init decoder if needed.
+ *
+ * @param format the format used to initialize decoder
+ */
+ public void maybeInitDecoder(MediaFormat format) throws ExoPlaybackException {
+ // Do nothing.
+ }
+
+ /** Returns input buffer that will be used in decoder. */
+ public ByteBuffer getInputBuffer() {
+ return null;
+ }
+
+ /** Returns the output format. */
+ public android.media.MediaFormat getOutputFormat() {
+ return null;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java
new file mode 100644
index 00000000..28389017
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java
@@ -0,0 +1,140 @@
+/*
+ * 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.audio;
+
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Monitors the rendering position of {@link AudioTrack}. */
+public class AudioTrackMonitor {
+ private static final String TAG = "AudioTrackMonitor";
+ private static final boolean DEBUG = false;
+
+ // For fetched audio samples
+ private final ArrayList<Pair<Long, Integer>> mPtsList = new ArrayList<>();
+ private final Set<Integer> mSampleSize = new HashSet<>();
+ private final Set<Integer> mCurSampleSize = new HashSet<>();
+ private final Set<Integer> mHeader = new HashSet<>();
+
+ private long mExpireMs;
+ private long mDuration;
+ private long mSampleCount;
+ private long mTotalCount;
+ private long mStartMs;
+
+ private boolean mIsMp2;
+
+ private void flush() {
+ mExpireMs += mDuration;
+ mSampleCount = 0;
+ mCurSampleSize.clear();
+ mPtsList.clear();
+ }
+
+ /**
+ * Resets and initializes {@link AudioTrackMonitor}.
+ *
+ * @param duration the frequency of monitoring in milliseconds
+ */
+ public void reset(long duration) {
+ mExpireMs = SystemClock.elapsedRealtime();
+ mDuration = duration;
+ mTotalCount = 0;
+ mStartMs = 0;
+ mSampleSize.clear();
+ mHeader.clear();
+ flush();
+ }
+
+ public void setEncoding(String mime) {
+ mIsMp2 = MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mime);
+ }
+
+ /**
+ * Adds an audio sample information for monitoring.
+ *
+ * @param pts the presentation timestamp of the sample
+ * @param sampleSize the size in bytes of the sample
+ * @param header the bitrate &amp; sampling information header of the sample
+ */
+ public void addPts(long pts, int sampleSize, int header) {
+ mTotalCount++;
+ mSampleCount++;
+ mSampleSize.add(sampleSize);
+ mHeader.add(header);
+ mCurSampleSize.add(sampleSize);
+ if (mTotalCount == 1) {
+ mStartMs = SystemClock.elapsedRealtime();
+ }
+ if (mPtsList.isEmpty() || mPtsList.get(mPtsList.size() - 1).first != pts) {
+ mPtsList.add(Pair.create(pts, 1));
+ return;
+ }
+ Pair<Long, Integer> pair = mPtsList.get(mPtsList.size() - 1);
+ mPtsList.set(mPtsList.size() - 1, Pair.create(pair.first, pair.second + 1));
+ }
+
+ /**
+ * Logs if interested events are present.
+ *
+ * <p>Periodic logging is not enabled in release mode in order to avoid verbose logging.
+ */
+ public void maybeLog() {
+ long now = SystemClock.elapsedRealtime();
+ if (mExpireMs != 0 && now >= mExpireMs) {
+ if (DEBUG) {
+ long unitDuration =
+ mIsMp2
+ ? MpegTsDefaultAudioTrackRenderer.MP2_SAMPLE_DURATION_US
+ : MpegTsDefaultAudioTrackRenderer.AC3_SAMPLE_DURATION_US;
+ long sampleDuration = (mTotalCount - 1) * unitDuration / 1000;
+ long totalDuration = now - mStartMs;
+ StringBuilder ptsBuilder = new StringBuilder();
+ ptsBuilder
+ .append("PTS received ")
+ .append(mSampleCount)
+ .append(", ")
+ .append(totalDuration - sampleDuration)
+ .append(' ');
+
+ for (Pair<Long, Integer> pair : mPtsList) {
+ ptsBuilder
+ .append('[')
+ .append(pair.first)
+ .append(':')
+ .append(pair.second)
+ .append("], ");
+ }
+ Log.d(TAG, ptsBuilder.toString());
+ }
+ if (DEBUG || mCurSampleSize.size() > 1) {
+ Log.d(
+ TAG,
+ "PTS received sample size: "
+ + String.valueOf(mSampleSize)
+ + mCurSampleSize
+ + mHeader);
+ }
+ flush();
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java
new file mode 100644
index 00000000..7446c923
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java
@@ -0,0 +1,174 @@
+/*
+ * 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.audio;
+
+import android.media.MediaFormat;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.audio.AudioTrack;
+import java.nio.ByteBuffer;
+
+/**
+ * {@link AudioTrack} wrapper class for trickplay operations including FF/RW. FF/RW trickplay
+ * operations do not need framework {@link AudioTrack}. This wrapper class will do nothing in
+ * disabled status for those operations.
+ */
+public class AudioTrackWrapper {
+ private static final int PCM16_FRAME_BYTES = 2;
+ private static final int AC3_FRAMES_IN_ONE_SAMPLE = 1536;
+ private static final int BUFFERED_SAMPLES_IN_AUDIOTRACK =
+ MpegTsDefaultAudioTrackRenderer.BUFFERED_SAMPLES_IN_AUDIOTRACK;
+ private final AudioTrack mAudioTrack = new AudioTrack();
+ private int mAudioSessionID;
+ private boolean mIsEnabled;
+
+ AudioTrackWrapper() {
+ mIsEnabled = true;
+ }
+
+ public void resetSessionId() {
+ mAudioSessionID = AudioTrack.SESSION_ID_NOT_SET;
+ }
+
+ public boolean isInitialized() {
+ return mIsEnabled && mAudioTrack.isInitialized();
+ }
+
+ public void restart() {
+ if (mAudioTrack.isInitialized()) {
+ mAudioTrack.release();
+ }
+ mIsEnabled = true;
+ resetSessionId();
+ }
+
+ public void release() {
+ if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) {
+ mAudioTrack.release();
+ }
+ }
+
+ public void initialize() throws AudioTrack.InitializationException {
+ if (!mIsEnabled) {
+ return;
+ }
+ if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) {
+ mAudioTrack.initialize(mAudioSessionID);
+ } else {
+ mAudioSessionID = mAudioTrack.initialize();
+ }
+ }
+
+ public void reset() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.reset();
+ }
+
+ public boolean isEnded() {
+ return !mIsEnabled || !mAudioTrack.hasPendingData();
+ }
+
+ public boolean isReady() {
+ // In the case of not playing actual audio data, Audio track is always ready.
+ return !mIsEnabled || mAudioTrack.hasPendingData();
+ }
+
+ public void play() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.play();
+ }
+
+ public void pause() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.pause();
+ }
+
+ public void setVolume(float volume) {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.setVolume(volume);
+ }
+
+ public void reconfigure(MediaFormat format, int audioBufferSize) {
+ if (!mIsEnabled || format == null) {
+ return;
+ }
+ String mimeType = format.getString(MediaFormat.KEY_MIME);
+ int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
+ int sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+ int pcmEncoding;
+ try {
+ pcmEncoding = format.getInteger(MediaFormat.KEY_PCM_ENCODING);
+ } catch (Exception e) {
+ pcmEncoding = C.ENCODING_PCM_16BIT;
+ }
+ // TODO: Handle non-AC3.
+ if (MediaFormat.MIMETYPE_AUDIO_AC3.equalsIgnoreCase(mimeType) && channelCount != 2) {
+ // Workarounds b/25955476.
+ // Since all devices and platforms does not support passthrough for non-stereo AC3,
+ // It is safe to fake non-stereo AC3 as AC3 stereo which is default passthrough mode.
+ // In other words, the channel count should be always 2.
+ channelCount = 2;
+ }
+ if (MediaFormat.MIMETYPE_AUDIO_RAW.equalsIgnoreCase(mimeType)) {
+ audioBufferSize =
+ channelCount
+ * PCM16_FRAME_BYTES
+ * AC3_FRAMES_IN_ONE_SAMPLE
+ * BUFFERED_SAMPLES_IN_AUDIOTRACK;
+ }
+ mAudioTrack.configure(mimeType, channelCount, sampleRate, pcmEncoding, audioBufferSize);
+ }
+
+ public void handleDiscontinuity() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.handleDiscontinuity();
+ }
+
+ public int handleBuffer(ByteBuffer buffer, int offset, int size, long presentationTimeUs)
+ throws AudioTrack.WriteException {
+ if (!mIsEnabled) {
+ return AudioTrack.RESULT_BUFFER_CONSUMED;
+ }
+ return mAudioTrack.handleBuffer(buffer, offset, size, presentationTimeUs);
+ }
+
+ public void setStatus(boolean enable) {
+ if (enable == mIsEnabled) {
+ return;
+ }
+ mAudioTrack.reset();
+ mIsEnabled = enable;
+ }
+
+ public boolean isEnabled() {
+ return mIsEnabled;
+ }
+
+ // This should be used only in case of being enabled.
+ public long getCurrentPositionUs(boolean isEnded) {
+ return mAudioTrack.getCurrentPositionUs(isEnded);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java
new file mode 100644
index 00000000..80f91ebd
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java
@@ -0,0 +1,233 @@
+/*
+ * 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.android.tv.tuner.exoplayer.audio;
+
+import android.media.MediaCodec;
+import android.util.Log;
+import com.google.android.exoplayer.CodecCounters;
+import com.google.android.exoplayer.DecoderInfo;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaCodecUtil;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/** A decoder to use MediaCodec for decoding audio stream. */
+public class MediaCodecAudioDecoder extends AudioDecoder {
+ private static final String TAG = "MediaCodecAudioDecoder";
+
+ public static final int INDEX_INVALID = -1;
+
+ private final CodecCounters mCodecCounters;
+ private final MediaCodecSelector mSelector;
+
+ private MediaCodec mCodec;
+ private MediaCodec.BufferInfo mOutputBufferInfo;
+ private ByteBuffer mMediaCodecOutputBuffer;
+ private ArrayList<Long> mDecodeOnlyPresentationTimestamps;
+ private boolean mWaitingForFirstSyncFrame;
+ private boolean mIsNewIndex;
+ private int mInputIndex;
+ private int mOutputIndex;
+
+ /** Creates a MediaCodec based audio decoder. */
+ public MediaCodecAudioDecoder(MediaCodecSelector selector) {
+ mSelector = selector;
+ mOutputBufferInfo = new MediaCodec.BufferInfo();
+ mCodecCounters = new CodecCounters();
+ mDecodeOnlyPresentationTimestamps = new ArrayList<>();
+ }
+
+ /** Returns {@code true} if there is decoder for {@code mimeType}. */
+ public static boolean supportMimeType(MediaCodecSelector selector, String mimeType) {
+ if (selector == null) {
+ return false;
+ }
+ return getDecoderInfo(selector, mimeType) != null;
+ }
+
+ private static DecoderInfo getDecoderInfo(MediaCodecSelector selector, String mimeType) {
+ try {
+ return selector.getDecoderInfo(mimeType, false);
+ } catch (MediaCodecUtil.DecoderQueryException e) {
+ Log.e(TAG, "Select decoder error:" + e);
+ return null;
+ }
+ }
+
+ private boolean shouldInitCodec(MediaFormat format) {
+ return format != null && mCodec == null;
+ }
+
+ @Override
+ public void maybeInitDecoder(MediaFormat format) throws ExoPlaybackException {
+ if (!shouldInitCodec(format)) {
+ return;
+ }
+
+ String mimeType = format.mimeType;
+ DecoderInfo decoderInfo = getDecoderInfo(mSelector, mimeType);
+ if (decoderInfo == null) {
+ Log.i(TAG, "There is not decoder found for " + mimeType);
+ return;
+ }
+
+ String codecName = decoderInfo.name;
+ try {
+ mCodec = MediaCodec.createByCodecName(codecName);
+ mCodec.configure(format.getFrameworkMediaFormatV16(), null, null, 0);
+ mCodec.start();
+ } catch (Exception e) {
+ Log.e(TAG, "Failed when configure or start codec:" + e);
+ throw new ExoPlaybackException(e);
+ }
+ mInputIndex = INDEX_INVALID;
+ mOutputIndex = INDEX_INVALID;
+ mWaitingForFirstSyncFrame = true;
+ mCodecCounters.codecInitCount++;
+ }
+
+ @Override
+ public void resetDecoderState(String mimeType) {
+ if (mCodec == null) {
+ return;
+ }
+ mInputIndex = INDEX_INVALID;
+ mOutputIndex = INDEX_INVALID;
+ mDecodeOnlyPresentationTimestamps.clear();
+ mCodec.flush();
+ mWaitingForFirstSyncFrame = true;
+ }
+
+ @Override
+ public void release() {
+ if (mCodec != null) {
+ mDecodeOnlyPresentationTimestamps.clear();
+ mInputIndex = INDEX_INVALID;
+ mOutputIndex = INDEX_INVALID;
+ mCodecCounters.codecReleaseCount++;
+ try {
+ mCodec.stop();
+ } finally {
+ try {
+ mCodec.release();
+ } finally {
+ mCodec = null;
+ }
+ }
+ }
+ }
+
+ /** Returns the index of input buffer which is ready for using. */
+ public int getInputIndex() {
+ return mInputIndex;
+ }
+
+ @Override
+ public ByteBuffer getInputBuffer() {
+ if (mInputIndex < 0) {
+ mInputIndex = mCodec.dequeueInputBuffer(0);
+ if (mInputIndex < 0) {
+ return null;
+ }
+ return mCodec.getInputBuffer(mInputIndex);
+ }
+ return mCodec.getInputBuffer(mInputIndex);
+ }
+
+ @Override
+ public void decode(SampleHolder sampleHolder) {
+ if (mWaitingForFirstSyncFrame) {
+ if (!sampleHolder.isSyncFrame()) {
+ sampleHolder.clearData();
+ return;
+ }
+ mWaitingForFirstSyncFrame = false;
+ }
+ long presentationTimeUs = sampleHolder.timeUs;
+ if (sampleHolder.isDecodeOnly()) {
+ mDecodeOnlyPresentationTimestamps.add(presentationTimeUs);
+ }
+ mCodec.queueInputBuffer(mInputIndex, 0, sampleHolder.data.limit(), presentationTimeUs, 0);
+ mInputIndex = INDEX_INVALID;
+ mCodecCounters.inputBufferCount++;
+ }
+
+ private int getDecodeOnlyIndex(long presentationTimeUs) {
+ final int size = mDecodeOnlyPresentationTimestamps.size();
+ for (int i = 0; i < size; i++) {
+ if (mDecodeOnlyPresentationTimestamps.get(i).longValue() == presentationTimeUs) {
+ return i;
+ }
+ }
+ return INDEX_INVALID;
+ }
+
+ /** Returns the index of output buffer which is ready for using. */
+ public int getOutputIndex() {
+ if (mOutputIndex < 0) {
+ mOutputIndex = mCodec.dequeueOutputBuffer(mOutputBufferInfo, 0);
+ mIsNewIndex = true;
+ } else {
+ mIsNewIndex = false;
+ }
+ return mOutputIndex;
+ }
+
+ @Override
+ public android.media.MediaFormat getOutputFormat() {
+ return mCodec.getOutputFormat();
+ }
+
+ /** Returns {@code true} if the output is only for decoding but not for rendering. */
+ public boolean maybeDecodeOnlyIndex() {
+ int decodeOnlyIndex = getDecodeOnlyIndex(mOutputBufferInfo.presentationTimeUs);
+ if (decodeOnlyIndex != INDEX_INVALID) {
+ mCodec.releaseOutputBuffer(mOutputIndex, false);
+ mCodecCounters.skippedOutputBufferCount++;
+ mDecodeOnlyPresentationTimestamps.remove(decodeOnlyIndex);
+ mOutputIndex = INDEX_INVALID;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public ByteBuffer getDecodedSample() {
+ if (maybeDecodeOnlyIndex() || mOutputIndex < 0) {
+ return null;
+ }
+ if (mIsNewIndex) {
+ mMediaCodecOutputBuffer = mCodec.getOutputBuffer(mOutputIndex);
+ }
+ return mMediaCodecOutputBuffer;
+ }
+
+ @Override
+ public long getDecodedTimeUs() {
+ return mOutputBufferInfo.presentationTimeUs;
+ }
+
+ /** Releases the output buffer after rendering. */
+ public void releaseOutputBuffer() {
+ mCodecCounters.renderedOutputBufferCount++;
+ mCodec.releaseOutputBuffer(mOutputIndex, false);
+ mOutputIndex = INDEX_INVALID;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java
new file mode 100644
index 00000000..944cfbcf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java
@@ -0,0 +1,739 @@
+/*
+ * 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.audio;
+
+import android.media.MediaCodec;
+import android.os.Build;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.util.Log;
+import com.android.tv.tuner.tvinput.TunerDebug;
+import com.google.android.exoplayer.CodecCounters;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaClock;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.audio.AudioTrack;
+import com.google.android.exoplayer.util.Assertions;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/**
+ * Decodes and renders DTV audio. Supports MediaCodec based decoding, passthrough playback and
+ * ffmpeg based software decoding (AC3, MP2).
+ */
+public class MpegTsDefaultAudioTrackRenderer extends TrackRenderer implements MediaClock {
+ public static final int MSG_SET_VOLUME = 10000;
+ public static final int MSG_SET_AUDIO_TRACK = MSG_SET_VOLUME + 1;
+ public static final int MSG_SET_PLAYBACK_SPEED = MSG_SET_VOLUME + 2;
+
+ // ATSC/53 allows sample rate to be only 48Khz.
+ // One AC3 sample has 1536 frames, and its duration is 32ms.
+ public static final long AC3_SAMPLE_DURATION_US = 32000;
+
+ // TODO: Check whether DVB broadcasting uses sample rate other than 48Khz.
+ // MPEG-1 audio Layer II and III has 1152 frames per sample.
+ // 1152 frames duration is 24ms when sample rate is 48Khz.
+ static final long MP2_SAMPLE_DURATION_US = 24000;
+
+ // This is around 150ms, 150ms is big enough not to under-run AudioTrack,
+ // and 150ms is also small enough to fill the buffer rapidly.
+ static int BUFFERED_SAMPLES_IN_AUDIOTRACK = 5;
+ public static final long INITIAL_AUDIO_BUFFERING_TIME_US =
+ BUFFERED_SAMPLES_IN_AUDIOTRACK * AC3_SAMPLE_DURATION_US;
+
+ private static final String TAG = "MpegTsDefaultAudioTrac";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Interface definition for a callback to be notified of {@link
+ * com.google.android.exoplayer.audio.AudioTrack} error.
+ */
+ public interface EventListener {
+ void onAudioTrackInitializationError(AudioTrack.InitializationException e);
+
+ void onAudioTrackWriteError(AudioTrack.WriteException e);
+ }
+
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 16384 * 2;
+ private static final int DEFAULT_OUTPUT_BUFFER_SIZE = 1024 * 1024;
+ private static final int MONITOR_DURATION_MS = 1000;
+ private static final int AC3_HEADER_BITRATE_OFFSET = 4;
+ private static final int MP2_HEADER_BITRATE_OFFSET = 2;
+ private static final int MP2_HEADER_BITRATE_MASK = 0xfc;
+
+ // Keep this as static in order to prevent new framework AudioTrack creation
+ // while old AudioTrack is being released.
+ private static final AudioTrackWrapper AUDIO_TRACK = new AudioTrackWrapper();
+ private static final long KEEP_ALIVE_AFTER_EOS_DURATION_MS = 3000;
+
+ // Ignore AudioTrack backward movement if duration of movement is below the threshold.
+ private static final long BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US = 3000;
+
+ // AudioTrack position cannot go ahead beyond this limit.
+ private static final long CURRENT_POSITION_FROM_PTS_LIMIT_US = 1000000;
+
+ // Since MediaCodec processing and AudioTrack playing add delay,
+ // PTS interpolated time should be delayed reasonably when AudioTrack is not used.
+ private static final long ESTIMATED_TRACK_RENDERING_DELAY_US = 500000;
+
+ private final MediaCodecSelector mSelector;
+
+ private final CodecCounters mCodecCounters;
+ private final SampleSource.SampleSourceReader mSource;
+ private final MediaFormatHolder mFormatHolder;
+ private final EventListener mEventListener;
+ private final Handler mEventHandler;
+ private final AudioTrackMonitor mMonitor;
+ private final AudioClock mAudioClock;
+ private final boolean mAc3Passthrough;
+ private final boolean mSoftwareDecoderAvailable;
+
+ private MediaFormat mFormat;
+ private SampleHolder mSampleHolder;
+ private String mDecodingMime;
+ private boolean mFormatConfigured;
+ private int mSampleSize;
+ private final ByteBuffer mOutputBuffer;
+ private AudioDecoder mAudioDecoder;
+ private boolean mOutputReady;
+ private int mTrackIndex;
+ private boolean mSourceStateReady;
+ private boolean mInputStreamEnded;
+ private boolean mOutputStreamEnded;
+ private long mEndOfStreamMs;
+ private long mCurrentPositionUs;
+ private int mPresentationCount;
+ private long mPresentationTimeUs;
+ private long mInterpolatedTimeUs;
+ private long mPreviousPositionUs;
+ private boolean mIsStopped;
+ private boolean mEnabled = true;
+ private boolean mIsMuted;
+ private ArrayList<Integer> mTracksIndex;
+ private boolean mUseFrameworkDecoder;
+
+ public MpegTsDefaultAudioTrackRenderer(
+ SampleSource source,
+ MediaCodecSelector selector,
+ Handler eventHandler,
+ EventListener listener,
+ boolean hasSoftwareAudioDecoder,
+ boolean usePassthrough) {
+ mSource = source.register();
+ mSelector = selector;
+ mEventHandler = eventHandler;
+ mEventListener = listener;
+ mTrackIndex = -1;
+ mOutputBuffer = ByteBuffer.allocate(DEFAULT_OUTPUT_BUFFER_SIZE);
+ mFormatHolder = new MediaFormatHolder();
+ AUDIO_TRACK.restart();
+ mCodecCounters = new CodecCounters();
+ mMonitor = new AudioTrackMonitor();
+ mAudioClock = new AudioClock();
+ mTracksIndex = new ArrayList<>();
+ mAc3Passthrough = usePassthrough;
+ // TODO reimplement ffmpeg decoder check for google3
+ mSoftwareDecoderAvailable = false;
+ }
+
+ @Override
+ protected MediaClock getMediaClock() {
+ return this;
+ }
+
+ private boolean handlesMimeType(String mimeType) {
+ return mimeType.equals(MimeTypes.AUDIO_AC3)
+ || mimeType.equals(MimeTypes.AUDIO_E_AC3)
+ || mimeType.equals(MimeTypes.AUDIO_MPEG_L2)
+ || MediaCodecAudioDecoder.supportMimeType(mSelector, mimeType);
+ }
+
+ @Override
+ protected boolean doPrepare(long positionUs) throws ExoPlaybackException {
+ boolean sourcePrepared = mSource.prepare(positionUs);
+ if (!sourcePrepared) {
+ return false;
+ }
+ for (int i = 0; i < mSource.getTrackCount(); i++) {
+ String mimeType = mSource.getFormat(i).mimeType;
+ if (MimeTypes.isAudio(mimeType) && handlesMimeType(mimeType)) {
+ if (mTrackIndex < 0) {
+ mTrackIndex = i;
+ }
+ mTracksIndex.add(i);
+ }
+ }
+
+ // TODO: Check this case. Source does not have the proper mime type.
+ return true;
+ }
+
+ @Override
+ protected int getTrackCount() {
+ return mTracksIndex.size();
+ }
+
+ @Override
+ protected MediaFormat getFormat(int track) {
+ Assertions.checkArgument(track >= 0 && track < mTracksIndex.size());
+ return mSource.getFormat(mTracksIndex.get(track));
+ }
+
+ @Override
+ protected void onEnabled(int track, long positionUs, boolean joining) {
+ Assertions.checkArgument(track >= 0 && track < mTracksIndex.size());
+ mTrackIndex = mTracksIndex.get(track);
+ mSource.enable(mTrackIndex, positionUs);
+ seekToInternal(positionUs);
+ }
+
+ @Override
+ protected void onDisabled() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ AUDIO_TRACK.resetSessionId();
+ }
+ clearDecodeState();
+ mFormat = null;
+ mSource.disable(mTrackIndex);
+ }
+
+ @Override
+ protected void onReleased() {
+ releaseDecoder();
+ AUDIO_TRACK.release();
+ mSource.release();
+ }
+
+ @Override
+ protected boolean isEnded() {
+ return mOutputStreamEnded && AUDIO_TRACK.isEnded();
+ }
+
+ @Override
+ protected boolean isReady() {
+ return AUDIO_TRACK.isReady() || (mFormat != null && (mSourceStateReady || mOutputReady));
+ }
+
+ private void seekToInternal(long positionUs) {
+ mMonitor.reset(MONITOR_DURATION_MS);
+ mSourceStateReady = false;
+ mInputStreamEnded = false;
+ mOutputStreamEnded = false;
+ mPresentationTimeUs = positionUs;
+ mPresentationCount = 0;
+ mPreviousPositionUs = 0;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ mInterpolatedTimeUs = Long.MIN_VALUE;
+ mAudioClock.setPositionUs(positionUs);
+ }
+
+ @Override
+ protected void seekTo(long positionUs) {
+ mSource.seekToUs(positionUs);
+ AUDIO_TRACK.reset();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ // resetSessionId() will create a new framework AudioTrack instead of reusing old one.
+ AUDIO_TRACK.resetSessionId();
+ }
+ seekToInternal(positionUs);
+ clearDecodeState();
+ }
+
+ @Override
+ protected void onStarted() {
+ AUDIO_TRACK.play();
+ mAudioClock.start();
+ mIsStopped = false;
+ }
+
+ @Override
+ protected void onStopped() {
+ AUDIO_TRACK.pause();
+ mAudioClock.stop();
+ mIsStopped = true;
+ }
+
+ @Override
+ protected void maybeThrowError() throws ExoPlaybackException {
+ try {
+ mSource.maybeThrowError();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ @Override
+ protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
+ mMonitor.maybeLog();
+ try {
+ if (mEndOfStreamMs != 0) {
+ // Ensure playback stops, after EoS was notified.
+ // Sometimes MediaCodecTrackRenderer does not fetch EoS timely
+ // after EoS was notified here long before.
+ long diff = SystemClock.elapsedRealtime() - mEndOfStreamMs;
+ if (diff >= KEEP_ALIVE_AFTER_EOS_DURATION_MS && !mIsStopped) {
+ throw new ExoPlaybackException("Much time has elapsed after EoS");
+ }
+ }
+ boolean continueBuffering = mSource.continueBuffering(mTrackIndex, positionUs);
+ if (mSourceStateReady != continueBuffering) {
+ mSourceStateReady = continueBuffering;
+ if (DEBUG) {
+ Log.d(TAG, "mSourceStateReady: " + String.valueOf(mSourceStateReady));
+ }
+ }
+ long discontinuity = mSource.readDiscontinuity(mTrackIndex);
+ if (discontinuity != SampleSource.NO_DISCONTINUITY) {
+ AUDIO_TRACK.handleDiscontinuity();
+ mPresentationTimeUs = discontinuity;
+ mPresentationCount = 0;
+ clearDecodeState();
+ return;
+ }
+ if (mFormat == null) {
+ readFormat();
+ return;
+ }
+
+ if (mAudioDecoder != null) {
+ mAudioDecoder.maybeInitDecoder(mFormat);
+ }
+ // Process only one sample at a time for doSomeWork() when using FFmpeg decoder.
+ if (processOutput()) {
+ if (!mOutputReady) {
+ while (feedInputBuffer()) {
+ if (mOutputReady) break;
+ }
+ }
+ }
+ mCodecCounters.ensureUpdated();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ private void ensureAudioTrackInitialized() {
+ if (!AUDIO_TRACK.isInitialized()) {
+ try {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack initialized");
+ }
+ AUDIO_TRACK.initialize();
+ } catch (AudioTrack.InitializationException e) {
+ Log.e(TAG, "Error on AudioTrack initialization", e);
+ notifyAudioTrackInitializationError(e);
+
+ // Do not throw exception here but just disabling audioTrack to keep playing
+ // video without audio.
+ AUDIO_TRACK.setStatus(false);
+ }
+ if (getState() == TrackRenderer.STATE_STARTED) {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack played");
+ }
+ AUDIO_TRACK.play();
+ }
+ }
+ }
+
+ private void clearDecodeState() {
+ mOutputReady = false;
+ if (mAudioDecoder != null) {
+ mAudioDecoder.resetDecoderState(mDecodingMime);
+ }
+ AUDIO_TRACK.reset();
+ }
+
+ private void releaseDecoder() {
+ if (mAudioDecoder != null) {
+ mAudioDecoder.release();
+ }
+ }
+
+ private void readFormat() throws IOException, ExoPlaybackException {
+ int result =
+ mSource.readData(mTrackIndex, mCurrentPositionUs, mFormatHolder, mSampleHolder);
+ if (result == SampleSource.FORMAT_READ) {
+ onInputFormatChanged(mFormatHolder);
+ }
+ }
+
+ private MediaFormat convertMediaFormatToRaw(MediaFormat format) {
+ return MediaFormat.createAudioFormat(
+ format.trackId,
+ MimeTypes.AUDIO_RAW,
+ format.bitrate,
+ format.maxInputSize,
+ format.durationUs,
+ format.channelCount,
+ format.sampleRate,
+ format.initializationData,
+ format.language);
+ }
+
+ private void onInputFormatChanged(MediaFormatHolder formatHolder) throws ExoPlaybackException {
+ String mimeType = formatHolder.format.mimeType;
+ mUseFrameworkDecoder = MediaCodecAudioDecoder.supportMimeType(mSelector, mimeType);
+ if (mUseFrameworkDecoder) {
+ mAudioDecoder = new MediaCodecAudioDecoder(mSelector);
+ mFormat = formatHolder.format;
+ mAudioDecoder.maybeInitDecoder(mFormat);
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DISABLED);
+ // TODO reimplement ffmeg for google3
+ // Here use else if
+ // (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mimeType)
+ // || MimeTypes.AUDIO_AC3.equalsIgnoreCase(mimeType) && !mAc3Passthrough
+ // then set the audio decoder to ffmpeg
+ } else {
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
+ mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE);
+ mFormat = formatHolder.format;
+ releaseDecoder();
+ }
+ mFormatConfigured = true;
+ mMonitor.setEncoding(mimeType);
+ if (DEBUG && !mUseFrameworkDecoder) {
+ Log.d(TAG, "AudioTrack was configured to FORMAT: " + mFormat.toString());
+ }
+ clearDecodeState();
+ if (!mUseFrameworkDecoder) {
+ AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16(), 0);
+ }
+ }
+
+ private void onSampleSizeChanged(int sampleSize) {
+ if (DEBUG) {
+ Log.d(TAG, "Sample size was changed to : " + sampleSize);
+ }
+ clearDecodeState();
+ int audioBufferSize = sampleSize * BUFFERED_SAMPLES_IN_AUDIOTRACK;
+ mSampleSize = sampleSize;
+ AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16(), audioBufferSize);
+ }
+
+ private void onOutputFormatChanged(android.media.MediaFormat format) {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack was configured to FORMAT: " + format.toString());
+ }
+ AUDIO_TRACK.reconfigure(format, 0);
+ }
+
+ private boolean feedInputBuffer() throws IOException, ExoPlaybackException {
+ if (mInputStreamEnded) {
+ return false;
+ }
+
+ if (mUseFrameworkDecoder) {
+ boolean indexChanged =
+ ((MediaCodecAudioDecoder) mAudioDecoder).getInputIndex()
+ == MediaCodecAudioDecoder.INDEX_INVALID;
+ if (indexChanged) {
+ mSampleHolder.data = mAudioDecoder.getInputBuffer();
+ if (mSampleHolder.data != null) {
+ mSampleHolder.clearData();
+ } else {
+ return false;
+ }
+ }
+ } else {
+ mSampleHolder.data.clear();
+ mSampleHolder.size = 0;
+ }
+ int result =
+ mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, mSampleHolder);
+ switch (result) {
+ case SampleSource.NOTHING_READ:
+ {
+ return false;
+ }
+ case SampleSource.FORMAT_READ:
+ {
+ Log.i(TAG, "Format was read again");
+ onInputFormatChanged(mFormatHolder);
+ return true;
+ }
+ case SampleSource.END_OF_STREAM:
+ {
+ Log.i(TAG, "End of stream from SampleSource");
+ mInputStreamEnded = true;
+ return false;
+ }
+ default:
+ {
+ if (mSampleHolder.size != mSampleSize
+ && mFormatConfigured
+ && !mUseFrameworkDecoder) {
+ onSampleSizeChanged(mSampleHolder.size);
+ }
+ mSampleHolder.data.flip();
+ if (!mUseFrameworkDecoder) {
+ if (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mDecodingMime)) {
+ mMonitor.addPts(
+ mSampleHolder.timeUs,
+ mOutputBuffer.position(),
+ mSampleHolder.data.get(MP2_HEADER_BITRATE_OFFSET)
+ & MP2_HEADER_BITRATE_MASK);
+ } else {
+ mMonitor.addPts(
+ mSampleHolder.timeUs,
+ mOutputBuffer.position(),
+ mSampleHolder.data.get(AC3_HEADER_BITRATE_OFFSET) & 0xff);
+ }
+ }
+ if (mAudioDecoder != null) {
+ mAudioDecoder.decode(mSampleHolder);
+ if (mUseFrameworkDecoder) {
+ int outputIndex =
+ ((MediaCodecAudioDecoder) mAudioDecoder).getOutputIndex();
+ if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ onOutputFormatChanged(mAudioDecoder.getOutputFormat());
+ return true;
+ } else if (outputIndex < 0) {
+ return true;
+ }
+ if (((MediaCodecAudioDecoder) mAudioDecoder).maybeDecodeOnlyIndex()) {
+ AUDIO_TRACK.handleDiscontinuity();
+ return true;
+ }
+ }
+ ByteBuffer outputBuffer = mAudioDecoder.getDecodedSample();
+ long presentationTimeUs = mAudioDecoder.getDecodedTimeUs();
+ decodeDone(outputBuffer, presentationTimeUs);
+ } else {
+ decodeDone(mSampleHolder.data, mSampleHolder.timeUs);
+ }
+ return true;
+ }
+ }
+ }
+
+ private boolean processOutput() throws ExoPlaybackException {
+ if (mOutputStreamEnded) {
+ return false;
+ }
+ if (!mOutputReady) {
+ if (mInputStreamEnded) {
+ mOutputStreamEnded = true;
+ mEndOfStreamMs = SystemClock.elapsedRealtime();
+ return false;
+ }
+ return true;
+ }
+
+ ensureAudioTrackInitialized();
+ int handleBufferResult;
+ try {
+ // To reduce discontinuity, interpolate presentation time.
+ if (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mDecodingMime)) {
+ mInterpolatedTimeUs =
+ mPresentationTimeUs + mPresentationCount * MP2_SAMPLE_DURATION_US;
+ } else if (!mUseFrameworkDecoder) {
+ mInterpolatedTimeUs =
+ mPresentationTimeUs + mPresentationCount * AC3_SAMPLE_DURATION_US;
+ } else {
+ mInterpolatedTimeUs = mPresentationTimeUs;
+ }
+ handleBufferResult =
+ AUDIO_TRACK.handleBuffer(
+ mOutputBuffer, 0, mOutputBuffer.limit(), mInterpolatedTimeUs);
+ } catch (AudioTrack.WriteException e) {
+ notifyAudioTrackWriteError(e);
+ throw new ExoPlaybackException(e);
+ }
+ if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) {
+ Log.i(TAG, "Play discontinuity happened");
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+ if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) {
+ mCodecCounters.renderedOutputBufferCount++;
+ mOutputReady = false;
+ if (mUseFrameworkDecoder) {
+ ((MediaCodecAudioDecoder) mAudioDecoder).releaseOutputBuffer();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected long getDurationUs() {
+ return mSource.getFormat(mTrackIndex).durationUs;
+ }
+
+ @Override
+ protected long getBufferedPositionUs() {
+ long pos = mSource.getBufferedPositionUs();
+ return pos == UNKNOWN_TIME_US || pos == END_OF_TRACK_US
+ ? pos
+ : Math.max(pos, getPositionUs());
+ }
+
+ @Override
+ public long getPositionUs() {
+ if (!AUDIO_TRACK.isInitialized()) {
+ return mAudioClock.getPositionUs();
+ } else if (!AUDIO_TRACK.isEnabled()) {
+ if (mInterpolatedTimeUs > 0 && !mUseFrameworkDecoder) {
+ return mInterpolatedTimeUs - ESTIMATED_TRACK_RENDERING_DELAY_US;
+ }
+ return mPresentationTimeUs;
+ }
+ long audioTrackCurrentPositionUs = AUDIO_TRACK.getCurrentPositionUs(isEnded());
+ if (audioTrackCurrentPositionUs == AudioTrack.CURRENT_POSITION_NOT_SET) {
+ mPreviousPositionUs = 0L;
+ if (DEBUG) {
+ long oldPositionUs = Math.max(mCurrentPositionUs, 0);
+ long currentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs);
+ Log.d(
+ TAG,
+ "Audio position is not set, diff in us: "
+ + String.valueOf(currentPositionUs - oldPositionUs));
+ }
+ mCurrentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs);
+ } else {
+ if (mPreviousPositionUs
+ > audioTrackCurrentPositionUs + BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US) {
+ Log.e(
+ TAG,
+ "audio_position BACK JUMP: "
+ + (mPreviousPositionUs - audioTrackCurrentPositionUs));
+ mCurrentPositionUs = audioTrackCurrentPositionUs;
+ } else {
+ mCurrentPositionUs = Math.max(mCurrentPositionUs, audioTrackCurrentPositionUs);
+ }
+ mPreviousPositionUs = audioTrackCurrentPositionUs;
+ }
+ long upperBound = mPresentationTimeUs + CURRENT_POSITION_FROM_PTS_LIMIT_US;
+ if (mCurrentPositionUs > upperBound) {
+ mCurrentPositionUs = upperBound;
+ }
+ return mCurrentPositionUs;
+ }
+
+ private void decodeDone(ByteBuffer outputBuffer, long presentationTimeUs) {
+ if (outputBuffer == null || mOutputBuffer == null) {
+ return;
+ }
+ if (presentationTimeUs < 0) {
+ Log.e(TAG, "decodeDone - invalid presentationTimeUs");
+ return;
+ }
+
+ if (TunerDebug.ENABLED) {
+ TunerDebug.setAudioPtsUs(presentationTimeUs);
+ }
+
+ mOutputBuffer.clear();
+ Assertions.checkState(mOutputBuffer.remaining() >= outputBuffer.limit());
+
+ mOutputBuffer.put(outputBuffer);
+ if (presentationTimeUs == mPresentationTimeUs) {
+ mPresentationCount++;
+ } else {
+ mPresentationCount = 0;
+ mPresentationTimeUs = presentationTimeUs;
+ }
+ mOutputBuffer.flip();
+ mOutputReady = true;
+ }
+
+ private void notifyAudioTrackInitializationError(final AudioTrack.InitializationException e) {
+ if (mEventHandler == null || mEventListener == null) {
+ return;
+ }
+ mEventHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mEventListener.onAudioTrackInitializationError(e);
+ }
+ });
+ }
+
+ private void notifyAudioTrackWriteError(final AudioTrack.WriteException e) {
+ if (mEventHandler == null || mEventListener == null) {
+ return;
+ }
+ mEventHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mEventListener.onAudioTrackWriteError(e);
+ }
+ });
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ switch (messageType) {
+ case MSG_SET_VOLUME:
+ float volume = (Float) message;
+ // Workaround: we cannot mute the audio track by setting the volume to 0, we need to
+ // disable the AUDIO_TRACK for this intent. However, enabling/disabling audio track
+ // whenever volume is being set might cause side effects, therefore we only handle
+ // "explicit mute operations", i.e., only after certain non-zero volume has been
+ // set, the subsequent volume setting operations will be consider as mute/un-mute
+ // operations and thus enable/disable the audio track.
+ if (mIsMuted && volume > 0) {
+ mIsMuted = false;
+ if (mEnabled) {
+ setStatus(true);
+ }
+ } else if (!mIsMuted && volume == 0) {
+ mIsMuted = true;
+ if (mEnabled) {
+ setStatus(false);
+ }
+ }
+ AUDIO_TRACK.setVolume(volume);
+ break;
+ case MSG_SET_AUDIO_TRACK:
+ mEnabled = (Integer) message == 1;
+ setStatus(mEnabled);
+ break;
+ case MSG_SET_PLAYBACK_SPEED:
+ mAudioClock.setPlaybackSpeed((Float) message);
+ break;
+ default:
+ super.handleMessage(messageType, message);
+ }
+ }
+
+ private void setStatus(boolean enabled) {
+ if (enabled == AUDIO_TRACK.isEnabled()) {
+ return;
+ }
+ if (!enabled) {
+ // mAudioClock can be different from getPositionUs. In order to sync them,
+ // we set mAudioClock.
+ mAudioClock.setPositionUs(getPositionUs());
+ }
+ AUDIO_TRACK.setStatus(enabled);
+ if (enabled) {
+ // When AUDIO_TRACK is enabled, we need to clear AUDIO_TRACK and seek to
+ // the current position. If not, AUDIO_TRACK has the obsolete data.
+ seekTo(mAudioClock.getPositionUs());
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java
new file mode 100644
index 00000000..b382545f
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java
@@ -0,0 +1,94 @@
+/*
+ * 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.android.tv.tuner.exoplayer.audio;
+
+import android.os.Handler;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.SampleSource;
+
+/**
+ * MPEG-2 TS audio track renderer.
+ *
+ * <p>Since the audio output from {@link android.media.MediaExtractor} contains extra samples at the
+ * beginning, using original {@link MediaCodecAudioTrackRenderer} as audio renderer causes
+ * asynchronous Audio/Video outputs. This class calculates the offset of audio data and adjust the
+ * presentation times to avoid the asynchronous Audio/Video problem.
+ */
+public class MpegTsMediaCodecAudioTrackRenderer extends MediaCodecAudioTrackRenderer {
+ private final Ac3EventListener mListener;
+
+ public interface Ac3EventListener extends EventListener {
+ /**
+ * Invoked when a {@link android.media.PlaybackParams} set to an {@link
+ * android.media.AudioTrack} is not valid.
+ *
+ * @param e The corresponding exception.
+ */
+ void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e);
+ }
+
+ public MpegTsMediaCodecAudioTrackRenderer(
+ SampleSource source,
+ MediaCodecSelector mediaCodecSelector,
+ Handler eventHandler,
+ EventListener eventListener) {
+ super(source, mediaCodecSelector, eventHandler, eventListener);
+ mListener = (Ac3EventListener) eventListener;
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ if (messageType == MSG_SET_PLAYBACK_PARAMS) {
+ try {
+ super.handleMessage(messageType, message);
+ } catch (IllegalArgumentException e) {
+ if (isAudioTrackSetPlaybackParamsError(e)) {
+ notifyAudioTrackSetPlaybackParamsError(e);
+ }
+ }
+ return;
+ }
+ super.handleMessage(messageType, message);
+ }
+
+ private void notifyAudioTrackSetPlaybackParamsError(final IllegalArgumentException e) {
+ if (eventHandler != null && mListener != null) {
+ eventHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mListener.onAudioTrackSetPlaybackParamsError(e);
+ }
+ });
+ }
+ }
+
+ private static boolean isAudioTrackSetPlaybackParamsError(IllegalArgumentException e) {
+ if (e.getStackTrace() == null || e.getStackTrace().length < 1) {
+ return false;
+ }
+ for (StackTraceElement element : e.getStackTrace()) {
+ String elementString = element.toString();
+ if (elementString.startsWith("android.media.AudioTrack.setPlaybackParams")) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
new file mode 100644
index 00000000..3e4ab103
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
@@ -0,0 +1,683 @@
+/*
+ * 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.android.tv.common.SoftPreconditions;
+import com.android.tv.common.util.CommonUtils;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.google.android.exoplayer.SampleHolder;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.ConcurrentModificationException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 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, Pair<SampleChunk, Integer>>> 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 int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK;
+ private long mTotalWriteSize;
+ private long mTotalWriteTimeNs;
+ private float mWriteBandwidth = 0.0f;
+ private final AtomicInteger mSpeedCheckCount = new AtomicInteger();
+
+ 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;
+ }
+
+ /** A Track format which will be loaded and saved from the permanent storage for recordings. */
+ public static class TrackFormat {
+
+ /**
+ * The track id for the specified track. The track id will be used as a track identifier for
+ * recordings.
+ */
+ public final String trackId;
+
+ /** The {@link MediaFormat} for the specified track. */
+ public final MediaFormat format;
+
+ /**
+ * Creates TrackFormat.
+ *
+ * @param trackId
+ * @param format
+ */
+ public TrackFormat(String trackId, MediaFormat format) {
+ this.trackId = trackId;
+ this.format = format;
+ }
+ }
+
+ /** A Holder for a sample position which will be loaded from the index file for recordings. */
+ public static class PositionHolder {
+
+ /**
+ * The current sample position in microseconds. The position is identical to the
+ * PTS(presentation time stamp) of the sample.
+ */
+ public final long positionUs;
+
+ /** Base sample position for the current {@link SampleChunk}. */
+ public final long basePositionUs;
+
+ /** The file offset for the current sample in the current {@link SampleChunk}. */
+ public final int offset;
+
+ /**
+ * Creates a holder for a specific position in the recording.
+ *
+ * @param positionUs
+ * @param offset
+ */
+ public PositionHolder(long positionUs, long basePositionUs, int offset) {
+ this.positionUs = positionUs;
+ this.basePositionUs = basePositionUs;
+ this.offset = offset;
+ }
+ }
+
+ /** 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();
+
+ /**
+ * 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 List} of TrackFormat
+ */
+ List<TrackFormat> readTrackInfoFiles(boolean isAudio);
+
+ /**
+ * Reads key sample positions for each written sample from storage.
+ *
+ * @param trackId track name
+ * @return indexes of the specified track
+ * @throws IOException
+ */
+ ArrayList<PositionHolder> readIndexFile(String trackId) throws IOException;
+
+ /**
+ * Writes track information to storage.
+ *
+ * @param formatList {@list List} of TrackFormat
+ * @param isAudio {@code true} if it is for audio track
+ * @throws IOException
+ */
+ void writeTrackInfoFiles(List<TrackFormat> formatList, 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, Pair<SampleChunk, Integer>> 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;
+ }
+
+ public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) {
+ mEvictListeners.put(id, listener);
+ }
+
+ public void unregisterChunkEvictedListener(String id) {
+ mEvictListeners.remove(id);
+ }
+
+ 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 if it is needed.
+ *
+ * @param id the name of the track
+ * @param positionUs current position to write a sample in micro seconds.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @param currentChunk the current {@link SampleChunk} to write, {@code null} when to create a
+ * new {@link SampleChunk}.
+ * @param currentOffset the current offset to write.
+ * @return returns the created {@link SampleChunk}.
+ * @throws IOException
+ */
+ public SampleChunk createNewWriteFileIfNeeded(
+ String id,
+ long positionUs,
+ SamplePool samplePool,
+ SampleChunk currentChunk,
+ int currentOffset)
+ throws IOException {
+ if (!maybeEvictChunk()) {
+ throw new IOException("Not enough storage space");
+ }
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(id);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(id, map);
+ mStartPositionMap.put(id, positionUs);
+ mPendingDelete.init(id);
+ }
+ if (currentChunk == null) {
+ File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs));
+ SampleChunk sampleChunk =
+ mSampleChunkCreator.createSampleChunk(
+ samplePool, file, positionUs, mChunkCallback);
+ map.put(positionUs, new Pair(sampleChunk, 0));
+ return sampleChunk;
+ } else {
+ map.put(positionUs, new Pair(currentChunk, currentOffset));
+ return null;
+ }
+ }
+
+ /**
+ * 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<PositionHolder> keyPositions = mStorageManager.readIndexFile(trackId);
+ long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0).positionUs : 0;
+
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(trackId);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(trackId, map);
+ mStartPositionMap.put(trackId, startPositionUs);
+ mPendingDelete.init(trackId);
+ }
+ SampleChunk chunk = null;
+ long basePositionUs = -1;
+ for (PositionHolder position : keyPositions) {
+ if (position.basePositionUs != basePositionUs) {
+ chunk =
+ mSampleChunkCreator.loadSampleChunkFromFile(
+ samplePool,
+ mStorageManager.getBufferDir(),
+ getFileName(trackId, position.positionUs),
+ position.positionUs,
+ mChunkCallback,
+ chunk);
+ basePositionUs = position.basePositionUs;
+ }
+ map.put(position.positionUs, new Pair(chunk, position.offset));
+ }
+ }
+
+ /**
+ * 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 Pair<SampleChunk, Integer> getReadFile(String id, long positionUs) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(id);
+ if (map == null) {
+ return null;
+ }
+ Pair<SampleChunk, Integer> ret;
+ SortedMap<Long, Pair<SampleChunk, Integer>> headMap = map.headMap(positionUs + 1);
+ if (!headMap.isEmpty()) {
+ ret = headMap.get(headMap.lastKey());
+ } else {
+ ret = map.get(map.firstKey());
+ }
+ return ret;
+ }
+
+ /**
+ * 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, Pair<SampleChunk, Integer>> earliestChunkMap = null;
+ SampleChunk earliestChunk = null;
+ String earliestChunkId = null;
+ for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry :
+ mChunkMap.entrySet()) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ SampleChunk chunk = map.get(map.firstKey()).first;
+ 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(),
+ CommonUtils.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, Pair<SampleChunk, Integer>>> entry :
+ mChunkMap.entrySet()) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> 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 List<TrackFormat> readTrackInfoFiles() throws IOException {
+ List<TrackFormat> trackFormatList = new ArrayList<>();
+ trackFormatList.addAll(mStorageManager.readTrackInfoFiles(false));
+ trackFormatList.addAll(mStorageManager.readTrackInfoFiles(true));
+ if (trackFormatList.isEmpty()) {
+ throw new IOException("No track information to load");
+ }
+ return trackFormatList;
+ }
+
+ /**
+ * Writes track information and index information for all tracks.
+ *
+ * @param audios list of audio track information
+ * @param videos list of audio track information
+ * @throws IOException
+ */
+ public void writeMetaFiles(List<TrackFormat> audios, List<TrackFormat> videos)
+ throws IOException {
+ if (audios.isEmpty() && videos.isEmpty()) {
+ throw new IOException("No track information to save");
+ }
+ if (!audios.isEmpty()) {
+ mStorageManager.writeTrackInfoFiles(audios, true);
+ for (TrackFormat trackFormat : audios) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map =
+ mChunkMap.get(trackFormat.trackId);
+ if (map == null) {
+ throw new IOException("Audio track index missing");
+ }
+ mStorageManager.writeIndexFile(trackFormat.trackId, map);
+ }
+ }
+ if (!videos.isEmpty()) {
+ mStorageManager.writeTrackInfoFiles(videos, false);
+ for (TrackFormat trackFormat : videos) {
+ SortedMap<Long, Pair<SampleChunk, Integer>> map =
+ mChunkMap.get(trackFormat.trackId);
+ if (map == null) {
+ throw new IOException("Video track index missing");
+ }
+ mStorageManager.writeIndexFile(trackFormat.trackId, map);
+ }
+ }
+ }
+
+ /** Releases all the resources. */
+ public void release() {
+ try {
+ mPendingDelete.release();
+ for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry :
+ mChunkMap.entrySet()) {
+ SampleChunk toRelease = null;
+ for (Pair<SampleChunk, Integer> positions : entry.getValue().values()) {
+ if (toRelease != positions.first) {
+ toRelease = positions.first;
+ SampleChunk.IoState.release(toRelease, !mStorageManager.isPersistent());
+ }
+ }
+ }
+ mChunkMap.clear();
+ } catch (ConcurrentModificationException | NullPointerException e) {
+ // TODO: remove this after it it confirmed that race condition issues are resolved.
+ // b/32492258, b/32373376
+ SoftPreconditions.checkState(
+ false, "Exception on BufferManager#release: ", e.toString());
+ }
+ }
+
+ 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.get() > MAXIMUM_SPEED_CHECK_COUNT) {
+ return false;
+ }
+ mSpeedCheckCount.incrementAndGet();
+ 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);
+ }
+
+ /**
+ * Returns if {@link BufferManager} has checked the write speed, which is suitable for
+ * Trickplay.
+ */
+ @VisibleForTesting
+ public boolean hasSpeedCheckDone() {
+ return mSpeedCheckCount.get() > 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;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java
new file mode 100644
index 00000000..2a58ffcf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java
@@ -0,0 +1,391 @@
+/*
+ * 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.util.Log;
+import android.util.Pair;
+import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack;
+import com.google.protobuf.nano.MessageNano;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+/** Manages DVR storage. */
+public class DvrStorageManager implements BufferManager.StorageManager {
+ private static final String TAG = "DvrStorageManager";
+
+ // TODO: make serializable classes and use protobuf after internal data structure is finalized.
+ private static final String KEY_PIXEL_WIDTH_HEIGHT_RATIO =
+ "com.google.android.videos.pixelWidthHeightRatio";
+ private static final String META_FILE_TYPE_AUDIO = "audio";
+ private static final String META_FILE_TYPE_VIDEO = "video";
+ private static final String META_FILE_TYPE_CAPTION = "caption";
+ private static final String META_FILE_SUFFIX = ".meta";
+ private static final String IDX_FILE_SUFFIX = ".idx";
+ private static final String IDX_FILE_SUFFIX_V2 = IDX_FILE_SUFFIX + "2";
+
+ // Size of minimum reserved storage buffer which will be used to save meta files
+ // and index files after actual recording finished.
+ private static final long MIN_BUFFER_BYTES = 256L * 1024 * 1024;
+ private static final int NO_VALUE = -1;
+ private static final long NO_VALUE_LONG = -1L;
+
+ private final File mBufferDir;
+
+ // {@code true} when this is for recording, {@code false} when this is for replaying.
+ private final boolean mIsRecording;
+
+ public DvrStorageManager(File file, boolean isRecording) {
+ mBufferDir = file;
+ mBufferDir.mkdirs();
+ mIsRecording = isRecording;
+ }
+
+ @Override
+ public File getBufferDir() {
+ return mBufferDir;
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return true;
+ }
+
+ @Override
+ public boolean reachedStorageMax(long bufferSize, long pendingDelete) {
+ return false;
+ }
+
+ @Override
+ public boolean hasEnoughBuffer(long pendingDelete) {
+ return !mIsRecording || mBufferDir.getUsableSpace() >= MIN_BUFFER_BYTES;
+ }
+
+ private void readFormatInt(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ int val = in.readInt();
+ if (val != NO_VALUE) {
+ format.setInteger(key, val);
+ }
+ }
+
+ private void readFormatLong(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ long val = in.readLong();
+ if (val != NO_VALUE_LONG) {
+ format.setLong(key, val);
+ }
+ }
+
+ private void readFormatFloat(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ float val = in.readFloat();
+ if (val != NO_VALUE) {
+ format.setFloat(key, val);
+ }
+ }
+
+ private String readString(DataInputStream in) throws IOException {
+ int len = in.readInt();
+ if (len <= 0) {
+ return null;
+ }
+ byte[] strBytes = new byte[len];
+ in.readFully(strBytes);
+ return new String(strBytes, StandardCharsets.UTF_8);
+ }
+
+ private void readFormatString(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ String str = readString(in);
+ if (str != null) {
+ format.setString(key, str);
+ }
+ }
+
+ private void readFormatStringOptional(DataInputStream in, MediaFormat format, String key) {
+ try {
+ String str = readString(in);
+ if (str != null) {
+ format.setString(key, str);
+ }
+ } catch (IOException e) {
+ // Since we are reading optional field, ignore the exception.
+ }
+ }
+
+ private ByteBuffer readByteBuffer(DataInputStream in) throws IOException {
+ int len = in.readInt();
+ if (len <= 0) {
+ return null;
+ }
+ byte[] bytes = new byte[len];
+ in.readFully(bytes);
+ ByteBuffer buffer = ByteBuffer.allocate(len);
+ buffer.put(bytes);
+ buffer.flip();
+
+ return buffer;
+ }
+
+ private void readFormatByteBuffer(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ ByteBuffer buffer = readByteBuffer(in);
+ if (buffer != null) {
+ format.setByteBuffer(key, buffer);
+ }
+ }
+
+ @Override
+ public List<BufferManager.TrackFormat> readTrackInfoFiles(boolean isAudio) {
+ List<BufferManager.TrackFormat> trackFormatList = new ArrayList<>();
+ int index = 0;
+ boolean trackNotFound = false;
+ do {
+ String fileName =
+ (isAudio ? META_FILE_TYPE_AUDIO : META_FILE_TYPE_VIDEO)
+ + ((index == 0) ? META_FILE_SUFFIX : (index + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ String name = readString(in);
+ MediaFormat format = new MediaFormat();
+ readFormatString(in, format, MediaFormat.KEY_MIME);
+ readFormatInt(in, format, MediaFormat.KEY_MAX_INPUT_SIZE);
+ readFormatInt(in, format, MediaFormat.KEY_WIDTH);
+ readFormatInt(in, format, MediaFormat.KEY_HEIGHT);
+ readFormatInt(in, format, MediaFormat.KEY_CHANNEL_COUNT);
+ readFormatInt(in, format, MediaFormat.KEY_SAMPLE_RATE);
+ readFormatFloat(in, format, KEY_PIXEL_WIDTH_HEIGHT_RATIO);
+ for (int i = 0; i < 3; ++i) {
+ readFormatByteBuffer(in, format, "csd-" + i);
+ }
+ readFormatLong(in, format, MediaFormat.KEY_DURATION);
+
+ // This is optional since language field is added later.
+ readFormatStringOptional(in, format, MediaFormat.KEY_LANGUAGE);
+ trackFormatList.add(new BufferManager.TrackFormat(name, format));
+ } catch (IOException e) {
+ trackNotFound = true;
+ }
+ index++;
+ } while (!trackNotFound);
+ return trackFormatList;
+ }
+
+ /**
+ * Reads caption information from files.
+ *
+ * @return a list of {@link AtscCaptionTrack} objects which store caption information.
+ */
+ public List<AtscCaptionTrack> readCaptionInfoFiles() {
+ List<AtscCaptionTrack> tracks = new ArrayList<>();
+ int index = 0;
+ boolean trackNotFound = false;
+ do {
+ String fileName =
+ META_FILE_TYPE_CAPTION
+ + ((index == 0) ? META_FILE_SUFFIX : (index + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ byte[] data = new byte[(int) file.length()];
+ in.read(data);
+ tracks.add(AtscCaptionTrack.parseFrom(data));
+ } catch (IOException e) {
+ trackNotFound = true;
+ }
+ index++;
+ } while (!trackNotFound);
+ return tracks;
+ }
+
+ private ArrayList<BufferManager.PositionHolder> readOldIndexFile(File indexFile)
+ throws IOException {
+ ArrayList<BufferManager.PositionHolder> indices = new ArrayList<>();
+ try (DataInputStream in = new DataInputStream(new FileInputStream(indexFile))) {
+ long count = in.readLong();
+ for (long i = 0; i < count; ++i) {
+ long positionUs = in.readLong();
+ indices.add(new BufferManager.PositionHolder(positionUs, positionUs, 0));
+ }
+ return indices;
+ }
+ }
+
+ private ArrayList<BufferManager.PositionHolder> readNewIndexFile(File indexFile)
+ throws IOException {
+ ArrayList<BufferManager.PositionHolder> indices = new ArrayList<>();
+ try (DataInputStream in = new DataInputStream(new FileInputStream(indexFile))) {
+ long count = in.readLong();
+ for (long i = 0; i < count; ++i) {
+ long positionUs = in.readLong();
+ long basePositionUs = in.readLong();
+ int offset = in.readInt();
+ indices.add(new BufferManager.PositionHolder(positionUs, basePositionUs, offset));
+ }
+ return indices;
+ }
+ }
+
+ @Override
+ public ArrayList<BufferManager.PositionHolder> readIndexFile(String trackId)
+ throws IOException {
+ File file = new File(getBufferDir(), trackId + IDX_FILE_SUFFIX_V2);
+ if (file.exists()) {
+ return readNewIndexFile(file);
+ } else {
+ return readOldIndexFile(new File(getBufferDir(), trackId + IDX_FILE_SUFFIX));
+ }
+ }
+
+ private void writeFormatInt(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeInt(format.getInteger(key));
+ } else {
+ out.writeInt(NO_VALUE);
+ }
+ }
+
+ private void writeFormatLong(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeLong(format.getLong(key));
+ } else {
+ out.writeLong(NO_VALUE_LONG);
+ }
+ }
+
+ private void writeFormatFloat(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeFloat(format.getFloat(key));
+ } else {
+ out.writeFloat(NO_VALUE);
+ }
+ }
+
+ private void writeString(DataOutputStream out, String str) throws IOException {
+ byte[] data = str.getBytes(StandardCharsets.UTF_8);
+ out.writeInt(data.length);
+ if (data.length > 0) {
+ out.write(data);
+ }
+ }
+
+ private void writeFormatString(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ writeString(out, format.getString(key));
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ private void writeByteBuffer(DataOutputStream out, ByteBuffer buffer) throws IOException {
+ byte[] data = new byte[buffer.limit()];
+ buffer.get(data);
+ buffer.flip();
+ out.writeInt(data.length);
+ if (data.length > 0) {
+ out.write(data);
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ private void writeFormatByteBuffer(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ writeByteBuffer(out, format.getByteBuffer(key));
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ @Override
+ public void writeTrackInfoFiles(List<BufferManager.TrackFormat> formatList, boolean isAudio)
+ throws IOException {
+ for (int i = 0; i < formatList.size(); ++i) {
+ BufferManager.TrackFormat trackFormat = formatList.get(i);
+ String fileName =
+ (isAudio ? META_FILE_TYPE_AUDIO : META_FILE_TYPE_VIDEO)
+ + ((i == 0) ? META_FILE_SUFFIX : (i + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
+ writeString(out, trackFormat.trackId);
+ writeFormatString(out, trackFormat.format, MediaFormat.KEY_MIME);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_MAX_INPUT_SIZE);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_WIDTH);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_HEIGHT);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_CHANNEL_COUNT);
+ writeFormatInt(out, trackFormat.format, MediaFormat.KEY_SAMPLE_RATE);
+ writeFormatFloat(out, trackFormat.format, KEY_PIXEL_WIDTH_HEIGHT_RATIO);
+ for (int j = 0; j < 3; ++j) {
+ writeFormatByteBuffer(out, trackFormat.format, "csd-" + j);
+ }
+ writeFormatLong(out, trackFormat.format, MediaFormat.KEY_DURATION);
+ writeFormatString(out, trackFormat.format, MediaFormat.KEY_LANGUAGE);
+ }
+ }
+ }
+
+ /**
+ * Writes caption information to files.
+ *
+ * @param tracks a list of {@link AtscCaptionTrack} objects which store caption information.
+ */
+ public void writeCaptionInfoFiles(List<AtscCaptionTrack> tracks) {
+ if (tracks == null || tracks.isEmpty()) {
+ return;
+ }
+ for (int i = 0; i < tracks.size(); i++) {
+ AtscCaptionTrack track = tracks.get(i);
+ String fileName =
+ META_FILE_TYPE_CAPTION + ((i == 0) ? META_FILE_SUFFIX : (i + META_FILE_SUFFIX));
+ File file = new File(getBufferDir(), fileName);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
+ out.write(MessageNano.toByteArray(track));
+ } catch (Exception e) {
+ Log.e(TAG, "Fail to write caption info to files", e);
+ }
+ }
+ }
+
+ @Override
+ public void writeIndexFile(String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index)
+ throws IOException {
+ File indexFile = new File(getBufferDir(), trackName + IDX_FILE_SUFFIX_V2);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(indexFile))) {
+ out.writeLong(index.size());
+ for (Map.Entry<Long, Pair<SampleChunk, Integer>> entry : index.entrySet()) {
+ out.writeLong(entry.getKey());
+ out.writeLong(entry.getValue().first.getStartPositionUs());
+ out.writeInt(entry.getValue().second);
+ }
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java
new file mode 100644
index 00000000..ebf00f59
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java
@@ -0,0 +1,303 @@
+/*
+ * 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.android.tv.tuner.exoplayer.buffer;
+
+import android.os.ConditionVariable;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.util.Assertions;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Handles I/O between {@link SampleExtractor} and {@link BufferManager}.Reads & writes samples
+ * from/to {@link SampleChunk} which is backed by physical storage.
+ */
+public class RecordingSampleBuffer
+ implements BufferManager.SampleBuffer, BufferManager.ChunkEvictedListener {
+ private static final String TAG = "RecordingSampleBuffer";
+
+ @IntDef({BUFFER_REASON_LIVE_PLAYBACK, BUFFER_REASON_RECORDED_PLAYBACK, BUFFER_REASON_RECORDING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface BufferReason {}
+
+ /** A buffer reason for live-stream playback. */
+ public static final int BUFFER_REASON_LIVE_PLAYBACK = 0;
+
+ /** A buffer reason for playback of a recorded program. */
+ public static final int BUFFER_REASON_RECORDED_PLAYBACK = 1;
+
+ /** A buffer reason for recording a program. */
+ public static final int BUFFER_REASON_RECORDING = 2;
+
+ /** The minimum duration to support seek in Trickplay. */
+ static final long MIN_SEEK_DURATION_US = TimeUnit.MILLISECONDS.toMicros(500);
+
+ /** The duration of a {@link SampleChunk} for recordings. */
+ static final long RECORDING_CHUNK_DURATION_US = MIN_SEEK_DURATION_US * 1200; // 10 minutes
+
+ private static final long BUFFER_WRITE_TIMEOUT_MS = 10 * 1000; // 10 seconds
+ private static final long BUFFER_NEEDED_US =
+ 1000L * Math.max(MpegTsPlayer.MIN_BUFFER_MS, MpegTsPlayer.MIN_REBUFFER_MS);
+
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+ private final @BufferReason int mBufferReason;
+
+ private int mTrackCount;
+ private boolean[] mTrackSelected;
+ private List<SampleQueue> mReadSampleQueues;
+ private final SamplePool mSamplePool = new SamplePool();
+ private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US;
+ private long mCurrentPlaybackPositionUs = 0;
+
+ // An error in I/O thread of {@link SampleChunkIoHelper} will be notified.
+ private volatile boolean mError;
+
+ // Eos was reached in I/O thread of {@link SampleChunkIoHelper}.
+ private volatile boolean mEos;
+ private SampleChunkIoHelper mSampleChunkIoHelper;
+ private final SampleChunkIoHelper.IoCallback mIoCallback =
+ new SampleChunkIoHelper.IoCallback() {
+ @Override
+ public void onIoReachedEos() {
+ mEos = true;
+ }
+
+ @Override
+ public void onIoError() {
+ mError = true;
+ }
+ };
+
+ /**
+ * Creates {@link BufferManager.SampleBuffer} with cached I/O backed by physical storage (e.g.
+ * trickplay,recording,recorded-playback).
+ *
+ * @param bufferManager the manager of {@link SampleChunk}
+ * @param bufferListener the listener for buffer I/O event
+ * @param enableTrickplay {@code true} when trickplay should be enabled
+ * @param bufferReason the reason for caching samples {@link RecordingSampleBuffer.BufferReason}
+ */
+ public RecordingSampleBuffer(
+ BufferManager bufferManager,
+ PlaybackBufferListener bufferListener,
+ boolean enableTrickplay,
+ @BufferReason int bufferReason) {
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ if (bufferListener != null) {
+ bufferListener.onBufferStateChanged(enableTrickplay);
+ }
+ mBufferReason = bufferReason;
+ }
+
+ @Override
+ public void init(@NonNull List<String> ids, @NonNull List<MediaFormat> mediaFormats)
+ throws IOException {
+ mTrackCount = ids.size();
+ if (mTrackCount <= 0) {
+ throw new IOException("No tracks to initialize");
+ }
+ mTrackSelected = new boolean[mTrackCount];
+ mReadSampleQueues = new ArrayList<>();
+ mSampleChunkIoHelper =
+ new SampleChunkIoHelper(
+ ids, mediaFormats, mBufferReason, mBufferManager, mSamplePool, mIoCallback);
+ for (int i = 0; i < mTrackCount; ++i) {
+ mReadSampleQueues.add(i, new SampleQueue(mSamplePool));
+ }
+ mSampleChunkIoHelper.init();
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.registerChunkEvictedListener(ids.get(i), RecordingSampleBuffer.this);
+ }
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ if (!mTrackSelected[index]) {
+ mTrackSelected[index] = true;
+ mReadSampleQueues.get(index).clear();
+ mSampleChunkIoHelper.openRead(index, mCurrentPlaybackPositionUs);
+ }
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ if (mTrackSelected[index]) {
+ mTrackSelected[index] = false;
+ mReadSampleQueues.get(index).clear();
+ mSampleChunkIoHelper.closeRead(index);
+ }
+ }
+
+ @Override
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ mSampleChunkIoHelper.writeSample(index, sample, conditionVariable);
+
+ if (!conditionVariable.block(BUFFER_WRITE_TIMEOUT_MS)) {
+ Log.e(TAG, "Error: Serious delay on writing buffer");
+ conditionVariable.block();
+ }
+ }
+
+ @Override
+ public boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs) {
+ if (mBufferReason == BUFFER_REASON_RECORDED_PLAYBACK) {
+ return false;
+ }
+ mBufferManager.addWriteStat(sampleSize, writeDurationNs);
+ return mBufferManager.isWriteSlow();
+ }
+
+ @Override
+ public void handleWriteSpeedSlow() throws IOException {
+ if (mBufferReason == BUFFER_REASON_RECORDING) {
+ // Recording does not need to stop because I/O speed is slow temporarily.
+ // If fixed size buffer of TsStreamer overflows, TsDataSource will reach EoS.
+ // Reaching EoS will stop recording eventually.
+ Log.w(
+ TAG,
+ "Disk I/O speed is slow for recording temporarily: "
+ + mBufferManager.getWriteBandwidth()
+ + "MBps");
+ return;
+ }
+ // Disables buffering samples afterwards, and notifies the disk speed is slow.
+ Log.w(TAG, "Disk is too slow for trickplay");
+ mBufferListener.onDiskTooSlow();
+ }
+
+ @Override
+ public void setEos() {
+ mSampleChunkIoHelper.closeWrite();
+ }
+
+ private boolean maybeReadSample(SampleQueue queue, int index) {
+ if (queue.getLastQueuedPositionUs() != null
+ && queue.getLastQueuedPositionUs() > mCurrentPlaybackPositionUs + BUFFER_NEEDED_US
+ && queue.isDurationGreaterThan(MIN_SEEK_DURATION_US)) {
+ // The speed of queuing samples can be higher than the playback speed.
+ // If the duration of the samples in the queue is not limited,
+ // samples can be accumulated and there can be out-of-memory issues.
+ // But, the throttling should provide enough samples for the player to
+ // finish the buffering state.
+ return false;
+ }
+ SampleHolder sample = mSampleChunkIoHelper.readSample(index);
+ if (sample != null) {
+ queue.queueSample(sample);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder outSample) {
+ Assertions.checkState(mTrackSelected[track]);
+ maybeReadSample(mReadSampleQueues.get(track), track);
+ int result = mReadSampleQueues.get(track).dequeueSample(outSample);
+ if ((result != SampleSource.SAMPLE_READ && mEos) || mError) {
+ return SampleSource.END_OF_STREAM;
+ }
+ return result;
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (mTrackSelected[i]) {
+ mReadSampleQueues.get(i).clear();
+ mSampleChunkIoHelper.openRead(i, positionUs);
+ }
+ }
+ mLastBufferedPositionUs = positionUs;
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ Long result = null;
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mTrackSelected[i]) {
+ continue;
+ }
+ Long lastQueuedSamplePositionUs = mReadSampleQueues.get(i).getLastQueuedPositionUs();
+ if (lastQueuedSamplePositionUs == null) {
+ // No sample has been queued.
+ result = mLastBufferedPositionUs;
+ continue;
+ }
+ if (result == null || result > lastQueuedSamplePositionUs) {
+ result = lastQueuedSamplePositionUs;
+ }
+ }
+ if (result == null) {
+ return mLastBufferedPositionUs;
+ }
+ return (mLastBufferedPositionUs = result);
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ mCurrentPlaybackPositionUs = positionUs;
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mTrackSelected[i]) {
+ continue;
+ }
+ SampleQueue queue = mReadSampleQueues.get(i);
+ maybeReadSample(queue, i);
+ if (queue.getLastQueuedPositionUs() == null
+ || positionUs > queue.getLastQueuedPositionUs()) {
+ // No more buffered data.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void release() throws IOException {
+ if (mTrackCount <= 0) {
+ return;
+ }
+ if (mSampleChunkIoHelper != null) {
+ mSampleChunkIoHelper.release();
+ }
+ }
+
+ // onChunkEvictedListener
+ @Override
+ public void onChunkEvicted(String id, long createdTimeMs) {
+ if (mBufferListener != null) {
+ mBufferListener.onBufferStartTimeChanged(
+ createdTimeMs + TimeUnit.MICROSECONDS.toMillis(MIN_SEEK_DURATION_US));
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java
new file mode 100644
index 00000000..bf77a6eb
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java
@@ -0,0 +1,433 @@
+/*
+ * 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.android.tv.tuner.exoplayer.buffer;
+
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+import com.google.android.exoplayer.SampleHolder;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileChannel;
+
+/**
+ * {@link SampleChunk} stores samples into file and makes them available for read. Stored file = {
+ * Header, Sample } * N Header = sample size : int, sample flag : int, sample PTS in micro second :
+ * long
+ */
+public class SampleChunk {
+ private static final String TAG = "SampleChunk";
+ private static final boolean DEBUG = false;
+
+ private final long mCreatedTimeMs;
+ private final long mStartPositionUs;
+ private SampleChunk mNextChunk;
+
+ // Header = sample size : int, sample flag : int, sample PTS in micro second : long
+ private static final int SAMPLE_HEADER_LENGTH = 16;
+
+ private final File mFile;
+ private final ChunkCallback mChunkCallback;
+ private final SamplePool mSamplePool;
+ private RandomAccessFile mAccessFile;
+ private long mWriteOffset;
+ private boolean mWriteFinished;
+ private boolean mIsReading;
+ private boolean mIsWriting;
+
+ /** A callback for chunks being committed to permanent storage. */
+ public abstract static class ChunkCallback {
+
+ /**
+ * Notifies when writing a SampleChunk is completed.
+ *
+ * @param chunk SampleChunk which is written completely
+ */
+ public void onChunkWrite(SampleChunk chunk) {}
+
+ /**
+ * Notifies when a SampleChunk is deleted.
+ *
+ * @param chunk SampleChunk which is deleted from storage
+ */
+ public void onChunkDelete(SampleChunk chunk) {}
+ }
+
+ /** A class for SampleChunk creation. */
+ public static class SampleChunkCreator {
+
+ /**
+ * Returns a newly created SampleChunk to read & write samples.
+ *
+ * @param samplePool sample allocator
+ * @param file filename which will be created newly
+ * @param startPositionUs the start position of the earliest sample to be stored
+ * @param chunkCallback for total storage usage change notification
+ */
+ @VisibleForTesting
+ public SampleChunk createSampleChunk(
+ SamplePool samplePool,
+ File file,
+ long startPositionUs,
+ ChunkCallback chunkCallback) {
+ return new SampleChunk(
+ samplePool, file, startPositionUs, System.currentTimeMillis(), chunkCallback);
+ }
+
+ /**
+ * Returns a newly created SampleChunk which is backed by an existing file. Created
+ * SampleChunk is read-only.
+ *
+ * @param samplePool sample allocator
+ * @param bufferDir the directory where the file to read is located
+ * @param filename the filename which will be read afterwards
+ * @param startPositionUs the start position of the earliest sample in the file
+ * @param chunkCallback for total storage usage change notification
+ * @param prev the previous SampleChunk just before the newly created SampleChunk
+ * @throws IOException
+ */
+ SampleChunk loadSampleChunkFromFile(
+ SamplePool samplePool,
+ File bufferDir,
+ String filename,
+ long startPositionUs,
+ ChunkCallback chunkCallback,
+ SampleChunk prev)
+ throws IOException {
+ File file = new File(bufferDir, filename);
+ SampleChunk chunk = new SampleChunk(samplePool, file, startPositionUs, chunkCallback);
+ if (prev != null) {
+ prev.mNextChunk = chunk;
+ }
+ return chunk;
+ }
+ }
+
+ /**
+ * Handles I/O for SampleChunk. Maintains current SampleChunk and the current offset for next
+ * I/O operation.
+ */
+ @VisibleForTesting
+ public static class IoState {
+ private SampleChunk mChunk;
+ private long mCurrentOffset;
+
+ private boolean equals(SampleChunk chunk, long offset) {
+ return chunk == mChunk && mCurrentOffset == offset;
+ }
+
+ /** Returns whether read I/O operation is finished. */
+ boolean isReadFinished() {
+ return mChunk == null;
+ }
+
+ /** Returns the start position of the current SampleChunk */
+ long getStartPositionUs() {
+ return mChunk == null ? 0 : mChunk.getStartPositionUs();
+ }
+
+ private void reset(@Nullable SampleChunk chunk) {
+ mChunk = chunk;
+ mCurrentOffset = 0;
+ }
+
+ private void reset(SampleChunk chunk, long offset) {
+ mChunk = chunk;
+ mCurrentOffset = offset;
+ }
+
+ /**
+ * Prepares for read I/O operation from a new SampleChunk.
+ *
+ * @param chunk the new SampleChunk to read from
+ * @throws IOException
+ */
+ void openRead(SampleChunk chunk, long offset) throws IOException {
+ if (mChunk != null) {
+ mChunk.closeRead();
+ }
+ chunk.openRead();
+ reset(chunk, offset);
+ }
+
+ /**
+ * Prepares for write I/O operation to a new SampleChunk.
+ *
+ * @param chunk the new SampleChunk to write samples afterwards
+ * @throws IOException
+ */
+ void openWrite(SampleChunk chunk) throws IOException {
+ if (mChunk != null) {
+ mChunk.closeWrite(chunk);
+ }
+ chunk.openWrite();
+ reset(chunk);
+ }
+
+ /**
+ * Reads a sample if it is available.
+ *
+ * @return Returns a sample if it is available, null otherwise.
+ * @throws IOException
+ */
+ SampleHolder read() throws IOException {
+ if (mChunk != null && mChunk.isReadFinished(this)) {
+ SampleChunk next = mChunk.mNextChunk;
+ mChunk.closeRead();
+ if (next != null) {
+ next.openRead();
+ }
+ reset(next);
+ }
+ if (mChunk != null) {
+ try {
+ return mChunk.read(this);
+ } catch (IllegalStateException e) {
+ // Write is finished and there is no additional buffer to read.
+ Log.w(TAG, "Tried to read sample over EOS.");
+ return null;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Writes a sample.
+ *
+ * @param sample to write
+ * @param nextChunk if this is {@code null} writes at the current SampleChunk, otherwise
+ * close current SampleChunk and writes at this
+ * @throws IOException
+ */
+ void write(SampleHolder sample, SampleChunk nextChunk) throws IOException {
+ if (mChunk == null) {
+ throw new IOException("mChunk should not be null");
+ }
+ if (nextChunk != null) {
+ if (mChunk.mNextChunk != null) {
+ throw new IllegalStateException("Requested write for wrong SampleChunk");
+ }
+ mChunk.closeWrite(nextChunk);
+ mChunk.mChunkCallback.onChunkWrite(mChunk);
+ nextChunk.openWrite();
+ reset(nextChunk);
+ }
+ mChunk.write(sample, this);
+ }
+
+ /**
+ * Finishes write I/O operation.
+ *
+ * @throws IOException
+ */
+ void closeWrite() throws IOException {
+ if (mChunk != null) {
+ mChunk.closeWrite(null);
+ }
+ }
+
+ /** Returns the current SampleChunk for subsequent I/O operation. */
+ SampleChunk getChunk() {
+ return mChunk;
+ }
+
+ /** Returns the current offset of the current SampleChunk for subsequent I/O operation. */
+ long getOffset() {
+ return mCurrentOffset;
+ }
+
+ /**
+ * Releases SampleChunk. the SampleChunk will not be used anymore.
+ *
+ * @param chunk to release
+ * @param delete {@code true} when the backed file needs to be deleted, {@code false}
+ * otherwise.
+ */
+ static void release(SampleChunk chunk, boolean delete) {
+ chunk.release(delete);
+ }
+ }
+
+ @VisibleForTesting
+ protected SampleChunk(
+ SamplePool samplePool,
+ File file,
+ long startPositionUs,
+ long createdTimeMs,
+ ChunkCallback chunkCallback) {
+ mStartPositionUs = startPositionUs;
+ mCreatedTimeMs = createdTimeMs;
+ mSamplePool = samplePool;
+ mFile = file;
+ mChunkCallback = chunkCallback;
+ }
+
+ // Constructor of SampleChunk which is backed by the given existing file.
+ private SampleChunk(
+ SamplePool samplePool, File file, long startPositionUs, ChunkCallback chunkCallback)
+ throws IOException {
+ mStartPositionUs = startPositionUs;
+ mCreatedTimeMs = mStartPositionUs / 1000;
+ mSamplePool = samplePool;
+ mFile = file;
+ mChunkCallback = chunkCallback;
+ mWriteFinished = true;
+ }
+
+ private void openRead() throws IOException {
+ if (!mIsReading) {
+ if (mAccessFile == null) {
+ mAccessFile = new RandomAccessFile(mFile, "r");
+ }
+ if (mWriteFinished && mWriteOffset == 0) {
+ // Lazy loading of write offset, in order not to load
+ // all SampleChunk's write offset at start time of recorded playback.
+ mWriteOffset = mAccessFile.length();
+ }
+ mIsReading = true;
+ }
+ }
+
+ private void openWrite() throws IOException {
+ if (mWriteFinished) {
+ throw new IllegalStateException("Opened for write though write is already finished");
+ }
+ if (!mIsWriting) {
+ if (mIsReading) {
+ throw new IllegalStateException(
+ "Write is requested for " + "an already opened SampleChunk");
+ }
+ mAccessFile = new RandomAccessFile(mFile, "rw");
+ mIsWriting = true;
+ }
+ }
+
+ private void CloseAccessFileIfNeeded() throws IOException {
+ if (!mIsReading && !mIsWriting) {
+ try {
+ if (mAccessFile != null) {
+ mAccessFile.close();
+ }
+ } finally {
+ mAccessFile = null;
+ }
+ }
+ }
+
+ private void closeRead() throws IOException {
+ if (mIsReading) {
+ mIsReading = false;
+ CloseAccessFileIfNeeded();
+ }
+ }
+
+ private void closeWrite(SampleChunk nextChunk) throws IOException {
+ if (mIsWriting) {
+ mNextChunk = nextChunk;
+ mIsWriting = false;
+ mWriteFinished = true;
+ CloseAccessFileIfNeeded();
+ }
+ }
+
+ private boolean isReadFinished(IoState state) {
+ return mWriteFinished && state.equals(this, mWriteOffset);
+ }
+
+ private SampleHolder read(IoState state) throws IOException {
+ if (mAccessFile == null || state.mChunk != this) {
+ throw new IllegalStateException("Requested read for wrong SampleChunk");
+ }
+ long offset = state.mCurrentOffset;
+ if (offset >= mWriteOffset) {
+ if (mWriteFinished) {
+ throw new IllegalStateException("Requested read for wrong range");
+ } else {
+ if (offset != mWriteOffset) {
+ Log.e(TAG, "This should not happen!");
+ }
+ return null;
+ }
+ }
+ mAccessFile.seek(offset);
+ int size = mAccessFile.readInt();
+ SampleHolder sample = mSamplePool.acquireSample(size);
+ sample.size = size;
+ sample.flags = mAccessFile.readInt();
+ sample.timeUs = mAccessFile.readLong();
+ sample.clearData();
+ sample.data.put(
+ mAccessFile
+ .getChannel()
+ .map(
+ FileChannel.MapMode.READ_ONLY,
+ offset + SAMPLE_HEADER_LENGTH,
+ sample.size));
+ offset += sample.size + SAMPLE_HEADER_LENGTH;
+ state.mCurrentOffset = offset;
+ return sample;
+ }
+
+ @VisibleForTesting
+ protected void write(SampleHolder sample, IoState state) throws IOException {
+ if (mAccessFile == null || mNextChunk != null || !state.equals(this, mWriteOffset)) {
+ throw new IllegalStateException("Requested write for wrong SampleChunk");
+ }
+
+ mAccessFile.seek(mWriteOffset);
+ mAccessFile.writeInt(sample.size);
+ mAccessFile.writeInt(sample.flags);
+ mAccessFile.writeLong(sample.timeUs);
+ sample.data.position(0).limit(sample.size);
+ mAccessFile.getChannel().position(mWriteOffset + SAMPLE_HEADER_LENGTH).write(sample.data);
+ mWriteOffset += sample.size + SAMPLE_HEADER_LENGTH;
+ state.mCurrentOffset = mWriteOffset;
+ }
+
+ private void release(boolean delete) {
+ mWriteFinished = true;
+ mIsReading = mIsWriting = false;
+ try {
+ if (mAccessFile != null) {
+ mAccessFile.close();
+ }
+ } catch (IOException e) {
+ // Since the SampleChunk will not be reused, ignore exception.
+ }
+ if (delete) {
+ mFile.delete();
+ mChunkCallback.onChunkDelete(this);
+ }
+ }
+
+ /** Returns the start position. */
+ public long getStartPositionUs() {
+ return mStartPositionUs;
+ }
+
+ /** Returns the creation time. */
+ public long getCreatedTimeMs() {
+ return mCreatedTimeMs;
+ }
+
+ /** Returns the current size. */
+ public long getSize() {
+ return mWriteOffset;
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java
new file mode 100644
index 00000000..d95d0adb
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java
@@ -0,0 +1,464 @@
+/*
+ * 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.android.tv.tuner.exoplayer.buffer;
+
+import android.media.MediaCodec;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Pair;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer.BufferReason;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.util.MimeTypes;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Handles all {@link SampleChunk} I/O operations. An I/O dedicated thread handles all I/O
+ * operations for synchronization.
+ */
+public class SampleChunkIoHelper implements Handler.Callback {
+ private static final String TAG = "SampleChunkIoHelper";
+
+ private static final int MAX_READ_BUFFER_SAMPLES = 3;
+ private static final int READ_RESCHEDULING_DELAY_MS = 10;
+
+ private static final int MSG_OPEN_READ = 1;
+ private static final int MSG_OPEN_WRITE = 2;
+ private static final int MSG_CLOSE_READ = 3;
+ private static final int MSG_CLOSE_WRITE = 4;
+ private static final int MSG_READ = 5;
+ private static final int MSG_WRITE = 6;
+ private static final int MSG_RELEASE = 7;
+
+ private final long mSampleChunkDurationUs;
+ private final int mTrackCount;
+ private final List<String> mIds;
+ private final List<MediaFormat> mMediaFormats;
+ private final @BufferReason int mBufferReason;
+ private final BufferManager mBufferManager;
+ private final SamplePool mSamplePool;
+ private final IoCallback mIoCallback;
+
+ private Handler mIoHandler;
+ private final ConcurrentLinkedQueue<SampleHolder> mReadSampleBuffers[];
+ private final ConcurrentLinkedQueue<SampleHolder> mHandlerReadSampleBuffers[];
+ private final long[] mWriteIndexEndPositionUs;
+ private final long[] mWriteChunkEndPositionUs;
+ private final SampleChunk.IoState[] mReadIoStates;
+ private final SampleChunk.IoState[] mWriteIoStates;
+ private final Set<Integer> mSelectedTracks = new ArraySet<>();
+ private long mBufferDurationUs = 0;
+ private boolean mWriteEnded;
+ private boolean mErrorNotified;
+ private boolean mFinished;
+
+ /** A Callback for I/O events. */
+ public abstract static class IoCallback {
+
+ /** Called when there is no sample to read. */
+ public void onIoReachedEos() {}
+
+ /** Called when there is an irrecoverable error during I/O. */
+ public void onIoError() {}
+ }
+
+ private static class IoParams {
+ private final int index;
+ private final long positionUs;
+ private final SampleHolder sample;
+ private final ConditionVariable conditionVariable;
+ private final ConcurrentLinkedQueue<SampleHolder> readSampleBuffer;
+
+ private IoParams(
+ int index,
+ long positionUs,
+ SampleHolder sample,
+ ConditionVariable conditionVariable,
+ ConcurrentLinkedQueue<SampleHolder> readSampleBuffer) {
+ this.index = index;
+ this.positionUs = positionUs;
+ this.sample = sample;
+ this.conditionVariable = conditionVariable;
+ this.readSampleBuffer = readSampleBuffer;
+ }
+ }
+
+ /**
+ * Creates {@link SampleChunk} I/O handler.
+ *
+ * @param ids track names
+ * @param mediaFormats {@link android.media.MediaFormat} for each track
+ * @param bufferReason reason to be buffered
+ * @param bufferManager manager of {@link SampleChunk} collections
+ * @param samplePool allocator for a sample
+ * @param ioCallback listeners for I/O events
+ */
+ public SampleChunkIoHelper(
+ List<String> ids,
+ List<MediaFormat> mediaFormats,
+ @BufferReason int bufferReason,
+ BufferManager bufferManager,
+ SamplePool samplePool,
+ IoCallback ioCallback) {
+ mTrackCount = ids.size();
+ mIds = ids;
+ mMediaFormats = mediaFormats;
+ mBufferReason = bufferReason;
+ mBufferManager = bufferManager;
+ mSamplePool = samplePool;
+ mIoCallback = ioCallback;
+
+ mReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount];
+ mHandlerReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount];
+ mWriteIndexEndPositionUs = new long[mTrackCount];
+ mWriteChunkEndPositionUs = new long[mTrackCount];
+ mReadIoStates = new SampleChunk.IoState[mTrackCount];
+ mWriteIoStates = new SampleChunk.IoState[mTrackCount];
+
+ // Small chunk duration for live playback will give more fine grained storage usage
+ // and eviction handling for trickplay.
+ mSampleChunkDurationUs =
+ bufferReason == RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK
+ ? RecordingSampleBuffer.MIN_SEEK_DURATION_US
+ : RecordingSampleBuffer.RECORDING_CHUNK_DURATION_US;
+ for (int i = 0; i < mTrackCount; ++i) {
+ mWriteIndexEndPositionUs[i] = RecordingSampleBuffer.MIN_SEEK_DURATION_US;
+ mWriteChunkEndPositionUs[i] = mSampleChunkDurationUs;
+ mReadIoStates[i] = new SampleChunk.IoState();
+ mWriteIoStates[i] = new SampleChunk.IoState();
+ }
+ }
+
+ /**
+ * Prepares and initializes for I/O operations.
+ *
+ * @throws IOException
+ */
+ public void init() throws IOException {
+ HandlerThread handlerThread = new HandlerThread(TAG);
+ handlerThread.start();
+ mIoHandler = new Handler(handlerThread.getLooper(), this);
+ if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.loadTrackFromStorage(mIds.get(i), mSamplePool);
+ }
+ mWriteEnded = true;
+ } else {
+ for (int i = 0; i < mTrackCount; ++i) {
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_WRITE, i));
+ }
+ }
+ }
+
+ /**
+ * Reads a sample if it is available.
+ *
+ * @param index track index
+ * @return {@code null} if a sample is not available, otherwise returns a sample
+ */
+ public SampleHolder readSample(int index) {
+ SampleHolder sample = mReadSampleBuffers[index].poll();
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index));
+ return sample;
+ }
+
+ /**
+ * Writes a sample.
+ *
+ * @param index track index
+ * @param sample to write
+ * @param conditionVariable which will be wait until the write is finished
+ * @throws IOException
+ */
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ if (mErrorNotified) {
+ throw new IOException("Storage I/O error happened");
+ }
+ conditionVariable.close();
+ IoParams params = new IoParams(index, 0, sample, conditionVariable, null);
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_WRITE, params));
+ }
+
+ /**
+ * Starts read from the specified position.
+ *
+ * @param index track index
+ * @param positionUs the specified position
+ */
+ public void openRead(int index, long positionUs) {
+ // Old mReadSampleBuffers may have a pending read.
+ mReadSampleBuffers[index] = new ConcurrentLinkedQueue<>();
+ IoParams params = new IoParams(index, positionUs, null, null, mReadSampleBuffers[index]);
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_READ, params));
+ }
+
+ /**
+ * Closes read from the specified track.
+ *
+ * @param index track index
+ */
+ public void closeRead(int index) {
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_CLOSE_READ, index));
+ }
+
+ /** Notifies writes are finished. */
+ public void closeWrite() {
+ mIoHandler.sendEmptyMessage(MSG_CLOSE_WRITE);
+ }
+
+ /**
+ * Finishes I/O operations and releases all the resources.
+ *
+ * @throws IOException
+ */
+ public void release() throws IOException {
+ if (mIoHandler == null) {
+ return;
+ }
+ // Finishes all I/O operations.
+ ConditionVariable conditionVariable = new ConditionVariable();
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_RELEASE, conditionVariable));
+ conditionVariable.block();
+
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.unregisterChunkEvictedListener(mIds.get(i));
+ }
+ try {
+ if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDING && mTrackCount > 0) {
+ // Saves meta information for recording.
+ List<BufferManager.TrackFormat> audios = new LinkedList<>();
+ List<BufferManager.TrackFormat> videos = new LinkedList<>();
+ for (int i = 0; i < mTrackCount; ++i) {
+ android.media.MediaFormat format =
+ mMediaFormats.get(i).getFrameworkMediaFormatV16();
+ format.setLong(android.media.MediaFormat.KEY_DURATION, mBufferDurationUs);
+ if (MimeTypes.isAudio(mMediaFormats.get(i).mimeType)) {
+ audios.add(new BufferManager.TrackFormat(mIds.get(i), format));
+ } else if (MimeTypes.isVideo(mMediaFormats.get(i).mimeType)) {
+ videos.add(new BufferManager.TrackFormat(mIds.get(i), format));
+ }
+ }
+ mBufferManager.writeMetaFiles(audios, videos);
+ }
+ } finally {
+ mBufferManager.release();
+ mIoHandler.getLooper().quitSafely();
+ }
+ }
+
+ @Override
+ public boolean handleMessage(Message message) {
+ if (mFinished) {
+ return true;
+ }
+ releaseEvictedChunks();
+ try {
+ switch (message.what) {
+ case MSG_OPEN_READ:
+ doOpenRead((IoParams) message.obj);
+ return true;
+ case MSG_OPEN_WRITE:
+ doOpenWrite((int) message.obj);
+ return true;
+ case MSG_CLOSE_READ:
+ doCloseRead((int) message.obj);
+ return true;
+ case MSG_CLOSE_WRITE:
+ doCloseWrite();
+ return true;
+ case MSG_READ:
+ doRead((int) message.obj);
+ return true;
+ case MSG_WRITE:
+ doWrite((IoParams) message.obj);
+ // Since only write will increase storage, eviction will be handled here.
+ return true;
+ case MSG_RELEASE:
+ doRelease((ConditionVariable) message.obj);
+ return true;
+ }
+ } catch (IOException e) {
+ mIoCallback.onIoError();
+ mErrorNotified = true;
+ Log.e(TAG, "IoException happened", e);
+ return true;
+ }
+ return false;
+ }
+
+ private void doOpenRead(IoParams params) throws IOException {
+ int index = params.index;
+ mIoHandler.removeMessages(MSG_READ, index);
+ Pair<SampleChunk, Integer> readPosition =
+ mBufferManager.getReadFile(mIds.get(index), params.positionUs);
+ if (readPosition == null) {
+ String errorMessage =
+ "Chunk ID:" + mIds.get(index) + " pos:" + params.positionUs + "is not found";
+ SoftPreconditions.checkNotNull(readPosition, TAG, errorMessage);
+ throw new IOException(errorMessage);
+ }
+ mSelectedTracks.add(index);
+ mReadIoStates[index].openRead(readPosition.first, (long) readPosition.second);
+ if (mHandlerReadSampleBuffers[index] != null) {
+ SampleHolder sample;
+ while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) {
+ mSamplePool.releaseSample(sample);
+ }
+ }
+ mHandlerReadSampleBuffers[index] = params.readSampleBuffer;
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index));
+ }
+
+ private void doOpenWrite(int index) throws IOException {
+ SampleChunk chunk =
+ mBufferManager.createNewWriteFileIfNeeded(mIds.get(index), 0, mSamplePool, null, 0);
+ mWriteIoStates[index].openWrite(chunk);
+ }
+
+ private void doCloseRead(int index) {
+ mSelectedTracks.remove(index);
+ if (mHandlerReadSampleBuffers[index] != null) {
+ SampleHolder sample;
+ while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) {
+ mSamplePool.releaseSample(sample);
+ }
+ }
+ mIoHandler.removeMessages(MSG_READ, index);
+ }
+
+ private void doRead(int index) throws IOException {
+ mIoHandler.removeMessages(MSG_READ, index);
+ if (mHandlerReadSampleBuffers[index].size() >= MAX_READ_BUFFER_SAMPLES) {
+ // If enough samples are buffered, try again few moments later hoping that
+ // buffered samples are consumed.
+ mIoHandler.sendMessageDelayed(
+ mIoHandler.obtainMessage(MSG_READ, index), READ_RESCHEDULING_DELAY_MS);
+ } else {
+ if (mReadIoStates[index].isReadFinished()) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mReadIoStates[i].isReadFinished()) {
+ return;
+ }
+ }
+ mIoCallback.onIoReachedEos();
+ return;
+ }
+ SampleHolder sample = mReadIoStates[index].read();
+ if (sample != null) {
+ mHandlerReadSampleBuffers[index].offer(sample);
+ } else {
+ // Read reached write but write is not finished yet --- wait a few moments to
+ // see if another sample is written.
+ mIoHandler.sendMessageDelayed(
+ mIoHandler.obtainMessage(MSG_READ, index), READ_RESCHEDULING_DELAY_MS);
+ }
+ }
+ }
+
+ private void doWrite(IoParams params) throws IOException {
+ try {
+ if (mWriteEnded) {
+ SoftPreconditions.checkState(false);
+ return;
+ }
+ int index = params.index;
+ SampleHolder sample = params.sample;
+ SampleChunk nextChunk = null;
+ if ((sample.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ if (sample.timeUs > mBufferDurationUs) {
+ mBufferDurationUs = sample.timeUs;
+ }
+ if (sample.timeUs >= mWriteIndexEndPositionUs[index]) {
+ SampleChunk currentChunk =
+ sample.timeUs >= mWriteChunkEndPositionUs[index]
+ ? null
+ : mWriteIoStates[params.index].getChunk();
+ int currentOffset = (int) mWriteIoStates[params.index].getOffset();
+ nextChunk =
+ mBufferManager.createNewWriteFileIfNeeded(
+ mIds.get(index),
+ mWriteIndexEndPositionUs[index],
+ mSamplePool,
+ currentChunk,
+ currentOffset);
+ mWriteIndexEndPositionUs[index] =
+ ((sample.timeUs / RecordingSampleBuffer.MIN_SEEK_DURATION_US) + 1)
+ * RecordingSampleBuffer.MIN_SEEK_DURATION_US;
+ if (nextChunk != null) {
+ mWriteChunkEndPositionUs[index] =
+ ((sample.timeUs / mSampleChunkDurationUs) + 1)
+ * mSampleChunkDurationUs;
+ }
+ }
+ }
+ mWriteIoStates[params.index].write(params.sample, nextChunk);
+ } finally {
+ params.conditionVariable.open();
+ }
+ }
+
+ private void doCloseWrite() throws IOException {
+ if (mWriteEnded) {
+ return;
+ }
+ mWriteEnded = true;
+ boolean readFinished = true;
+ for (int i = 0; i < mTrackCount; ++i) {
+ readFinished = readFinished && mReadIoStates[i].isReadFinished();
+ mWriteIoStates[i].closeWrite();
+ }
+ if (readFinished) {
+ mIoCallback.onIoReachedEos();
+ }
+ }
+
+ private void doRelease(ConditionVariable conditionVariable) {
+ mIoHandler.removeCallbacksAndMessages(null);
+ mFinished = true;
+ conditionVariable.open();
+ mSelectedTracks.clear();
+ }
+
+ private void releaseEvictedChunks() {
+ if (mBufferReason != RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK
+ || mSelectedTracks.isEmpty()) {
+ return;
+ }
+ long currentStartPositionUs = Long.MAX_VALUE;
+ for (int trackIndex : mSelectedTracks) {
+ currentStartPositionUs =
+ Math.min(
+ currentStartPositionUs, mReadIoStates[trackIndex].getStartPositionUs());
+ }
+ for (int i = 0; i < mTrackCount; ++i) {
+ long evictEndPositionUs =
+ Math.min(
+ mBufferManager.getStartPositionUs(mIds.get(i)), currentStartPositionUs);
+ mBufferManager.evictChunks(mIds.get(i), evictEndPositionUs);
+ }
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java
new file mode 100644
index 00000000..b89a14db
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java
@@ -0,0 +1,67 @@
+/*
+ * 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 com.google.android.exoplayer.SampleHolder;
+import java.util.LinkedList;
+
+/** Pool of samples to recycle ByteBuffers as much as possible. */
+public class SamplePool {
+ private final LinkedList<SampleHolder> mSamplePool = new LinkedList<>();
+
+ /**
+ * Acquires a sample with a buffer larger than size from the pool. Allocate new one or resize an
+ * existing buffer if necessary.
+ */
+ public synchronized SampleHolder acquireSample(int size) {
+ if (mSamplePool.isEmpty()) {
+ SampleHolder sample = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ sample.ensureSpaceForWrite(size);
+ return sample;
+ }
+ SampleHolder smallestSufficientSample = null;
+ SampleHolder maxSample = mSamplePool.getFirst();
+ for (SampleHolder sample : mSamplePool) {
+ // Grab the smallest sufficient sample.
+ if (sample.data.capacity() >= size
+ && (smallestSufficientSample == null
+ || smallestSufficientSample.data.capacity() > sample.data.capacity())) {
+ smallestSufficientSample = sample;
+ }
+
+ // Grab the max size sample.
+ if (maxSample.data.capacity() < sample.data.capacity()) {
+ maxSample = sample;
+ }
+ }
+ SampleHolder sampleFromPool = smallestSufficientSample;
+
+ // If there's no sufficient sample, grab the maximum sample and resize it to size.
+ if (sampleFromPool == null) {
+ sampleFromPool = maxSample;
+ sampleFromPool.ensureSpaceForWrite(size);
+ }
+ mSamplePool.remove(sampleFromPool);
+ return sampleFromPool;
+ }
+
+ /** Releases the sample back to the pool. */
+ public synchronized void releaseSample(SampleHolder sample) {
+ sample.clearData();
+ mSamplePool.offerLast(sample);
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java
new file mode 100644
index 00000000..e208f2c2
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java
@@ -0,0 +1,72 @@
+/*
+ * 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 com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import java.util.LinkedList;
+
+/** A sample queue which reads from the buffer and passes to player pipeline. */
+public class SampleQueue {
+ private final LinkedList<SampleHolder> mQueue = new LinkedList<>();
+ private final SamplePool mSamplePool;
+ private Long mLastQueuedPositionUs = null;
+
+ public SampleQueue(SamplePool samplePool) {
+ mSamplePool = samplePool;
+ }
+
+ public void queueSample(SampleHolder sample) {
+ mQueue.offer(sample);
+ mLastQueuedPositionUs = sample.timeUs;
+ }
+
+ public int dequeueSample(SampleHolder sample) {
+ SampleHolder sampleFromQueue = mQueue.poll();
+ if (sampleFromQueue == null) {
+ return SampleSource.NOTHING_READ;
+ }
+ sample.ensureSpaceForWrite(sampleFromQueue.size);
+ sample.size = sampleFromQueue.size;
+ sample.flags = sampleFromQueue.flags;
+ sample.timeUs = sampleFromQueue.timeUs;
+ sample.clearData();
+ sampleFromQueue.data.position(0).limit(sample.size);
+ sample.data.put(sampleFromQueue.data);
+ mSamplePool.releaseSample(sampleFromQueue);
+ return SampleSource.SAMPLE_READ;
+ }
+
+ public void clear() {
+ while (!mQueue.isEmpty()) {
+ mSamplePool.releaseSample(mQueue.poll());
+ }
+ mLastQueuedPositionUs = null;
+ }
+
+ public Long getLastQueuedPositionUs() {
+ return mLastQueuedPositionUs;
+ }
+
+ public boolean isDurationGreaterThan(long durationUs) {
+ return !mQueue.isEmpty() && mQueue.getLast().timeUs - mQueue.getFirst().timeUs > durationUs;
+ }
+
+ public boolean isEmpty() {
+ return mQueue.isEmpty();
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java
new file mode 100644
index 00000000..4c6260bf
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java
@@ -0,0 +1,177 @@
+/*
+ * 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.os.ConditionVariable;
+import android.support.annotation.NonNull;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Handles I/O for {@link SampleExtractor} when physical storage based buffer is not used. Trickplay
+ * is disabled.
+ */
+public class SimpleSampleBuffer implements BufferManager.SampleBuffer {
+ private final SamplePool mSamplePool = new SamplePool();
+ private SampleQueue[] mPlayingSampleQueues;
+ private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US;
+
+ private volatile boolean mEos;
+
+ public SimpleSampleBuffer(PlaybackBufferListener bufferListener) {
+ if (bufferListener != null) {
+ // Disables trickplay.
+ bufferListener.onBufferStateChanged(false);
+ }
+ }
+
+ @Override
+ public synchronized void init(
+ @NonNull List<String> ids, @NonNull List<MediaFormat> mediaFormats) {
+ int trackCount = ids.size();
+ mPlayingSampleQueues = new SampleQueue[trackCount];
+ for (int i = 0; i < trackCount; i++) {
+ mPlayingSampleQueues[i] = null;
+ }
+ }
+
+ @Override
+ public void setEos() {
+ mEos = true;
+ }
+
+ private boolean reachedEos() {
+ return mEos;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] == null) {
+ mPlayingSampleQueues[index] = new SampleQueue(mSamplePool);
+ } else {
+ mPlayingSampleQueues[index].clear();
+ }
+ }
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] != null) {
+ mPlayingSampleQueues[index].clear();
+ mPlayingSampleQueues[index] = null;
+ }
+ }
+ }
+
+ @Override
+ public synchronized long getBufferedPositionUs() {
+ Long result = null;
+ for (SampleQueue queue : mPlayingSampleQueues) {
+ if (queue == null) {
+ continue;
+ }
+ Long lastQueuedSamplePositionUs = queue.getLastQueuedPositionUs();
+ if (lastQueuedSamplePositionUs == null) {
+ // No sample has been queued.
+ result = mLastBufferedPositionUs;
+ continue;
+ }
+ if (result == null || result > lastQueuedSamplePositionUs) {
+ result = lastQueuedSamplePositionUs;
+ }
+ }
+ if (result == null) {
+ return mLastBufferedPositionUs;
+ }
+ return (mLastBufferedPositionUs = result);
+ }
+
+ @Override
+ public synchronized int readSample(int track, SampleHolder sampleHolder) {
+ SampleQueue queue = mPlayingSampleQueues[track];
+ SoftPreconditions.checkNotNull(queue);
+ int result = queue == null ? SampleSource.NOTHING_READ : queue.dequeueSample(sampleHolder);
+ if (result != SampleSource.SAMPLE_READ && reachedEos()) {
+ return SampleSource.END_OF_STREAM;
+ }
+ return result;
+ }
+
+ @Override
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ sample.data.position(0).limit(sample.size);
+ SampleHolder sampleToQueue = mSamplePool.acquireSample(sample.size);
+ sampleToQueue.size = sample.size;
+ sampleToQueue.clearData();
+ sampleToQueue.data.put(sample.data);
+ sampleToQueue.timeUs = sample.timeUs;
+ sampleToQueue.flags = sample.flags;
+
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] != null) {
+ mPlayingSampleQueues[index].queueSample(sampleToQueue);
+ }
+ }
+ }
+
+ @Override
+ public boolean isWriteSpeedSlow(int sampleSize, long durationNs) {
+ // Since SimpleSampleBuffer write samples only to memory (not to physical storage),
+ // write speed is always fine.
+ return false;
+ }
+
+ @Override
+ public void handleWriteSpeedSlow() {
+ // no-op
+ }
+
+ @Override
+ public synchronized boolean continueBuffering(long positionUs) {
+ for (SampleQueue queue : mPlayingSampleQueues) {
+ if (queue == null) {
+ continue;
+ }
+ if (queue.getLastQueuedPositionUs() == null
+ || positionUs > queue.getLastQueuedPositionUs()) {
+ // No more buffered data.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ // Not used.
+ }
+
+ @Override
+ public void release() {
+ // Not used.
+ }
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java b/tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java
new file mode 100644
index 00000000..b22b8af1
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java
@@ -0,0 +1,145 @@
+/*
+ * 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.content.Context;
+import android.os.AsyncTask;
+import android.provider.Settings;
+import android.support.annotation.NonNull;
+import android.util.Pair;
+import com.android.tv.common.SoftPreconditions;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.SortedMap;
+
+/** Manages Trickplay storage. */
+public class TrickplayStorageManager implements BufferManager.StorageManager {
+ // TODO: Support multi-sessions.
+ private static final String BUFFER_DIR = "timeshift";
+
+ // Copied from android.provider.Settings.Global (hidden fields)
+ private static final String SYS_STORAGE_THRESHOLD_PERCENTAGE =
+ "sys_storage_threshold_percentage";
+ private static final String SYS_STORAGE_THRESHOLD_MAX_BYTES = "sys_storage_threshold_max_bytes";
+
+ // Copied from android.os.StorageManager
+ private static final int DEFAULT_THRESHOLD_PERCENTAGE = 10;
+ private static final long DEFAULT_THRESHOLD_MAX_BYTES = 500L * 1024 * 1024;
+
+ private static AsyncTask<Void, Void, Void> sLastCacheCleanUpTask;
+ private static File sBufferDir;
+ private static long sStorageBufferBytes;
+
+ private final long mMaxBufferSize;
+
+ private static void initParamsIfNeeded(Context context, @NonNull File path) {
+ // TODO: Support multi-sessions.
+ SoftPreconditions.checkState(sBufferDir == null || sBufferDir.equals(path));
+ if (path.equals(sBufferDir)) {
+ return;
+ }
+ sBufferDir = path;
+ long lowPercentage =
+ Settings.Global.getInt(
+ context.getContentResolver(),
+ SYS_STORAGE_THRESHOLD_PERCENTAGE,
+ DEFAULT_THRESHOLD_PERCENTAGE);
+ long lowPercentageToBytes = path.getTotalSpace() * lowPercentage / 100;
+ long maxLowBytes =
+ Settings.Global.getLong(
+ context.getContentResolver(),
+ SYS_STORAGE_THRESHOLD_MAX_BYTES,
+ DEFAULT_THRESHOLD_MAX_BYTES);
+ sStorageBufferBytes = Math.min(lowPercentageToBytes, maxLowBytes);
+ }
+
+ public TrickplayStorageManager(Context context, @NonNull File baseDir, long maxBufferSize) {
+ initParamsIfNeeded(context, new File(baseDir, BUFFER_DIR));
+ sBufferDir.mkdirs();
+ mMaxBufferSize = maxBufferSize;
+ clearStorage();
+ }
+
+ private void clearStorage() {
+ long now = System.currentTimeMillis();
+ if (sLastCacheCleanUpTask != null) {
+ sLastCacheCleanUpTask.cancel(true);
+ }
+ sLastCacheCleanUpTask =
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (isCancelled()) {
+ return null;
+ }
+ File files[] = sBufferDir.listFiles();
+ if (files == null || files.length == 0) {
+ return null;
+ }
+ for (File file : files) {
+ if (isCancelled()) {
+ break;
+ }
+ long lastModified = file.lastModified();
+ if (lastModified != 0 && lastModified < now) {
+ file.delete();
+ }
+ }
+ return null;
+ }
+ };
+ sLastCacheCleanUpTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ public File getBufferDir() {
+ return sBufferDir;
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return false;
+ }
+
+ @Override
+ public boolean reachedStorageMax(long bufferSize, long pendingDelete) {
+ return bufferSize - pendingDelete > mMaxBufferSize;
+ }
+
+ @Override
+ public boolean hasEnoughBuffer(long pendingDelete) {
+ return sBufferDir.getUsableSpace() + pendingDelete >= sStorageBufferBytes;
+ }
+
+ @Override
+ public List<BufferManager.TrackFormat> readTrackInfoFiles(boolean isAudio) {
+ return null;
+ }
+
+ @Override
+ public ArrayList<BufferManager.PositionHolder> readIndexFile(String trackId) {
+ return null;
+ }
+
+ @Override
+ public void writeTrackInfoFiles(List<BufferManager.TrackFormat> formatList, boolean isAudio) {}
+
+ @Override
+ public void writeIndexFile(
+ String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index) {}
+}
diff --git a/tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java b/tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java
new file mode 100644
index 00000000..91bee7a0
--- /dev/null
+++ b/tuner/src/com/android/tv/tuner/exoplayer/text/SubtitleView.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (C) 2014 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.text;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Paint.Join;
+import android.graphics.Paint.Style;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.text.Layout.Alignment;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.View;
+import com.google.android.exoplayer.text.CaptionStyleCompat;
+import com.google.android.exoplayer.util.Util;
+import java.util.ArrayList;
+import java.util.Objects;
+
+/**
+ * Since this class does not exist in recent version of ExoPlayer and used by {@link
+ * com.android.tv.tuner.cc.CaptionWindowLayout}, this class is copied from older version of
+ * ExoPlayer. A view for rendering a single caption.
+ */
+@Deprecated
+public class SubtitleView extends View {
+ /** Ratio of inner padding to font size. */
+ private static final float INNER_PADDING_RATIO = 0.125f;
+
+ /** Temporary rectangle used for computing line bounds. */
+ private final RectF mLineBounds = new RectF();
+
+ // Styled dimensions.
+ private final float mCornerRadius;
+ private final float mOutlineWidth;
+ private final float mShadowRadius;
+ private final float mShadowOffset;
+
+ private final TextPaint mTextPaint;
+ private final Paint mPaint;
+
+ private CharSequence mText;
+
+ private int mForegroundColor;
+ private int mBackgroundColor;
+ private int mEdgeColor;
+ private int mEdgeType;
+
+ private boolean mHasMeasurements;
+ private int mLastMeasuredWidth;
+ private StaticLayout mLayout;
+
+ private Alignment mAlignment;
+ private final float mSpacingMult;
+ private final float mSpacingAdd;
+ private int mInnerPaddingX;
+ private float mWhiteSpaceWidth;
+ private ArrayList<Integer> mPrefixSpaces = new ArrayList<>();
+
+ public SubtitleView(Context context) {
+ this(context, null);
+ }
+
+ public SubtitleView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SubtitleView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ int[] viewAttr = {
+ android.R.attr.text,
+ android.R.attr.textSize,
+ android.R.attr.lineSpacingExtra,
+ android.R.attr.lineSpacingMultiplier
+ };
+ TypedArray a = context.obtainStyledAttributes(attrs, viewAttr, defStyleAttr, 0);
+ CharSequence text = a.getText(0);
+ int textSize = a.getDimensionPixelSize(1, 15);
+ mSpacingAdd = a.getDimensionPixelSize(2, 0);
+ mSpacingMult = a.getFloat(3, 1);
+ a.recycle();
+
+ Resources resources = getContext().getResources();
+ DisplayMetrics displayMetrics = resources.getDisplayMetrics();
+ int twoDpInPx =
+ Math.round((2f * displayMetrics.densityDpi) / DisplayMetrics.DENSITY_DEFAULT);
+ mCornerRadius = twoDpInPx;
+ mOutlineWidth = twoDpInPx;
+ mShadowRadius = twoDpInPx;
+ mShadowOffset = twoDpInPx;
+
+ mTextPaint = new TextPaint();
+ mTextPaint.setAntiAlias(true);
+ mTextPaint.setSubpixelText(true);
+
+ mAlignment = Alignment.ALIGN_CENTER;
+
+ mPaint = new Paint();
+ mPaint.setAntiAlias(true);
+
+ mInnerPaddingX = 0;
+ setText(text);
+ setTextSize(textSize);
+ setStyle(CaptionStyleCompat.DEFAULT);
+ }
+
+ @Override
+ public void setBackgroundColor(int color) {
+ mBackgroundColor = color;
+ forceUpdate(false);
+ }
+
+ /**
+ * Sets the text to be displayed by the view.
+ *
+ * @param text The text to display.
+ */
+ public void setText(CharSequence text) {
+ this.mText = text;
+ forceUpdate(true);
+ }
+
+ /**
+ * Sets the text size in pixels.
+ *
+ * @param size The text size in pixels.
+ */
+ public void setTextSize(float size) {
+ if (mTextPaint.getTextSize() != size) {
+ mTextPaint.setTextSize(size);
+ mInnerPaddingX = (int) (size * INNER_PADDING_RATIO + 0.5f);
+ mWhiteSpaceWidth -= mInnerPaddingX * 2;
+ forceUpdate(true);
+ }
+ }
+
+ /**
+ * Sets the text alignment.
+ *
+ * @param textAlignment The text alignment.
+ */
+ public void setTextAlignment(Alignment textAlignment) {
+ mAlignment = textAlignment;
+ }
+
+ /**
+ * Configures the view according to the given style.
+ *
+ * @param style A style for the view.
+ */
+ public void setStyle(CaptionStyleCompat style) {
+ mForegroundColor = style.foregroundColor;
+ mBackgroundColor = style.backgroundColor;
+ mEdgeType = style.edgeType;
+ mEdgeColor = style.edgeColor;
+ setTypeface(style.typeface);
+ super.setBackgroundColor(style.windowColor);
+ forceUpdate(true);
+ }
+
+ public void setPrefixSpaces(ArrayList<Integer> prefixSpaces) {
+ mPrefixSpaces = prefixSpaces;
+ }
+
+ public void setWhiteSpaceWidth(float whiteSpaceWidth) {
+ mWhiteSpaceWidth = whiteSpaceWidth;
+ }
+
+ private void setTypeface(Typeface typeface) {
+ if (Objects.equals(mTextPaint.getTypeface(), (typeface))) {
+ mTextPaint.setTypeface(typeface);
+ forceUpdate(true);
+ }
+ }
+
+ private void forceUpdate(boolean needsLayout) {
+ if (needsLayout) {
+ mHasMeasurements = false;
+ requestLayout();
+ }
+ invalidate();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int widthSpec = MeasureSpec.getSize(widthMeasureSpec);
+
+ if (computeMeasurements(widthSpec)) {
+ final StaticLayout layout = this.mLayout;
+ final int paddingX = getPaddingLeft() + getPaddingRight() + mInnerPaddingX * 2;
+ final int height = layout.getHeight() + getPaddingTop() + getPaddingBottom();
+ int width = 0;
+ int lineCount = layout.getLineCount();
+ for (int i = 0; i < lineCount; i++) {
+ width = Math.max((int) Math.ceil(layout.getLineWidth(i)), width);
+ }
+ width += paddingX;
+ setMeasuredDimension(width, height);
+ } else if (Util.SDK_INT >= 11) {
+ setTooSmallMeasureDimensionV11();
+ } else {
+ setMeasuredDimension(0, 0);
+ }
+ }
+
+ @TargetApi(11)
+ private void setTooSmallMeasureDimensionV11() {
+ setMeasuredDimension(MEASURED_STATE_TOO_SMALL, MEASURED_STATE_TOO_SMALL);
+ }
+
+ @Override
+ public void onLayout(boolean changed, int l, int t, int r, int b) {
+ final int width = r - l;
+ computeMeasurements(width);
+ }
+
+ private boolean computeMeasurements(int maxWidth) {
+ if (mHasMeasurements && maxWidth == mLastMeasuredWidth) {
+ return true;
+ }
+
+ // Account for padding.
+ final int paddingX = getPaddingLeft() + getPaddingRight() + mInnerPaddingX * 2;
+ maxWidth -= paddingX;
+ if (maxWidth <= 0) {
+ return false;
+ }
+
+ mHasMeasurements = true;
+ mLastMeasuredWidth = maxWidth;
+ mLayout =
+ new StaticLayout(
+ mText, mTextPaint, maxWidth, mAlignment, mSpacingMult, mSpacingAdd, true);
+ return true;
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ final StaticLayout layout = this.mLayout;
+ if (layout == null) {
+ return;
+ }
+
+ final int saveCount = c.save();
+ final int innerPaddingX = this.mInnerPaddingX;
+ c.translate(getPaddingLeft() + innerPaddingX, getPaddingTop());
+
+ final int lineCount = layout.getLineCount();
+ final Paint textPaint = this.mTextPaint;
+ final Paint paint = this.mPaint;
+ final RectF bounds = mLineBounds;
+
+ if (Color.alpha(mBackgroundColor) > 0) {
+ final float cornerRadius = this.mCornerRadius;
+ float previousBottom = layout.getLineTop(0);
+
+ paint.setColor(mBackgroundColor);
+ paint.setStyle(Style.FILL);
+
+ for (int i = 0; i < lineCount; i++) {
+ float spacesPadding = 0.0f;
+ if (i < mPrefixSpaces.size()) {
+ spacesPadding += mPrefixSpaces.get(i) * mWhiteSpaceWidth;
+ }
+ bounds.left = layout.getLineLeft(i) - innerPaddingX + spacesPadding;
+ bounds.right = layout.getLineRight(i) + innerPaddingX;
+ bounds.top = previousBottom;
+ bounds.bottom = layout.getLineBottom(i);
+ previousBottom = bounds.bottom;
+
+ c.drawRoundRect(bounds, cornerRadius, cornerRadius, paint);
+ }
+ }
+
+ if (mEdgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) {
+ textPaint.setStrokeJoin(Join.ROUND);
+ textPaint.setStrokeWidth(mOutlineWidth);
+ textPaint.setColor(mEdgeColor);
+ textPaint.setStyle(Style.FILL_AND_STROKE);
+ layout.draw(c);
+ } else if (mEdgeType == CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW) {
+ textPaint.setShadowLayer(mShadowRadius, mShadowOffset, mShadowOffset, mEdgeColor);
+ } else if (mEdgeType == CaptionStyleCompat.EDGE_TYPE_RAISED
+ || mEdgeType == CaptionStyleCompat.EDGE_TYPE_DEPRESSED) {
+ boolean raised = mEdgeType == CaptionStyleCompat.EDGE_TYPE_RAISED;
+ int colorUp = raised ? Color.WHITE : mEdgeColor;
+ int colorDown = raised ? mEdgeColor : Color.WHITE;
+ float offset = mShadowRadius / 2f;
+ textPaint.setColor(mForegroundColor);
+ textPaint.setStyle(Style.FILL);
+ textPaint.setShadowLayer(mShadowRadius, -offset, -offset, colorUp);
+ layout.draw(c);
+ textPaint.setShadowLayer(mShadowRadius, offset, offset, colorDown);
+ }
+
+ textPaint.setColor(mForegroundColor);
+ textPaint.setStyle(Style.FILL);
+ layout.draw(c);
+ textPaint.setShadowLayer(0, 0, 0, 0);
+ c.restoreToCount(saveCount);
+ }
+}