public final class

Cea708Decoder

extends androidx.media3.extractor.text.cea.CeaDecoder

 java.lang.Object

↳androidx.media3.extractor.text.cea.CeaDecoder

↳androidx.media3.extractor.text.cea.Cea708Decoder

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-extractor', version: '1.5.0-alpha01'

  • groupId: androidx.media3
  • artifactId: media3-extractor
  • version: 1.5.0-alpha01

Artifact androidx.media3:media3-extractor:1.5.0-alpha01 it located at Google repository (https://maven.google.com/)

Overview

A SubtitleDecoder for CEA-708 (also known as "EIA-708").

Summary

Constructors
publicCea708Decoder(int accessibilityChannel, java.util.List<UnknownReference> initializationData)

Constructs an instance.

Methods
protected abstract SubtitlecreateSubtitle()

Creates a Subtitle from the available data.

protected abstract voiddecode(SubtitleInputBuffer inputBuffer)

Filters and processes the raw data, providing Subtitles via createSubtitle when sufficient data has been processed.

public voidflush()

public java.lang.StringgetName()

protected abstract booleanisNewSubtitleDataAvailable()

Returns whether there is data available to create a new Subtitle.

from androidx.media3.extractor.text.cea.CeaDecoderdequeueInputBuffer, dequeueOutputBuffer, getAvailableOutputBuffer, getPositionUs, queueInputBuffer, release, releaseOutputBuffer, setOutputStartTimeUs, setPositionUs
from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Constructors

public Cea708Decoder(int accessibilityChannel, java.util.List<UnknownReference> initializationData)

Constructs an instance.

Parameters:

accessibilityChannel: The accessibility channel, or Format.NO_VALUE if unknown.
initializationData: Optional initialization data for the decoder. If present, it must conform to the structure created by CodecSpecificDataUtil.buildCea708InitializationData(boolean).

Methods

public java.lang.String getName()

public void flush()

protected abstract boolean isNewSubtitleDataAvailable()

Returns whether there is data available to create a new Subtitle.

protected abstract Subtitle createSubtitle()

Creates a Subtitle from the available data.

protected abstract void decode(SubtitleInputBuffer inputBuffer)

Filters and processes the raw data, providing Subtitles via createSubtitle when sufficient data has been processed.

Source

/*
 * 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 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.BackgroundColorSpan;
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.text.Cue;
import androidx.media3.common.text.Cue.AnchorType;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.CodecSpecificDataUtil;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.ParsableBitArray;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleInputBuffer;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/** A {@link SubtitleDecoder} for CEA-708 (also known as "EIA-708"). */
@UnstableApi
public final class Cea708Decoder extends CeaDecoder {

  private static final String TAG = "Cea708Decoder";

  private static final int NUM_WINDOWS = 8;

  private static final int DTVCC_PACKET_DATA = 0x02;
  private static final int DTVCC_PACKET_START = 0x03;
  private static final int CC_VALID_FLAG = 0x04;

  // Base Commands
  private static final int GROUP_C0_END = 0x1F; // Miscellaneous Control Codes
  private static final int GROUP_G0_END = 0x7F; // ASCII Printable Characters
  private static final int GROUP_C1_END = 0x9F; // Captioning Command Control Codes
  private static final int GROUP_G1_END = 0xFF; // ISO 8859-1 LATIN-1 Character Set

  // Extended Commands
  private static final int GROUP_C2_END = 0x1F; // Extended Control Code Set 1
  private static final int GROUP_G2_END = 0x7F; // Extended Miscellaneous Characters
  private static final int GROUP_C3_END = 0x9F; // Extended Control Code Set 2
  private static final int GROUP_G3_END = 0xFF; // Future Expansion

  // Group C0 Commands
  private static final int COMMAND_NUL = 0x00; // Nul
  private static final int COMMAND_ETX = 0x03; // EndOfText
  private static final int COMMAND_BS = 0x08; // Backspace
  private static final int COMMAND_FF = 0x0C; // FormFeed (Flush)
  private static final int COMMAND_CR = 0x0D; // CarriageReturn
  private static final int COMMAND_HCR = 0x0E; // ClearLine
  private static final int COMMAND_EXT1 = 0x10; // Extended Control Code Flag
  private static final int COMMAND_EXT1_START = 0x11;
  private static final int COMMAND_EXT1_END = 0x17;
  private static final int COMMAND_P16_START = 0x18;
  private static final int COMMAND_P16_END = 0x1F;

  // Group C1 Commands
  private static final int COMMAND_CW0 = 0x80; // SetCurrentWindow to 0
  private static final int COMMAND_CW1 = 0x81; // SetCurrentWindow to 1
  private static final int COMMAND_CW2 = 0x82; // SetCurrentWindow to 2
  private static final int COMMAND_CW3 = 0x83; // SetCurrentWindow to 3
  private static final int COMMAND_CW4 = 0x84; // SetCurrentWindow to 4
  private static final int COMMAND_CW5 = 0x85; // SetCurrentWindow to 5
  private static final int COMMAND_CW6 = 0x86; // SetCurrentWindow to 6
  private static final int COMMAND_CW7 = 0x87; // SetCurrentWindow to 7
  private static final int COMMAND_CLW = 0x88; // ClearWindows (+1 byte)
  private static final int COMMAND_DSW = 0x89; // DisplayWindows (+1 byte)
  private static final int COMMAND_HDW = 0x8A; // HideWindows (+1 byte)
  private static final int COMMAND_TGW = 0x8B; // ToggleWindows (+1 byte)
  private static final int COMMAND_DLW = 0x8C; // DeleteWindows (+1 byte)
  private static final int COMMAND_DLY = 0x8D; // Delay (+1 byte)
  private static final int COMMAND_DLC = 0x8E; // DelayCancel
  private static final int COMMAND_RST = 0x8F; // Reset
  private static final int COMMAND_SPA = 0x90; // SetPenAttributes (+2 bytes)
  private static final int COMMAND_SPC = 0x91; // SetPenColor (+3 bytes)
  private static final int COMMAND_SPL = 0x92; // SetPenLocation (+2 bytes)
  private static final int COMMAND_SWA = 0x97; // SetWindowAttributes (+4 bytes)
  private static final int COMMAND_DF0 = 0x98; // DefineWindow 0 (+6 bytes)
  private static final int COMMAND_DF1 = 0x99; // DefineWindow 1 (+6 bytes)
  private static final int COMMAND_DF2 = 0x9A; // DefineWindow 2 (+6 bytes)
  private static final int COMMAND_DF3 = 0x9B; // DefineWindow 3 (+6 bytes)
  private static final int COMMAND_DF4 = 0x9C; // DefineWindow 4 (+6 bytes)
  private static final int COMMAND_DF5 = 0x9D; // DefineWindow 5 (+6 bytes)
  private static final int COMMAND_DF6 = 0x9E; // DefineWindow 6 (+6 bytes)
  private static final int COMMAND_DF7 = 0x9F; // DefineWindow 7 (+6 bytes)

  // G0 Table Special Chars
  private static final int CHARACTER_MN = 0x7F; // MusicNote

  // G2 Table Special Chars
  private static final int CHARACTER_TSP = 0x20;
  private static final int CHARACTER_NBTSP = 0x21;
  private static final int CHARACTER_ELLIPSIS = 0x25;
  private static final int CHARACTER_BIG_CARONS = 0x2A;
  private static final int CHARACTER_BIG_OE = 0x2C;
  private static final int CHARACTER_SOLID_BLOCK = 0x30;
  private static final int CHARACTER_OPEN_SINGLE_QUOTE = 0x31;
  private static final int CHARACTER_CLOSE_SINGLE_QUOTE = 0x32;
  private static final int CHARACTER_OPEN_DOUBLE_QUOTE = 0x33;
  private static final int CHARACTER_CLOSE_DOUBLE_QUOTE = 0x34;
  private static final int CHARACTER_BOLD_BULLET = 0x35;
  private static final int CHARACTER_TM = 0x39;
  private static final int CHARACTER_SMALL_CARONS = 0x3A;
  private static final int CHARACTER_SMALL_OE = 0x3C;
  private static final int CHARACTER_SM = 0x3D;
  private static final int CHARACTER_DIAERESIS_Y = 0x3F;
  private static final int CHARACTER_ONE_EIGHTH = 0x76;
  private static final int CHARACTER_THREE_EIGHTHS = 0x77;
  private static final int CHARACTER_FIVE_EIGHTHS = 0x78;
  private static final int CHARACTER_SEVEN_EIGHTHS = 0x79;
  private static final int CHARACTER_VERTICAL_BORDER = 0x7A;
  private static final int CHARACTER_UPPER_RIGHT_BORDER = 0x7B;
  private static final int CHARACTER_LOWER_LEFT_BORDER = 0x7C;
  private static final int CHARACTER_HORIZONTAL_BORDER = 0x7D;
  private static final int CHARACTER_LOWER_RIGHT_BORDER = 0x7E;
  private static final int CHARACTER_UPPER_LEFT_BORDER = 0x7F;

  private final ParsableByteArray ccData;
  private final ParsableBitArray captionChannelPacketData;
  private int previousSequenceNumber;

  // TODO: Use isWideAspectRatio in decoding.
  @SuppressWarnings({"unused", "FieldCanBeLocal"})
  private final boolean isWideAspectRatio;

  private final int selectedServiceNumber;
  private final CueInfoBuilder[] cueInfoBuilders;

  private CueInfoBuilder currentCueInfoBuilder;
  @Nullable private List<Cue> cues;
  @Nullable private List<Cue> lastCues;

  @Nullable private DtvCcPacket currentDtvCcPacket;
  private int currentWindow;

  /**
   * Constructs an instance.
   *
   * @param accessibilityChannel The accessibility channel, or {@link Format#NO_VALUE} if unknown.
   * @param initializationData Optional initialization data for the decoder. If present, it must
   *     conform to the structure created by {@link
   *     CodecSpecificDataUtil#buildCea708InitializationData}.
   */
  public Cea708Decoder(int accessibilityChannel, @Nullable List<byte[]> initializationData) {
    ccData = new ParsableByteArray();
    captionChannelPacketData = new ParsableBitArray();
    previousSequenceNumber = C.INDEX_UNSET;
    selectedServiceNumber = accessibilityChannel == Format.NO_VALUE ? 1 : accessibilityChannel;
    isWideAspectRatio =
        initializationData != null
            && CodecSpecificDataUtil.parseCea708InitializationData(initializationData);

    cueInfoBuilders = new CueInfoBuilder[NUM_WINDOWS];
    for (int i = 0; i < NUM_WINDOWS; i++) {
      cueInfoBuilders[i] = new CueInfoBuilder();
    }

    currentCueInfoBuilder = cueInfoBuilders[0];
  }

  @Override
  public String getName() {
    return "Cea708Decoder";
  }

  @Override
  public void flush() {
    super.flush();
    cues = null;
    lastCues = null;
    currentWindow = 0;
    currentCueInfoBuilder = cueInfoBuilders[currentWindow];
    resetCueBuilders();
    currentDtvCcPacket = null;
  }

  @Override
  protected boolean isNewSubtitleDataAvailable() {
    return cues != lastCues;
  }

  @Override
  protected Subtitle createSubtitle() {
    lastCues = cues;
    return new CeaSubtitle(Assertions.checkNotNull(cues));
  }

  @Override
  protected void decode(SubtitleInputBuffer inputBuffer) {
    // Subtitle input buffers are non-direct and the position is zero, so calling array() is safe.
    ByteBuffer subtitleData = Assertions.checkNotNull(inputBuffer.data);
    @SuppressWarnings("ByteBufferBackingArray")
    byte[] inputBufferData = subtitleData.array();
    ccData.reset(inputBufferData, subtitleData.limit());
    while (ccData.bytesLeft() >= 3) {
      int ccTypeAndValid = (ccData.readUnsignedByte() & 0x07);

      int ccType = ccTypeAndValid & (DTVCC_PACKET_DATA | DTVCC_PACKET_START);
      boolean ccValid = (ccTypeAndValid & CC_VALID_FLAG) == CC_VALID_FLAG;
      byte ccData1 = (byte) ccData.readUnsignedByte();
      byte ccData2 = (byte) ccData.readUnsignedByte();

      // Ignore any non-CEA-708 data
      if (ccType != DTVCC_PACKET_DATA && ccType != DTVCC_PACKET_START) {
        continue;
      }

      if (!ccValid) {
        // This byte-pair isn't valid, ignore it and continue.
        continue;
      }

      if (ccType == DTVCC_PACKET_START) {
        finalizeCurrentPacket();

        int sequenceNumber = (ccData1 & 0xC0) >> 6; // first 2 bits
        if (previousSequenceNumber != C.INDEX_UNSET
            && sequenceNumber != (previousSequenceNumber + 1) % 4) {
          resetCueBuilders();
          Log.w(
              TAG,
              "Sequence number discontinuity. previous="
                  + previousSequenceNumber
                  + " current="
                  + sequenceNumber);
        }
        previousSequenceNumber = sequenceNumber;

        int packetSize = ccData1 & 0x3F; // last 6 bits
        if (packetSize == 0) {
          packetSize = 64;
        }

        currentDtvCcPacket = new DtvCcPacket(sequenceNumber, packetSize);
        currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2;
      } else {
        // The only remaining valid packet type is DTVCC_PACKET_DATA
        Assertions.checkArgument(ccType == DTVCC_PACKET_DATA);

        if (currentDtvCcPacket == null) {
          Log.e(TAG, "Encountered DTVCC_PACKET_DATA before DTVCC_PACKET_START");
          continue;
        }

        currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData1;
        currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2;
      }

      if (currentDtvCcPacket.currentIndex == (currentDtvCcPacket.packetSize * 2 - 1)) {
        finalizeCurrentPacket();
      }
    }
  }

  private void finalizeCurrentPacket() {
    if (currentDtvCcPacket == null) {
      // No packet to finalize;
      return;
    }

    processCurrentPacket();
    currentDtvCcPacket = null;
  }

  @RequiresNonNull("currentDtvCcPacket")
  private void processCurrentPacket() {
    if (currentDtvCcPacket.currentIndex != (currentDtvCcPacket.packetSize * 2 - 1)) {
      Log.d(
          TAG,
          "DtvCcPacket ended prematurely; size is "
              + (currentDtvCcPacket.packetSize * 2 - 1)
              + ", but current index is "
              + currentDtvCcPacket.currentIndex
              + " (sequence number "
              + currentDtvCcPacket.sequenceNumber
              + ");");
      // We've received cc_type=0x03 (packet start) before receiving packetSize byte pairs of data.
      // This might indicate a byte pair has been lost, but we'll still attempt to process the data
      // we have received.
    }

    // The cues should be updated if we receive a C0 ETX command, any C1 command, or if after
    // processing the service block any text has been added to the buffer. See CEA-708-B Section
    // 8.10.4 for more details.
    boolean cuesNeedUpdate = false;

    // Streams with multiple embedded CC tracks (different language tracks) can be delivered
    // in the same frame packet, so captionChannelPacketData can contain service blocks with
    // different service numbers.
    //
    // We iterate over the full buffer until we find a null service block or until the buffer is
    // exhausted. On each iteration we process a single service block. If the block has a service
    // number different to the currently selected service, then we skip it and continue with the
    // next service block.
    captionChannelPacketData.reset(currentDtvCcPacket.packetData, currentDtvCcPacket.currentIndex);
    while (captionChannelPacketData.bitsLeft() > 0) {
      // Parse the Standard Service Block Header (see CEA-708B 6.2.1)
      int serviceNumber = captionChannelPacketData.readBits(3);
      int blockSize = captionChannelPacketData.readBits(5);
      if (serviceNumber == 7) {
        // Parse the Extended Service Block Header (see CEA-708B 6.2.2)
        captionChannelPacketData.skipBits(2);
        serviceNumber = captionChannelPacketData.readBits(6);
        if (serviceNumber < 7) {
          Log.w(TAG, "Invalid extended service number: " + serviceNumber);
        }
      }

      // Ignore packets with the Null Service Block Header (see CEA-708B 6.2.3)
      if (blockSize == 0) {
        if (serviceNumber != 0) {
          Log.w(TAG, "serviceNumber is non-zero (" + serviceNumber + ") when blockSize is 0");
        }
        break;
      }

      if (serviceNumber != selectedServiceNumber) {
        captionChannelPacketData.skipBytes(blockSize);
        continue;
      }

      // Process only the information for the current service block (there could be
      // more data in the buffer, but it is not part of the current service block).
      int endBlockPosition = captionChannelPacketData.getPosition() + (blockSize * 8);
      while (captionChannelPacketData.getPosition() < endBlockPosition) {
        int command = captionChannelPacketData.readBits(8);
        if (command != COMMAND_EXT1) {
          if (command <= GROUP_C0_END) {
            handleC0Command(command);
            // If the C0 command was an ETX command, the cues are updated in handleC0Command.
          } else if (command <= GROUP_G0_END) {
            handleG0Character(command);
            cuesNeedUpdate = true;
          } else if (command <= GROUP_C1_END) {
            handleC1Command(command);
            cuesNeedUpdate = true;
          } else if (command <= GROUP_G1_END) {
            handleG1Character(command);
            cuesNeedUpdate = true;
          } else {
            Log.w(TAG, "Invalid base command: " + command);
          }
        } else {
          // Read the extended command
          command = captionChannelPacketData.readBits(8);
          if (command <= GROUP_C2_END) {
            handleC2Command(command);
          } else if (command <= GROUP_G2_END) {
            handleG2Character(command);
            cuesNeedUpdate = true;
          } else if (command <= GROUP_C3_END) {
            handleC3Command(command);
          } else if (command <= GROUP_G3_END) {
            handleG3Character(command);
            cuesNeedUpdate = true;
          } else {
            Log.w(TAG, "Invalid extended command: " + command);
          }
        }
      }
    }

    if (cuesNeedUpdate) {
      cues = getDisplayCues();
    }
  }

  private void handleC0Command(int command) {
    switch (command) {
      case COMMAND_NUL:
        // Do nothing.
        break;
      case COMMAND_ETX:
        cues = getDisplayCues();
        break;
      case COMMAND_BS:
        currentCueInfoBuilder.backspace();
        break;
      case COMMAND_FF:
        resetCueBuilders();
        break;
      case COMMAND_CR:
        currentCueInfoBuilder.append('\n');
        break;
      case COMMAND_HCR:
        // TODO: Add support for this command.
        break;
      default:
        if (command >= COMMAND_EXT1_START && command <= COMMAND_EXT1_END) {
          Log.w(TAG, "Currently unsupported COMMAND_EXT1 Command: " + command);
          captionChannelPacketData.skipBits(8);
        } else if (command >= COMMAND_P16_START && command <= COMMAND_P16_END) {
          Log.w(TAG, "Currently unsupported COMMAND_P16 Command: " + command);
          captionChannelPacketData.skipBits(16);
        } else {
          Log.w(TAG, "Invalid C0 command: " + command);
        }
    }
  }

  private void handleC1Command(int command) {
    int window;
    switch (command) {
      case COMMAND_CW0:
      case COMMAND_CW1:
      case COMMAND_CW2:
      case COMMAND_CW3:
      case COMMAND_CW4:
      case COMMAND_CW5:
      case COMMAND_CW6:
      case COMMAND_CW7:
        window = (command - COMMAND_CW0);
        if (currentWindow != window) {
          currentWindow = window;
          currentCueInfoBuilder = cueInfoBuilders[window];
        }
        break;
      case COMMAND_CLW:
        for (int i = 1; i <= NUM_WINDOWS; i++) {
          if (captionChannelPacketData.readBit()) {
            cueInfoBuilders[NUM_WINDOWS - i].clear();
          }
        }
        break;
      case COMMAND_DSW:
        for (int i = 1; i <= NUM_WINDOWS; i++) {
          if (captionChannelPacketData.readBit()) {
            cueInfoBuilders[NUM_WINDOWS - i].setVisibility(true);
          }
        }
        break;
      case COMMAND_HDW:
        for (int i = 1; i <= NUM_WINDOWS; i++) {
          if (captionChannelPacketData.readBit()) {
            cueInfoBuilders[NUM_WINDOWS - i].setVisibility(false);
          }
        }
        break;
      case COMMAND_TGW:
        for (int i = 1; i <= NUM_WINDOWS; i++) {
          if (captionChannelPacketData.readBit()) {
            CueInfoBuilder cueInfoBuilder = cueInfoBuilders[NUM_WINDOWS - i];
            cueInfoBuilder.setVisibility(!cueInfoBuilder.isVisible());
          }
        }
        break;
      case COMMAND_DLW:
        for (int i = 1; i <= NUM_WINDOWS; i++) {
          if (captionChannelPacketData.readBit()) {
            cueInfoBuilders[NUM_WINDOWS - i].reset();
          }
        }
        break;
      case COMMAND_DLY:
        // TODO: Add support for delay commands.
        captionChannelPacketData.skipBits(8);
        break;
      case COMMAND_DLC:
        // TODO: Add support for delay commands.
        break;
      case COMMAND_RST:
        resetCueBuilders();
        break;
      case COMMAND_SPA:
        if (!currentCueInfoBuilder.isDefined()) {
          // ignore this command if the current window/cue isn't defined
          captionChannelPacketData.skipBits(16);
        } else {
          handleSetPenAttributes();
        }
        break;
      case COMMAND_SPC:
        if (!currentCueInfoBuilder.isDefined()) {
          // ignore this command if the current window/cue isn't defined
          captionChannelPacketData.skipBits(24);
        } else {
          handleSetPenColor();
        }
        break;
      case COMMAND_SPL:
        if (!currentCueInfoBuilder.isDefined()) {
          // ignore this command if the current window/cue isn't defined
          captionChannelPacketData.skipBits(16);
        } else {
          handleSetPenLocation();
        }
        break;
      case COMMAND_SWA:
        if (!currentCueInfoBuilder.isDefined()) {
          // ignore this command if the current window/cue isn't defined
          captionChannelPacketData.skipBits(32);
        } else {
          handleSetWindowAttributes();
        }
        break;
      case COMMAND_DF0:
      case COMMAND_DF1:
      case COMMAND_DF2:
      case COMMAND_DF3:
      case COMMAND_DF4:
      case COMMAND_DF5:
      case COMMAND_DF6:
      case COMMAND_DF7:
        window = (command - COMMAND_DF0);
        handleDefineWindow(window);
        // We also set the current window to the newly defined window.
        if (currentWindow != window) {
          currentWindow = window;
          currentCueInfoBuilder = cueInfoBuilders[window];
        }
        break;
      default:
        Log.w(TAG, "Invalid C1 command: " + command);
    }
  }

  private void handleC2Command(int command) {
    // C2 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes
    if (command <= 0x07) {
      // Do nothing.
    } else if (command <= 0x0F) {
      captionChannelPacketData.skipBits(8);
    } else if (command <= 0x17) {
      captionChannelPacketData.skipBits(16);
    } else if (command <= 0x1F) {
      captionChannelPacketData.skipBits(24);
    }
  }

  private void handleC3Command(int command) {
    // C3 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes
    if (command <= 0x87) {
      captionChannelPacketData.skipBits(32);
    } else if (command <= 0x8F) {
      captionChannelPacketData.skipBits(40);
    } else if (command <= 0x9F) {
      // 90-9F are variable length codes; the first byte defines the header with the first
      // 2 bits specifying the type and the last 6 bits specifying the remaining length of the
      // command in bytes
      captionChannelPacketData.skipBits(2);
      int length = captionChannelPacketData.readBits(6);
      captionChannelPacketData.skipBits(8 * length);
    }
  }

  private void handleG0Character(int characterCode) {
    if (characterCode == CHARACTER_MN) {
      currentCueInfoBuilder.append('\u266B');
    } else {
      currentCueInfoBuilder.append((char) (characterCode & 0xFF));
    }
  }

  private void handleG1Character(int characterCode) {
    currentCueInfoBuilder.append((char) (characterCode & 0xFF));
  }

  private void handleG2Character(int characterCode) {
    switch (characterCode) {
      case CHARACTER_TSP:
        currentCueInfoBuilder.append('\u0020');
        break;
      case CHARACTER_NBTSP:
        currentCueInfoBuilder.append('\u00A0');
        break;
      case CHARACTER_ELLIPSIS:
        currentCueInfoBuilder.append('\u2026');
        break;
      case CHARACTER_BIG_CARONS:
        currentCueInfoBuilder.append('\u0160');
        break;
      case CHARACTER_BIG_OE:
        currentCueInfoBuilder.append('\u0152');
        break;
      case CHARACTER_SOLID_BLOCK:
        currentCueInfoBuilder.append('\u2588');
        break;
      case CHARACTER_OPEN_SINGLE_QUOTE:
        currentCueInfoBuilder.append('\u2018');
        break;
      case CHARACTER_CLOSE_SINGLE_QUOTE:
        currentCueInfoBuilder.append('\u2019');
        break;
      case CHARACTER_OPEN_DOUBLE_QUOTE:
        currentCueInfoBuilder.append('\u201C');
        break;
      case CHARACTER_CLOSE_DOUBLE_QUOTE:
        currentCueInfoBuilder.append('\u201D');
        break;
      case CHARACTER_BOLD_BULLET:
        currentCueInfoBuilder.append('\u2022');
        break;
      case CHARACTER_TM:
        currentCueInfoBuilder.append('\u2122');
        break;
      case CHARACTER_SMALL_CARONS:
        currentCueInfoBuilder.append('\u0161');
        break;
      case CHARACTER_SMALL_OE:
        currentCueInfoBuilder.append('\u0153');
        break;
      case CHARACTER_SM:
        currentCueInfoBuilder.append('\u2120');
        break;
      case CHARACTER_DIAERESIS_Y:
        currentCueInfoBuilder.append('\u0178');
        break;
      case CHARACTER_ONE_EIGHTH:
        currentCueInfoBuilder.append('\u215B');
        break;
      case CHARACTER_THREE_EIGHTHS:
        currentCueInfoBuilder.append('\u215C');
        break;
      case CHARACTER_FIVE_EIGHTHS:
        currentCueInfoBuilder.append('\u215D');
        break;
      case CHARACTER_SEVEN_EIGHTHS:
        currentCueInfoBuilder.append('\u215E');
        break;
      case CHARACTER_VERTICAL_BORDER:
        currentCueInfoBuilder.append('\u2502');
        break;
      case CHARACTER_UPPER_RIGHT_BORDER:
        currentCueInfoBuilder.append('\u2510');
        break;
      case CHARACTER_LOWER_LEFT_BORDER:
        currentCueInfoBuilder.append('\u2514');
        break;
      case CHARACTER_HORIZONTAL_BORDER:
        currentCueInfoBuilder.append('\u2500');
        break;
      case CHARACTER_LOWER_RIGHT_BORDER:
        currentCueInfoBuilder.append('\u2518');
        break;
      case CHARACTER_UPPER_LEFT_BORDER:
        currentCueInfoBuilder.append('\u250C');
        break;
      default:
        Log.w(TAG, "Invalid G2 character: " + characterCode);
        // The CEA-708 specification doesn't specify what to do in the case of an unexpected
        // value in the G2 character range, so we ignore it.
    }
  }

  private void handleG3Character(int characterCode) {
    if (characterCode == 0xA0) {
      currentCueInfoBuilder.append('\u33C4');
    } else {
      Log.w(TAG, "Invalid G3 character: " + characterCode);
      // Substitute any unsupported G3 character with an underscore as per CEA-708 specification.
      currentCueInfoBuilder.append('_');
    }
  }

  private void handleSetPenAttributes() {
    // the SetPenAttributes command contains 2 bytes of data
    // first byte
    int textTag = captionChannelPacketData.readBits(4);
    int offset = captionChannelPacketData.readBits(2);
    int penSize = captionChannelPacketData.readBits(2);
    // second byte
    boolean italicsToggle = captionChannelPacketData.readBit();
    boolean underlineToggle = captionChannelPacketData.readBit();
    int edgeType = captionChannelPacketData.readBits(3);
    int fontStyle = captionChannelPacketData.readBits(3);

    currentCueInfoBuilder.setPenAttributes(
        textTag, offset, penSize, italicsToggle, underlineToggle, edgeType, fontStyle);
  }

  private void handleSetPenColor() {
    // the SetPenColor command contains 3 bytes of data
    // first byte
    int foregroundO = captionChannelPacketData.readBits(2);
    int foregroundR = captionChannelPacketData.readBits(2);
    int foregroundG = captionChannelPacketData.readBits(2);
    int foregroundB = captionChannelPacketData.readBits(2);
    int foregroundColor =
        CueInfoBuilder.getArgbColorFromCeaColor(foregroundR, foregroundG, foregroundB, foregroundO);
    // second byte
    int backgroundO = captionChannelPacketData.readBits(2);
    int backgroundR = captionChannelPacketData.readBits(2);
    int backgroundG = captionChannelPacketData.readBits(2);
    int backgroundB = captionChannelPacketData.readBits(2);
    int backgroundColor =
        CueInfoBuilder.getArgbColorFromCeaColor(backgroundR, backgroundG, backgroundB, backgroundO);
    // third byte
    captionChannelPacketData.skipBits(2); // null padding
    int edgeR = captionChannelPacketData.readBits(2);
    int edgeG = captionChannelPacketData.readBits(2);
    int edgeB = captionChannelPacketData.readBits(2);
    int edgeColor = CueInfoBuilder.getArgbColorFromCeaColor(edgeR, edgeG, edgeB);

    currentCueInfoBuilder.setPenColor(foregroundColor, backgroundColor, edgeColor);
  }

  private void handleSetPenLocation() {
    // the SetPenLocation command contains 2 bytes of data
    // first byte
    captionChannelPacketData.skipBits(4);
    int row = captionChannelPacketData.readBits(4);
    // second byte
    captionChannelPacketData.skipBits(2);
    int column = captionChannelPacketData.readBits(6);

    currentCueInfoBuilder.setPenLocation(row, column);
  }

  private void handleSetWindowAttributes() {
    // the SetWindowAttributes command contains 4 bytes of data
    // first byte
    int fillO = captionChannelPacketData.readBits(2);
    int fillR = captionChannelPacketData.readBits(2);
    int fillG = captionChannelPacketData.readBits(2);
    int fillB = captionChannelPacketData.readBits(2);
    int fillColor = CueInfoBuilder.getArgbColorFromCeaColor(fillR, fillG, fillB, fillO);
    // second byte
    int borderType = captionChannelPacketData.readBits(2); // only the lower 2 bits of borderType
    int borderR = captionChannelPacketData.readBits(2);
    int borderG = captionChannelPacketData.readBits(2);
    int borderB = captionChannelPacketData.readBits(2);
    int borderColor = CueInfoBuilder.getArgbColorFromCeaColor(borderR, borderG, borderB);
    // third byte
    if (captionChannelPacketData.readBit()) {
      borderType |= 0x04; // set the top bit of the 3-bit borderType
    }
    boolean wordWrapToggle = captionChannelPacketData.readBit();
    int printDirection = captionChannelPacketData.readBits(2);
    int scrollDirection = captionChannelPacketData.readBits(2);
    int justification = captionChannelPacketData.readBits(2);
    // fourth byte
    // Note that we don't intend to support display effects
    captionChannelPacketData.skipBits(8); // effectSpeed(4), effectDirection(2), displayEffect(2)

    currentCueInfoBuilder.setWindowAttributes(
        fillColor,
        borderColor,
        wordWrapToggle,
        borderType,
        printDirection,
        scrollDirection,
        justification);
  }

  private void handleDefineWindow(int window) {
    CueInfoBuilder cueInfoBuilder = cueInfoBuilders[window];

    // the DefineWindow command contains 6 bytes of data
    // first byte
    captionChannelPacketData.skipBits(2); // null padding
    boolean visible = captionChannelPacketData.readBit();
    // ANSI/CTA-708-E S-2023 spec (Section 8.4.7) indicates that rowLock and columnLock values in
    // the media should be ignored and assumed to be true.
    captionChannelPacketData.skipBits(2);
    int priority = captionChannelPacketData.readBits(3);
    // second byte
    boolean relativePositioning = captionChannelPacketData.readBit();
    int verticalAnchor = captionChannelPacketData.readBits(7);
    // third byte
    int horizontalAnchor = captionChannelPacketData.readBits(8);
    // fourth byte
    int anchorId = captionChannelPacketData.readBits(4);
    int rowCount = captionChannelPacketData.readBits(4);
    // fifth byte
    captionChannelPacketData.skipBits(2); // null padding
    // TODO: Add support for column count.
    captionChannelPacketData.skipBits(6); // column count
    // sixth byte
    captionChannelPacketData.skipBits(2); // null padding
    int windowStyle = captionChannelPacketData.readBits(3);
    int penStyle = captionChannelPacketData.readBits(3);

    cueInfoBuilder.defineWindow(
        visible,
        priority,
        relativePositioning,
        verticalAnchor,
        horizontalAnchor,
        rowCount,
        anchorId,
        windowStyle,
        penStyle);
  }

  private List<Cue> getDisplayCues() {
    List<Cea708CueInfo> displayCueInfos = new ArrayList<>();
    for (int i = 0; i < NUM_WINDOWS; i++) {
      if (!cueInfoBuilders[i].isEmpty() && cueInfoBuilders[i].isVisible()) {
        @Nullable Cea708CueInfo cueInfo = cueInfoBuilders[i].build();
        if (cueInfo != null) {
          displayCueInfos.add(cueInfo);
        }
      }
    }
    Collections.sort(displayCueInfos, Cea708CueInfo.LEAST_IMPORTANT_FIRST);
    List<Cue> displayCues = new ArrayList<>(displayCueInfos.size());
    for (int i = 0; i < displayCueInfos.size(); i++) {
      displayCues.add(displayCueInfos.get(i).cue);
    }
    return Collections.unmodifiableList(displayCues);
  }

  private void resetCueBuilders() {
    for (int i = 0; i < NUM_WINDOWS; i++) {
      cueInfoBuilders[i].reset();
    }
  }

  private static final class DtvCcPacket {

    public final int sequenceNumber;
    public final int packetSize;
    public final byte[] packetData;

    int currentIndex;

    public DtvCcPacket(int sequenceNumber, int packetSize) {
      this.sequenceNumber = sequenceNumber;
      this.packetSize = packetSize;
      packetData = new byte[2 * packetSize - 1];
      currentIndex = 0;
    }
  }

  // TODO: There is a lot of overlap between Cea708Decoder.CueInfoBuilder and
  // Cea608Decoder.CueBuilder which could be refactored into a separate class.
  private static final class CueInfoBuilder {

    private static final int RELATIVE_CUE_SIZE = 99;
    private static final int VERTICAL_SIZE = 74;
    private static final int HORIZONTAL_SIZE = 209;

    private static final int DEFAULT_PRIORITY = 4;

    private static final int MAXIMUM_ROW_COUNT = 15;

    private static final int JUSTIFICATION_LEFT = 0;
    private static final int JUSTIFICATION_RIGHT = 1;
    private static final int JUSTIFICATION_CENTER = 2;
    private static final int JUSTIFICATION_FULL = 3;

    private static final int DIRECTION_LEFT_TO_RIGHT = 0;
    private static final int DIRECTION_RIGHT_TO_LEFT = 1;
    private static final int DIRECTION_TOP_TO_BOTTOM = 2;
    private static final int DIRECTION_BOTTOM_TO_TOP = 3;

    // TODO: Add other border/edge types when utilized.
    private static final int BORDER_AND_EDGE_TYPE_NONE = 0;
    private static final int BORDER_AND_EDGE_TYPE_UNIFORM = 3;

    public static final int COLOR_SOLID_WHITE = getArgbColorFromCeaColor(2, 2, 2, 0);
    public static final int COLOR_SOLID_BLACK = getArgbColorFromCeaColor(0, 0, 0, 0);
    public static final int COLOR_TRANSPARENT = getArgbColorFromCeaColor(0, 0, 0, 3);

    // TODO: Add other sizes when utilized.
    private static final int PEN_SIZE_STANDARD = 1;

    // TODO: Add other pen font styles when utilized.
    private static final int PEN_FONT_STYLE_DEFAULT = 0;
    private static final int PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS = 1;
    private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS = 2;
    private static final int PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS = 3;
    private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS = 4;

    // TODO: Add other pen offsets when utilized.
    private static final int PEN_OFFSET_NORMAL = 1;

    // The window style properties are specified in the CEA-708 specification.
    private static final int[] WINDOW_STYLE_JUSTIFICATION =
        new int[] {
          JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_LEFT,
          JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_CENTER,
          JUSTIFICATION_LEFT
        };
    private static final int[] WINDOW_STYLE_PRINT_DIRECTION =
        new int[] {
          DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT,
          DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT,
          DIRECTION_TOP_TO_BOTTOM
        };
    private static final int[] WINDOW_STYLE_SCROLL_DIRECTION =
        new int[] {
          DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP,
          DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP,
          DIRECTION_RIGHT_TO_LEFT
        };
    private static final boolean[] WINDOW_STYLE_WORD_WRAP =
        new boolean[] {false, false, false, true, true, true, false};
    private static final int[] WINDOW_STYLE_FILL =
        new int[] {
          COLOR_SOLID_BLACK,
          COLOR_TRANSPARENT,
          COLOR_SOLID_BLACK,
          COLOR_SOLID_BLACK,
          COLOR_TRANSPARENT,
          COLOR_SOLID_BLACK,
          COLOR_SOLID_BLACK
        };

    // The pen style properties are specified in the CEA-708 specification.
    private static final int[] PEN_STYLE_FONT_STYLE =
        new int[] {
          PEN_FONT_STYLE_DEFAULT,
          PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS,
          PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS,
          PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS,
          PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS,
          PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS,
          PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS
        };
    private static final int[] PEN_STYLE_EDGE_TYPE =
        new int[] {
          BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE,
          BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_UNIFORM,
          BORDER_AND_EDGE_TYPE_UNIFORM
        };
    private static final int[] PEN_STYLE_BACKGROUND =
        new int[] {
          COLOR_SOLID_BLACK,
          COLOR_SOLID_BLACK,
          COLOR_SOLID_BLACK,
          COLOR_SOLID_BLACK,
          COLOR_SOLID_BLACK,
          COLOR_TRANSPARENT,
          COLOR_TRANSPARENT
        };

    private final List<SpannableString> rolledUpCaptions;
    private final SpannableStringBuilder captionStringBuilder;

    // Window/Cue properties
    private boolean defined;
    private boolean visible;
    private int priority;
    private boolean relativePositioning;
    private int verticalAnchor;
    private int horizontalAnchor;
    private int anchorId;
    private int rowCount;
    private int justification;
    private int windowStyleId;
    private int penStyleId;
    private int windowFillColor;

    // Pen/Text properties
    private int italicsStartPosition;
    private int underlineStartPosition;
    private int foregroundColorStartPosition;
    private int foregroundColor;
    private int backgroundColorStartPosition;
    private int backgroundColor;
    private int row;

    public CueInfoBuilder() {
      rolledUpCaptions = new ArrayList<>();
      captionStringBuilder = new SpannableStringBuilder();
      reset();
    }

    public boolean isEmpty() {
      return !isDefined() || (rolledUpCaptions.isEmpty() && captionStringBuilder.length() == 0);
    }

    public void reset() {
      clear();

      defined = false;
      visible = false;
      priority = DEFAULT_PRIORITY;
      relativePositioning = false;
      verticalAnchor = 0;
      horizontalAnchor = 0;
      anchorId = 0;
      rowCount = MAXIMUM_ROW_COUNT;
      justification = JUSTIFICATION_LEFT;
      windowStyleId = 0;
      penStyleId = 0;
      windowFillColor = COLOR_SOLID_BLACK;

      foregroundColor = COLOR_SOLID_WHITE;
      backgroundColor = COLOR_SOLID_BLACK;
    }

    public void clear() {
      rolledUpCaptions.clear();
      captionStringBuilder.clear();
      italicsStartPosition = C.INDEX_UNSET;
      underlineStartPosition = C.INDEX_UNSET;
      foregroundColorStartPosition = C.INDEX_UNSET;
      backgroundColorStartPosition = C.INDEX_UNSET;
      row = 0;
    }

    public boolean isDefined() {
      return defined;
    }

    public void setVisibility(boolean visible) {
      this.visible = visible;
    }

    public boolean isVisible() {
      return visible;
    }

    public void defineWindow(
        boolean visible,
        int priority,
        boolean relativePositioning,
        int verticalAnchor,
        int horizontalAnchor,
        int rowCount,
        int anchorId,
        int windowStyleId,
        int penStyleId) {
      this.defined = true;
      this.visible = visible;
      this.priority = priority;
      this.relativePositioning = relativePositioning;
      this.verticalAnchor = verticalAnchor;
      this.horizontalAnchor = horizontalAnchor;
      this.anchorId = anchorId;

      // Decoders must add one to rowCount to get the desired number of rows.
      if (this.rowCount != rowCount + 1) {
        this.rowCount = rowCount + 1;

        // Trim any rolled up captions that are no longer valid, if applicable.
        while (rolledUpCaptions.size() >= this.rowCount
            || rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT) {
          rolledUpCaptions.remove(0);
        }
      }

      if (windowStyleId != 0 && this.windowStyleId != windowStyleId) {
        this.windowStyleId = windowStyleId;
        // windowStyleId is 1-based.
        int windowStyleIdIndex = windowStyleId - 1;
        // Note that Border type and border color are the same for all window styles.
        setWindowAttributes(
            WINDOW_STYLE_FILL[windowStyleIdIndex],
            COLOR_TRANSPARENT,
            WINDOW_STYLE_WORD_WRAP[windowStyleIdIndex],
            BORDER_AND_EDGE_TYPE_NONE,
            WINDOW_STYLE_PRINT_DIRECTION[windowStyleIdIndex],
            WINDOW_STYLE_SCROLL_DIRECTION[windowStyleIdIndex],
            WINDOW_STYLE_JUSTIFICATION[windowStyleIdIndex]);
      }

      if (penStyleId != 0 && this.penStyleId != penStyleId) {
        this.penStyleId = penStyleId;
        // penStyleId is 1-based.
        int penStyleIdIndex = penStyleId - 1;
        // Note that pen size, offset, italics, underline, foreground color, and foreground
        // opacity are the same for all pen styles.
        setPenAttributes(
            0,
            PEN_OFFSET_NORMAL,
            PEN_SIZE_STANDARD,
            false,
            false,
            PEN_STYLE_EDGE_TYPE[penStyleIdIndex],
            PEN_STYLE_FONT_STYLE[penStyleIdIndex]);
        setPenColor(COLOR_SOLID_WHITE, PEN_STYLE_BACKGROUND[penStyleIdIndex], COLOR_SOLID_BLACK);
      }
    }

    public void setWindowAttributes(
        int fillColor,
        int borderColor,
        boolean wordWrapToggle,
        int borderType,
        int printDirection,
        int scrollDirection,
        int justification) {
      this.windowFillColor = fillColor;
      // TODO: Add support for border color and types.
      // TODO: Add support for word wrap.
      // TODO: Add support for other scroll directions.
      // TODO: Add support for other print directions.
      this.justification = justification;
    }

    public void setPenAttributes(
        int textTag,
        int offset,
        int penSize,
        boolean italicsToggle,
        boolean underlineToggle,
        int edgeType,
        int fontStyle) {
      // TODO: Add support for text tags.
      // TODO: Add support for other offsets.
      // TODO: Add support for other pen sizes.

      if (italicsStartPosition != C.INDEX_UNSET) {
        if (!italicsToggle) {
          captionStringBuilder.setSpan(
              new StyleSpan(Typeface.ITALIC),
              italicsStartPosition,
              captionStringBuilder.length(),
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
          italicsStartPosition = C.INDEX_UNSET;
        }
      } else if (italicsToggle) {
        italicsStartPosition = captionStringBuilder.length();
      }

      if (underlineStartPosition != C.INDEX_UNSET) {
        if (!underlineToggle) {
          captionStringBuilder.setSpan(
              new UnderlineSpan(),
              underlineStartPosition,
              captionStringBuilder.length(),
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
          underlineStartPosition = C.INDEX_UNSET;
        }
      } else if (underlineToggle) {
        underlineStartPosition = captionStringBuilder.length();
      }

      // TODO: Add support for edge types.
      // TODO: Add support for other font styles.
    }

    public void setPenColor(int foregroundColor, int backgroundColor, int edgeColor) {
      if (foregroundColorStartPosition != C.INDEX_UNSET) {
        if (this.foregroundColor != foregroundColor) {
          captionStringBuilder.setSpan(
              new ForegroundColorSpan(this.foregroundColor),
              foregroundColorStartPosition,
              captionStringBuilder.length(),
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
      }
      if (foregroundColor != COLOR_SOLID_WHITE) {
        foregroundColorStartPosition = captionStringBuilder.length();
        this.foregroundColor = foregroundColor;
      }

      if (backgroundColorStartPosition != C.INDEX_UNSET) {
        if (this.backgroundColor != backgroundColor) {
          captionStringBuilder.setSpan(
              new BackgroundColorSpan(this.backgroundColor),
              backgroundColorStartPosition,
              captionStringBuilder.length(),
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
      }
      if (backgroundColor != COLOR_SOLID_BLACK) {
        backgroundColorStartPosition = captionStringBuilder.length();
        this.backgroundColor = backgroundColor;
      }

      // TODO: Add support for edge color.
    }

    public void setPenLocation(int row, int column) {
      // TODO: Support moving the pen location with a window properly.

      // Until we support proper pen locations, if we encounter a row that's different from the
      // previous one, we should append a new line. Otherwise, we'll see strings that should be
      // on new lines concatenated with the previous, resulting in 2 words being combined, as
      // well as potentially drawing beyond the width of the window/screen.
      if (this.row != row) {
        append('\n');
      }
      this.row = row;
    }

    public void backspace() {
      int length = captionStringBuilder.length();
      if (length > 0) {
        captionStringBuilder.delete(length - 1, length);
      }
    }

    public void append(char text) {
      if (text == '\n') {
        rolledUpCaptions.add(buildSpannableString());
        captionStringBuilder.clear();

        if (italicsStartPosition != C.INDEX_UNSET) {
          italicsStartPosition = 0;
        }
        if (underlineStartPosition != C.INDEX_UNSET) {
          underlineStartPosition = 0;
        }
        if (foregroundColorStartPosition != C.INDEX_UNSET) {
          foregroundColorStartPosition = 0;
        }
        if (backgroundColorStartPosition != C.INDEX_UNSET) {
          backgroundColorStartPosition = 0;
        }

        while (rolledUpCaptions.size() >= rowCount
            || rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT) {
          rolledUpCaptions.remove(0);
        }
        // update row value after newline
        row = rolledUpCaptions.size();
      } else {
        captionStringBuilder.append(text);
      }
    }

    public SpannableString buildSpannableString() {
      SpannableStringBuilder spannableStringBuilder =
          new SpannableStringBuilder(captionStringBuilder);
      int length = spannableStringBuilder.length();

      if (length > 0) {
        if (italicsStartPosition != C.INDEX_UNSET) {
          spannableStringBuilder.setSpan(
              new StyleSpan(Typeface.ITALIC),
              italicsStartPosition,
              length,
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }

        if (underlineStartPosition != C.INDEX_UNSET) {
          spannableStringBuilder.setSpan(
              new UnderlineSpan(),
              underlineStartPosition,
              length,
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }

        if (foregroundColorStartPosition != C.INDEX_UNSET) {
          spannableStringBuilder.setSpan(
              new ForegroundColorSpan(foregroundColor),
              foregroundColorStartPosition,
              length,
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }

        if (backgroundColorStartPosition != C.INDEX_UNSET) {
          spannableStringBuilder.setSpan(
              new BackgroundColorSpan(backgroundColor),
              backgroundColorStartPosition,
              length,
              Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
      }

      return new SpannableString(spannableStringBuilder);
    }

    @Nullable
    public Cea708CueInfo build() {
      if (isEmpty()) {
        // The cue is empty.
        return null;
      }

      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(buildSpannableString());

      // TODO: Add support for right-to-left languages (i.e. where right would correspond to normal
      // alignment).
      Alignment alignment;
      switch (justification) {
        case JUSTIFICATION_FULL:
        // TODO: Add support for full justification.
        case JUSTIFICATION_LEFT:
          alignment = Alignment.ALIGN_NORMAL;
          break;
        case JUSTIFICATION_RIGHT:
          alignment = Alignment.ALIGN_OPPOSITE;
          break;
        case JUSTIFICATION_CENTER:
          alignment = Alignment.ALIGN_CENTER;
          break;
        default:
          throw new IllegalArgumentException("Unexpected justification value: " + justification);
      }

      float position;
      float line;
      if (relativePositioning) {
        position = (float) horizontalAnchor / RELATIVE_CUE_SIZE;
        line = (float) verticalAnchor / RELATIVE_CUE_SIZE;
      } else {
        position = (float) horizontalAnchor / HORIZONTAL_SIZE;
        line = (float) verticalAnchor / VERTICAL_SIZE;
      }
      // Apply screen-edge padding to the line and position.
      position = (position * 0.9f) + 0.05f;
      line = (line * 0.9f) + 0.05f;

      // anchorId specifies where the anchor should be placed on the caption cue/window. The 9
      // possible configurations are as follows:
      //   0-----1-----2
      //   |           |
      //   3     4     5
      //   |           |
      //   6-----7-----8
      @AnchorType int verticalAnchorType;
      if (anchorId / 3 == 0) {
        verticalAnchorType = Cue.ANCHOR_TYPE_START;
      } else if (anchorId / 3 == 1) {
        verticalAnchorType = Cue.ANCHOR_TYPE_MIDDLE;
      } else {
        verticalAnchorType = Cue.ANCHOR_TYPE_END;
      }
      // TODO: Add support for right-to-left languages (i.e. where start is on the right).
      @AnchorType int horizontalAnchorType;
      if (anchorId % 3 == 0) {
        horizontalAnchorType = Cue.ANCHOR_TYPE_START;
      } else if (anchorId % 3 == 1) {
        horizontalAnchorType = Cue.ANCHOR_TYPE_MIDDLE;
      } else {
        horizontalAnchorType = Cue.ANCHOR_TYPE_END;
      }

      boolean windowColorSet = (windowFillColor != COLOR_SOLID_BLACK);

      return new Cea708CueInfo(
          cueString,
          alignment,
          line,
          Cue.LINE_TYPE_FRACTION,
          verticalAnchorType,
          position,
          horizontalAnchorType,
          Cue.DIMEN_UNSET,
          windowColorSet,
          windowFillColor,
          priority);
    }

    public static int getArgbColorFromCeaColor(int red, int green, int blue) {
      return getArgbColorFromCeaColor(red, green, blue, 0);
    }

    public static int getArgbColorFromCeaColor(int red, int green, int blue, int opacity) {
      Assertions.checkIndex(red, 0, 4);
      Assertions.checkIndex(green, 0, 4);
      Assertions.checkIndex(blue, 0, 4);
      Assertions.checkIndex(opacity, 0, 4);

      int alpha;
      switch (opacity) {
        case 0:
        case 1:
          // Note the value of '1' is actually FLASH, but we don't support that.
          alpha = 255;
          break;
        case 2:
          alpha = 127;
          break;
        case 3:
          alpha = 0;
          break;
        default:
          alpha = 255;
      }

      // TODO: Add support for the Alternative Minimum Color List or the full 64 RGB combinations.

      // Return values based on the Minimum Color List
      return Color.argb(alpha, (red > 1 ? 255 : 0), (green > 1 ? 255 : 0), (blue > 1 ? 255 : 0));
    }
  }

  /** A {@link Cue} for CEA-708. */
  private static final class Cea708CueInfo {

    /**
     * Sorts cue infos in order of ascending {@link Cea708CueInfo#priority} (which is descending by
     * numeric value).
     */
    private static final Comparator<Cea708CueInfo> LEAST_IMPORTANT_FIRST =
        (thisInfo, thatInfo) -> Integer.compare(thatInfo.priority, thisInfo.priority);

    public final Cue cue;

    /**
     * The priority of the cue box. Low values are higher priority.
     *
     * <p>If cue boxes overlap, higher priority cue boxes are drawn on top.
     *
     * <p>See 8.4.2 of the CEA-708B spec.
     */
    public final int priority;

    /**
     * @param text See {@link Cue#text}.
     * @param textAlignment See {@link Cue#textAlignment}.
     * @param line See {@link Cue#line}.
     * @param lineType See {@link Cue#lineType}.
     * @param lineAnchor See {@link Cue#lineAnchor}.
     * @param position See {@link Cue#position}.
     * @param positionAnchor See {@link Cue#positionAnchor}.
     * @param size See {@link Cue#size}.
     * @param windowColorSet See {@link Cue#windowColorSet}.
     * @param windowColor See {@link Cue#windowColor}.
     * @param priority See {@link #priority}.
     */
    public Cea708CueInfo(
        CharSequence text,
        Alignment textAlignment,
        float line,
        @Cue.LineType int lineType,
        @AnchorType int lineAnchor,
        float position,
        @AnchorType int positionAnchor,
        float size,
        boolean windowColorSet,
        int windowColor,
        int priority) {
      Cue.Builder cueBuilder =
          new Cue.Builder()
              .setText(text)
              .setTextAlignment(textAlignment)
              .setLine(line, lineType)
              .setLineAnchor(lineAnchor)
              .setPosition(position)
              .setPositionAnchor(positionAnchor)
              .setSize(size);
      if (windowColorSet) {
        cueBuilder.setWindowColor(windowColor);
      }
      this.cue = cueBuilder.build();
      this.priority = priority;
    }
  }
}