diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java
index 1f78e39390..ddf4bef12f 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java
@@ -23,6 +23,7 @@ import androidx.media3.extractor.text.DefaultSubtitleParserFactory;
import androidx.media3.extractor.text.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.media3.extractor.text.cea.Cea608Decoder;
+import androidx.media3.extractor.text.cea.Cea608Parser;
import androidx.media3.extractor.text.cea.Cea708Decoder;
import java.util.Objects;
@@ -85,9 +86,10 @@ public interface SubtitleDecoderFactory {
case MimeTypes.APPLICATION_CEA608:
case MimeTypes.APPLICATION_MP4CEA608:
return new Cea608Decoder(
- mimeType,
- format.accessibilityChannel,
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ mimeType,
+ format.accessibilityChannel,
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
case MimeTypes.APPLICATION_CEA708:
return new Cea708Decoder(format.accessibilityChannel, format.initializationData);
default:
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java
index b2a271956b..dd4d39be7c 100644
--- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/DefaultSubtitleParserFactory.java
@@ -20,6 +20,7 @@ import androidx.media3.common.Format;
import androidx.media3.common.Format.CueReplacementBehavior;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
+import androidx.media3.extractor.text.cea.Cea608Parser;
import androidx.media3.extractor.text.dvb.DvbParser;
import androidx.media3.extractor.text.pgs.PgsParser;
import androidx.media3.extractor.text.ssa.SsaParser;
@@ -44,6 +45,7 @@ import java.util.Objects;
*
PGS ({@link PgsParser})
* DVB ({@link DvbParser})
* TTML ({@link TtmlParser})
+ * CEA-608 ({@link Cea608Parser})
*
*/
@UnstableApi
@@ -59,7 +61,9 @@ public final class DefaultSubtitleParserFactory implements SubtitleParser.Factor
|| Objects.equals(mimeType, MimeTypes.APPLICATION_TX3G)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_PGS)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_DVBSUBS)
- || Objects.equals(mimeType, MimeTypes.APPLICATION_TTML);
+ || Objects.equals(mimeType, MimeTypes.APPLICATION_TTML)
+ || Objects.equals(mimeType, MimeTypes.APPLICATION_MP4CEA608)
+ || Objects.equals(mimeType, MimeTypes.APPLICATION_CEA608);
}
@Override
@@ -83,6 +87,9 @@ public final class DefaultSubtitleParserFactory implements SubtitleParser.Factor
return DvbParser.CUE_REPLACEMENT_BEHAVIOR;
case MimeTypes.APPLICATION_TTML:
return TtmlParser.CUE_REPLACEMENT_BEHAVIOR;
+ case MimeTypes.APPLICATION_MP4CEA608:
+ case MimeTypes.APPLICATION_CEA608:
+ return Cea608Parser.CUE_REPLACEMENT_BEHAVIOR;
default:
break;
}
@@ -111,6 +118,15 @@ public final class DefaultSubtitleParserFactory implements SubtitleParser.Factor
return new DvbParser(format.initializationData);
case MimeTypes.APPLICATION_TTML:
return new TtmlParser();
+ case MimeTypes.APPLICATION_MP4CEA608:
+ case MimeTypes.APPLICATION_CEA608:
+ // Deliberately pass a timeout longer than Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS
+ // because Cea608Parser erases 'stuck' cues starting from their start time, rather than
+ // the last piece of CEA data received.
+ return new Cea608Parser(
+ mimeType,
+ format.accessibilityChannel,
+ /* validDataChannelTimeoutMs= */ 2 * Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
default:
break;
}
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Decoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Decoder.java
index c8b10201c4..0885c949e2 100644
--- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Decoder.java
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Decoder.java
@@ -15,385 +15,44 @@
*/
package androidx.media3.extractor.text.cea;
-import static java.lang.Math.min;
+import static androidx.media3.common.util.Assertions.checkNotNull;
-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 androidx.media3.common.C;
import androidx.media3.common.Format;
-import androidx.media3.common.MimeTypes;
-import androidx.media3.common.text.Cue;
-import androidx.media3.common.util.Assertions;
-import androidx.media3.common.util.Log;
-import androidx.media3.common.util.NullableType;
-import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
+import androidx.media3.extractor.text.CuesWithTiming;
+import androidx.media3.extractor.text.CuesWithTimingSubtitle;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleDecoderException;
import androidx.media3.extractor.text.SubtitleInputBuffer;
import androidx.media3.extractor.text.SubtitleOutputBuffer;
+import androidx.media3.extractor.text.SubtitleParser.OutputOptions;
+import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
/** A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). */
@UnstableApi
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 CuesWithTiming EMPTY_CUES =
+ new CuesWithTiming(
+ ImmutableList.of(), /* startTimeUs= */ C.TIME_UNSET, /* durationUs= */ C.TIME_UNSET);
- 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 cueBuilders;
-
- private CueBuilder currentCueBuilder;
- @Nullable private List cues;
- @Nullable private List 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 final Cea608Parser cea608Parser;
+ @Nullable private CuesWithTiming cues;
+ private boolean isNewSubtitleDataAvailable;
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.
+ * @param parser A {@link Cea608Parser} to parse the subtitle data.
*/
- 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;
+ public Cea608Decoder(Cea608Parser parser) {
+ this.cea608Parser = parser;
lastCueUpdateUs = C.TIME_UNSET;
}
@@ -405,18 +64,9 @@ public final class Cea608Decoder extends CeaDecoder {
@Override
public void flush() {
super.flush();
+ isNewSubtitleDataAvailable = false;
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;
+ cea608Parser.reset();
}
@Override
@@ -434,7 +84,7 @@ public final class Cea608Decoder extends CeaDecoder {
if (shouldClearStuckCaptions()) {
outputBuffer = getAvailableOutputBuffer();
if (outputBuffer != null) {
- cues = Collections.emptyList();
+ cues = EMPTY_CUES;
lastCueUpdateUs = C.TIME_UNSET;
Subtitle subtitle = createSubtitle();
outputBuffer.setContent(getPositionUs(), subtitle, Format.OFFSET_SAMPLE_RELATIVE);
@@ -446,712 +96,41 @@ public final class Cea608Decoder extends CeaDecoder {
@Override
protected boolean isNewSubtitleDataAvailable() {
- return cues != lastCues;
+ return isNewSubtitleDataAvailable;
}
@Override
protected Subtitle createSubtitle() {
- lastCues = cues;
- return new CeaSubtitle(Assertions.checkNotNull(cues));
+ isNewSubtitleDataAvailable = false;
+ return new CuesWithTimingSubtitle(ImmutableList.of(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) {
- int ccHeader = packetLength == 2 ? CC_IMPLICIT_DATA_HEADER : 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 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 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|F
- return (cc1 & 0xF6) == 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 cueStyles;
- private final List 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.
- 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) {
- SpannableStringBuilder cueString = new SpannableStringBuilder();
- // Add any rolled up captions, separated by new lines.
- for (int i = 0; i < rolledUpCaptions.size(); i++) {
- cueString.append(rolledUpCaptions.get(i));
- cueString.append('\n');
- }
- // Add the current line.
- cueString.append(buildCurrentLine());
-
- if (cueString.length() == 0) {
- // The cue is empty.
- return null;
- }
-
- int positionAnchor;
- // The number of empty columns before the start of the text, in the range [0-31].
- int startPadding = indent + tabOffset;
- // 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;
- }
- }
+ ByteBuffer subtitleData = checkNotNull(inputBuffer.data);
+
+ cea608Parser.parse(
+ subtitleData.array(),
+ /* offset= */ subtitleData.arrayOffset(),
+ /* length= */ subtitleData.limit(),
+ OutputOptions.allCues(),
+ /* output= */ cues -> {
+ isNewSubtitleDataAvailable = true;
+ // Remove the 'stuck captions' duration - in this class the clearing of stuck captions is
+ // implemented by shouldClearStuckCaptions() below.
+ this.cues =
+ new CuesWithTiming(
+ cues.cues, /* startTimeUs= */ C.TIME_UNSET, /* durationUs= */ C.TIME_UNSET);
+ });
}
/** 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) {
+ if (cea608Parser.validDataChannelTimeoutUs == C.TIME_UNSET || lastCueUpdateUs == C.TIME_UNSET) {
return false;
}
long elapsedUs = getPositionUs() - lastCueUpdateUs;
- return elapsedUs >= validDataChannelTimeoutUs;
+ return elapsedUs >= cea608Parser.validDataChannelTimeoutUs;
}
}
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Parser.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Parser.java
new file mode 100644
index 0000000000..c990627a2f
--- /dev/null
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/cea/Cea608Parser.java
@@ -0,0 +1,1134 @@
+/*
+ * 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 androidx.media3.extractor.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 androidx.media3.common.C;
+import androidx.media3.common.Format;
+import androidx.media3.common.Format.CueReplacementBehavior;
+import androidx.media3.common.MimeTypes;
+import androidx.media3.common.text.Cue;
+import androidx.media3.common.util.Assertions;
+import androidx.media3.common.util.Consumer;
+import androidx.media3.common.util.Log;
+import androidx.media3.common.util.NullableType;
+import androidx.media3.common.util.ParsableByteArray;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.extractor.text.CuesWithTiming;
+import androidx.media3.extractor.text.SubtitleParser;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** A {@link SubtitleParser} for CEA-608 (also known as "line 21 captions" and "EIA-608"). */
+@UnstableApi
+public final class Cea608Parser implements SubtitleParser {
+
+ /**
+ * The {@link CueReplacementBehavior} for consecutive {@link CuesWithTiming} emitted by this
+ * implementation.
+ */
+ public static final @CueReplacementBehavior int CUE_REPLACEMENT_BEHAVIOR =
+ Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE;
+
+ /**
+ * 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 = "Cea608Parser";
+
+ 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;
+ // TODO: b/289983417 - Make this private when Cea608Decoder is deleted.
+ /* package */ final long validDataChannelTimeoutUs;
+ private final ArrayList cueBuilders;
+
+ private CueBuilder currentCueBuilder;
+ @Nullable private List cues;
+ @Nullable private List 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;
+
+ /**
+ * 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. This applies an upper-bound on the duration of a single caption.
+ */
+ public Cea608Parser(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;
+ }
+
+ @Override
+ public void reset() {
+ 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;
+ }
+
+ @Override
+ public @CueReplacementBehavior int getCueReplacementBehavior() {
+ return CUE_REPLACEMENT_BEHAVIOR;
+ }
+
+ @Override
+ public void parse(
+ byte[] data,
+ int offset,
+ int length,
+ OutputOptions outputOptions,
+ Consumer output) {
+ ccData.reset(data, length);
+ ccData.setPosition(offset);
+ boolean captionDataProcessed = false;
+ while (ccData.bytesLeft() >= packetLength) {
+ int ccHeader = packetLength == 2 ? CC_IMPLICIT_DATA_HEADER : 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();
+ }
+ }
+ if (cues != lastCues) {
+ lastCues = cues;
+ // Passing validDataChannelTimeoutUs as the duration, combined with returning
+ // Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE from getCueReplacementBehavior, means that each
+ // cue will be shown either until the next cue is shown, or until validDataChannelTimeoutUs is
+ // exceeded, whichever is sooner. This implements the 'automatic caption erasure' described in
+ // ANSI/CTA-608-E-R-2014 Annex C.9. This implementation technically starts the 'stuck timer'
+ // at the wrong time (when a cue is emitted, it should be when the last piece of CEA data
+ // arrived). This could result in captions being prematurely judged 'stuck' and hidden,
+ // however it is safe because the spec says:
+ // > the time limit should be no less than 16 seconds, an amount of time said by caption
+ // > service providers to be longer than their most enduring caption.
+ output.accept(
+ new CuesWithTiming(
+ Assertions.checkNotNull(cues),
+ /* startTimeUs= */ C.TIME_UNSET,
+ /* durationUs= */ validDataChannelTimeoutUs));
+ }
+ }
+
+ 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 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 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|F
+ return (cc1 & 0xF6) == 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 cueStyles;
+ private final List 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.
+ 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) {
+ SpannableStringBuilder cueString = new SpannableStringBuilder();
+ // Add any rolled up captions, separated by new lines.
+ for (int i = 0; i < rolledUpCaptions.size(); i++) {
+ cueString.append(rolledUpCaptions.get(i));
+ cueString.append('\n');
+ }
+ // Add the current line.
+ cueString.append(buildCurrentLine());
+
+ if (cueString.length() == 0) {
+ // The cue is empty.
+ return null;
+ }
+
+ int positionAnchor;
+ // The number of empty columns before the start of the text, in the range [0-31].
+ int startPadding = indent + tabOffset;
+ // 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;
+ }
+ }
+ }
+}
diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608DecoderTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608DecoderTest.java
index c1ea977886..7bb950d7a4 100644
--- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608DecoderTest.java
+++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608DecoderTest.java
@@ -45,9 +45,10 @@ public class Cea608DecoderTest {
public void paintOnEmitsSubtitlesImmediately() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
- MimeTypes.APPLICATION_CEA608,
- /* accessibilityChannel= */ 1,
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1,
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
byte[] sample1 =
Bytes.concat(
// 'paint on' control character
@@ -82,9 +83,10 @@ public class Cea608DecoderTest {
public void rollUpEmitsSubtitlesImmediately() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
- MimeTypes.APPLICATION_CEA608,
- /* accessibilityChannel= */ 1, // field 1, channel 1
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1, // field 1, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
byte[] sample1 =
Bytes.concat(
// 'roll up 2 rows' control character
@@ -133,9 +135,10 @@ public class Cea608DecoderTest {
public void onlySelectedFieldIsUsed() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
- MimeTypes.APPLICATION_CEA608,
- /* accessibilityChannel= */ 1, // field 1, channel 1
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1, // field 1, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header): 'test subtitle'
// field 2 (0xFD header): 'wrong field!'
byte[] sample1 =
@@ -171,9 +174,10 @@ public class Cea608DecoderTest {
public void onlySelectedChannelIsUsed() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
- MimeTypes.APPLICATION_CEA608,
- /* accessibilityChannel= */ 2, // field 1, channel 2
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 2, // field 1, channel 2
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header), channel 1: 'wrong channel'
// field 1 (0xFC header), channel 2: 'test subtitle'
// field 2 (0xFD header), channel 1: 'wrong field!'
@@ -259,9 +263,10 @@ public class Cea608DecoderTest {
public void serviceSwitchOnField1Handled() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
- MimeTypes.APPLICATION_CEA608,
- /* accessibilityChannel= */ 1, // field 1, channel 1
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1, // field 1, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header): 'test' then service switch
// field 2 (0xFD header): 'wrong!'
byte[] sample1 =
@@ -289,9 +294,10 @@ public class Cea608DecoderTest {
public void serviceSwitchOnField2Handled() throws Exception {
Cea608Decoder decoder =
new Cea608Decoder(
- MimeTypes.APPLICATION_CEA608,
- /* accessibilityChannel= */ 3, // field 2, channel 1
- Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 3, // field 2, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS));
// field 1 (0xFC header): 'wrong!'
// field 2 (0xFD header): 'test' then service switch
byte[] sample1 =
diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608ParserTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608ParserTest.java
new file mode 100644
index 0000000000..21d3ccde9b
--- /dev/null
+++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/cea/Cea608ParserTest.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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 androidx.media3.extractor.text.cea;
+
+import static androidx.media3.common.util.Assertions.checkArgument;
+import static androidx.media3.common.util.Assertions.checkNotNull;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.annotation.Nullable;
+import androidx.media3.common.MimeTypes;
+import androidx.media3.extractor.text.CuesWithTiming;
+import androidx.media3.extractor.text.SubtitleDecoderException;
+import androidx.media3.extractor.text.SubtitleParser.OutputOptions;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Bytes;
+import com.google.common.primitives.UnsignedBytes;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link Cea608Parser}. */
+@RunWith(AndroidJUnit4.class)
+public class Cea608ParserTest {
+
+ @Test
+ public void paintOnEmitsSubtitlesImmediately() throws Exception {
+ Cea608Parser cea608Parser =
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1,
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ byte[] sample1 =
+ Bytes.concat(
+ // 'paint on' control character
+ createPacket(0xFC, 0x14, 0x29),
+ createPacket(0xFC, 't', 'e'),
+ createPacket(0xFC, 's', 't'),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFC, 'u', 'b'),
+ createPacket(0xFC, 't', 'i'),
+ createPacket(0xFC, 't', 'l'),
+ createPacket(0xFC, 'e', ','),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFC, 'p', 'a'));
+ byte[] sample2 =
+ Bytes.concat(
+ createPacket(0xFC, 'n', 's'),
+ createPacket(0xFC, ' ', '2'),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFC, 'a', 'm'),
+ createPacket(0xFC, 'p', 'l'),
+ createPacket(0xFC, 'e', 's'));
+
+ CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
+ CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
+
+ assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString())
+ .isEqualTo("test subtitle, spa");
+ assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString())
+ .isEqualTo("test subtitle, spans 2 samples");
+ }
+
+ @Test
+ public void rollUpEmitsSubtitlesImmediately() throws Exception {
+ Cea608Parser cea608Parser =
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1, // field 1, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ byte[] sample1 =
+ Bytes.concat(
+ // 'roll up 2 rows' control character
+ createPacket(0xFC, 0x14, 0x25),
+ createPacket(0xFC, 't', 'e'),
+ createPacket(0xFC, 's', 't'),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFC, 'u', 'b'),
+ createPacket(0xFC, 't', 'i'),
+ createPacket(0xFC, 't', 'l'),
+ createPacket(0xFC, 'e', ','),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFC, 'p', 'a'));
+ byte[] sample2 =
+ Bytes.concat(
+ createPacket(0xFC, 'n', 's'),
+ createPacket(0xFC, ' ', '3'),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFC, 'a', 'm'),
+ createPacket(0xFC, 'p', 'l'),
+ createPacket(0xFC, 'e', 's'),
+ // Carriage return control character
+ createPacket(0xFC, 0x14, 0x2D),
+ createPacket(0xFC, 'w', 'i'),
+ createPacket(0xFC, 't', 'h'),
+ createPacket(0xFC, ' ', 'n'));
+ byte[] sample3 =
+ Bytes.concat(
+ createPacket(0xFC, 'e', 'w'),
+ createPacket(0xFC, 'l', 'i'),
+ createPacket(0xFC, 'n', 'e'),
+ createPacket(0xFC, 's', 0x0));
+
+ CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
+ CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
+ CuesWithTiming thirdCues = checkNotNull(parseSample(cea608Parser, sample3));
+
+ assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString())
+ .isEqualTo("test subtitle, spa");
+ assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString())
+ .isEqualTo("test subtitle, spans 3 samples\nwith n");
+ assertThat(Iterables.getOnlyElement(thirdCues.cues).text.toString())
+ .isEqualTo("test subtitle, spans 3 samples\nwith newlines");
+ }
+
+ @Test
+ public void onlySelectedFieldIsUsed() throws Exception {
+ Cea608Parser cea608Parser =
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1, // field 1, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ // field 1 (0xFC header): 'test subtitle'
+ // field 2 (0xFD header): 'wrong field!'
+ byte[] sample1 =
+ Bytes.concat(
+ // 'paint on' control character
+ createPacket(0xFC, 0x14, 0x29),
+ createPacket(0xFD, 0x15, 0x29),
+ createPacket(0xFC, 't', 'e'),
+ createPacket(0xFD, 'w', 'r'),
+ createPacket(0xFC, 's', 't'),
+ createPacket(0xFD, 'o', 'n'),
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFD, 'g', ' '),
+ createPacket(0xFC, 'u', 'b'),
+ createPacket(0xFD, 'f', 'i'));
+ byte[] sample2 =
+ Bytes.concat(
+ createPacket(0xFC, 't', 'i'),
+ createPacket(0xFD, 'e', 'l'),
+ createPacket(0xFC, 't', 'l'),
+ createPacket(0xFD, 'd', '!'),
+ createPacket(0xFC, 'e', 0x0),
+ createPacket(0xFD, 0x0, 0x0));
+
+ CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
+ CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
+
+ assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("test sub");
+ assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString())
+ .isEqualTo("test subtitle");
+ }
+
+ @Test
+ public void onlySelectedChannelIsUsed() throws Exception {
+ Cea608Parser cea608Parser =
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 2, // field 1, channel 2
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ // field 1 (0xFC header), channel 1: 'wrong channel'
+ // field 1 (0xFC header), channel 2: 'test subtitle'
+ // field 2 (0xFD header), channel 1: 'wrong field!'
+ byte[] sample1 =
+ Bytes.concat(
+ // 'paint on' control character
+ createPacket(0xFC, 0x14, 0x29),
+ createPacket(0xFD, 0x15, 0x29),
+ createPacket(0xFC, 'w', 'r'),
+ createPacket(0xFD, 'w', 'r'),
+ createPacket(0xFC, 'o', 'n'),
+ createPacket(0xFD, 'o', 'n'),
+ // Switch to channel 2 & 'paint on' control character
+ createPacket(0xFC, 0x14 | 0x08, 0x29),
+ createPacket(0xFD, 'g', ' '),
+ createPacket(0xFC, 't', 'e'),
+ createPacket(0xFD, 'f', 'i'));
+ byte[] sample2 =
+ Bytes.concat(
+ createPacket(0xFC, 's', 't'),
+ createPacket(0xFD, 'e', 'l'),
+ // Switch to channel 1
+ createPacket(0xFC, 0x14, 0x0),
+ createPacket(0xFD, 'd', '!'),
+ createPacket(0xFC, 'g', ' '),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 'c', 'h'),
+ createPacket(0xFD, 0x0, 0x0),
+ // Switch to channel 2
+ createPacket(0xFC, 0x14 | 0x08, 0x0),
+ createPacket(0xFD, 0x0, 0x0));
+ byte[] sample3 =
+ Bytes.concat(
+ createPacket(0xFC, ' ', 's'),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 'u', 'b'),
+ createPacket(0xFD, 0x0, 0x0),
+ // Switch to channel 1
+ createPacket(0xFC, 0x14, 0x0),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 'a', 'n'),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 'n', 'e'),
+ createPacket(0xFD, 0x0, 0x0));
+ byte[] sample4 =
+ Bytes.concat(
+ // Switch to channel 2
+ createPacket(0xFC, 0x14 | 0x08, 0x0),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 't', 'i'),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 't', 'l'),
+ createPacket(0xFD, 0x0, 0x0),
+ // Switch to channel 1
+ createPacket(0xFC, 0x14, 0x0),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 'l', 0x0),
+ createPacket(0xFD, 0x0, 0x0));
+ byte[] sample5 =
+ Bytes.concat(
+ createPacket(0xFC, 0x0, 0x0),
+ createPacket(0xFD, 0x0, 0x0),
+ // Switch to channel 2
+ createPacket(0xFC, 0x14 | 0x08, 0x0),
+ createPacket(0xFD, 0x0, 0x0),
+ createPacket(0xFC, 'e', 0x0),
+ createPacket(0xFD, 0x0, 0x0));
+
+ CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
+ CuesWithTiming secondCues = checkNotNull(parseSample(cea608Parser, sample2));
+ CuesWithTiming thirdCues = checkNotNull(parseSample(cea608Parser, sample3));
+ CuesWithTiming fourthCues = checkNotNull(parseSample(cea608Parser, sample4));
+ CuesWithTiming fifthCues = checkNotNull(parseSample(cea608Parser, sample5));
+
+ assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("te");
+ assertThat(Iterables.getOnlyElement(secondCues.cues).text.toString()).isEqualTo("test");
+ assertThat(Iterables.getOnlyElement(thirdCues.cues).text.toString()).isEqualTo("test sub");
+ assertThat(Iterables.getOnlyElement(fourthCues.cues).text.toString()).isEqualTo("test subtitl");
+ assertThat(Iterables.getOnlyElement(fifthCues.cues).text.toString()).isEqualTo("test subtitle");
+ }
+
+ @Test
+ public void serviceSwitchOnField1Handled() throws Exception {
+ Cea608Parser cea608Parser =
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 1, // field 1, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ // field 1 (0xFC header): 'test' then service switch
+ // field 2 (0xFD header): 'wrong!'
+ byte[] sample1 =
+ Bytes.concat(
+ // 'paint on' control character
+ createPacket(0xFC, 0x14, 0x29),
+ createPacket(0xFD, 0x15, 0x29),
+ createPacket(0xFC, 't', 'e'),
+ createPacket(0xFD, 'w', 'r'),
+ createPacket(0xFC, 's', 't'),
+ createPacket(0xFD, 'o', 'n'),
+ // Enter TEXT service
+ createPacket(0xFC, 0x14, 0x2A),
+ createPacket(0xFD, 'g', '!'),
+ createPacket(0xFC, 'X', 'X'),
+ createPacket(0xFD, 0x0, 0x0));
+
+ CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
+
+ assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("test");
+ }
+
+ // https://github.com/google/ExoPlayer/issues/10666
+ @Test
+ public void serviceSwitchOnField2Handled() throws Exception {
+ Cea608Parser cea608Parser =
+ new Cea608Parser(
+ MimeTypes.APPLICATION_CEA608,
+ /* accessibilityChannel= */ 3, // field 2, channel 1
+ Cea608Parser.MIN_DATA_CHANNEL_TIMEOUT_MS);
+ // field 1 (0xFC header): 'wrong!'
+ // field 2 (0xFD header): 'test' then service switch
+ byte[] sample1 =
+ Bytes.concat(
+ // 'paint on' control character
+ createPacket(0xFC, 0x14, 0x29),
+ createPacket(0xFD, 0x15, 0x29),
+ createPacket(0xFC, 'w', 'r'),
+ createPacket(0xFD, 't', 'e'),
+ createPacket(0xFC, 'o', 'n'),
+ createPacket(0xFD, 's', 't'),
+ createPacket(0xFC, 'g', '!'),
+ // Enter TEXT service
+ createPacket(0xFD, 0x15, 0x2A),
+ createPacket(0xFC, 0x0, 0x0),
+ createPacket(0xFD, 'X', 'X'));
+
+ CuesWithTiming firstCues = checkNotNull(parseSample(cea608Parser, sample1));
+
+ assertThat(Iterables.getOnlyElement(firstCues.cues).text.toString()).isEqualTo("test");
+ }
+
+ private static byte[] createPacket(int header, int cc1, int cc2) {
+ return new byte[] {
+ UnsignedBytes.checkedCast(header),
+ ensureUnsignedByteOddParity(cc1),
+ ensureUnsignedByteOddParity(cc2)
+ };
+ }
+
+ private static byte ensureUnsignedByteOddParity(int input) {
+ checkArgument(input >= 0);
+ checkArgument(input < 128);
+
+ return UnsignedBytes.checkedCast(Integer.bitCount(input) % 2 == 0 ? input | 0x80 : input);
+ }
+
+ /**
+ * Passes {@code sample} to {@link Cea608Parser#parse} and returns either the emitted {@link
+ * CuesWithTiming} or null if none was emitted.
+ */
+ @Nullable
+ private static CuesWithTiming parseSample(Cea608Parser parser, byte[] sample)
+ throws SubtitleDecoderException {
+ List result = new ArrayList<>();
+ parser.parse(sample, OutputOptions.allCues(), result::add);
+ return result.isEmpty() ? null : Iterables.getOnlyElement(result);
+ }
+}