diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82b60a696..567556c6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -262,6 +262,9 @@ android { // 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") + // Vendored media3 1.9.0 TS extractor that keeps every DVB subtitle stream in HLS + // (see iptvts/package-info.java for the re-vendoring procedure on media3 bumps). + java.srcDir("src/main/iptvts-java") res.srcDir("src/main/res-player") } } diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackControlsDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackControlsDeviceTest.kt new file mode 100644 index 000000000..d0168020f --- /dev/null +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackControlsDeviceTest.kt @@ -0,0 +1,103 @@ +package com.arflix.tv.ui.screens.tv.live + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arflix.tv.R +import com.arflix.tv.ui.theme.ArflixTvTheme +import com.arflix.tv.util.DeviceType +import com.arflix.tv.util.LocalDeviceType +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LiveTrackControlsDeviceTest { + @get:Rule val compose = createAndroidComposeRule() + private val strings get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun show(content: @Composable () -> Unit) { + compose.setContent { + CompositionLocalProvider(LocalDeviceType provides DeviceType.TV) { + ArflixTvTheme { Box(Modifier.fillMaxSize().background(Color.Black)) { content() } } + } + } + } + + @Test fun aSelectedTrackBelowTheFirstScreenIsScrolledToAndFocused() { + val options = (0..24).map { LiveTrackOption("lang$it", "Language $it", selected = it == 20) } + show { + LiveTrackPickerOverlay( + title = "Subtitles", icon = Icons.Filled.Subtitles, channelName = "Channel", + options = options, onDismiss = {}, onPick = {}, + ) + } + // Without scrolling first, the request for the unplaced row failed and nothing was focused. + compose.waitUntil(5_000) { compose.onAllNodes(isFocused()).fetchSemanticsNodes().isNotEmpty() } + val focused = compose.onAllNodes(isFocused()).fetchSemanticsNodes().single().boundsInRoot + val selectedLabel = compose.onNodeWithText("Language 20").assertIsDisplayed() + .fetchSemanticsNode().boundsInRoot + val overlap = focused.intersect(selectedLabel) + assertFalse("Focus is not on the selected row", overlap.width <= 0f || overlap.height <= 0f) + } + + @Test fun controlsDoNotOverlapOnANarrowScreenWithEveryButtonShown() = + assertNoOverlappingControls(widthDp = 480) + + @Test fun controlsDoNotOverlapOnATvScreenWithEveryButtonShown() = + assertNoOverlappingControls(widthDp = 960) + + private fun assertNoOverlappingControls(widthDp: Int) { + show { + Box(Modifier.width(widthDp.dp).fillMaxHeight()) { + FullscreenHud( + channel = null, nowNext = null, pokeSignal = 0, + isCatchupMode = true, playbackDurationMs = 3_600_000L, + onGuideClick = {}, onOpenVariants = {}, onSubtitlesClick = {}, onAudioClick = {}, + onPlayPauseClick = {}, onRewindClick = {}, onFastForwardClick = {}, + onPreviousCatchupClick = {}, onNextCatchupClick = {}, onReplayClick = {}, onGoLiveClick = {}, + showControls = true, + ) + } + } + compose.waitForIdle() + val controls: List> = + listOf(R.string.live_audio, R.string.live_subtitles, R.string.live_label_choose_source, + R.string.live_btn_guide, R.string.live_badge_live) + .map { strings.getString(it) } + .map { it to compose.onNodeWithText(it) } + + listOf(R.string.live_cd_previous_channel, R.string.live_cd_rewind, R.string.live_cd_pause, + R.string.live_cd_fast_forward, R.string.live_cd_next_channel, R.string.live_cd_replay) + .map { strings.getString(it) } + .map { it to compose.onNodeWithContentDescription(it) } + val bounds: List> = controls.map { (name, node) -> name to node.fetchSemanticsNode().boundsInRoot } + for (i in bounds.indices) { + for (j in i + 1 until bounds.size) { + val (a, ra) = bounds[i] + val (b, rb) = bounds[j] + val overlap = ra.intersect(rb) + assertFalse("\"$a\" $ra overlaps \"$b\" $rb at ${widthDp}dp", overlap.width > 0f && overlap.height > 0f) + } + } + } +} diff --git a/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsBinarySearchSeeker.java b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsBinarySearchSeeker.java new file mode 100644 index 000000000..9b8a8dc66 --- /dev/null +++ b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsBinarySearchSeeker.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2018 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.iptvts; + +import static java.lang.Math.min; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.TimestampAdjuster; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.BinarySearchSeeker; +import androidx.media3.extractor.ExtractorInput; +import java.io.IOException; +import androidx.media3.extractor.ts.TsUtil; + +/** + * A seeker that supports seeking within TS stream using binary search. + * + *

This seeker uses the first and last PCR values within the stream, as well as the stream + * duration to interpolate the PCR value of the seeking position. Then it performs binary search + * within the stream to find a packets whose PCR value is within {@link #SEEK_TOLERANCE_US} from the + * target PCR. + */ +/* package */ final class TsBinarySearchSeeker extends BinarySearchSeeker { + + private static final long SEEK_TOLERANCE_US = 100_000; + private static final int MINIMUM_SEARCH_RANGE_BYTES = 5 * TsExtractor.TS_PACKET_SIZE; + + public TsBinarySearchSeeker( + TimestampAdjuster pcrTimestampAdjuster, + long streamDurationUs, + long inputLength, + int pcrPid, + int timestampSearchBytes) { + super( + new DefaultSeekTimestampConverter(), + new TsPcrSeeker(pcrPid, pcrTimestampAdjuster, timestampSearchBytes), + streamDurationUs, + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamDurationUs + 1, + /* floorBytePosition= */ 0, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE, + MINIMUM_SEARCH_RANGE_BYTES); + } + + /** + * A {@link TimestampSeeker} implementation that looks for a given PCR timestamp at a given + * position in a TS stream. + * + *

Given a PCR timestamp, and a position within a TS stream, this seeker will peek up to {@link + * #timestampSearchBytes} from that stream position, look for all packets with PID equal to + * PCR_PID, and then compare the PCR timestamps (if available) of these packets to the target + * timestamp. + */ + private static final class TsPcrSeeker implements TimestampSeeker { + + private final TimestampAdjuster pcrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + private final int pcrPid; + private final int timestampSearchBytes; + + public TsPcrSeeker( + int pcrPid, TimestampAdjuster pcrTimestampAdjuster, int timestampSearchBytes) { + this.pcrPid = pcrPid; + this.pcrTimestampAdjuster = pcrTimestampAdjuster; + this.timestampSearchBytes = timestampSearchBytes; + packetBuffer = new ParsableByteArray(); + } + + @Override + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) + throws IOException { + long inputPosition = input.getPosition(); + int bytesToSearch = (int) min(timestampSearchBytes, input.getLength() - inputPosition); + + packetBuffer.reset(bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); + + return searchForPcrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); + } + + private TimestampSearchResult searchForPcrValueInBuffer( + ParsableByteArray packetBuffer, long targetPcrTimeUs, long bufferStartOffset) { + int limit = packetBuffer.limit(); + + long startOfLastPacketPosition = C.INDEX_UNSET; + long endOfLastPacketPosition = C.INDEX_UNSET; + long lastPcrTimeUsInRange = C.TIME_UNSET; + + while (packetBuffer.bytesLeft() >= TsExtractor.TS_PACKET_SIZE) { + int startOfPacket = + TsUtil.findSyncBytePosition(packetBuffer.getData(), packetBuffer.getPosition(), limit); + int endOfPacket = startOfPacket + TsExtractor.TS_PACKET_SIZE; + if (endOfPacket > limit) { + break; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, startOfPacket, pcrPid); + if (pcrValue != C.TIME_UNSET) { + long pcrTimeUs = pcrTimestampAdjuster.adjustTsTimestamp(pcrValue); + if (pcrTimeUs > targetPcrTimeUs) { + if (lastPcrTimeUsInRange == C.TIME_UNSET) { + // First PCR timestamp is already over target. + return TimestampSearchResult.overestimatedResult(pcrTimeUs, bufferStartOffset); + } else { + // Last PCR timestamp < target timestamp < this timestamp. + return TimestampSearchResult.targetFoundResult( + bufferStartOffset + startOfLastPacketPosition); + } + } else if (pcrTimeUs + SEEK_TOLERANCE_US > targetPcrTimeUs) { + long startOfPacketInStream = bufferStartOffset + startOfPacket; + return TimestampSearchResult.targetFoundResult(startOfPacketInStream); + } + + lastPcrTimeUsInRange = pcrTimeUs; + startOfLastPacketPosition = startOfPacket; + } + packetBuffer.setPosition(endOfPacket); + endOfLastPacketPosition = endOfPacket; + } + + if (lastPcrTimeUsInRange != C.TIME_UNSET) { + long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition; + return TimestampSearchResult.underestimatedResult( + lastPcrTimeUsInRange, endOfLastPacketPositionInStream); + } else { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + } + + @Override + public void onSeekFinished() { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + } + } +} diff --git a/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsDurationReader.java b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsDurationReader.java new file mode 100644 index 000000000..55c09d977 --- /dev/null +++ b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsDurationReader.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2018 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.iptvts; + +import static java.lang.Math.min; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.TimestampAdjuster; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.PositionHolder; +import java.io.IOException; +import androidx.media3.extractor.ts.TsUtil; + +/** + * A reader that can extract the approximate duration from a given MPEG transport stream (TS). + * + *

This reader extracts the duration by reading PCR values of the PCR PID packets at the start + * and at the end of the stream, calculating the difference, and converting that into stream + * duration. This reader also handles the case when a single PCR wraparound takes place within the + * stream, which can make PCR values at the beginning of the stream larger than PCR values at the + * end. This class can only be used once to read duration from a given stream, and the usage of the + * class is not thread-safe, so all calls should be made from the same thread. + */ +/* package */ final class TsDurationReader { + + private static final String TAG = "TsDurationReader"; + + private final int timestampSearchBytes; + private final TimestampAdjuster pcrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private boolean isDurationRead; + private boolean isFirstPcrValueRead; + private boolean isLastPcrValueRead; + + private long firstPcrValue; + private long lastPcrValue; + private long durationUs; + + /* package */ TsDurationReader(int timestampSearchBytes) { + this.timestampSearchBytes = timestampSearchBytes; + pcrTimestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + firstPcrValue = C.TIME_UNSET; + lastPcrValue = C.TIME_UNSET; + durationUs = C.TIME_UNSET; + packetBuffer = new ParsableByteArray(); + } + + /** Returns true if a TS duration has been read. */ + public boolean isDurationReadFinished() { + return isDurationRead; + } + + /** + * Reads a TS duration from the input, using the given PCR PID. + * + *

This reader reads the duration by reading PCR values of the PCR PID packets at the start and + * at the end of the stream, calculating the difference, and converting that into stream duration. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated + * to hold the position of the required seek. + * @param pcrPid The PID of the packet stream within this TS stream that contains PCR values. + * @return One of the {@code RESULT_} values defined in {@link Extractor}. + * @throws IOException If an error occurred reading from the input. + */ + public @Extractor.ReadResult int readDuration( + ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException { + if (pcrPid <= 0) { + return finishReadDuration(input); + } + if (!isLastPcrValueRead) { + return readLastPcrValue(input, seekPositionHolder, pcrPid); + } + if (lastPcrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + if (!isFirstPcrValueRead) { + return readFirstPcrValue(input, seekPositionHolder, pcrPid); + } + if (firstPcrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + + long minPcrPositionUs = pcrTimestampAdjuster.adjustTsTimestamp(firstPcrValue); + long maxPcrPositionUs = + pcrTimestampAdjuster.adjustTsTimestampGreaterThanPreviousTimestamp(lastPcrValue); + durationUs = maxPcrPositionUs - minPcrPositionUs; + return finishReadDuration(input); + } + + /** + * Returns the duration last read from {@link #readDuration(ExtractorInput, PositionHolder, int)}. + */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the {@link TimestampAdjuster} that this class uses to adjust timestamps read from the + * input TS stream. + */ + public TimestampAdjuster getPcrTimestampAdjuster() { + return pcrTimestampAdjuster; + } + + private int finishReadDuration(ExtractorInput input) { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + isDurationRead = true; + input.resetPeekPosition(); + return Extractor.RESULT_CONTINUE; + } + + private int readFirstPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) + throws IOException { + int bytesToSearch = (int) min(timestampSearchBytes, input.getLength()); + int searchStartPosition = 0; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); + + firstPcrValue = readFirstPcrValueFromBuffer(packetBuffer, pcrPid); + isFirstPcrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readFirstPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcrPid) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchStartPosition; + searchPosition < searchEndPosition; + searchPosition++) { + if (packetBuffer.getData()[searchPosition] != TsExtractor.TS_SYNC_BYTE) { + continue; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); + if (pcrValue != C.TIME_UNSET) { + return pcrValue; + } + } + return C.TIME_UNSET; + } + + private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) + throws IOException { + long inputLength = input.getLength(); + int bytesToSearch = (int) min(timestampSearchBytes, inputLength); + long searchStartPosition = inputLength - bytesToSearch; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); + + lastPcrValue = readLastPcrValueFromBuffer(packetBuffer, pcrPid); + isLastPcrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readLastPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcrPid) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + // We start searching 'TsExtractor.TS_PACKET_SIZE' bytes from the end to prevent trying to read + // from an incomplete TS packet. + for (int searchPosition = searchEndPosition - TsExtractor.TS_PACKET_SIZE; + searchPosition >= searchStartPosition; + searchPosition--) { + if (!TsUtil.isStartOfTsPacket( + packetBuffer.getData(), searchStartPosition, searchEndPosition, searchPosition)) { + continue; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); + if (pcrValue != C.TIME_UNSET) { + return pcrValue; + } + } + return C.TIME_UNSET; + } +} diff --git a/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsExtractor.java b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsExtractor.java new file mode 100644 index 000000000..35a32084a --- /dev/null +++ b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsExtractor.java @@ -0,0 +1,987 @@ +/* + * 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.iptvts; + +import static androidx.media3.extractor.ts.TsPayloadReader.EsInfo.AUDIO_TYPE_UNDEFINED; +import static androidx.media3.extractor.ts.TsPayloadReader.FLAG_PAYLOAD_UNIT_START_INDICATOR; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import android.util.SparseIntArray; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.NullableType; +import androidx.media3.common.util.ParsableBitArray; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.TimestampAdjuster; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.TrackOutput; +import androidx.media3.extractor.text.SubtitleParser; +import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput; +import androidx.media3.extractor.ts.TsPayloadReader.DvbSubtitleInfo; +import androidx.media3.extractor.ts.TsPayloadReader.EsInfo; +import androidx.media3.extractor.ts.TsPayloadReader.TrackIdGenerator; +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.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory; +import androidx.media3.extractor.ts.PesReader; +import androidx.media3.extractor.ts.SectionReader; +import androidx.media3.extractor.ts.SectionPayloadReader; +import androidx.media3.extractor.ts.TsPayloadReader; +import androidx.media3.extractor.ts.TsUtil; + +/** Extracts data from the MPEG-2 TS container format. */ +@UnstableApi +public final class TsExtractor implements Extractor { + + /** + * Creates a factory for {@link TsExtractor} instances with the provided {@link + * SubtitleParser.Factory}. + */ + public static ExtractorsFactory newFactory(SubtitleParser.Factory subtitleParserFactory) { + return () -> new Extractor[] {new TsExtractor(subtitleParserFactory)}; + } + + /** + * Modes for the extractor. One of {@link #MODE_MULTI_PMT}, {@link #MODE_SINGLE_PMT} or {@link + * #MODE_HLS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({MODE_MULTI_PMT, MODE_SINGLE_PMT, MODE_HLS}) + public @interface Mode {} + + /** Behave as defined in ISO/IEC 13818-1. */ + public static final int MODE_MULTI_PMT = 0; + + /** Assume only one PMT will be contained in the stream, even if more are declared by the PAT. */ + public static final int MODE_SINGLE_PMT = 1; + + /** + * Enable single PMT mode, map {@link TrackOutput}s by their type (instead of PID) and ignore + * continuity counters. + */ + public static final int MODE_HLS = 2; + + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_EMIT_RAW_SUBTITLE_DATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef( + flag = true, + value = {FLAG_EMIT_RAW_SUBTITLE_DATA}) + public @interface Flags {} + + /** + * 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; + + /** + * @deprecated Use {@link #newFactory(SubtitleParser.Factory)} instead. + */ + @Deprecated + public static final ExtractorsFactory FACTORY = + () -> + new Extractor[] { + new TsExtractor(FLAG_EMIT_RAW_SUBTITLE_DATA, SubtitleParser.Factory.UNSUPPORTED) + }; + + public static final int TS_PACKET_SIZE = 188; + public static final int DEFAULT_TIMESTAMP_SEARCH_BYTES = 600 * TS_PACKET_SIZE; + + public static final int TS_STREAM_TYPE_MPA = 0x03; + public static final int TS_STREAM_TYPE_MPA_LSF = 0x04; + public static final int TS_STREAM_TYPE_AAC_ADTS = 0x0F; + public static final int TS_STREAM_TYPE_AAC_LATM = 0x11; + public static final int TS_STREAM_TYPE_AC3 = 0x81; + public static final int TS_STREAM_TYPE_DTS = 0x8A; + public static final int TS_STREAM_TYPE_HDMV_DTS = 0x82; + public static final int TS_STREAM_TYPE_E_AC3 = 0x87; + public static final int TS_STREAM_TYPE_AC4 = 0xAC; // DVB/ATSC AC-4 Descriptor + public static final int TS_STREAM_TYPE_H262 = 0x02; + public static final int TS_STREAM_TYPE_H263 = 0x10; // MPEG-4 Part 2 and H.263 + public static final int TS_STREAM_TYPE_H264 = 0x1B; + public static final int TS_STREAM_TYPE_H265 = 0x24; + public static final int TS_STREAM_TYPE_MHAS = 0x2D; + public static final int TS_STREAM_TYPE_ID3 = 0x15; + public static final int TS_STREAM_TYPE_SPLICE_INFO = 0x86; + public static final int TS_STREAM_TYPE_DVBSUBS = 0x59; + + /** + * ARVIO: HLS-mode track ids of DVB subtitle services lie in [BASE, BASE + RANGE): above every + * stream type (which is what other HLS tracks use as their id) and below MAX_PID_PLUS_ONE, the + * step TrackIdGenerator adds for a second service carried on the same PID. + */ + private static final int HLS_DVB_SUBTITLE_TRACK_ID_BASE = 0x100; + + private static final int HLS_DVB_SUBTITLE_TRACK_ID_RANGE = 0x1000; + public static final int TS_STREAM_TYPE_DTS_HD = 0x88; // As per ATSC Code Point Registry + public static final int TS_STREAM_TYPE_DTS_UHD = 0x8B; + + // Stream types that aren't defined by the MPEG-2 TS specification. + public static final int TS_STREAM_TYPE_DC2_H262 = 0x80; + public static final int TS_STREAM_TYPE_AIT = 0x101; + + public static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet. + + private static final int TS_PAT_PID = 0; + private static final int MAX_PID_PLUS_ONE = 0x2000; + + private static final long AC3_FORMAT_IDENTIFIER = 0x41432d33; + private static final long E_AC3_FORMAT_IDENTIFIER = 0x45414333; + private static final long AC4_FORMAT_IDENTIFIER = 0x41432d34; + private static final long HEVC_FORMAT_IDENTIFIER = 0x48455643; + + private static final int BUFFER_SIZE = TS_PACKET_SIZE * 50; + private static final int SNIFF_TS_PACKET_COUNT = 5; + + private final @Mode int mode; + private final @Flags int extractorFlags; + private final int timestampSearchBytes; + private final List timestampAdjusters; + private final ParsableByteArray tsPacketBuffer; + private final SparseIntArray continuityCounters; + private final TsPayloadReader.Factory payloadReaderFactory; + private final SubtitleParser.Factory subtitleParserFactory; + private final SparseArray tsPayloadReaders; // Indexed by pid + private final SparseBooleanArray trackIds; + private final SparseBooleanArray trackPids; + private final TsDurationReader durationReader; + + // Accessed only by the loading thread. + private @MonotonicNonNull TsBinarySearchSeeker tsBinarySearchSeeker; + private ExtractorOutput output; + private int remainingPmts; + private boolean tracksEnded; + private boolean hasOutputSeekMap; + private boolean pendingSeekToStart; + @Nullable private TsPayloadReader id3Reader; + private int bytesSinceLastSync; + private int pcrPid; + + /** + * @deprecated Use {@link #TsExtractor(SubtitleParser.Factory)} instead. + */ + @Deprecated + public TsExtractor() { + this( + MODE_SINGLE_PMT, + /* extractorFlags= */ FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(/* defaultTsPayloadReaderFlags= */ 0), + DEFAULT_TIMESTAMP_SEARCH_BYTES); + } + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + */ + public TsExtractor(SubtitleParser.Factory subtitleParserFactory) { + this( + MODE_SINGLE_PMT, + /* extractorFlags= */ 0, + subtitleParserFactory, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(/* defaultTsPayloadReaderFlags= */ 0), + DEFAULT_TIMESTAMP_SEARCH_BYTES); + } + + /** + * Constructs an instance. + * + * @param extractorFlags Flags that control the extractor's behavior. + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + */ + public TsExtractor(@Flags int extractorFlags, SubtitleParser.Factory subtitleParserFactory) { + this( + MODE_SINGLE_PMT, + extractorFlags, + subtitleParserFactory, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(/* defaultTsPayloadReaderFlags= */ 0), + DEFAULT_TIMESTAMP_SEARCH_BYTES); + } + + /** + * @deprecated Use {@link #TsExtractor(int, int, SubtitleParser.Factory, TimestampAdjuster, + * TsPayloadReader.Factory, int)} instead. + */ + @Deprecated + public TsExtractor(@DefaultTsPayloadReaderFactory.Flags int defaultTsPayloadReaderFlags) { + this( + MODE_SINGLE_PMT, + FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(defaultTsPayloadReaderFlags), + DEFAULT_TIMESTAMP_SEARCH_BYTES); + } + + /** + * @deprecated Use {@link #TsExtractor(int, int, SubtitleParser.Factory, TimestampAdjuster, + * TsPayloadReader.Factory, int)} instead. + */ + @Deprecated + public TsExtractor( + @Mode int mode, + @DefaultTsPayloadReaderFactory.Flags int defaultTsPayloadReaderFlags, + int timestampSearchBytes) { + this( + mode, + FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(defaultTsPayloadReaderFlags), + timestampSearchBytes); + } + + /** + * @deprecated Use {@link #TsExtractor(int, int, SubtitleParser.Factory, TimestampAdjuster, + * TsPayloadReader.Factory, int)} instead. + */ + @Deprecated + public TsExtractor( + @Mode int mode, + TimestampAdjuster timestampAdjuster, + TsPayloadReader.Factory payloadReaderFactory) { + this( + mode, + FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + timestampAdjuster, + payloadReaderFactory, + DEFAULT_TIMESTAMP_SEARCH_BYTES); + } + + /** + * @deprecated Use {@link #TsExtractor(int, int, SubtitleParser.Factory, TimestampAdjuster, + * TsPayloadReader.Factory, int)} instead. + */ + @Deprecated + public TsExtractor( + @Mode int mode, + TimestampAdjuster timestampAdjuster, + TsPayloadReader.Factory payloadReaderFactory, + int timestampSearchBytes) { + this( + mode, + FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + timestampAdjuster, + payloadReaderFactory, + timestampSearchBytes); + } + + /** + * Constructs an instance. + * + * @param mode Mode for the extractor. One of {@link #MODE_MULTI_PMT}, {@link #MODE_SINGLE_PMT} + * and {@link #MODE_HLS}. + * @param extractorFlags Flags that control the extractor's behavior. + * @param subtitleParserFactory The {@link SubtitleParser.Factory} for parsing subtitles during + * extraction. + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param payloadReaderFactory Factory for injecting a custom set of payload readers. + * @param timestampSearchBytes The number of bytes searched from a given position in the stream to + * find a PCR timestamp. If this value is too small, the duration might be unknown and seeking + * might not be supported for high bitrate progressive streams. Setting a large value for this + * field might be inefficient though because the extractor stores a buffer of {@code + * timestampSearchBytes} bytes when determining the duration or when performing a seek + * operation. The default value is {@link #DEFAULT_TIMESTAMP_SEARCH_BYTES}. If the number of + * bytes left in the stream from the current position is less than {@code + * timestampSearchBytes}, the search is performed on the bytes left. + */ + public TsExtractor( + @Mode int mode, + @Flags int extractorFlags, + SubtitleParser.Factory subtitleParserFactory, + TimestampAdjuster timestampAdjuster, + TsPayloadReader.Factory payloadReaderFactory, + int timestampSearchBytes) { + this.payloadReaderFactory = checkNotNull(payloadReaderFactory); + this.timestampSearchBytes = timestampSearchBytes; + this.mode = mode; + this.extractorFlags = extractorFlags; + this.subtitleParserFactory = subtitleParserFactory; + if (mode == MODE_SINGLE_PMT || mode == MODE_HLS) { + timestampAdjusters = Collections.singletonList(timestampAdjuster); + } else { + timestampAdjusters = new ArrayList<>(); + timestampAdjusters.add(timestampAdjuster); + } + tsPacketBuffer = new ParsableByteArray(new byte[BUFFER_SIZE], 0); + trackIds = new SparseBooleanArray(); + trackPids = new SparseBooleanArray(); + tsPayloadReaders = new SparseArray<>(); + continuityCounters = new SparseIntArray(); + durationReader = new TsDurationReader(timestampSearchBytes); + output = ExtractorOutput.PLACEHOLDER; + pcrPid = -1; + resetPayloadReaders(); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException { + byte[] buffer = tsPacketBuffer.getData(); + input.peekFully(buffer, 0, TS_PACKET_SIZE * SNIFF_TS_PACKET_COUNT); + for (int startPosCandidate = 0; startPosCandidate < TS_PACKET_SIZE; startPosCandidate++) { + // Try to identify at least SNIFF_TS_PACKET_COUNT packets starting with TS_SYNC_BYTE. + boolean isSyncBytePatternCorrect = true; + for (int i = 0; i < SNIFF_TS_PACKET_COUNT; i++) { + if (buffer[startPosCandidate + i * TS_PACKET_SIZE] != TS_SYNC_BYTE) { + isSyncBytePatternCorrect = false; + break; + } + } + if (isSyncBytePatternCorrect) { + input.skipFully(startPosCandidate); + return true; + } + } + return false; + } + + @Override + public void init(ExtractorOutput output) { + this.output = + (extractorFlags & FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 + ? new SubtitleTranscodingExtractorOutput(output, subtitleParserFactory) + : output; + } + + @Override + public void seek(long position, long timeUs) { + checkState(mode != MODE_HLS); + int timestampAdjustersCount = timestampAdjusters.size(); + for (int i = 0; i < timestampAdjustersCount; i++) { + TimestampAdjuster timestampAdjuster = timestampAdjusters.get(i); + // If the timestamp adjuster has not yet established a timestamp offset, we need to reset its + // expected first sample timestamp to be the new seek position. Without this, the timestamp + // adjuster would incorrectly establish its timestamp offset assuming that the first sample + // after this seek corresponds to the start of the stream (or a previous seek position, if + // there was one). + boolean resetTimestampAdjuster = timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET; + if (!resetTimestampAdjuster) { + long adjusterFirstSampleTimestampUs = timestampAdjuster.getFirstSampleTimestampUs(); + // Also reset the timestamp adjuster if its offset was calculated based on a non-zero + // position in the stream (other than the position being seeked to), since in this case the + // offset may not be accurate. + resetTimestampAdjuster = + adjusterFirstSampleTimestampUs != C.TIME_UNSET + && adjusterFirstSampleTimestampUs != 0 + && adjusterFirstSampleTimestampUs != timeUs; + } + if (resetTimestampAdjuster) { + timestampAdjuster.reset(timeUs); + } + } + if (timeUs != 0 && tsBinarySearchSeeker != null) { + tsBinarySearchSeeker.setSeekTargetUs(timeUs); + } + tsPacketBuffer.reset(/* limit= */ 0); + continuityCounters.clear(); + for (int i = 0; i < tsPayloadReaders.size(); i++) { + tsPayloadReaders.valueAt(i).seek(); + } + bytesSinceLastSync = 0; + } + + @Override + public void release() { + // Do nothing + } + + @Override + public @ReadResult int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException { + long inputLength = input.getLength(); + boolean isModeHls = mode == MODE_HLS; + if (tracksEnded) { + boolean canReadDuration = inputLength != C.LENGTH_UNSET && !isModeHls; + if (canReadDuration && !durationReader.isDurationReadFinished()) { + return durationReader.readDuration(input, seekPosition, pcrPid); + } + maybeOutputSeekMap(inputLength); + + if (pendingSeekToStart) { + pendingSeekToStart = false; + seek(/* position= */ 0, /* timeUs= */ 0); + if (input.getPosition() != 0) { + seekPosition.position = 0; + return RESULT_SEEK; + } + } + + if (tsBinarySearchSeeker != null && tsBinarySearchSeeker.isSeeking()) { + return tsBinarySearchSeeker.handlePendingSeek(input, seekPosition); + } + } + + if (!fillBufferWithAtLeastOnePacket(input)) { + // Send a synthesized empty pusi to allow for packetFinished to be triggered on the last unit. + for (int i = 0; i < tsPayloadReaders.size(); i++) { + TsPayloadReader payloadReader = tsPayloadReaders.valueAt(i); + if (payloadReader instanceof PesReader) { + PesReader pesReader = (PesReader) payloadReader; + if (pesReader.canConsumeSynthesizedEmptyPusi(isModeHls)) { + pesReader.consume(new ParsableByteArray(), FLAG_PAYLOAD_UNIT_START_INDICATOR); + } + } + } + return RESULT_END_OF_INPUT; + } + + int endOfPacket = findEndOfFirstTsPacketInBuffer(); + int limit = tsPacketBuffer.limit(); + if (endOfPacket > limit) { + return RESULT_CONTINUE; + } + + @TsPayloadReader.Flags int packetHeaderFlags = 0; + + // Note: See ISO/IEC 13818-1, section 2.4.3.2 for details of the header format. + int tsPacketHeader = tsPacketBuffer.readInt(); + if ((tsPacketHeader & 0x800000) != 0) { // transport_error_indicator + // There are uncorrectable errors in this packet. + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + packetHeaderFlags |= (tsPacketHeader & 0x400000) != 0 ? FLAG_PAYLOAD_UNIT_START_INDICATOR : 0; + // Ignoring transport_priority (tsPacketHeader & 0x200000) + int pid = (tsPacketHeader & 0x1FFF00) >> 8; + // Ignoring transport_scrambling_control (tsPacketHeader & 0xC0) + boolean adaptationFieldExists = (tsPacketHeader & 0x20) != 0; + boolean payloadExists = (tsPacketHeader & 0x10) != 0; + + TsPayloadReader payloadReader = payloadExists ? tsPayloadReaders.get(pid) : null; + if (payloadReader == null) { + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + + // Discontinuity check. + if (mode != MODE_HLS) { + int continuityCounter = tsPacketHeader & 0xF; + int previousCounter = continuityCounters.get(pid, continuityCounter - 1); + continuityCounters.put(pid, continuityCounter); + if (previousCounter == continuityCounter) { + // Duplicate packet found. + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } else if (continuityCounter != ((previousCounter + 1) & 0xF)) { + // Discontinuity found. + payloadReader.seek(); + } + } + + // Skip the adaptation field. + if (adaptationFieldExists) { + int adaptationFieldLength = tsPacketBuffer.readUnsignedByte(); + int adaptationFieldFlags = tsPacketBuffer.readUnsignedByte(); + + packetHeaderFlags |= + (adaptationFieldFlags & 0x40) != 0 // random_access_indicator. + ? TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR + : 0; + tsPacketBuffer.skipBytes(adaptationFieldLength - 1 /* flags */); + } + + // Read the payload. + boolean wereTracksEnded = tracksEnded; + if (shouldConsumePacketPayload(pid)) { + tsPacketBuffer.setLimit(endOfPacket); + payloadReader.consume(tsPacketBuffer, packetHeaderFlags); + tsPacketBuffer.setLimit(limit); + } + if (mode != MODE_HLS && !wereTracksEnded && tracksEnded && inputLength != C.LENGTH_UNSET) { + // We have read all tracks from all PMTs in this non-live stream. Now seek to the beginning + // and read again to make sure we output all media, including any contained in packets prior + // to those containing the track information. + pendingSeekToStart = true; + } + + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + + // Internals. + + private void maybeOutputSeekMap(long inputLength) { + if (!hasOutputSeekMap) { + hasOutputSeekMap = true; + if (durationReader.getDurationUs() != C.TIME_UNSET) { + tsBinarySearchSeeker = + new TsBinarySearchSeeker( + durationReader.getPcrTimestampAdjuster(), + durationReader.getDurationUs(), + inputLength, + pcrPid, + timestampSearchBytes); + output.seekMap(tsBinarySearchSeeker.getSeekMap()); + } else { + output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + } + } + } + + private boolean fillBufferWithAtLeastOnePacket(ExtractorInput input) throws IOException { + byte[] data = tsPacketBuffer.getData(); + // Shift bytes to the start of the buffer if there isn't enough space left at the end. + if (BUFFER_SIZE - tsPacketBuffer.getPosition() < TS_PACKET_SIZE) { + int bytesLeft = tsPacketBuffer.bytesLeft(); + if (bytesLeft > 0) { + System.arraycopy(data, tsPacketBuffer.getPosition(), data, 0, bytesLeft); + } + tsPacketBuffer.reset(data, bytesLeft); + } + // Read more bytes until we have at least one packet. + while (tsPacketBuffer.bytesLeft() < TS_PACKET_SIZE) { + int limit = tsPacketBuffer.limit(); + int read = input.read(data, limit, BUFFER_SIZE - limit); + if (read == C.RESULT_END_OF_INPUT) { + return false; + } + tsPacketBuffer.setLimit(limit + read); + } + return true; + } + + /** + * Returns the position of the end of the first TS packet (exclusive) in the packet buffer. + * + *

This may be a position beyond the buffer limit if the packet has not been read fully into + * the buffer, or if no packet could be found within the buffer. + */ + private int findEndOfFirstTsPacketInBuffer() throws ParserException { + int searchStart = tsPacketBuffer.getPosition(); + int limit = tsPacketBuffer.limit(); + int syncBytePosition = + TsUtil.findSyncBytePosition(tsPacketBuffer.getData(), searchStart, limit); + // Discard all bytes before the sync byte. + // If sync byte is not found, this means discard the whole buffer. + tsPacketBuffer.setPosition(syncBytePosition); + int endOfPacket = syncBytePosition + TS_PACKET_SIZE; + if (endOfPacket > limit) { + bytesSinceLastSync += syncBytePosition - searchStart; + if (mode == MODE_HLS && bytesSinceLastSync > TS_PACKET_SIZE * 2) { + throw ParserException.createForMalformedContainer( + "Cannot find sync byte. Most likely not a Transport Stream.", /* cause= */ null); + } + } else { + // We have found a packet within the buffer. + bytesSinceLastSync = 0; + } + return endOfPacket; + } + + private boolean shouldConsumePacketPayload(int packetPid) { + return mode == MODE_HLS + || tracksEnded + || !trackPids.get(packetPid, /* valueIfKeyNotFound= */ false); // It's a PSI packet + } + + private void resetPayloadReaders() { + trackIds.clear(); + tsPayloadReaders.clear(); + SparseArray initialPayloadReaders = + payloadReaderFactory.createInitialPayloadReaders(); + int initialPayloadReadersSize = initialPayloadReaders.size(); + for (int i = 0; i < initialPayloadReadersSize; i++) { + tsPayloadReaders.put(initialPayloadReaders.keyAt(i), initialPayloadReaders.valueAt(i)); + } + tsPayloadReaders.put(TS_PAT_PID, new SectionReader(new PatReader())); + id3Reader = null; + } + + /** Parses Program Association Table data. */ + private class PatReader implements SectionPayloadReader { + + private final ParsableBitArray patScratch; + + public PatReader() { + patScratch = new ParsableBitArray(new byte[4]); + } + + @Override + public void init( + TimestampAdjuster timestampAdjuster, + ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + // Do nothing. + } + + @Override + public void consume(ParsableByteArray sectionData) { + int tableId = sectionData.readUnsignedByte(); + if (tableId != 0x00 /* program_association_section */) { + // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. + return; + } + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.5. + return; + } + // section_length(8), transport_stream_id (16), reserved (2), version_number (5), + // current_next_indicator (1), section_number (8), last_section_number (8) + sectionData.skipBytes(6); + + int programCount = sectionData.bytesLeft() / 4; + for (int i = 0; i < programCount; i++) { + sectionData.readBytes(patScratch, 4); + int programNumber = patScratch.readBits(16); + patScratch.skipBits(3); // reserved (3) + if (programNumber == 0) { + patScratch.skipBits(13); // network_PID (13) + } else { + int pid = patScratch.readBits(13); + if (tsPayloadReaders.get(pid) == null) { + tsPayloadReaders.put(pid, new SectionReader(new PmtReader(pid))); + remainingPmts++; + } + } + } + if (mode != MODE_HLS) { + tsPayloadReaders.remove(TS_PAT_PID); + } + } + } + + /** Parses Program Map Table. */ + private class PmtReader implements SectionPayloadReader { + + private static final int TS_PMT_DESC_REGISTRATION = 0x05; + private static final int TS_PMT_DESC_ISO639_LANG = 0x0A; + private static final int TS_PMT_DESC_AC3 = 0x6A; + private static final int TS_PMT_DESC_AIT = 0x6F; + private static final int TS_PMT_DESC_EAC3 = 0x7A; + private static final int TS_PMT_DESC_DTS = 0x7B; + private static final int TS_PMT_DESC_DVB_EXT = 0x7F; + private static final int TS_PMT_DESC_DVBSUBS = 0x59; + + private static final int TS_PMT_DESC_DVB_EXT_AC4 = 0x15; + private static final int TS_PMT_DESC_DVB_EXT_DTS_HD = 0x0E; + private static final int TS_PMT_DESC_DVB_EXT_DTS_UHD = 0x21; + + private final ParsableBitArray pmtScratch; + private final SparseArray<@NullableType TsPayloadReader> trackIdToReaderScratch; + private final SparseIntArray trackIdToPidScratch; + private final int pid; + + public PmtReader(int pid) { + pmtScratch = new ParsableBitArray(new byte[5]); + trackIdToReaderScratch = new SparseArray<>(); + trackIdToPidScratch = new SparseIntArray(); + this.pid = pid; + } + + @Override + public void init( + TimestampAdjuster timestampAdjuster, + ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + // Do nothing. + } + + @Override + public void consume(ParsableByteArray sectionData) { + int tableId = sectionData.readUnsignedByte(); + if (tableId != 0x02 /* TS_program_map_section */) { + // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. + return; + } + // TimestampAdjuster assignment. + TimestampAdjuster timestampAdjuster; + if (mode == MODE_SINGLE_PMT || mode == MODE_HLS || remainingPmts == 1) { + timestampAdjuster = timestampAdjusters.get(0); + } else { + timestampAdjuster = + new TimestampAdjuster(timestampAdjusters.get(0).getFirstSampleTimestampUs()); + timestampAdjusters.add(timestampAdjuster); + } + + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.9. + return; + } + // section_length(8) + sectionData.skipBytes(1); + int programNumber = sectionData.readUnsignedShort(); + + // Skip 3 bytes (24 bits), including: + // reserved (2), version_number (5), current_next_indicator (1), section_number (8), + // last_section_number (8) + sectionData.skipBytes(3); + + sectionData.readBytes(pmtScratch, 2); + // reserved (3), PCR_PID (13) + pmtScratch.skipBits(3); + pcrPid = pmtScratch.readBits(13); + + // Read program_info_length. + sectionData.readBytes(pmtScratch, 2); + pmtScratch.skipBits(4); + int programInfoLength = pmtScratch.readBits(12); + + // Skip the descriptors. + sectionData.skipBytes(programInfoLength); + + if (mode == MODE_HLS && id3Reader == null) { + // Setup an ID3 track regardless of whether there's a corresponding entry, in case one + // appears intermittently during playback. See [Internal: b/20261500]. + EsInfo id3EsInfo = + new EsInfo(TS_STREAM_TYPE_ID3, null, AUDIO_TYPE_UNDEFINED, null, Util.EMPTY_BYTE_ARRAY); + id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, id3EsInfo); + if (id3Reader != null) { + id3Reader.init( + timestampAdjuster, + output, + new TrackIdGenerator(programNumber, TS_STREAM_TYPE_ID3, MAX_PID_PLUS_ONE)); + } + } + + trackIdToReaderScratch.clear(); + trackIdToPidScratch.clear(); + int dvbSubtitleOrdinal = 0; // ARVIO: position among this PMT's DVB subtitle streams. + int remainingEntriesLength = sectionData.bytesLeft(); + while (remainingEntriesLength > 0) { + sectionData.readBytes(pmtScratch, 5); + int streamType = pmtScratch.readBits(8); + pmtScratch.skipBits(3); // reserved + int elementaryPid = pmtScratch.readBits(13); + pmtScratch.skipBits(4); // reserved + int esInfoLength = pmtScratch.readBits(12); // ES_info_length. + EsInfo esInfo = readEsInfo(sectionData, esInfoLength); + if (streamType == 0x06 || streamType == 0x05) { + streamType = esInfo.streamType; + } + remainingEntriesLength -= esInfoLength + 5; + + // ARVIO: in HLS mode every DVB subtitle service keeps its own track. Upstream keys HLS tracks + // by stream type and keeps only the lowest PID of each, so a broadcast carrying Hebrew and + // Russian subtitles exposed only one of them. HLS maps text tracks by id (unlike audio and + // video, which it maps by type), so the extra subtitle tracks reach the player. The id comes + // from the service's descriptor, not its PID, so it stays the same in every rendition. + int trackId = + mode != MODE_HLS + ? elementaryPid + : streamType == TS_STREAM_TYPE_DVBSUBS + ? hlsDvbSubtitleTrackId(esInfo, dvbSubtitleOrdinal++) + : streamType; + if (trackIds.get(trackId)) { + continue; + } + + @Nullable + TsPayloadReader reader = + mode == MODE_HLS && streamType == TS_STREAM_TYPE_ID3 + ? id3Reader + : payloadReaderFactory.createPayloadReader(streamType, esInfo); + if (mode != MODE_HLS + || elementaryPid < trackIdToPidScratch.get(trackId, MAX_PID_PLUS_ONE)) { + trackIdToPidScratch.put(trackId, elementaryPid); + trackIdToReaderScratch.put(trackId, reader); + } + } + + int trackIdCount = trackIdToPidScratch.size(); + for (int i = 0; i < trackIdCount; i++) { + int trackId = trackIdToPidScratch.keyAt(i); + int trackPid = trackIdToPidScratch.valueAt(i); + trackIds.put(trackId, true); + trackPids.put(trackPid, true); + @Nullable TsPayloadReader reader = trackIdToReaderScratch.valueAt(i); + if (reader != null) { + if (reader != id3Reader) { + reader.init( + timestampAdjuster, + output, + new TrackIdGenerator(programNumber, trackId, MAX_PID_PLUS_ONE)); + } + tsPayloadReaders.put(trackPid, reader); + } + } + + if (mode == MODE_HLS) { + if (!tracksEnded) { + output.endTracks(); + remainingPmts = 0; + tracksEnded = true; + } + } else { + tsPayloadReaders.remove(pid); + remainingPmts = mode == MODE_SINGLE_PMT ? 0 : remainingPmts - 1; + if (remainingPmts == 0) { + output.endTracks(); + tracksEnded = true; + } + } + } + + /** + * Returns the stream info read from the available descriptors. Sets {@code data}'s position to + * the end of the descriptors. + * + * @param data A buffer with its position set to the start of the first descriptor. + * @param length The length of descriptors to read from the current position in {@code data}. + * @return The stream info read from the available descriptors. + */ + /** + * ARVIO: an HLS track id for a DVB subtitle service that is the same in every rendition and + * after every discontinuity. HLS discards text samples whose track id it has not seen when the + * tracks were first announced, so an id built from the PID made subtitles stop after a switch + * to a rendition carrying the same service on another PID. The id is derived from what the + * service is (language, subtitling type, composition and ancillary page ids from its + * descriptor), or from its position among the program's subtitle streams when it has no + * descriptor. Two services with the same identity in one program take consecutive ids. + */ + private int hlsDvbSubtitleTrackId(EsInfo esInfo, int ordinal) { + StringBuilder identity = new StringBuilder(); + for (DvbSubtitleInfo info : esInfo.dvbSubtitleInfos) { + identity.append(info.language).append('|').append(info.type).append('|'); + for (byte value : info.initializationData) { + identity.append(value & 0xFF).append(','); + } + identity.append(';'); + } + if (identity.length() == 0) { + identity.append("ordinal:").append(ordinal); + } + int offset = identity.toString().hashCode() % HLS_DVB_SUBTITLE_TRACK_ID_RANGE; + if (offset < 0) { + offset += HLS_DVB_SUBTITLE_TRACK_ID_RANGE; + } + for (int attempt = 0; attempt < HLS_DVB_SUBTITLE_TRACK_ID_RANGE; attempt++) { + int id = HLS_DVB_SUBTITLE_TRACK_ID_BASE + offset; + if (trackIdToPidScratch.indexOfKey(id) < 0) { + return id; + } + offset = (offset + 1) % HLS_DVB_SUBTITLE_TRACK_ID_RANGE; + } + return HLS_DVB_SUBTITLE_TRACK_ID_BASE + offset; + } + + private EsInfo readEsInfo(ParsableByteArray data, int length) { + int descriptorsStartPosition = data.getPosition(); + int descriptorsEndPosition = descriptorsStartPosition + length; + int streamType = -1; + String language = null; + @EsInfo.AudioType int audioType = AUDIO_TYPE_UNDEFINED; + List dvbSubtitleInfos = null; + while (data.getPosition() < descriptorsEndPosition) { + int descriptorTag = data.readUnsignedByte(); + int descriptorLength = data.readUnsignedByte(); + int positionOfNextDescriptor = data.getPosition() + descriptorLength; + if (positionOfNextDescriptor > descriptorsEndPosition) { + // Descriptor claims to extend past the end position. Skip it. + break; + } + if (descriptorTag == TS_PMT_DESC_REGISTRATION) { // registration_descriptor + long formatIdentifier = data.readUnsignedInt(); + if (formatIdentifier == AC3_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_AC3; + } else if (formatIdentifier == E_AC3_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_E_AC3; + } else if (formatIdentifier == AC4_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_AC4; + } else if (formatIdentifier == HEVC_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_H265; + } + } else if (descriptorTag == TS_PMT_DESC_AC3) { // AC-3_descriptor in DVB (ETSI EN 300 468) + streamType = TS_STREAM_TYPE_AC3; + } else if (descriptorTag == TS_PMT_DESC_EAC3) { // enhanced_AC-3_descriptor + streamType = TS_STREAM_TYPE_E_AC3; + } else if (descriptorTag == TS_PMT_DESC_DVB_EXT) { + // Extension descriptor in DVB (ETSI EN 300 468). + int descriptorTagExt = data.readUnsignedByte(); + if (descriptorTagExt == TS_PMT_DESC_DVB_EXT_AC4) { + // AC-4_descriptor in DVB (ETSI EN 300 468). + streamType = TS_STREAM_TYPE_AC4; + } else if (descriptorTagExt == TS_PMT_DESC_DVB_EXT_DTS_HD) { + // DTS-HD descriptor in DVB (ETSI EN 300 468). + streamType = TS_STREAM_TYPE_DTS_HD; + } else if (descriptorTagExt == TS_PMT_DESC_DVB_EXT_DTS_UHD) { + // DTS-UHD descriptor in DVB (ETSI EN 300 468). + streamType = TS_STREAM_TYPE_DTS_UHD; + } + } else if (descriptorTag == TS_PMT_DESC_DTS) { // DTS_descriptor + streamType = TS_STREAM_TYPE_DTS; + } else if (descriptorTag == TS_PMT_DESC_ISO639_LANG) { + language = data.readString(3).trim(); + audioType = data.readUnsignedByte(); + } else if (descriptorTag == TS_PMT_DESC_DVBSUBS) { + streamType = TS_STREAM_TYPE_DVBSUBS; + dvbSubtitleInfos = new ArrayList<>(); + while (data.getPosition() < positionOfNextDescriptor) { + String dvbLanguage = data.readString(3).trim(); + int dvbSubtitlingType = data.readUnsignedByte(); + byte[] initializationData = new byte[4]; + data.readBytes(initializationData, 0, 4); + dvbSubtitleInfos.add( + new DvbSubtitleInfo(dvbLanguage, dvbSubtitlingType, initializationData)); + } + } else if (descriptorTag == TS_PMT_DESC_AIT) { + streamType = TS_STREAM_TYPE_AIT; + } + // Skip unused bytes of current descriptor. + data.skipBytes(positionOfNextDescriptor - data.getPosition()); + } + data.setPosition(descriptorsEndPosition); + return new EsInfo( + streamType, + language, + audioType, + dvbSubtitleInfos, + Arrays.copyOfRange(data.getData(), descriptorsStartPosition, descriptorsEndPosition)); + } + } +} diff --git a/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/package-info.java b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/package-info.java new file mode 100644 index 000000000..f4bca5f25 --- /dev/null +++ b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/package-info.java @@ -0,0 +1,13 @@ +/** + * Vendored copy of media3 1.9.0's MPEG-TS extractor ({@code androidx.media3.extractor.ts}), used for + * IPTV HLS segments. In HLS mode the stock extractor keeps one track per stream type, so a channel + * carrying several DVB subtitle languages exposed only the lowest PID; this copy gives each DVB + * subtitle PID its own track (search for "ARVIO:" in TsExtractor). TsDurationReader and + * TsBinarySearchSeeker are copied unchanged because they are package-private upstream. Keep in sync + * when bumping the media3 version: re-copy the three files from the new tag and re-apply the + * "ARVIO:" changes. + */ +@NonNullApi +package com.arflix.tv.player.iptvts; + +import androidx.media3.common.util.NonNullApi; diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 2fa54ebd1..edbe361d4 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -177,6 +177,20 @@ internal fun normalizeIptvSortOrder(value: String?): String = when (value?.trim( else -> "provider" } +/** + * Removes characters a pasted address can carry that `trim()` keeps: control characters such as a + * trailing NUL (an emulator/clipboard paste produced `playlist.m3u\u0000`, which the server + * answered with HTTP 400) and invisible format characters (zero-width spaces, BOM, direction marks). + * [keepSeparators] keeps line breaks and tabs, which separate several guide addresses (or an + * Xtream "host user pass" triplet) in one field. + */ +internal fun stripInvisibleInputChars(value: String, keepSeparators: Boolean = false): String = + value.filterNot { ch -> + val separator = ch == '\n' || ch == '\r' || ch == '\t' + (Character.isISOControl(ch) && !(keepSeparators && separator)) || + Character.getType(ch) == Character.FORMAT.toInt() + } + /** * Title normalization for Xtream VOD/series lookups. * @@ -702,6 +716,10 @@ class IptvRepository @Inject constructor( private var cachedEpgAt: Long = 0L private val discoveredM3uEpgUrls = java.util.concurrent.CopyOnWriteArraySet() + // Playlists whose #EXTM3U header was already read for a guide address this session. + private val embeddedEpgHeadersRead = java.util.concurrent.CopyOnWriteArraySet() + // The playlist source the saved guide addresses were last restored for. + @Volatile private var discoveredEpgUrlsRestoredFor: String? = null @Volatile private var cacheOwnerProfileId: String? = null @Volatile @@ -1348,8 +1366,8 @@ class IptvRepository @Inject constructor( suspend fun saveStalkerConfig(portalUrl: String, macAddress: String) { val profileId = profileManager.getProfileIdSync() val previousConfig = observeConfig().first() - val normalizedUrl = portalUrl.trim().trimEnd('/') - val normalizedMac = macAddress.trim().uppercase().let { mac -> + val normalizedUrl = stripInvisibleInputChars(portalUrl).trim().trimEnd('/') + val normalizedMac = stripInvisibleInputChars(macAddress).trim().uppercase().let { mac -> if (mac.isNotEmpty() && !mac.startsWith("00:1A:79:")) mac else mac } // Legacy single-portal save: writes/updates the first portal entry. @@ -1537,7 +1555,7 @@ class IptvRepository @Inject constructor( } private fun normalizeStoredIptvUrl(raw: String): String { - val trimmed = raw.trim() + val trimmed = stripInvisibleInputChars(raw).trim() if (trimmed.isBlank()) return "" val decoded = decodeLegacyBase64Url(trimmed) return normalizeIptvInput(decoded ?: trimmed) @@ -1604,7 +1622,7 @@ class IptvRepository @Inject constructor( } private fun normalizeStoredEpgInputs(raw: String): List { - val trimmed = raw.trim() + val trimmed = stripInvisibleInputChars(raw, keepSeparators = true).trim() if (trimmed.isBlank()) return emptyList() val decoded = decodeLegacyBase64Url(trimmed) return normalizeEpgInputs(decoded ?: trimmed) @@ -3444,6 +3462,8 @@ class IptvRepository @Inject constructor( if (key != currentEpgIndexKey) return@withContext null val channels = channelStore.loadStartupChannels(key, LargeIptvListChannelCount, LargeListMemoryChannelLimit) if (channels.isEmpty()) return@withContext null + // This path skips the playlist parse, so bring back the guide address it declared. + restoreDiscoveredEpgUrlsFromCache(config) IptvSnapshot( channels = channels, grouped = buildGroupedChannels(channels), favoriteChannels = observeFavoriteChannels().first(), @@ -3996,7 +4016,13 @@ class IptvRepository @Inject constructor( channels: List ): Pair, Boolean> { if (channels.isEmpty()) return Pair(emptyMap(), false) - val candidates = resolveScopedEpgCandidates(config) + var candidates = resolveScopedEpgCandidates(config) + if (candidates.isEmpty()) { + // No guide known yet: the playlist may name one in its #EXTM3U line, which is + // never read when the channels come from the store instead of a playlist parse. + discoverEmbeddedEpgSourcesIfNeeded(activePlaylists(config)) + candidates = resolveScopedEpgCandidates(config) + } if (candidates.isEmpty()) return Pair(emptyMap(), false) val playlistKey = channels @@ -4024,9 +4050,15 @@ class IptvRepository @Inject constructor( } visibleXmlEpgCooldownUntil[cooldownKey] = nowMs + 90_000L + // The guide file is downloaded and read in full either way, and the same file may not be + // fetched again for two minutes, so for a list small enough to hold keep every channel's + // programmes: one download then fills the whole guide instead of only the visible rows. + val guideChannels = smallListChannelsForGuide() + ?.let { all -> (channels + all).distinctBy { it.id } } + ?: channels val visibleCandidates = candidates.take(2) for ((index, candidate) in visibleCandidates.withIndex()) { - val candidateChannels = channelsForScopedEpgCandidate(candidate, channels) + val candidateChannels = channelsForScopedEpgCandidate(candidate, guideChannels) if (candidateChannels.isEmpty()) continue System.err.println( "[EPG-Refresh] XMLTV visible fallback ${index + 1}/${visibleCandidates.size} " + @@ -4038,7 +4070,11 @@ class IptvRepository @Inject constructor( withTimeoutOrNull(12_000L) { val jobContext = currentCoroutineContext() runInterruptible(Dispatchers.IO) { - fetchAndParseEpg(candidate.url, candidateChannels) { jobContext.ensureActive() } + fetchAndParseEpg( + candidate.url, + candidateChannels, + notModifiedCheckChannels = channelsForScopedEpgCandidate(candidate, channels), + ) { jobContext.ensureActive() } } } } ?: emptyMap() @@ -4070,6 +4106,15 @@ class IptvRepository @Inject constructor( return Pair(emptyMap(), false) } + /** Every channel of the active list when it is at most [LargeIptvListChannelCount], else null. */ + private fun smallListChannelsForGuide(): List? { + val key = currentEpgIndexKey + if (key.isBlank()) return null + val count = pagedChannelStoreCount() + if (count <= 0 || count > LargeIptvListChannelCount) return null + return runCatching { channelStore.loadAll(key) }.getOrNull()?.takeIf { it.isNotEmpty() } + } + private fun persistCurrentCacheSnapshot(config: IptvConfig, loadedAtMs: Long = System.currentTimeMillis()) { val channels = cachedChannels if (channels.isEmpty()) return @@ -4256,6 +4301,8 @@ class IptvRepository @Inject constructor( // a portal that did gets caught by the fingerprint check on read. stalkerCategoryStore = null discoveredM3uEpgUrls.clear() + embeddedEpgHeadersRead.clear() + discoveredEpgUrlsRestoredFor = null xtreamVodCacheKey = null xtreamVodLoadedAtMs = 0L xtreamSeriesLoadedAtMs = 0L @@ -8334,6 +8381,7 @@ class IptvRepository @Inject constructor( } private fun resolveScopedEpgCandidates(config: IptvConfig): List { + restoreDiscoveredEpgUrlsFromCache(config) val allLists = activePlaylists(config) return buildList { allLists.forEach { list -> @@ -8397,7 +8445,8 @@ class IptvRepository @Inject constructor( val candidates = playlists.filter { playlist -> playlist.m3uUrl.isNotBlank() && playlist.allEpgUrls().isEmpty() && - resolveXtreamCredentials(playlist) == null + resolveXtreamCredentials(playlist) == null && + playlist.m3uUrl !in embeddedEpgHeadersRead } if (candidates.isEmpty()) return @@ -8405,6 +8454,10 @@ class IptvRepository @Inject constructor( candidates.map { playlist -> async(Dispatchers.IO) { discoverM3uHeaderEpgUrls(playlist.m3uUrl) + // A header that was read (with or without a guide) is not read again + // this session; a failed request is retried on the next guide request. + ?.also { embeddedEpgHeadersRead.add(playlist.m3uUrl) } + .orEmpty() } }.awaitAll() } @@ -8414,14 +8467,15 @@ class IptvRepository @Inject constructor( .forEach { discoveredM3uEpgUrls.add(it) } } - private fun discoverM3uHeaderEpgUrls(m3uUrl: String): List { + /** The guide addresses named in a playlist's `#EXTM3U` line, or null when it could not be read. */ + private fun discoverM3uHeaderEpgUrls(m3uUrl: String): List? { return runCatching { val request = Request.Builder() .url(validatedIptvHttpUrl(m3uUrl, "IPTV playlist URL")) .build() iptvCatalogHttpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) return@use emptyList() - val body = response.body ?: return@use emptyList() + if (!response.isSuccessful) return@use null + val body = response.body ?: return@use null BufferedReader(InputStreamReader(body.byteStream(), StandardCharsets.UTF_8), 32 * 1024).use { reader -> repeat(32) { val line = reader.readLine() ?: return@use emptyList() @@ -8436,9 +8490,50 @@ class IptvRepository @Inject constructor( emptyList() } } - }.getOrDefault(emptyList()) + }.getOrNull() } + /** + * Brings back the guide addresses a playlist declared in its header, saved with the guide + * cache. Startup paths that read channels from the SQLite store never parse the playlist, + * so without this the address stayed unknown and the guide never loaded. Runs once per + * playlist source; the file is streamed so a large stored guide is skipped, not parsed. + */ + internal fun restoreDiscoveredEpgUrlsFromCache(config: IptvConfig) { + if (discoveredM3uEpgUrls.isNotEmpty()) return + val sourceKey = buildSourceSignature(config) + if (discoveredEpgUrlsRestoredFor == sourceKey) return + discoveredEpgUrlsRestoredFor = sourceKey + runCatching { + val file = cacheFile() + if (!file.exists() || file.length() > MAX_IPTV_CACHE_BYTES * 2) return + var urls: List = emptyList() + var configSignature = "" + var sourceSignature = "" + cacheJsonReader(file).use { reader -> + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "configSignature" -> configSignature = reader.nextString().orEmpty() + "sourceSignature" -> sourceSignature = reader.nextString().orEmpty() + "discoveredEpgUrls" -> { + val type = TypeToken.getParameterized(List::class.java, String::class.java).type + urls = gson.fromJson>(reader, type).orEmpty() + } + else -> reader.skipValue() + } + } + reader.endObject() + } + if (isValidCacheSignature(config, configSignature, sourceSignature)) { + rememberDiscoveredEpgUrls(urls) + } + } + } + + /** Guide addresses the active playlists declared themselves (`url-tvg`), not entered in settings. */ + fun playlistDeclaredEpgUrls(): List = discoveredM3uEpgUrls.toList() + private suspend fun fetchXtreamLiveChannels( creds: XtreamCredentials, onProgress: (IptvLoadProgress) -> Unit @@ -8631,11 +8726,13 @@ class IptvRepository @Inject constructor( private fun fetchAndParseEpg( url: String, channels: List, + // The channels the caller actually needs; a wider [channels] set is parsed as a bonus. + notModifiedCheckChannels: List = channels, checkActive: () -> Unit = {}, ): Map { checkActive() // A 304 is only useful when this exact request can be served from cache. - val hasDbEntries = channels.all { hasProgramData(cachedNowNext[it.id]) } + val hasDbEntries = notModifiedCheckChannels.all { hasProgramData(cachedNowNext[it.id]) } fun epgRequest(targetUrl: String, userAgent: String, forceFull: Boolean = false): Request { val builder = Request.Builder() @@ -11637,10 +11734,12 @@ class IptvRepository @Inject constructor( if (text.isBlank()) return null val payload = gson.fromJson(text, IptvCachePayload::class.java) ?: return null if (!isValidCacheSignature(config, payload.configSignature, payload.sourceSignature)) return null + // The playlist's own guide address must come back even when no guide has been + // stored yet: without it the guide never loads, so nowNext would stay empty forever. + rememberDiscoveredEpgUrls(payload.discoveredEpgUrls.orEmpty()) // Channels now come from the SQLite store, so the guide cache is channel-less; // it's only useful for its nowNext slice. if (payload.channels.isEmpty() && payload.nowNext.isEmpty()) return null - rememberDiscoveredEpgUrls(payload.discoveredEpgUrls.orEmpty()) payload }.getOrNull() } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt index e1d10a0a9..181dd7d20 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt @@ -178,8 +178,10 @@ internal object StalkerPortalSupport { portal: StalkerPortalEntry, index: Int ): StalkerPortalEntry? { - val portalUrl = runCatching { portal.portalUrl }.getOrNull().orEmpty().trim().trimEnd('/') - val macAddress = runCatching { portal.macAddress }.getOrNull().orEmpty().trim().uppercase() + val portalUrl = stripInvisibleInputChars(runCatching { portal.portalUrl }.getOrNull().orEmpty()) + .trim().trimEnd('/') + val macAddress = stripInvisibleInputChars(runCatching { portal.macAddress }.getOrNull().orEmpty()) + .trim().uppercase() if (portalUrl.isBlank() || macAddress.isBlank()) return null return StalkerPortalEntry( id = runCatching { portal.id }.getOrNull().orEmpty().trim().ifBlank { "stalker${index + 1}" }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefresh.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefresh.kt index 6de75eb5a..152d17be9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefresh.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefresh.kt @@ -1,6 +1,7 @@ package com.arflix.tv.ui.screens.home import com.arflix.tv.data.model.IptvNowNext +import com.arflix.tv.data.repository.IptvConfig /** * When the Favorite TV row may go back to the network for guide data: only while the saved @@ -22,3 +23,12 @@ internal fun favoriteTvGuideNeedsNetwork( /** Moving along the row re-reads the saved guide at most once per [intervalMs]. */ internal fun favoriteTvFocusRefreshDue(lastRefreshMs: Long, nowMs: Long, intervalMs: Long): Boolean = lastRefreshMs == 0L || nowMs - lastRefreshMs >= intervalMs + +/** + * Which IPTV sources the Favorite TV row is built from: the enabled playlists and Stalker + * portals. A change means the row may show channels of a source that was just switched off. + */ +internal fun iptvFavoriteSourceKey(config: IptvConfig): String = buildList { + config.playlists.filter { it.enabled }.forEach { add("p:${it.id}|${it.m3uUrl}") } + config.stalkerPortals.filter { it.enabled }.forEach { add("s:${it.id}|${it.portalUrl}|${it.macAddress}") } +}.joinToString("\n") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 97cdf4d53..2003122a9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -2,6 +2,7 @@ package com.arflix.tv.ui.screens.home +import com.arflix.tv.ui.screens.tv.live.withLiveSubtitleDefault import com.arflix.tv.ui.components.LocalBottomBarInset import com.arflix.tv.ui.components.LocalBottomBarHeight import androidx.activity.compose.BackHandler @@ -549,7 +550,7 @@ private fun createHomeHeroPlaybackHandles(context: Context): HomeHeroPlaybackHan // The hero previews favorite IPTV channels, so it needs the same non-IDR keyframe // extractors as the TV screens or those channels stall on the home screen. val heroHlsFactory = HlsMediaSource.Factory(heroDataSourceFactory) - .setAllowChunklessPreparation(true) + .setAllowChunklessPreparation(com.arflix.tv.ui.screens.tv.live.IPTV_HLS_CHUNKLESS_PREPARATION) .setExtractorFactory(com.arflix.tv.ui.screens.tv.live.iptvHlsExtractorFactory()) val heroDefaultFactory = DefaultMediaSourceFactory(context, com.arflix.tv.ui.screens.tv.live.iptvExtractorsFactory()) .setDataSourceFactory(heroDataSourceFactory) @@ -1161,8 +1162,17 @@ fun HomeScreen( val iptvTarget = displayHeroItem ?.takeIf { isHeroIptv } ?.let { viewModel.resolveIptvPreviewTarget(it.id) } + // Channel previews follow the profile's default subtitle setting, as the Live TV + // screen does. Trailers and other previews share this player, so they get the + // player's own defaults back rather than whatever the last channel set. + val subtitleDefault = if (isHeroIptv) viewModel.previewSubtitleDefault() else null player?.stop() player?.clearMediaItems() + player?.let { p -> + p.trackSelectionParameters = subtitleDefault + ?.let { p.trackSelectionParameters.withLiveSubtitleDefault(it) } + ?: androidx.media3.common.TrackSelectionParameters.Builder(context).build() + } val useHls = homeHeroUsesHls(heroVideoUrl, iptvTarget?.isHls == true) val mi = androidx.media3.common.MediaItem.Builder() .setUri(iptvTarget?.url ?: heroVideoUrl) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index faf87a928..7037a02a9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1036,6 +1036,15 @@ class HomeViewModel @Inject constructor( .getOrNull() } + /** The profile's "Default subtitle" setting for channel previews (off when it cannot be read). */ + internal suspend fun previewSubtitleDefault(): com.arflix.tv.ui.screens.tv.live.LiveSubtitleDefault = runCatching { + val prefs = context.settingsDataStore.data.first() + com.arflix.tv.ui.screens.tv.live.liveSubtitleDefault( + prefs[profileManager.profileStringKey("default_subtitle")] + ) + }.onFailure { if (it is CancellationException) throw it } + .getOrDefault(com.arflix.tv.ui.screens.tv.live.LiveSubtitleDefault.Off) + /** * Looks up the channels behind IPTV cards this view model didn't build itself. Rows * preloaded at startup carry the cards but not their channels, and the full home load @@ -1315,6 +1324,45 @@ class HomeViewModel @Inject constructor( } } + /** + * After the active IPTV sources change, the home load finds the new channel list still loading, + * reports the Favorite TV row as unavailable and keeps it — with the old sources' channels, + * until a restart. Rebuild it once the new list can be read: replace it, or remove it when the + * new sources have no favourites. If the list never becomes readable, the stale row goes too. + */ + private suspend fun rebuildFavoriteTvAfterSourceChange() { + repeat(FAVORITE_TV_SOURCE_CHANGE_ATTEMPTS) { attempt -> + delay(if (attempt == 0) 500L else FAVORITE_TV_SOURCE_CHANGE_RETRY_MS) + val outcome = withContext(Dispatchers.IO) { + runCatching { buildFavoriteTvOutcome() }.getOrDefault(FavoriteTvOutcome.Unavailable) + } + when (outcome) { + is FavoriteTvOutcome.Available -> { + val cfg = savedCatalogById[FAVORITE_TV_CATEGORY_ID] + val titled = if (cfg != null && cfg.title.isNotBlank()) { + outcome.category.copy(title = cfg.title) + } else { + outcome.category + } + updateMobileCategoryRow(FAVORITE_TV_CATEGORY_ID, titled) + return + } + FavoriteTvOutcome.Empty -> { + removeFavoriteTvRow() + return + } + FavoriteTvOutcome.Unavailable -> Unit + } + } + removeFavoriteTvRow() + } + + private fun removeFavoriteTvRow() { + val categories = _uiState.value.categories + if (categories.none { it.id == FAVORITE_TV_CATEGORY_ID }) return + _uiState.value = _uiState.value.copy(categories = categories.filterNot { it.id == FAVORITE_TV_CATEGORY_ID }) + } + /** * The Favorite TV row's now/next only shows on a focused card, so it is brought up to date * when focus reaches the row, not on a timer. A Home screen left open used to download the @@ -1508,6 +1556,9 @@ class HomeViewModel @Inject constructor( // EPG refresh for the Favorite TV row, driven by focus (see refreshFavoriteTvGuideOnFocus) /** Moving between cards re-derives now/next from the saved guide at most this often. */ private val EPG_FOCUS_REFRESH_MS = 60_000L + /** After an IPTV source change, how long the Favorite TV row waits for the new channel list. */ + private val FAVORITE_TV_SOURCE_CHANGE_ATTEMPTS = 30 + private val FAVORITE_TV_SOURCE_CHANGE_RETRY_MS = 2_000L /** How often the network may be asked while the saved guide lacks a current programme. */ private val EPG_NETWORK_RETRY_MS = 30 * 60_000L private var activeEpgRefreshJob: Job? = null @@ -1975,6 +2026,15 @@ class HomeViewModel @Inject constructor( } } + // Switching IPTV playlists or portals on or off must rebuild the Favorite TV row. + viewModelScope.launch { + iptvRepository.observeConfig() + .map(::iptvFavoriteSourceKey) + .distinctUntilChanged() + .drop(1) + .collectLatest { rebuildFavoriteTvAfterSourceChange() } + } + // Keep Home preferences bound to the active profile. Cloud restore and profile // switching can update DataStore after this ViewModel has already been created. viewModelScope.launch { 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 a88164710..d393c43fa 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 @@ -52,6 +52,7 @@ import com.arflix.tv.util.AppLogger import com.arflix.tv.util.Constants import com.arflix.tv.util.EpisodeAvailability import com.arflix.tv.util.ForcedSubtitles +import com.arflix.tv.util.normalizeLanguageCode import com.arflix.tv.util.fallbackAdjacentEpisodeIdentity import com.arflix.tv.util.adjacentTmdbEpisodeIdentity import com.arflix.tv.util.settingsDataStore @@ -3022,100 +3023,7 @@ class PlayerViewModel @Inject constructor( * Normalize language codes to a standard format for matching * Maps: "English" -> "en", "eng" -> "en", "Spanish" -> "es", etc. */ - private fun normalizeLanguage(lang: String): String { - val lowerLang = lang.lowercase().trim() - return when { - lowerLang in listOf("ms", "msa", "may", "malay", "bahasa melayu") || - lowerLang.startsWith("ms-") || lowerLang.startsWith("ms_") -> "ms" - // Full names - lowerLang == "english" || lowerLang.startsWith("english") -> "en" - lowerLang == "spanish" || lowerLang.startsWith("spanish") || lowerLang == "espanol" -> "es" - lowerLang == "french" || lowerLang.startsWith("french") || lowerLang == "francais" -> "fr" - lowerLang == "german" || lowerLang.startsWith("german") || lowerLang == "deutsch" -> "de" - lowerLang == "italian" || lowerLang.startsWith("italian") -> "it" - lowerLang == "portuguese" -> "pt" - lowerLang == "portuguese (brazil)" || - lowerLang == "portuguese-brazil" || - lowerLang == "brazilian portuguese" || - lowerLang == "brazil portuguese" || - lowerLang == "pt-br" || - lowerLang == "ptbr" -> "pt-br" - lowerLang.startsWith("portuguese") -> "pt" - lowerLang == "dutch" || lowerLang.startsWith("dutch") -> "nl" - lowerLang == "russian" || lowerLang.startsWith("russian") -> "ru" - lowerLang == "chinese" || lowerLang.startsWith("chinese") -> "zh" - lowerLang == "japanese" || lowerLang.startsWith("japanese") || lowerLang == "jp" || lowerLang == "jap" -> "ja" - lowerLang == "korean" || lowerLang.startsWith("korean") -> "ko" - lowerLang == "arabic" || lowerLang.startsWith("arabic") -> "ar" - lowerLang == "hindi" || lowerLang.startsWith("hindi") -> "hi" - lowerLang == "turkish" || lowerLang.startsWith("turkish") -> "tr" - lowerLang == "polish" || lowerLang.startsWith("polish") -> "pl" - lowerLang == "swedish" || lowerLang.startsWith("swedish") -> "sv" - lowerLang == "norwegian" || lowerLang.startsWith("norwegian") -> "no" - lowerLang == "danish" || lowerLang.startsWith("danish") -> "da" - lowerLang == "finnish" || lowerLang.startsWith("finnish") -> "fi" - lowerLang == "greek" || lowerLang.startsWith("greek") -> "el" - lowerLang == "czech" || lowerLang.startsWith("czech") -> "cs" - lowerLang == "hungarian" || lowerLang.startsWith("hungarian") -> "hu" - lowerLang == "romanian" || lowerLang.startsWith("romanian") -> "ro" - lowerLang == "thai" || lowerLang.startsWith("thai") -> "th" - lowerLang == "vietnamese" || lowerLang.startsWith("vietnamese") -> "vi" - lowerLang == "indonesian" || lowerLang.startsWith("indonesian") -> "id" - lowerLang == "hebrew" || lowerLang.startsWith("hebrew") -> "he" - lowerLang == "persian" || lowerLang.startsWith("persian") || lowerLang == "farsi" -> "fa" - lowerLang == "ukrainian" || lowerLang.startsWith("ukrainian") -> "uk" - lowerLang == "bengali" || lowerLang.startsWith("bengali") -> "bn" - lowerLang == "bulgarian" || lowerLang.startsWith("bulgarian") -> "bg" - lowerLang == "croatian" || lowerLang.startsWith("croatian") -> "hr" - lowerLang == "serbian" || lowerLang.startsWith("serbian") -> "sr" - lowerLang == "slovak" || lowerLang.startsWith("slovak") -> "sk" - lowerLang == "slovenian" || lowerLang.startsWith("slovenian") -> "sl" - lowerLang == "lithuanian" || lowerLang.startsWith("lithuanian") -> "lt" - lowerLang == "estonian" || lowerLang.startsWith("estonian") -> "et" - // ISO 639-1 codes (2 letter) - lowerLang.length == 2 -> lowerLang - // ISO 639-2 codes (3 letter) - lowerLang == "eng" -> "en" - lowerLang == "spa" -> "es" - lowerLang == "fra" || lowerLang == "fre" -> "fr" - lowerLang == "deu" || lowerLang == "ger" -> "de" - lowerLang == "ita" -> "it" - lowerLang == "por" -> "pt" - lowerLang == "pob" || lowerLang == "pobr" -> "pt-br" - lowerLang == "nld" || lowerLang == "dut" -> "nl" - lowerLang == "rus" -> "ru" - lowerLang == "zho" || lowerLang == "chi" -> "zh" - lowerLang == "jpn" -> "ja" - lowerLang == "kor" -> "ko" - lowerLang == "ara" -> "ar" - lowerLang == "hin" -> "hi" - lowerLang == "tur" -> "tr" - lowerLang == "pol" -> "pl" - lowerLang == "swe" -> "sv" - lowerLang == "nor" -> "no" - lowerLang == "dan" -> "da" - lowerLang == "fin" -> "fi" - lowerLang == "ell" || lowerLang == "gre" -> "el" - lowerLang == "ces" || lowerLang == "cze" -> "cs" - lowerLang == "hun" -> "hu" - lowerLang == "ron" || lowerLang == "rum" -> "ro" - lowerLang == "tha" -> "th" - lowerLang == "vie" -> "vi" - lowerLang == "ind" -> "id" - lowerLang == "heb" || lowerLang == "iw" -> "he" - lowerLang == "fas" || lowerLang == "per" -> "fa" - lowerLang == "ukr" -> "uk" - lowerLang == "ben" -> "bn" - lowerLang == "bul" -> "bg" - lowerLang == "hrv" -> "hr" - lowerLang == "srp" -> "sr" - lowerLang == "slk" || lowerLang == "slo" -> "sk" - lowerLang == "slv" -> "sl" - lowerLang == "lit" -> "lt" - lowerLang == "est" -> "et" - else -> lowerLang - } - } + private fun normalizeLanguage(lang: String): String = normalizeLanguageCode(lang) // Track current stream index for auto-retry private var currentStreamIndex = 0 diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 9ad6db0a1..edbdb1ec2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -599,10 +599,10 @@ fun TvScreen( OkHttpDataSource.Factory(iptvHttpClient) .setUserAgent(OkHttpProvider.getAppUserAgent(context)) } - // HLS factory with chunkless preparation (used when stream is detected as HLS) + // HLS factory (used when stream is detected as HLS) val iptvHlsFactory = remember(iptvDataSourceFactory) { HlsMediaSource.Factory(iptvDataSourceFactory) - .setAllowChunklessPreparation(true) + .setAllowChunklessPreparation(com.arflix.tv.ui.screens.tv.live.IPTV_HLS_CHUNKLESS_PREPARATION) .setExtractorFactory(com.arflix.tv.ui.screens.tv.live.iptvHlsExtractorFactory()) } // Default factory handles all formats (MPEG-TS, HLS, DASH, progressive, etc.) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index 602d1ccb4..d4541ccf4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -126,6 +126,23 @@ class TvViewModel @Inject constructor( ) : ViewModel() { suspend fun loadSportsGuideArtwork() = sportsRepository.loadGuideArtwork() + + /** The profile's "Default audio language" setting for live channels, or null for "Auto". */ + suspend fun preferredAudioLanguage(): String? = runCatching { + val prefs = context.settingsDataStore.data.first() + com.arflix.tv.ui.screens.tv.live.audioPreferenceLanguage( + prefs[profileManager.profileStringKey("default_audio_language")] + ) + }.getOrNull() + + /** The profile's "Default subtitle" setting for live channels (off when it cannot be read). */ + internal suspend fun liveSubtitleDefault(): com.arflix.tv.ui.screens.tv.live.LiveSubtitleDefault = runCatching { + val prefs = context.settingsDataStore.data.first() + com.arflix.tv.ui.screens.tv.live.liveSubtitleDefault( + prefs[profileManager.profileStringKey("default_subtitle")] + ) + }.getOrDefault(com.arflix.tv.ui.screens.tv.live.LiveSubtitleDefault.Off) + suspend fun cachedSportsMetadata() = sportsRepository.cachedMetadata() internal var cachedSportsSchedule: com.arflix.tv.ui.screens.tv.live.SportsScheduleSnapshot? = null private var sportsBroadcasterIndexLoad: Pair>? = null @@ -861,6 +878,10 @@ class TvViewModel @Inject constructor( return try { iptvRepository.pagedChannelStoreCount() > 10_000 } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } } + /** An XMLTV guide from settings, an Xtream login, or the playlist's own `url-tvg` line. */ + private fun hasXmltvGuide(config: IptvConfig): Boolean = + LiveTvGuideSources.hasXmltvSource(config, iptvRepository.playlistDeclaredEpgUrls()) + /** * Resolve a channel by id without scanning the full snapshot list. For large * playlists a `firstOrNull` over 50k+ channels ran per focus change on the main @@ -948,7 +969,7 @@ class TvViewModel @Inject constructor( iptvRepository.setLiveTvInteractive(active) // Bounded XMLTV batches can continue in the background. Keep unbounded // per-channel API sweeps deferred while the video decoder is active. - if (active && !(isActiveLargeIptvList() && LiveTvGuideSources.hasXmltvSource(_uiState.value.config))) { + if (active && !(isActiveLargeIptvList() && hasXmltvGuide(_uiState.value.config))) { deferredCompleteEpgBackfillJob?.cancel() deferredCompleteEpgBackfillJob = null if (completeEpgBackfillJob?.isActive == true) { @@ -1132,7 +1153,7 @@ class TvViewModel @Inject constructor( // Stalker uses its own channel IDs instead of M3U/Xtream identity fields. if (!force && !largeList && channels.none { channel -> !channel.epgId.isNullOrBlank() || !channel.tvgName.isNullOrBlank() || - (LiveTvGuideSources.hasXmltvSource(state.config) && channel.name.isNotBlank()) || + (hasXmltvGuide(state.config) && channel.name.isNotBlank()) || StalkerPortalSupport.portalIdFromChannelId(channel.id) != null } ) { @@ -1143,7 +1164,7 @@ class TvViewModel @Inject constructor( ) return } - val streamingXmlBackfill = largeList && LiveTvGuideSources.hasXmltvSource(state.config) + val streamingXmlBackfill = largeList && hasXmltvGuide(state.config) if (!force && streamingXmlBackfill && iptvRepository.completedFullGuideAgeMs() < 6 * 60 * 60_000L) { setEpgBackfillInProgress(false) return @@ -1154,7 +1175,7 @@ class TvViewModel @Inject constructor( } // Large-list XMLTV is streamed into bounded database batches. Do not turn // a missing XMLTV feed into tens of thousands of per-channel API calls. - if (largeList && !LiveTvGuideSources.hasXmltvSource(state.config)) { + if (largeList && !hasXmltvGuide(state.config)) { setEpgBackfillInProgress(false) System.err.println("[EPG-Complete] Skipping on-device full guide backfill for large playlist; visible guide loads on demand") return diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt index 0e64a2a95..7708e1844 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenHud.kt @@ -15,11 +15,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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 @@ -63,6 +65,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -89,7 +94,7 @@ import java.util.Locale * its own after a channel zap is then pure information with nothing focusable in it. With it on the * controls are drawn and focused in the same breath, so anything visible is always operable. */ -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun FullscreenHud( channel: EnrichedChannel?, @@ -109,6 +114,8 @@ fun FullscreenHud( onBackClick: (() -> Unit)? = null, onGuideClick: (() -> Unit)? = null, onOpenVariants: (() -> Unit)? = null, + onSubtitlesClick: (() -> Unit)? = null, + onAudioClick: (() -> Unit)? = null, onPlayPauseClick: (() -> Unit)? = null, onRewindClick: (() -> Unit)? = null, onFastForwardClick: (() -> Unit)? = null, @@ -121,6 +128,8 @@ fun FullscreenHud( onVisibilityChanged: ((Boolean) -> Unit)? = null, hideSignal: Int = 0, showControls: Boolean = true, + /** Bumped when a list opened from the controls (subtitles, audio) closes: focus returns to its button. */ + refocusSignal: Int = 0, modifier: Modifier = Modifier, ) { var visible by remember { mutableStateOf(true) } @@ -164,6 +173,10 @@ fun FullscreenHud( var localIsPlaying by remember(isPlaying) { mutableStateOf(isPlaying) } val playPauseFocusRequester = remember { FocusRequester() } + val subtitlesFocusRequester = remember { FocusRequester() } + val audioFocusRequester = remember { FocusRequester() } + // The button whose list is open, so closing the list lands back on it. + var focusReturnTarget by remember { mutableStateOf(null) } var initialFocusApplied by remember { mutableStateOf(false) } // Dismiss on request (Back), without leaving fullscreen. @@ -192,17 +205,26 @@ fun FullscreenHud( // set and swallowed the second open the same way. // Retrying costs nothing once focus has landed, and covers the row simply // not being attached yet on the frame the HUD fades in. - LaunchedEffect(visible, showControls) { + // + // Closing a list opened from the row (subtitles, audio) bumps `refocusSignal`: focus left + // the row for the list, so it is placed again, on the button that opened it. + LaunchedEffect(visible, showControls, refocusSignal) { if (touchControls) return@LaunchedEffect if (!visible || !showControls) { initialFocusApplied = false } else { + val returnTarget = focusReturnTarget + if (returnTarget != null) initialFocusApplied = false var attempts = 0 while (!initialFocusApplied && attempts < ControlFocusAttempts) { attempts++ delay(ControlFocusRetryMs) - if (runCatching { playPauseFocusRequester.requestFocus() }.isSuccess) { + // The last attempt falls back to play/pause, in case the button that opened the + // list is gone (its tracks changed while the list was open). + val target = returnTarget?.takeIf { attempts < ControlFocusAttempts } ?: playPauseFocusRequester + if (runCatching { target.requestFocus() }.isSuccess) { initialFocusApplied = true + focusReturnTarget = null } } } @@ -517,15 +539,12 @@ fun FullscreenHud( ) // --- Row 3: Bottom Control Bar (Exact Centering & Time on Left) --- - Box( + HudControlBar( modifier = Modifier .fillMaxWidth() - .height(56.dp), - ) { - // Left Side: Time Text below Seek Bar - Box( - modifier = Modifier.align(Alignment.CenterStart), - ) { + .heightIn(min = 56.dp), + start = { + // Left Side: Time Text below Seek Bar Text( text = positionText, style = LiveType.TimeMono.copy( @@ -534,97 +553,224 @@ fun FullscreenHud( fontWeight = FontWeight.Medium, ), ) - } - - // EXACT CENTER: Playback Controls Bar - Row( - modifier = Modifier.align(Alignment.Center), - horizontalArrangement = Arrangement.spacedBy(if (touchControls) 12.dp else 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Previous channel (|<) - HudIconButton( - icon = Icons.Filled.SkipPrevious, - contentDescription = stringResource(R.string.live_cd_previous_channel), - onClick = { onPreviousCatchupClick?.invoke() }, - ) - - // Rewind (<<) - HudIconButton( - icon = Icons.Filled.FastRewind, - contentDescription = stringResource(R.string.live_cd_rewind), - onClick = { onRewindClick?.invoke() }, - ) - - // Central Play/Pause button - HudIconButton( - icon = if (localIsPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = stringResource(if (localIsPlaying) R.string.live_cd_pause else R.string.play), - emphasis = true, - focusRequester = playPauseFocusRequester, - onClick = { - localIsPlaying = !localIsPlaying - onPlayPauseClick?.invoke() - }, - ) - - // Fast Forward (>>) - HudIconButton( - icon = Icons.Filled.FastForward, - contentDescription = stringResource(R.string.live_cd_fast_forward), - onClick = { onFastForwardClick?.invoke() }, - ) + }, + center = { + // Centered Playback Controls Bar + Row( + horizontalArrangement = Arrangement.spacedBy(if (touchControls) 12.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Previous channel (|<) + HudIconButton( + icon = Icons.Filled.SkipPrevious, + contentDescription = stringResource(R.string.live_cd_previous_channel), + onClick = { onPreviousCatchupClick?.invoke() }, + ) - // Next channel (>|) - HudIconButton( - icon = Icons.Filled.SkipNext, - contentDescription = stringResource(R.string.live_cd_next_channel), - onClick = { onNextCatchupClick?.invoke() }, - ) - } + // Rewind (<<) + HudIconButton( + icon = Icons.Filled.FastRewind, + contentDescription = stringResource(R.string.live_cd_rewind), + onClick = { onRewindClick?.invoke() }, + ) - // Right Side: Replay, LIVE, GUIDE - Row( - modifier = Modifier.align(Alignment.CenterEnd), - horizontalArrangement = Arrangement.spacedBy(if (touchControls) 8.dp else 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Replay / Restart - HudIconButton( - icon = Icons.Filled.Replay, - contentDescription = stringResource(R.string.live_cd_replay), - onClick = { onReplayClick?.invoke() }, - ) + // Central Play/Pause button + HudIconButton( + icon = if (localIsPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = stringResource(if (localIsPlaying) R.string.live_cd_pause else R.string.play), + emphasis = true, + focusRequester = playPauseFocusRequester, + onClick = { + localIsPlaying = !localIsPlaying + onPlayPauseClick?.invoke() + }, + ) - // LIVE button - if (isCatchupMode) { - HudActionButton( - label = stringResource(R.string.live_badge_live), - onClick = { onGoLiveClick?.invoke() }, + // Fast Forward (>>) + HudIconButton( + icon = Icons.Filled.FastForward, + contentDescription = stringResource(R.string.live_cd_fast_forward), + onClick = { onFastForwardClick?.invoke() }, ) - } - // Guide button at far right - if (onOpenVariants != null) { - HudActionButton( - label = stringResource(R.string.live_label_choose_source), - onClick = onOpenVariants, + // Next channel (>|) + HudIconButton( + icon = Icons.Filled.SkipNext, + contentDescription = stringResource(R.string.live_cd_next_channel), + onClick = { onNextCatchupClick?.invoke() }, ) } - if (onGuideClick != null) { - HudActionButton( - label = stringResource(R.string.live_btn_guide), - onClick = onGuideClick, + }, + end = { + // Right Side: Replay, LIVE, GUIDE. Wraps onto further lines when even + // a line of its own is too narrow (phones in portrait). + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + if (touchControls) 8.dp else 12.dp, + Alignment.End, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Replay / Restart + HudIconButton( + modifier = Modifier.align(Alignment.CenterVertically), + icon = Icons.Filled.Replay, + contentDescription = stringResource(R.string.live_cd_replay), + onClick = { onReplayClick?.invoke() }, ) + + // LIVE button + if (isCatchupMode) { + HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), + label = stringResource(R.string.live_badge_live), + onClick = { onGoLiveClick?.invoke() }, + ) + } + + if (onAudioClick != null) { + HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), + label = stringResource(R.string.live_audio), + focusRequester = audioFocusRequester, + onClick = { + focusReturnTarget = audioFocusRequester + onAudioClick() + }, + ) + } + if (onSubtitlesClick != null) { + HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), + label = stringResource(R.string.live_subtitles), + focusRequester = subtitlesFocusRequester, + onClick = { + focusReturnTarget = subtitlesFocusRequester + onSubtitlesClick() + }, + ) + } + // Guide button at far right + if (onOpenVariants != null) { + HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), + label = stringResource(R.string.live_label_choose_source), + onClick = onOpenVariants, + ) + } + if (onGuideClick != null) { + HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), + label = stringResource(R.string.live_btn_guide), + onClick = onGuideClick, + ) + } } - } - } + }, + ) } } } } } +/** + * The bottom button row: time at the start, playback controls centred, actions at the end. + * The controls stay exactly centred while there is room and slide aside when the actions reach + * them. When start, controls and actions cannot share one line at all, the actions take a line + * of their own above the controls (see [hudControlBarPlacement]). + */ +@Composable +private fun HudControlBar( + modifier: Modifier, + start: @Composable () -> Unit, + center: @Composable () -> Unit, + end: @Composable () -> Unit, +) { + Layout( + modifier = modifier, + content = { + Box { start() } + Box { center() } + Box { end() } + }, + ) { measurables, constraints -> + val width = constraints.maxWidth + val loose = constraints.copy(minWidth = 0, minHeight = 0, maxHeight = Constraints.Infinity) + val startPlaceable = measurables[0].measure(loose) + val centerPlaceable = measurables[1].measure(loose) + val endPlaceable = measurables[2].measure(loose) + val placement = hudControlBarPlacement( + width = width, + minHeight = constraints.minHeight, + startWidth = startPlaceable.width, startHeight = startPlaceable.height, + centerWidth = centerPlaceable.width, centerHeight = centerPlaceable.height, + endWidth = endPlaceable.width, endHeight = endPlaceable.height, + gap = 16.dp.roundToPx(), + lineGap = 8.dp.roundToPx(), + ) + layout(width, constraints.constrainHeight(placement.height)) { + startPlaceable.placeRelative(0, placement.startY) + centerPlaceable.placeRelative(placement.centerX, placement.centerY) + endPlaceable.placeRelative(width - endPlaceable.width, placement.endY) + } + } +} + +/** Where [HudControlBar] puts its three groups (x for the centred group, y for each). */ +internal data class HudBarPlacement( + val oneLine: Boolean, + val height: Int, + val startY: Int, + val centerX: Int, + val centerY: Int, + val endY: Int, +) + +/** + * One line when time, controls and actions fit side by side with [gap] between them: controls + * centred, moved only as far as needed to clear both sides. Otherwise the actions go on a line of + * their own at the top and time and controls share the line below, so nothing is drawn over + * anything else. + */ +internal fun hudControlBarPlacement( + width: Int, + minHeight: Int, + startWidth: Int, + startHeight: Int, + centerWidth: Int, + centerHeight: Int, + endWidth: Int, + endHeight: Int, + gap: Int, + lineGap: Int, +): HudBarPlacement { + val centered = (width - centerWidth) / 2 + if (startWidth + gap + centerWidth + gap + endWidth <= width) { + val height = maxOf(minHeight, startHeight, centerHeight, endHeight) + return HudBarPlacement( + oneLine = true, + height = height, + startY = (height - startHeight) / 2, + centerX = centered.coerceIn(startWidth + gap, width - endWidth - gap - centerWidth), + centerY = (height - centerHeight) / 2, + endY = (height - endHeight) / 2, + ) + } + val lowerTop = endHeight + lineGap + val lowerHeight = maxOf(startHeight, centerHeight) + val minX = startWidth + gap + val maxX = (width - centerWidth).coerceAtLeast(minX) + return HudBarPlacement( + oneLine = false, + height = maxOf(minHeight, lowerTop + lowerHeight), + startY = lowerTop + (lowerHeight - startHeight) / 2, + centerX = centered.coerceIn(minX, maxX), + centerY = lowerTop + (lowerHeight - centerHeight) / 2, + endY = 0, + ) +} + @Composable internal fun HudSeekBar( progress: Float, @@ -812,11 +958,14 @@ private fun formatPlaybackDuration(ms: Long): String { private fun HudActionButton( label: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, + modifier: Modifier = Modifier, ) { var isFocused by remember { mutableStateOf(false) } Box( - modifier = Modifier + modifier = modifier + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .clip(RoundedCornerShape(999.dp)) .onFocusChanged { isFocused = it.isFocused } .background(if (isFocused) Color.White else liveAccent()) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvHlsExtractorFactory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvHlsExtractorFactory.kt new file mode 100644 index 000000000..462408d2e --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvHlsExtractorFactory.kt @@ -0,0 +1,149 @@ +@file:androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + +package com.arflix.tv.ui.screens.tv.live + +import android.net.Uri +import android.text.TextUtils +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.TimestampAdjuster +import androidx.media3.exoplayer.analytics.PlayerId +import androidx.media3.exoplayer.hls.DefaultHlsExtractorFactory +import androidx.media3.exoplayer.hls.HlsExtractorFactory +import androidx.media3.exoplayer.hls.HlsMediaChunkExtractor +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory +import java.io.EOFException +import com.arflix.tv.player.iptvts.TsExtractor as IptvTsExtractor + +/** + * HLS segment extractors for IPTV. MPEG-TS segments go to ARVIO's copy of Media3's TS extractor, + * which keeps every DVB subtitle language of a channel (Media3's keeps one per stream type, the + * lowest PID, so a second subtitle language never appeared). Every other segment type is handled + * by Media3's [DefaultHlsExtractorFactory] exactly as before. + */ +internal class IptvHlsExtractorFactory( + private val payloadReaderFactoryFlags: Int, + private val exposeCea608WhenMissingDeclarations: Boolean, +) : HlsExtractorFactory { + + private val fallback = DefaultHlsExtractorFactory(payloadReaderFactoryFlags, exposeCea608WhenMissingDeclarations) + private var subtitleParserFactory: SubtitleParser.Factory = DefaultSubtitleParserFactory() + private var parseSubtitlesDuringExtraction = false + + override fun createExtractor( + uri: Uri, + format: Format, + muxedCaptionFormats: List?, + timestampAdjuster: TimestampAdjuster, + responseHeaders: Map>, + sniffingExtractorInput: ExtractorInput, + playerId: PlayerId, + ): HlsMediaChunkExtractor { + val tsExtractor = createTsExtractor(format, muxedCaptionFormats, timestampAdjuster) + val isTs = try { + tsExtractor.sniff(sniffingExtractorInput) + } catch (_: EOFException) { + false + } finally { + sniffingExtractorInput.resetPeekPosition() + } + if (isTs) return TsChunkExtractor(tsExtractor) + return fallback.createExtractor( + uri, format, muxedCaptionFormats, timestampAdjuster, responseHeaders, sniffingExtractorInput, playerId, + ) + } + + override fun setSubtitleParserFactory(subtitleParserFactory: SubtitleParser.Factory): HlsExtractorFactory { + this.subtitleParserFactory = subtitleParserFactory + fallback.setSubtitleParserFactory(subtitleParserFactory) + return this + } + + override fun experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction: Boolean): HlsExtractorFactory { + this.parseSubtitlesDuringExtraction = parseSubtitlesDuringExtraction + fallback.experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction) + return this + } + + override fun experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies: Int, + ): HlsExtractorFactory { + fallback.experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies) + return this + } + + override fun getOutputTextFormat(sourceFormat: Format): Format = fallback.getOutputTextFormat(sourceFormat) + + /** Same configuration as Media3 1.9.0's `DefaultHlsExtractorFactory.createTsExtractor`. */ + private fun createTsExtractor( + format: Format, + declaredCaptionFormats: List?, + timestampAdjuster: TimestampAdjuster, + ): IptvTsExtractor { + var flags = DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM or payloadReaderFactoryFlags + val captionFormats = when { + declaredCaptionFormats != null -> { + // The playlist declares closed caption renditions: ignore the stream's descriptors. + flags = flags or DefaultTsPayloadReaderFactory.FLAG_OVERRIDE_CAPTION_DESCRIPTORS + declaredCaptionFormats + } + exposeCea608WhenMissingDeclarations -> + listOf(Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build()) + else -> emptyList() + } + val codecs = format.codecs + if (!TextUtils.isEmpty(codecs)) { + // Streams declared in the TS but absent from the codecs attribute are ignored. + if (!MimeTypes.containsCodecsCorrespondingToMimeType(codecs, MimeTypes.AUDIO_AAC)) { + flags = flags or DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM + } + if (!MimeTypes.containsCodecsCorrespondingToMimeType(codecs, MimeTypes.VIDEO_H264)) { + flags = flags or DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM + } + } + var extractorFlags = 0 + val parserFactory = if (parseSubtitlesDuringExtraction) { + subtitleParserFactory + } else { + extractorFlags = extractorFlags or IptvTsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA + SubtitleParser.Factory.UNSUPPORTED + } + return IptvTsExtractor( + IptvTsExtractor.MODE_HLS, + extractorFlags, + parserFactory, + timestampAdjuster, + DefaultTsPayloadReaderFactory(flags, captionFormats), + IptvTsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES, + ) + } +} + +/** + * Media3's `BundledHlsMediaChunkExtractor` recognises its own TS extractor by class to reuse it + * across segments (and throws when asked to recreate an unknown one), so ARVIO's copy gets this + * equivalent wrapper: reused across segments, never recreated. + */ +private class TsChunkExtractor(private val extractor: IptvTsExtractor) : HlsMediaChunkExtractor { + private val positionHolder = PositionHolder() + + override fun init(extractorOutput: ExtractorOutput) = extractor.init(extractorOutput) + + override fun read(extractorInput: ExtractorInput): Boolean = + extractor.read(extractorInput, positionHolder) == Extractor.RESULT_CONTINUE + + override fun isPackedAudioExtractor(): Boolean = false + + override fun isReusable(): Boolean = true + + override fun recreate(): HlsMediaChunkExtractor = + throw IllegalStateException("A reusable TS extractor is never recreated") + + override fun onTruncatedSegmentParsed() = extractor.seek(0, 0) +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvKeyframeExtractors.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvKeyframeExtractors.kt index b993eeefa..e96d0b879 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvKeyframeExtractors.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvKeyframeExtractors.kt @@ -1,6 +1,5 @@ package com.arflix.tv.ui.screens.tv.live -import androidx.media3.exoplayer.hls.DefaultHlsExtractorFactory import androidx.media3.exoplayer.hls.HlsExtractorFactory import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ExtractorsFactory @@ -13,11 +12,23 @@ import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory * start on any I-frame, which is why the same channels play there. */ -/** HLS extractors for IPTV that also start video on a non-IDR I-frame. */ +/** + * IPTV HLS is prepared from its first segment, not from the playlist alone. "Chunkless" + * preparation builds the track list from the playlist's CODECS attribute, which names only video + * and audio, so subtitles muxed into the segments (DVB subtitles on re-broadcast channels) never + * appear. Media3 already falls back to reading a segment when a variant lists two audio codecs, + * which is why some channels of one provider had subtitles and others did not. + */ +internal const val IPTV_HLS_CHUNKLESS_PREPARATION = false + +/** + * HLS extractors for IPTV that also start video on a non-IDR I-frame, and that keep every DVB + * subtitle language of a channel ([IptvHlsExtractorFactory]). + */ internal fun iptvHlsExtractorFactory(): HlsExtractorFactory = - DefaultHlsExtractorFactory( + IptvHlsExtractorFactory( DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES, - /* exposeCea608WhenMissingDeclarations= */ true + exposeCea608WhenMissingDeclarations = true, ) /** Progressive (raw MPEG-TS) extractors for IPTV that also start video on a non-IDR I-frame. */ diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitles.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitles.kt new file mode 100644 index 000000000..443c06a2b --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitles.kt @@ -0,0 +1,213 @@ +package com.arflix.tv.ui.screens.tv.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import com.arflix.tv.util.normalizeLanguageCode +import java.util.Locale + +/** + * Subtitle and audio choices on live channels. Channels often carry their subtitles inside the + * stream (DVB bitmap subtitles in MPEG-TS, one track per language) and more than one audio + * language. Nothing on the Live TV screen used to choose between them, so subtitles only showed + * when the device's system captions happened to be on in the same language, and the audio was + * whatever the stream listed first. + */ + +/** "off" (subtitles only), or a track's normalized language (or its position when it has none). */ +internal const val LIVE_SUBTITLES_OFF = "off" + +internal data class LiveTrackOption( + val key: String, + val label: String, + val selected: Boolean, +) + +/** What a channel starts with, from the profile's "Default subtitle" setting. */ +internal sealed interface LiveSubtitleDefault { + /** "Off", or nothing set: no subtitles until the viewer picks one. */ + data object Off : LiveSubtitleDefault + + /** "Forced": only tracks the stream marks as forced (foreign-language parts). */ + data object ForcedOnly : LiveSubtitleDefault + + /** A language: its track when the channel carries one. */ + data class Language(val code: String) : LiveSubtitleDefault +} + +/** + * The profile's "Default subtitle" setting for live channels. Blank and "Off" both mean off, as in + * the VOD player; a language the app cannot place is treated as off too, rather than letting the + * player pick whatever the device's caption settings would. + */ +internal fun liveSubtitleDefault(preference: String?): LiveSubtitleDefault { + val value = preference?.trim().orEmpty() + val lower = value.lowercase(Locale.ROOT) + return when { + value.isBlank() || lower in DISABLED_PREFERENCES -> LiveSubtitleDefault.Off + lower == "forced" -> LiveSubtitleDefault.ForcedOnly + else -> languageFromPreference(value)?.let { LiveSubtitleDefault.Language(it) } ?: LiveSubtitleDefault.Off + } +} + +/** The profile's "Default audio language" setting, or null for "Auto (Original)". */ +internal fun audioPreferenceLanguage(preference: String?): String? { + val value = preference?.trim().orEmpty() + if (value.isBlank() || value.lowercase(Locale.ROOT).startsWith("auto") || + value.lowercase(Locale.ROOT).startsWith("original") + ) return null + return languageFromPreference(value) +} + +/** The subtitle choices for what is playing: "Off" first, then one per language or track. */ +internal fun liveSubtitleOptions(tracks: Tracks, offLabel: String, displayLocale: Locale = Locale.getDefault()) = + liveTrackOptions(tracks, C.TRACK_TYPE_TEXT, offLabel, displayLocale) + +/** The audio choices for what is playing, one per language or track. */ +internal fun liveAudioOptions(tracks: Tracks, displayLocale: Locale = Locale.getDefault()) = + liveTrackOptions(tracks, C.TRACK_TYPE_AUDIO, offLabel = null, displayLocale) + +/** Applies a choice from [liveSubtitleOptions]; false when that track is not in this stream. */ +internal fun applyLiveSubtitleChoice(player: Player, key: String): Boolean = + applyLiveTrackChoice(player, C.TRACK_TYPE_TEXT, key) + +/** Applies a choice from [liveAudioOptions]; false when that track is not in this stream. */ +internal fun applyLiveAudioChoice(player: Player, key: String): Boolean = + applyLiveTrackChoice(player, C.TRACK_TYPE_AUDIO, key) + +/** Back to the profile's default subtitle choice, dropping any choice made on the previous channel. */ +internal fun resetLiveSubtitleChoice(player: Player, default: LiveSubtitleDefault) { + player.trackSelectionParameters = player.trackSelectionParameters.withLiveSubtitleDefault(default) +} + +/** + * Text-track parameters for [default]. Every case sets the text preferences explicitly: the + * player starts from the device's caption settings (their language and caption roles), which + * would otherwise turn subtitles on with the profile set to Off, or pick a language it did not ask for. + */ +internal fun TrackSelectionParameters.withLiveSubtitleDefault(default: LiveSubtitleDefault): TrackSelectionParameters = + buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setPreferredTextRoleFlags(0) + .setSelectUndeterminedTextLanguage(false) + .apply { + when (default) { + LiveSubtitleDefault.Off -> setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + .setPreferredTextLanguages() + .setIgnoredTextSelectionFlags(0) + // Without a preferred language only forced and default-flagged tracks are picked; + // ignoring the default flag leaves the forced ones. + LiveSubtitleDefault.ForcedOnly -> setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .setPreferredTextLanguages() + .setIgnoredTextSelectionFlags(C.SELECTION_FLAG_DEFAULT) + is LiveSubtitleDefault.Language -> setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .setPreferredTextLanguages(default.code) + .setIgnoredTextSelectionFlags(0) + } + } + .build() + +/** Back to automatic audio selection: the preferred language, if the profile has one. */ +internal fun resetLiveAudioChoice(player: Player, preferredLanguage: String?) { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .apply { if (preferredLanguage != null) setPreferredAudioLanguages(preferredLanguage) } + .build() +} + +private fun liveTrackOptions(tracks: Tracks, type: Int, offLabel: String?, displayLocale: Locale): List { + val list = typedTracks(tracks, type) + if (list.isEmpty()) return emptyList() + val options = mutableListOf() + if (offLabel != null) { + options += LiveTrackOption(LIVE_SUBTITLES_OFF, offLabel, selected = list.none { it.selected }) + } + list.distinctBy { it.key }.forEach { track -> + options += LiveTrackOption(track.key, track.label(displayLocale), selected = list.any { it.key == track.key && it.selected }) + } + return options +} + +private fun applyLiveTrackChoice(player: Player, type: Int, key: String): Boolean { + val builder = player.trackSelectionParameters.buildUpon().clearOverridesOfType(type) + if (key == LIVE_SUBTITLES_OFF && type == C.TRACK_TYPE_TEXT) { + player.trackSelectionParameters = builder.setTrackTypeDisabled(type, true).build() + return true + } + val track = typedTracks(player.currentTracks, type).firstOrNull { it.key == key } ?: return false + player.trackSelectionParameters = builder + .setTrackTypeDisabled(type, false) + .addOverride(TrackSelectionOverride(track.group.mediaTrackGroup, track.trackIndex)) + .build() + return true +} + +private class LiveTrack( + val group: Tracks.Group, + val trackIndex: Int, + val key: String, + val language: String?, + val name: String?, + val selected: Boolean, + val position: Int, +) { + fun label(displayLocale: Locale): String { + val languageName = language + ?.let { Locale.forLanguageTag(it).getDisplayLanguage(displayLocale) } + ?.takeIf { it.isNotBlank() && !it.equals(language, ignoreCase = true) } + ?.replaceFirstChar { it.titlecase(displayLocale) } + return listOfNotNull(languageName ?: language, name?.takeIf { it.isNotBlank() && it != languageName }) + .joinToString(" · ") + .ifBlank { "#$position" } + } +} + +private fun typedTracks(tracks: Tracks, type: Int): List = buildList { + var position = 0 + tracks.groups.filter { it.type == type }.forEach { group -> + for (i in 0 until group.length) { + position++ + if (!group.isTrackSupported(i)) continue + val format = group.getTrackFormat(i) + val language = format.language?.takeIf { it.isNotBlank() && it != C.LANGUAGE_UNDETERMINED } + ?.let(::normalizeLanguage) + add(LiveTrack(group, i, language ?: "track$position", language, format.label, group.isTrackSelected(i), position)) + } + } +} + +/** + * A settings value ("Hebrew", "Portuguese (Brazil)", "pt-BR", "heb") as a player language code, + * using the app's shared mapping ([normalizeLanguageCode], as the VOD player does) and the + * platform's language names for the rest. Null when it is not a language. + */ +private fun languageFromPreference(value: String): String? { + val mapped = normalizeLanguageCode(value) + if (LANGUAGE_TAG.matches(mapped)) return normalizeLanguage(mapped) + val byName = Locale.getAvailableLocales().firstOrNull { + it.getDisplayLanguage(Locale.ENGLISH).equals(value, ignoreCase = true) + } ?: return null + return normalizeLanguage(byName.language) +} + +/** + * Track language codes in the form [liveSubtitleOptions] keys them by: ISO 639-2 and legacy codes + * to two letters ("heb", "iw" -> "he"), regional tags kept ("pt-BR" -> "pt-br"). + */ +private fun normalizeLanguage(code: String): String { + val lower = code.lowercase(Locale.ROOT).replace('_', '-') + val primary = lower.substringBefore('-') + val region = lower.substringAfter('-', missingDelimiterValue = "") + val twoLetter = when { + primary == "iw" || primary == "heb" -> "he" + primary.length == 3 -> Locale.getISOLanguages().firstOrNull { Locale(it).isO3Language == primary } ?: primary + else -> primary + } + return if (region.isEmpty()) twoLetter else "$twoLetter-$region" +} + +private val LANGUAGE_TAG = Regex("[a-z]{2,3}(-[a-z0-9]{2,8})*") + +private val DISABLED_PREFERENCES = setOf("off", "none", "no subtitles", "disabled") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackPicker.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackPicker.kt new file mode 100644 index 000000000..ff1a66451 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackPicker.kt @@ -0,0 +1,187 @@ +package com.arflix.tv.ui.screens.tv.live + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.flow.first +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text + +/** The full-screen subtitle or audio list, in the same panel style as the source picker. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun LiveTrackPickerOverlay( + title: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + channelName: String, + options: List, + onDismiss: () -> Unit, + onPick: (String) -> Unit, +) { + if (options.isEmpty()) return + val selectedIndex = options.indexOfFirst { it.selected }.coerceAtLeast(0) + val firstFocus = remember { FocusRequester() } + // A lazy list only attaches the rows it places, so the selected row is scrolled into view + // first and focused once it has been laid out. Requesting focus on a row further down than the + // first screen used to fail silently and left focus behind the overlay. + val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + LaunchedEffect(Unit) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.any { it.index == selectedIndex } } + .first { it } + withFrameNanos { } + runCatching { firstFocus.requestFocus() } + } + BackHandler { onDismiss() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Back) { + onDismiss() + true + } else { + false + } + }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(420.dp) + .clip(RoundedCornerShape(16.dp)) + .background(LiveColors.PanelRaised) + .border(1.dp, LiveColors.DividerStrong, RoundedCornerShape(16.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = liveAccent(), + modifier = Modifier.size(22.dp), + ) + Column { + Text(title, style = LiveType.ChannelName.copy(color = LiveColors.Fg, fontSize = 18.sp)) + Text( + channelName, + style = LiveType.SectionTag.copy(color = LiveColors.FgDim), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(options, key = { _, option -> option.key }) { index, option -> + TrackRow( + option = option, + modifier = if (index == selectedIndex) Modifier.focusRequester(firstFocus) else Modifier, + onPick = { + onPick(option.key) + onDismiss() + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TrackRow( + option: LiveTrackOption, + onPick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (focused) LiveColors.Panel else LiveColors.PanelDeep) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) LiveColors.FocusRing else LiveColors.Divider, + shape = RoundedCornerShape(10.dp), + ) + .onFocusChanged { focused = it.hasFocus } + .focusable() + .clickable(onClick = onPick) + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && (event.key == Key.DirectionCenter || event.key == Key.Enter)) { + onPick() + true + } else { + false + } + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(if (option.selected) liveAccent() else LiveColors.FgMute, CircleShape), + ) + Text( + text = option.label, + style = LiveType.CellTitle.copy(color = LiveColors.Fg, fontSize = 14.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSources.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSources.kt index b38821286..bd5e08547 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSources.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSources.kt @@ -16,11 +16,13 @@ object LiveTvGuideSources { const val LARGE_LIST_CHANNEL_COUNT: Int = 10_000 /** - * Explicit guides and standard Xtream logins both provide an XMLTV endpoint. + * Explicit guides and standard Xtream logins both provide an XMLTV endpoint, and so does + * a playlist that names its own guide in its `#EXTM3U` line ([playlistDeclaredGuides]). * Large-list backfill uses this single feed, not a per-channel API sweep. */ - fun hasXmltvSource(config: IptvConfig): Boolean { + fun hasXmltvSource(config: IptvConfig, playlistDeclaredGuides: Collection = emptyList()): Boolean { if (config.epgUrl.isNotBlank()) return true + if (playlistDeclaredGuides.any { it.isNotBlank() }) return true if (config.playlists.isEmpty() && hasXtreamLogin(config.m3uUrl)) return true return config.playlists.any { playlist -> playlist.enabled && @@ -44,8 +46,12 @@ object LiveTvGuideSources { * guide at all, no matter how long it was left running. XMLTV is bounded, * so it is allowed through. */ - fun allowsFullGuideBackfill(config: IptvConfig, channelCount: Int): Boolean { + fun allowsFullGuideBackfill( + config: IptvConfig, + channelCount: Int, + playlistDeclaredGuides: Collection = emptyList(), + ): Boolean { if (channelCount < LARGE_LIST_CHANNEL_COUNT) return true - return hasXmltvSource(config) + return hasXmltvSource(config, playlistDeclaredGuides) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicy.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicy.kt index f0075d40e..2bf29c2f3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicy.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicy.kt @@ -1,5 +1,20 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.media3.common.util.StuckPlayerException + +/** How long a live stream may buffer without its buffer moving before it counts as stalled. */ +internal const val LIVE_STUCK_BUFFERING_TIMEOUT_MS = 12_000 + +/** True when the player gave up because buffering stopped making progress, not on a load error. */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal fun Throwable.isStuckBuffering(): Boolean = + generateSequence(this) { it.cause }.any { cause -> + cause is StuckPlayerException && ( + cause.stuckType == StuckPlayerException.STUCK_BUFFERING_NO_PROGRESS || + cause.stuckType == StuckPlayerException.STUCK_BUFFERING_NOT_LOADING + ) + } + private val terminalPlaybackHttpCodes = setOf(401, 403, 429, 444, 451, 513) internal fun isMissingPlaybackResource(httpCode: Int?): Boolean = httpCode == 404 || httpCode == 410 diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index d96123616..8db064672 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -61,6 +61,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Search import androidx.compose.foundation.layout.size @@ -78,6 +80,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -2257,6 +2260,12 @@ fun LiveTvScreen( var quickZapOpen by remember { mutableStateOf(false) } var variantPickerChannel by remember { mutableStateOf(null) } var sourcesChannel by remember { mutableStateOf(null) } + // Subtitle choices made on a channel this session, by channel id ("off" or a language). + val liveSubtitleChoices = remember { mutableStateMapOf() } + var subtitlePickerOpen by remember { mutableStateOf(false) } + // Audio choices made on a channel this session, by channel id (a language or track key). + val liveAudioChoices = remember { mutableStateMapOf() } + var audioPickerOpen by remember { mutableStateOf(false) } var sourcesLoading by remember { mutableStateOf(false) } var sourcesFailed by remember { mutableStateOf(false) } var sourcesVariants by remember { mutableStateOf>(emptyList()) } @@ -2354,16 +2363,29 @@ fun LiveTvScreen( // Bumped to dismiss the HUD from outside, so Back can close it without // leaving fullscreen. var hudHideSignal by remember { mutableStateOf(0) } + // Bumped when the subtitle or audio list closes, so focus returns to the HUD button row. + var hudRefocusSignal by remember { mutableStateOf(0) } // The HUD shows itself for a few seconds after a zap, but only OK hands it // the focus. While it is not engaged the arrow keys stay with playback. var hudEngaged by remember { mutableStateOf(false) } var lastZapAtMs by remember { mutableLongStateOf(0L) } + // A subtitle or audio list is opened from the engaged controls; closing it goes back to + // them, with focus on the button that opened it, even if the HUD timed out meanwhile. + fun returnToHudControls() { + hudEngaged = true + hudPokeSignal++ + hudRefocusSignal++ + } + // Dismissing the controls takes the focused button out of composition, so // hand the focus back to the playback surface — otherwise the next key - // press lands nowhere and the remote looks dead. + // press lands nowhere and the remote looks dead. An open subtitle or audio list keeps the + // focus: the HUD timing out behind it must not hand the arrows back to channel zapping. LaunchedEffect(isHudVisible) { - if (!isHudVisible && isFullScreen && !fullscreenGuideOpen && !quickZapOpen) { + if (!isHudVisible && isFullScreen && !fullscreenGuideOpen && !quickZapOpen && + !subtitlePickerOpen && !audioPickerOpen + ) { runCatching { fsFocus.requestFocus() } } } @@ -3072,6 +3094,9 @@ fun LiveTvScreen( ExoPlayer.Builder(context) .setMediaSourceFactory(mediaSourceFactory) .setLoadControl(loadControl) + // A live stream whose buffer stops moving while buffering raises an error after + // this long, which the retry below recovers, instead of a spinner for ten minutes. + .setStuckBufferingDetectionTimeoutMs(LIVE_STUCK_BUFFERING_TIMEOUT_MS) .build().apply { playWhenReady = true videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT @@ -3087,6 +3112,46 @@ fun LiveTvScreen( var playbackQuality by remember(exoPlayer) { mutableStateOf(null) } + // Subtitles: start every channel on the profile's default subtitle language; a choice made in + // the subtitle list overrides that for its channel. Without this the player only turned + // subtitles on when the device's system captions matched, so they showed on some TVs only. + var liveTracks by remember(exoPlayer) { mutableStateOf(exoPlayer.currentTracks) } + var subtitleDefault by remember { mutableStateOf(LiveSubtitleDefault.Off) } + var preferredAudioLanguage by remember { mutableStateOf(null) } + val latestPlayingChannelId by rememberUpdatedState(playingChannelId) + LaunchedEffect(exoPlayer) { + subtitleDefault = viewModel.liveSubtitleDefault() + resetLiveSubtitleChoice(exoPlayer, subtitleDefault) + preferredAudioLanguage = viewModel.preferredAudioLanguage() + resetLiveAudioChoice(exoPlayer, preferredAudioLanguage) + } + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: androidx.media3.common.MediaItem?, reason: Int) { + if (latestPlayingChannelId?.let { liveSubtitleChoices[it] } == null) { + resetLiveSubtitleChoice(exoPlayer, subtitleDefault) + } + if (latestPlayingChannelId?.let { liveAudioChoices[it] } == null) { + resetLiveAudioChoice(exoPlayer, preferredAudioLanguage) + } + } + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + liveTracks = tracks + val channelId = latestPlayingChannelId ?: return + liveAudioChoices[channelId]?.let { wanted -> + val current = liveAudioOptions(tracks).firstOrNull { it.selected }?.key + if (current != wanted) applyLiveAudioChoice(exoPlayer, wanted) + } + liveSubtitleChoices[channelId]?.let { wanted -> + val current = liveSubtitleOptions(tracks, "").firstOrNull { it.selected }?.key + if (current != wanted) applyLiveSubtitleChoice(exoPlayer, wanted) + } + } + } + exoPlayer.addListener(listener) + onDispose { exoPlayer.removeListener(listener) } + } + var streamStats by remember(exoPlayer) { mutableStateOf(LiveStreamTechInfo()) } DisposableEffect(exoPlayer) { val listener = LivePlaybackQualityListener(exoPlayer) { playbackQuality = it } @@ -3271,6 +3336,7 @@ fun LiveTvScreen( // and channels without IDR frames need iptvHlsExtractorFactory() to show video. val source = if (isHls) { HlsMediaSource.Factory(sourceDataFactory) + .setAllowChunklessPreparation(IPTV_HLS_CHUNKLESS_PREPARATION) .setExtractorFactory(iptvHlsExtractorFactory()) .setLoadErrorHandlingPolicy(IptvLoadErrorHandlingPolicy()) .createMediaSource(mediaItem) @@ -3499,6 +3565,7 @@ fun LiveTvScreen( ) { var retryJob: Job? = null val liveWindowRecovery = LiveWindowRecovery(android.os.SystemClock::elapsedRealtime) + val stallRecovery = LiveWindowRecovery(android.os.SystemClock::elapsedRealtime) val usageListener = com.arflix.tv.util.UsageAnalytics.playbackListener(live = true) exoPlayer.addListener(usageListener) val listener = object : Player.Listener { @@ -3546,6 +3613,17 @@ fun LiveTvScreen( System.err.println("[IPTV] Recovered expired live window at default live position") return } + if (error.isStuckBuffering() && stallRecovery.claim(isCatchup = playingCatchupProgram != null)) { + // A stalled live stream (buffer full, playback never resuming) gets a fresh + // start at the live edge, at most once a minute and outside the retry budget, + // so a long session can recover from more than three stalls. + retryJob?.cancel() + System.err.println("[IPTV] Live playback stalled; restarting the stream") + prepareStream(prepared, isHls = lastPreparedIsHls, headers = lastPreparedHeaders, resetRetry = false, + drmInfo = playingChannel?.source?.drmInfo, forcePrepare = true, + resolvedMimeType = lastPreparedMimeType) + return + } val preparedIsHls = lastPreparedIsHls val unsupportedContainer = error.errorCode == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED val nextAttempt = playerRetryCount + 1 @@ -4586,6 +4664,10 @@ fun LiveTvScreen( null }, onGuideClick = { openFullscreenGuide() }, + onSubtitlesClick = { subtitlePickerOpen = true } + .takeIf { liveSubtitleOptions(liveTracks, "").size > 1 }, + onAudioClick = { audioPickerOpen = true } + .takeIf { liveAudioOptions(liveTracks).size > 1 }, onOpenVariants = playingChannel?.takeIf { playingCatchupProgram == null }?.let { channel -> { sourcesLoading = true @@ -4665,6 +4747,7 @@ fun LiveTvScreen( if (!visible) hudEngaged = false }, hideSignal = hudHideSignal, + refocusSignal = hudRefocusSignal, // On a remote the controls wait for OK; on a touch device // a tap is the only way to reach them, so they stay part // of what a tap brings up, exactly as before. @@ -4745,10 +4828,13 @@ fun LiveTvScreen( } } - LaunchedEffect(isFullScreen, fullscreenGuideOpen, quickZapOpen, playingCatchupProgram) { - if (isFullScreen && !fullscreenGuideOpen && !quickZapOpen) { + LaunchedEffect(isFullScreen, fullscreenGuideOpen, quickZapOpen, playingCatchupProgram, subtitlePickerOpen, audioPickerOpen) { + if (isFullScreen && !fullscreenGuideOpen && !quickZapOpen && !subtitlePickerOpen && !audioPickerOpen) { delay(50L) - runCatching { fsFocus.requestFocus() } + // With the controls engaged the HUD places focus in its button row (it sits + // inside this box, so the keys above still reach it); taking focus back here + // left the row on screen but unreachable with left/right. + if (!hudEngaged || isTouchDevice) runCatching { fsFocus.requestFocus() } } } } @@ -4818,6 +4904,40 @@ fun LiveTvScreen( ) } + if (!searchOpen && isFullScreen && subtitlePickerOpen) { + LiveTrackPickerOverlay( + title = stringResource(R.string.live_subtitles), + icon = Icons.Filled.Subtitles, + channelName = playingChannel?.name.orEmpty(), + options = liveSubtitleOptions(liveTracks, stringResource(R.string.live_subtitles_off)), + onDismiss = { + subtitlePickerOpen = false + returnToHudControls() + }, + onPick = { key -> + playingChannelId?.let { liveSubtitleChoices[it] = key } + applyLiveSubtitleChoice(exoPlayer, key) + }, + ) + } + + if (!searchOpen && isFullScreen && audioPickerOpen) { + LiveTrackPickerOverlay( + title = stringResource(R.string.live_audio), + icon = Icons.Filled.Audiotrack, + channelName = playingChannel?.name.orEmpty(), + options = liveAudioOptions(liveTracks), + onDismiss = { + audioPickerOpen = false + returnToHudControls() + }, + onPick = { key -> + playingChannelId?.let { liveAudioChoices[it] = key } + applyLiveAudioChoice(exoPlayer, key) + }, + ) + } + if (!searchOpen) { FullscreenSourcesOverlay( visible = isFullScreen && sourcesChannel != null, diff --git a/app/src/main/kotlin/com/arflix/tv/util/LanguageCodes.kt b/app/src/main/kotlin/com/arflix/tv/util/LanguageCodes.kt new file mode 100644 index 000000000..43c767d5d --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/util/LanguageCodes.kt @@ -0,0 +1,102 @@ +package com.arflix.tv.util + +/** + * A language name, ISO 639-1/639-2 code or settings label ("Hebrew", "heb", "Portuguese (Brazil)", + * "pt-BR") as the short code the players match tracks by ("he", "pt-br"). Shared by the VOD player + * and Live TV so both read the profile's language settings the same way. Unknown input comes back + * lower-cased. + */ +internal fun normalizeLanguageCode(lang: String): String { + val lowerLang = lang.lowercase().trim() + return when { + lowerLang in listOf("ms", "msa", "may", "malay", "bahasa melayu") || + lowerLang.startsWith("ms-") || lowerLang.startsWith("ms_") -> "ms" + // Full names + lowerLang == "english" || lowerLang.startsWith("english") -> "en" + lowerLang == "spanish" || lowerLang.startsWith("spanish") || lowerLang == "espanol" -> "es" + lowerLang == "french" || lowerLang.startsWith("french") || lowerLang == "francais" -> "fr" + lowerLang == "german" || lowerLang.startsWith("german") || lowerLang == "deutsch" -> "de" + lowerLang == "italian" || lowerLang.startsWith("italian") -> "it" + lowerLang == "portuguese" -> "pt" + lowerLang == "portuguese (brazil)" || + lowerLang == "portuguese-brazil" || + lowerLang == "brazilian portuguese" || + lowerLang == "brazil portuguese" || + lowerLang == "pt-br" || + lowerLang == "ptbr" -> "pt-br" + lowerLang.startsWith("portuguese") -> "pt" + lowerLang == "dutch" || lowerLang.startsWith("dutch") -> "nl" + lowerLang == "russian" || lowerLang.startsWith("russian") -> "ru" + lowerLang == "chinese" || lowerLang.startsWith("chinese") -> "zh" + lowerLang == "japanese" || lowerLang.startsWith("japanese") || lowerLang == "jp" || lowerLang == "jap" -> "ja" + lowerLang == "korean" || lowerLang.startsWith("korean") -> "ko" + lowerLang == "arabic" || lowerLang.startsWith("arabic") -> "ar" + lowerLang == "hindi" || lowerLang.startsWith("hindi") -> "hi" + lowerLang == "turkish" || lowerLang.startsWith("turkish") -> "tr" + lowerLang == "polish" || lowerLang.startsWith("polish") -> "pl" + lowerLang == "swedish" || lowerLang.startsWith("swedish") -> "sv" + lowerLang == "norwegian" || lowerLang.startsWith("norwegian") -> "no" + lowerLang == "danish" || lowerLang.startsWith("danish") -> "da" + lowerLang == "finnish" || lowerLang.startsWith("finnish") -> "fi" + lowerLang == "greek" || lowerLang.startsWith("greek") -> "el" + lowerLang == "czech" || lowerLang.startsWith("czech") -> "cs" + lowerLang == "hungarian" || lowerLang.startsWith("hungarian") -> "hu" + lowerLang == "romanian" || lowerLang.startsWith("romanian") -> "ro" + lowerLang == "thai" || lowerLang.startsWith("thai") -> "th" + lowerLang == "vietnamese" || lowerLang.startsWith("vietnamese") -> "vi" + lowerLang == "indonesian" || lowerLang.startsWith("indonesian") -> "id" + lowerLang == "hebrew" || lowerLang.startsWith("hebrew") -> "he" + lowerLang == "persian" || lowerLang.startsWith("persian") || lowerLang == "farsi" -> "fa" + lowerLang == "ukrainian" || lowerLang.startsWith("ukrainian") -> "uk" + lowerLang == "bengali" || lowerLang.startsWith("bengali") -> "bn" + lowerLang == "bulgarian" || lowerLang.startsWith("bulgarian") -> "bg" + lowerLang == "croatian" || lowerLang.startsWith("croatian") -> "hr" + lowerLang == "serbian" || lowerLang.startsWith("serbian") -> "sr" + lowerLang == "slovak" || lowerLang.startsWith("slovak") -> "sk" + lowerLang == "slovenian" || lowerLang.startsWith("slovenian") -> "sl" + lowerLang == "lithuanian" || lowerLang.startsWith("lithuanian") -> "lt" + lowerLang == "estonian" || lowerLang.startsWith("estonian") -> "et" + // ISO 639-1 codes (2 letter) + lowerLang.length == 2 -> lowerLang + // ISO 639-2 codes (3 letter) + lowerLang == "eng" -> "en" + lowerLang == "spa" -> "es" + lowerLang == "fra" || lowerLang == "fre" -> "fr" + lowerLang == "deu" || lowerLang == "ger" -> "de" + lowerLang == "ita" -> "it" + lowerLang == "por" -> "pt" + lowerLang == "pob" || lowerLang == "pobr" -> "pt-br" + lowerLang == "nld" || lowerLang == "dut" -> "nl" + lowerLang == "rus" -> "ru" + lowerLang == "zho" || lowerLang == "chi" -> "zh" + lowerLang == "jpn" -> "ja" + lowerLang == "kor" -> "ko" + lowerLang == "ara" -> "ar" + lowerLang == "hin" -> "hi" + lowerLang == "tur" -> "tr" + lowerLang == "pol" -> "pl" + lowerLang == "swe" -> "sv" + lowerLang == "nor" -> "no" + lowerLang == "dan" -> "da" + lowerLang == "fin" -> "fi" + lowerLang == "ell" || lowerLang == "gre" -> "el" + lowerLang == "ces" || lowerLang == "cze" -> "cs" + lowerLang == "hun" -> "hu" + lowerLang == "ron" || lowerLang == "rum" -> "ro" + lowerLang == "tha" -> "th" + lowerLang == "vie" -> "vi" + lowerLang == "ind" -> "id" + lowerLang == "heb" || lowerLang == "iw" -> "he" + lowerLang == "fas" || lowerLang == "per" -> "fa" + lowerLang == "ukr" -> "uk" + lowerLang == "ben" -> "bn" + lowerLang == "bul" -> "bg" + lowerLang == "hrv" -> "hr" + lowerLang == "srp" -> "sr" + lowerLang == "slk" || lowerLang == "slo" -> "sk" + lowerLang == "slv" -> "sl" + lowerLang == "lit" -> "lt" + lowerLang == "est" -> "et" + else -> lowerLang + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5543c4c1f..3d5e9ba24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1890,5 +1890,8 @@ Connect SIMKL SIMKL connected Open SIMKL + Subtitles + Off + Audio diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvDeclaredGuideRestoreTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvDeclaredGuideRestoreTest.kt new file mode 100644 index 000000000..322632848 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvDeclaredGuideRestoreTest.kt @@ -0,0 +1,89 @@ +package com.arflix.tv.data.repository + +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * A playlist that names its guide only in its `#EXTM3U url-tvg=...` line got no guide at all + * once its channels were kept in the SQLite store: startup skips the playlist parse, and the + * saved guide address was only read back from a cache that already held guide data — which it + * never could, because the guide needs that address to load in the first place. + */ +class IptvDeclaredGuideRestoreTest { + + @get:Rule val temp = TemporaryFolder() + + private val guideUrl = "http://guide.example/epg.xml.gz" + private val config = IptvConfig( + playlists = listOf( + IptvPlaylistEntry(id = "list_1", name = "List 1", m3uUrl = "http://playlist.example/list.m3u8"), + ), + ) + + private fun newRepository(filesDir: File): IptvRepository { + val context = mockk(relaxed = true) + every { context.filesDir } returns filesDir + val profileManager = mockk(relaxed = true) + every { profileManager.getProfileIdSync() } returns "profile" + return IptvRepository(context, mockk(relaxed = true), profileManager, mockk(relaxed = true)) + } + + private fun writeGuideCache(filesDir: File, json: String) { + File(filesDir, "iptv_cache").apply { mkdirs() } + .resolve("profile_iptv_cache.json") + .writeText(json) + } + + @Test fun `saved guide address comes back although no guide was stored yet`() { + val filesDir = temp.newFolder() + writeGuideCache( + filesDir, + """{"channels":[],"configSignature":"","discoveredEpgUrls":["$guideUrl"],"loadedAtEpochMs":1,"nowNext":{},"sourceSignature":""}""", + ) + val repository = newRepository(filesDir) + + repository.restoreDiscoveredEpgUrlsFromCache(config) + + assertEquals(listOf(guideUrl), repository.playlistDeclaredEpgUrls()) + } + + @Test fun `a stored guide is skipped, not needed, to read the address`() { + val filesDir = temp.newFolder() + writeGuideCache( + filesDir, + """{"nowNext":{"ch":{"now":{"title":"A","startUtcMillis":1,"endUtcMillis":2}}},"discoveredEpgUrls":["$guideUrl"],"configSignature":"","sourceSignature":""}""", + ) + val repository = newRepository(filesDir) + + repository.restoreDiscoveredEpgUrlsFromCache(config) + + assertEquals(listOf(guideUrl), repository.playlistDeclaredEpgUrls()) + } + + @Test fun `an address saved for another playlist source is not used`() { + val filesDir = temp.newFolder() + writeGuideCache( + filesDir, + """{"discoveredEpgUrls":["$guideUrl"],"configSignature":"","sourceSignature":"another-source"}""", + ) + val repository = newRepository(filesDir) + + repository.restoreDiscoveredEpgUrlsFromCache(config) + + assertTrue(repository.playlistDeclaredEpgUrls().isEmpty()) + } + + @Test fun `no saved cache leaves the address unknown without failing`() { + val repository = newRepository(temp.newFolder()) + + repository.restoreDiscoveredEpgUrlsFromCache(config) + + assertTrue(repository.playlistDeclaredEpgUrls().isEmpty()) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvInputSanitizingTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvInputSanitizingTest.kt new file mode 100644 index 000000000..a68538805 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvInputSanitizingTest.kt @@ -0,0 +1,48 @@ +package com.arflix.tv.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * A playlist address pasted on the TV was stored as `.../list.m3u` followed by a NUL character. + * `trim()` keeps NUL, so every request went to `list.m3u%00` and the server answered HTTP 400. + */ +class IptvInputSanitizingTest { + + @Test fun `a trailing NUL from a paste is removed`() { + assertEquals( + "https://example.test/list.m3u", + stripInvisibleInputChars("https://example.test/list.m3u\u0000"), + ) + } + + @Test fun `zero-width and direction marks are removed`() { + assertEquals( + "https://example.test/list.m3u", + stripInvisibleInputChars("https://example.test/​list.m3u‎"), + ) + } + + @Test fun `ordinary text is unchanged`() { + val url = "http://example.test:8080/get.php?username=u&password=p&type=m3u_plus" + assertEquals(url, stripInvisibleInputChars(url)) + } + + @Test fun `guide fields keep the separators between several addresses`() { + assertEquals( + "https://a.test/epg.xml\nhttps://b.test/epg.xml\thost", + stripInvisibleInputChars("https://a.test/epg.xml\u0000\nhttps://b.test/epg.xml\thost", keepSeparators = true), + ) + assertEquals("ab", stripInvisibleInputChars("a\n\tb")) + } + + @Test fun `a pasted Stalker portal and MAC lose the invisible characters`() { + val portal = StalkerPortalSupport.normalizeStalkerPortalEntry( + StalkerPortalEntry("s1", "Portal", "http://portal.test/c/\u0000", "00:1a:79:00:00:01​"), + index = 0, + ) + + assertEquals("http://portal.test/c", portal?.portalUrl) + assertEquals("00:1A:79:00:00:01", portal?.macAddress) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryOptimizationTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryOptimizationTest.kt index 5ec429d19..cecf5bf53 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryOptimizationTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryOptimizationTest.kt @@ -273,11 +273,14 @@ class IptvRepositoryOptimizationTest { io.mockk.every { customClient.newCall(any()) } returns call val repository = IptvRepository(context, okHttpClient, profileManager, invalidationBus) - val method = IptvRepository::class.java.getDeclaredMethod("fetchAndParseEpg", String::class.java, List::class.java, Function0::class.java) + val method = IptvRepository::class.java.getDeclaredMethod( + "fetchAndParseEpg", String::class.java, List::class.java, List::class.java, Function0::class.java, + ) method.isAccessible = true try { - method.invoke(repository, "http://example.com/epg.xml", emptyList(), {}) + val noChannels = emptyList() + method.invoke(repository, "http://example.com/epg.xml", noChannels, noChannels, {}) fail("Expected EpgNotModifiedException to be thrown") } catch (e: java.lang.reflect.InvocationTargetException) { val cause = e.cause diff --git a/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt b/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt new file mode 100644 index 000000000..78140ade9 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt @@ -0,0 +1,189 @@ +package com.arflix.tv.player.iptvts + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.Format +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.TimestampAdjuster +import androidx.media3.common.util.Util +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.ConscryptMode + +/** + * A broadcast channel carrying Hebrew and Russian DVB subtitles showed only Hebrew: in HLS mode + * Media3's TS extractor keeps one track per stream type (the lowest PID). ARVIO's copy keeps each. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +@ConscryptMode(ConscryptMode.Mode.OFF) +class IptvTsSubtitleTracksTest { + + @Test fun `every DVB subtitle language of an HLS segment becomes a track`() { + val languages = textTrackLanguages(IptvTsExtractorUnderTest.ARVIO) + + assertEquals(listOf("he", "ru"), languages) + } + + @Test fun `the stock extractor keeps only the lowest PID, which is the bug this copy fixes`() { + val languages = textTrackLanguages(IptvTsExtractorUnderTest.MEDIA3) + + assertEquals(listOf("he"), languages) + } + + @Test fun `a subtitle service keeps its track id in a rendition that carries it on another PID`() { + // HLS keeps the text track ids announced first and discards samples for unknown ids, so a + // quality switch to a rendition with different subtitle PIDs must yield the same ids. + val first = textTrackIds(subtitles = listOf("heb" to 0x401, "rus" to 0x402)) + val second = textTrackIds(subtitles = listOf("rus" to 0x511, "heb" to 0x512)) + + assertEquals(setOf("he", "ru"), first.keys) + assertEquals(first, second) + } + + @Test fun `a repeated program table does not add tracks`() { + val extractorTracks = textTrackIds(subtitles = listOf("heb" to 0x401, "rus" to 0x402), pmtRepeats = 3) + + assertEquals(setOf("he", "ru"), extractorTracks.keys) + } + + /** Language -> track id of the text tracks an HLS-mode ARVIO extractor announces. */ + private fun textTrackIds(subtitles: List>, pmtRepeats: Int = 1): Map { + val extractor = TsExtractor( + TsExtractor.MODE_HLS, TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA, SubtitleParser.Factory.UNSUPPORTED, + TimestampAdjuster(0), DefaultTsPayloadReaderFactory(0, emptyList()), TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES, + ) + val output = RecordingOutput() + extractor.init(output) + var stream = patPacket() + repeat(pmtRepeats) { stream += pmtPacket(subtitles) } + val input = DefaultExtractorInput(ByteArrayReader(stream), 0, stream.size.toLong()) + val position = PositionHolder() + while (extractor.read(input, position) == Extractor.RESULT_CONTINUE) Unit + val text = output.tracks.filter { it.type == C.TRACK_TYPE_TEXT } + assertEquals("duplicate text tracks", text.size, text.map { it.id }.toSet().size) + return text.associate { it.format?.language to it.id } + } + + private enum class IptvTsExtractorUnderTest { ARVIO, MEDIA3 } + + private fun textTrackLanguages(which: IptvTsExtractorUnderTest): List { + val payloadReaders = DefaultTsPayloadReaderFactory(0, emptyList()) + val extractor: Extractor = when (which) { + IptvTsExtractorUnderTest.ARVIO -> TsExtractor( + TsExtractor.MODE_HLS, TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA, SubtitleParser.Factory.UNSUPPORTED, + TimestampAdjuster(0), payloadReaders, TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES, + ) + IptvTsExtractorUnderTest.MEDIA3 -> androidx.media3.extractor.ts.TsExtractor( + androidx.media3.extractor.ts.TsExtractor.MODE_HLS, + androidx.media3.extractor.ts.TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED, + TimestampAdjuster(0), payloadReaders, + androidx.media3.extractor.ts.TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES, + ) + } + val output = RecordingOutput() + extractor.init(output) + val stream = patPacket() + pmtPacket() + val input = DefaultExtractorInput(ByteArrayReader(stream), 0, stream.size.toLong()) + val position = PositionHolder() + while (extractor.read(input, position) == Extractor.RESULT_CONTINUE) Unit + return output.tracks.filter { it.type == C.TRACK_TYPE_TEXT }.map { it.format?.language } + } + + // ── A two-packet MPEG-TS: PAT -> PMT with video and two DVB subtitle streams ───────────── + + private fun patPacket(): ByteArray = tsPacket( + pid = 0, + section = section( + tableId = 0x00, + idField = 1, // transport_stream_id + body = byteArrayOf(0x00, 0x01, (0xE0 or (PMT_PID shr 8)).toByte(), (PMT_PID and 0xFF).toByte()), + ), + ) + + private fun pmtPacket(subtitles: List> = listOf("heb" to 0x401, "rus" to 0x402)): ByteArray { + val video = esEntry(streamType = 0x1B, pid = 0x100, esInfo = byteArrayOf()) + val body = byteArrayOf( + (0xE0 or (0x100 shr 8)).toByte(), 0x00, // PCR PID + 0xF0.toByte(), 0x00, // program_info_length = 0 + ) + video + subtitles.fold(byteArrayOf()) { acc, (language, pid) -> + acc + esEntry(0x06, pid, dvbSubtitleDescriptor(language)) + } + return tsPacket(pid = PMT_PID, section = section(tableId = 0x02, idField = 1, body = body)) + } + + private fun esEntry(streamType: Int, pid: Int, esInfo: ByteArray): ByteArray = byteArrayOf( + streamType.toByte(), + (0xE0 or (pid shr 8)).toByte(), (pid and 0xFF).toByte(), + (0xF0 or (esInfo.size shr 8)).toByte(), (esInfo.size and 0xFF).toByte(), + ) + esInfo + + private fun dvbSubtitleDescriptor(language: String): ByteArray = + byteArrayOf(0x59, 8) + language.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x10, 0x00, 0x01, 0x00, 0x01) + + /** A PSI section with section_syntax_indicator set and a valid MPEG-2 CRC. */ + private fun section(tableId: Int, idField: Int, body: ByteArray): ByteArray { + val length = 5 + body.size + 4 // id(2) version(1) section numbers(2) + body + CRC + val withoutCrc = byteArrayOf( + tableId.toByte(), + (0xB0 or (length shr 8)).toByte(), (length and 0xFF).toByte(), + (idField shr 8).toByte(), (idField and 0xFF).toByte(), + 0xC1.toByte(), 0x00, 0x00, + ) + body + val crc = Util.crc32(withoutCrc, 0, withoutCrc.size, -1) + return withoutCrc + byteArrayOf((crc ushr 24).toByte(), (crc ushr 16).toByte(), (crc ushr 8).toByte(), crc.toByte()) + } + + private fun tsPacket(pid: Int, section: ByteArray): ByteArray { + val header = byteArrayOf(0x47, (0x40 or (pid shr 8)).toByte(), (pid and 0xFF).toByte(), 0x10, 0x00) + val packet = ByteArray(188) { 0xFF.toByte() } + (header + section).copyInto(packet) + return packet + } + + private class ByteArrayReader(private val data: ByteArray) : DataReader { + private var position = 0 + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= data.size) return C.RESULT_END_OF_INPUT + val count = minOf(length, data.size - position) + data.copyInto(buffer, offset, position, position + count) + position += count + return count + } + } + + private class RecordingOutput : ExtractorOutput { + val tracks = mutableListOf() + override fun track(id: Int, type: Int): TrackOutput = + tracks.firstOrNull { it.id == id } ?: RecordingTrack(id, type).also { tracks += it } + override fun endTracks() = Unit + override fun seekMap(seekMap: SeekMap) = Unit + } + + private class RecordingTrack(val id: Int, val type: Int) : TrackOutput { + var format: Format? = null + override fun format(format: Format) { this.format = format } + override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = + input.read(ByteArray(length), 0, length) + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) = data.skipBytes(length) + override fun sampleMetadata( + timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?, + ) = Unit + } + + private companion object { + const val PMT_PID = 0x1000 + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefreshTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefreshTest.kt index 53f4ec8c3..3042ed00a 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefreshTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/home/FavoriteTvGuideRefreshTest.kt @@ -30,4 +30,24 @@ class FavoriteTvGuideRefreshTest { assertFalse(favoriteTvFocusRefreshDue(lastRefreshMs = 5, nowMs = 59_000, intervalMs = 60_000)) assertTrue(favoriteTvFocusRefreshDue(lastRefreshMs = 5, nowMs = 60_005, intervalMs = 60_000)) } + + private val mine = com.arflix.tv.data.repository.IptvPlaylistEntry(id = "list_1", name = "Mine", m3uUrl = "https://a.test/list.m3u") + private val other = com.arflix.tv.data.repository.IptvPlaylistEntry(id = "list_2", name = "Other", m3uUrl = "https://b.test/list.m3u") + + @Test fun switchingWhichPlaylistIsOnChangesTheFavoriteSourceKey() { + val before = com.arflix.tv.data.repository.IptvConfig(playlists = listOf(mine, other.copy(enabled = false))) + val after = com.arflix.tv.data.repository.IptvConfig(playlists = listOf(mine.copy(enabled = false), other)) + + assertNotEquals(iptvFavoriteSourceKey(before), iptvFavoriteSourceKey(after)) + } + + @Test fun unrelatedSettingsLeaveTheFavoriteSourceKeyAlone() { + val config = com.arflix.tv.data.repository.IptvConfig(playlists = listOf(mine, other.copy(enabled = false))) + + assertEquals(iptvFavoriteSourceKey(config), iptvFavoriteSourceKey(config.copy(sortOrder = "name"))) + assertEquals( + iptvFavoriteSourceKey(config), + iptvFavoriteSourceKey(config.copy(playlists = listOf(mine, other.copy(enabled = false, name = "Renamed")))), + ) + } } diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/HudControlBarTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/HudControlBarTest.kt new file mode 100644 index 000000000..5de61a6bf --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/HudControlBarTest.kt @@ -0,0 +1,54 @@ +package com.arflix.tv.ui.screens.tv.live + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HudControlBarTest { + private fun place(width: Int, start: Int, center: Int, end: Int, endHeight: Int = 40) = hudControlBarPlacement( + width = width, minHeight = 56, + startWidth = start, startHeight = 20, + centerWidth = center, centerHeight = 48, + endWidth = end, endHeight = endHeight, + gap = 20, lineGap = 8, + ) + + @Test fun controlsStayExactlyCentredWhenThereIsRoom() { + val placement = place(width = 1000, start = 100, center = 300, end = 200) + assertTrue(placement.oneLine) + assertEquals(350, placement.centerX) + assertEquals(56, placement.height) + } + + @Test fun wideActionsPushTheControlsAsideOnTheSameLine() { + val placement = place(width = 1000, start = 100, center = 300, end = 450) + assertTrue(placement.oneLine) + assertEquals(230, placement.centerX) + assertTrue(placement.centerX + 300 + 20 <= 1000 - 450) + } + + @Test fun aWideStartPushesTheControlsTheOtherWay() { + assertEquals(470, place(width = 1000, start = 450, center = 300, end = 100).centerX) + } + + @Test fun actionsThatCannotShareTheLineMoveAboveTheControls() { + // Narrow screen with Audio, Subtitles, Choose source, Guide and catch-up LIVE all shown. + val placement = place(width = 700, start = 60, center = 320, end = 520) + assertFalse(placement.oneLine) + assertEquals(0, placement.endY) + // The lower line starts below the actions, so their rectangles cannot intersect. + assertTrue(placement.centerY >= 40 + 8) + assertTrue(placement.startY >= 40 + 8) + assertEquals(40 + 8 + 48, placement.height) + // Time and controls still do not overlap each other. + assertTrue(placement.centerX >= 60 + 20) + assertTrue(placement.centerX + 320 <= 700) + } + + @Test fun wrappedActionLinesPushTheControlsFurtherDown() { + val placement = place(width = 400, start = 60, center = 300, end = 400, endHeight = 88) + assertFalse(placement.oneLine) + assertTrue(placement.centerY >= 88 + 8) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlesTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlesTest.kt new file mode 100644 index 000000000..67fbd9c8f --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlesTest.kt @@ -0,0 +1,134 @@ +package com.arflix.tv.ui.screens.tv.live + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.ConscryptMode +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +@ConscryptMode(ConscryptMode.Mode.OFF) +class LiveSubtitlesTest { + private fun dvb(language: String?) = Format.Builder() + .setSampleMimeType(MimeTypes.APPLICATION_DVBSUBS) + .setLanguage(language) + .build() + + private fun tracks(vararg subtitles: Pair) = Tracks(subtitles.map { (language, selected) -> + Tracks.Group(TrackGroup(dvb(language)), false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(selected)) + }) + + @Test fun preferenceNamesBecomePlayerLanguages() { + assertEquals(LiveSubtitleDefault.Language("he"), liveSubtitleDefault("Hebrew")) + assertEquals(LiveSubtitleDefault.Language("en"), liveSubtitleDefault("English")) + assertEquals(LiveSubtitleDefault.Language("he"), liveSubtitleDefault("heb")) + assertEquals(LiveSubtitleDefault.Language("ru"), liveSubtitleDefault("ru")) + } + + @Test fun regionalLanguagesUseTheAppMapping() { + assertEquals(LiveSubtitleDefault.Language("pt-br"), liveSubtitleDefault("Portuguese (Brazil)")) + assertEquals(LiveSubtitleDefault.Language("pt-br"), liveSubtitleDefault("pt-BR")) + assertEquals(LiveSubtitleDefault.Language("pt"), liveSubtitleDefault("Portuguese")) + assertEquals(LiveSubtitleDefault.Language("es-419"), liveSubtitleDefault("es-419")) + // Names the shared mapping does not list come from the platform's language names. + assertEquals(LiveSubtitleDefault.Language("ta"), liveSubtitleDefault("Tamil")) + assertEquals("pt-br", audioPreferenceLanguage("Portuguese (Brazil)")) + } + + @Test fun offForcedAndUnsetAreDistinguished() { + assertEquals(LiveSubtitleDefault.Off, liveSubtitleDefault("Off")) + assertEquals(LiveSubtitleDefault.Off, liveSubtitleDefault("")) + assertEquals(LiveSubtitleDefault.Off, liveSubtitleDefault(null)) + assertEquals(LiveSubtitleDefault.Off, liveSubtitleDefault("Not a language")) + assertEquals(LiveSubtitleDefault.ForcedOnly, liveSubtitleDefault("Forced")) + } + + /** Parameters as a device with system captions on (English, caption role) would start with. */ + private fun captioningParameters() = TrackSelectionParameters.Builder() + .setPreferredTextLanguages("en") + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + .setSelectUndeterminedTextLanguage(true) + .build() + + @Test fun offTurnsSubtitlesOffWhateverTheDeviceCaptionSettings() { + val params = captioningParameters().withLiveSubtitleDefault(LiveSubtitleDefault.Off) + + assertTrue(C.TRACK_TYPE_TEXT in params.disabledTrackTypes) + assertTrue(params.preferredTextLanguages.isEmpty()) + assertEquals(0, params.preferredTextRoleFlags) + } + + @Test fun forcedPicksOnlyForcedTracks() { + val params = captioningParameters().withLiveSubtitleDefault(LiveSubtitleDefault.ForcedOnly) + + assertFalse(C.TRACK_TYPE_TEXT in params.disabledTrackTypes) + assertTrue(params.preferredTextLanguages.isEmpty()) + assertEquals(0, params.preferredTextRoleFlags) + assertFalse(params.selectUndeterminedTextLanguage) + assertEquals(C.SELECTION_FLAG_DEFAULT, params.ignoredTextSelectionFlags) + } + + @Test fun aLanguageReplacesTheDeviceCaptionLanguage() { + val params = captioningParameters().withLiveSubtitleDefault(LiveSubtitleDefault.Language("pt-br")) + + assertFalse(C.TRACK_TYPE_TEXT in params.disabledTrackTypes) + assertEquals(listOf("pt-br"), params.preferredTextLanguages) + assertEquals(0, params.preferredTextRoleFlags) + assertEquals(0, params.ignoredTextSelectionFlags) + } + + @Test fun optionsListOffThenEachLanguageWithTheSelectionMarked() { + val options = liveSubtitleOptions(tracks("heb" to true, "rus" to false), offLabel = "Off", displayLocale = Locale.ENGLISH) + + assertEquals(listOf(LIVE_SUBTITLES_OFF, "he", "ru"), options.map { it.key }) + assertEquals(listOf("Off", "Hebrew", "Russian"), options.map { it.label }) + assertEquals(listOf(false, true, false), options.map { it.selected }) + } + + @Test fun offIsSelectedWhenNoSubtitleTrackIsOn() { + val options = liveSubtitleOptions(tracks("heb" to false, "rus" to false), offLabel = "Off", displayLocale = Locale.ENGLISH) + assertTrue(options.first().selected) + } + + @Test fun aStreamWithoutSubtitlesOffersNothing() { + assertTrue(liveSubtitleOptions(Tracks.EMPTY, offLabel = "Off").isEmpty()) + } + + @Test fun audioPreferenceAutoMeansNoPreference() { + assertNull(audioPreferenceLanguage("Auto (Original)")) + assertNull(audioPreferenceLanguage(null)) + assertEquals("he", audioPreferenceLanguage("Hebrew")) + assertEquals("ru", audioPreferenceLanguage("Russian")) + } + + @Test fun audioOptionsListEachLanguageWithoutAnOffEntry() { + val audio = Tracks(listOf("eng" to true, "rus" to false).map { (language, selected) -> + Tracks.Group( + TrackGroup(Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setLanguage(language).build()), + false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(selected) + ) + }) + + val options = liveAudioOptions(audio, displayLocale = Locale.ENGLISH) + + assertEquals(listOf("en", "ru"), options.map { it.key }) + assertEquals(listOf("English", "Russian"), options.map { it.label }) + assertEquals(listOf(true, false), options.map { it.selected }) + assertTrue(liveSubtitleOptions(audio, offLabel = "Off").isEmpty()) + } + + @Test fun tracksWithoutALanguageStillGetAnEntry() { + val options = liveSubtitleOptions(tracks(null to false, null to false), offLabel = "Off", displayLocale = Locale.ENGLISH) + assertEquals(3, options.size) + assertEquals(2, options.drop(1).map { it.key }.distinct().size) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSourcesTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSourcesTest.kt index 2b33f6756..5b2377db3 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSourcesTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvGuideSourcesTest.kt @@ -130,6 +130,19 @@ class LiveTvGuideSourcesTest { ).isFalse() } + @Test + fun aGuideNamedInThePlaylistHeaderCountsAsXmltv() { + // `#EXTM3U url-tvg="..."` with nothing entered in settings. + val config = IptvConfig(playlists = listOf(playlist())) + val declared = listOf("https://example.test/epg.xml.gz") + + assertThat(LiveTvGuideSources.hasXmltvSource(config, declared)).isTrue() + assertThat(LiveTvGuideSources.hasXmltvSource(config, listOf(" "))).isFalse() + assertThat( + LiveTvGuideSources.allowsFullGuideBackfill(config, channelCount = 84_704, playlistDeclaredGuides = declared) + ).isTrue() + } + @Test fun theLargeListThresholdIsInclusiveOfSmallerLists() { val config = IptvConfig(m3uUrl = "https://example.test/playlist.m3u8") diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicyTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicyTest.kt index 0b95844a4..750143249 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicyTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvRetryPolicyTest.kt @@ -53,4 +53,19 @@ class LiveTvRetryPolicyTest { assertFalse(isMissingPlaybackResource(status)) } } + + @Test fun stalledBufferingIsRecognisedThroughThePlayerError() { + val stuck = androidx.media3.common.util.StuckPlayerException( + androidx.media3.common.util.StuckPlayerException.STUCK_BUFFERING_NO_PROGRESS, LIVE_STUCK_BUFFERING_TIMEOUT_MS, + ) + assertTrue(RuntimeException("playback failed", stuck).isStuckBuffering()) + } + + @Test fun loadErrorsAndOtherStuckKindsAreNotStalls() { + assertFalse(RuntimeException("HTTP 500").isStuckBuffering()) + val stuckPlaying = androidx.media3.common.util.StuckPlayerException( + androidx.media3.common.util.StuckPlayerException.STUCK_PLAYING_NOT_ENDING, 60_000, + ) + assertFalse(stuckPlaying.isStuckBuffering()) + } }