| /* |
| * 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.google.android.exoplayer2.text.cea; |
| |
| import static java.lang.Math.min; |
| |
| import android.graphics.Color; |
| import android.graphics.Typeface; |
| import android.text.Layout.Alignment; |
| import android.text.SpannableString; |
| import android.text.SpannableStringBuilder; |
| import android.text.Spanned; |
| import android.text.style.ForegroundColorSpan; |
| import android.text.style.StyleSpan; |
| import android.text.style.UnderlineSpan; |
| import androidx.annotation.Nullable; |
| import com.google.android.exoplayer2.C; |
| import com.google.android.exoplayer2.Format; |
| import com.google.android.exoplayer2.text.Cue; |
| import com.google.android.exoplayer2.text.Subtitle; |
| import com.google.android.exoplayer2.text.SubtitleDecoder; |
| import com.google.android.exoplayer2.text.SubtitleDecoderException; |
| import com.google.android.exoplayer2.text.SubtitleInputBuffer; |
| import com.google.android.exoplayer2.text.SubtitleOutputBuffer; |
| import com.google.android.exoplayer2.util.Assertions; |
| import com.google.android.exoplayer2.util.Log; |
| import com.google.android.exoplayer2.util.MimeTypes; |
| import com.google.android.exoplayer2.util.ParsableByteArray; |
| import com.google.android.exoplayer2.util.Util; |
| import java.nio.ByteBuffer; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.List; |
| import org.checkerframework.checker.nullness.compatqual.NullableType; |
| |
| /** A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). */ |
| public final class Cea608Decoder extends CeaDecoder { |
| |
| /** |
| * The minimum value for the {@code validDataChannelTimeoutMs} constructor parameter permitted by |
| * ANSI/CTA-608-E R-2014 Annex C.9. |
| */ |
| public static final long MIN_DATA_CHANNEL_TIMEOUT_MS = 16_000; |
| |
| private static final String TAG = "Cea608Decoder"; |
| |
| private static final int CC_VALID_FLAG = 0x04; |
| private static final int CC_TYPE_FLAG = 0x02; |
| private static final int CC_FIELD_FLAG = 0x01; |
| |
| private static final int NTSC_CC_FIELD_1 = 0x00; |
| private static final int NTSC_CC_FIELD_2 = 0x01; |
| private static final int NTSC_CC_CHANNEL_1 = 0x00; |
| private static final int NTSC_CC_CHANNEL_2 = 0x01; |
| |
| 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; |
| |
| private static final int[] ROW_INDICES = new int[] {11, 1, 3, 12, 14, 5, 7, 9}; |
| private static final int[] COLUMN_INDICES = new int[] {0, 4, 8, 12, 16, 20, 24, 28}; |
| |
| private static final int[] STYLE_COLORS = |
| new int[] { |
| Color.WHITE, Color.GREEN, Color.BLUE, Color.CYAN, Color.RED, Color.YELLOW, Color.MAGENTA |
| }; |
| private static final int STYLE_ITALICS = 0x07; |
| private static final int STYLE_UNCHANGED = 0x08; |
| |
| // The default number of rows to display in roll-up captions mode. |
| private static final int DEFAULT_CAPTIONS_ROW_COUNT = 4; |
| |
| // An implied first byte for packets that are only 2 bytes long, consisting of marker bits |
| // (0b11111) + valid bit (0b1) + NTSC field 1 type bits (0b00). |
| private static final byte CC_IMPLICIT_DATA_HEADER = (byte) 0xFC; |
| |
| /** |
| * Command initiating pop-on style captioning. Subsequent data should be loaded into a |
| * non-displayed memory and held there until the {@link #CTRL_END_OF_CAPTION} command is received, |
| * at which point the non-displayed memory becomes the displayed memory (and vice versa). |
| */ |
| private static final byte CTRL_RESUME_CAPTION_LOADING = 0x20; |
| |
| private static final byte CTRL_BACKSPACE = 0x21; |
| |
| private static final byte CTRL_DELETE_TO_END_OF_ROW = 0x24; |
| |
| /** |
| * Command initiating roll-up style captioning, with the maximum of 2 rows displayed |
| * simultaneously. |
| */ |
| private static final byte CTRL_ROLL_UP_CAPTIONS_2_ROWS = 0x25; |
| /** |
| * Command initiating roll-up style captioning, with the maximum of 3 rows displayed |
| * simultaneously. |
| */ |
| private static final byte CTRL_ROLL_UP_CAPTIONS_3_ROWS = 0x26; |
| /** |
| * Command initiating roll-up style captioning, with the maximum of 4 rows displayed |
| * simultaneously. |
| */ |
| private static final byte CTRL_ROLL_UP_CAPTIONS_4_ROWS = 0x27; |
| |
| /** |
| * Command initiating paint-on style captioning. Subsequent data should be addressed immediately |
| * to displayed memory without need for the {@link #CTRL_RESUME_CAPTION_LOADING} command. |
| */ |
| private static final byte CTRL_RESUME_DIRECT_CAPTIONING = 0x29; |
| /** |
| * TEXT commands are switching to TEXT service. All consecutive incoming data must be filtered out |
| * until a command is received that switches back to the CAPTION service. |
| */ |
| private static final byte CTRL_TEXT_RESTART = 0x2A; |
| |
| private static final byte CTRL_RESUME_TEXT_DISPLAY = 0x2B; |
| |
| private static final byte CTRL_ERASE_DISPLAYED_MEMORY = 0x2C; |
| private static final byte CTRL_CARRIAGE_RETURN = 0x2D; |
| private static final byte CTRL_ERASE_NON_DISPLAYED_MEMORY = 0x2E; |
| |
| /** |
| * Command indicating the end of a pop-on style caption. At this point the caption loaded in |
| * non-displayed memory should be swapped with the one in displayed memory. If no {@link |
| * #CTRL_RESUME_CAPTION_LOADING} command has been received, this command forces the receiver into |
| * pop-on style. |
| */ |
| private static final byte CTRL_END_OF_CAPTION = 0x2F; |
| |
| // Basic North American 608 CC char set, mostly ASCII. Indexed by (char-0x20). |
| private static final int[] BASIC_CHARACTER_SET = |
| new int[] { |
| 0x20, |
| 0x21, |
| 0x22, |
| 0x23, |
| 0x24, |
| 0x25, |
| 0x26, |
| 0x27, // ! " # $ % & ' |
| 0x28, |
| 0x29, // ( ) |
| 0xE1, // 2A: 225 'รก' "Latin small letter A with acute" |
| 0x2B, |
| 0x2C, |
| 0x2D, |
| 0x2E, |
| 0x2F, // + , - . / |
| 0x30, |
| 0x31, |
| 0x32, |
| 0x33, |
| 0x34, |
| 0x35, |
| 0x36, |
| 0x37, // 0 1 2 3 4 5 6 7 |
| 0x38, |
| 0x39, |
| 0x3A, |
| 0x3B, |
| 0x3C, |
| 0x3D, |
| 0x3E, |
| 0x3F, // 8 9 : ; < = > ? |
| 0x40, |
| 0x41, |
| 0x42, |
| 0x43, |
| 0x44, |
| 0x45, |
| 0x46, |
| 0x47, // @ A B C D E F G |
| 0x48, |
| 0x49, |
| 0x4A, |
| 0x4B, |
| 0x4C, |
| 0x4D, |
| 0x4E, |
| 0x4F, // H I J K L M N O |
| 0x50, |
| 0x51, |
| 0x52, |
| 0x53, |
| 0x54, |
| 0x55, |
| 0x56, |
| 0x57, // P Q R S T U V W |
| 0x58, |
| 0x59, |
| 0x5A, |
| 0x5B, // X Y Z [ |
| 0xE9, // 5C: 233 'รฉ' "Latin small letter E with acute" |
| 0x5D, // ] |
| 0xED, // 5E: 237 'รญ' "Latin small letter I with acute" |
| 0xF3, // 5F: 243 'รณ' "Latin small letter O with acute" |
| 0xFA, // 60: 250 'รบ' "Latin small letter U with acute" |
| 0x61, |
| 0x62, |
| 0x63, |
| 0x64, |
| 0x65, |
| 0x66, |
| 0x67, // a b c d e f g |
| 0x68, |
| 0x69, |
| 0x6A, |
| 0x6B, |
| 0x6C, |
| 0x6D, |
| 0x6E, |
| 0x6F, // h i j k l m n o |
| 0x70, |
| 0x71, |
| 0x72, |
| 0x73, |
| 0x74, |
| 0x75, |
| 0x76, |
| 0x77, // p q r s t u v w |
| 0x78, |
| 0x79, |
| 0x7A, // x y z |
| 0xE7, // 7B: 231 'รง' "Latin small letter C with cedilla" |
| 0xF7, // 7C: 247 'รท' "Division sign" |
| 0xD1, // 7D: 209 'ร' "Latin capital letter N with tilde" |
| 0xF1, // 7E: 241 'รฑ' "Latin small letter N with tilde" |
| 0x25A0 // 7F: "Black Square" (NB: 2588 = Full Block) |
| }; |
| |
| // Special North American 608 CC char set. |
| private static final int[] SPECIAL_CHARACTER_SET = |
| new int[] { |
| 0xAE, // 30: 174 'ยฎ' "Registered Sign" - registered trademark symbol |
| 0xB0, // 31: 176 'ยฐ' "Degree Sign" |
| 0xBD, // 32: 189 'ยฝ' "Vulgar Fraction One Half" (1/2 symbol) |
| 0xBF, // 33: 191 'ยฟ' "Inverted Question Mark" |
| 0x2122, // 34: "Trade Mark Sign" (tm superscript) |
| 0xA2, // 35: 162 'ยข' "Cent Sign" |
| 0xA3, // 36: 163 'ยฃ' "Pound Sign" - pounds sterling |
| 0x266A, // 37: "Eighth Note" - music note |
| 0xE0, // 38: 224 'ร ' "Latin small letter A with grave" |
| 0x20, // 39: TRANSPARENT SPACE - for now use ordinary space |
| 0xE8, // 3A: 232 'รจ' "Latin small letter E with grave" |
| 0xE2, // 3B: 226 'รข' "Latin small letter A with circumflex" |
| 0xEA, // 3C: 234 'รช' "Latin small letter E with circumflex" |
| 0xEE, // 3D: 238 'รฎ' "Latin small letter I with circumflex" |
| 0xF4, // 3E: 244 'รด' "Latin small letter O with circumflex" |
| 0xFB // 3F: 251 'รป' "Latin small letter U with circumflex" |
| }; |
| |
| // Extended Spanish/Miscellaneous and French char set. |
| private static final int[] SPECIAL_ES_FR_CHARACTER_SET = |
| new int[] { |
| // Spanish and misc. |
| 0xC1, 0xC9, 0xD3, 0xDA, 0xDC, 0xFC, 0x2018, 0xA1, |
| 0x2A, 0x27, 0x2014, 0xA9, 0x2120, 0x2022, 0x201C, 0x201D, |
| // French. |
| 0xC0, 0xC2, 0xC7, 0xC8, 0xCA, 0xCB, 0xEB, 0xCE, |
| 0xCF, 0xEF, 0xD4, 0xD9, 0xF9, 0xDB, 0xAB, 0xBB |
| }; |
| |
| // Extended Portuguese and German/Danish char set. |
| private static final int[] SPECIAL_PT_DE_CHARACTER_SET = |
| new int[] { |
| // Portuguese. |
| 0xC3, 0xE3, 0xCD, 0xCC, 0xEC, 0xD2, 0xF2, 0xD5, |
| 0xF5, 0x7B, 0x7D, 0x5C, 0x5E, 0x5F, 0x7C, 0x7E, |
| // German/Danish. |
| 0xC4, 0xE4, 0xD6, 0xF6, 0xDF, 0xA5, 0xA4, 0x2502, |
| 0xC5, 0xE5, 0xD8, 0xF8, 0x250C, 0x2510, 0x2514, 0x2518 |
| }; |
| |
| private static final boolean[] ODD_PARITY_BYTE_TABLE = { |
| false, true, true, false, true, false, false, true, // 0 |
| true, false, false, true, false, true, true, false, // 8 |
| true, false, false, true, false, true, true, false, // 16 |
| false, true, true, false, true, false, false, true, // 24 |
| true, false, false, true, false, true, true, false, // 32 |
| false, true, true, false, true, false, false, true, // 40 |
| false, true, true, false, true, false, false, true, // 48 |
| true, false, false, true, false, true, true, false, // 56 |
| true, false, false, true, false, true, true, false, // 64 |
| false, true, true, false, true, false, false, true, // 72 |
| false, true, true, false, true, false, false, true, // 80 |
| true, false, false, true, false, true, true, false, // 88 |
| false, true, true, false, true, false, false, true, // 96 |
| true, false, false, true, false, true, true, false, // 104 |
| true, false, false, true, false, true, true, false, // 112 |
| false, true, true, false, true, false, false, true, // 120 |
| true, false, false, true, false, true, true, false, // 128 |
| false, true, true, false, true, false, false, true, // 136 |
| false, true, true, false, true, false, false, true, // 144 |
| true, false, false, true, false, true, true, false, // 152 |
| false, true, true, false, true, false, false, true, // 160 |
| true, false, false, true, false, true, true, false, // 168 |
| true, false, false, true, false, true, true, false, // 176 |
| false, true, true, false, true, false, false, true, // 184 |
| false, true, true, false, true, false, false, true, // 192 |
| true, false, false, true, false, true, true, false, // 200 |
| true, false, false, true, false, true, true, false, // 208 |
| false, true, true, false, true, false, false, true, // 216 |
| true, false, false, true, false, true, true, false, // 224 |
| false, true, true, false, true, false, false, true, // 232 |
| false, true, true, false, true, false, false, true, // 240 |
| true, false, false, true, false, true, true, false, // 248 |
| }; |
| |
| private final ParsableByteArray ccData; |
| private final int packetLength; |
| private final int selectedField; |
| private final int selectedChannel; |
| private final long validDataChannelTimeoutUs; |
| private final ArrayList<CueBuilder> cueBuilders; |
| |
| private CueBuilder currentCueBuilder; |
| @Nullable private List<Cue> cues; |
| @Nullable private List<Cue> lastCues; |
| |
| private int captionMode; |
| private int captionRowCount; |
| |
| private boolean isCaptionValid; |
| private boolean repeatableControlSet; |
| private byte repeatableControlCc1; |
| private byte repeatableControlCc2; |
| private int currentChannel; |
| |
| // The incoming characters may belong to 3 different services based on the last received control |
| // codes. The 3 services are Captioning, Text and XDS. The decoder only processes Captioning |
| // service bytes and drops the rest. |
| private boolean isInCaptionService; |
| |
| private long lastCueUpdateUs; |
| |
| /** |
| * Constructs an instance. |
| * |
| * @param mimeType The MIME type of the CEA-608 data. |
| * @param accessibilityChannel The Accessibility channel, or {@link Format#NO_VALUE} if unknown. |
| * @param validDataChannelTimeoutMs The timeout (in milliseconds) permitted by ANSI/CTA-608-E |
| * R-2014 Annex C.9 to clear "stuck" captions where no removal control code is received. The |
| * timeout should be at least {@link #MIN_DATA_CHANNEL_TIMEOUT_MS} or {@link C#TIME_UNSET} for |
| * no timeout. |
| */ |
| public Cea608Decoder(String mimeType, int accessibilityChannel, long validDataChannelTimeoutMs) { |
| ccData = new ParsableByteArray(); |
| cueBuilders = new ArrayList<>(); |
| currentCueBuilder = new CueBuilder(CC_MODE_UNKNOWN, DEFAULT_CAPTIONS_ROW_COUNT); |
| currentChannel = NTSC_CC_CHANNEL_1; |
| this.validDataChannelTimeoutUs = |
| validDataChannelTimeoutMs > 0 ? validDataChannelTimeoutMs * 1000 : C.TIME_UNSET; |
| packetLength = MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) ? 2 : 3; |
| switch (accessibilityChannel) { |
| case 1: |
| selectedChannel = NTSC_CC_CHANNEL_1; |
| selectedField = NTSC_CC_FIELD_1; |
| break; |
| case 2: |
| selectedChannel = NTSC_CC_CHANNEL_2; |
| selectedField = NTSC_CC_FIELD_1; |
| break; |
| case 3: |
| selectedChannel = NTSC_CC_CHANNEL_1; |
| selectedField = NTSC_CC_FIELD_2; |
| break; |
| case 4: |
| selectedChannel = NTSC_CC_CHANNEL_2; |
| selectedField = NTSC_CC_FIELD_2; |
| break; |
| default: |
| Log.w(TAG, "Invalid channel. Defaulting to CC1."); |
| selectedChannel = NTSC_CC_CHANNEL_1; |
| selectedField = NTSC_CC_FIELD_1; |
| } |
| |
| setCaptionMode(CC_MODE_UNKNOWN); |
| resetCueBuilders(); |
| isInCaptionService = true; |
| lastCueUpdateUs = C.TIME_UNSET; |
| } |
| |
| @Override |
| public String getName() { |
| return "Cea608Decoder"; |
| } |
| |
| @Override |
| public void flush() { |
| super.flush(); |
| cues = null; |
| lastCues = null; |
| setCaptionMode(CC_MODE_UNKNOWN); |
| setCaptionRowCount(DEFAULT_CAPTIONS_ROW_COUNT); |
| resetCueBuilders(); |
| isCaptionValid = false; |
| repeatableControlSet = false; |
| repeatableControlCc1 = 0; |
| repeatableControlCc2 = 0; |
| currentChannel = NTSC_CC_CHANNEL_1; |
| isInCaptionService = true; |
| lastCueUpdateUs = C.TIME_UNSET; |
| } |
| |
| @Override |
| public void release() { |
| // Do nothing |
| } |
| |
| @Nullable |
| @Override |
| public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderException { |
| SubtitleOutputBuffer outputBuffer = super.dequeueOutputBuffer(); |
| if (outputBuffer != null) { |
| return outputBuffer; |
| } |
| if (shouldClearStuckCaptions()) { |
| outputBuffer = getAvailableOutputBuffer(); |
| if (outputBuffer != null) { |
| cues = Collections.emptyList(); |
| lastCueUpdateUs = C.TIME_UNSET; |
| Subtitle subtitle = createSubtitle(); |
| outputBuffer.setContent(getPositionUs(), subtitle, Format.OFFSET_SAMPLE_RELATIVE); |
| return outputBuffer; |
| } |
| } |
| return null; |
| } |
| |
| @Override |
| protected boolean isNewSubtitleDataAvailable() { |
| return cues != lastCues; |
| } |
| |
| @Override |
| protected Subtitle createSubtitle() { |
| lastCues = cues; |
| return new CeaSubtitle(Assertions.checkNotNull(cues)); |
| } |
| |
| @SuppressWarnings("ByteBufferBackingArray") |
| @Override |
| protected void decode(SubtitleInputBuffer inputBuffer) { |
| ByteBuffer subtitleData = Assertions.checkNotNull(inputBuffer.data); |
| ccData.reset(subtitleData.array(), subtitleData.limit()); |
| boolean captionDataProcessed = false; |
| while (ccData.bytesLeft() >= packetLength) { |
| byte ccHeader = |
| packetLength == 2 ? CC_IMPLICIT_DATA_HEADER : (byte) ccData.readUnsignedByte(); |
| int ccByte1 = ccData.readUnsignedByte(); |
| int ccByte2 = ccData.readUnsignedByte(); |
| |
| // TODO: We're currently ignoring the top 5 marker bits, which should all be 1s according |
| // to the CEA-608 specification. We need to determine if the data should be handled |
| // differently when that is not the case. |
| |
| if ((ccHeader & CC_TYPE_FLAG) != 0) { |
| // Do not process anything that is not part of the 608 byte stream. |
| continue; |
| } |
| |
| if ((ccHeader & CC_FIELD_FLAG) != selectedField) { |
| // Do not process packets not within the selected field. |
| continue; |
| } |
| |
| // Strip the parity bit from each byte to get CC data. |
| byte ccData1 = (byte) (ccByte1 & 0x7F); |
| byte ccData2 = (byte) (ccByte2 & 0x7F); |
| |
| if (ccData1 == 0 && ccData2 == 0) { |
| // Ignore empty captions. |
| continue; |
| } |
| |
| boolean previousIsCaptionValid = isCaptionValid; |
| isCaptionValid = |
| (ccHeader & CC_VALID_FLAG) == CC_VALID_FLAG |
| && ODD_PARITY_BYTE_TABLE[ccByte1] |
| && ODD_PARITY_BYTE_TABLE[ccByte2]; |
| |
| if (isRepeatedCommand(isCaptionValid, ccData1, ccData2)) { |
| // Ignore repeated valid commands. |
| continue; |
| } |
| |
| if (!isCaptionValid) { |
| if (previousIsCaptionValid) { |
| // The encoder has flipped the validity bit to indicate captions are being turned off. |
| resetCueBuilders(); |
| captionDataProcessed = true; |
| } |
| continue; |
| } |
| |
| maybeUpdateIsInCaptionService(ccData1, ccData2); |
| if (!isInCaptionService) { |
| // Only the Captioning service is supported. Drop all other bytes. |
| continue; |
| } |
| |
| if (!updateAndVerifyCurrentChannel(ccData1)) { |
| // Wrong channel. |
| continue; |
| } |
| |
| if (isCtrlCode(ccData1)) { |
| if (isSpecialNorthAmericanChar(ccData1, ccData2)) { |
| currentCueBuilder.append(getSpecialNorthAmericanChar(ccData2)); |
| } else if (isExtendedWestEuropeanChar(ccData1, ccData2)) { |
| // Remove standard equivalent of the special extended char before appending new one. |
| currentCueBuilder.backspace(); |
| currentCueBuilder.append(getExtendedWestEuropeanChar(ccData1, ccData2)); |
| } else if (isMidrowCtrlCode(ccData1, ccData2)) { |
| handleMidrowCtrl(ccData2); |
| } else if (isPreambleAddressCode(ccData1, ccData2)) { |
| handlePreambleAddressCode(ccData1, ccData2); |
| } else if (isTabCtrlCode(ccData1, ccData2)) { |
| currentCueBuilder.tabOffset = ccData2 - 0x20; |
| } else if (isMiscCode(ccData1, ccData2)) { |
| handleMiscCode(ccData2); |
| } |
| } else { |
| // Basic North American character set. |
| currentCueBuilder.append(getBasicChar(ccData1)); |
| if ((ccData2 & 0xE0) != 0x00) { |
| currentCueBuilder.append(getBasicChar(ccData2)); |
| } |
| } |
| captionDataProcessed = true; |
| } |
| |
| if (captionDataProcessed) { |
| if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { |
| cues = getDisplayCues(); |
| lastCueUpdateUs = getPositionUs(); |
| } |
| } |
| } |
| |
| private boolean updateAndVerifyCurrentChannel(byte cc1) { |
| if (isCtrlCode(cc1)) { |
| currentChannel = getChannel(cc1); |
| } |
| return currentChannel == selectedChannel; |
| } |
| |
| private boolean isRepeatedCommand(boolean captionValid, byte cc1, byte cc2) { |
| // Most control commands are sent twice in succession to ensure they are received properly. We |
| // don't want to process duplicate commands, so if we see the same repeatable command twice in a |
| // row then we ignore the second one. |
| if (captionValid && isRepeatable(cc1)) { |
| if (repeatableControlSet && repeatableControlCc1 == cc1 && repeatableControlCc2 == cc2) { |
| // This is a repeated command, so we ignore it. |
| repeatableControlSet = false; |
| return true; |
| } else { |
| // This is the first occurrence of a repeatable command. Set the repeatable control |
| // variables so that we can recognize and ignore a duplicate (if there is one), and then |
| // continue to process the command below. |
| repeatableControlSet = true; |
| repeatableControlCc1 = cc1; |
| repeatableControlCc2 = cc2; |
| } |
| } else { |
| // This command is not repeatable. |
| repeatableControlSet = false; |
| } |
| return false; |
| } |
| |
| private void handleMidrowCtrl(byte cc2) { |
| // TODO: support the extended styles (i.e. backgrounds and transparencies) |
| |
| // A midrow control code advances the cursor. |
| currentCueBuilder.append(' '); |
| |
| // cc2 - 0|0|1|0|STYLE|U |
| boolean underline = (cc2 & 0x01) == 0x01; |
| int style = (cc2 >> 1) & 0x07; |
| currentCueBuilder.setStyle(style, underline); |
| } |
| |
| private void handlePreambleAddressCode(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|E|ROW |
| // C is the channel toggle, E is the extended flag, and ROW is the encoded row |
| int row = ROW_INDICES[cc1 & 0x07]; |
| // TODO: support the extended address and style |
| |
| // cc2 - 0|1|N|ATTRBTE|U |
| // N is the next row down toggle, ATTRBTE is the 4-byte encoded attribute, and U is the |
| // underline toggle. |
| boolean nextRowDown = (cc2 & 0x20) != 0; |
| if (nextRowDown) { |
| row++; |
| } |
| |
| if (row != currentCueBuilder.row) { |
| if (captionMode != CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { |
| currentCueBuilder = new CueBuilder(captionMode, captionRowCount); |
| cueBuilders.add(currentCueBuilder); |
| } |
| currentCueBuilder.row = row; |
| } |
| |
| // cc2 - 0|1|N|0|STYLE|U |
| // cc2 - 0|1|N|1|CURSR|U |
| boolean isCursor = (cc2 & 0x10) == 0x10; |
| boolean underline = (cc2 & 0x01) == 0x01; |
| int cursorOrStyle = (cc2 >> 1) & 0x07; |
| |
| // We need to call setStyle even for the isCursor case, to update the underline bit. |
| // STYLE_UNCHANGED is used for this case. |
| currentCueBuilder.setStyle(isCursor ? STYLE_UNCHANGED : cursorOrStyle, underline); |
| |
| if (isCursor) { |
| currentCueBuilder.indent = COLUMN_INDICES[cursorOrStyle]; |
| } |
| } |
| |
| private void handleMiscCode(byte cc2) { |
| switch (cc2) { |
| case CTRL_ROLL_UP_CAPTIONS_2_ROWS: |
| setCaptionMode(CC_MODE_ROLL_UP); |
| setCaptionRowCount(2); |
| return; |
| case CTRL_ROLL_UP_CAPTIONS_3_ROWS: |
| setCaptionMode(CC_MODE_ROLL_UP); |
| setCaptionRowCount(3); |
| return; |
| case CTRL_ROLL_UP_CAPTIONS_4_ROWS: |
| setCaptionMode(CC_MODE_ROLL_UP); |
| setCaptionRowCount(4); |
| return; |
| case CTRL_RESUME_CAPTION_LOADING: |
| setCaptionMode(CC_MODE_POP_ON); |
| return; |
| case CTRL_RESUME_DIRECT_CAPTIONING: |
| setCaptionMode(CC_MODE_PAINT_ON); |
| return; |
| default: |
| // Fall through. |
| break; |
| } |
| |
| if (captionMode == CC_MODE_UNKNOWN) { |
| return; |
| } |
| |
| switch (cc2) { |
| case CTRL_ERASE_DISPLAYED_MEMORY: |
| cues = Collections.emptyList(); |
| if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { |
| resetCueBuilders(); |
| } |
| break; |
| case CTRL_ERASE_NON_DISPLAYED_MEMORY: |
| resetCueBuilders(); |
| break; |
| case CTRL_END_OF_CAPTION: |
| cues = getDisplayCues(); |
| resetCueBuilders(); |
| break; |
| case CTRL_CARRIAGE_RETURN: |
| // carriage returns only apply to rollup captions; don't bother if we don't have anything |
| // to add a carriage return to |
| if (captionMode == CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { |
| currentCueBuilder.rollUp(); |
| } |
| break; |
| case CTRL_BACKSPACE: |
| currentCueBuilder.backspace(); |
| break; |
| case CTRL_DELETE_TO_END_OF_ROW: |
| // TODO: implement |
| break; |
| default: |
| // Fall through. |
| break; |
| } |
| } |
| |
| private List<Cue> getDisplayCues() { |
| // CEA-608 does not define middle and end alignment, however content providers artificially |
| // introduce them using whitespace. When each cue is built, we try and infer the alignment based |
| // on the amount of whitespace either side of the text. To avoid consecutive cues being aligned |
| // differently, we force all cues to have the same alignment, with start alignment given |
| // preference, then middle alignment, then end alignment. |
| @Cue.AnchorType int positionAnchor = Cue.ANCHOR_TYPE_END; |
| int cueBuilderCount = cueBuilders.size(); |
| List<@NullableType Cue> cueBuilderCues = new ArrayList<>(cueBuilderCount); |
| for (int i = 0; i < cueBuilderCount; i++) { |
| @Nullable Cue cue = cueBuilders.get(i).build(/* forcedPositionAnchor= */ Cue.TYPE_UNSET); |
| cueBuilderCues.add(cue); |
| if (cue != null) { |
| positionAnchor = min(positionAnchor, cue.positionAnchor); |
| } |
| } |
| |
| // Skip null cues and rebuild any that don't have the preferred alignment. |
| List<Cue> displayCues = new ArrayList<>(cueBuilderCount); |
| for (int i = 0; i < cueBuilderCount; i++) { |
| @Nullable Cue cue = cueBuilderCues.get(i); |
| if (cue != null) { |
| if (cue.positionAnchor != positionAnchor) { |
| // The last time we built this cue it was non-null, it will be non-null this time too. |
| cue = Assertions.checkNotNull(cueBuilders.get(i).build(positionAnchor)); |
| } |
| displayCues.add(cue); |
| } |
| } |
| |
| return displayCues; |
| } |
| |
| private void setCaptionMode(int captionMode) { |
| if (this.captionMode == captionMode) { |
| return; |
| } |
| |
| int oldCaptionMode = this.captionMode; |
| this.captionMode = captionMode; |
| |
| if (captionMode == CC_MODE_PAINT_ON) { |
| // Switching to paint-on mode should have no effect except to select the mode. |
| for (int i = 0; i < cueBuilders.size(); i++) { |
| cueBuilders.get(i).setCaptionMode(captionMode); |
| } |
| return; |
| } |
| |
| // Clear the working memory. |
| resetCueBuilders(); |
| if (oldCaptionMode == CC_MODE_PAINT_ON |
| || captionMode == CC_MODE_ROLL_UP |
| || captionMode == CC_MODE_UNKNOWN) { |
| // When switching from paint-on or to roll-up or unknown, we also need to clear the caption. |
| cues = Collections.emptyList(); |
| } |
| } |
| |
| private void setCaptionRowCount(int captionRowCount) { |
| this.captionRowCount = captionRowCount; |
| currentCueBuilder.setCaptionRowCount(captionRowCount); |
| } |
| |
| private void resetCueBuilders() { |
| currentCueBuilder.reset(captionMode); |
| cueBuilders.clear(); |
| cueBuilders.add(currentCueBuilder); |
| } |
| |
| private void maybeUpdateIsInCaptionService(byte cc1, byte cc2) { |
| if (isXdsControlCode(cc1)) { |
| isInCaptionService = false; |
| } else if (isServiceSwitchCommand(cc1)) { |
| switch (cc2) { |
| case CTRL_TEXT_RESTART: |
| case CTRL_RESUME_TEXT_DISPLAY: |
| isInCaptionService = false; |
| break; |
| case CTRL_END_OF_CAPTION: |
| case CTRL_RESUME_CAPTION_LOADING: |
| case CTRL_RESUME_DIRECT_CAPTIONING: |
| case CTRL_ROLL_UP_CAPTIONS_2_ROWS: |
| case CTRL_ROLL_UP_CAPTIONS_3_ROWS: |
| case CTRL_ROLL_UP_CAPTIONS_4_ROWS: |
| isInCaptionService = true; |
| break; |
| default: |
| // No update. |
| } |
| } |
| } |
| |
| private static char getBasicChar(byte ccData) { |
| int index = (ccData & 0x7F) - 0x20; |
| return (char) BASIC_CHARACTER_SET[index]; |
| } |
| |
| private static boolean isSpecialNorthAmericanChar(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|0|0|1 |
| // cc2 - 0|0|1|1|X|X|X|X |
| return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x30); |
| } |
| |
| private static char getSpecialNorthAmericanChar(byte ccData) { |
| int index = ccData & 0x0F; |
| return (char) SPECIAL_CHARACTER_SET[index]; |
| } |
| |
| private static boolean isExtendedWestEuropeanChar(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|0|1|S |
| // cc2 - 0|0|1|X|X|X|X|X |
| return ((cc1 & 0xF6) == 0x12) && ((cc2 & 0xE0) == 0x20); |
| } |
| |
| private static char getExtendedWestEuropeanChar(byte cc1, byte cc2) { |
| if ((cc1 & 0x01) == 0x00) { |
| // Extended Spanish/Miscellaneous and French character set (S = 0). |
| return getExtendedEsFrChar(cc2); |
| } else { |
| // Extended Portuguese and German/Danish character set (S = 1). |
| return getExtendedPtDeChar(cc2); |
| } |
| } |
| |
| private static char getExtendedEsFrChar(byte ccData) { |
| int index = ccData & 0x1F; |
| return (char) SPECIAL_ES_FR_CHARACTER_SET[index]; |
| } |
| |
| private static char getExtendedPtDeChar(byte ccData) { |
| int index = ccData & 0x1F; |
| return (char) SPECIAL_PT_DE_CHARACTER_SET[index]; |
| } |
| |
| private static boolean isCtrlCode(byte cc1) { |
| // cc1 - 0|0|0|X|X|X|X|X |
| return (cc1 & 0xE0) == 0x00; |
| } |
| |
| private static int getChannel(byte cc1) { |
| // cc1 - X|X|X|X|C|X|X|X |
| return (cc1 >> 3) & 0x1; |
| } |
| |
| private static boolean isMidrowCtrlCode(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|0|0|1 |
| // cc2 - 0|0|1|0|X|X|X|X |
| return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x20); |
| } |
| |
| private static boolean isPreambleAddressCode(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|X|X|X |
| // cc2 - 0|1|X|X|X|X|X|X |
| return ((cc1 & 0xF0) == 0x10) && ((cc2 & 0xC0) == 0x40); |
| } |
| |
| private static boolean isTabCtrlCode(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|1|1|1 |
| // cc2 - 0|0|1|0|0|0|0|1 to 0|0|1|0|0|0|1|1 |
| return ((cc1 & 0xF7) == 0x17) && (cc2 >= 0x21 && cc2 <= 0x23); |
| } |
| |
| private static boolean isMiscCode(byte cc1, byte cc2) { |
| // cc1 - 0|0|0|1|C|1|0|F |
| // cc2 - 0|0|1|0|X|X|X|X |
| return ((cc1 & 0xF6) == 0x14) && ((cc2 & 0xF0) == 0x20); |
| } |
| |
| private static boolean isRepeatable(byte cc1) { |
| // cc1 - 0|0|0|1|X|X|X|X |
| return (cc1 & 0xF0) == 0x10; |
| } |
| |
| private static boolean isXdsControlCode(byte cc1) { |
| return 0x01 <= cc1 && cc1 <= 0x0F; |
| } |
| |
| private static boolean isServiceSwitchCommand(byte cc1) { |
| // cc1 - 0|0|0|1|C|1|0|0 |
| return (cc1 & 0xF7) == 0x14; |
| } |
| |
| private static final class CueBuilder { |
| |
| // 608 captions define a 15 row by 32 column screen grid. These constants convert from 608 |
| // positions to normalized screen position. |
| private static final int SCREEN_CHARWIDTH = 32; |
| private static final int BASE_ROW = 15; |
| |
| private final List<CueStyle> cueStyles; |
| private final List<SpannableString> rolledUpCaptions; |
| private final StringBuilder captionStringBuilder; |
| |
| private int row; |
| private int indent; |
| private int tabOffset; |
| private int captionMode; |
| private int captionRowCount; |
| |
| public CueBuilder(int captionMode, int captionRowCount) { |
| cueStyles = new ArrayList<>(); |
| rolledUpCaptions = new ArrayList<>(); |
| captionStringBuilder = new StringBuilder(); |
| reset(captionMode); |
| this.captionRowCount = captionRowCount; |
| } |
| |
| public void reset(int captionMode) { |
| this.captionMode = captionMode; |
| cueStyles.clear(); |
| rolledUpCaptions.clear(); |
| captionStringBuilder.setLength(0); |
| row = BASE_ROW; |
| indent = 0; |
| tabOffset = 0; |
| } |
| |
| public boolean isEmpty() { |
| return cueStyles.isEmpty() |
| && rolledUpCaptions.isEmpty() |
| && captionStringBuilder.length() == 0; |
| } |
| |
| public void setCaptionMode(int captionMode) { |
| this.captionMode = captionMode; |
| } |
| |
| public void setCaptionRowCount(int captionRowCount) { |
| this.captionRowCount = captionRowCount; |
| } |
| |
| public void setStyle(int style, boolean underline) { |
| cueStyles.add(new CueStyle(style, underline, captionStringBuilder.length())); |
| } |
| |
| public void backspace() { |
| int length = captionStringBuilder.length(); |
| if (length > 0) { |
| captionStringBuilder.delete(length - 1, length); |
| // Decrement style start positions if necessary. |
| for (int i = cueStyles.size() - 1; i >= 0; i--) { |
| CueStyle style = cueStyles.get(i); |
| if (style.start == length) { |
| style.start--; |
| } else { |
| // All earlier cues must have style.start < length. |
| break; |
| } |
| } |
| } |
| } |
| |
| public void append(char text) { |
| // Don't accept more than 32 chars. We'll trim further, considering indent & tabOffset, in |
| // build(). |
| if (captionStringBuilder.length() < SCREEN_CHARWIDTH) { |
| captionStringBuilder.append(text); |
| } |
| } |
| |
| public void rollUp() { |
| rolledUpCaptions.add(buildCurrentLine()); |
| captionStringBuilder.setLength(0); |
| cueStyles.clear(); |
| int numRows = min(captionRowCount, row); |
| while (rolledUpCaptions.size() >= numRows) { |
| rolledUpCaptions.remove(0); |
| } |
| } |
| |
| @Nullable |
| public Cue build(@Cue.AnchorType int forcedPositionAnchor) { |
| // The number of empty columns before the start of the text, in the range [0-31]. |
| int startPadding = indent + tabOffset; |
| int maxTextLength = SCREEN_CHARWIDTH - startPadding; |
| SpannableStringBuilder cueString = new SpannableStringBuilder(); |
| // Add any rolled up captions, separated by new lines. |
| for (int i = 0; i < rolledUpCaptions.size(); i++) { |
| cueString.append(Util.truncateAscii(rolledUpCaptions.get(i), maxTextLength)); |
| cueString.append('\n'); |
| } |
| // Add the current line. |
| cueString.append(Util.truncateAscii(buildCurrentLine(), maxTextLength)); |
| |
| if (cueString.length() == 0) { |
| // The cue is empty. |
| return null; |
| } |
| |
| int positionAnchor; |
| // The number of empty columns after the end of the text, in the same range. |
| int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); |
| int startEndPaddingDelta = startPadding - endPadding; |
| if (forcedPositionAnchor != Cue.TYPE_UNSET) { |
| positionAnchor = forcedPositionAnchor; |
| } else if (captionMode == CC_MODE_POP_ON |
| && (Math.abs(startEndPaddingDelta) < 3 || endPadding < 0)) { |
| // Treat approximately centered pop-on captions as middle aligned. We also treat captions |
| // that are wider than they should be in this way. See |
| // https://github.com/google/ExoPlayer/issues/3534. |
| positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; |
| } else if (captionMode == CC_MODE_POP_ON && startEndPaddingDelta > 0) { |
| // Treat pop-on captions with less padding at the end than the start as end aligned. |
| positionAnchor = Cue.ANCHOR_TYPE_END; |
| } else { |
| // For all other cases assume start aligned. |
| positionAnchor = Cue.ANCHOR_TYPE_START; |
| } |
| |
| float position; |
| switch (positionAnchor) { |
| case Cue.ANCHOR_TYPE_MIDDLE: |
| position = 0.5f; |
| break; |
| case Cue.ANCHOR_TYPE_END: |
| position = (float) (SCREEN_CHARWIDTH - endPadding) / SCREEN_CHARWIDTH; |
| // Adjust the position to fit within the safe area. |
| position = position * 0.8f + 0.1f; |
| break; |
| case Cue.ANCHOR_TYPE_START: |
| default: |
| position = (float) startPadding / SCREEN_CHARWIDTH; |
| // Adjust the position to fit within the safe area. |
| position = position * 0.8f + 0.1f; |
| break; |
| } |
| |
| int line; |
| // Note: Row indices are in the range [1-15], Cue.line counts from 0 (top) and -1 (bottom). |
| if (row > (BASE_ROW / 2)) { |
| line = row - BASE_ROW; |
| // Two line adjustments. The first is because line indices from the bottom of the window |
| // start from -1 rather than 0. The second is a blank row to act as the safe area. |
| line -= 2; |
| } else { |
| // The `row` of roll-up cues positions the bottom line (even for cues shown in the top |
| // half of the screen), so we need to consider the number of rows in this cue. In |
| // non-roll-up, we don't need any further adjustments because we leave the first line |
| // (cue.line=0) blank to act as the safe area, so positioning row=1 at Cue.line=1 is |
| // correct. |
| line = captionMode == CC_MODE_ROLL_UP ? row - (captionRowCount - 1) : row; |
| } |
| |
| return new Cue.Builder() |
| .setText(cueString) |
| .setTextAlignment(Alignment.ALIGN_NORMAL) |
| .setLine(line, Cue.LINE_TYPE_NUMBER) |
| .setPosition(position) |
| .setPositionAnchor(positionAnchor) |
| .build(); |
| } |
| |
| private SpannableString buildCurrentLine() { |
| SpannableStringBuilder builder = new SpannableStringBuilder(captionStringBuilder); |
| int length = builder.length(); |
| |
| int underlineStartPosition = C.INDEX_UNSET; |
| int italicStartPosition = C.INDEX_UNSET; |
| int colorStartPosition = 0; |
| int color = Color.WHITE; |
| |
| boolean nextItalic = false; |
| int nextColor = Color.WHITE; |
| |
| for (int i = 0; i < cueStyles.size(); i++) { |
| CueStyle cueStyle = cueStyles.get(i); |
| boolean underline = cueStyle.underline; |
| int style = cueStyle.style; |
| if (style != STYLE_UNCHANGED) { |
| // If the style is a color then italic is cleared. |
| nextItalic = style == STYLE_ITALICS; |
| // If the style is italic then the color is left unchanged. |
| nextColor = style == STYLE_ITALICS ? nextColor : STYLE_COLORS[style]; |
| } |
| |
| int position = cueStyle.start; |
| int nextPosition = (i + 1) < cueStyles.size() ? cueStyles.get(i + 1).start : length; |
| if (position == nextPosition) { |
| // There are more cueStyles to process at the current position. |
| continue; |
| } |
| |
| // Process changes to underline up to the current position. |
| if (underlineStartPosition != C.INDEX_UNSET && !underline) { |
| setUnderlineSpan(builder, underlineStartPosition, position); |
| underlineStartPosition = C.INDEX_UNSET; |
| } else if (underlineStartPosition == C.INDEX_UNSET && underline) { |
| underlineStartPosition = position; |
| } |
| // Process changes to italic up to the current position. |
| if (italicStartPosition != C.INDEX_UNSET && !nextItalic) { |
| setItalicSpan(builder, italicStartPosition, position); |
| italicStartPosition = C.INDEX_UNSET; |
| } else if (italicStartPosition == C.INDEX_UNSET && nextItalic) { |
| italicStartPosition = position; |
| } |
| // Process changes to color up to the current position. |
| if (nextColor != color) { |
| setColorSpan(builder, colorStartPosition, position, color); |
| color = nextColor; |
| colorStartPosition = position; |
| } |
| } |
| |
| // Add any final spans. |
| if (underlineStartPosition != C.INDEX_UNSET && underlineStartPosition != length) { |
| setUnderlineSpan(builder, underlineStartPosition, length); |
| } |
| if (italicStartPosition != C.INDEX_UNSET && italicStartPosition != length) { |
| setItalicSpan(builder, italicStartPosition, length); |
| } |
| if (colorStartPosition != length) { |
| setColorSpan(builder, colorStartPosition, length, color); |
| } |
| |
| return new SpannableString(builder); |
| } |
| |
| private static void setUnderlineSpan(SpannableStringBuilder builder, int start, int end) { |
| builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| |
| private static void setItalicSpan(SpannableStringBuilder builder, int start, int end) { |
| builder.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| |
| private static void setColorSpan( |
| SpannableStringBuilder builder, int start, int end, int color) { |
| if (color == Color.WHITE) { |
| // White is treated as the default color (i.e. no span is attached). |
| return; |
| } |
| builder.setSpan(new ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| |
| private static class CueStyle { |
| |
| public final int style; |
| public final boolean underline; |
| |
| public int start; |
| |
| public CueStyle(int style, boolean underline, int start) { |
| this.style = style; |
| this.underline = underline; |
| this.start = start; |
| } |
| } |
| } |
| |
| /** See ANSI/CTA-608-E R-2014 Annex C.9 for Caption Erase Logic. */ |
| private boolean shouldClearStuckCaptions() { |
| if (validDataChannelTimeoutUs == C.TIME_UNSET || lastCueUpdateUs == C.TIME_UNSET) { |
| return false; |
| } |
| long elapsedUs = getPositionUs() - lastCueUpdateUs; |
| return elapsedUs >= validDataChannelTimeoutUs; |
| } |
| } |