diff options
Diffstat (limited to 'tuner/src/com/android/tv/tuner/exoplayer')
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 & 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); + } +} |