/* * 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.google.android.exoplayer.text.eia608; import com.google.android.exoplayer.C; import com.google.android.exoplayer.ExoPlaybackException; 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.SampleSourceTrackRenderer; import com.google.android.exoplayer.TrackRenderer; import com.google.android.exoplayer.text.Cue; import com.google.android.exoplayer.text.TextRenderer; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.Util; import android.os.Handler; import android.os.Handler.Callback; import android.os.Looper; import android.os.Message; import java.util.Collections; import java.util.TreeSet; /** * A {@link TrackRenderer} for EIA-608 closed captions in a media stream. */ public final class Eia608TrackRenderer extends SampleSourceTrackRenderer implements Callback { private static final int MSG_INVOKE_RENDERER = 0; private static final int CC_MODE_UNKNOWN = 0; private static final int CC_MODE_ROLL_UP = 1; private static final int CC_MODE_POP_ON = 2; private static final int CC_MODE_PAINT_ON = 3; // The default number of rows to display in roll-up captions mode. private static final int DEFAULT_CAPTIONS_ROW_COUNT = 4; // The maximum duration that captions are parsed ahead of the current position. private static final int MAX_SAMPLE_READAHEAD_US = 5000000; private final Eia608Parser eia608Parser; private final TextRenderer textRenderer; private final Handler textRendererHandler; private final MediaFormatHolder formatHolder; private final SampleHolder sampleHolder; private final StringBuilder captionStringBuilder; private final TreeSet<ClosedCaptionList> pendingCaptionLists; private boolean inputStreamEnded; private int captionMode; private int captionRowCount; private String caption; private String lastRenderedCaption; /** * @param source A source from which samples containing EIA-608 closed captions can be read. * @param textRenderer The text renderer. * @param textRendererLooper The looper associated with the thread on which textRenderer should be * invoked. If the renderer makes use of standard Android UI components, then this should * normally be the looper associated with the applications' main thread, which can be * obtained using {@link android.app.Activity#getMainLooper()}. Null may be passed if the * renderer should be invoked directly on the player's internal rendering thread. */ public Eia608TrackRenderer(SampleSource source, TextRenderer textRenderer, Looper textRendererLooper) { super(source); this.textRenderer = Assertions.checkNotNull(textRenderer); textRendererHandler = textRendererLooper == null ? null : new Handler(textRendererLooper, this); eia608Parser = new Eia608Parser(); formatHolder = new MediaFormatHolder(); sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); captionStringBuilder = new StringBuilder(); pendingCaptionLists = new TreeSet<>(); } @Override protected boolean handlesTrack(MediaFormat mediaFormat) { return eia608Parser.canParse(mediaFormat.mimeType); } @Override protected void onEnabled(int track, long positionUs, boolean joining) throws ExoPlaybackException { super.onEnabled(track, positionUs, joining); seekToInternal(); } @Override protected void seekTo(long positionUs) throws ExoPlaybackException { super.seekTo(positionUs); seekToInternal(); } private void seekToInternal() { inputStreamEnded = false; pendingCaptionLists.clear(); clearPendingSample(); captionRowCount = DEFAULT_CAPTIONS_ROW_COUNT; setCaptionMode(CC_MODE_UNKNOWN); invokeRenderer(null); } @Override protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { continueBufferingSource(positionUs); if (isSamplePending()) { maybeParsePendingSample(positionUs); } int result = inputStreamEnded ? SampleSource.END_OF_STREAM : SampleSource.SAMPLE_READ; while (!isSamplePending() && result == SampleSource.SAMPLE_READ) { result = readSource(positionUs, formatHolder, sampleHolder, false); if (result == SampleSource.SAMPLE_READ) { maybeParsePendingSample(positionUs); } else if (result == SampleSource.END_OF_STREAM) { inputStreamEnded = true; } } while (!pendingCaptionLists.isEmpty()) { if (pendingCaptionLists.first().timeUs > positionUs) { // We're too early to render any of the pending caption lists. return; } // Remove and consume the next caption list. ClosedCaptionList nextCaptionList = pendingCaptionLists.pollFirst(); consumeCaptionList(nextCaptionList); // Update the renderer, unless the caption list was marked for decoding only. if (!nextCaptionList.decodeOnly) { invokeRenderer(caption); } } } @Override protected long getBufferedPositionUs() { return TrackRenderer.END_OF_TRACK_US; } @Override protected boolean isEnded() { return inputStreamEnded; } @Override protected boolean isReady() { return true; } private void invokeRenderer(String text) { if (Util.areEqual(lastRenderedCaption, text)) { // No change. return; } this.lastRenderedCaption = text; if (textRendererHandler != null) { textRendererHandler.obtainMessage(MSG_INVOKE_RENDERER, text).sendToTarget(); } else { invokeRendererInternal(text); } } @SuppressWarnings("unchecked") @Override public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_INVOKE_RENDERER: invokeRendererInternal((String) msg.obj); return true; } return false; } private void invokeRendererInternal(String cueText) { if (cueText == null) { textRenderer.onCues(Collections.<Cue>emptyList()); } else { textRenderer.onCues(Collections.singletonList(new Cue(cueText))); } } private void maybeParsePendingSample(long positionUs) { if (sampleHolder.timeUs > positionUs + MAX_SAMPLE_READAHEAD_US) { // We're too early to parse the sample. return; } ClosedCaptionList holder = eia608Parser.parse(sampleHolder); clearPendingSample(); if (holder != null) { pendingCaptionLists.add(holder); } } private void consumeCaptionList(ClosedCaptionList captionList) { int captionBufferSize = captionList.captions.length; if (captionBufferSize == 0) { return; } for (int i = 0; i < captionBufferSize; i++) { ClosedCaption caption = captionList.captions[i]; if (caption.type == ClosedCaption.TYPE_CTRL) { ClosedCaptionCtrl captionCtrl = (ClosedCaptionCtrl) caption; if (captionCtrl.isMiscCode()) { handleMiscCode(captionCtrl); } else if (captionCtrl.isPreambleAddressCode()) { handlePreambleAddressCode(); } } else { handleText((ClosedCaptionText) caption); } } if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { caption = getDisplayCaption(); } } private void handleText(ClosedCaptionText captionText) { if (captionMode != CC_MODE_UNKNOWN) { captionStringBuilder.append(captionText.text); } } private void handleMiscCode(ClosedCaptionCtrl captionCtrl) { switch (captionCtrl.cc2) { case ClosedCaptionCtrl.ROLL_UP_CAPTIONS_2_ROWS: captionRowCount = 2; setCaptionMode(CC_MODE_ROLL_UP); return; case ClosedCaptionCtrl.ROLL_UP_CAPTIONS_3_ROWS: captionRowCount = 3; setCaptionMode(CC_MODE_ROLL_UP); return; case ClosedCaptionCtrl.ROLL_UP_CAPTIONS_4_ROWS: captionRowCount = 4; setCaptionMode(CC_MODE_ROLL_UP); return; case ClosedCaptionCtrl.RESUME_CAPTION_LOADING: setCaptionMode(CC_MODE_POP_ON); return; case ClosedCaptionCtrl.RESUME_DIRECT_CAPTIONING: setCaptionMode(CC_MODE_PAINT_ON); return; } if (captionMode == CC_MODE_UNKNOWN) { return; } switch (captionCtrl.cc2) { case ClosedCaptionCtrl.ERASE_DISPLAYED_MEMORY: caption = null; if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { captionStringBuilder.setLength(0); } return; case ClosedCaptionCtrl.ERASE_NON_DISPLAYED_MEMORY: captionStringBuilder.setLength(0); return; case ClosedCaptionCtrl.END_OF_CAPTION: caption = getDisplayCaption(); captionStringBuilder.setLength(0); return; case ClosedCaptionCtrl.CARRIAGE_RETURN: maybeAppendNewline(); return; case ClosedCaptionCtrl.BACKSPACE: if (captionStringBuilder.length() > 0) { captionStringBuilder.setLength(captionStringBuilder.length() - 1); } return; } } private void handlePreambleAddressCode() { // TODO: Add better handling of this with specific positioning. maybeAppendNewline(); } private void setCaptionMode(int captionMode) { if (this.captionMode == captionMode) { return; } this.captionMode = captionMode; // Clear the working memory. captionStringBuilder.setLength(0); if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_UNKNOWN) { // When switching to roll-up or unknown, we also need to clear the caption. caption = null; } } private void maybeAppendNewline() { int buildLength = captionStringBuilder.length(); if (buildLength > 0 && captionStringBuilder.charAt(buildLength - 1) != '\n') { captionStringBuilder.append('\n'); } } private String getDisplayCaption() { int buildLength = captionStringBuilder.length(); if (buildLength == 0) { return null; } boolean endsWithNewline = captionStringBuilder.charAt(buildLength - 1) == '\n'; if (buildLength == 1 && endsWithNewline) { return null; } int endIndex = endsWithNewline ? buildLength - 1 : buildLength; if (captionMode != CC_MODE_ROLL_UP) { return captionStringBuilder.substring(0, endIndex); } int startIndex = 0; int searchBackwardFromIndex = endIndex; for (int i = 0; i < captionRowCount && searchBackwardFromIndex != -1; i++) { searchBackwardFromIndex = captionStringBuilder.lastIndexOf("\n", searchBackwardFromIndex - 1); } if (searchBackwardFromIndex != -1) { startIndex = searchBackwardFromIndex + 1; } captionStringBuilder.delete(0, startIndex); return captionStringBuilder.substring(0, endIndex - startIndex); } private void clearPendingSample() { sampleHolder.timeUs = C.UNKNOWN_TIME_US; sampleHolder.clearData(); } private boolean isSamplePending() { return sampleHolder.timeUs != C.UNKNOWN_TIME_US; } }