From f9865780f00a4281f5146baeba8c86a3816fe773 Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 10 Jul 2026 22:18:40 +0300 Subject: [PATCH 1/3] prevent duplication issue --- .../data/repository/HomeServerRepository.kt | 2 +- .../tv/data/repository/StreamRepository.kt | 16 +++++++-- .../arflix/tv/ui/components/StreamSelector.kt | 35 ++++++++++++++----- .../tv/ui/screens/details/DetailsViewModel.kt | 14 ++++---- .../player/AiSubtitleRenderersFactory.kt | 25 +++++++++++++ .../tv/ui/screens/player/PlayerViewModel.kt | 12 +++---- 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt index 7d462ddbe..75915100d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt @@ -1906,7 +1906,7 @@ class HomeServerRepository @Inject constructor( // on the raw MKV while time still advances. listOf(compatibleSource, directSource) } - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } .sortedWith(compareByDescending { qualityRank(it.quality) } .thenByDescending { it.sizeBytes ?: 0L }) } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 3fbde2f41..385b9ee9d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -2085,12 +2085,18 @@ class StreamRepository @Inject constructor( val totalAddons = prioritizedAddons.size + (if (telegramEnabled) 1 else 0) suspend fun sendProgress() { + // Dedup is scoped PER ADDON (addonId in the key, here and at every other stream + // merge site): aggregators (AIOStreams) legitimately return the same debrid + // resolve-URLs as the standalone addons they wrap (Torrentio/Debridio on the same + // account). A global URL key silently dropped most of the aggregator's list — + // whichever addon responded first won — gutting its tab in the source menu + // (38 fetched, 12 shown). The same file under two addon tabs is honest. val deduped = aggregatedStreams .filter { stream -> val u = stream.url?.trim().orEmpty() u.isNotBlank() && !u.startsWith("magnet:", ignoreCase = true) } - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } val filtered = applyQualityRegexFilters(deduped) if (completed == totalAddons) { val createdAtMs = System.currentTimeMillis() @@ -2502,12 +2508,18 @@ class StreamRepository @Inject constructor( val totalAddons = prioritizedAddons.size + (if (telegramEnabled) 1 else 0) suspend fun sendProgress() { + // Dedup is scoped PER ADDON (addonId in the key, here and at every other stream + // merge site): aggregators (AIOStreams) legitimately return the same debrid + // resolve-URLs as the standalone addons they wrap (Torrentio/Debridio on the same + // account). A global URL key silently dropped most of the aggregator's list — + // whichever addon responded first won — gutting its tab in the source menu + // (38 fetched, 12 shown). The same file under two addon tabs is honest. val deduped = aggregatedStreams .filter { stream -> val u = stream.url?.trim().orEmpty() u.isNotBlank() && !u.startsWith("magnet:", ignoreCase = true) } - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } val filtered = applyQualityRegexFilters(deduped) if (completed == totalAddons) { val finalResult = StreamResult(filtered, emptyList()) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 9e89f257c..368c75af3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -250,15 +250,28 @@ fun StreamSelector( } } // Addon order first so "All Sources" groups by the user's installed-addon order. - // Within each addon, best quality floats up (resolution → release → size). + // Within each addon, best quality floats up (resolution → release → size) — EXCEPT + // aggregator addons (AIOStreams): they sort server-side per the user's own web config, so + // their streams keep arrival order (same exemption as PlayerViewModel.keepsOwnStreamOrder; + // re-sorting here silently overrode the user's configured order in the source menu). val orderedPresentations = remember(presentations, addonOrder) { - presentations.sortedWith( - compareBy { addonOrder[sourceTabId(it.stream)] ?: Int.MAX_VALUE } - .thenByDescending { it.resolutionScore } - .thenByDescending { it.releaseScore } - .thenByDescending { it.sizeBytes } - .thenBy { it.title.lowercase() } - ) + val qualityOrder = compareByDescending> { it.value.resolutionScore } + .thenByDescending { it.value.releaseScore } + .thenByDescending { it.value.sizeBytes } + .thenBy { it.value.title.lowercase() } + presentations.withIndex() + .sortedWith( + compareBy> { + addonOrder[sourceTabId(it.value.stream)] ?: Int.MAX_VALUE + }.then { a, b -> + if (keepsOwnStreamOrder(a.value.stream) && keepsOwnStreamOrder(b.value.stream)) { + a.index.compareTo(b.index) + } else { + qualityOrder.compare(a, b) + } + } + ) + .map { it.value } } // Filter streams by selected tab @@ -977,6 +990,12 @@ private object StreamRegexes { val MD_NOISE = Regex("""[`*_]{1,4}""") } +/** Aggregator addons (AIOStreams) sort results server-side per the user's own web config — + * keep their arrival order instead of re-sorting. Mirror of PlayerViewModel.keepsOwnStreamOrder. */ +private fun keepsOwnStreamOrder(stream: StreamSource): Boolean = + stream.addonName.contains("aiostream", ignoreCase = true) || + stream.addonId.contains("aiostream", ignoreCase = true) + private fun sourceTabId(stream: StreamSource): String { val baseName = stream.addonName.split(" - ").firstOrNull()?.trim() ?: stream.addonName return if (stream.addonId == "home_server" && baseName.isNotBlank()) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index d98d537b2..5add9e45a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -1243,7 +1243,7 @@ class DetailsViewModel @Inject constructor( sortPlayableStreamsFirst( progressive.streams .filter { !it.url.isNullOrBlank() && !it.url.orEmpty().startsWith("magnet:", ignoreCase = true) } - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) ) } @@ -1277,7 +1277,7 @@ class DetailsViewModel @Inject constructor( sortPlayableStreamsFirst( progressive.streams .filter { !it.url.isNullOrBlank() && !it.url.orEmpty().startsWith("magnet:", ignoreCase = true) } - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) ) } @@ -1470,7 +1470,7 @@ class DetailsViewModel @Inject constructor( val pluginStreams = results.map { it.toStreamSource() } val merged = sortPlayableStreamsFirst( (current.streams + pluginStreams) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) _uiState.value = current.copy( streams = merged, @@ -1546,7 +1546,7 @@ class DetailsViewModel @Inject constructor( val existingVod = _uiState.value.streams.filter(::isSupplementalStream) val mergedStreams = sortPlayableStreamsFirst( (progressive.streams + existingVod) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) Log.d( TAG, @@ -1607,7 +1607,7 @@ class DetailsViewModel @Inject constructor( val existingVod = _uiState.value.streams.filter(::isSupplementalStream) val mergedStreams = sortPlayableStreamsFirst( (progressive.streams + existingVod) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) val addonCount = streamRepository.installedAddons.first() .count { it.isVodStreamingAddon() } @@ -2508,7 +2508,7 @@ class DetailsViewModel @Inject constructor( } val mergedStreams = sortPlayableStreamsFirst( (latest + validSources) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) _uiState.value = _uiState.value.copy( streams = mergedStreams, @@ -2566,7 +2566,7 @@ class DetailsViewModel @Inject constructor( } val mergedStreams = sortPlayableStreamsFirst( (latest + validVodSources) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } ) _uiState.value = _uiState.value.copy( streams = mergedStreams, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt index 07a45b632..8ac866b28 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt @@ -6,8 +6,10 @@ import androidx.media3.common.text.Cue import androidx.media3.common.text.CueGroup import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.audio.AudioRendererEventListener import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.text.TextOutput import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -60,6 +62,29 @@ class AiSubtitleRenderersFactory( return emptyList() } + override fun buildAudioRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + audioSink: AudioSink, + eventHandler: Handler, + eventListener: AudioRendererEventListener, + out: ArrayList + ) { + // EXTENSION_RENDERER_MODE_PREFER exists only for the Amlogic/SEI *video* C2 decoder hang + // (PlayerScreen preferExtensionDecoder). For AUDIO, FFmpeg-first software-decodes every + // E-AC3/TrueHD/DTS stream to PCM — killing AVR bitstream passthrough — and jellyfin-ffmpeg's + // TrueHD path fails outright. Always build audio with mode ON: the platform + // MediaCodecAudioRenderer (whose DefaultAudioSink auto-detects AVR capabilities and + // bitstreams via bypass) goes first; FFmpeg stays strictly a fallback for codecs the + // device can neither passthrough nor decode. + super.buildAudioRenderers( + context, EXTENSION_RENDERER_MODE_ON, mediaCodecSelector, + enableDecoderFallback, audioSink, eventHandler, eventListener, out + ) + } + override fun buildAudioSink( context: Context, enableFloatOutput: Boolean, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 85e0cba93..d33909774 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -914,7 +914,7 @@ class PlayerViewModel @Inject constructor( val existingVod = _uiState.value.streams.filter(::isSupplementalStream) val mergedStreams = sortStreamsByQualityAndSize( (allStreams + existingVod) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" }, + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" }, preferredLanguage ) lastMergedStreams = mergedStreams @@ -1008,7 +1008,7 @@ class PlayerViewModel @Inject constructor( if (remainingMs > 0L) delay(remainingMs) snapshot = sortStreamsByQualityAndSize( (_uiState.value.streams + snapshot) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" }, + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" }, preferredLanguage ) } @@ -1062,7 +1062,7 @@ class PlayerViewModel @Inject constructor( if (remainingMs > 0L) delay(remainingMs) lastMergedStreams = sortStreamsByQualityAndSize( (_uiState.value.streams + lastMergedStreams) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" }, + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" }, preferredLanguage ) } @@ -3994,7 +3994,7 @@ class PlayerViewModel @Inject constructor( val latest = _uiState.value.streams val updated = (latest + validSources) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } val preferredLanguage = _uiState.value.preferredAudioLanguage.ifBlank { "en" } val sortedStreams = sortStreamsByQualityAndSize(updated, preferredLanguage) val shouldAutoplayHomeServer = _uiState.value.selectedStreamUrl.isNullOrBlank() @@ -4056,7 +4056,7 @@ class PlayerViewModel @Inject constructor( val latest = _uiState.value.streams val updated = (latest + validVodSources) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } val preferredLanguage = _uiState.value.preferredAudioLanguage.ifBlank { "en" } val sortedStreams = sortStreamsByQualityAndSize(updated, preferredLanguage) val shouldAutoplayVod = _uiState.value.selectedStreamUrl.isNullOrBlank() @@ -4136,7 +4136,7 @@ class PlayerViewModel @Inject constructor( val existingVod = _uiState.value.streams.filter(::isSupplementalStream) val mergedStreams = (allStreams + existingVod) - .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + .distinctBy { "${it.addonId}|${it.url?.trim().orEmpty()}|${it.source}" } val preferredLanguage = _uiState.value.preferredAudioLanguage.ifBlank { "en" } val sortedStreams = sortStreamsByQualityAndSize(mergedStreams, preferredLanguage) From 4cc2512287858c61681bb803f483ff1f026fe1e9 Mon Sep 17 00:00:00 2001 From: silentbil Date: Sat, 11 Jul 2026 07:26:17 +0300 Subject: [PATCH 2/3] dobly support --- app/build.gradle.kts | 6 + .../tv/player/dvmkv/DefaultEbmlReader.java | 261 ++ .../dvmkv/DolbyVisionCompatibility.java | 168 + .../arflix/tv/player/dvmkv/EbmlProcessor.java | 159 + .../arflix/tv/player/dvmkv/EbmlReader.java | 55 + .../tv/player/dvmkv/MatroskaExtractor.java | 3505 +++++++++++++++++ .../com/arflix/tv/player/dvmkv/Sniffer.java | 112 + .../arflix/tv/player/dvmkv/VarintReader.java | 150 + .../arflix/tv/player/dvmkv/package-info.java | 12 + .../tv/data/repository/CloudSyncRepository.kt | 6 + .../player/dv/DolbyVisionBaseLayerPolicy.kt | 270 ++ .../dv/DolbyVisionStripExtractorsFactory.kt | 68 + .../player/dv/DolbyVisionStripTransformer.kt | 121 + .../arflix/tv/player/dv/HevcDvRpuStripper.kt | 131 + .../tv/player/dv/HevcHdr10PlusStripper.kt | 270 ++ .../tv/ui/screens/player/PlayerScreen.kt | 63 +- .../tv/ui/screens/player/PlayerViewModel.kt | 6 + .../tv/ui/screens/settings/SettingsScreen.kt | 8 +- .../ui/screens/settings/SettingsViewModel.kt | 12 + app/src/main/res/values-iw/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + docs/dolby-vision-compat.md | 56 + docs/subtitle-auto-match.md | 4 +- 23 files changed, 5439 insertions(+), 8 deletions(-) create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DefaultEbmlReader.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DolbyVisionCompatibility.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlProcessor.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlReader.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/MatroskaExtractor.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/Sniffer.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/VarintReader.java create mode 100644 app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/package-info.java create mode 100644 app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionBaseLayerPolicy.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripExtractorsFactory.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripTransformer.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/player/dv/HevcDvRpuStripper.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/player/dv/HevcHdr10PlusStripper.kt create mode 100644 docs/dolby-vision-compat.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c0249d4d..abe97d511 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -158,6 +158,9 @@ android { sourceSets { getByName("main") { java.srcDir("src/main/tdlib-java") + // Vendored media3 1.9.0 Matroska extractor with Dolby Vision P7 sample hooks + // (see dvmkv/package-info.java for the re-vendoring procedure on media3 bumps). + java.srcDir("src/main/dvmkv-java") } } @@ -232,6 +235,9 @@ ksp { // Core library desugaring for Java 8+ APIs coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + // Nullness annotations used by the vendored media3 Matroska extractor (src/main/dvmkv-java). + compileOnly("org.checkerframework:checker-qual:3.43.0") + // Core Android implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-splashscreen:1.0.1") // Android 12+ Splash Screen diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DefaultEbmlReader.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DefaultEbmlReader.java new file mode 100644 index 000000000..be545d53d --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DefaultEbmlReader.java @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.lang.annotation.ElementType.TYPE_USE; + +import androidx.annotation.IntDef; +import androidx.media3.common.C; +import androidx.media3.common.ParserException; +import androidx.media3.extractor.ExtractorInput; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayDeque; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** Default implementation of {@link EbmlReader}. */ +/* package */ final class DefaultEbmlReader implements EbmlReader { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({ELEMENT_STATE_READ_ID, ELEMENT_STATE_READ_CONTENT_SIZE, ELEMENT_STATE_READ_CONTENT}) + private @interface ElementState {} + + private static final int ELEMENT_STATE_READ_ID = 0; + private static final int ELEMENT_STATE_READ_CONTENT_SIZE = 1; + private static final int ELEMENT_STATE_READ_CONTENT = 2; + + private static final int MAX_ID_BYTES = 4; + private static final int MAX_LENGTH_BYTES = 8; + + private static final int MAX_INTEGER_ELEMENT_SIZE_BYTES = 8; + private static final int VALID_FLOAT32_ELEMENT_SIZE_BYTES = 4; + private static final int VALID_FLOAT64_ELEMENT_SIZE_BYTES = 8; + + private final byte[] scratch; + private final ArrayDeque masterElementsStack; + private final VarintReader varintReader; + + private @MonotonicNonNull EbmlProcessor processor; + private @ElementState int elementState; + private int elementId; + private long elementContentSize; + + public DefaultEbmlReader() { + scratch = new byte[8]; + masterElementsStack = new ArrayDeque<>(); + varintReader = new VarintReader(); + } + + @Override + public void init(EbmlProcessor processor) { + this.processor = processor; + } + + @Override + public void reset() { + elementState = ELEMENT_STATE_READ_ID; + masterElementsStack.clear(); + varintReader.reset(); + } + + @Override + public boolean read(ExtractorInput input) throws IOException { + checkNotNull(processor); + while (true) { + MasterElement head = masterElementsStack.peek(); + if (head != null && input.getPosition() >= head.elementEndPosition) { + processor.endMasterElement(masterElementsStack.pop().elementId); + return true; + } + + if (elementState == ELEMENT_STATE_READ_ID) { + long result = varintReader.readUnsignedVarint(input, true, false, MAX_ID_BYTES); + if (result == C.RESULT_MAX_LENGTH_EXCEEDED) { + result = maybeResyncToNextLevel1Element(input); + } + if (result == C.RESULT_END_OF_INPUT) { + return false; + } + // Element IDs are at most 4 bytes, so we can cast to integers. + elementId = (int) result; + elementState = ELEMENT_STATE_READ_CONTENT_SIZE; + } + + if (elementState == ELEMENT_STATE_READ_CONTENT_SIZE) { + elementContentSize = varintReader.readUnsignedVarint(input, false, true, MAX_LENGTH_BYTES); + elementState = ELEMENT_STATE_READ_CONTENT; + } + + @EbmlProcessor.ElementType int type = processor.getElementType(elementId); + switch (type) { + case EbmlProcessor.ELEMENT_TYPE_MASTER: + long elementContentPosition = input.getPosition(); + long elementEndPosition = elementContentPosition + elementContentSize; + masterElementsStack.push(new MasterElement(elementId, elementEndPosition)); + processor.startMasterElement(elementId, elementContentPosition, elementContentSize); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT: + if (elementContentSize > MAX_INTEGER_ELEMENT_SIZE_BYTES) { + throw ParserException.createForMalformedContainer( + "Invalid integer size: " + elementContentSize, /* cause= */ null); + } + processor.integerElement(elementId, readInteger(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_FLOAT: + if (elementContentSize != VALID_FLOAT32_ELEMENT_SIZE_BYTES + && elementContentSize != VALID_FLOAT64_ELEMENT_SIZE_BYTES) { + throw ParserException.createForMalformedContainer( + "Invalid float size: " + elementContentSize, /* cause= */ null); + } + processor.floatElement(elementId, readFloat(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_STRING: + if (elementContentSize > Integer.MAX_VALUE) { + throw ParserException.createForMalformedContainer( + "String element size: " + elementContentSize, /* cause= */ null); + } + processor.stringElement(elementId, readString(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_BINARY: + processor.binaryElement(elementId, (int) elementContentSize, input); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_UNKNOWN: + input.skipFully((int) elementContentSize); + elementState = ELEMENT_STATE_READ_ID; + elementContentSize = 0; + elementId = 0; + break; + default: + throw ParserException.createForMalformedContainer( + "Invalid element type " + type, /* cause= */ null); + } + } + } + + /** + * Does a byte by byte search to try and find the next level 1 element. This method is called if + * some invalid data is encountered in the parser. + * + * @param input The {@link ExtractorInput} from which data has to be read. + * @return id of the next level 1 element that has been found. + * @throws EOFException If the end of input was encountered when searching for the next level 1 + * element. + * @throws IOException If an error occurs reading from the input. + */ + @RequiresNonNull("processor") + private long maybeResyncToNextLevel1Element(ExtractorInput input) throws IOException { + input.resetPeekPosition(); + while (true) { + input.peekFully(scratch, 0, MAX_ID_BYTES); + int varintLength = VarintReader.parseUnsignedVarintLength(scratch[0]); + if (varintLength != C.LENGTH_UNSET && varintLength <= MAX_ID_BYTES) { + int potentialId = (int) VarintReader.assembleVarint(scratch, varintLength, false); + if (processor.isLevel1Element(potentialId)) { + input.skipFully(varintLength); + return potentialId; + } + } + input.skipFully(1); + } + } + + /** + * Reads and returns an integer of length {@code byteLength} from the {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the integer being read. + * @return The read integer value. + * @throws IOException If an error occurs reading from the input. + */ + private long readInteger(ExtractorInput input, int byteLength) throws IOException { + input.readFully(scratch, 0, byteLength); + long value = 0; + for (int i = 0; i < byteLength; i++) { + value = (value << 8) | (scratch[i] & 0xFF); + } + return value; + } + + /** + * Reads and returns a float of length {@code byteLength} from the {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the float being read. + * @return The read float value. + * @throws IOException If an error occurs reading from the input. + */ + private double readFloat(ExtractorInput input, int byteLength) throws IOException { + long integerValue = readInteger(input, byteLength); + double floatValue; + if (byteLength == VALID_FLOAT32_ELEMENT_SIZE_BYTES) { + floatValue = Float.intBitsToFloat((int) integerValue); + } else { + floatValue = Double.longBitsToDouble(integerValue); + } + return floatValue; + } + + /** + * Reads a string of length {@code byteLength} from the {@link ExtractorInput}. Zero padding is + * removed, so the returned string may be shorter than {@code byteLength}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the string being read, including zero padding. + * @return The read string value. + * @throws IOException If an error occurs reading from the input. + */ + private static String readString(ExtractorInput input, int byteLength) throws IOException { + if (byteLength == 0) { + return ""; + } + byte[] stringBytes = new byte[byteLength]; + input.readFully(stringBytes, 0, byteLength); + // Remove zero padding. + int trimmedLength = byteLength; + while (trimmedLength > 0 && stringBytes[trimmedLength - 1] == 0) { + trimmedLength--; + } + return new String(stringBytes, 0, trimmedLength); + } + + /** + * Used in {@link #masterElementsStack} to track when the current master element ends, so that + * {@link EbmlProcessor#endMasterElement(int)} can be called. + */ + private static final class MasterElement { + + private final int elementId; + private final long elementEndPosition; + + private MasterElement(int elementId, long elementEndPosition) { + this.elementId = elementId; + this.elementEndPosition = elementEndPosition; + } + } +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DolbyVisionCompatibility.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DolbyVisionCompatibility.java new file mode 100644 index 000000000..320056bba --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/DolbyVisionCompatibility.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import androidx.annotation.Nullable; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import java.util.Locale; +import java.util.Objects; + +/** + * Runtime compatibility helpers for Dolby Vision to HEVC fallback flows. + * + *

This class intentionally stores transformer hooks as raw {@link Object} references so that + * shared modules can exchange hook instances without introducing module dependency cycles. + */ +@UnstableApi +public final class DolbyVisionCompatibility { + + private static volatile boolean mapDv7ToHevcEnabled; + @Nullable private static volatile Object matroskaDolbyVisionSampleTransformer; + @Nullable private static volatile Object mp4DolbyVisionSampleTransformer; + @Nullable private static volatile Object fragmentedMp4DolbyVisionSampleTransformer; + @Nullable private static volatile Object tsDolbyVisionNalTransformer; + + private static volatile boolean isHdr10BaseLayerModeActive; + + public static void setHdr10BaseLayerModeActive(boolean active) { + isHdr10BaseLayerModeActive = active; + } + + public static boolean isHdr10BaseLayerModeActive() { + return isHdr10BaseLayerModeActive; + } + + public static void setMapDv7ToHevcEnabled(boolean enabled) { + mapDv7ToHevcEnabled = enabled; + } + + public static boolean isMapDv7ToHevcEnabled() { + return mapDv7ToHevcEnabled; + } + + public static boolean shouldMapDolbyVisionProfile7( + @Nullable String sampleMimeType, @Nullable String codecs) { + if (!mapDv7ToHevcEnabled) { + return false; + } + if (sampleMimeType != null && !Objects.equals(sampleMimeType, MimeTypes.VIDEO_DOLBY_VISION)) { + return false; + } + if (codecs == null || codecs.trim().isEmpty()) { + // If we already know this is a Dolby Vision track by mime type, allow fallback mapping even + // without an explicit codec string. + return Objects.equals(sampleMimeType, MimeTypes.VIDEO_DOLBY_VISION); + } + return extractDolbyVisionCodec(codecs) != null; + } + + public static boolean isDolbyVisionProfile7(@Nullable String codecs) { + @Nullable String dvCodecs = extractDolbyVisionCodec(codecs); + if (dvCodecs == null) { + return false; + } + String[] parts = dvCodecs.split("\\."); + if (parts.length < 2) { + return false; + } + int profile = parseIntOrUnset(parts[1]); + return profile == 7; + } + + @Nullable + public static String chooseHevcCodecsString( + @Nullable String codecs, @Nullable String supplementalCodecs) { + if (isHevcCodecsString(codecs)) { + return codecs; + } + if (isHevcCodecsString(supplementalCodecs)) { + return supplementalCodecs; + } + return null; + } + + public static void setMatroskaDolbyVisionSampleTransformer(@Nullable Object transformer) { + matroskaDolbyVisionSampleTransformer = transformer; + } + + @Nullable + public static Object getMatroskaDolbyVisionSampleTransformer() { + return matroskaDolbyVisionSampleTransformer; + } + + public static void setMp4DolbyVisionSampleTransformer(@Nullable Object transformer) { + mp4DolbyVisionSampleTransformer = transformer; + } + + @Nullable + public static Object getMp4DolbyVisionSampleTransformer() { + return mp4DolbyVisionSampleTransformer; + } + + public static void setFragmentedMp4DolbyVisionSampleTransformer(@Nullable Object transformer) { + fragmentedMp4DolbyVisionSampleTransformer = transformer; + } + + @Nullable + public static Object getFragmentedMp4DolbyVisionSampleTransformer() { + return fragmentedMp4DolbyVisionSampleTransformer; + } + + public static void setTsDolbyVisionNalTransformer(@Nullable Object transformer) { + tsDolbyVisionNalTransformer = transformer; + } + + @Nullable + public static Object getTsDolbyVisionNalTransformer() { + return tsDolbyVisionNalTransformer; + } + + private static boolean isHevcCodecsString(@Nullable String codecs) { + if (codecs == null) { + return false; + } + String trimmed = codecs.trim(); + return trimmed.startsWith("hvc1") || trimmed.startsWith("hev1"); + } + + @Nullable + private static String extractDolbyVisionCodec(@Nullable String codecs) { + if (codecs == null) { + return null; + } + String[] codecParts = Util.splitCodecs(codecs); + for (int i = 0; i < codecParts.length; i++) { + String codec = codecParts[i].trim().toLowerCase(Locale.US); + if (codec.startsWith("dvhe") || codec.startsWith("dvh1")) { + return codec; + } + } + return null; + } + + private static int parseIntOrUnset(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return Format.NO_VALUE; + } + } + + private DolbyVisionCompatibility() {} +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlProcessor.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlProcessor.java new file mode 100644 index 000000000..cd711d83d --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlProcessor.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import static java.lang.annotation.ElementType.TYPE_USE; + +import androidx.annotation.IntDef; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Defines EBML element IDs/types and processes events. */ +@UnstableApi +public interface EbmlProcessor { + + /** + * EBML element types. One of {@link #ELEMENT_TYPE_UNKNOWN}, {@link #ELEMENT_TYPE_MASTER}, {@link + * #ELEMENT_TYPE_UNSIGNED_INT}, {@link #ELEMENT_TYPE_STRING}, {@link #ELEMENT_TYPE_BINARY} or + * {@link #ELEMENT_TYPE_FLOAT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({ + ELEMENT_TYPE_UNKNOWN, + ELEMENT_TYPE_MASTER, + ELEMENT_TYPE_UNSIGNED_INT, + ELEMENT_TYPE_STRING, + ELEMENT_TYPE_BINARY, + ELEMENT_TYPE_FLOAT + }) + @interface ElementType {} + + /** Type for unknown elements. */ + int ELEMENT_TYPE_UNKNOWN = 0; + + /** Type for elements that contain child elements. */ + int ELEMENT_TYPE_MASTER = 1; + + /** Type for integer value elements of up to 8 bytes. */ + int ELEMENT_TYPE_UNSIGNED_INT = 2; + + /** Type for string elements. */ + int ELEMENT_TYPE_STRING = 3; + + /** Type for binary elements. */ + int ELEMENT_TYPE_BINARY = 4; + + /** Type for IEEE floating point value elements of either 4 or 8 bytes. */ + int ELEMENT_TYPE_FLOAT = 5; + + /** + * Maps an element ID to a corresponding type. + * + *

If {@link #ELEMENT_TYPE_UNKNOWN} is returned then the element is skipped. Note that all + * children of a skipped element are also skipped. + * + * @param id The element ID to map. + * @return One of {@link #ELEMENT_TYPE_UNKNOWN}, {@link #ELEMENT_TYPE_MASTER}, {@link + * #ELEMENT_TYPE_UNSIGNED_INT}, {@link #ELEMENT_TYPE_STRING}, {@link #ELEMENT_TYPE_BINARY} and + * {@link #ELEMENT_TYPE_FLOAT}. + */ + @ElementType + int getElementType(int id); + + /** + * Checks if the given id is that of a level 1 element. + * + * @param id The element ID. + * @return Whether the given id is that of a level 1 element. + */ + boolean isLevel1Element(int id); + + /** + * Called when the start of a master element is encountered. + * + *

Following events should be considered as taking place within this element until a matching + * call to {@link #endMasterElement(int)} is made. + * + *

Note that it is possible for another master element of the same element ID to be nested + * within itself. + * + * @param id The element ID. + * @param contentPosition The position of the start of the element's content in the stream. + * @param contentSize The size of the element's content in bytes. + * @throws ParserException If a parsing error occurs. + */ + void startMasterElement(int id, long contentPosition, long contentSize) throws ParserException; + + /** + * Called when the end of a master element is encountered. + * + * @param id The element ID. + * @throws ParserException If a parsing error occurs. + */ + void endMasterElement(int id) throws ParserException; + + /** + * Called when an integer element is encountered. + * + * @param id The element ID. + * @param value The integer value that the element contains. + * @throws ParserException If a parsing error occurs. + */ + void integerElement(int id, long value) throws ParserException; + + /** + * Called when a float element is encountered. + * + * @param id The element ID. + * @param value The float value that the element contains + * @throws ParserException If a parsing error occurs. + */ + void floatElement(int id, double value) throws ParserException; + + /** + * Called when a string element is encountered. + * + * @param id The element ID. + * @param value The string value that the element contains. + * @throws ParserException If a parsing error occurs. + */ + void stringElement(int id, String value) throws ParserException; + + /** + * Called when a binary element is encountered. + * + *

The element header (containing the element ID and content size) will already have been read. + * Implementations are required to consume the whole remainder of the element, which is {@code + * contentSize} bytes in length, before returning. Implementations are permitted to fail (by + * throwing an exception) having partially consumed the data, however if they do this, they must + * consume the remainder of the content when called again. + * + * @param id The element ID. + * @param contentsSize The element's content size. + * @param input The {@link ExtractorInput} from which data should be read. + * @throws ParserException If a parsing error occurs. + * @throws IOException If an error occurs reading from the input. + */ + void binaryElement(int id, int contentsSize, ExtractorInput input) throws IOException; +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlReader.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlReader.java new file mode 100644 index 000000000..529fe278e --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/EbmlReader.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import androidx.media3.common.ParserException; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; + +/** + * Event-driven EBML reader that delivers events to an {@link EbmlProcessor}. + * + *

EBML can be summarized as a binary XML format somewhat similar to Protocol Buffers. It was + * originally designed for the Matroska container format. More information about EBML and Matroska + * is available here. + */ +/* package */ interface EbmlReader { + + /** + * Initializes the extractor with an {@link EbmlProcessor}. + * + * @param processor An {@link EbmlProcessor} to process events. + */ + void init(EbmlProcessor processor); + + /** + * Resets the state of the reader. + * + *

Subsequent calls to {@link #read(ExtractorInput)} will start reading a new EBML structure + * from scratch. + */ + void reset(); + + /** + * Reads from an {@link ExtractorInput}, invoking an event callback if possible. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @return True if data can continue to be read. False if the end of the input was encountered. + * @throws ParserException If parsing fails. + * @throws IOException If an error occurs reading from the input. + */ + boolean read(ExtractorInput input) throws IOException; +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/MatroskaExtractor.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/MatroskaExtractor.java new file mode 100644 index 000000000..5c6ba8f2c --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/MatroskaExtractor.java @@ -0,0 +1,3505 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.util.Pair; +import android.util.SparseArray; +import androidx.annotation.CallSuper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.ColorInfo; +import androidx.media3.common.DrmInitData; +import androidx.media3.common.DrmInitData.SchemeData; +import androidx.media3.common.Format; +import androidx.media3.common.Metadata; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +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.common.util.Util; +import androidx.media3.container.DolbyVisionConfig; +import androidx.media3.container.NalUnitUtil; +import androidx.media3.extractor.AacUtil; +import androidx.media3.extractor.AvcConfig; +import androidx.media3.extractor.ChunkIndex; +import androidx.media3.extractor.ChunkIndexProvider; +import androidx.media3.extractor.DtsUtil; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.HevcConfig; +import androidx.media3.extractor.MpegAudioUtil; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.SeekPoint; +import androidx.media3.extractor.TrackAwareSeekMap; +import androidx.media3.extractor.TrackOutput; +import androidx.media3.extractor.TrueHdSampleRechunker; +import androidx.media3.extractor.metadata.ThumbnailMetadata; +import androidx.media3.extractor.text.SubtitleParser; +import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput; +import com.google.common.collect.ImmutableList; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** Extracts data from the Matroska and WebM container formats. */ +@UnstableApi +public class MatroskaExtractor implements Extractor { + + /** + * Creates a factory for {@link MatroskaExtractor} instances with the provided {@link + * SubtitleParser.Factory}. + */ + public static ExtractorsFactory newFactory(SubtitleParser.Factory subtitleParserFactory) { + return () -> new Extractor[] {new MatroskaExtractor(subtitleParserFactory)}; + } + + /** + * Creates a factory for {@link MatroskaExtractor} instances with the provided {@link + * SubtitleParser.Factory} and optional Dolby Vision sample hook. + */ + public static ExtractorsFactory newFactory( + SubtitleParser.Factory subtitleParserFactory, + @Nullable DolbyVisionSampleTransformer dolbyVisionSampleTransformer) { + return () -> + new Extractor[] { + new MatroskaExtractor(subtitleParserFactory, /* flags= */ 0, dolbyVisionSampleTransformer) + }; + } + + /** + * Flags controlling the behavior of the extractor. Possible flag values are {@link + * #FLAG_DISABLE_SEEK_FOR_CUES} and {#FLAG_EMIT_RAW_SUBTITLE_DATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_SEEK_FOR_CUES, FLAG_EMIT_RAW_SUBTITLE_DATA}) + public @interface Flags {} + + /** + * Hook invoked for Dolby Vision related Matroska data. + * + *

This is a phase-2 integration seam for DV7 conversion experiments. The extractor keeps + * existing behavior unless this hook is provided. + */ + public interface DolbyVisionSampleTransformer { + + /** + * Called when Dolby Vision BlockAdditional data is encountered for an HEVC track. + * + * @param blockAdditionalData Block additional payload bytes. + * @param blockAddIdType Dolby Vision BlockAddIdType ({@code dvcc}/{@code dvvc}). + * @param dolbyVisionConfigBytes Track-level Dolby Vision config bytes, if available. + * @return Payload to retain for the associated sample, or null to keep original data. + */ + @Nullable + default byte[] onDolbyVisionBlockAdditionalData( + byte[] blockAdditionalData, int blockAddIdType, @Nullable byte[] dolbyVisionConfigBytes) { + return null; + } + + /** + * Called before writing an HEVC sample that has pending Dolby Vision block-additional bytes. + * + *

Current spike wiring is telemetry/inspection only. A future phase may allow replacing the + * sample payload at this point. + */ + default void onHevcSample( + int sampleSizeBytes, + @Nullable byte[] blockAdditionalData, + @Nullable byte[] dolbyVisionConfigBytes) {} + + /** + * Optionally rewrites an HEVC sample payload. + * + *

Reads the first {@code sampleLength} bytes of {@code sampleLengthDelimitedData}. A non-null + * result is the transformer's reusable buffer, valid for {@link #lastTransformedSampleLength()} + * bytes and only until the next call; null keeps the original payload. + * + * @param sampleLengthDelimitedData Sample payload in length-delimited NAL format. + * @param sampleLength Number of valid bytes in {@code sampleLengthDelimitedData}. + * @param nalUnitLengthFieldLength Length field size in bytes. + * @param blockAdditionalData Block additional payload associated with this sample. + * @param dolbyVisionConfigBytes Track-level Dolby Vision config bytes, if available. + * @return Reusable buffer with the rewritten sample, or null to keep original. + */ + @Nullable + default byte[] transformHevcSample( + byte[] sampleLengthDelimitedData, + int sampleLength, + int nalUnitLengthFieldLength, + @Nullable byte[] blockAdditionalData, + @Nullable byte[] dolbyVisionConfigBytes) { + return null; + } + + /** Valid byte count of the buffer returned by the last {@link #transformHevcSample} call. */ + default int lastTransformedSampleLength() { + return 0; + } + + /** + * Optionally rewrites Dolby Vision codec signaling for output {@link Format}. + * + *

This is used when sample-level metadata conversion changes the effective Dolby Vision + * profile (for example DV7 to DV8.1), but container-level codec signaling would otherwise still + * advertise the source profile. + * + * @param codecs Existing codec string (for example {@code dvhe.07.06}). + * @param dolbyVisionConfigBytes Track-level Dolby Vision config bytes, if available. + * @return Replacement codec string, or null to keep original signaling. + */ + @Nullable + default String onDolbyVisionCodecString( + @Nullable String codecs, @Nullable byte[] dolbyVisionConfigBytes) { + return null; + } + } + + /** + * Flag to disable seeking for cues. + * + *

Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its + * position is specified in the seek head and if it's after the first cluster. Setting this flag + * disables seeking to the cues element. If the cues element is after the first cluster then the + * media is treated as being unseekable. + */ + public static final int FLAG_DISABLE_SEEK_FOR_CUES = 1; + + /** + * Flag to use the source subtitle formats without modification. If unset, subtitles will be + * transcoded to {@link MimeTypes#APPLICATION_MEDIA3_CUES} during extraction. + */ + public static final int FLAG_EMIT_RAW_SUBTITLE_DATA = 1 << 1; // 2 + + /** + * @deprecated Use {@link #newFactory(SubtitleParser.Factory)} instead. + */ + @Deprecated + public static final ExtractorsFactory FACTORY = + () -> + new Extractor[] { + new MatroskaExtractor(SubtitleParser.Factory.UNSUPPORTED, FLAG_EMIT_RAW_SUBTITLE_DATA) + }; + + private static final String TAG = "MatroskaExtractor"; + + private static final int UNSET_ENTRY_ID = -1; + + private static final int BLOCK_STATE_START = 0; + private static final int BLOCK_STATE_HEADER = 1; + private static final int BLOCK_STATE_DATA = 2; + + private static final String DOC_TYPE_MATROSKA = "matroska"; + private static final String DOC_TYPE_WEBM = "webm"; + private static final String CODEC_ID_VP8 = "V_VP8"; + private static final String CODEC_ID_VP9 = "V_VP9"; + private static final String CODEC_ID_AV1 = "V_AV1"; + private static final String CODEC_ID_MPEG2 = "V_MPEG2"; + private static final String CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP"; + private static final String CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP"; + private static final String CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP"; + private static final String CODEC_ID_H264 = "V_MPEG4/ISO/AVC"; + private static final String CODEC_ID_H265 = "V_MPEGH/ISO/HEVC"; + private static final String CODEC_ID_FOURCC = "V_MS/VFW/FOURCC"; + private static final String CODEC_ID_THEORA = "V_THEORA"; + private static final String CODEC_ID_VORBIS = "A_VORBIS"; + private static final String CODEC_ID_OPUS = "A_OPUS"; + private static final String CODEC_ID_AAC = "A_AAC"; + private static final String CODEC_ID_MP2 = "A_MPEG/L2"; + private static final String CODEC_ID_MP3 = "A_MPEG/L3"; + private static final String CODEC_ID_AC3 = "A_AC3"; + private static final String CODEC_ID_E_AC3 = "A_EAC3"; + private static final String CODEC_ID_TRUEHD = "A_TRUEHD"; + private static final String CODEC_ID_DTS = "A_DTS"; + private static final String CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS"; + private static final String CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS"; + private static final String CODEC_ID_FLAC = "A_FLAC"; + private static final String CODEC_ID_ACM = "A_MS/ACM"; + private static final String CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT"; + private static final String CODEC_ID_PCM_INT_BIG = "A_PCM/INT/BIG"; + private static final String CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE"; + private static final String CODEC_ID_SUBRIP = "S_TEXT/UTF8"; + private static final String CODEC_ID_ASS = "S_TEXT/ASS"; + private static final String CODEC_ID_SSA = "S_TEXT/SSA"; + private static final String CODEC_ID_VTT = "S_TEXT/WEBVTT"; + private static final String CODEC_ID_VOBSUB = "S_VOBSUB"; + private static final String CODEC_ID_PGS = "S_HDMV/PGS"; + private static final String CODEC_ID_DVBSUB = "S_DVBSUB"; + + private static final int VORBIS_MAX_INPUT_SIZE = 8192; + private static final int OPUS_MAX_INPUT_SIZE = 5760; + private static final int ENCRYPTION_IV_SIZE = 8; + + private static final int ID_EBML = 0x1A45DFA3; + private static final int ID_EBML_READ_VERSION = 0x42F7; + private static final int ID_DOC_TYPE = 0x4282; + private static final int ID_DOC_TYPE_READ_VERSION = 0x4285; + private static final int ID_SEGMENT = 0x18538067; + private static final int ID_SEGMENT_INFO = 0x1549A966; + private static final int ID_SEEK_HEAD = 0x114D9B74; + private static final int ID_SEEK = 0x4DBB; + private static final int ID_SEEK_ID = 0x53AB; + private static final int ID_SEEK_POSITION = 0x53AC; + private static final int ID_INFO = 0x1549A966; + private static final int ID_TIMECODE_SCALE = 0x2AD7B1; + private static final int ID_DURATION = 0x4489; + private static final int ID_CLUSTER = 0x1F43B675; + private static final int ID_TIME_CODE = 0xE7; + private static final int ID_SIMPLE_BLOCK = 0xA3; + private static final int ID_BLOCK_GROUP = 0xA0; + private static final int ID_BLOCK = 0xA1; + private static final int ID_BLOCK_DURATION = 0x9B; + private static final int ID_BLOCK_ADDITIONS = 0x75A1; + private static final int ID_BLOCK_MORE = 0xA6; + private static final int ID_BLOCK_ADD_ID = 0xEE; + private static final int ID_BLOCK_ADDITIONAL = 0xA5; + private static final int ID_REFERENCE_BLOCK = 0xFB; + private static final int ID_TRACKS = 0x1654AE6B; + private static final int ID_TRACK_ENTRY = 0xAE; + private static final int ID_TRACK_NUMBER = 0xD7; + private static final int ID_TRACK_TYPE = 0x83; + private static final int ID_FLAG_DEFAULT = 0x88; + private static final int ID_FLAG_FORCED = 0x55AA; + private static final int ID_DEFAULT_DURATION = 0x23E383; + private static final int ID_MAX_BLOCK_ADDITION_ID = 0x55EE; + private static final int ID_BLOCK_ADDITION_MAPPING = 0x41E4; + private static final int ID_BLOCK_ADD_ID_TYPE = 0x41E7; + private static final int ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED; + private static final int ID_NAME = 0x536E; + private static final int ID_CODEC_ID = 0x86; + private static final int ID_CODEC_PRIVATE = 0x63A2; + private static final int ID_CODEC_DELAY = 0x56AA; + private static final int ID_SEEK_PRE_ROLL = 0x56BB; + private static final int ID_DISCARD_PADDING = 0x75A2; + private static final int ID_VIDEO = 0xE0; + private static final int ID_PIXEL_WIDTH = 0xB0; + private static final int ID_PIXEL_HEIGHT = 0xBA; + private static final int ID_DISPLAY_WIDTH = 0x54B0; + private static final int ID_DISPLAY_HEIGHT = 0x54BA; + private static final int ID_DISPLAY_UNIT = 0x54B2; + private static final int ID_AUDIO = 0xE1; + private static final int ID_CHANNELS = 0x9F; + private static final int ID_AUDIO_BIT_DEPTH = 0x6264; + private static final int ID_SAMPLING_FREQUENCY = 0xB5; + private static final int ID_CONTENT_ENCODINGS = 0x6D80; + private static final int ID_CONTENT_ENCODING = 0x6240; + private static final int ID_CONTENT_ENCODING_ORDER = 0x5031; + private static final int ID_CONTENT_ENCODING_SCOPE = 0x5032; + private static final int ID_CONTENT_COMPRESSION = 0x5034; + private static final int ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254; + private static final int ID_CONTENT_COMPRESSION_SETTINGS = 0x4255; + private static final int ID_CONTENT_ENCRYPTION = 0x5035; + private static final int ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1; + private static final int ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8; + private static final int ID_CUES = 0x1C53BB6B; + private static final int ID_CUE_POINT = 0xBB; + private static final int ID_CUE_TIME = 0xB3; + private static final int ID_CUE_TRACK = 0xF7; + private static final int ID_CUE_TRACK_POSITIONS = 0xB7; + private static final int ID_CUE_CLUSTER_POSITION = 0xF1; + private static final int ID_CUE_RELATIVE_POSITION = 0xF0; + private static final int ID_LANGUAGE = 0x22B59C; + private static final int ID_PROJECTION = 0x7670; + private static final int ID_PROJECTION_TYPE = 0x7671; + private static final int ID_PROJECTION_PRIVATE = 0x7672; + private static final int ID_PROJECTION_POSE_YAW = 0x7673; + private static final int ID_PROJECTION_POSE_PITCH = 0x7674; + private static final int ID_PROJECTION_POSE_ROLL = 0x7675; + private static final int ID_STEREO_MODE = 0x53B8; + private static final int ID_COLOUR = 0x55B0; + private static final int ID_COLOUR_RANGE = 0x55B9; + private static final int ID_COLOUR_BITS_PER_CHANNEL = 0x55B2; + private static final int ID_COLOUR_TRANSFER = 0x55BA; + private static final int ID_COLOUR_PRIMARIES = 0x55BB; + private static final int ID_MAX_CLL = 0x55BC; + private static final int ID_MAX_FALL = 0x55BD; + private static final int ID_MASTERING_METADATA = 0x55D0; + private static final int ID_PRIMARY_R_CHROMATICITY_X = 0x55D1; + private static final int ID_PRIMARY_R_CHROMATICITY_Y = 0x55D2; + private static final int ID_PRIMARY_G_CHROMATICITY_X = 0x55D3; + private static final int ID_PRIMARY_G_CHROMATICITY_Y = 0x55D4; + private static final int ID_PRIMARY_B_CHROMATICITY_X = 0x55D5; + private static final int ID_PRIMARY_B_CHROMATICITY_Y = 0x55D6; + private static final int ID_WHITE_POINT_CHROMATICITY_X = 0x55D7; + private static final int ID_WHITE_POINT_CHROMATICITY_Y = 0x55D8; + private static final int ID_LUMNINANCE_MAX = 0x55D9; + private static final int ID_LUMNINANCE_MIN = 0x55DA; + + /** + * BlockAddID value for ITU T.35 metadata in a VP9 track. See also + * https://www.webmproject.org/docs/container/. + */ + private static final int BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4; + + /** + * BlockAddIdType value for Dolby Vision configuration with profile <= 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private static final int BLOCK_ADD_ID_TYPE_DVCC = 0x64766343; + + /** + * BlockAddIdType value for Dolby Vision configuration with profile > 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private static final int BLOCK_ADD_ID_TYPE_DVVC = 0x64767643; + + private static final int LACING_NONE = 0; + private static final int LACING_XIPH = 1; + private static final int LACING_FIXED_SIZE = 2; + private static final int LACING_EBML = 3; + + private static final int FOURCC_COMPRESSION_DIVX = 0x58564944; + private static final int FOURCC_COMPRESSION_H263 = 0x33363248; + private static final int FOURCC_COMPRESSION_VC1 = 0x31435657; + + /** The maximum number of chunks to scan when searching for a thumbnail. */ + private static final int MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20; + + /** The maximum duration to scan for a thumbnail, in microseconds. */ + private static final long MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L; + + /** + * A template for the prefix that must be added to each subrip sample. + * + *

The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + *

Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". + */ + private static final byte[] SUBRIP_PREFIX = + new byte[] { + 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, + 48, 58, 48, 48, 44, 48, 48, 48, 10 + }; + + /** The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. */ + private static final int SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19; + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a subrip timecode (milliseconds). + */ + private static final long SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; + + /** The format of a subrip timecode. */ + private static final String SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d"; + + /** Matroska specific format line for SSA subtitles. */ + private static final byte[] SSA_DIALOGUE_FORMAT = + Util.getUtf8Bytes( + "Format: Start, End, " + + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); + + /** + * A template for the prefix that must be added to each SSA sample. + * + *

The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + *

Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". + */ + private static final byte[] SSA_PREFIX = + new byte[] { + 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 + }; + + /** The byte offset of the end timecode in {@link #SSA_PREFIX}. */ + private static final int SSA_PREFIX_END_TIMECODE_OFFSET = 21; + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in an SSA timecode (1/100ths of a second). + */ + private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10_000; + + /** The format of an SSA timecode. */ + private static final String SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d"; + + /** + * A template for the prefix that must be added to each VTT sample. + * + *

The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #VTT_PREFIX_END_TIMECODE_OFFSET} is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + *

Equivalent to the UTF-8 string: "WEBVTT\n\n00:00:00.000 --> 00:00:00.000\n". + */ + private static final byte[] VTT_PREFIX = + new byte[] { + 87, 69, 66, 86, 84, 84, 10, 10, 48, 48, 58, 48, 48, 58, 48, 48, 46, 48, 48, 48, 32, 45, 45, + 62, 32, 48, 48, 58, 48, 48, 58, 48, 48, 46, 48, 48, 48, 10 + }; + + /** The byte offset of the end timecode in {@link #VTT_PREFIX}. */ + private static final int VTT_PREFIX_END_TIMECODE_OFFSET = 25; + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a VTT timecode (milliseconds). + */ + private static final long VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; + + /** The format of a VTT timecode. */ + private static final String VTT_TIMECODE_FORMAT = "%02d:%02d:%02d.%03d"; + + /** The length in bytes of a WAVEFORMATEX structure. */ + private static final int WAVE_FORMAT_SIZE = 18; + + /** Format tag indicating a WAVEFORMATEXTENSIBLE structure. */ + private static final int WAVE_FORMAT_EXTENSIBLE = 0xFFFE; + + /** Format tag for PCM. */ + private static final int WAVE_FORMAT_PCM = 1; + + /** Sub format for PCM. */ + private static final UUID WAVE_SUBFORMAT_PCM = new UUID(0x0100000000001000L, 0x800000AA00389B71L); + + /** Some HTC devices signal rotation in track names. */ + private static final Map TRACK_NAME_TO_ROTATION_DEGREES; + + static { + Map trackNameToRotationDegrees = new HashMap<>(); + trackNameToRotationDegrees.put("htc_video_rotA-000", 0); + trackNameToRotationDegrees.put("htc_video_rotA-090", 90); + trackNameToRotationDegrees.put("htc_video_rotA-180", 180); + trackNameToRotationDegrees.put("htc_video_rotA-270", 270); + TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees); + } + + private final EbmlReader reader; + private final VarintReader varintReader; + private final SparseArray tracks; + private final boolean seekForCuesEnabled; + private final boolean parseSubtitlesDuringExtraction; + private final SubtitleParser.Factory subtitleParserFactory; + @Nullable private final DolbyVisionSampleTransformer dolbyVisionSampleTransformer; + + /** Returns the Dolby Vision sample transformer, if one was provided at construction time. */ + @Nullable + public DolbyVisionSampleTransformer getDolbyVisionSampleTransformer() { + return dolbyVisionSampleTransformer; + } + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalLength; + private final ParsableByteArray scratch; + private final ParsableByteArray vorbisNumPageSamples; + private final ParsableByteArray seekEntryIdBytes; + private final ParsableByteArray sampleStrippedBytes; + private final ParsableByteArray subtitleSample; + private final ParsableByteArray encryptionInitializationVector; + private final ParsableByteArray encryptionSubsampleData; + private final ParsableByteArray supplementalData; + private @MonotonicNonNull ByteBuffer encryptionSubsampleDataBuffer; + + // Reused per-sample read buffer for Dolby Vision conversion; grows to the largest sample. + private byte[] dolbyVisionSampleBuffer = new byte[0]; + + private long segmentContentSize; + private long segmentContentPosition = C.INDEX_UNSET; + private long timecodeScale = C.TIME_UNSET; + private long durationTimecode = C.TIME_UNSET; + private long durationUs = C.TIME_UNSET; + private boolean isWebm; + private boolean pendingEndTracks; + + // The track corresponding to the current TrackEntry element, or null. + @Nullable private Track currentTrack; + + // Whether a seek map has been sent to the output. + private boolean sentSeekMap; + + // Master seek entry related elements. + private int seekEntryId; + private long seekEntryPosition; + + // Cue related elements. + private final SparseArray> perTrackCues; + private boolean inCuesElement; + private long currentCueTimeUs = C.TIME_UNSET; + private int currentCueTrackNumber = C.INDEX_UNSET; + private long currentCueClusterPosition = C.INDEX_UNSET; + private long currentCueRelativePosition = C.INDEX_UNSET; + private int primarySeekTrackNumber = C.INDEX_UNSET; + private boolean seekForCues; + private long cuesContentPosition = C.INDEX_UNSET; + private long seekPositionAfterBuildingCues = C.INDEX_UNSET; + private long clusterTimecodeUs = C.TIME_UNSET; + + // Reading state. + private boolean haveOutputSample; + + // Block reading state. + private int blockState; + private long blockTimeUs; + private long blockDurationUs; + private int blockSampleIndex; + private int blockSampleCount; + private int[] blockSampleSizes; + private int blockTrackNumber; + private int blockTrackNumberLength; + private @C.BufferFlags int blockFlags; + private int blockAdditionalId; + private boolean blockHasReferenceBlock; + private long blockGroupDiscardPaddingNs; + + // Sample writing state. + private int sampleBytesRead; + private int sampleBytesWritten; + private int sampleCurrentNalBytesRemaining; + private boolean sampleEncodingHandled; + private boolean sampleSignalByteRead; + private boolean samplePartitionCountRead; + private int samplePartitionCount; + private byte sampleSignalByte; + private boolean sampleInitializationVectorRead; + + // Extractor outputs. + private @MonotonicNonNull ExtractorOutput extractorOutput; + + /** + * @deprecated Use {@link #MatroskaExtractor(SubtitleParser.Factory)} instead. + */ + @Deprecated + public MatroskaExtractor() { + this( + new DefaultEbmlReader(), + FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + /* dolbyVisionSampleTransformer= */ null); + } + + /** + * @deprecated Use {@link #MatroskaExtractor(SubtitleParser.Factory, int)} instead. + */ + @Deprecated + public MatroskaExtractor(@Flags int flags) { + this( + new DefaultEbmlReader(), + flags | FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + /* dolbyVisionSampleTransformer= */ null); + } + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + */ + public MatroskaExtractor(SubtitleParser.Factory subtitleParserFactory) { + this( + new DefaultEbmlReader(), + /* flags= */ 0, + subtitleParserFactory, + /* dolbyVisionSampleTransformer= */ null); + } + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + * @param flags Flags that control the extractor's behavior. + */ + public MatroskaExtractor(SubtitleParser.Factory subtitleParserFactory, @Flags int flags) { + this( + new DefaultEbmlReader(), + flags, + subtitleParserFactory, + /* dolbyVisionSampleTransformer= */ null); + } + + /** + * Constructs an instance with an optional Dolby Vision sample hook. + * + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + * @param flags Flags that control the extractor's behavior. + * @param dolbyVisionSampleTransformer Optional hook for DV sample processing. + */ + public MatroskaExtractor( + SubtitleParser.Factory subtitleParserFactory, + @Flags int flags, + @Nullable DolbyVisionSampleTransformer dolbyVisionSampleTransformer) { + this(new DefaultEbmlReader(), flags, subtitleParserFactory, dolbyVisionSampleTransformer); + } + + /* package */ MatroskaExtractor( + EbmlReader reader, @Flags int flags, SubtitleParser.Factory subtitleParserFactory) { + this( + reader, + flags, + subtitleParserFactory, + /* dolbyVisionSampleTransformer= */ null); + } + + /* package */ MatroskaExtractor( + EbmlReader reader, + @Flags int flags, + SubtitleParser.Factory subtitleParserFactory, + @Nullable DolbyVisionSampleTransformer dolbyVisionSampleTransformer) { + this.reader = reader; + this.reader.init(new InnerEbmlProcessor()); + this.subtitleParserFactory = subtitleParserFactory; + this.dolbyVisionSampleTransformer = dolbyVisionSampleTransformer; + this.perTrackCues = new SparseArray<>(); + seekForCuesEnabled = (flags & FLAG_DISABLE_SEEK_FOR_CUES) == 0; + parseSubtitlesDuringExtraction = (flags & FLAG_EMIT_RAW_SUBTITLE_DATA) == 0; + varintReader = new VarintReader(); + tracks = new SparseArray<>(); + scratch = new ParsableByteArray(4); + vorbisNumPageSamples = new ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()); + seekEntryIdBytes = new ParsableByteArray(4); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalLength = new ParsableByteArray(4); + sampleStrippedBytes = new ParsableByteArray(); + subtitleSample = new ParsableByteArray(); + encryptionInitializationVector = new ParsableByteArray(ENCRYPTION_IV_SIZE); + encryptionSubsampleData = new ParsableByteArray(); + supplementalData = new ParsableByteArray(); + blockSampleSizes = new int[1]; + pendingEndTracks = true; + } + + @Override + public final boolean sniff(ExtractorInput input) throws IOException { + return new Sniffer().sniff(input); + } + + @Override + public final void init(ExtractorOutput output) { + extractorOutput = + parseSubtitlesDuringExtraction + ? new SubtitleTranscodingExtractorOutput(output, subtitleParserFactory) + : output; + } + + @CallSuper + @Override + public void seek(long position, long timeUs) { + clusterTimecodeUs = C.TIME_UNSET; + blockState = BLOCK_STATE_START; + reader.reset(); + varintReader.reset(); + resetWriteSampleData(); + inCuesElement = false; + currentCueTimeUs = C.TIME_UNSET; + currentCueTrackNumber = C.INDEX_UNSET; + currentCueClusterPosition = C.INDEX_UNSET; + currentCueRelativePosition = C.INDEX_UNSET; + // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the + // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent + // Cues elements will be ignored by the parsing logic. + if (!sentSeekMap) { + perTrackCues.clear(); + } + for (int i = 0; i < tracks.size(); i++) { + tracks.valueAt(i).reset(); + } + } + + @Override + public final void release() { + // Do nothing + } + + @Override + public final int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { + haveOutputSample = false; + boolean continueReading = true; + while (continueReading && !haveOutputSample) { + continueReading = reader.read(input); + if (continueReading && maybeSeekForCues(seekPosition, input.getPosition())) { + return Extractor.RESULT_SEEK; + } + } + if (!continueReading) { + for (int i = 0; i < tracks.size(); i++) { + Track track = tracks.valueAt(i); + track.assertOutputInitialized(); + track.outputPendingSampleMetadata(); + } + return Extractor.RESULT_END_OF_INPUT; + } + return Extractor.RESULT_CONTINUE; + } + + /** + * Maps an element ID to a corresponding type. + * + * @see EbmlProcessor#getElementType(int) + */ + @CallSuper + protected @EbmlProcessor.ElementType int getElementType(int id) { + switch (id) { + case ID_EBML: + case ID_SEGMENT: + case ID_SEEK_HEAD: + case ID_SEEK: + case ID_INFO: + case ID_CLUSTER: + case ID_TRACKS: + case ID_TRACK_ENTRY: + case ID_BLOCK_ADDITION_MAPPING: + case ID_AUDIO: + case ID_VIDEO: + case ID_CONTENT_ENCODINGS: + case ID_CONTENT_ENCODING: + case ID_CONTENT_COMPRESSION: + case ID_CONTENT_ENCRYPTION: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS: + case ID_CUES: + case ID_CUE_POINT: + case ID_CUE_TRACK_POSITIONS: + case ID_BLOCK_GROUP: + case ID_BLOCK_ADDITIONS: + case ID_BLOCK_MORE: + case ID_PROJECTION: + case ID_COLOUR: + case ID_MASTERING_METADATA: + return EbmlProcessor.ELEMENT_TYPE_MASTER; + case ID_EBML_READ_VERSION: + case ID_DOC_TYPE_READ_VERSION: + case ID_SEEK_POSITION: + case ID_TIMECODE_SCALE: + case ID_TIME_CODE: + case ID_BLOCK_DURATION: + case ID_PIXEL_WIDTH: + case ID_PIXEL_HEIGHT: + case ID_DISPLAY_WIDTH: + case ID_DISPLAY_HEIGHT: + case ID_DISPLAY_UNIT: + case ID_TRACK_NUMBER: + case ID_TRACK_TYPE: + case ID_FLAG_DEFAULT: + case ID_FLAG_FORCED: + case ID_DEFAULT_DURATION: + case ID_MAX_BLOCK_ADDITION_ID: + case ID_BLOCK_ADD_ID_TYPE: + case ID_CODEC_DELAY: + case ID_SEEK_PRE_ROLL: + case ID_DISCARD_PADDING: + case ID_CHANNELS: + case ID_AUDIO_BIT_DEPTH: + case ID_CONTENT_ENCODING_ORDER: + case ID_CONTENT_ENCODING_SCOPE: + case ID_CONTENT_COMPRESSION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + case ID_CUE_TIME: + case ID_CUE_CLUSTER_POSITION: + case ID_CUE_RELATIVE_POSITION: + case ID_CUE_TRACK: + case ID_REFERENCE_BLOCK: + case ID_STEREO_MODE: + case ID_COLOUR_BITS_PER_CHANNEL: + case ID_COLOUR_RANGE: + case ID_COLOUR_TRANSFER: + case ID_COLOUR_PRIMARIES: + case ID_MAX_CLL: + case ID_MAX_FALL: + case ID_PROJECTION_TYPE: + case ID_BLOCK_ADD_ID: + return EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT; + case ID_DOC_TYPE: + case ID_NAME: + case ID_CODEC_ID: + case ID_LANGUAGE: + return EbmlProcessor.ELEMENT_TYPE_STRING; + case ID_SEEK_ID: + case ID_BLOCK_ADD_ID_EXTRA_DATA: + case ID_CONTENT_COMPRESSION_SETTINGS: + case ID_CONTENT_ENCRYPTION_KEY_ID: + case ID_SIMPLE_BLOCK: + case ID_BLOCK: + case ID_CODEC_PRIVATE: + case ID_PROJECTION_PRIVATE: + case ID_BLOCK_ADDITIONAL: + return EbmlProcessor.ELEMENT_TYPE_BINARY; + case ID_DURATION: + case ID_SAMPLING_FREQUENCY: + case ID_PRIMARY_R_CHROMATICITY_X: + case ID_PRIMARY_R_CHROMATICITY_Y: + case ID_PRIMARY_G_CHROMATICITY_X: + case ID_PRIMARY_G_CHROMATICITY_Y: + case ID_PRIMARY_B_CHROMATICITY_X: + case ID_PRIMARY_B_CHROMATICITY_Y: + case ID_WHITE_POINT_CHROMATICITY_X: + case ID_WHITE_POINT_CHROMATICITY_Y: + case ID_LUMNINANCE_MAX: + case ID_LUMNINANCE_MIN: + case ID_PROJECTION_POSE_YAW: + case ID_PROJECTION_POSE_PITCH: + case ID_PROJECTION_POSE_ROLL: + return EbmlProcessor.ELEMENT_TYPE_FLOAT; + default: + return EbmlProcessor.ELEMENT_TYPE_UNKNOWN; + } + } + + /** + * Checks if the given id is that of a level 1 element. + * + * @see EbmlProcessor#isLevel1Element(int) + */ + @CallSuper + protected boolean isLevel1Element(int id) { + return id == ID_SEGMENT_INFO || id == ID_CLUSTER || id == ID_CUES || id == ID_TRACKS; + } + + /** + * Called when the start of a master element is encountered. + * + * @see EbmlProcessor#startMasterElement(int, long, long) + */ + @CallSuper + protected void startMasterElement(int id, long contentPosition, long contentSize) + throws ParserException { + assertInitialized(); + switch (id) { + case ID_SEGMENT: + if (segmentContentPosition != C.INDEX_UNSET && segmentContentPosition != contentPosition) { + throw ParserException.createForMalformedContainer( + "Multiple Segment elements not supported", /* cause= */ null); + } + segmentContentPosition = contentPosition; + segmentContentSize = contentSize; + break; + case ID_SEEK: + seekEntryId = UNSET_ENTRY_ID; + seekEntryPosition = C.INDEX_UNSET; + break; + case ID_CUES: + if (!sentSeekMap) { + inCuesElement = true; + } + break; + case ID_CUE_POINT: + if (!sentSeekMap) { + assertInCues(id); + currentCueTimeUs = C.TIME_UNSET; + } + break; + case ID_CUE_TRACK_POSITIONS: + if (!sentSeekMap) { + assertInCues(id); + currentCueTrackNumber = C.INDEX_UNSET; + currentCueClusterPosition = C.INDEX_UNSET; + currentCueRelativePosition = C.INDEX_UNSET; + } + break; + case ID_CLUSTER: + if (!sentSeekMap) { + // We need to build cues before parsing the cluster. + if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET) { + // We know where the Cues element is located. Seek to request it. + seekForCues = true; + } else { + // We don't know where the Cues element is located. It's most likely omitted. Allow + // playback, but disable seeking. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); + sentSeekMap = true; + } + } + break; + case ID_BLOCK_GROUP: + blockHasReferenceBlock = false; + blockGroupDiscardPaddingNs = 0L; + break; + case ID_CONTENT_ENCODING: + // TODO: check and fail if more than one content encoding is present. + break; + case ID_CONTENT_ENCRYPTION: + getCurrentTrack(id).hasContentEncryption = true; + break; + case ID_TRACK_ENTRY: + currentTrack = new Track(); + currentTrack.isWebm = isWebm; + break; + case ID_MASTERING_METADATA: + getCurrentTrack(id).hasColorInfo = true; + break; + default: + break; + } + } + + /** + * Called when the end of a master element is encountered. + * + * @see EbmlProcessor#endMasterElement(int) + */ + @CallSuper + protected void endMasterElement(int id) throws ParserException { + assertInitialized(); + switch (id) { + case ID_SEGMENT_INFO: + if (timecodeScale == C.TIME_UNSET) { + // timecodeScale was omitted. Use the default value. + timecodeScale = 1000000; + } + if (durationTimecode != C.TIME_UNSET) { + durationUs = scaleTimecodeToUs(durationTimecode); + } + break; + case ID_SEEK: + if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.INDEX_UNSET) { + throw ParserException.createForMalformedContainer( + "Mandatory element SeekID or SeekPosition not found", /* cause= */ null); + } + if (seekEntryId == ID_CUES) { + cuesContentPosition = seekEntryPosition; + } + break; + case ID_CUES: + if (!sentSeekMap) { + boolean hasAnyCues = false; + for (int i = 0; i < perTrackCues.size(); i++) { + if (!perTrackCues.valueAt(i).isEmpty()) { + hasAnyCues = true; + break; + } + } + if (!hasAnyCues || durationUs == C.TIME_UNSET) { + // Cues are missing, empty, or duration is unknown. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); + } else { + for (int i = 0; i < perTrackCues.size(); i++) { + Collections.sort(perTrackCues.valueAt(i)); + } + MatroskaSeekMap seekMap = + new MatroskaSeekMap( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize); + extractorOutput.seekMap(seekMap); + } + sentSeekMap = true; + inCuesElement = false; + for (int i = 0; i < tracks.size(); i++) { + Track track = tracks.valueAt(i); + track.maybeAddThumbnailMetadata( + perTrackCues, durationUs, segmentContentPosition, segmentContentSize); + if (!track.waitingForDtsAnalysis) { + track.assertOutputInitialized(); + track.output.format(checkNotNull(track.format)); + } + } + maybeEndTracks(); + } + break; + case ID_CUE_TRACK_POSITIONS: + if (!sentSeekMap) { + assertInCues(id); + if (currentCueTimeUs != C.TIME_UNSET + && currentCueTrackNumber != C.INDEX_UNSET + && currentCueClusterPosition != C.INDEX_UNSET) { + List trackCues = perTrackCues.get(currentCueTrackNumber); + if (trackCues == null) { + trackCues = new ArrayList<>(); + perTrackCues.put(currentCueTrackNumber, trackCues); + } + trackCues.add( + new MatroskaSeekMap.CuePointData( + currentCueTimeUs, + /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, + /* relativePosition= */ currentCueRelativePosition)); + } + } + break; + case ID_BLOCK_GROUP: + if (blockState != BLOCK_STATE_DATA) { + // We've skipped this block (due to incompatible track number). + return; + } + Track track = tracks.get(blockTrackNumber); + track.assertOutputInitialized(); + if (blockGroupDiscardPaddingNs > 0L && CODEC_ID_OPUS.equals(track.codecId)) { + // For Opus, attach DiscardPadding to the block group samples as supplemental data. + supplementalData.reset( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(blockGroupDiscardPaddingNs) + .array()); + } + + // Commit sample metadata. + int sampleOffset = 0; + for (int i = 0; i < blockSampleCount; i++) { + sampleOffset += blockSampleSizes[i]; + } + for (int i = 0; i < blockSampleCount; i++) { + long sampleTimeUs = blockTimeUs + (i * track.defaultSampleDurationNs) / 1000; + int sampleFlags = blockFlags; + if (i == 0 && !blockHasReferenceBlock) { + // If the ReferenceBlock element was not found in this block, then the first frame is a + // keyframe. + sampleFlags |= C.BUFFER_FLAG_KEY_FRAME; + } + int sampleSize = blockSampleSizes[i]; + sampleOffset -= sampleSize; // The offset is to the end of the sample. + commitSampleToOutput(track, sampleTimeUs, sampleFlags, sampleSize, sampleOffset); + } + blockState = BLOCK_STATE_START; + break; + case ID_CONTENT_ENCODING: + assertInTrackEntry(id); + if (currentTrack.hasContentEncryption) { + if (currentTrack.cryptoData == null) { + throw ParserException.createForMalformedContainer( + "Encrypted Track found but ContentEncKeyID was not found", /* cause= */ null); + } + currentTrack.drmInitData = + new DrmInitData( + new SchemeData( + C.UUID_NIL, MimeTypes.VIDEO_WEBM, currentTrack.cryptoData.encryptionKey)); + } + break; + case ID_CONTENT_ENCODINGS: + assertInTrackEntry(id); + if (currentTrack.hasContentEncryption && currentTrack.sampleStrippedBytes != null) { + throw ParserException.createForMalformedContainer( + "Combining encryption and compression is not supported", /* cause= */ null); + } + break; + case ID_TRACK_ENTRY: + Track currentTrack = checkNotNull(this.currentTrack); + if (currentTrack.codecId == null) { + throw ParserException.createForMalformedContainer( + "CodecId is missing in TrackEntry element", /* cause= */ null); + } else { + if (isCodecSupported(currentTrack.codecId)) { + currentTrack.initializeFormat(currentTrack.number, dolbyVisionSampleTransformer); + currentTrack.output = extractorOutput.track(currentTrack.number, currentTrack.type); + tracks.put(currentTrack.number, currentTrack); + } + } + this.currentTrack = null; + break; + case ID_TRACKS: + if (tracks.size() == 0) { + throw ParserException.createForMalformedContainer( + "No valid tracks were found", /* cause= */ null); + } + + // Determine the track to use for default seeking. + int defaultVideoTrackNumber = C.INDEX_UNSET; + int firstVideoTrackNumber = C.INDEX_UNSET; + int defaultAudioTrackNumber = C.INDEX_UNSET; + int firstAudioTrackNumber = C.INDEX_UNSET; + + // If we're not going to seek for cues, output the formats immediately. + boolean mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET; + + for (int i = 0; i < tracks.size(); i++) { + Track trackItem = tracks.valueAt(i); + + @C.TrackType int trackType = trackItem.type; + if (trackType == C.TRACK_TYPE_VIDEO) { + if (trackItem.flagDefault) { + defaultVideoTrackNumber = trackItem.number; + } + if (firstVideoTrackNumber == C.INDEX_UNSET) { + firstVideoTrackNumber = trackItem.number; + } + } else if (trackType == C.TRACK_TYPE_AUDIO) { + if (trackItem.flagDefault) { + defaultAudioTrackNumber = trackItem.number; + } + if (firstAudioTrackNumber == C.INDEX_UNSET) { + firstAudioTrackNumber = trackItem.number; + } + } + + if (mayBeSendFormatsEarly) { + trackItem.assertOutputInitialized(); + if (!trackItem.waitingForDtsAnalysis) { + trackItem.output.format(checkNotNull(trackItem.format)); + } + } + } + + if (defaultVideoTrackNumber != C.INDEX_UNSET) { + primarySeekTrackNumber = defaultVideoTrackNumber; + } else if (firstVideoTrackNumber != C.INDEX_UNSET) { + primarySeekTrackNumber = firstVideoTrackNumber; + } else if (defaultAudioTrackNumber != C.INDEX_UNSET) { + primarySeekTrackNumber = defaultAudioTrackNumber; + } else if (firstAudioTrackNumber != C.INDEX_UNSET) { + primarySeekTrackNumber = firstAudioTrackNumber; + } else { + primarySeekTrackNumber = tracks.size() > 0 ? tracks.valueAt(0).number : C.INDEX_UNSET; + } + + if (mayBeSendFormatsEarly) { + maybeEndTracks(); + } + break; + default: + break; + } + } + + /** + * Called when an integer element is encountered. + * + * @see EbmlProcessor#integerElement(int, long) + */ + @CallSuper + protected void integerElement(int id, long value) throws ParserException { + switch (id) { + case ID_EBML_READ_VERSION: + // Validate that EBMLReadVersion is supported. This extractor only supports v1. + if (value != 1) { + throw ParserException.createForMalformedContainer( + "EBMLReadVersion " + value + " not supported", /* cause= */ null); + } + break; + case ID_DOC_TYPE_READ_VERSION: + // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. + if (value < 1 || value > 2) { + throw ParserException.createForMalformedContainer( + "DocTypeReadVersion " + value + " not supported", /* cause= */ null); + } + break; + case ID_SEEK_POSITION: + // Seek Position is the relative offset beginning from the Segment. So to get absolute + // offset from the beginning of the file, we need to add segmentContentPosition to it. + seekEntryPosition = value + segmentContentPosition; + break; + case ID_TIMECODE_SCALE: + timecodeScale = value; + break; + case ID_PIXEL_WIDTH: + getCurrentTrack(id).width = (int) value; + break; + case ID_PIXEL_HEIGHT: + getCurrentTrack(id).height = (int) value; + break; + case ID_DISPLAY_WIDTH: + getCurrentTrack(id).displayWidth = (int) value; + break; + case ID_DISPLAY_HEIGHT: + getCurrentTrack(id).displayHeight = (int) value; + break; + case ID_DISPLAY_UNIT: + getCurrentTrack(id).displayUnit = (int) value; + break; + case ID_TRACK_NUMBER: + getCurrentTrack(id).number = (int) value; + break; + case ID_FLAG_DEFAULT: + getCurrentTrack(id).flagDefault = value == 1; + break; + case ID_FLAG_FORCED: + getCurrentTrack(id).flagForced = value == 1; + break; + case ID_TRACK_TYPE: + int matroskaTrackType = (int) value; + switch (matroskaTrackType) { + case 1: // Matroska video + getCurrentTrack(id).type = C.TRACK_TYPE_VIDEO; + break; + case 2: // Matroska audio + getCurrentTrack(id).type = C.TRACK_TYPE_AUDIO; + break; + case 17: // Matroska subtitle + getCurrentTrack(id).type = C.TRACK_TYPE_TEXT; + break; + case 33: // Matroska metadata + getCurrentTrack(id).type = C.TRACK_TYPE_METADATA; + break; + default: + getCurrentTrack(id).type = C.TRACK_TYPE_UNKNOWN; + break; + } + break; + case ID_DEFAULT_DURATION: + getCurrentTrack(id).defaultSampleDurationNs = (int) value; + break; + case ID_MAX_BLOCK_ADDITION_ID: + getCurrentTrack(id).maxBlockAdditionId = (int) value; + break; + case ID_BLOCK_ADD_ID_TYPE: + getCurrentTrack(id).blockAddIdType = (int) value; + break; + case ID_CODEC_DELAY: + getCurrentTrack(id).codecDelayNs = value; + break; + case ID_SEEK_PRE_ROLL: + getCurrentTrack(id).seekPreRollNs = value; + break; + case ID_DISCARD_PADDING: + blockGroupDiscardPaddingNs = value; + break; + case ID_CHANNELS: + getCurrentTrack(id).channelCount = (int) value; + break; + case ID_AUDIO_BIT_DEPTH: + getCurrentTrack(id).audioBitDepth = (int) value; + break; + case ID_REFERENCE_BLOCK: + blockHasReferenceBlock = true; + break; + case ID_CONTENT_ENCODING_ORDER: + // This extractor only supports one ContentEncoding element and hence the order has to be 0. + if (value != 0) { + throw ParserException.createForMalformedContainer( + "ContentEncodingOrder " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_ENCODING_SCOPE: + // This extractor only supports the scope of all frames. + if (value != 1) { + throw ParserException.createForMalformedContainer( + "ContentEncodingScope " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_COMPRESSION_ALGORITHM: + // This extractor only supports header stripping. + if (value != 3) { + throw ParserException.createForMalformedContainer( + "ContentCompAlgo " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_ENCRYPTION_ALGORITHM: + // Only the value 5 (AES) is allowed according to the WebM specification. + if (value != 5) { + throw ParserException.createForMalformedContainer( + "ContentEncAlgo " + value + " not supported", /* cause= */ null); + } + break; + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + // Only the value 1 is allowed according to the WebM specification. + if (value != 1) { + throw ParserException.createForMalformedContainer( + "AESSettingsCipherMode " + value + " not supported", /* cause= */ null); + } + break; + case ID_CUE_TIME: + if (!sentSeekMap) { + assertInCues(id); + currentCueTimeUs = scaleTimecodeToUs(value); + } + break; + case ID_CUE_TRACK: + if (!sentSeekMap) { + assertInCues(id); + currentCueTrackNumber = (int) value; + } + break; + case ID_CUE_CLUSTER_POSITION: + if (!sentSeekMap) { + assertInCues(id); + if (currentCueClusterPosition == C.INDEX_UNSET) { + currentCueClusterPosition = value; + } + } + break; + case ID_CUE_RELATIVE_POSITION: + if (!sentSeekMap) { + assertInCues(id); + if (currentCueRelativePosition == C.INDEX_UNSET) { + currentCueRelativePosition = value; + } + } + break; + case ID_TIME_CODE: + clusterTimecodeUs = scaleTimecodeToUs(value); + break; + case ID_BLOCK_DURATION: + blockDurationUs = scaleTimecodeToUs(value); + break; + case ID_STEREO_MODE: + int layout = (int) value; + assertInTrackEntry(id); + switch (layout) { + case 0: + currentTrack.stereoMode = C.STEREO_MODE_MONO; + break; + case 1: + currentTrack.stereoMode = C.STEREO_MODE_LEFT_RIGHT; + break; + case 3: + currentTrack.stereoMode = C.STEREO_MODE_TOP_BOTTOM; + break; + case 15: + currentTrack.stereoMode = C.STEREO_MODE_STEREO_MESH; + break; + default: + break; + } + break; + case ID_COLOUR_PRIMARIES: + assertInTrackEntry(id); + currentTrack.hasColorInfo = true; + int colorSpace = ColorInfo.isoColorPrimariesToColorSpace((int) value); + if (colorSpace != Format.NO_VALUE) { + currentTrack.colorSpace = colorSpace; + } + break; + case ID_COLOUR_TRANSFER: + assertInTrackEntry(id); + int colorTransfer = ColorInfo.isoTransferCharacteristicsToColorTransfer((int) value); + if (colorTransfer != Format.NO_VALUE) { + currentTrack.colorTransfer = colorTransfer; + } + break; + case ID_COLOUR_BITS_PER_CHANNEL: + assertInTrackEntry(id); + currentTrack.hasColorInfo = true; + currentTrack.bitsPerChannel = (int) value; + break; + case ID_COLOUR_RANGE: + assertInTrackEntry(id); + switch ((int) value) { + case 1: // Broadcast range. + currentTrack.colorRange = C.COLOR_RANGE_LIMITED; + break; + case 2: + currentTrack.colorRange = C.COLOR_RANGE_FULL; + break; + default: + break; + } + break; + case ID_MAX_CLL: + getCurrentTrack(id).maxContentLuminance = (int) value; + break; + case ID_MAX_FALL: + getCurrentTrack(id).maxFrameAverageLuminance = (int) value; + break; + case ID_PROJECTION_TYPE: + assertInTrackEntry(id); + switch ((int) value) { + case 0: + currentTrack.projectionType = C.PROJECTION_RECTANGULAR; + break; + case 1: + currentTrack.projectionType = C.PROJECTION_EQUIRECTANGULAR; + break; + case 2: + currentTrack.projectionType = C.PROJECTION_CUBEMAP; + break; + case 3: + currentTrack.projectionType = C.PROJECTION_MESH; + break; + default: + break; + } + break; + case ID_BLOCK_ADD_ID: + blockAdditionalId = (int) value; + break; + default: + break; + } + } + + /** + * Called when a float element is encountered. + * + * @see EbmlProcessor#floatElement(int, double) + */ + @CallSuper + protected void floatElement(int id, double value) throws ParserException { + switch (id) { + case ID_DURATION: + durationTimecode = (long) value; + break; + case ID_SAMPLING_FREQUENCY: + getCurrentTrack(id).sampleRate = (int) value; + break; + case ID_PRIMARY_R_CHROMATICITY_X: + getCurrentTrack(id).primaryRChromaticityX = (float) value; + break; + case ID_PRIMARY_R_CHROMATICITY_Y: + getCurrentTrack(id).primaryRChromaticityY = (float) value; + break; + case ID_PRIMARY_G_CHROMATICITY_X: + getCurrentTrack(id).primaryGChromaticityX = (float) value; + break; + case ID_PRIMARY_G_CHROMATICITY_Y: + getCurrentTrack(id).primaryGChromaticityY = (float) value; + break; + case ID_PRIMARY_B_CHROMATICITY_X: + getCurrentTrack(id).primaryBChromaticityX = (float) value; + break; + case ID_PRIMARY_B_CHROMATICITY_Y: + getCurrentTrack(id).primaryBChromaticityY = (float) value; + break; + case ID_WHITE_POINT_CHROMATICITY_X: + getCurrentTrack(id).whitePointChromaticityX = (float) value; + break; + case ID_WHITE_POINT_CHROMATICITY_Y: + getCurrentTrack(id).whitePointChromaticityY = (float) value; + break; + case ID_LUMNINANCE_MAX: + getCurrentTrack(id).maxMasteringLuminance = (float) value; + break; + case ID_LUMNINANCE_MIN: + getCurrentTrack(id).minMasteringLuminance = (float) value; + break; + case ID_PROJECTION_POSE_YAW: + getCurrentTrack(id).projectionPoseYaw = (float) value; + break; + case ID_PROJECTION_POSE_PITCH: + getCurrentTrack(id).projectionPosePitch = (float) value; + break; + case ID_PROJECTION_POSE_ROLL: + getCurrentTrack(id).projectionPoseRoll = (float) value; + break; + default: + break; + } + } + + /** + * Called when a string element is encountered. + * + * @see EbmlProcessor#stringElement(int, String) + */ + @CallSuper + protected void stringElement(int id, String value) throws ParserException { + switch (id) { + case ID_DOC_TYPE: + // Validate that DocType is supported. + if (!DOC_TYPE_WEBM.equals(value) && !DOC_TYPE_MATROSKA.equals(value)) { + throw ParserException.createForMalformedContainer( + "DocType " + value + " not supported", /* cause= */ null); + } + isWebm = Objects.equals(value, DOC_TYPE_WEBM); + break; + case ID_NAME: + getCurrentTrack(id).name = value; + break; + case ID_CODEC_ID: + getCurrentTrack(id).codecId = value; + break; + case ID_LANGUAGE: + getCurrentTrack(id).language = value; + break; + default: + break; + } + } + + /** + * Called when a binary element is encountered. + * + * @see EbmlProcessor#binaryElement(int, int, ExtractorInput) + */ + @CallSuper + protected void binaryElement(int id, int contentSize, ExtractorInput input) throws IOException { + switch (id) { + case ID_SEEK_ID: + Arrays.fill(seekEntryIdBytes.getData(), (byte) 0); + input.readFully(seekEntryIdBytes.getData(), 4 - contentSize, contentSize); + seekEntryIdBytes.setPosition(0); + seekEntryId = (int) seekEntryIdBytes.readUnsignedInt(); + break; + case ID_BLOCK_ADD_ID_EXTRA_DATA: + handleBlockAddIDExtraData(getCurrentTrack(id), input, contentSize); + break; + case ID_CODEC_PRIVATE: + assertInTrackEntry(id); + currentTrack.codecPrivate = new byte[contentSize]; + input.readFully(currentTrack.codecPrivate, 0, contentSize); + break; + case ID_PROJECTION_PRIVATE: + assertInTrackEntry(id); + currentTrack.projectionData = new byte[contentSize]; + input.readFully(currentTrack.projectionData, 0, contentSize); + break; + case ID_CONTENT_COMPRESSION_SETTINGS: + assertInTrackEntry(id); + // This extractor only supports header stripping, so the payload is the stripped bytes. + currentTrack.sampleStrippedBytes = new byte[contentSize]; + input.readFully(currentTrack.sampleStrippedBytes, 0, contentSize); + break; + case ID_CONTENT_ENCRYPTION_KEY_ID: + byte[] encryptionKey = new byte[contentSize]; + input.readFully(encryptionKey, 0, contentSize); + getCurrentTrack(id).cryptoData = + new TrackOutput.CryptoData( + C.CRYPTO_MODE_AES_CTR, encryptionKey, 0, 0); // We assume patternless AES-CTR. + break; + case ID_SIMPLE_BLOCK: + case ID_BLOCK: + // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure + // and http://matroska.org/technical/specs/index.html#block_structure + // for info about how data is organized in SimpleBlock and Block elements respectively. They + // differ only in the way flags are specified. + + if (blockState == BLOCK_STATE_START) { + blockTrackNumber = (int) varintReader.readUnsignedVarint(input, false, true, 8); + blockTrackNumberLength = varintReader.getLastLength(); + blockDurationUs = C.TIME_UNSET; + blockState = BLOCK_STATE_HEADER; + scratch.reset(/* limit= */ 0); + } + + Track track = tracks.get(blockTrackNumber); + + // Ignore the block if we don't know about the track to which it belongs. + if (track == null) { + input.skipFully(contentSize - blockTrackNumberLength); + blockState = BLOCK_STATE_START; + return; + } + + track.assertOutputInitialized(); + + if (blockState == BLOCK_STATE_HEADER) { + // Read the relative timecode (2 bytes) and flags (1 byte). + readScratch(input, 3); + int lacing = (scratch.getData()[2] & 0x06) >> 1; + if (lacing == LACING_NONE) { + blockSampleCount = 1; + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1); + blockSampleSizes[0] = contentSize - blockTrackNumberLength - 3; + } else { + // Read the sample count (1 byte). + readScratch(input, 4); + blockSampleCount = (scratch.getData()[3] & 0xFF) + 1; + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount); + if (lacing == LACING_FIXED_SIZE) { + int blockLacingSampleSize = + (contentSize - blockTrackNumberLength - 4) / blockSampleCount; + Arrays.fill(blockSampleSizes, 0, blockSampleCount, blockLacingSampleSize); + } else if (lacing == LACING_XIPH) { + int totalSamplesSize = 0; + int headerSize = 4; + for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { + blockSampleSizes[sampleIndex] = 0; + int byteValue; + do { + readScratch(input, ++headerSize); + byteValue = scratch.getData()[headerSize - 1] & 0xFF; + blockSampleSizes[sampleIndex] += byteValue; + } while (byteValue == 0xFF); + totalSamplesSize += blockSampleSizes[sampleIndex]; + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize; + } else if (lacing == LACING_EBML) { + int totalSamplesSize = 0; + int headerSize = 4; + for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { + blockSampleSizes[sampleIndex] = 0; + readScratch(input, ++headerSize); + if (scratch.getData()[headerSize - 1] == 0) { + throw ParserException.createForMalformedContainer( + "No valid varint length mask found", /* cause= */ null); + } + long readValue = 0; + for (int i = 0; i < 8; i++) { + int lengthMask = 1 << (7 - i); + if ((scratch.getData()[headerSize - 1] & lengthMask) != 0) { + int readPosition = headerSize - 1; + headerSize += i; + readScratch(input, headerSize); + readValue = (scratch.getData()[readPosition++] & 0xFF) & ~lengthMask; + while (readPosition < headerSize) { + readValue <<= 8; + readValue |= (scratch.getData()[readPosition++] & 0xFF); + } + // The first read value is the first size. Later values are signed offsets. + if (sampleIndex > 0) { + readValue -= (1L << (6 + i * 7)) - 1; + } + break; + } + } + if (readValue < Integer.MIN_VALUE || readValue > Integer.MAX_VALUE) { + throw ParserException.createForMalformedContainer( + "EBML lacing sample size out of range.", /* cause= */ null); + } + int intReadValue = (int) readValue; + blockSampleSizes[sampleIndex] = + sampleIndex == 0 + ? intReadValue + : blockSampleSizes[sampleIndex - 1] + intReadValue; + totalSamplesSize += blockSampleSizes[sampleIndex]; + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize; + } else { + // Lacing is always in the range 0--3. + throw ParserException.createForMalformedContainer( + "Unexpected lacing value: " + lacing, /* cause= */ null); + } + } + + int timecode = (scratch.getData()[0] << 8) | (scratch.getData()[1] & 0xFF); + blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode); + boolean isKeyframe = + track.type == C.TRACK_TYPE_AUDIO + || (id == ID_SIMPLE_BLOCK && (scratch.getData()[2] & 0x80) == 0x80); + blockFlags = isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; + blockState = BLOCK_STATE_DATA; + blockSampleIndex = 0; + } + + if (id == ID_SIMPLE_BLOCK) { + // For SimpleBlock, we can write sample data and immediately commit the corresponding + // sample metadata. + while (blockSampleIndex < blockSampleCount) { + int sampleSize = + writeSampleData( + input, track, blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ false); + long sampleTimeUs = + blockTimeUs + (blockSampleIndex * track.defaultSampleDurationNs) / 1000; + commitSampleToOutput(track, sampleTimeUs, blockFlags, sampleSize, /* offset= */ 0); + blockSampleIndex++; + } + blockState = BLOCK_STATE_START; + } else { + // For Block, we need to wait until the end of the BlockGroup element before committing + // sample metadata. This is so that we can handle ReferenceBlock (which can be used to + // infer whether the first sample in the block is a keyframe), and BlockAdditions (which + // can contain additional sample data to append) contained in the block group. Just output + // the sample data, storing the final sample sizes for when we commit the metadata. + while (blockSampleIndex < blockSampleCount) { + blockSampleSizes[blockSampleIndex] = + writeSampleData( + input, track, blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ true); + blockSampleIndex++; + } + } + + break; + case ID_BLOCK_ADDITIONAL: + if (blockState != BLOCK_STATE_DATA) { + return; + } + handleBlockAdditionalData( + tracks.get(blockTrackNumber), blockAdditionalId, input, contentSize); + break; + default: + throw ParserException.createForMalformedContainer( + "Unexpected id: " + id, /* cause= */ null); + } + } + + protected void handleBlockAddIDExtraData(Track track, ExtractorInput input, int contentSize) + throws IOException { + if (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC + || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC) { + track.dolbyVisionConfigBytes = new byte[contentSize]; + input.readFully(track.dolbyVisionConfigBytes, 0, contentSize); + } else { + // Unhandled BlockAddIDExtraData. + input.skipFully(contentSize); + } + } + + /** + * Vendored copy of {@code DtsUtil.isSampleDtsHd} (a post-1.8.0 API not present in the stock + * media3 this app links against in AAR mode). Relies only on long-standing public {@link DtsUtil} + * APIs ({@code getFrameType}, {@code getDtsFrameSize}, {@code FRAME_TYPE_*}). Peeks the sample to + * decide whether a DTS track carries a DTS-HD extension substream. + */ + private static boolean isSampleDtsHd(ExtractorInput input, int sampleSize) throws IOException { + ParsableByteArray sampleData = new ParsableByteArray(sampleSize); + if (!input.peekFully( + sampleData.getData(), /* offset= */ 0, sampleSize, /* allowEndOfInput= */ true)) { + return false; + } + input.resetPeekPosition(); + // Equivalent to the post-1.8.0 ParsableByteArray.peekInt(): read the leading + // int without consuming it (the 10-byte header is read from position 0 next). + int word = sampleData.readInt(); + sampleData.setPosition(0); + if (androidx.media3.extractor.DtsUtil.getFrameType(word) == androidx.media3.extractor.DtsUtil.FRAME_TYPE_CORE) { + if (sampleData.bytesLeft() < 10) { + return false; + } + byte[] header = new byte[10]; + sampleData.readBytes(header, /* offset= */ 0, /* length= */ 10); + sampleData.setPosition(0); + int frameSize = androidx.media3.extractor.DtsUtil.getDtsFrameSize(header); + if (frameSize <= 0 || sampleData.bytesLeft() < frameSize + 4) { + return false; + } + sampleData.skipBytes(frameSize); + word = sampleData.readInt(); + return androidx.media3.extractor.DtsUtil.getFrameType(word) == androidx.media3.extractor.DtsUtil.FRAME_TYPE_EXTENSION_SUBSTREAM; + } + return false; + } + + protected void handleBlockAdditionalData( + Track track, int blockAdditionalId, ExtractorInput input, int contentSize) + throws IOException { + if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 + && CODEC_ID_VP9.equals(track.codecId)) { + supplementalData.reset(contentSize); + input.readFully(supplementalData.getData(), 0, contentSize); + } else if (CODEC_ID_H265.equals(track.codecId) + && (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC + || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC)) { + byte[] blockAdditionalData = new byte[contentSize]; + input.readFully(blockAdditionalData, 0, contentSize); + track.pendingDolbyVisionBlockAdditionalData = blockAdditionalData; + + if (dolbyVisionSampleTransformer != null) { + try { + byte[] transformed = + dolbyVisionSampleTransformer.onDolbyVisionBlockAdditionalData( + blockAdditionalData, track.blockAddIdType, track.dolbyVisionConfigBytes); + if (transformed != null) { + track.pendingDolbyVisionBlockAdditionalData = transformed; + } + } catch (RuntimeException e) { + Log.w( + TAG, + "DolbyVisionSampleTransformer.onDolbyVisionBlockAdditionalData failed: " + + e.getMessage()); + } + } + } else { + // Unhandled block additional data. + input.skipFully(contentSize); + } + } + + @EnsuresNonNull("currentTrack") + private void assertInTrackEntry(int id) throws ParserException { + if (currentTrack == null) { + throw ParserException.createForMalformedContainer( + "Element " + id + " must be in a TrackEntry", /* cause= */ null); + } + } + + private void assertInCues(int id) throws ParserException { + if (!inCuesElement) { + throw ParserException.createForMalformedContainer( + "Element " + id + " must be in a Cues", /* cause= */ null); + } + } + + /** + * Returns the track corresponding to the current TrackEntry element. + * + * @throws ParserException if the element id is not in a TrackEntry. + */ + protected Track getCurrentTrack(int currentElementId) throws ParserException { + assertInTrackEntry(currentElementId); + return currentTrack; + } + + @RequiresNonNull("#1.output") + private void commitSampleToOutput( + Track track, long timeUs, @C.BufferFlags int flags, int size, int offset) { + if (track.trueHdSampleRechunker != null) { + track.trueHdSampleRechunker.sampleMetadata( + track.output, timeUs, flags, size, offset, track.cryptoData); + } else { + if (CODEC_ID_SUBRIP.equals(track.codecId) + || CODEC_ID_ASS.equals(track.codecId) + || CODEC_ID_SSA.equals(track.codecId) + || CODEC_ID_VTT.equals(track.codecId)) { + if (blockSampleCount > 1) { + Log.w(TAG, "Skipping subtitle sample in laced block."); + } else if (blockDurationUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping subtitle sample with no duration."); + } else { + setSubtitleEndTime(track.codecId, blockDurationUs, subtitleSample.getData()); + // The Matroska spec doesn't clearly define whether subtitle samples are null-terminated + // or the sample should instead be sized precisely. We truncate the sample at a null-byte + // to gracefully handle null-terminated strings followed by garbage bytes. + for (int i = subtitleSample.getPosition(); i < subtitleSample.limit(); i++) { + if (subtitleSample.getData()[i] == 0) { + subtitleSample.setLimit(i); + break; + } + } + // Note: If we ever want to support DRM protected subtitles then we'll need to output the + // appropriate encryption data here. + track.output.sampleData(subtitleSample, subtitleSample.limit()); + size += subtitleSample.limit(); + } + } + + if ((flags & C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) { + if (blockSampleCount > 1) { + // There were multiple samples in the block. Appending the additional data to the last + // sample doesn't make sense. Skip instead. + supplementalData.reset(/* limit= */ 0); + } else { + // Append supplemental data. + int supplementalDataSize = supplementalData.limit(); + track.output.sampleData( + supplementalData, supplementalDataSize, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL); + size += supplementalDataSize; + } + } + track.output.sampleMetadata(timeUs, flags, size, offset, track.cryptoData); + } + if (CODEC_ID_H265.equals(track.codecId)) { + track.pendingDolbyVisionBlockAdditionalData = null; + } + haveOutputSample = true; + } + + /** + * Ensures {@link #scratch} contains at least {@code requiredLength} bytes of data, reading from + * the extractor input if necessary. + */ + private void readScratch(ExtractorInput input, int requiredLength) throws IOException { + if (scratch.limit() >= requiredLength) { + return; + } + if (scratch.capacity() < requiredLength) { + scratch.ensureCapacity(max(scratch.capacity() * 2, requiredLength)); + } + input.readFully(scratch.getData(), scratch.limit(), requiredLength - scratch.limit()); + scratch.setLimit(requiredLength); + } + + /** + * Writes data for a single sample to the track output. + * + * @param input The input from which to read sample data. + * @param track The track to output the sample to. + * @param size The size of the sample data on the input side. + * @param isBlockGroup Whether the samples are from a BlockGroup. + * @return The final size of the written sample. + * @throws IOException If an error occurs reading from the input. + */ + @RequiresNonNull("#2.output") + private int writeSampleData(ExtractorInput input, Track track, int size, boolean isBlockGroup) + throws IOException { + boolean deferSupplementalMainSampleSizePrefix = false; + if (CODEC_ID_SUBRIP.equals(track.codecId)) { + writeSubtitleSampleData(input, SUBRIP_PREFIX, size); + return finishWriteSampleData(); + } else if (CODEC_ID_ASS.equals(track.codecId) || CODEC_ID_SSA.equals(track.codecId)) { + writeSubtitleSampleData(input, SSA_PREFIX, size); + return finishWriteSampleData(); + } else if (CODEC_ID_VTT.equals(track.codecId)) { + writeSubtitleSampleData(input, VTT_PREFIX, size); + return finishWriteSampleData(); + } + + if (track.waitingForDtsAnalysis) { + checkNotNull(track.format); + if (DtsUtil.isSampleDtsHd(input, size)) { + track.format = track.format.buildUpon().setSampleMimeType(MimeTypes.AUDIO_DTS_HD).build(); + } + track.output.format(track.format); + track.waitingForDtsAnalysis = false; + maybeEndTracks(); + } + + TrackOutput output = track.output; + if (!sampleEncodingHandled) { + if (track.hasContentEncryption) { + // If the sample is encrypted, read its encryption signal byte and set the IV size. + // Clear the encrypted flag. + blockFlags &= ~C.BUFFER_FLAG_ENCRYPTED; + if (!sampleSignalByteRead) { + input.readFully(scratch.getData(), 0, 1); + sampleBytesRead++; + if ((scratch.getData()[0] & 0x80) == 0x80) { + throw ParserException.createForMalformedContainer( + "Extension bit is set in signal byte", /* cause= */ null); + } + sampleSignalByte = scratch.getData()[0]; + sampleSignalByteRead = true; + } + boolean isEncrypted = (sampleSignalByte & 0x01) == 0x01; + if (isEncrypted) { + boolean hasSubsampleEncryption = (sampleSignalByte & 0x02) == 0x02; + blockFlags |= C.BUFFER_FLAG_ENCRYPTED; + if (!sampleInitializationVectorRead) { + input.readFully(encryptionInitializationVector.getData(), 0, ENCRYPTION_IV_SIZE); + sampleBytesRead += ENCRYPTION_IV_SIZE; + sampleInitializationVectorRead = true; + // Write the signal byte, containing the IV size and the subsample encryption flag. + scratch.getData()[0] = + (byte) (ENCRYPTION_IV_SIZE | (hasSubsampleEncryption ? 0x80 : 0x00)); + scratch.setPosition(0); + output.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); + sampleBytesWritten++; + // Write the IV. + encryptionInitializationVector.setPosition(0); + output.sampleData( + encryptionInitializationVector, + ENCRYPTION_IV_SIZE, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); + sampleBytesWritten += ENCRYPTION_IV_SIZE; + } + if (hasSubsampleEncryption) { + if (!samplePartitionCountRead) { + input.readFully(scratch.getData(), 0, 1); + sampleBytesRead++; + scratch.setPosition(0); + samplePartitionCount = scratch.readUnsignedByte(); + samplePartitionCountRead = true; + } + int samplePartitionDataSize = samplePartitionCount * 4; + scratch.reset(samplePartitionDataSize); + input.readFully(scratch.getData(), 0, samplePartitionDataSize); + sampleBytesRead += samplePartitionDataSize; + short subsampleCount = (short) (1 + (samplePartitionCount / 2)); + int subsampleDataSize = 2 + 6 * subsampleCount; + if (encryptionSubsampleDataBuffer == null + || encryptionSubsampleDataBuffer.capacity() < subsampleDataSize) { + encryptionSubsampleDataBuffer = ByteBuffer.allocate(subsampleDataSize); + } + encryptionSubsampleDataBuffer.position(0); + encryptionSubsampleDataBuffer.putShort(subsampleCount); + // Loop through the partition offsets and write out the data in the way ExoPlayer + // wants it (ISO 23001-7 Part 7): + // 2 bytes - sub sample count. + // for each sub sample: + // 2 bytes - clear data size. + // 4 bytes - encrypted data size. + int partitionOffset = 0; + for (int i = 0; i < samplePartitionCount; i++) { + int previousPartitionOffset = partitionOffset; + partitionOffset = scratch.readUnsignedIntToInt(); + if ((i % 2) == 0) { + encryptionSubsampleDataBuffer.putShort( + (short) (partitionOffset - previousPartitionOffset)); + } else { + encryptionSubsampleDataBuffer.putInt(partitionOffset - previousPartitionOffset); + } + } + int finalPartitionSize = size - sampleBytesRead - partitionOffset; + if ((samplePartitionCount % 2) == 1) { + encryptionSubsampleDataBuffer.putInt(finalPartitionSize); + } else { + encryptionSubsampleDataBuffer.putShort((short) finalPartitionSize); + encryptionSubsampleDataBuffer.putInt(0); + } + encryptionSubsampleData.reset(encryptionSubsampleDataBuffer.array(), subsampleDataSize); + output.sampleData( + encryptionSubsampleData, + subsampleDataSize, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); + sampleBytesWritten += subsampleDataSize; + } + } + } else if (track.sampleStrippedBytes != null) { + // If the sample has header stripping, prepare to read/output the stripped bytes first. + sampleStrippedBytes.reset(track.sampleStrippedBytes, track.sampleStrippedBytes.length); + } + + if (track.samplesHaveSupplementalData(isBlockGroup)) { + blockFlags |= C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA; + supplementalData.reset(/* limit= */ 0); + // If there is supplemental data, the structure of the sample data is: + // encryption data (if any) || sample size (4 bytes) || sample data || supplemental data + deferSupplementalMainSampleSizePrefix = + CODEC_ID_H265.equals(track.codecId) && dolbyVisionSampleTransformer != null; + if (!deferSupplementalMainSampleSizePrefix) { + int sampleSize = size + sampleStrippedBytes.limit() - sampleBytesRead; + writeSupplementalMainSampleSizePrefix(output, sampleSize); + sampleBytesWritten += 4; + } + } + + sampleEncodingHandled = true; + } + size += sampleStrippedBytes.limit(); + if (CODEC_ID_H265.equals(track.codecId) && dolbyVisionSampleTransformer != null) { + try { + // Phase-2 seam: sample event is surfaced here. Payload replacement is added in a later + // step once DV conversion is wired for full HEVC access units. + dolbyVisionSampleTransformer.onHevcSample( + size, track.pendingDolbyVisionBlockAdditionalData, track.dolbyVisionConfigBytes); + } catch (RuntimeException e) { + Log.w(TAG, "DolbyVisionSampleTransformer.onHevcSample failed: " + e.getMessage()); + } + } + + if (CODEC_ID_H265.equals(track.codecId) && dolbyVisionSampleTransformer != null) { + int remainingSampleBytes = size - sampleBytesRead; + if (dolbyVisionSampleBuffer.length < remainingSampleBytes) { + dolbyVisionSampleBuffer = new byte[remainingSampleBytes]; + } + byte[] sampleLengthDelimitedData = dolbyVisionSampleBuffer; + writeToTarget(input, sampleLengthDelimitedData, /* offset= */ 0, remainingSampleBytes); + sampleBytesRead += remainingSampleBytes; + + byte[] payloadToWrite = sampleLengthDelimitedData; + int payloadLength = remainingSampleBytes; + try { + byte[] transformedPayload = + dolbyVisionSampleTransformer.transformHevcSample( + sampleLengthDelimitedData, + remainingSampleBytes, + track.nalUnitLengthFieldLength, + track.pendingDolbyVisionBlockAdditionalData, + track.dolbyVisionConfigBytes); + if (transformedPayload != null) { + payloadToWrite = transformedPayload; + payloadLength = dolbyVisionSampleTransformer.lastTransformedSampleLength(); + } + } catch (RuntimeException e) { + Log.w(TAG, "DolbyVisionSampleTransformer.transformHevcSample failed: " + e.getMessage()); + } + + if (deferSupplementalMainSampleSizePrefix) { + int annexBSize = getAnnexBSize(payloadToWrite, payloadLength, track.nalUnitLengthFieldLength); + writeSupplementalMainSampleSizePrefix(output, annexBSize); + sampleBytesWritten += 4; + int bytesWritten = + writeLengthDelimitedSampleAsAnnexB( + output, payloadToWrite, payloadLength, track.nalUnitLengthFieldLength, track.codecId); + sampleBytesWritten += bytesWritten; + } else { + int bytesWritten = + writeLengthDelimitedSampleAsAnnexB( + output, payloadToWrite, payloadLength, track.nalUnitLengthFieldLength, track.codecId); + sampleBytesWritten += bytesWritten; + } + } else if (CODEC_ID_H264.equals(track.codecId) || CODEC_ID_H265.equals(track.codecId)) { + // TODO: Deduplicate with Mp4Extractor. + + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalLengthData = nalLength.getData(); + nalLengthData[0] = 0; + nalLengthData[1] = 0; + nalLengthData[2] = 0; + int nalUnitLengthFieldLength = track.nalUnitLengthFieldLength; + int nalUnitLengthFieldLengthDiff = 4 - track.nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + while (sampleBytesRead < size) { + if (sampleCurrentNalBytesRemaining == 0) { + // Read the NAL length so that we know where we find the next one. + writeToTarget( + input, nalLengthData, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); + sampleBytesRead += nalUnitLengthFieldLength; + nalLength.setPosition(0); + sampleCurrentNalBytesRemaining = nalLength.readUnsignedIntToInt(); + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + sampleBytesWritten += 4; + } else { + // Write the payload of the NAL unit. + int bytesWritten = writeToOutput(input, output, sampleCurrentNalBytesRemaining); + sampleBytesRead += bytesWritten; + sampleBytesWritten += bytesWritten; + sampleCurrentNalBytesRemaining -= bytesWritten; + } + } + } else { + if (track.trueHdSampleRechunker != null) { + checkState(sampleStrippedBytes.limit() == 0); + track.trueHdSampleRechunker.startSample(input); + } + while (sampleBytesRead < size) { + int bytesWritten = writeToOutput(input, output, size - sampleBytesRead); + sampleBytesRead += bytesWritten; + sampleBytesWritten += bytesWritten; + } + } + + if (CODEC_ID_VORBIS.equals(track.codecId)) { + // Vorbis decoder in android MediaCodec [1] expects the last 4 bytes of the sample to be the + // number of samples in the current page. This definition holds good only for Ogg and + // irrelevant for Matroska. So we always set this to -1 (the decoder will ignore this value if + // we set it to -1). The android platform media extractor [2] does the same. + // [1] + // https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/codecs/vorbis/dec/SoftVorbis.cpp#314 + // [2] + // https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/NuMediaExtractor.cpp#474 + vorbisNumPageSamples.setPosition(0); + output.sampleData(vorbisNumPageSamples, 4); + sampleBytesWritten += 4; + } + + return finishWriteSampleData(); + } + + private int writeLengthDelimitedSampleAsAnnexB( + TrackOutput output, + byte[] sampleLengthDelimitedData, + int dataLength, + int nalUnitLengthFieldLength, + String codecId) + throws ParserException { + if (nalUnitLengthFieldLength <= 0 || nalUnitLengthFieldLength > 4) { + throw ParserException.createForMalformedContainer( + "Invalid NAL length field size for " + codecId + ": " + nalUnitLengthFieldLength, + /* cause= */ null); + } + + ParsableByteArray source = new ParsableByteArray(sampleLengthDelimitedData, dataLength); + int bytesWritten = 0; + while (source.bytesLeft() > 0) { + if (source.bytesLeft() < nalUnitLengthFieldLength) { + throw ParserException.createForMalformedContainer( + "Truncated NAL length for " + codecId, /* cause= */ null); + } + + int nalLength = 0; + for (int i = 0; i < nalUnitLengthFieldLength; i++) { + nalLength = (nalLength << 8) | source.readUnsignedByte(); + } + + if (nalLength < 0 || source.bytesLeft() < nalLength) { + throw ParserException.createForMalformedContainer( + "Invalid NAL length for " + codecId + ": " + nalLength, /* cause= */ null); + } + + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + bytesWritten += 4; + output.sampleData(source, nalLength); + bytesWritten += nalLength; + } + + return bytesWritten; + } + + private static int getAnnexBSize( + byte[] sampleLengthDelimitedData, int dataLength, int nalUnitLengthFieldLength) { + if (nalUnitLengthFieldLength == 4) { + return dataLength; + } + int annexBSize = 0; + int offset = 0; + while (offset + nalUnitLengthFieldLength <= dataLength) { + int nalLength = 0; + for (int i = 0; i < nalUnitLengthFieldLength; i++) { + nalLength = (nalLength << 8) | (sampleLengthDelimitedData[offset + i] & 0xFF); + } + if (nalLength < 0 || offset + nalUnitLengthFieldLength + nalLength > dataLength) { + break; // Stop parsing if data is malformed to prevent OutOfBounds + } + annexBSize += 4 + nalLength; + offset += nalUnitLengthFieldLength + nalLength; + } + return annexBSize; + } + + private byte[] convertLengthDelimitedSampleToAnnexB( + byte[] sampleLengthDelimitedData, int dataLength, int nalUnitLengthFieldLength, String codecId) + throws ParserException { + if (nalUnitLengthFieldLength <= 0 || nalUnitLengthFieldLength > 4) { + throw ParserException.createForMalformedContainer( + "Invalid NAL length field size for " + codecId + ": " + nalUnitLengthFieldLength, + /* cause= */ null); + } + + ParsableByteArray source = new ParsableByteArray(sampleLengthDelimitedData, dataLength); + ByteArrayOutputStream output = new ByteArrayOutputStream(dataLength + 64); + while (source.bytesLeft() > 0) { + if (source.bytesLeft() < nalUnitLengthFieldLength) { + throw ParserException.createForMalformedContainer( + "Truncated NAL length for " + codecId, /* cause= */ null); + } + int nalLength = 0; + for (int i = 0; i < nalUnitLengthFieldLength; i++) { + nalLength = (nalLength << 8) | source.readUnsignedByte(); + } + if (nalLength < 0 || source.bytesLeft() < nalLength) { + throw ParserException.createForMalformedContainer( + "Invalid NAL length for " + codecId + ": " + nalLength, /* cause= */ null); + } + output.write(NalUnitUtil.NAL_START_CODE, 0, NalUnitUtil.NAL_START_CODE.length); + output.write(source.getData(), source.getPosition(), nalLength); + source.skipBytes(nalLength); + } + return output.toByteArray(); + } + + private void writeSupplementalMainSampleSizePrefix(TrackOutput output, int sampleSize) { + scratch.reset(/* limit= */ 4); + scratch.getData()[0] = (byte) ((sampleSize >> 24) & 0xFF); + scratch.getData()[1] = (byte) ((sampleSize >> 16) & 0xFF); + scratch.getData()[2] = (byte) ((sampleSize >> 8) & 0xFF); + scratch.getData()[3] = (byte) (sampleSize & 0xFF); + output.sampleData(scratch, 4, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL); + } + + /** + * Called by {@link #writeSampleData(ExtractorInput, Track, int, boolean)} when the sample has + * been written. Returns the final sample size and resets state for the next sample. + */ + private int finishWriteSampleData() { + int sampleSize = sampleBytesWritten; + resetWriteSampleData(); + return sampleSize; + } + + /** Resets state used by {@link #writeSampleData(ExtractorInput, Track, int, boolean)}. */ + private void resetWriteSampleData() { + sampleBytesRead = 0; + sampleBytesWritten = 0; + sampleCurrentNalBytesRemaining = 0; + sampleEncodingHandled = false; + sampleSignalByteRead = false; + samplePartitionCountRead = false; + samplePartitionCount = 0; + sampleSignalByte = (byte) 0; + sampleInitializationVectorRead = false; + sampleStrippedBytes.reset(/* limit= */ 0); + } + + private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) + throws IOException { + int sizeWithPrefix = samplePrefix.length + size; + if (subtitleSample.capacity() < sizeWithPrefix) { + // Initialize subripSample to contain the required prefix and have space to hold a subtitle + // twice as long as this one. + subtitleSample.reset(Arrays.copyOf(samplePrefix, sizeWithPrefix + size)); + } else { + System.arraycopy(samplePrefix, 0, subtitleSample.getData(), 0, samplePrefix.length); + } + input.readFully(subtitleSample.getData(), samplePrefix.length, size); + subtitleSample.setPosition(0); + subtitleSample.setLimit(sizeWithPrefix); + // Defer writing the data to the track output. We need to modify the sample data by setting + // the correct end timecode, which we might not have yet. + } + + /** + * Overwrites the end timecode in {@code subtitleData} with the correctly formatted time derived + * from {@code durationUs}. + * + *

See documentation on {@link #SSA_DIALOGUE_FORMAT} and {@link #SUBRIP_PREFIX} for why we use + * the duration as the end timecode. + * + * @param codecId The subtitle codec; must be {@link #CODEC_ID_SUBRIP}, {@link #CODEC_ID_ASS}, + * {@link #CODEC_ID_SSA} or {@link #CODEC_ID_VTT}. + * @param durationUs The duration of the sample, in microseconds. + * @param subtitleData The subtitle sample in which to overwrite the end timecode (output + * parameter). + */ + private static void setSubtitleEndTime(String codecId, long durationUs, byte[] subtitleData) { + byte[] endTimecode; + int endTimecodeOffset; + switch (codecId) { + case CODEC_ID_SUBRIP: + endTimecode = + formatSubtitleTimecode( + durationUs, SUBRIP_TIMECODE_FORMAT, SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET; + break; + case CODEC_ID_ASS: + case CODEC_ID_SSA: + endTimecode = + formatSubtitleTimecode( + durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = SSA_PREFIX_END_TIMECODE_OFFSET; + break; + case CODEC_ID_VTT: + endTimecode = + formatSubtitleTimecode( + durationUs, VTT_TIMECODE_FORMAT, VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = VTT_PREFIX_END_TIMECODE_OFFSET; + break; + default: + throw new IllegalArgumentException(); + } + System.arraycopy(endTimecode, 0, subtitleData, endTimecodeOffset, endTimecode.length); + } + + /** + * Formats {@code timeUs} using {@code timecodeFormat}, and sets it as the end timecode in {@code + * subtitleSampleData}. + */ + private static byte[] formatSubtitleTimecode( + long timeUs, String timecodeFormat, long lastTimecodeValueScalingFactor) { + checkArgument(timeUs != C.TIME_UNSET); + byte[] timeCodeData; + int hours = (int) (timeUs / (3600 * C.MICROS_PER_SECOND)); + timeUs -= (hours * 3600L * C.MICROS_PER_SECOND); + int minutes = (int) (timeUs / (60 * C.MICROS_PER_SECOND)); + timeUs -= (minutes * 60L * C.MICROS_PER_SECOND); + int seconds = (int) (timeUs / C.MICROS_PER_SECOND); + timeUs -= (seconds * C.MICROS_PER_SECOND); + int lastValue = (int) (timeUs / lastTimecodeValueScalingFactor); + timeCodeData = + Util.getUtf8Bytes( + String.format(Locale.US, timecodeFormat, hours, minutes, seconds, lastValue)); + return timeCodeData; + } + + /** + * Writes {@code length} bytes of sample data into {@code target} at {@code offset}, consisting of + * pending {@link #sampleStrippedBytes} and any remaining data read from {@code input}. + */ + private void writeToTarget(ExtractorInput input, byte[] target, int offset, int length) + throws IOException { + int pendingStrippedBytes = min(length, sampleStrippedBytes.bytesLeft()); + input.readFully(target, offset + pendingStrippedBytes, length - pendingStrippedBytes); + if (pendingStrippedBytes > 0) { + sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes); + } + } + + /** + * Outputs up to {@code length} bytes of sample data to {@code output}, consisting of either + * {@link #sampleStrippedBytes} or data read from {@code input}. + */ + private int writeToOutput(ExtractorInput input, TrackOutput output, int length) + throws IOException { + int bytesWritten; + int strippedBytesLeft = sampleStrippedBytes.bytesLeft(); + if (strippedBytesLeft > 0) { + bytesWritten = min(length, strippedBytesLeft); + output.sampleData(sampleStrippedBytes, bytesWritten); + } else { + bytesWritten = output.sampleData(input, length, false); + } + return bytesWritten; + } + + /** + * Updates the position of the holder to Cues element's position if the extractor configuration + * permits use of master seek entry. After building Cues sets the holder's position back to where + * it was before. + * + * @param seekPosition The holder whose position will be updated. + * @param currentPosition Current position of the input. + * @return Whether the seek position was updated. + */ + private boolean maybeSeekForCues(PositionHolder seekPosition, long currentPosition) { + if (seekForCues) { + seekPositionAfterBuildingCues = currentPosition; + seekPosition.position = cuesContentPosition; + seekForCues = false; + return true; + } + // After parsing Cues, seek back to original position if available. We will not do this unless + // we seeked to get to the Cues in the first place. + if (sentSeekMap && seekPositionAfterBuildingCues != C.INDEX_UNSET) { + seekPosition.position = seekPositionAfterBuildingCues; + seekPositionAfterBuildingCues = C.INDEX_UNSET; + return true; + } + return false; + } + + private long scaleTimecodeToUs(long unscaledTimecode) throws ParserException { + if (timecodeScale == C.TIME_UNSET) { + throw ParserException.createForMalformedContainer( + "Can't scale timecode prior to timecodeScale being set.", /* cause= */ null); + } + return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000); + } + + private static boolean isCodecSupported(String codecId) { + switch (codecId) { + case CODEC_ID_VP8: + case CODEC_ID_VP9: + case CODEC_ID_AV1: + case CODEC_ID_MPEG2: + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + case CODEC_ID_H264: + case CODEC_ID_H265: + case CODEC_ID_FOURCC: + case CODEC_ID_THEORA: + case CODEC_ID_OPUS: + case CODEC_ID_VORBIS: + case CODEC_ID_AAC: + case CODEC_ID_MP2: + case CODEC_ID_MP3: + case CODEC_ID_AC3: + case CODEC_ID_E_AC3: + case CODEC_ID_TRUEHD: + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + case CODEC_ID_DTS_LOSSLESS: + case CODEC_ID_FLAC: + case CODEC_ID_ACM: + case CODEC_ID_PCM_INT_LIT: + case CODEC_ID_PCM_INT_BIG: + case CODEC_ID_PCM_FLOAT: + case CODEC_ID_SUBRIP: + case CODEC_ID_ASS: + case CODEC_ID_SSA: + case CODEC_ID_VTT: + case CODEC_ID_VOBSUB: + case CODEC_ID_PGS: + case CODEC_ID_DVBSUB: + return true; + default: + return false; + } + } + + /** + * Returns an array that can store (at least) {@code length} elements, which will be either a new + * array or {@code array} if it's not null and large enough. + */ + private static int[] ensureArrayCapacity(@Nullable int[] array, int length) { + if (array == null) { + return new int[length]; + } else if (array.length >= length) { + return array; + } else { + // Double the size to avoid allocating constantly if the required length increases gradually. + return new int[max(array.length * 2, length)]; + } + } + + @EnsuresNonNull("extractorOutput") + private void assertInitialized() { + checkNotNull(extractorOutput); + } + + private void maybeEndTracks() { + if (!pendingEndTracks) { + return; + } + for (int i = 0; i < tracks.size(); i++) { + if (tracks.valueAt(i).waitingForDtsAnalysis) { + return; + } + } + checkNotNull(extractorOutput).endTracks(); + pendingEndTracks = false; + } + + /** Passes events through to the outer {@link MatroskaExtractor}. */ + private final class InnerEbmlProcessor implements EbmlProcessor { + + @Override + public @ElementType int getElementType(int id) { + return MatroskaExtractor.this.getElementType(id); + } + + @Override + public boolean isLevel1Element(int id) { + return MatroskaExtractor.this.isLevel1Element(id); + } + + @Override + public void startMasterElement(int id, long contentPosition, long contentSize) + throws ParserException { + MatroskaExtractor.this.startMasterElement(id, contentPosition, contentSize); + } + + @Override + public void endMasterElement(int id) throws ParserException { + MatroskaExtractor.this.endMasterElement(id); + } + + @Override + public void integerElement(int id, long value) throws ParserException { + MatroskaExtractor.this.integerElement(id, value); + } + + @Override + public void floatElement(int id, double value) throws ParserException { + MatroskaExtractor.this.floatElement(id, value); + } + + @Override + public void stringElement(int id, String value) throws ParserException { + MatroskaExtractor.this.stringElement(id, value); + } + + @Override + public void binaryElement(int id, int contentsSize, ExtractorInput input) throws IOException { + MatroskaExtractor.this.binaryElement(id, contentsSize, input); + } + } + + /** Holds data corresponding to a single track. */ + protected static final class Track { + + private static final int DISPLAY_UNIT_PIXELS = 0; + private static final int MAX_CHROMATICITY = 50_000; // Defined in CTA-861.3. + + /** Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ + private static final int DEFAULT_MAX_CLL = 1000; // nits. + + /** Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. */ + private static final int DEFAULT_MAX_FALL = 200; // nits. + + // Common elements. + public boolean isWebm; + public @MonotonicNonNull String name; + public @MonotonicNonNull String codecId; + public int number; + public @C.TrackType int type; + public int defaultSampleDurationNs; + public int maxBlockAdditionId; + private int blockAddIdType; + public boolean hasContentEncryption; + public byte @MonotonicNonNull [] sampleStrippedBytes; + public TrackOutput.@MonotonicNonNull CryptoData cryptoData; + public byte @MonotonicNonNull [] codecPrivate; + public @MonotonicNonNull DrmInitData drmInitData; + + // Video elements. + public int width = Format.NO_VALUE; + public int height = Format.NO_VALUE; + public int bitsPerChannel = Format.NO_VALUE; + public int displayWidth = Format.NO_VALUE; + public int displayHeight = Format.NO_VALUE; + public int displayUnit = DISPLAY_UNIT_PIXELS; + public @C.Projection int projectionType = Format.NO_VALUE; + public float projectionPoseYaw = 0f; + public float projectionPosePitch = 0f; + public float projectionPoseRoll = 0f; + public byte @MonotonicNonNull [] projectionData = null; + public @C.StereoMode int stereoMode = Format.NO_VALUE; + public boolean hasColorInfo = false; + public @C.ColorSpace int colorSpace = Format.NO_VALUE; + public @C.ColorTransfer int colorTransfer = Format.NO_VALUE; + public @C.ColorRange int colorRange = Format.NO_VALUE; + public int maxContentLuminance = DEFAULT_MAX_CLL; + public int maxFrameAverageLuminance = DEFAULT_MAX_FALL; + public float primaryRChromaticityX = Format.NO_VALUE; + public float primaryRChromaticityY = Format.NO_VALUE; + public float primaryGChromaticityX = Format.NO_VALUE; + public float primaryGChromaticityY = Format.NO_VALUE; + public float primaryBChromaticityX = Format.NO_VALUE; + public float primaryBChromaticityY = Format.NO_VALUE; + public float whitePointChromaticityX = Format.NO_VALUE; + public float whitePointChromaticityY = Format.NO_VALUE; + public float maxMasteringLuminance = Format.NO_VALUE; + public float minMasteringLuminance = Format.NO_VALUE; + public byte @MonotonicNonNull [] dolbyVisionConfigBytes; + public byte @MonotonicNonNull [] pendingDolbyVisionBlockAdditionalData; + + // Audio elements. Initially set to their default values. + public int channelCount = 1; + public int audioBitDepth = Format.NO_VALUE; + public int sampleRate = 8000; + public long codecDelayNs = 0; + public long seekPreRollNs = 0; + public @MonotonicNonNull TrueHdSampleRechunker trueHdSampleRechunker; + public boolean waitingForDtsAnalysis = false; + + // Text elements. + public boolean flagForced; + + // Common track elements. + public boolean flagDefault = true; + private String language = "eng"; + + // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. + public @MonotonicNonNull TrackOutput output; + public @MonotonicNonNull Format format; + public int nalUnitLengthFieldLength; + + /** Builds the {@link Format} for the track. */ + @RequiresNonNull("codecId") + public void initializeFormat( + int trackId, @Nullable DolbyVisionSampleTransformer dolbyVisionSampleTransformer) + throws ParserException { + String mimeType; + int maxInputSize = Format.NO_VALUE; + @C.PcmEncoding int pcmEncoding = Format.NO_VALUE; + @Nullable List initializationData = null; + @Nullable String codecs = null; + switch (codecId) { + case CODEC_ID_VP8: + mimeType = MimeTypes.VIDEO_VP8; + break; + case CODEC_ID_VP9: + mimeType = MimeTypes.VIDEO_VP9; + initializationData = codecPrivate == null ? null : ImmutableList.of(codecPrivate); + break; + case CODEC_ID_AV1: + mimeType = MimeTypes.VIDEO_AV1; + initializationData = codecPrivate == null ? null : ImmutableList.of(codecPrivate); + break; + case CODEC_ID_MPEG2: + mimeType = MimeTypes.VIDEO_MPEG2; + break; + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + mimeType = MimeTypes.VIDEO_MP4V; + initializationData = + codecPrivate == null ? null : Collections.singletonList(codecPrivate); + break; + case CODEC_ID_H264: + mimeType = MimeTypes.VIDEO_H264; + AvcConfig avcConfig = AvcConfig.parse(new ParsableByteArray(getCodecPrivate(codecId))); + initializationData = avcConfig.initializationData; + nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; + codecs = avcConfig.codecs; + break; + case CODEC_ID_H265: + mimeType = MimeTypes.VIDEO_H265; + HevcConfig hevcConfig = HevcConfig.parse(new ParsableByteArray(getCodecPrivate(codecId))); + initializationData = hevcConfig.initializationData; + nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength; + codecs = hevcConfig.codecs; + break; + case CODEC_ID_FOURCC: + Pair> pair = + parseFourCcPrivate(new ParsableByteArray(getCodecPrivate(codecId))); + mimeType = pair.first; + initializationData = pair.second; + break; + case CODEC_ID_THEORA: + // TODO: This can be set to the real mimeType if/when we work out what initializationData + // should be set to for this case. + mimeType = MimeTypes.VIDEO_UNKNOWN; + break; + case CODEC_ID_VORBIS: + mimeType = MimeTypes.AUDIO_VORBIS; + maxInputSize = VORBIS_MAX_INPUT_SIZE; + initializationData = parseVorbisCodecPrivate(getCodecPrivate(codecId)); + break; + case CODEC_ID_OPUS: + mimeType = MimeTypes.AUDIO_OPUS; + maxInputSize = OPUS_MAX_INPUT_SIZE; + initializationData = new ArrayList<>(3); + initializationData.add(getCodecPrivate(codecId)); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(codecDelayNs).array()); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(seekPreRollNs).array()); + break; + case CODEC_ID_AAC: + mimeType = MimeTypes.AUDIO_AAC; + initializationData = Collections.singletonList(getCodecPrivate(codecId)); + AacUtil.Config aacConfig = AacUtil.parseAudioSpecificConfig(codecPrivate); + // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, + // which is more reliable. See [Internal: b/10903778]. + sampleRate = aacConfig.sampleRateHz; + channelCount = aacConfig.channelCount; + codecs = aacConfig.codecs; + break; + case CODEC_ID_MP2: + mimeType = MimeTypes.AUDIO_MPEG_L2; + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_MP3: + mimeType = MimeTypes.AUDIO_MPEG; + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_AC3: + mimeType = MimeTypes.AUDIO_AC3; + break; + case CODEC_ID_E_AC3: + mimeType = MimeTypes.AUDIO_E_AC3; + break; + case CODEC_ID_TRUEHD: + mimeType = MimeTypes.AUDIO_TRUEHD; + trueHdSampleRechunker = new TrueHdSampleRechunker(); + break; + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + mimeType = MimeTypes.AUDIO_DTS; // temporary + waitingForDtsAnalysis = true; + break; + case CODEC_ID_DTS_LOSSLESS: + mimeType = MimeTypes.AUDIO_DTS_HD; + break; + case CODEC_ID_FLAC: + mimeType = MimeTypes.AUDIO_FLAC; + initializationData = Collections.singletonList(getCodecPrivate(codecId)); + break; + case CODEC_ID_ACM: + mimeType = MimeTypes.AUDIO_RAW; + if (parseMsAcmCodecPrivate(new ParsableByteArray(getCodecPrivate(codecId)))) { + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + } else { + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Non-PCM MS/ACM is unsupported. Setting mimeType to " + mimeType); + } + break; + case CODEC_ID_PCM_INT_LIT: + mimeType = MimeTypes.AUDIO_RAW; + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported little endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_PCM_INT_BIG: + mimeType = MimeTypes.AUDIO_RAW; + if (audioBitDepth == 8) { + pcmEncoding = C.ENCODING_PCM_8BIT; + } else if (audioBitDepth == 16) { + pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN; + } else if (audioBitDepth == 24) { + pcmEncoding = C.ENCODING_PCM_24BIT_BIG_ENDIAN; + } else if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_32BIT_BIG_ENDIAN; + } else { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported big endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_PCM_FLOAT: + mimeType = MimeTypes.AUDIO_RAW; + if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_FLOAT; + } else { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported floating point PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_SUBRIP: + mimeType = MimeTypes.APPLICATION_SUBRIP; + break; + case CODEC_ID_ASS: + case CODEC_ID_SSA: + mimeType = MimeTypes.TEXT_SSA; + initializationData = ImmutableList.of(SSA_DIALOGUE_FORMAT, getCodecPrivate(codecId)); + break; + case CODEC_ID_VTT: + mimeType = MimeTypes.TEXT_VTT; + break; + case CODEC_ID_VOBSUB: + mimeType = MimeTypes.APPLICATION_VOBSUB; + initializationData = ImmutableList.of(getCodecPrivate(codecId)); + break; + case CODEC_ID_PGS: + mimeType = MimeTypes.APPLICATION_PGS; + break; + case CODEC_ID_DVBSUB: + mimeType = MimeTypes.APPLICATION_DVBSUBS; + // Init data: composition_page (2), ancillary_page (2) + byte[] initializationDataBytes = new byte[4]; + System.arraycopy(getCodecPrivate(codecId), 0, initializationDataBytes, 0, 4); + initializationData = ImmutableList.of(initializationDataBytes); + break; + default: + throw ParserException.createForMalformedContainer( + "Unrecognized codec identifier.", /* cause= */ null); + } + @Nullable String hevcCodecsString = codecs; + if (dolbyVisionConfigBytes != null) { + @Nullable + DolbyVisionConfig dolbyVisionConfig = + DolbyVisionConfig.parse(new ParsableByteArray(this.dolbyVisionConfigBytes)); + if (dolbyVisionConfig != null) { + codecs = dolbyVisionConfig.codecs; + mimeType = MimeTypes.VIDEO_DOLBY_VISION; + if (dolbyVisionSampleTransformer != null) { + @Nullable + String transformedCodecs = + dolbyVisionSampleTransformer.onDolbyVisionCodecString( + codecs, this.dolbyVisionConfigBytes); + if (transformedCodecs != null && !transformedCodecs.isEmpty()) { + codecs = transformedCodecs; + } + if (codecs != null) { + String lower = codecs.toLowerCase(Locale.ROOT); + if (lower.startsWith("hvc1.") || lower.startsWith("hev1.")) { + mimeType = MimeTypes.VIDEO_H265; + } + } + if (MimeTypes.VIDEO_DOLBY_VISION.equals(mimeType) && hevcCodecsString != null) { + if (DolbyVisionCompatibility.isHdr10BaseLayerModeActive()) { + mimeType = MimeTypes.VIDEO_H265; + codecs = hevcCodecsString; + } + } + } + } + } else if (dolbyVisionSampleTransformer != null && codecs != null) { + String lower = codecs.toLowerCase(Locale.ROOT); + if (lower.startsWith("dvhe.") + || lower.startsWith("dvh1.") + || lower.startsWith("dvav.") + || lower.startsWith("dva1.")) { + @Nullable + String transformedCodecs = + dolbyVisionSampleTransformer.onDolbyVisionCodecString(codecs, null); + if (transformedCodecs != null && !transformedCodecs.isEmpty()) { + codecs = transformedCodecs; + //Check whether transformer downgraded to HEVC before assuming DV. + String tLower = codecs.toLowerCase(Locale.ROOT); + if (tLower.startsWith("hvc1.") || tLower.startsWith("hev1.")) { + mimeType = MimeTypes.VIDEO_H265; + } else { + mimeType = MimeTypes.VIDEO_DOLBY_VISION; + } + } + } + } + if (DolbyVisionCompatibility.shouldMapDolbyVisionProfile7(mimeType, codecs)) { + // NOTE: Vendored for app-level (AAR) DV7 use. The original called + // NalUnitUtil.getH265BaseLayerCodecsString(initializationData) (a + // post-1.8.0 API absent from the stock media3 we link against). This + // HEVC-fallback branch is gated by mapDv7ToHevcEnabled (never enabled + // in the conversion path), so we use the self-contained codec mapping. + mimeType = MimeTypes.VIDEO_H265; + codecs = DolbyVisionCompatibility.chooseHevcCodecsString(codecs, null); + } + + @C.SelectionFlags int selectionFlags = 0; + selectionFlags |= flagDefault ? C.SELECTION_FLAG_DEFAULT : 0; + selectionFlags |= flagForced ? C.SELECTION_FLAG_FORCED : 0; + + Format.Builder formatBuilder = new Format.Builder(); + // TODO: Consider reading the name elements of the tracks and, if present, incorporating them + // into the trackId passed when creating the formats. + if (MimeTypes.isAudio(mimeType)) { + formatBuilder + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .setPcmEncoding(pcmEncoding); + } else if (MimeTypes.isVideo(mimeType)) { + if (displayUnit == Track.DISPLAY_UNIT_PIXELS) { + displayWidth = displayWidth == Format.NO_VALUE ? width : displayWidth; + displayHeight = displayHeight == Format.NO_VALUE ? height : displayHeight; + } + float pixelWidthHeightRatio = Format.NO_VALUE; + if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { + pixelWidthHeightRatio = ((float) (height * displayWidth)) / (width * displayHeight); + } + @Nullable ColorInfo colorInfo = null; + if (hasColorInfo) { + @Nullable byte[] hdrStaticInfo = getHdrStaticInfo(); + colorInfo = + new ColorInfo.Builder() + .setColorSpace(colorSpace) + .setColorRange(colorRange) + .setColorTransfer(colorTransfer) + .setHdrStaticInfo(hdrStaticInfo) + .setLumaBitdepth(bitsPerChannel) + .setChromaBitdepth(bitsPerChannel) + .build(); + } + int rotationDegrees = Format.NO_VALUE; + + if (name != null && TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES.get(name); + } + if (projectionType == C.PROJECTION_RECTANGULAR + && Float.compare(projectionPoseYaw, 0f) == 0 + && Float.compare(projectionPosePitch, 0f) == 0) { + // The range of projectionPoseRoll is [-180, 180]. + if (Float.compare(projectionPoseRoll, 0f) == 0) { + rotationDegrees = 0; + } else if (Float.compare(projectionPoseRoll, 90f) == 0) { + rotationDegrees = 90; + } else if (Float.compare(projectionPoseRoll, -180f) == 0 + || Float.compare(projectionPoseRoll, 180f) == 0) { + rotationDegrees = 180; + } else if (Float.compare(projectionPoseRoll, -90f) == 0) { + rotationDegrees = 270; + } + } + formatBuilder + .setWidth(width) + .setHeight(height) + .setPixelWidthHeightRatio(pixelWidthHeightRatio) + .setRotationDegrees(rotationDegrees) + .setProjectionData(projectionData) + .setStereoMode(stereoMode) + .setColorInfo(colorInfo); + } else if (MimeTypes.APPLICATION_SUBRIP.equals(mimeType) + || MimeTypes.TEXT_SSA.equals(mimeType) + || MimeTypes.TEXT_VTT.equals(mimeType) + || MimeTypes.APPLICATION_VOBSUB.equals(mimeType) + || MimeTypes.APPLICATION_PGS.equals(mimeType) + || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType)) { + } else { + throw ParserException.createForMalformedContainer( + "Unexpected MIME type.", /* cause= */ null); + } + + if (name != null && !TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + formatBuilder.setLabel(name); + } + + format = + formatBuilder + .setId(trackId) + .setContainerMimeType(isWebm ? MimeTypes.VIDEO_WEBM : MimeTypes.VIDEO_MATROSKA) + .setSampleMimeType(mimeType) + .setMaxInputSize(maxInputSize) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setInitializationData(initializationData) + .setCodecs(codecs) + .setDrmInitData(drmInitData) + .build(); + } + + /** Forces any pending sample metadata to be flushed to the output. */ + @RequiresNonNull("output") + public void outputPendingSampleMetadata() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker.outputPendingSampleMetadata(output, cryptoData); + } + } + + /** Resets any state stored in the track in response to a seek. */ + public void reset() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker.reset(); + } + pendingDolbyVisionBlockAdditionalData = null; + } + + /** + * Returns true if supplemental data will be attached to the samples. + * + * @param isBlockGroup Whether the samples are from a BlockGroup. + */ + private boolean samplesHaveSupplementalData(boolean isBlockGroup) { + if (CODEC_ID_OPUS.equals(codecId)) { + // At the end of a BlockGroup, a positive DiscardPadding value will be written out as + // supplemental data for Opus codec. Otherwise (i.e. DiscardPadding <= 0) supplemental data + // size will be 0. + return isBlockGroup; + } + return maxBlockAdditionId > 0; + } + + /** Returns the HDR Static Info as defined in CTA-861.3. */ + @Nullable + private byte[] getHdrStaticInfo() { + // Are all fields present. + if (primaryRChromaticityX == Format.NO_VALUE + || primaryRChromaticityY == Format.NO_VALUE + || primaryGChromaticityX == Format.NO_VALUE + || primaryGChromaticityY == Format.NO_VALUE + || primaryBChromaticityX == Format.NO_VALUE + || primaryBChromaticityY == Format.NO_VALUE + || whitePointChromaticityX == Format.NO_VALUE + || whitePointChromaticityY == Format.NO_VALUE + || maxMasteringLuminance == Format.NO_VALUE + || minMasteringLuminance == Format.NO_VALUE) { + return null; + } + + byte[] hdrStaticInfoData = new byte[25]; + ByteBuffer hdrStaticInfo = ByteBuffer.wrap(hdrStaticInfoData).order(ByteOrder.LITTLE_ENDIAN); + hdrStaticInfo.put((byte) 0); // Type. + hdrStaticInfo.putShort((short) ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) (maxMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) (minMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) maxContentLuminance); + hdrStaticInfo.putShort((short) maxFrameAverageLuminance); + return hdrStaticInfoData; + } + + /** + * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as + * {@link ThumbnailMetadata}. + */ + private void maybeAddThumbnailMetadata( + SparseArray> perTrackCues, + long durationUs, + long segmentContentPosition, + long segmentContentSize) { + if (type != C.TRACK_TYPE_VIDEO) { + return; + } + + List cuePoints = perTrackCues.get(number); + if (cuePoints == null || cuePoints.isEmpty()) { + return; + } + + long thumbnailTimestampUs = + findBestThumbnailPresentationTimeUs( + cuePoints, durationUs, segmentContentPosition, segmentContentSize); + + if (thumbnailTimestampUs != C.TIME_UNSET) { + Metadata existingMetadata = checkNotNull(format).metadata; + ThumbnailMetadata thumbnailMetadata = new ThumbnailMetadata(thumbnailTimestampUs); + Metadata newMetadata = + (existingMetadata == null) + ? new Metadata(thumbnailMetadata) + : existingMetadata.copyWithAppendedEntries(thumbnailMetadata); + format = format.buildUpon().setMetadata(newMetadata).build(); + } + } + + /** + * Finds the best thumbnail timestamp from the provided cue points. + * + *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk + * size corresponds to a more complex and representative frame. It calculates an approximate + * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. + */ + private static long findBestThumbnailPresentationTimeUs( + List cuePoints, + long durationUs, + long segmentContentPosition, + long segmentContentSize) { + if (cuePoints.isEmpty()) { + return C.TIME_UNSET; + } + + double maxBitrate = 0; + int bestCueIndex = -1; + int scanLimit = min(cuePoints.size(), MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL); + + for (int i = 0; i < scanLimit; i++) { + MatroskaSeekMap.CuePointData cue = cuePoints.get(i); + + if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) { + break; + } + + long bytesBetweenCues; + long durationBetweenCuesUs; + + if (i < cuePoints.size() - 1) { + MatroskaSeekMap.CuePointData nextCue = cuePoints.get(i + 1); + bytesBetweenCues = + (nextCue.clusterPosition + nextCue.relativePosition) + - (cue.clusterPosition + cue.relativePosition); + durationBetweenCuesUs = nextCue.timeUs - cue.timeUs; + } else { + // Last cue point + bytesBetweenCues = + (segmentContentPosition + segmentContentSize) + - (cue.clusterPosition + cue.relativePosition); + durationBetweenCuesUs = durationUs - cue.timeUs; + } + + if (durationBetweenCuesUs > 0) { + // This is an approximation of the bitrate for thumbnail heuristic. + double bitrate = (double) bytesBetweenCues / durationBetweenCuesUs; + if (bitrate > maxBitrate) { + maxBitrate = bitrate; + bestCueIndex = i; + } + } + } + + return bestCueIndex == -1 ? C.TIME_UNSET : cuePoints.get(bestCueIndex).timeUs; + } + + /** + * Builds initialization data for a {@link Format} from FourCC codec private data. + * + * @return The codec MIME type and initialization data. If the compression type is not supported + * then the MIME type is set to {@link MimeTypes#VIDEO_UNKNOWN} and the initialization data + * is {@code null}. + * @throws ParserException If the initialization data could not be built. + */ + private static Pair> parseFourCcPrivate( + ParsableByteArray buffer) throws ParserException { + try { + buffer.skipBytes(16); // size(4), width(4), height(4), planes(2), bitcount(2). + long compression = buffer.readLittleEndianUnsignedInt(); + if (compression == FOURCC_COMPRESSION_DIVX) { + return new Pair<>(MimeTypes.VIDEO_DIVX, null); + } else if (compression == FOURCC_COMPRESSION_H263) { + return new Pair<>(MimeTypes.VIDEO_H263, null); + } else if (compression == FOURCC_COMPRESSION_VC1) { + // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 + // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). + int startOffset = buffer.getPosition() + 20; + byte[] bufferData = buffer.getData(); + for (int offset = startOffset; offset < bufferData.length - 4; offset++) { + if (bufferData[offset] == 0x00 + && bufferData[offset + 1] == 0x00 + && bufferData[offset + 2] == 0x01 + && bufferData[offset + 3] == 0x0F) { + // We've found the initialization data. + byte[] initializationData = Arrays.copyOfRange(bufferData, offset, bufferData.length); + return new Pair<>(MimeTypes.VIDEO_VC1, Collections.singletonList(initializationData)); + } + } + throw ParserException.createForMalformedContainer( + "Failed to find FourCC VC1 initialization data", /* cause= */ null); + } + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForMalformedContainer( + "Error parsing FourCC private data", /* cause= */ null); + } + + Log.w(TAG, "Unknown FourCC. Setting mimeType to " + MimeTypes.VIDEO_UNKNOWN); + return new Pair<>(MimeTypes.VIDEO_UNKNOWN, null); + } + + /** + * Builds initialization data for a {@link Format} from Vorbis codec private data. + * + * @return The initialization data for the {@link Format}. + * @throws ParserException If the initialization data could not be built. + */ + private static List parseVorbisCodecPrivate(byte[] codecPrivate) + throws ParserException { + try { + if (codecPrivate[0] != 0x02) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + int offset = 1; + int vorbisInfoLength = 0; + while ((codecPrivate[offset] & 0xFF) == 0xFF) { + vorbisInfoLength += 0xFF; + offset++; + } + vorbisInfoLength += codecPrivate[offset++] & 0xFF; + + int vorbisSkipLength = 0; + while ((codecPrivate[offset] & 0xFF) == 0xFF) { + vorbisSkipLength += 0xFF; + offset++; + } + vorbisSkipLength += codecPrivate[offset++] & 0xFF; + + if (codecPrivate[offset] != 0x01) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + byte[] vorbisInfo = new byte[vorbisInfoLength]; + System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength); + offset += vorbisInfoLength; + if (codecPrivate[offset] != 0x03) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + offset += vorbisSkipLength; + if (codecPrivate[offset] != 0x05) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + byte[] vorbisBooks = new byte[codecPrivate.length - offset]; + System.arraycopy(codecPrivate, offset, vorbisBooks, 0, codecPrivate.length - offset); + List initializationData = new ArrayList<>(2); + initializationData.add(vorbisInfo); + initializationData.add(vorbisBooks); + return initializationData; + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */ null); + } + } + + /** + * Parses an MS/ACM codec private, returning whether it indicates PCM audio. + * + * @return Whether the codec private indicates PCM audio. + * @throws ParserException If a parsing error occurs. + */ + private static boolean parseMsAcmCodecPrivate(ParsableByteArray buffer) throws ParserException { + try { + int formatTag = buffer.readLittleEndianUnsignedShort(); + if (formatTag == WAVE_FORMAT_PCM) { + return true; + } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { + buffer.setPosition(WAVE_FORMAT_SIZE + 6); // unionSamples(2), channelMask(4) + return buffer.readLong() == WAVE_SUBFORMAT_PCM.getMostSignificantBits() + && buffer.readLong() == WAVE_SUBFORMAT_PCM.getLeastSignificantBits(); + } else { + return false; + } + } catch (ArrayIndexOutOfBoundsException e) { + throw ParserException.createForMalformedContainer( + "Error parsing MS/ACM codec private", /* cause= */ null); + } + } + + /** + * Checks that the track has an output. + * + *

It is unfortunately not possible to mark {@link MatroskaExtractor#tracks} as only + * containing tracks with output with the nullness checker. This method is used to check that + * fact at runtime. + */ + @EnsuresNonNull("output") + private void assertOutputInitialized() { + checkNotNull(output); + } + + @EnsuresNonNull("codecPrivate") + private byte[] getCodecPrivate(String codecId) throws ParserException { + if (codecPrivate == null) { + throw ParserException.createForMalformedContainer( + "Missing CodecPrivate for codec " + codecId, /* cause= */ null); + } + return codecPrivate; + } + } + + private static final class MatroskaSeekMap implements TrackAwareSeekMap, ChunkIndexProvider { + + @Nullable private final ChunkIndex chunkIndex; + private final SparseArray> perTrackCues; + private final long durationUs; + private final int primarySeekTrackNumber; + + public MatroskaSeekMap( + SparseArray> perTrackCues, + long durationUs, + int primarySeekTrackNumber, + long segmentContentPosition, + long segmentContentSize) { + this.perTrackCues = perTrackCues; + this.durationUs = durationUs; + this.primarySeekTrackNumber = primarySeekTrackNumber; + this.chunkIndex = + buildChunkIndex( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize); + } + + @Override + public boolean isSeekable() { + // The media is seekable overall only if the primary seek track has cue points. + return isSeekable(primarySeekTrackNumber); + } + + @Override + public boolean isSeekable(int trackId) { + List cuePoints = perTrackCues.get(trackId); + return cuePoints != null && !cuePoints.isEmpty(); + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + if (chunkIndex != null) { + return chunkIndex.getSeekPoints(timeUs); + } + return new SeekPoints(SeekPoint.START); + } + + @Override + public SeekPoints getSeekPoints(long timeUs, int trackId) { + List cuePoints = perTrackCues.get(trackId); + if ((cuePoints == null || cuePoints.isEmpty()) && trackId != primarySeekTrackNumber) { + cuePoints = perTrackCues.get(primarySeekTrackNumber); + } + if (cuePoints == null || cuePoints.isEmpty()) { + return new SeekPoints(SeekPoint.START); + } + + int bestIndex = + Util.binarySearchFloor( + cuePoints, + new CuePointData(timeUs, C.INDEX_UNSET, C.INDEX_UNSET), + /* inclusive= */ true, + /* stayInBounds= */ false); + + if (bestIndex != -1) { + CuePointData bestCue = cuePoints.get(bestIndex); + SeekPoint firstPoint = new SeekPoint(bestCue.timeUs, bestCue.clusterPosition); + + if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size()) { + CuePointData nextCue = cuePoints.get(bestIndex + 1); + SeekPoint secondPoint = new SeekPoint(nextCue.timeUs, nextCue.clusterPosition); + return new SeekPoints(firstPoint, secondPoint); + } else { + return new SeekPoints(firstPoint); + } + } else { + CuePointData firstCue = cuePoints.get(0); + return new SeekPoints(new SeekPoint(firstCue.timeUs, firstCue.clusterPosition)); + } + } + + @Override + @Nullable + public ChunkIndex getChunkIndex() { + return chunkIndex; + } + + @Nullable + private static ChunkIndex buildChunkIndex( + SparseArray> perTrackCues, + long durationUs, + int primarySeekTrackNumber, + long segmentContentPosition, + long segmentContentSize) { + List primaryTrackCuePoints = perTrackCues.get(primarySeekTrackNumber); + if (primaryTrackCuePoints == null || primaryTrackCuePoints.isEmpty()) { + return null; + } + + int cuePointsSize = primaryTrackCuePoints.size(); + int[] sizes = new int[cuePointsSize]; + long[] offsets = new long[cuePointsSize]; + long[] durationsUs = new long[cuePointsSize]; + long[] timesUs = new long[cuePointsSize]; + + for (int i = 0; i < cuePointsSize; i++) { + CuePointData cue = primaryTrackCuePoints.get(i); + timesUs[i] = cue.timeUs; + offsets[i] = cue.clusterPosition; + } + + for (int i = 0; i < cuePointsSize - 1; i++) { + sizes[i] = (int) (offsets[i + 1] - offsets[i]); + durationsUs[i] = timesUs[i + 1] - timesUs[i]; + } + + // Start from the last cue point and move backward until a valid duration is found. + int lastValidIndex = cuePointsSize - 1; + while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { + lastValidIndex--; + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (int) (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]); + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex]; + + // If trailing cue points were found, truncate the arrays to the last valid index. + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration."); + sizes = Arrays.copyOf(sizes, lastValidIndex + 1); + offsets = Arrays.copyOf(offsets, lastValidIndex + 1); + durationsUs = Arrays.copyOf(durationsUs, lastValidIndex + 1); + timesUs = Arrays.copyOf(timesUs, lastValidIndex + 1); + } + + return new ChunkIndex(sizes, offsets, durationsUs, timesUs); + } + + private static final class CuePointData implements Comparable { + /** The timestamp of the cue point, in microseconds. */ + private final long timeUs; + + /** The absolute byte offset of the start of the cluster containing this cue point. */ + private final long clusterPosition; + + /** + * The relative byte offset of the cue point's data block within its cluster. + * + *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. + */ + private final long relativePosition; + + private CuePointData(long timeUs, long clusterPosition, long relativePosition) { + this.timeUs = timeUs; + this.clusterPosition = clusterPosition; + this.relativePosition = relativePosition; + } + + @Override + public int compareTo(CuePointData other) { + return Long.compare(timeUs, other.timeUs); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof CuePointData)) { + return false; + } + CuePointData other = (CuePointData) obj; + return this.timeUs == other.timeUs + && this.clusterPosition == other.clusterPosition + && this.relativePosition == other.relativePosition; + } + + @Override + public int hashCode() { + return Objects.hash(timeUs, clusterPosition, relativePosition); + } + } + } +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/Sniffer.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/Sniffer.java new file mode 100644 index 000000000..c7dbf5868 --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/Sniffer.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; + +/** + * Utility class that peeks from the input stream in order to determine whether it appears to be + * compatible input for this extractor. + */ +/* package */ final class Sniffer { + + /** The number of bytes to search for a valid header in {@link #sniff(ExtractorInput)}. */ + private static final int SEARCH_LENGTH = 1024; + + private static final int ID_EBML = 0x1A45DFA3; + + private final ParsableByteArray scratch; + private int peekLength; + + public Sniffer() { + scratch = new ParsableByteArray(8); + } + + /** See {@link Extractor#sniff(ExtractorInput)}. */ + public boolean sniff(ExtractorInput input) throws IOException { + long inputLength = input.getLength(); + int bytesToSearch = + (int) + (inputLength == C.LENGTH_UNSET || inputLength > SEARCH_LENGTH + ? SEARCH_LENGTH + : inputLength); + // Find four bytes equal to ID_EBML near the start of the input. + input.peekFully(scratch.getData(), 0, 4); + long tag = scratch.readUnsignedInt(); + peekLength = 4; + while (tag != ID_EBML) { + if (++peekLength == bytesToSearch) { + return false; + } + input.peekFully(scratch.getData(), 0, 1); + tag = (tag << 8) & 0xFFFFFF00; + tag |= scratch.getData()[0] & 0xFF; + } + + // Read the size of the EBML header and make sure it is within the stream. + long headerSize = readUint(input); + long headerStart = peekLength; + if (headerSize == Long.MIN_VALUE + || (inputLength != C.LENGTH_UNSET && headerStart + headerSize >= inputLength)) { + return false; + } + + // Read the payload elements in the EBML header. + while (peekLength < headerStart + headerSize) { + long id = readUint(input); + if (id == Long.MIN_VALUE) { + return false; + } + long size = readUint(input); + if (size < 0 || size > Integer.MAX_VALUE) { + return false; + } + if (size != 0) { + int sizeInt = (int) size; + input.advancePeekPosition(sizeInt); + peekLength += sizeInt; + } + } + return peekLength == headerStart + headerSize; + } + + /** Peeks a variable-length unsigned EBML integer from the input. */ + private long readUint(ExtractorInput input) throws IOException { + input.peekFully(scratch.getData(), 0, 1); + int value = scratch.getData()[0] & 0xFF; + if (value == 0) { + return Long.MIN_VALUE; + } + int mask = 0x80; + int length = 0; + while ((value & mask) == 0) { + mask >>= 1; + length++; + } + value &= ~mask; + input.peekFully(scratch.getData(), 1, length); + for (int i = 0; i < length; i++) { + value <<= 8; + value += scratch.getData()[i + 1] & 0xFF; + } + peekLength += length + 1; + return value; + } +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/VarintReader.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/VarintReader.java new file mode 100644 index 000000000..59c8b1b99 --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/VarintReader.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.arflix.tv.player.dvmkv; + +import androidx.media3.common.C; +import androidx.media3.extractor.ExtractorInput; +import java.io.EOFException; +import java.io.IOException; + +/** Reads EBML variable-length integers (varints) from an {@link ExtractorInput}. */ +/* package */ final class VarintReader { + + private static final int STATE_BEGIN_READING = 0; + private static final int STATE_READ_CONTENTS = 1; + + /** + * The first byte of a variable-length integer (varint) will have one of these bit masks + * indicating the total length in bytes. + * + *

{@code 0x80} is a one-byte integer, {@code 0x40} is two bytes, and so on up to eight bytes. + */ + private static final long[] VARINT_LENGTH_MASKS = + new long[] {0x80L, 0x40L, 0x20L, 0x10L, 0x08L, 0x04L, 0x02L, 0x01L}; + + private final byte[] scratch; + + private int state; + private int length; + + public VarintReader() { + scratch = new byte[8]; + } + + /** Resets the reader to start reading a new variable-length integer. */ + public void reset() { + state = STATE_BEGIN_READING; + length = 0; + } + + /** + * Reads an EBML variable-length integer (varint) from an {@link ExtractorInput} such that reading + * can be resumed later if an error occurs having read only some of it. + * + *

If an value is successfully read, then the reader will automatically reset itself ready to + * read another value. + * + *

If an {@link IOException} is thrown, the read can be resumed later by calling this method + * again, passing an {@link ExtractorInput} providing data starting where the previous one left + * off. + * + * @param input The {@link ExtractorInput} from which the integer should be read. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it + * should be considered an error, causing an {@link EOFException} to be thrown. + * @param removeLengthMask Removes the variable-length integer length mask from the value. + * @param maximumAllowedLength Maximum allowed length of the variable integer to be read. + * @return The read value, or {@link C#RESULT_END_OF_INPUT} if {@code allowEndOfStream} is true + * and the end of the input was encountered, or {@link C#RESULT_MAX_LENGTH_EXCEEDED} if the + * length of the varint exceeded maximumAllowedLength. + * @throws IOException If an error occurs reading from the input. + */ + public long readUnsignedVarint( + ExtractorInput input, + boolean allowEndOfInput, + boolean removeLengthMask, + int maximumAllowedLength) + throws IOException { + if (state == STATE_BEGIN_READING) { + // Read the first byte to establish the length. + if (!input.readFully(scratch, 0, 1, allowEndOfInput)) { + return C.RESULT_END_OF_INPUT; + } + int firstByte = scratch[0] & 0xFF; + length = parseUnsignedVarintLength(firstByte); + if (length == C.LENGTH_UNSET) { + throw new IllegalStateException("No valid varint length mask found"); + } + state = STATE_READ_CONTENTS; + } + + if (length > maximumAllowedLength) { + state = STATE_BEGIN_READING; + return C.RESULT_MAX_LENGTH_EXCEEDED; + } + + if (length != 1) { + // Read the remaining bytes. + input.readFully(scratch, 1, length - 1); + } + + state = STATE_BEGIN_READING; + return assembleVarint(scratch, length, removeLengthMask); + } + + /** Returns the number of bytes occupied by the most recently parsed varint. */ + public int getLastLength() { + return length; + } + + /** + * Parses and the length of the varint given the first byte. + * + * @param firstByte First byte of the varint. + * @return Length of the varint beginning with the given byte if it was valid, {@link + * C#LENGTH_UNSET} otherwise. + */ + public static int parseUnsignedVarintLength(int firstByte) { + int varIntLength = C.LENGTH_UNSET; + for (int i = 0; i < VARINT_LENGTH_MASKS.length; i++) { + if ((VARINT_LENGTH_MASKS[i] & firstByte) != 0) { + varIntLength = i + 1; + break; + } + } + return varIntLength; + } + + /** + * Assemble a varint from the given byte array. + * + * @param varintBytes Bytes that make up the varint. + * @param varintLength Length of the varint to assemble. + * @param removeLengthMask Removes the variable-length integer length mask from the value. + * @return Parsed and assembled varint. + */ + public static long assembleVarint( + byte[] varintBytes, int varintLength, boolean removeLengthMask) { + long varint = varintBytes[0] & 0xFFL; + if (removeLengthMask) { + varint &= ~VARINT_LENGTH_MASKS[varintLength - 1]; + } + for (int i = 1; i < varintLength; i++) { + varint = (varint << 8) | (varintBytes[i] & 0xFFL); + } + return varint; + } +} diff --git a/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/package-info.java b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/package-info.java new file mode 100644 index 000000000..3c954ef3f --- /dev/null +++ b/app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/package-info.java @@ -0,0 +1,12 @@ +/** + * Vendored copy of media3 1.9.0's Matroska extractor with Dolby Vision profile-7 sample hooks. + * The DV7 RPU rides in Matroska BlockAdditional, which the stock extractor + * discards before any TrackOutput sees it — there is no app-level seam, hence the fork. Keep in + * sync when bumping the media3 version: re-base on the new tag and re-apply the DV diff + * (DolbyVisionSampleTransformer interface + handleBlockAdditionalData + sample-commit transform + + * codec-string hook). + */ +@NonNullApi +package com.arflix.tv.player.dvmkv; + +import androidx.media3.common.util.NonNullApi; diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index d63acb19d..9b3dd635b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -354,6 +354,7 @@ class CloudSyncRepository @Inject constructor( private val subtitleAiAutoSelectKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_ai_auto_select") private val subtitleAiFindBestMatchKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_ai_find_best_match") private val subtitlePreloadEnabledKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_preload_enabled") + private val dolbyVisionCompatKey = androidx.datastore.preferences.core.booleanPreferencesKey("dolby_vision_compat") private val subtitleAiApiKeyKey = androidx.datastore.preferences.core.stringPreferencesKey("subtitle_ai_api_key") private val subtitleAiModelKey = androidx.datastore.preferences.core.stringPreferencesKey("subtitle_ai_model") private val subtitleRemoveHearingImpairedKey = androidx.datastore.preferences.core.booleanPreferencesKey("subtitle_remove_hearing_impaired") @@ -493,6 +494,7 @@ class CloudSyncRepository @Inject constructor( root.put("subtitleAiAutoSelect", prefs[subtitleAiAutoSelectKey] ?: false) root.put("subtitleAiFindBestMatch", prefs[subtitleAiFindBestMatchKey] ?: false) root.put("subtitlePreloadEnabled", prefs[subtitlePreloadEnabledKey] ?: true) + root.put("dolbyVisionCompatEnabled", prefs[dolbyVisionCompatKey] ?: true) root.put("subtitleAiApiKey", prefs[subtitleAiApiKeyKey] ?: "") root.put("subtitleAiModel", prefs[subtitleAiModelKey] ?: "GROQ_LLAMA_70B") root.put("subtitleRemoveHearingImpaired", prefs[subtitleRemoveHearingImpairedKey] ?: true) @@ -1238,6 +1240,10 @@ class CloudSyncRepository @Inject constructor( if (root.has("subtitlePreloadEnabled")) { prefs[subtitlePreloadEnabledKey] = root.optBoolean("subtitlePreloadEnabled", false) } + // has() guard: backups from app versions predating this field must not reset it. + if (root.has("dolbyVisionCompatEnabled")) { + prefs[dolbyVisionCompatKey] = root.optBoolean("dolbyVisionCompatEnabled", true) + } val apiKey = root.optString("subtitleAiApiKey", "") if (apiKey.isNotBlank()) prefs[subtitleAiApiKeyKey] = apiKey val model = root.optString("subtitleAiModel", "GROQ_LLAMA_70B") diff --git a/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionBaseLayerPolicy.kt b/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionBaseLayerPolicy.kt new file mode 100644 index 000000000..39641daa0 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionBaseLayerPolicy.kt @@ -0,0 +1,270 @@ +package com.arflix.tv.player.dv + +import android.content.Context +import android.hardware.display.DisplayManager +import android.media.MediaCodecInfo +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaCodecList +import android.os.Build +import android.view.Display + +/** + * Decides what to do with a Dolby Vision stream at the codec selector. It only intervenes + * where there's a concrete fix; everything else falls through to default ExoPlayer behavior. + * + * Convert DV7 to DV8.1 when: + * - the display is DV-capable and a Profile-8 decoder is present (Fire TV, Chromecast + * with Google TV, etc.); + * - the display is DV-capable but the DV decoder is hidden and doesn't advertise Profile 8 + * (some Amlogic boxes) — convert and let DolbyVisionCodecFallback find the decoder; + * - the display is HDR10-only but a DV8.1 decoder exists — convert and let it emit HDR10. + * + * A true DV7 decoder (e.g. Shield) stays NATIVE_DV7; HDR10-only displays elsewhere strip + * to the HEVC base layer. + */ +object DolbyVisionBaseLayerPolicy { + + enum class Decision { + /** Display supports DV. Either a native DV7 decoder is present, or we have no + * useful intervention - pass the stream through and let the device handle it. */ + NATIVE_DV7, + /** Convert DV7 RPU to DV8.1 with libdovi, then feed to a DV-aware decoder. + * Used on Fire TV (profile-8-only Mediatek decoder), Xiaomi boxes (hidden + * Amlogic DV decoder), and Samsung HDR10 (no DV display but DV81 decoder). */ + CONVERT_TO_DV81, + /** Display supports HDR10/HDR10+. Strip DV, play HEVC base layer. */ + STRIP_TO_HDR10, + /** Display capabilities unknown (pre-N or null caps). Strip defensively. */ + STRIP_BEST_EFFORT, + /** Display known to be SDR-only or HLG-only. Strip and let renderer tonemap. */ + STRIP_AND_TONEMAP + } + + data class Result( + val decision: Decision, + val hdrCapsKnown: Boolean, + val displayDv: Boolean, + val displayHdr10: Boolean, + val displayHdr10Plus: Boolean, + val displayHlg: Boolean, + val codecSupportsDvheDtb: Boolean, + val codecSupportsDvheStn: Boolean, + val codecSupportsDvheSt: Boolean, + val isAmazonFireTv: Boolean, + val isSamsung: Boolean, + val isXiaomi: Boolean, + val bridgeReady: Boolean, + val apiLevel: Int + ) { + /** True when DV7 streams should be diverted away from the native DV7 decoder path. */ + val divertsFromNativeDv7: Boolean + get() = decision != Decision.NATIVE_DV7 + + /** True when DV7 should be mapped to its HEVC base layer at the codec selector. */ + val mapToHevc: Boolean + get() = when (decision) { + Decision.STRIP_TO_HDR10, + Decision.STRIP_BEST_EFFORT, + Decision.STRIP_AND_TONEMAP -> true + else -> false + } + } + + fun resolveFromCapabilities( + hdrCapsKnown: Boolean, + displayDv: Boolean, + displayHdr10: Boolean, + displayHdr10Plus: Boolean, + displayHlg: Boolean, + codecSupportsDvheDtb: Boolean, + codecSupportsDvheStn: Boolean, + codecSupportsDvheSt: Boolean, + isAmazonFireTv: Boolean, + isSamsung: Boolean, + isXiaomi: Boolean, + bridgeReady: Boolean, + apiLevel: Int + ): Result { + val displayHdr10Family = displayHdr10 || displayHdr10Plus + + val decision = when { + !hdrCapsKnown -> Decision.STRIP_BEST_EFFORT + + // Display does DV, device has native DV7 decoder: best case, do nothing. + // Shield TV and similar with real DvheDtb decoder. + displayDv && codecSupportsDvheDtb -> Decision.NATIVE_DV7 + + // General DV-display convert path: any DV-capable display whose device has a + // Profile-8 decoder (DVHE.ST/STH) and a ready bridge. libdovi rewrites the DV7 + // RPU to DV8.1 (mode 1 / ToMel by default) and the chip decodes 8.1 and emits + // DV. This was previously gated to Amazon Fire TV only, on May-2026 data that + // showed conversion degraded to HDR10 on Amlogic (Chromecast). Later testing + // showed the mode-1 app-level conversion produces real DV on Chromecast, LG + // panels, etc., so the manufacturer gate is removed. Devices with a true DV7 + // decoder (DvheDtb, e.g. Shield) are handled by the NATIVE_DV7 branch above and + // never reach here; DV displays without a P8 decoder fall through to NATIVE_DV7 + // below. + displayDv && bridgeReady && codecSupportsDvheSt -> + Decision.CONVERT_TO_DV81 + + // Xiaomi box path: Amlogic DV decoder exists but does NOT advertise Profile 8 + // via MediaCodecList API (codecSupportsDvheSt is false). The hardware can + // decode DV8.1 when fed directly. DolbyVisionCodecFallback handles finding + // the hidden decoder in the codec selector. + // No codecSupportsDvheSt requirement — that's the whole point of this branch. + displayDv && isXiaomi && bridgeReady -> Decision.CONVERT_TO_DV81 + + // DV-capable display, non-intervened device. Pass through and let the device's + // media stack handle it. Chromecast with Google TV (older Amlogic) decodes + // DV7 natively. Google TV Streamer / Onn / MeCool etc. fall back to HEVC + // base layer via ExoPlayer's decoder fallback path, producing HDR10. Either + // way, no intervention from us improves things. + displayDv -> Decision.NATIVE_DV7 + + // Samsung HDR10 fallback (User 3): no DV display but a DV81 decoder is + // available. Convert DV7 to DV81 so the decoder emits HDR10. Without this + // branch the HEVC base layer fallback fails on some MTK SoCs. + // Also fires for Amazon devices on HDR10-only TVs (Karat + HDR10 TV). + displayHdr10Family && bridgeReady && codecSupportsDvheSt && (isSamsung || isAmazonFireTv) -> + Decision.CONVERT_TO_DV81 + + // Xiaomi box on HDR10-only TV: convert so the hidden decoder can emit HDR10. + // Without this, STRIP_TO_HDR10 fires and the HEVC fallback may fail on some + // Amlogic firmware (same class of issue as Samsung HDR10 path). + displayHdr10Family && isXiaomi && bridgeReady -> Decision.CONVERT_TO_DV81 + + // HDR10/HDR10+ display on a non-Amazon, non-Samsung, non-Xiaomi device. + // Includes Google TV Streamer on HDR10 TV. Strip DV, play HEVC base layer. + displayHdr10Family -> Decision.STRIP_TO_HDR10 + + else -> Decision.STRIP_AND_TONEMAP + } + + return Result( + decision = decision, + hdrCapsKnown = hdrCapsKnown, + displayDv = displayDv, + displayHdr10 = displayHdr10, + displayHdr10Plus = displayHdr10Plus, + displayHlg = displayHlg, + codecSupportsDvheDtb = codecSupportsDvheDtb, + codecSupportsDvheStn = codecSupportsDvheStn, + codecSupportsDvheSt = codecSupportsDvheSt, + isAmazonFireTv = isAmazonFireTv, + isSamsung = isSamsung, + isXiaomi = isXiaomi, + bridgeReady = bridgeReady, + apiLevel = apiLevel + ) + } + + fun resolve(context: Context, bridgeReady: Boolean): Result { + val apiLevel = Build.VERSION.SDK_INT + val manufacturer = Build.MANUFACTURER + val isAmazonFireTv = manufacturer.equals("Amazon", ignoreCase = true) + val isSamsung = manufacturer.equals("Samsung", ignoreCase = true) + val isXiaomi = manufacturer.equals("Xiaomi", ignoreCase = true) + + if (apiLevel < Build.VERSION_CODES.N) { + return resolveFromCapabilities( + hdrCapsKnown = false, + displayDv = false, + displayHdr10 = false, + displayHdr10Plus = false, + displayHlg = false, + codecSupportsDvheDtb = false, + codecSupportsDvheStn = false, + codecSupportsDvheSt = false, + isAmazonFireTv = isAmazonFireTv, + isSamsung = isSamsung, + isXiaomi = isXiaomi, + bridgeReady = bridgeReady, + apiLevel = apiLevel + ) + } + + @Suppress("DEPRECATION") + val hdrTypes: IntArray? = runCatching { + val dm = context.getSystemService(DisplayManager::class.java) + val display = dm?.getDisplay(Display.DEFAULT_DISPLAY) + display?.hdrCapabilities?.supportedHdrTypes + }.getOrNull() + + val hdrCapsKnown = hdrTypes != null + val displayDv = hdrTypes?.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION) == true + val displayHdr10 = hdrTypes?.contains(Display.HdrCapabilities.HDR_TYPE_HDR10) == true + val displayHdr10Plus = + hdrTypes?.contains(Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS) == true + val displayHlg = hdrTypes?.contains(Display.HdrCapabilities.HDR_TYPE_HLG) == true + + val decoderProfiles = queryDvDecoderProfileSupport() + + return resolveFromCapabilities( + hdrCapsKnown = hdrCapsKnown, + displayDv = displayDv, + displayHdr10 = displayHdr10, + displayHdr10Plus = displayHdr10Plus, + displayHlg = displayHlg, + codecSupportsDvheDtb = decoderProfiles.dvheDtb, + codecSupportsDvheStn = decoderProfiles.dvheStn, + codecSupportsDvheSt = decoderProfiles.dvheSt, + isAmazonFireTv = isAmazonFireTv, + isSamsung = isSamsung, + isXiaomi = isXiaomi, + bridgeReady = bridgeReady, + apiLevel = apiLevel + ) + } + + private data class DvDecoderProfileSupport( + val dvheDtb: Boolean, // P7 + val dvheStn: Boolean, // P5 + val dvheSt: Boolean // P8 + ) + + /** + * Enumerates the platform's decoders and reports which DV profiles they advertise. + * Returns all-false on any error or pre-N devices. + */ + private fun queryDvDecoderProfileSupport(): DvDecoderProfileSupport { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return DvDecoderProfileSupport(false, false, false) + } + return runCatching { + var dvheDtb = false + var dvheStn = false + var dvheSt = false + val list = MediaCodecList(MediaCodecList.REGULAR_CODECS) + for (info in list.codecInfos) { + if (info.isEncoder) continue + val supportsDvMime = info.supportedTypes.any { type -> + type.equals(DOLBY_VISION_MIME, ignoreCase = true) + } + if (!supportsDvMime) continue + val caps: MediaCodecInfo.CodecCapabilities = runCatching { + info.getCapabilitiesForType(DOLBY_VISION_MIME) + }.getOrNull() ?: continue + val profileLevels = caps.profileLevels ?: continue + for (profileLevel in profileLevels) { + when (profileLevel.profile) { + DvheDtbProfile -> dvheDtb = true + DvheStnProfile -> dvheStn = true + DvheStProfile -> dvheSt = true + } + } + } + DvDecoderProfileSupport(dvheDtb, dvheStn, dvheSt) + }.getOrDefault(DvDecoderProfileSupport(false, false, false)) + } + + private const val DOLBY_VISION_MIME = "video/dolby-vision" + + /** Android constant for DV Profile 7 (dual-layer FEL/MEL). Added in API 24. */ + private const val DvheDtbProfile = CodecProfileLevel.DolbyVisionProfileDvheDtb + + /** Android constant for DV Profile 5 (single-layer DV-only HEVC). Added in API 24. */ + private const val DvheStnProfile = CodecProfileLevel.DolbyVisionProfileDvheStn + + /** Android constant for DV Profile 8 (single-layer HDR10-compatible HEVC). Added in API 24. */ + private const val DvheStProfile = CodecProfileLevel.DolbyVisionProfileDvheSt +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripExtractorsFactory.kt b/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripExtractorsFactory.kt new file mode 100644 index 000000000..101002357 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripExtractorsFactory.kt @@ -0,0 +1,68 @@ +package com.arflix.tv.player.dv + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import com.arflix.tv.player.dvmkv.DolbyVisionCompatibility +import com.arflix.tv.player.dvmkv.MatroskaExtractor as DvMatroskaExtractor + +/** + * Wraps a stock [ExtractorsFactory] and swaps the stock Matroska extractor for the vendored + * [DvMatroskaExtractor] wired to a [DolbyVisionStripTransformer] — Dolby Vision profile-7 + * remux MKVs then play as their HDR10-compatible HEVC base layer on devices without a DV + * decoder (see docs in the dvmkv package and docs/dolby-vision-compat.md). + * + * MKV-scoped on purpose: DV P7 in the wild is BluRay remuxes, which are Matroska. MP4 DV + * WEB-DLs are P8/P5 single-layer — P8 already falls back to the plain HEVC decoder via media3's + * built-in Dolby Vision codec mapping, and P5 has no compatible base layer to strip. MP4/fMP4/TS + * sample interception would only matter for a future RPU-conversion path, which is out of scope. + * + * Every non-MKV extractor passes through untouched, and a non-DV MKV pays only a per-sample + * no-op transformer call — the vendored extractor is otherwise stock media3 1.9.0. + */ +@UnstableApi +class DolbyVisionStripExtractorsFactory( + private val delegate: ExtractorsFactory, + private val stripHdr10PlusSei: Boolean = false, + /** + * Consulted at extractor-creation time (i.e. per prepare), so the user toggle and per-URL + * forcing take effect without rebuilding the media source factories that hold this wrapper. + */ + private val enabledProvider: () -> Boolean = { true }, +) : ExtractorsFactory { + + override fun createExtractors(): Array = + delegate.createExtractors().map(::wrap).toTypedArray() + + override fun createExtractors( + uri: Uri, + responseHeaders: Map> + ): Array = + delegate.createExtractors(uri, responseHeaders).map(::wrap).toTypedArray() + + private fun wrap(extractor: Extractor): Extractor { + val enabled = enabledProvider() + // The vendored extractor's format rewrite (video/dolby-vision -> video/H265 with the + // container's base-layer codec string) is gated on this process-wide flag. Only the + // vendored extractor consults it, and only this factory instantiates that extractor, + // so refreshing it per created extractor keeps the flag coherent with the toggle. + DolbyVisionCompatibility.setHdr10BaseLayerModeActive(enabled) + if (!enabled) return extractor + // The DV7 RPU rides in Matroska BlockAdditional, which the stock MatroskaExtractor + // discards before any TrackOutput — swapping the extractor is the only seam. + if (extractor.javaClass.name == STOCK_MATROSKA_EXTRACTOR) { + return DvMatroskaExtractor( + DefaultSubtitleParserFactory(), + /* flags= */ 0, + DolbyVisionStripTransformer(stripHdr10PlusSei) + ) + } + return extractor + } + + private companion object { + private const val STOCK_MATROSKA_EXTRACTOR = "androidx.media3.extractor.mkv.MatroskaExtractor" + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripTransformer.kt b/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripTransformer.kt new file mode 100644 index 000000000..ad851aa5d --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/player/dv/DolbyVisionStripTransformer.kt @@ -0,0 +1,121 @@ +package com.arflix.tv.player.dv + +import android.util.Log +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.container.DolbyVisionConfig +import com.arflix.tv.player.dvmkv.MatroskaExtractor +import java.util.concurrent.atomic.AtomicLong + +/** + * Strip-only implementation of the vendored Matroska extractor's + * [MatroskaExtractor.DolbyVisionSampleTransformer] seam. A DV8.1 RPU-conversion path is + * intentionally not implemented — this app plays the HDR10-compatible base layer; the conversion + * seams remain in the vendored extractor for a future phase. + * + * Per sample: drops the Matroska BlockAdditional RPU, strips in-band DV RPU NALs (type 62) and + * every enhancement-layer NAL (nuh_layer_id > 0) via [HevcDvRpuStripper]. The format-level + * rewrite (video/dolby-vision → video/H265 with the container's base-layer codec string) happens + * inside the vendored extractor, gated on + * [com.arflix.tv.player.dvmkv.DolbyVisionCompatibility.isHdr10BaseLayerModeActive]. + * + * Graceful degradation is load-bearing: any failure path returns the ORIGINAL sample (the + * vendored extractor additionally catches transformer exceptions per sample) — a strip problem + * must degrade to today's behavior, never to a hard playback error. + */ +@UnstableApi +internal class DolbyVisionStripTransformer( + private val stripHdr10PlusSei: Boolean = false, +) : MatroskaExtractor.DolbyVisionSampleTransformer { + + private var lastTransformedLength = 0 + private var loggedFirstStrip = false + + override fun onDolbyVisionBlockAdditionalData( + blockAdditionalData: ByteArray?, + blockAddIdType: Int, + dolbyVisionConfigBytes: ByteArray? + ): ByteArray? { + // Strip mode: never retain the BlockAdditional RPU. + if (blockAdditionalData == null) return null + return ByteArray(0) + } + + override fun onHevcSample( + sampleSizeBytes: Int, + blockAdditionalData: ByteArray?, + dolbyVisionConfigBytes: ByteArray? + ) { + // Telemetry-only seam; nothing to do. + } + + override fun lastTransformedSampleLength(): Int = lastTransformedLength + + override fun transformHevcSample( + sampleLengthDelimitedData: ByteArray?, + sampleLength: Int, + nalUnitLengthFieldLength: Int, + blockAdditionalData: ByteArray?, + dolbyVisionConfigBytes: ByteArray? + ): ByteArray? { + val sample = sampleLengthDelimitedData ?: return null + lastTransformedLength = sampleLength + + // Profile 5 has NO backward-compatible base layer (IPTPQc2) — stripping its RPU would + // leave undisplayable purple/green video. Pass through untouched. + if (resolveProfile(dolbyVisionConfigBytes) == 5) { + return stripHdr10PlusIfEnabled(sample, sampleLength, nalUnitLengthFieldLength) ?: sample + } + + val stripped = HevcDvRpuStripper.stripRpuLengthDelimited( + sample, sampleLength, nalUnitLengthFieldLength + ) + if (stripped != null) { + samplesStripped.incrementAndGet() + if (!loggedFirstStrip) { + loggedFirstStrip = true + Log.i(TAG, "DV strip active: first sample rewritten ($sampleLength -> ${stripped.size} bytes)") + } + lastTransformedLength = stripped.size + return stripHdr10PlusIfEnabled(stripped, stripped.size, nalUnitLengthFieldLength) ?: stripped + } + return stripHdr10PlusIfEnabled(sample, sampleLength, nalUnitLengthFieldLength) ?: sample + } + + override fun onDolbyVisionCodecString( + codecs: String?, + dolbyVisionConfigBytes: ByteArray? + ): String? { + // Strip mode never advertises a DV codec rewrite — the extractor's + // isHdr10BaseLayerModeActive branch swaps the whole format to base-layer HEVC instead. + return null + } + + private fun stripHdr10PlusIfEnabled( + data: ByteArray, + len: Int, + nalLengthFieldLength: Int + ): ByteArray? { + if (!stripHdr10PlusSei) return null + val stripped = HevcHdr10PlusStripper.stripHdr10PlusLengthDelimited(data, len, nalLengthFieldLength) + if (stripped != null) { + lastTransformedLength = stripped.size + return stripped + } + return null + } + + private fun resolveProfile(configBytes: ByteArray?): Int? { + if (configBytes == null || configBytes.isEmpty()) return null + return runCatching { + DolbyVisionConfig.parse(ParsableByteArray(configBytes))?.profile + }.getOrNull() + } + + companion object { + private const val TAG = "DvCompat" + + /** Process-wide count of rewritten samples — verification/diagnostics only. */ + val samplesStripped = AtomicLong(0) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/player/dv/HevcDvRpuStripper.kt b/app/src/main/kotlin/com/arflix/tv/player/dv/HevcDvRpuStripper.kt new file mode 100644 index 000000000..088409e1c --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/player/dv/HevcDvRpuStripper.kt @@ -0,0 +1,131 @@ +package com.arflix.tv.player.dv + +import androidx.media3.common.util.UnstableApi +import java.io.ByteArrayOutputStream + +/** + * Strips Dolby Vision RPU NAL units (HEVC NAL type 62) from an HEVC bitstream + * on-the-fly, leaving the HDR10/HDR10+ base layer intact. + */ +@UnstableApi +internal object HevcDvRpuStripper { + + private const val NAL_TYPE_DV_RPU = 62 + private const val NAL_TYPE_DV_EL = 63 + + /** + * Rewrites a length-delimited (MP4/fMP4) sample, removing any NAL unit + * whose type is 62 (DV RPU). Returns the rewritten bytes, or null if + * nothing was stripped (caller should use the original). + */ + fun stripRpuLengthDelimited( + sample: ByteArray, + sampleLen: Int, + nalLengthFieldLength: Int, + ): ByteArray? { + if (sampleLen < nalLengthFieldLength) return null + val out = ByteArrayOutputStream(sampleLen) + var pos = 0 + var changed = false + while (pos + nalLengthFieldLength <= sampleLen) { + var nalSize = 0 + for (i in 0 until nalLengthFieldLength) { + nalSize = (nalSize shl 8) or (sample[pos + i].toInt() and 0xFF) + } + val nalStart = pos + nalLengthFieldLength + if (nalSize <= 0 || nalStart + nalSize > sampleLen) return null + val nalHeader = sample[nalStart].toInt() + val nalType = (nalHeader ushr 1) and 0x3F + val layerId = + if (nalStart + 1 < sampleLen) { + ((nalHeader and 0x01) shl 5) or + ((sample[nalStart + 1].toInt() and 0xF8) ushr 3) + } else { + 0 + } + val isRpu = nalType == NAL_TYPE_DV_RPU + val isEnhancementLayer = + layerId > 0 + val shouldDrop = isRpu || isEnhancementLayer + if (shouldDrop) { + changed = true + } else { + for (i in nalLengthFieldLength - 1 downTo 0) { + out.write((nalSize ushr (i * 8)) and 0xFF) + } + out.write(sample, nalStart, nalSize) + } + pos = nalStart + nalSize + } + return if (changed) out.toByteArray() else null + } + + /** + * Rewrites an Annex-B (TS/raw HEVC) sample, removing DV RPU NAL units. + * Returns the rewritten bytes, or null if nothing was stripped. + */ + fun stripRpuAnnexB(sample: ByteArray, sampleLen: Int): ByteArray? { + val out = ByteArrayOutputStream(sampleLen) + var scan = 0 + var changed = false + while (scan < sampleLen) { + val startCode = findStartCode(sample, scan, sampleLen) + if (startCode < 0) { + out.write(sample, scan, sampleLen - scan) + break + } + val scLen = startCodeLength(sample, startCode, sampleLen) + val nalBegin = startCode + scLen + val nextStartCode = findStartCode(sample, nalBegin + 2, sampleLen) + val nalEnd = if (nextStartCode < 0) sampleLen else nextStartCode + + // Write any bytes before this start code + if (startCode > scan) out.write(sample, scan, startCode - scan) + + if (nalBegin < nalEnd) { + val nalType = (sample[nalBegin].toInt() ushr 1) and 0x3F + val layerId = + if (nalBegin + 1 < nalEnd) { + ((sample[nalBegin].toInt() and 0x01) shl 5) or + ((sample[nalBegin + 1].toInt() ushr 3) and 0x1F) + } else { + 0 + } + + if ( + nalType == NAL_TYPE_DV_RPU || + nalType == NAL_TYPE_DV_EL || + layerId > 0 + ) { + changed = true + // Drop start code + NAL payload entirely + } else { + out.write(sample, startCode, nalEnd - startCode) + } + } + scan = nalEnd + } + return if (changed) out.toByteArray() else null + } + + private fun findStartCode(data: ByteArray, from: Int, limit: Int): Int { + var i = from + while (i + 2 < limit) { + if (data[i].toInt() == 0 && data[i + 1].toInt() == 0) { + if (data[i + 2].toInt() == 1) return i + if (i + 3 < limit && data[i + 2].toInt() == 0 && data[i + 3].toInt() == 1) return i + } + i++ + } + return -1 + } + + private fun startCodeLength(data: ByteArray, offset: Int, limit: Int): Int { + return if (offset + 3 < limit && + data[offset].toInt() == 0 && + data[offset + 1].toInt() == 0 && + data[offset + 2].toInt() == 0 && + data[offset + 3].toInt() == 1 + ) 4 else 3 + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/player/dv/HevcHdr10PlusStripper.kt b/app/src/main/kotlin/com/arflix/tv/player/dv/HevcHdr10PlusStripper.kt new file mode 100644 index 000000000..9d2caaf42 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/player/dv/HevcHdr10PlusStripper.kt @@ -0,0 +1,270 @@ +package com.arflix.tv.player.dv + +import android.util.Log +import androidx.media3.common.util.UnstableApi +import java.io.ByteArrayOutputStream + +/** + * Strips HDR10+ SEI messages from an HEVC bitstream, leaving the HDR10 base + * layer and all other SEI content intact. + * + * HDR10+ metadata arrives as user_data_registered_itu_t_t35 SEI (payload type 4) + * inside HEVC Prefix SEI (NAL type 39) or Suffix SEI (NAL type 40) NAL units. + * Identified by: ITU-T T.35 country_code=0xB5, provider_code=0x003C (Samsung), + * provider_oriented_code=0x0001. + * + * Per-SEI-message filtering is used: non-HDR10+ messages in the same NAL unit + * are preserved. If an entire SEI NAL contains only HDR10+ messages it is + * dropped; if it has mixed content the NAL is reconstructed without HDR10+. + */ +@UnstableApi +internal object HevcHdr10PlusStripper { + + private const val NAL_TYPE_PREFIX_SEI = 39 + private const val NAL_TYPE_SUFFIX_SEI = 40 + private const val SEI_PAYLOAD_TYPE_USER_DATA_REGISTERED = 4 + + // ITU-T T.35: country_code=0xB5 (USA), provider_code=0x003C, provider_oriented_code=0x0001 + private val HDR10_PLUS_T35_SIGNATURE = byteArrayOf( + 0xB5.toByte(), 0x00, 0x3C.toByte(), 0x00, 0x01 + ) + + /** + * Rewrites a length-delimited (MP4/fMP4) sample, removing HDR10+ SEI messages. + * Returns the rewritten bytes, or null if nothing was stripped. + */ + fun stripHdr10PlusLengthDelimited( + sample: ByteArray, + sampleLen: Int, + nalLengthFieldLength: Int + ): ByteArray? { + if (sampleLen < nalLengthFieldLength) return null + val out = ByteArrayOutputStream(sampleLen) + var pos = 0 + var changed = false + while (pos + nalLengthFieldLength <= sampleLen) { + var nalSize = 0 + for (i in 0 until nalLengthFieldLength) { + nalSize = (nalSize shl 8) or (sample[pos + i].toInt() and 0xFF) + } + val nalStart = pos + nalLengthFieldLength + if (nalSize <= 0 || nalStart + nalSize > sampleLen) { + // Fallback: If length field parsing fails due to missing/corrupted CSD, + // perform an emergency Annex-B byte scan pass over this sample frame. + return stripHdr10PlusAnnexB(sample, sampleLen) + } + val nalType = (sample[nalStart].toInt() ushr 1) and 0x3F + if (nalType == NAL_TYPE_PREFIX_SEI || nalType == NAL_TYPE_SUFFIX_SEI) { + val filtered = filterSeiNal(sample, nalStart, nalSize) + when { + filtered == null -> { + // No HDR10+ in this NAL; keep as-is + for (i in nalLengthFieldLength - 1 downTo 0) { + out.write((nalSize ushr (i * 8)) and 0xFF) + } + out.write(sample, nalStart, nalSize) + } + filtered.isNotEmpty() -> { + // Some HDR10+ stripped; output reduced SEI NAL + changed = true + for (i in nalLengthFieldLength - 1 downTo 0) { + out.write((filtered.size ushr (i * 8)) and 0xFF) + } + out.write(filtered) + } + else -> changed = true // entire NAL was HDR10+; drop it + } + } else { + for (i in nalLengthFieldLength - 1 downTo 0) { + out.write((nalSize ushr (i * 8)) and 0xFF) + } + out.write(sample, nalStart, nalSize) + } + pos = nalStart + nalSize + } + return if (changed) out.toByteArray() else null + } + + /** + * Rewrites an Annex-B (TS/raw HEVC) sample, removing HDR10+ SEI messages. + * Returns the rewritten bytes, or null if nothing was stripped. + */ + fun stripHdr10PlusAnnexB(sample: ByteArray, sampleLen: Int): ByteArray? { + val out = ByteArrayOutputStream(sampleLen) + var scan = 0 + var changed = false + while (scan < sampleLen) { + val startCode = findStartCode(sample, scan, sampleLen) + if (startCode < 0) { + out.write(sample, scan, sampleLen - scan) + break + } + val scLen = startCodeLength(sample, startCode, sampleLen) + val nalBegin = startCode + scLen + val nextStartCode = findStartCode(sample, nalBegin + 2, sampleLen) + val nalEnd = if (nextStartCode < 0) sampleLen else nextStartCode + + if (startCode > scan) out.write(sample, scan, startCode - scan) + + if (nalBegin < nalEnd) { + val nalSize = nalEnd - nalBegin + val nalType = (sample[nalBegin].toInt() ushr 1) and 0x3F + if (nalType == NAL_TYPE_PREFIX_SEI || nalType == NAL_TYPE_SUFFIX_SEI) { + val filtered = filterSeiNal(sample, nalBegin, nalSize) + when { + filtered == null -> out.write(sample, startCode, nalEnd - startCode) + filtered.isNotEmpty() -> { + changed = true + out.write(sample, startCode, scLen) + out.write(filtered) + } + else -> changed = true // entire NAL was HDR10+; drop it + } + } else { + out.write(sample, startCode, nalEnd - startCode) + } + } + scan = nalEnd + } + return if (changed) out.toByteArray() else null + } + + /** + * Parses a SEI NAL unit and removes HDR10+ messages. + * Returns null → no HDR10+ found (caller keeps original) + * Returns empty → all messages were HDR10+ (caller drops NAL) + * Returns bytes → filtered NAL with HDR10+ messages removed + */ + private fun filterSeiNal(data: ByteArray, nalOffset: Int, nalSize: Int): ByteArray? { + if (nalSize < 3) return null + + // 1. Un-escape the NAL unit payload to get raw RBSP bytes (remove 0x00 0x00 0x03) + val rbsp = ByteArrayOutputStream(nalSize) + // Preserve the 2-byte HEVC NAL header + rbsp.write(data[nalOffset].toInt() and 0xFF) + rbsp.write(data[nalOffset + 1].toInt() and 0xFF) + + var i = nalOffset + 2 + val end = nalOffset + nalSize + while (i < end) { + if (i + 2 < end && data[i].toInt() == 0 && data[i + 1].toInt() == 0 && data[i + 2].toInt() == 3) { + rbsp.write(0) + rbsp.write(0) + i += 3 + } else { + rbsp.write(data[i].toInt() and 0xFF) + i++ + } + } + + val rbspData = rbsp.toByteArray() + val rbspEnd = rbspData.size + + // 2. Filter out HDR10+ messages from the un-escaped RBSP data + val outRbsp = ByteArrayOutputStream(rbspEnd) + outRbsp.write(rbspData[0].toInt() and 0xFF) + outRbsp.write(rbspData[1].toInt() and 0xFF) + + var pos = 2 + var hasHdr10Plus = false + + while (pos < rbspEnd) { + // Lone 0x80 at end = RBSP stop bit; nothing more to parse + if (rbspEnd - pos == 1 && (rbspData[pos].toInt() and 0xFF) == 0x80) break + + val msgStart = pos + + // Read SEI payloadType (variable-length encoding) + var payloadType = 0 + while (pos < rbspEnd) { + val b = rbspData[pos++].toInt() and 0xFF + payloadType += b + if (b != 0xFF) break + } + + // Read SEI payloadSize (variable-length encoding) + var payloadSize = 0 + while (pos < rbspEnd) { + val b = rbspData[pos++].toInt() and 0xFF + payloadSize += b + if (b != 0xFF) break + } + + if (pos + payloadSize > rbspEnd) return null // malformed; keep original + val payloadStart = pos + pos += payloadSize + val msgEnd = pos + + if (payloadType == SEI_PAYLOAD_TYPE_USER_DATA_REGISTERED && + payloadSize >= HDR10_PLUS_T35_SIGNATURE.size && + matchesSignature(rbspData, payloadStart, HDR10_PLUS_T35_SIGNATURE) + ) { + hasHdr10Plus = true // strip this SEI message + } else { + outRbsp.write(rbspData, msgStart, msgEnd - msgStart) // keep verbatim + } + } + + if (!hasHdr10Plus) return null + if (outRbsp.size() <= 2) return ByteArray(0) // only NAL header left → whole NAL was HDR10+ + + outRbsp.write(0x80) // RBSP stop bit + val filteredRbsp = outRbsp.toByteArray() + + // 3. Re-escape the modified RBSP data back into a valid NAL unit payload + val finalNal = ByteArrayOutputStream(filteredRbsp.size) + finalNal.write(filteredRbsp[0].toInt() and 0xFF) + finalNal.write(filteredRbsp[1].toInt() and 0xFF) + + var consecutiveZeros = 0 + if (filteredRbsp[0] == 0.toByte()) consecutiveZeros++ + if (filteredRbsp[1] == 0.toByte()) { + consecutiveZeros = if (consecutiveZeros == 1) 2 else 1 + } else { + consecutiveZeros = 0 + } + + for (j in 2 until filteredRbsp.size) { + val b = filteredRbsp[j].toInt() and 0xFF + if (consecutiveZeros == 2 && (b == 0 || b == 1 || b == 2 || b == 3)) { + finalNal.write(0x03) + consecutiveZeros = 0 + } + finalNal.write(b) + if (b == 0) { + consecutiveZeros++ + } else { + consecutiveZeros = 0 + } + } + + return finalNal.toByteArray() + } + + private fun findStartCode(data: ByteArray, from: Int, limit: Int): Int { + var i = from + while (i + 2 < limit) { + if (data[i].toInt() == 0 && data[i + 1].toInt() == 0) { + if (data[i + 2].toInt() == 1) return i + if (i + 3 < limit && data[i + 2].toInt() == 0 && data[i + 3].toInt() == 1) return i + } + i++ + } + return -1 + } + + private fun matchesSignature(data: ByteArray, offset: Int, sig: ByteArray): Boolean { + if (offset + sig.size > data.size) return false + for (i in sig.indices) { + if (data[offset + i] != sig[i]) return false + } + return true + } + + private fun startCodeLength(data: ByteArray, offset: Int, limit: Int): Int { + return if (offset + 3 < limit && + data[offset].toInt() == 0 && data[offset + 1].toInt() == 0 && + data[offset + 2].toInt() == 0 && data[offset + 3].toInt() == 1 + ) 4 else 3 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 3243c397b..0193e14e8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -802,10 +802,37 @@ fun PlayerScreen( .setUpstreamDataSourceFactory(fileCapableDataSourceFactory) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) } - // Non-cached factory for heavy/debrid progressive streams to avoid disk I/O bottleneck + // Dolby Vision compatibility (see com.arflix.tv.player.dv): on devices + // whose display/decoder can't do DV, profile-7 remux MKVs are rewritten on the fly to their + // HDR10 HEVC base layer. The policy is probed once; the per-prepare decision (user toggle + + // per-URL force from the black-video watchdog) is read through the provider so it always + // reflects the CURRENT state — extractors are created at prepare time. + val dvPolicy = remember { com.arflix.tv.player.dv.DolbyVisionBaseLayerPolicy.resolve(context, bridgeReady = false) } + val dvForcedStripUrls = remember { mutableSetOf() } + fun dvStripEnabledNow(): Boolean = + latestUiState.dolbyVisionCompatEnabled || + dvForcedStripUrls.contains(latestUiState.selectedStreamUrl.orEmpty()) + val dvStripExtractorsFactory = remember(dvPolicy) { + if (!dvPolicy.mapToHevc) null else { + android.util.Log.i( + "DvCompat", + "policy=${dvPolicy.decision} displayDv=${dvPolicy.displayDv} hdr10=${dvPolicy.displayHdr10} dvP7Decoder=${dvPolicy.codecSupportsDvheDtb} — DV strip available" + ) + com.arflix.tv.player.dv.DolbyVisionStripExtractorsFactory( + androidx.media3.extractor.DefaultExtractorsFactory(), + enabledProvider = ::dvStripEnabledNow + ) + } + } + + // Non-cached factory for heavy/debrid progressive streams to avoid disk I/O bottleneck. + // The DV variant only differs by the rewriting ExtractorsFactory (MKV-only inside). val directProgressiveFactory = remember(httpDataSourceFactory) { ProgressiveMediaSource.Factory(httpDataSourceFactory) } + val directProgressiveDvFactory = remember(httpDataSourceFactory, dvStripExtractorsFactory) { + dvStripExtractorsFactory?.let { ProgressiveMediaSource.Factory(httpDataSourceFactory, it) } + } // Protocol-specific media source factories for faster startup val hlsFactory = remember(httpDataSourceFactory) { @@ -816,7 +843,8 @@ fun PlayerScreen( DashMediaSource.Factory(httpDataSourceFactory) } val mediaSourceFactory = remember(httpDataSourceFactory) { - DefaultMediaSourceFactory(context) + (dvStripExtractorsFactory?.let { DefaultMediaSourceFactory(context, it) } + ?: DefaultMediaSourceFactory(context)) .setDataSourceFactory(cacheDataSourceFactory) } // "Preload Subtitles" mode: sidecar subtitle configs only merge through a @@ -824,7 +852,8 @@ fun PlayerScreen( // the disk cache (I/O bottleneck). This variant keeps file:// support for the local subtitle // copies while streaming video uncached, like directProgressiveFactory. val preloadMediaSourceFactory = remember(fileCapableDataSourceFactory) { - DefaultMediaSourceFactory(context) + (dvStripExtractorsFactory?.let { DefaultMediaSourceFactory(context, it) } + ?: DefaultMediaSourceFactory(context)) .setDataSourceFactory(fileCapableDataSourceFactory) } @@ -1604,8 +1633,10 @@ fun PlayerScreen( isDashStream -> dashFactory.createMediaSource(mediaItem) isHeavy || isRemoteHttp -> - // Bypass disk cache for large/debrid progressive streams to avoid I/O bottleneck - directProgressiveFactory.createMediaSource(mediaItem) + // Bypass disk cache for large/debrid progressive streams to avoid I/O + // bottleneck. The DV variant additionally rewrites DV P7 MKUs to their + // HDR10 base layer (enabled-check happens inside the extractors factory). + (directProgressiveDvFactory ?: directProgressiveFactory).createMediaSource(mediaItem) else -> mediaSourceFactory.createMediaSource(mediaItem) } @@ -2123,6 +2154,28 @@ fun PlayerScreen( "black video failure no_first_frame elapsedMs=$stuckMs " + "state=${exoPlayer.playbackState} failovers=$autoAdvanceAttempts" ) + // Last resort before abandoning the source: if this looks like Dolby + // Vision and the strip pipeline exists but wasn't active for this URL + // (policy said the device handles DV natively, or the toggle is off), + // force-strip THIS url and re-prepare once before giving up on it. + val currentUrl = latestUiState.selectedStreamUrl.orEmpty() + if (dvStripExtractorsFactory != null && + currentUrl.isNotBlank() && + !dvStripEnabledNow() && + isLikelyDolbyVisionStream(latestUiState.selectedStream) && + dvForcedStripUrls.add(currentUrl) + ) { + android.util.Log.i("DvCompat", "black-video exhausted — forcing DV strip for this source") + val resumeAt = exoPlayer.currentPosition.coerceAtLeast(0L) + val keepPlaying = exoPlayer.playWhenReady + exoPlayer.stop() + exoPlayer.seekTo(resumeAt) + exoPlayer.prepare() + exoPlayer.playWhenReady = keepPlaying + blackVideoRecoveryStage = 0 + blackVideoReadySinceMs = null + continue + } if (allowStartupSourceFallback && !userSelectedSourceManually && tryAdvanceToNextStream() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index d33909774..9ba1dda14 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -94,6 +94,10 @@ data class PlayerUiState( // playback so they can be side-loaded into the initial MediaItem — switching then needs only // a track override, not a MediaItem rebuild (no visible video reload). Default ON. val subtitlePreloadEnabled: Boolean = true, + // Dolby Vision compatibility: strip DV P7 metadata so remuxes play as HDR10 on devices + // without a DV decoder (see com.arflix.tv.player.dv). Default ON; the device policy + // additionally gates activation to devices that actually need it. + val dolbyVisionCompatEnabled: Boolean = true, // Localized (file://) copies ready to attach. Separate from [subtitles]: menu entries keep // their remote URLs so scan/download flows are unaffected. val preloadedSubtitles: List = emptyList(), @@ -246,6 +250,7 @@ class PlayerViewModel @Inject constructor( private val aiAutoSelectKey = booleanPreferencesKey("subtitle_ai_auto_select") private val aiFindBestMatchKey = booleanPreferencesKey("subtitle_ai_find_best_match") private val subtitlePreloadKey = booleanPreferencesKey("subtitle_preload_enabled") + private val dolbyVisionCompatPrefKey = booleanPreferencesKey("dolby_vision_compat") private val aiApiKeyKey = globalStringPreferencesKey("subtitle_ai_api_key") private val aiModelKey = globalStringPreferencesKey("subtitle_ai_model") private val aiRemoveHearingImpairedKey = booleanPreferencesKey("subtitle_remove_hearing_impaired") @@ -570,6 +575,7 @@ class PlayerViewModel @Inject constructor( showLoadingStats = showLoadingStats, volumeBoostDb = volumeBoostDb, subtitlePreloadEnabled = subtitlePreloadEnabled, + dolbyVisionCompatEnabled = prefs[dolbyVisionCompatPrefKey] ?: true, // Nothing to preload without a preferred language — don't make the gate wait. subtitlePreloadComplete = normalizeLanguage(preferredSub).isBlank() ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 029c50aae..d82c31b9c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -218,7 +218,7 @@ private fun tvGeneralRowsForSection(section: String): List { "language" -> listOf(0, 3) "subtitles" -> listOf(1, 2, 38, 39, 4, 5, 6, 7, 8, 9) "ai_subtitles" -> listOf(28, 29, 30, 31, 32, 33) - "playback" -> listOf(10, 11, 12, 13, 14, 37, 34, 16, 15, 27) + "playback" -> listOf(10, 11, 12, 13, 14, 37, 34, 16, 15, 40, 27) "appearance" -> listOf(17, 18, 20, 21, 24, 23, 22, 36) "profiles" -> listOf(19) "network" -> listOf(25, 26, 35) @@ -905,6 +905,7 @@ fun SettingsScreen( 30 -> viewModel.setSubtitleAiAutoSelect(!uiState.subtitleAiAutoSelect) 38 -> viewModel.setSubtitleAiFindBestMatch(!uiState.subtitleAiFindBestMatch) 39 -> viewModel.setSubtitlePreloadEnabled(!uiState.subtitlePreloadEnabled) + 40 -> viewModel.setDolbyVisionCompatEnabled(!uiState.dolbyVisionCompatEnabled) 31 -> viewModel.setSubtitleRemoveHearingImpaired(!uiState.subtitleRemoveHearingImpaired) 32 -> showAiApiKeyDialog = true 33 -> viewModel.startAiKeyServer() @@ -1334,6 +1335,7 @@ fun SettingsScreen( subtitleAiAutoSelect = uiState.subtitleAiAutoSelect, subtitleAiFindBestMatch = uiState.subtitleAiFindBestMatch, subtitlePreloadEnabled = uiState.subtitlePreloadEnabled, + dolbyVisionCompatEnabled = uiState.dolbyVisionCompatEnabled, subtitleAiApiKey = uiState.subtitleAiApiKey, subtitleAiModel = uiState.subtitleAiModel, subtitleRemoveHearingImpaired = uiState.subtitleRemoveHearingImpaired, @@ -1342,6 +1344,7 @@ fun SettingsScreen( onSubtitleAiAutoSelectToggle = { viewModel.setSubtitleAiAutoSelect(it) }, onSubtitleAiFindBestMatchToggle = { viewModel.setSubtitleAiFindBestMatch(it) }, onSubtitlePreloadToggle = { viewModel.setSubtitlePreloadEnabled(it) }, + onDolbyVisionCompatToggle = { viewModel.setDolbyVisionCompatEnabled(it) }, onSubtitleRemoveHearingImpairedToggle = { viewModel.setSubtitleRemoveHearingImpaired(it) }, onSubtitleAiApiKeyClick = { showAiApiKeyDialog = true }, onSubtitleAiQrClick = { viewModel.startAiKeyServer() }, @@ -4763,6 +4766,7 @@ private fun TvGeneralSettingsRows( subtitleAiAutoSelect: Boolean = false, subtitleAiFindBestMatch: Boolean = false, subtitlePreloadEnabled: Boolean = false, + dolbyVisionCompatEnabled: Boolean = true, subtitleAiApiKey: String = "", subtitleAiModel: com.arflix.tv.ui.screens.player.SubtitleAiModel = com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, subtitleRemoveHearingImpaired: Boolean = true, @@ -4771,6 +4775,7 @@ private fun TvGeneralSettingsRows( onSubtitleAiAutoSelectToggle: (Boolean) -> Unit = {}, onSubtitleAiFindBestMatchToggle: (Boolean) -> Unit = {}, onSubtitlePreloadToggle: (Boolean) -> Unit = {}, + onDolbyVisionCompatToggle: (Boolean) -> Unit = {}, onSubtitleRemoveHearingImpairedToggle: (Boolean) -> Unit = {}, onSubtitleAiApiKeyClick: () -> Unit = {}, onSubtitleAiQrClick: () -> Unit = {}, @@ -4880,6 +4885,7 @@ private fun TvGeneralSettingsRows( // AI-independent: the timing-based match scan needs no API key. 38 -> SettingsToggleRow(stringResource(R.string.ai_find_best_match_title), stringResource(R.string.ai_find_best_match_desc), subtitleAiFindBestMatch, focusedIndex == localIndex, onSubtitleAiFindBestMatchToggle, Modifier.settingsFocusSlot(localIndex)) 39 -> SettingsToggleRow(stringResource(R.string.subtitle_preload_title), stringResource(R.string.subtitle_preload_desc), subtitlePreloadEnabled, focusedIndex == localIndex, onSubtitlePreloadToggle, Modifier.settingsFocusSlot(localIndex)) + 40 -> SettingsToggleRow(stringResource(R.string.dv_compat_title), stringResource(R.string.dv_compat_desc), dolbyVisionCompatEnabled, focusedIndex == localIndex, onDolbyVisionCompatToggle, Modifier.settingsFocusSlot(localIndex)) 31 -> SettingsToggleRow(stringResource(R.string.ai_remove_hi_title), stringResource(R.string.ai_remove_hi_desc), subtitleRemoveHearingImpaired, focusedIndex == localIndex, onSubtitleRemoveHearingImpairedToggle, Modifier.settingsFocusSlot(localIndex).alpha(if (subtitleAiEnabled) 1f else 0.4f)) 32 -> SettingsRow(Icons.Default.VpnKey, stringResource(R.string.ai_api_key_title), stringResource(R.string.ai_api_key_desc), maskAiApiKey(subtitleAiApiKey, stringResource(R.string.ai_key_not_set)), focusedIndex == localIndex, onSubtitleAiApiKeyClick, Modifier.settingsFocusSlot(localIndex).alpha(if (subtitleAiEnabled) 1f else 0.4f)) 33 -> SettingsRow(Icons.Default.QrCode, stringResource(R.string.ai_scan_qr_title), stringResource(R.string.ai_scan_qr_desc), "", focusedIndex == localIndex, onSubtitleAiQrClick, Modifier.settingsFocusSlot(localIndex).alpha(if (subtitleAiEnabled) 1f else 0.4f)) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 1350f0a0c..2e052d0ef 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -199,6 +199,7 @@ data class SettingsUiState( val subtitleAiAutoSelect: Boolean = false, val subtitleAiFindBestMatch: Boolean = false, val subtitlePreloadEnabled: Boolean = true, + val dolbyVisionCompatEnabled: Boolean = true, val subtitleAiApiKey: String = "", val subtitleAiModel: SubtitleAiModel = SubtitleAiModel.GROQ_LLAMA_70B, val subtitleRemoveHearingImpaired: Boolean = true, @@ -290,6 +291,7 @@ class SettingsViewModel @Inject constructor( private val subtitleAiAutoSelectKey = booleanPreferencesKey("subtitle_ai_auto_select") private val subtitleAiFindBestMatchKey = booleanPreferencesKey("subtitle_ai_find_best_match") private val subtitlePreloadEnabledKey = booleanPreferencesKey("subtitle_preload_enabled") + private val dolbyVisionCompatKey = booleanPreferencesKey("dolby_vision_compat") private val subtitleAiApiKeyKey = stringPreferencesKey("subtitle_ai_api_key") private val subtitleAiModelKey = stringPreferencesKey("subtitle_ai_model") private val subtitleRemoveHearingImpairedKey = booleanPreferencesKey("subtitle_remove_hearing_impaired") @@ -495,6 +497,7 @@ class SettingsViewModel @Inject constructor( val subtitleAiAutoSelect = prefs[subtitleAiAutoSelectKey] ?: false val subtitleAiFindBestMatch = prefs[subtitleAiFindBestMatchKey] ?: false val subtitlePreloadEnabled = prefs[subtitlePreloadEnabledKey] ?: true + val dolbyVisionCompatEnabled = prefs[dolbyVisionCompatKey] ?: true val subtitleAiApiKey = prefs[subtitleAiApiKeyKey] ?: "" val subtitleAiModel = runCatching { SubtitleAiModel.valueOf(prefs[subtitleAiModelKey] ?: SubtitleAiModel.GROQ_LLAMA_70B.name) @@ -566,6 +569,7 @@ class SettingsViewModel @Inject constructor( subtitleAiAutoSelect = subtitleAiAutoSelect, subtitleAiFindBestMatch = subtitleAiFindBestMatch, subtitlePreloadEnabled = subtitlePreloadEnabled, + dolbyVisionCompatEnabled = dolbyVisionCompatEnabled, subtitleAiApiKey = subtitleAiApiKey, subtitleAiModel = subtitleAiModel, subtitleRemoveHearingImpaired = subtitleRemoveHearingImpaired, @@ -1322,6 +1326,14 @@ class SettingsViewModel @Inject constructor( } } + fun setDolbyVisionCompatEnabled(enabled: Boolean) { + viewModelScope.launch { + context.settingsDataStore.edit { it[dolbyVisionCompatKey] = enabled } + _uiState.value = _uiState.value.copy(dolbyVisionCompatEnabled = enabled) + syncLocalStateToCloud(silent = true) + } + } + fun setSubtitleRemoveHearingImpaired(enabled: Boolean) { viewModelScope.launch { context.settingsDataStore.edit { it[subtitleRemoveHearingImpairedKey] = enabled } diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index df066c4e2..d4f51b077 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -212,6 +212,8 @@ בחירה אוטומטית של הכתובית המסונכרנת ביותר מההרחבות שלך טעינת כתוביות מראש המתן לכתוביות לפני תחילת הווידאו כך שהחלפה ביניהן תהיה מיידית + תאימות Dolby Vision + נגן תוכן Dolby Vision כ-HDR10 כשהמכשיר לא תומך בפענוח הסר טקסט למוגבלי שמיעה הסר סימוני [סוגריים] למוגבלי שמיעה לפני תרגום מפתח API diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7cefa516a..278818fe1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -239,6 +239,8 @@ Auto-pick the best-synced subtitle from your addons Preload Subtitles Wait for subtitles before starting the video so switching them is instant + Dolby Vision Compatibility + Play Dolby Vision releases as HDR10 when this device can\'t decode them Remove Hearing Impaired Text Strip [bracketed] hearing-impaired markers before translation API Key diff --git a/docs/dolby-vision-compat.md b/docs/dolby-vision-compat.md new file mode 100644 index 000000000..a94b65bb8 --- /dev/null +++ b/docs/dolby-vision-compat.md @@ -0,0 +1,56 @@ +# Dolby Vision Compatibility (DV7 → HDR10 base layer) — Reference + +Strip path only: a native libdovi DV7→DV8.1 conversion is deliberately NOT implemented — +the interface seams for it remain in the vendored extractor for a future phase. + +**Problem:** DV profile-7 BluRay-remux MKVs on devices without a DV decoder reach READY, play +audio, but render zero video frames (`black video recovery … size=0x0`), causing source-failover +churn. The DV7 RPU rides in Matroska **BlockAdditional**, which stock media3 discards before any +`TrackOutput` — there is no app-level seam, hence a vendored extractor. + +**Code map** + +| Concern | Location | +|---|---| +| Vendored media3 1.9.0 MKV extractor + DV hooks | `app/src/main/dvmkv-java/com/arflix/tv/player/dvmkv/` (own `java.srcDir`; see its `package-info.java` for the re-vendoring procedure on media3 bumps) | +| RPU/EL NAL stripping (types 62/63, `nuh_layer_id>0`) | `player/dv/HevcDvRpuStripper.kt` | +| HDR10+ SEI stripping (optional, off) | `player/dv/HevcHdr10PlusStripper.kt` | +| Strip-mode transformer (per-sample) | `player/dv/DolbyVisionStripTransformer.kt` | +| Wrapping ExtractorsFactory (MKV-only swap) | `player/dv/DolbyVisionStripExtractorsFactory.kt` | +| Device policy (display HDR caps + decoder probe) | `player/dv/DolbyVisionBaseLayerPolicy.kt` (called with `bridgeReady=false` → CONVERT branches dormant) | +| Wiring + per-URL forcing | `PlayerScreen.kt` (`dvPolicy` / `dvStripExtractorsFactory` / `dvForcedStripUrls`, factories block; black-video watchdog last-resort branch) | +| Setting `dolby_vision_compat` (default ON) | Settings → Playback row 40; SettingsViewModel; PlayerViewModel → `PlayerUiState.dolbyVisionCompatEnabled`; CloudSyncRepository (**`has()` guard** — old-version backups must not reset it) | + +**How it works** +1. `DolbyVisionBaseLayerPolicy.resolve()` (once per player composition): display `HdrCapabilities` + + `MediaCodecList` DV-profile probe. Devices with a real DV display+P7 decoder (Shield-class) → + `NATIVE_DV7` → factory is null → **zero change to playback**. Everything else → strip available. +2. `DolbyVisionStripExtractorsFactory` wraps `DefaultExtractorsFactory` and is installed at three + seams: `directProgressiveFactory`'s DV twin (heavy/debrid MKV — the main path), + `mediaSourceFactory`, `preloadMediaSourceFactory` (constructor arg — media3 has no setter). + The `enabledProvider` is consulted per `createExtractors()` (= per prepare), so the user + toggle / per-URL force apply without factory rebuilds. It also refreshes the process-wide + `DolbyVisionCompatibility.setHdr10BaseLayerModeActive` flag that gates the vendored + extractor's format rewrite (`video/dolby-vision` → `video/H265` + container's base-layer + codec string). +3. Per sample, `DolbyVisionStripTransformer` drops the BlockAdditional RPU and strips RPU/EL NALs + (`HevcDvRpuStripper`). **Profile 5 passes through untouched** (no BL-compatible base layer — + stripping would render purple/green). Any strip failure returns the ORIGINAL sample; the + extractor additionally try/catches the transformer per sample — degradation, never hard error. +4. Last-resort ladder: if black-video recovery exhausts its stages on a `isLikelyDolbyVisionStream` + source while strip was NOT active (native-DV policy or toggle off), the URL is added to + `dvForcedStripUrls` and the source is re-prepared once with strip forced, before advancing to + the next source. + +**Scope notes** +- MKV-only by design: DV P7 in the wild = BluRay remux MKV. MP4 WEB-DLs are P8 (media3's built-in + DV→HEVC decoder mapping already handles) or P5 (cannot be stripped). MP4/fMP4/TS sample + interception would only matter for a future RPU-conversion path — out of scope. +- Pure JVM: ships in BOTH flavors (play + sideload), no NDK. +- `compileOnly("org.checkerframework:checker-qual")` exists solely for the vendored sources. + +**Log workflow** (`adb logcat -s DvCompat PlaybackStartup:*`) +- `policy=STRIP_TO_HDR10 … DV strip available` — device qualifies (logged once per player). +- `DV strip active: first sample rewritten (N -> M bytes)` — the pipeline is actually rewriting. +- `black-video exhausted — forcing DV strip for this source` — the last-resort ladder fired. +- Success = first frame renders on a DV7 remux with NO `black video recovery` lines. diff --git a/docs/subtitle-auto-match.md b/docs/subtitle-auto-match.md index 1435456ac..59ffdf7f2 100644 --- a/docs/subtitle-auto-match.md +++ b/docs/subtitle-auto-match.md @@ -171,8 +171,8 @@ side-loaded at startup by **Preload Subtitles** mode (see §3b), in which case s ## 3b. Preload Subtitles mode (July 2026) Setting **"Preload Subtitles"** (`subtitle_preload_enabled`, global, cloud-synced, row 39 -in Settings → Subtitles, default **ON** since July 2026). Modeled on NuvioTV's -`AddonSubtitleStartupMode` but scoped to the preferred language and downloading files ourselves. +in Settings → Subtitles, default **ON** since July 2026). Scoped to the preferred +language, downloading the files ourselves before prepare. Flow: 1. `PlayerViewModel.preloadSubtitles()` runs after every addon-subtitle merge (all 3 fetch sites From 4b44f3588dc0961cfb77a2995bbddb3272110c72 Mon Sep 17 00:00:00 2001 From: silentbil Date: Sun, 12 Jul 2026 09:33:11 +0300 Subject: [PATCH 3/3] more fixes --- .../kotlin/com/arflix/tv/data/model/Models.kt | 6 +- .../tv/data/repository/StreamRepository.kt | 34 +++++- .../arflix/tv/ui/components/StreamSelector.kt | 109 ++++++++++++++++-- 3 files changed, 131 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt index b00b9ee03..d22c1a5db 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt @@ -173,7 +173,11 @@ data class StreamSource( // Stremio "sources" are commonly tracker URLs. Keeping them helps P2P playback (TorrServer) work // across more addons. val sources: List = emptyList(), - val description: String? = null + val description: String? = null, + // Raw Stremio "name" field (e.g. AIOStreams' "2160p (4k) [TB]\nRemux\nExtended"). Carries + // clean, delimited quality tags that garbage filenames run together ("4Kremux") — used for + // chip/label detection in the source menu. + val rawLabel: String? = null ) : Serializable /** diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 385b9ee9d..e6d26f6e2 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -1410,6 +1410,10 @@ class StreamRepository @Inject constructor( private val ADDON_EXTENDED_TIMEOUT_MS = 30_000L private val ADDON_EXTENDED_EPISODE_TIMEOUT_MS = 45_000L private val ADDON_EXTENDED_SINGLE_STREAM_REQUEST_TIMEOUT_MS = 35_000L + // Aggregators (AIOStreams/Comet/MediaFusion): cover the ~10-15s cold case without the full + // 30s. Progressive fetch means these seconds never block the fast addons' results. + private val ADDON_AGGREGATOR_TIMEOUT_MS = 12_000L + private val ADDON_AGGREGATOR_EPISODE_TIMEOUT_MS = 15_000L // Subtitles should not block playback but need enough time on slow connections. // Generous because aggregator addons (AIOStreams) fan out to upstreams server-side and can // take 10s+ cold (or 502 outright, then succeed warm — hence the retry). Fetches run in @@ -1520,6 +1524,24 @@ class StreamRepository @Inject constructor( haystack.contains("telegram") } + /** + * Aggregators fan out to many upstreams server-side and merge per request, so cold responses + * reach 10-15s on popular releases (F1 returned 55 streams but at 15s cold) — the default 6s + * timeout silently dropped them. They get a middle-ground timeout (not the 30s reserved for + * telegram/flix). Safe because the movie/episode fetch is progressive: fast addons already + * showed; the aggregator just appends when it lands. + */ + private fun addonIsSlowAggregator(addon: Addon): Boolean { + val haystack = listOf( + addon.id, addon.name, addon.url.orEmpty(), addon.transportUrl.orEmpty(), + addon.manifest?.id.orEmpty(), addon.manifest?.name.orEmpty() + ).joinToString(" ").lowercase(Locale.US) + return haystack.contains("aiostreams") || + haystack.contains("aio-streams") || + haystack.contains("comet") || + haystack.contains("mediafusion") + } + private fun latencyBucket(ms: Long): String = when { ms < 500L -> "lt_500ms" ms < 2_000L -> "lt_2s" @@ -1547,10 +1569,10 @@ class StreamRepository @Inject constructor( year: Int? = null ): List { val startedAt = System.currentTimeMillis() - val addonTimeoutMs = if (addonNeedsExtendedStreamTimeout(addon)) { - ADDON_EXTENDED_TIMEOUT_MS - } else { - ADDON_TIMEOUT_MS + val addonTimeoutMs = when { + addonNeedsExtendedStreamTimeout(addon) -> ADDON_EXTENDED_TIMEOUT_MS + addonIsSlowAggregator(addon) -> ADDON_AGGREGATOR_TIMEOUT_MS + else -> ADDON_TIMEOUT_MS } return try { withTimeout(addonTimeoutMs) { @@ -1644,6 +1666,7 @@ class StreamRepository @Inject constructor( val addonTimeoutMs = when { extendedTimeout -> ADDON_EXTENDED_EPISODE_TIMEOUT_MS nativeAnimeAddonHint -> ADDON_NATIVE_ANIME_EPISODE_TIMEOUT_MS + addonIsSlowAggregator(addon) -> ADDON_AGGREGATOR_EPISODE_TIMEOUT_MS else -> ADDON_EPISODE_TIMEOUT_MS } return try { @@ -2319,7 +2342,8 @@ class StreamRepository @Inject constructor( }, subtitles = embeddedSubs, sources = stream.sources ?: emptyList(), - description = stream.description?.trim()?.takeIf { it.isNotBlank() } + description = stream.description?.trim()?.takeIf { it.isNotBlank() }, + rawLabel = stream.name?.trim()?.takeIf { it.isNotBlank() } ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 368c75af3..2241652de 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -915,7 +918,12 @@ private data class SourcePresentation( val sizeBytes: Long, val sortCached: Boolean, val sortDirect: Boolean, - val description: String? = null + val description: String? = null, + // Parsed from aggregator descriptions (AIOStreams "📦 55.5 GB · 67.9 ᴹᵇᵖˢ" / "🔌Torrentio | + // ⚙️ ThePirateBay" lines) or the release name; null when the addon doesn't provide them. + val bitrateLabel: String? = null, + val editionLabel: String? = null, + val upstreamLabel: String? = null, ) @androidx.compose.runtime.Immutable @@ -978,6 +986,12 @@ private object StreamRegexes { val HDR10_PLUS = Regex("""\b(HDR10\+|HDR10\s*PLUS|HDR\s*10\s*\+)\b""", RegexOption.IGNORE_CASE) val HDR10 = Regex("""\bHDR10\b""", RegexOption.IGNORE_CASE) val HDR = Regex("""\bHDR(10\+?|10)?\b""", RegexOption.IGNORE_CASE) + // Aggregator descriptions write bitrate with superscript glyphs ("67.9 ᴹᵇᵖˢ") or plain "Mbps". + val BITRATE = Regex("""(\d+(?:\.\d+)?)\s*(?:Mbps|ᴹᵇᵖˢ)""", RegexOption.IGNORE_CASE) + val EDITION = Regex( + """\b(Extended(?:\s+Cut)?|Director'?s\s+Cut|Theatrical(?:\s+Cut)?|Unrated|Uncut|Remastered|Special\s+Edition|Ultimate\s+Edition|Criterion)\b""", + RegexOption.IGNORE_CASE + ) val IMAX = Regex("""\bIMAX\b""", RegexOption.IGNORE_CASE) val WHITESPACE = Regex("""\s+""") val SIZE_PATTERN_1 = Regex("""(\d+(?:\.\d+)?)\s*(TB|GB|MB|KB)""") @@ -1130,6 +1144,14 @@ private fun presentSource(stream: StreamSource): SourcePresentation { append(stream.addonName) append(' ') append(stream.behaviorHints?.filename.orEmpty()) + append(' ') + // Aggregators (AIOStreams) put clean, word-delimited metadata in the description + // ("Dolby Vision • HDR10", "TrueHD • Atmos") and the name field ("Remux", "Extended") — + // essential for garbage scene filenames like "4Kremux2160.www.atomohd.click.mkv" and + // "...UHDRemux..." where \bREMUX\b can't match the run-together tokens. + append(stream.description.orEmpty()) + append(' ') + append(stream.rawLabel.orEmpty()) } val resolutionLabel = when { @@ -1259,7 +1281,18 @@ private fun presentSource(stream: StreamSource): SourcePresentation { sizeBytes = getSizeBytes(stream), sortCached = isReady, sortDirect = !stream.url.isNullOrBlank() && stream.url.startsWith("http", true), - description = cleanStreamDescription(stream.description, rawTitle) + description = cleanStreamDescription(stream.description, rawTitle), + bitrateLabel = StreamRegexes.BITRATE.find(stream.description.orEmpty()) + ?.let { "${it.groupValues[1]} Mbps" }, + editionLabel = StreamRegexes.EDITION + .find("$rawTitle ${stream.description.orEmpty()} ${stream.rawLabel.orEmpty()}") + ?.value?.replaceFirstChar { it.uppercase() }, + upstreamLabel = stream.description.orEmpty().lines() + .firstOrNull { it.trimStart().startsWith("🔌") } + // Keep "Torrentio | ThePirateBay", drop the emoji decorations. + ?.replace(Regex("""[^\p{L}\p{N} .+|\-]"""), "") + ?.trim() + ?.takeIf { it.isNotBlank() }, ) } @@ -1294,6 +1327,12 @@ private fun sourceBadges(presentation: SourcePresentation): List = append(presentation.stream.quality) append(' ') append(presentation.chips.joinToString(" ")) + append(' ') + // Description + name carry word-delimited HDR/DV/audio/remux metadata that garbage + // filenames lack. + append(presentation.stream.description.orEmpty()) + append(' ') + append(presentation.stream.rawLabel.orEmpty()) } when (presentation.resolutionLabel) { @@ -1319,11 +1358,15 @@ private fun sourceBadges(presentation: SourcePresentation): List = "AV1" -> add(SourceBadge("AV1")) } + // DV and HDR10/HDR10+ are not mutually exclusive — DV remuxes carry an HDR10 base layer and + // releases advertise both ("DV HDR10"). Show them together like the aggregators do; the + // generic HDR badge only stands in when nothing more specific matched. + val hasDv = StreamRegexes.DV.containsMatchIn(blob) + if (hasDv) add(SourceBadge("DV", SourceBadgeImages.DOLBY_VISION)) when { - StreamRegexes.DV.containsMatchIn(blob) -> add(SourceBadge("DV", SourceBadgeImages.DOLBY_VISION)) StreamRegexes.HDR10_PLUS.containsMatchIn(blob) -> add(SourceBadge("HDR10+", SourceBadgeImages.HDR10_PLUS)) StreamRegexes.HDR10.containsMatchIn(blob) -> add(SourceBadge("HDR10", SourceBadgeImages.HDR10)) - StreamRegexes.HDR.containsMatchIn(blob) -> add(SourceBadge("HDR", SourceBadgeImages.HDR)) + !hasDv && StreamRegexes.HDR.containsMatchIn(blob) -> add(SourceBadge("HDR", SourceBadgeImages.HDR)) } if (StreamRegexes.IMAX.containsMatchIn(blob)) { add(SourceBadge("IMAX", SourceBadgeImages.IMAX)) @@ -1345,7 +1388,15 @@ private fun sourceBadges(presentation: SourcePresentation): List = }.distinctBy { it.text } private fun rowSubtitle(presentation: SourcePresentation): String { - return presentation.addonLabel + // Addon (with the aggregator's upstream provider when known), then edition and bitrate + val addonPart = presentation.upstreamLabel + ?.let { "${presentation.addonLabel} — $it" } + ?: presentation.addonLabel + return listOfNotNull( + addonPart, + presentation.editionLabel, + presentation.bitrateLabel + ).joinToString(" · ") } private fun languageBadgeText(language: String?): String? { @@ -1779,7 +1830,9 @@ private fun OledSourceRow( Column( modifier = Modifier .fillMaxWidth() - .height(92.dp) + // Min height keeps list rhythm; long release names grow the row instead of + // getting ellipsized (the tail is what differentiates sources). + .heightIn(min = 92.dp) .padding(horizontal = 3.dp, vertical = 1.dp) .focusProperties { canFocus = false } .clip(RoundedCornerShape(15.dp)) @@ -1807,8 +1860,18 @@ private fun OledSourceRow( .fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + // Badges take a bounded ~42% and WRAP into 2-3 rows instead of a single row that + // eats the whole card on high-chip sources; the filename then always keeps ≥55%. + OledBadgeFlow( + presentation = presentation, + maxBadges = 8, + modifier = Modifier.weight(0.42f) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(0.58f)) { Row(verticalAlignment = Alignment.CenterVertically) { + // Full filename, wrapped — an ellipsized release name hides exactly the + // parts that differentiate sources (edition, audio, group). Text( text = presentation.rawTitle, style = ArflixTypography.body.copy( @@ -1817,8 +1880,6 @@ private fun OledSourceRow( fontWeight = if (isFocused) FontWeight.Bold else FontWeight.SemiBold ), color = TextPrimary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) if (isSelected) { @@ -1847,12 +1908,10 @@ private fun OledSourceRow( fontWeight = FontWeight.Medium ), color = OledMutedText, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis ) } - Spacer(modifier = Modifier.width(10.dp)) - SourceBadgeTray(presentation = presentation, maxBadges = 4) } } } @@ -1950,6 +2009,32 @@ private fun OledBadgeRow( } } +/** + * Bounded-width badge block that WRAPS into multiple rows. Size (and language) are the first + * items, so they land at the same top-start corner on every card regardless of how many quality + * chips follow — the "size in a consistent position" requirement — while the quality chips flow + * onto 2-3 rows instead of a single row that would starve the filename column. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun OledBadgeFlow( + presentation: SourcePresentation, + maxBadges: Int, + modifier: Modifier = Modifier +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + SourceSizeBadge(size = presentation.stream.size) + SourceLanguageBadge(language = presentation.languageLabel) + sourceBadges(presentation).take(maxBadges).forEach { badge -> + SourceBadgeView(badge) + } + } +} + @Composable private fun SourceBadgeView( badge: SourceBadge,