From 3d13ff012e7105ecd57f928d0e7b87997dcf94b5 Mon Sep 17 00:00:00 2001 From: silentbil Date: Tue, 13 Oct 2026 13:09:59 +0300 Subject: [PATCH 1/7] feat(live-tv): pick subtitles and audio on live channels Live channels often carry their subtitles inside the stream (DVB bitmap subtitles in MPEG-TS) and more than one audio language. The Live TV player never chose a subtitle track, so subtitles only showed when the device's system captions were on in the same language - on some TVs and not on others - and the audio was whatever the stream listed first. Live TV now starts each channel on the profile's default subtitle language and default audio language (when not "Off" / "Auto"), and the full-screen controls gain Subtitles and Audio buttons that list the channel's tracks. A choice is remembered per channel for the session. --- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 16 ++ .../tv/ui/screens/tv/live/FullscreenHud.kt | 14 ++ .../ui/screens/tv/live/LiveSubtitlePicker.kt | 174 ++++++++++++++++++ .../tv/ui/screens/tv/live/LiveSubtitles.kt | 157 ++++++++++++++++ .../tv/ui/screens/tv/live/LiveTvScreen.kt | 91 ++++++++- app/src/main/res/values/strings.xml | 3 + .../ui/screens/tv/live/LiveSubtitlesTest.kt | 84 +++++++++ 7 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitles.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlesTest.kt 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..19f43a6c2 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,22 @@ 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 as a language for live channels, or null when off. */ + suspend fun preferredSubtitleLanguage(): String? = runCatching { + val prefs = context.settingsDataStore.data.first() + com.arflix.tv.ui.screens.tv.live.subtitlePreferenceLanguage( + prefs[profileManager.profileStringKey("default_subtitle")] + ) + }.getOrNull() suspend fun cachedSportsMetadata() = sportsRepository.cachedMetadata() internal var cachedSportsSchedule: com.arflix.tv.ui.screens.tv.live.SportsScheduleSnapshot? = null private var sportsBroadcasterIndexLoad: Pair>? = null 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..ab6bb1279 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 @@ -109,6 +109,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, @@ -604,6 +606,18 @@ fun FullscreenHud( ) } + if (onAudioClick != null) { + HudActionButton( + label = stringResource(R.string.live_audio), + onClick = onAudioClick, + ) + } + if (onSubtitlesClick != null) { + HudActionButton( + label = stringResource(R.string.live_subtitles), + onClick = onSubtitlesClick, + ) + } // Guide button at far right if (onOpenVariants != null) { HudActionButton( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt new file mode 100644 index 000000000..d6ddf3d9a --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt @@ -0,0 +1,174 @@ +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +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.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() } + LaunchedEffect(Unit) { 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( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(options, key = { _, option -> option.key }) { index, option -> + SubtitleRow( + option = option, + modifier = if (index == selectedIndex) Modifier.focusRequester(firstFocus) else Modifier, + onPick = { + onPick(option.key) + onDismiss() + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SubtitleRow( + option: LiveSubtitleOption, + 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/LiveSubtitles.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitles.kt new file mode 100644 index 000000000..a3af8cb9a --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitles.kt @@ -0,0 +1,157 @@ +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.Tracks +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 LiveSubtitleOption( + val key: String, + val label: String, + val selected: Boolean, +) + +/** + * The profile's "Default subtitle" setting as a language for the player ("Hebrew" -> "he"), + * or null when subtitles are switched off there. + */ +internal fun subtitlePreferenceLanguage(preference: String?): String? { + val value = preference?.trim().orEmpty() + if (value.isBlank() || value.lowercase(Locale.ROOT) in DISABLED_PREFERENCES) return null + return languageFromPreference(value) +} + +/** 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 automatic subtitle selection: the preferred language, if the profile has one. */ +internal fun resetLiveSubtitleChoice(player: Player, preferredLanguage: String?) { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .apply { if (preferredLanguage != null) setPreferredTextLanguages(preferredLanguage) } + .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 += LiveSubtitleOption(LIVE_SUBTITLES_OFF, offLabel, selected = list.none { it.selected }) + } + list.distinctBy { it.key }.forEach { track -> + options += LiveSubtitleOption(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)) + } + } +} + +private fun languageFromPreference(value: String): String? { + if (value.length in 2..3 && value.all(Char::isLetter)) return normalizeLanguage(value) + val byName = Locale.getAvailableLocales().firstOrNull { + it.getDisplayLanguage(Locale.ENGLISH).equals(value, ignoreCase = true) + } ?: return null + return normalizeLanguage(byName.language) +} + +/** ISO 639-2 and legacy codes to the two-letter form the player uses ("heb", "iw" -> "he"). */ +private fun normalizeLanguage(code: String): String { + val lower = code.lowercase(Locale.ROOT) + if (lower == "iw" || lower == "heb") return "he" + if (lower.length == 3) { + Locale.getISOLanguages().firstOrNull { Locale(it).isO3Language == lower }?.let { return it } + } + return lower +} + +private val DISABLED_PREFERENCES = setOf("off", "none", "no subtitles", "disabled") 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 cecd16190..af155973d 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()) } @@ -3087,6 +3096,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 preferredSubtitleLanguage by remember { mutableStateOf(null) } + var preferredAudioLanguage by remember { mutableStateOf(null) } + val latestPlayingChannelId by rememberUpdatedState(playingChannelId) + LaunchedEffect(exoPlayer) { + preferredSubtitleLanguage = viewModel.preferredSubtitleLanguage() + resetLiveSubtitleChoice(exoPlayer, preferredSubtitleLanguage) + 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, preferredSubtitleLanguage) + } + 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 } @@ -4583,6 +4632,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 @@ -4742,8 +4795,8 @@ 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() } } @@ -4815,6 +4868,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 + hudPokeSignal++ + }, + 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 + hudPokeSignal++ + }, + onPick = { key -> + playingChannelId?.let { liveAudioChoices[it] = key } + applyLiveAudioChoice(exoPlayer, key) + }, + ) + } + if (!searchOpen) { FullscreenSourcesOverlay( visible = isFullScreen && sourcesChannel != null, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75c430921..4f21b91e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1888,5 +1888,8 @@ Connect SIMKL SIMKL connected Open SIMKL + Subtitles + Off + Audio 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..2c5a0dfa5 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlesTest.kt @@ -0,0 +1,84 @@ +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.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("he", subtitlePreferenceLanguage("Hebrew")) + assertEquals("en", subtitlePreferenceLanguage("English")) + assertEquals("he", subtitlePreferenceLanguage("heb")) + assertEquals("ru", subtitlePreferenceLanguage("ru")) + assertNull(subtitlePreferenceLanguage("Off")) + assertNull(subtitlePreferenceLanguage("")) + assertNull(subtitlePreferenceLanguage(null)) + } + + @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) + } +} From 5c6932c8e4c900f4c38192f2869e440cf3b2fb04 Mon Sep 17 00:00:00 2001 From: silentbil Date: Tue, 13 Oct 2026 21:58:47 +0300 Subject: [PATCH 2/7] fix(live-tv): find muxed subtitles, recover stalled streams, tidy HUD focus IPTV HLS channels often carry DVB subtitles inside the TS segments while the master playlist's CODECS lists only video and audio. With chunkless preparation the player built its track list from CODECS alone, so those subtitles never appeared. Media3 already reads a segment when a variant lists two audio codecs, which is why channels of the same provider showed subtitles or not depending on their audio count. IPTV HLS is now always prepared from its first segment (Live TV, the Home preview and TvScreen); measured startup is unchanged. - Home preview: channel previews use the profile's default subtitle language; trailers keep the no-subtitle default. - A live stream whose buffer stops moving now raises an error after 12s (Media3's default is 10 minutes) and is restarted at the live edge, at most once a minute and outside the existing retry budget. - Full-screen controls: the action buttons no longer overlap the centred playback controls; the controls slide aside only when needed. - Closing the Subtitles or Audio list returns focus to the button that opened it, so left/right move between the controls again. - The track list is shared by Subtitles and Audio, so it is renamed to LiveTrackPicker / LiveTrackOption. --- .../arflix/tv/ui/screens/home/HomeScreen.kt | 10 +- .../tv/ui/screens/home/HomeViewModel.kt | 8 + .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 4 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 1 + .../tv/ui/screens/tv/live/FullscreenHud.kt | 266 +++++++++++------- .../screens/tv/live/IptvKeyframeExtractors.kt | 9 + .../tv/ui/screens/tv/live/LiveSubtitles.kt | 10 +- ...veSubtitlePicker.kt => LiveTrackPicker.kt} | 9 +- .../ui/screens/tv/live/LiveTvRetryPolicy.kt | 15 + .../tv/ui/screens/tv/live/LiveTvScreen.kt | 36 ++- .../ui/screens/tv/live/HudControlBarTest.kt | 26 ++ .../screens/tv/live/LiveTvRetryPolicyTest.kt | 15 + 12 files changed, 297 insertions(+), 112 deletions(-) rename app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/{LiveSubtitlePicker.kt => LiveTrackPicker.kt} (97%) create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/HudControlBarTest.kt 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..04166b008 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 @@ -549,7 +549,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 +1161,16 @@ fun HomeScreen( val iptvTarget = displayHeroItem ?.takeIf { isHeroIptv } ?.let { viewModel.resolveIptvPreviewTarget(it.id) } + // Channel previews show the profile's default subtitle language, as the Live TV + // screen does; trailers and other previews keep the player's no-subtitle default. + val subtitleLanguage = if (isHeroIptv) viewModel.previewSubtitleLanguage() else null player?.stop() player?.clearMediaItems() + player?.let { p -> + p.trackSelectionParameters = p.trackSelectionParameters.buildUpon() + .setPreferredTextLanguages(*listOfNotNull(subtitleLanguage).toTypedArray()) + .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..a4dfefd9a 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,14 @@ class HomeViewModel @Inject constructor( .getOrNull() } + /** The profile's "Default subtitle" setting as a language for channel previews, or null when off. */ + internal suspend fun previewSubtitleLanguage(): String? = runCatching { + val prefs = context.settingsDataStore.data.first() + com.arflix.tv.ui.screens.tv.live.subtitlePreferenceLanguage( + prefs[profileManager.profileStringKey("default_subtitle")] + ) + }.onFailure { if (it is CancellationException) throw it }.getOrNull() + /** * 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 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 19f43a6c2..e84040747 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 @@ -142,6 +142,7 @@ class TvViewModel @Inject constructor( prefs[profileManager.profileStringKey("default_subtitle")] ) }.getOrNull() + suspend fun cachedSportsMetadata() = sportsRepository.cachedMetadata() internal var cachedSportsSchedule: com.arflix.tv.ui.screens.tv.live.SportsScheduleSnapshot? = null private var sportsBroadcasterIndexLoad: Pair>? = null 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 ab6bb1279..b546578ea 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 @@ -63,6 +63,7 @@ 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.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -123,6 +124,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) } @@ -166,6 +169,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. @@ -194,17 +201,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 } } } @@ -519,15 +535,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), - ) { + start = { + // Left Side: Time Text below Seek Bar Text( text = positionText, style = LiveType.TimeMono.copy( @@ -536,109 +549,168 @@ 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() }, - ) - - // Next channel (>|) - HudIconButton( - icon = Icons.Filled.SkipNext, - contentDescription = stringResource(R.string.live_cd_next_channel), - onClick = { onNextCatchupClick?.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() }, - ) + }, + 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() }, + ) - // LIVE button - if (isCatchupMode) { - HudActionButton( - label = stringResource(R.string.live_badge_live), - onClick = { onGoLiveClick?.invoke() }, + // Rewind (<<) + HudIconButton( + icon = Icons.Filled.FastRewind, + contentDescription = stringResource(R.string.live_cd_rewind), + onClick = { onRewindClick?.invoke() }, ) - } - if (onAudioClick != null) { - HudActionButton( - label = stringResource(R.string.live_audio), - onClick = onAudioClick, + // 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() + }, ) - } - if (onSubtitlesClick != null) { - HudActionButton( - label = stringResource(R.string.live_subtitles), - onClick = onSubtitlesClick, + + // 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 + Row( + 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() }, ) + + // LIVE button + if (isCatchupMode) { + HudActionButton( + label = stringResource(R.string.live_badge_live), + onClick = { onGoLiveClick?.invoke() }, + ) + } + + if (onAudioClick != null) { + HudActionButton( + label = stringResource(R.string.live_audio), + focusRequester = audioFocusRequester, + onClick = { + focusReturnTarget = audioFocusRequester + onAudioClick() + }, + ) + } + if (onSubtitlesClick != null) { + HudActionButton( + label = stringResource(R.string.live_subtitles), + focusRequester = subtitlesFocusRequester, + onClick = { + focusReturnTarget = subtitlesFocusRequester + onSubtitlesClick() + }, + ) + } + // Guide button at far right + if (onOpenVariants != null) { + HudActionButton( + label = stringResource(R.string.live_label_choose_source), + onClick = onOpenVariants, + ) + } + if (onGuideClick != null) { + HudActionButton( + 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; when the actions grow wide enough to + * reach them (subtitle and audio buttons, the catch-up LIVE button) they slide aside instead + * of being drawn on top of each other. + */ +@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 loose = constraints.copy(minWidth = 0, minHeight = 0) + val startPlaceable = measurables[0].measure(loose) + val centerPlaceable = measurables[1].measure(loose) + val endPlaceable = measurables[2].measure(loose) + val width = constraints.maxWidth + val height = constraints.maxHeight + val gap = 16.dp.roundToPx() + val centerX = hudCenterGroupX( + width = width, + startWidth = startPlaceable.width, + centerWidth = centerPlaceable.width, + endWidth = endPlaceable.width, + gap = gap, + ) + layout(width, height) { + startPlaceable.placeRelative(0, (height - startPlaceable.height) / 2) + centerPlaceable.placeRelative(centerX, (height - centerPlaceable.height) / 2) + endPlaceable.placeRelative(width - endPlaceable.width, (height - endPlaceable.height) / 2) + } + } +} + +/** Where the centred group starts: the exact centre, moved only as far as needed to clear both sides. */ +internal fun hudCenterGroupX(width: Int, startWidth: Int, centerWidth: Int, endWidth: Int, gap: Int): Int { + val centered = (width - centerWidth) / 2 + val maxX = width - endWidth - gap - centerWidth + val minX = startWidth + gap + return if (maxX < minX) minX else centered.coerceIn(minX, maxX) +} + @Composable internal fun HudSeekBar( progress: Float, @@ -826,11 +898,13 @@ private fun formatPlaybackDuration(ms: Long): String { private fun HudActionButton( label: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, ) { var isFocused by remember { mutableStateOf(false) } Box( 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/IptvKeyframeExtractors.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvKeyframeExtractors.kt index b993eeefa..7fb35f3de 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 @@ -13,6 +13,15 @@ import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory * start on any I-frame, which is why the same channels play there. */ +/** + * 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. */ internal fun iptvHlsExtractorFactory(): HlsExtractorFactory = DefaultHlsExtractorFactory( 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 index a3af8cb9a..97227eede 100644 --- 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 @@ -17,7 +17,7 @@ import java.util.Locale /** "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 LiveSubtitleOption( +internal data class LiveTrackOption( val key: String, val label: String, val selected: Boolean, @@ -75,15 +75,15 @@ internal fun resetLiveAudioChoice(player: Player, preferredLanguage: String?) { .build() } -private fun liveTrackOptions(tracks: Tracks, type: Int, offLabel: String?, displayLocale: Locale): List { +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() + val options = mutableListOf() if (offLabel != null) { - options += LiveSubtitleOption(LIVE_SUBTITLES_OFF, offLabel, selected = list.none { it.selected }) + options += LiveTrackOption(LIVE_SUBTITLES_OFF, offLabel, selected = list.none { it.selected }) } list.distinctBy { it.key }.forEach { track -> - options += LiveSubtitleOption(track.key, track.label(displayLocale), selected = list.any { it.key == track.key && it.selected }) + options += LiveTrackOption(track.key, track.label(displayLocale), selected = list.any { it.key == track.key && it.selected }) } return options } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackPicker.kt similarity index 97% rename from app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt rename to app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackPicker.kt index d6ddf3d9a..2504cd8e6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSubtitlePicker.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackPicker.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,7 +51,7 @@ internal fun LiveTrackPickerOverlay( title: String, icon: androidx.compose.ui.graphics.vector.ImageVector, channelName: String, - options: List, + options: List, onDismiss: () -> Unit, onPick: (String) -> Unit, ) { @@ -112,7 +111,7 @@ internal fun LiveTrackPickerOverlay( verticalArrangement = Arrangement.spacedBy(6.dp), ) { itemsIndexed(options, key = { _, option -> option.key }) { index, option -> - SubtitleRow( + TrackRow( option = option, modifier = if (index == selectedIndex) Modifier.focusRequester(firstFocus) else Modifier, onPick = { @@ -128,8 +127,8 @@ internal fun LiveTrackPickerOverlay( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SubtitleRow( - option: LiveSubtitleOption, +private fun TrackRow( + option: LiveTrackOption, onPick: () -> Unit, modifier: Modifier = Modifier, ) { 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 af155973d..868025828 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 @@ -2363,11 +2363,21 @@ 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. @@ -3081,6 +3091,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 @@ -3320,6 +3333,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) @@ -3548,6 +3562,7 @@ fun LiveTvScreen( ) { var retryJob: Job? = null val liveWindowRecovery = LiveWindowRecovery(android.os.SystemClock::elapsedRealtime) + val stallRecovery = LiveWindowRecovery(android.os.SystemClock::elapsedRealtime) val listener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { playerIsBuffering = (playbackState == Player.STATE_BUFFERING) @@ -3593,6 +3608,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 @@ -4715,6 +4741,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. @@ -4798,7 +4825,10 @@ fun LiveTvScreen( 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() } } } } @@ -4876,7 +4906,7 @@ fun LiveTvScreen( options = liveSubtitleOptions(liveTracks, stringResource(R.string.live_subtitles_off)), onDismiss = { subtitlePickerOpen = false - hudPokeSignal++ + returnToHudControls() }, onPick = { key -> playingChannelId?.let { liveSubtitleChoices[it] = key } @@ -4893,7 +4923,7 @@ fun LiveTvScreen( options = liveAudioOptions(liveTracks), onDismiss = { audioPickerOpen = false - hudPokeSignal++ + returnToHudControls() }, onPick = { key -> playingChannelId?.let { liveAudioChoices[it] = key } 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..510759105 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/HudControlBarTest.kt @@ -0,0 +1,26 @@ +package com.arflix.tv.ui.screens.tv.live + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HudControlBarTest { + @Test fun controlsStayExactlyCentredWhenThereIsRoom() { + // 1000 wide, 300-wide controls: centred at 350, clear of a 100 start and a 200 end. + assertEquals(350, hudCenterGroupX(width = 1000, startWidth = 100, centerWidth = 300, endWidth = 200, gap = 20)) + } + + @Test fun wideActionsPushTheControlsAwayInsteadOfOverlapping() { + // A 450-wide action group would reach the centred controls; they move to end before it. + val x = hudCenterGroupX(width = 1000, startWidth = 100, centerWidth = 300, endWidth = 450, gap = 20) + assertEquals(230, x) + assertEquals(true, x + 300 + 20 <= 1000 - 450) + } + + @Test fun aWideStartPushesTheControlsTheOtherWay() { + assertEquals(470, hudCenterGroupX(width = 1000, startWidth = 450, centerWidth = 300, endWidth = 100, gap = 20)) + } + + @Test fun whenNothingFitsTheControlsKeepClearOfTheStart() { + assertEquals(120, hudCenterGroupX(width = 1000, startWidth = 100, centerWidth = 600, endWidth = 400, gap = 20)) + } +} 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()) + } } From aa464b5a0ba3549eb33ddc7c7dada94a211eeca3 Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 14 Oct 2026 10:04:57 +0300 Subject: [PATCH 3/7] fix(iptv): load the guide a playlist declares itself, clean pasted addresses A plain M3U whose guide is only named in its `#EXTM3U url-tvg="..."` line showed no guide at all once its channels were kept in the SQLite store: - readCache() returned before restoring the saved guide address when the cache held no channels and no guide yet - always the case before the first guide load, so the guide could never load. - The store startup path (getPagedStartupSnapshotOrNull) never restored it. - Only a typed or Xtream guide counted as an XMLTV source. The saved address is now restored on those paths; when none was saved the playlist header is read once per session; a declared guide counts as an XMLTV source. The visible XMLTV fallback downloads the whole file, and the same file may not be fetched again for two minutes, so for lists up to 10,000 channels it now keeps every channel's programmes instead of only the visible rows (the 304 check still uses the requested channels). Pasted addresses can carry characters trim() keeps, e.g. a trailing NUL that sent every playlist request to `list.m3u%00` (HTTP 400). Playlist, guide and Stalker portal/MAC input now drop control and invisible format characters before use, which also repairs addresses already saved. --- .../tv/data/repository/IptvRepository.kt | 127 ++++++++++++++++-- .../data/repository/StalkerPortalSupport.kt | 6 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 12 +- .../ui/screens/tv/live/LiveTvGuideSources.kt | 14 +- .../IptvDeclaredGuideRestoreTest.kt | 89 ++++++++++++ .../repository/IptvInputSanitizingTest.kt | 48 +++++++ .../IptvRepositoryOptimizationTest.kt | 7 +- .../screens/tv/live/LiveTvGuideSourcesTest.kt | 13 ++ 8 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/IptvDeclaredGuideRestoreTest.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/IptvInputSanitizingTest.kt 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/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index e84040747..5f9d7096d 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 @@ -878,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 @@ -965,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) { @@ -1149,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 } ) { @@ -1160,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 @@ -1171,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/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/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/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") From a1dcf30535f8838583a928acb8d94eaed3ce069f Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 14 Oct 2026 10:05:22 +0300 Subject: [PATCH 4/7] fix(home): rebuild Favorite TV after switching IPTV playlists Switching which playlist is enabled left the Home Favorite TV row showing the old playlist's channels until the app restarted: the home load that follows finds the new channel list still loading, reports the row as unavailable and keeps it, and nothing rebuilt it once the list was ready. Home now watches the enabled playlists and portals. When they change it rebuilds the row as soon as the new list can be read - replaced, or removed when the new sources have no favourites - and drops a stale row that still cannot be rebuilt after about a minute. Renames and other settings do not trigger it. --- .../ui/screens/home/FavoriteTvGuideRefresh.kt | 10 ++++ .../tv/ui/screens/home/HomeViewModel.kt | 51 +++++++++++++++++++ .../home/FavoriteTvGuideRefreshTest.kt | 20 ++++++++ 3 files changed, 81 insertions(+) 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/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index a4dfefd9a..09d00319e 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 @@ -1323,6 +1323,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 @@ -1516,6 +1555,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 @@ -1983,6 +2025,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/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")))), + ) + } } From f8f2f97bd671c7db0fa7285cb9e3b03fc95244a6 Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 14 Oct 2026 10:05:53 +0300 Subject: [PATCH 5/7] fix(live-tv): keep the subtitle/audio list focused when the HUD hides With the subtitle or audio list open longer than the HUD's 5 s timeout, the HUD hiding handed focus back to the playback surface. Down then zapped channels and OK/Back no longer reached the list. The hand-back now waits while either list is open. --- .../com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 868025828..b814ec8e8 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 @@ -2380,9 +2380,12 @@ fun LiveTvScreen( // 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() } } } From f8e3d0667380648151bd5141d8a995b6b20c705a Mon Sep 17 00:00:00 2001 From: silentbil Date: Wed, 14 Oct 2026 15:26:15 +0300 Subject: [PATCH 6/7] fix(live-tv): offer every DVB subtitle language on IPTV HLS channels Broadcast IPTV channels often carry several DVB subtitle streams (for example Hebrew and Russian). In HLS mode Media3's TsExtractor keys tracks by stream type and keeps only the lowest PID of each, so only the first subtitle language ever reached the player - chosen by stream order, not by the user's language. IPTV HLS now uses a vendored copy of the media3 1.9.0 TS extractor (src/main/iptvts-java, same pattern as the vendored Matroska extractor) whose only change gives each DVB subtitle PID its own HLS track id. HLS maps text tracks by id, so all languages appear in the Subtitles list. IptvHlsExtractorFactory routes MPEG-TS segments to it and every other segment type to Media3's DefaultHlsExtractorFactory, configured exactly as before. A second audio language is still collapsed by Media3's HLS audio mapping; out of scope here. --- app/build.gradle.kts | 3 + .../player/iptvts/TsBinarySearchSeeker.java | 149 +++ .../tv/player/iptvts/TsDurationReader.java | 203 ++++ .../arflix/tv/player/iptvts/TsExtractor.java | 947 ++++++++++++++++++ .../arflix/tv/player/iptvts/package-info.java | 13 + .../tv/live/IptvHlsExtractorFactory.kt | 149 +++ .../screens/tv/live/IptvKeyframeExtractors.kt | 10 +- .../player/iptvts/IptvTsSubtitleTracksTest.kt | 154 +++ 8 files changed, 1624 insertions(+), 4 deletions(-) create mode 100644 app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsBinarySearchSeeker.java create mode 100644 app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsDurationReader.java create mode 100644 app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsExtractor.java create mode 100644 app/src/main/iptvts-java/com/arflix/tv/player/iptvts/package-info.java create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/IptvHlsExtractorFactory.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c1794a8f..79caa6783 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -250,6 +250,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/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..cfb1bb310 --- /dev/null +++ b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsExtractor.java @@ -0,0 +1,947 @@ +/* + * 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 streams are this plus the PID, above every stream + * type (which is what other HLS tracks use as their id), so each subtitle PID gets its own track. + */ + private static final int HLS_DVB_SUBTITLE_TRACK_ID_BASE = 0x100; + 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 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 stream 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. + int trackId = + mode != MODE_HLS + ? elementaryPid + : streamType == TS_STREAM_TYPE_DVBSUBS + ? HLS_DVB_SUBTITLE_TRACK_ID_BASE + elementaryPid + : 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. + */ + 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/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 7fb35f3de..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 @@ -22,11 +21,14 @@ import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory */ internal const val IPTV_HLS_CHUNKLESS_PREPARATION = false -/** HLS extractors for IPTV that also start video on a non-IDR I-frame. */ +/** + * 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/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..515e7334a --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt @@ -0,0 +1,154 @@ +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) + } + + 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(): ByteArray { + val video = esEntry(streamType = 0x1B, pid = 0x100, esInfo = byteArrayOf()) + val hebrew = esEntry(0x06, 0x401, dvbSubtitleDescriptor("heb")) + val russian = esEntry(0x06, 0x402, dvbSubtitleDescriptor("rus")) + val body = byteArrayOf( + (0xE0 or (0x100 shr 8)).toByte(), 0x00, // PCR PID + 0xF0.toByte(), 0x00, // program_info_length = 0 + ) + video + hebrew + russian + 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 = RecordingTrack(type).also { tracks += it } + override fun endTracks() = Unit + override fun seekMap(seekMap: SeekMap) = Unit + } + + private class RecordingTrack(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 + } +} From b5e5084706646529ad3aeda02b4941a84a870cae Mon Sep 17 00:00:00 2001 From: silentbil Date: Thu, 15 Oct 2026 08:23:39 +0300 Subject: [PATCH 7/7] fix(live-tv): address review of subtitle/audio tracks and HUD layout - DVB subtitle track ids in HLS no longer come from the PID. The id is derived from the subtitle service itself (language, subtitling type, composition/ancillary page ids from its descriptor; its position among the program's subtitle streams when it has none), so a rendition switch or discontinuity that carries the same service on another PID keeps the same track id instead of being discarded by the HLS sample-stream wrapper. - The profile's Default subtitle is applied fully: Off disables text tracks, Forced selects only forced tracks, a language selects that language. Every case sets the text preferences explicitly, so the device caption settings (language and caption roles) no longer turn subtitles on or pick another language. The Home channel preview uses the same rules; trailers get the player's own defaults back. - Languages resolve through the app's mapping, now shared with the VOD player (util/LanguageCodes.kt, moved unchanged from PlayerViewModel), so "Portuguese (Brazil)" and regional tags such as pt-BR work. - The track picker opens scrolled to the selected row and requests focus once that row is laid out; a selection below the first screen used to leave focus behind the overlay. - When time, playback controls and actions cannot share one line, the actions take a line of their own above the controls (wrapping further if needed) instead of being drawn over them. Tests: rendition switch with different PIDs, repeated program tables, Off/Forced/language/pt-BR defaults against device caption settings, and control-bar placement (unit); a long track list and every HUD button shown in catch-up mode at narrow and TV widths (device). --- .../tv/live/LiveTrackControlsDeviceTest.kt | 103 ++++++++++++++++ .../arflix/tv/player/iptvts/TsExtractor.java | 50 +++++++- .../arflix/tv/ui/screens/home/HomeScreen.kt | 14 ++- .../tv/ui/screens/home/HomeViewModel.kt | 9 +- .../tv/ui/screens/player/PlayerViewModel.kt | 96 +-------------- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 8 +- .../tv/ui/screens/tv/live/FullscreenHud.kt | 115 ++++++++++++++---- .../tv/ui/screens/tv/live/LiveSubtitles.kt | 92 +++++++++++--- .../tv/ui/screens/tv/live/LiveTrackPicker.kt | 16 ++- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 8 +- .../com/arflix/tv/util/LanguageCodes.kt | 102 ++++++++++++++++ .../player/iptvts/IptvTsSubtitleTracksTest.kt | 47 ++++++- .../ui/screens/tv/live/HudControlBarTest.kt | 48 ++++++-- .../ui/screens/tv/live/LiveSubtitlesTest.kt | 64 ++++++++-- 14 files changed, 586 insertions(+), 186 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTrackControlsDeviceTest.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/util/LanguageCodes.kt 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/TsExtractor.java b/app/src/main/iptvts-java/com/arflix/tv/player/iptvts/TsExtractor.java index cfb1bb310..35a32084a 100644 --- 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 @@ -148,10 +148,13 @@ public static ExtractorsFactory newFactory(SubtitleParser.Factory subtitleParser public static final int TS_STREAM_TYPE_DVBSUBS = 0x59; /** - * ARVIO: HLS-mode track ids of DVB subtitle streams are this plus the PID, above every stream - * type (which is what other HLS tracks use as their id), so each subtitle PID gets its own track. + * 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; @@ -788,6 +791,7 @@ public void consume(ParsableByteArray sectionData) { 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); @@ -802,15 +806,16 @@ public void consume(ParsableByteArray sectionData) { } remainingEntriesLength -= esInfoLength + 5; - // ARVIO: in HLS mode every DVB subtitle stream keeps its own track. Upstream keys HLS tracks + // 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. + // 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 - ? HLS_DVB_SUBTITLE_TRACK_ID_BASE + elementaryPid + ? hlsDvbSubtitleTrackId(esInfo, dvbSubtitleOrdinal++) : streamType; if (trackIds.get(trackId)) { continue; @@ -870,6 +875,41 @@ public void consume(ParsableByteArray sectionData) { * @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; 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 04166b008..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 @@ -1161,15 +1162,16 @@ fun HomeScreen( val iptvTarget = displayHeroItem ?.takeIf { isHeroIptv } ?.let { viewModel.resolveIptvPreviewTarget(it.id) } - // Channel previews show the profile's default subtitle language, as the Live TV - // screen does; trailers and other previews keep the player's no-subtitle default. - val subtitleLanguage = if (isHeroIptv) viewModel.previewSubtitleLanguage() else null + // 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 = p.trackSelectionParameters.buildUpon() - .setPreferredTextLanguages(*listOfNotNull(subtitleLanguage).toTypedArray()) - .build() + 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() 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 09d00319e..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,13 +1036,14 @@ class HomeViewModel @Inject constructor( .getOrNull() } - /** The profile's "Default subtitle" setting as a language for channel previews, or null when off. */ - internal suspend fun previewSubtitleLanguage(): String? = runCatching { + /** 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.subtitlePreferenceLanguage( + com.arflix.tv.ui.screens.tv.live.liveSubtitleDefault( prefs[profileManager.profileStringKey("default_subtitle")] ) - }.onFailure { if (it is CancellationException) throw it }.getOrNull() + }.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 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/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index 5f9d7096d..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 @@ -135,13 +135,13 @@ class TvViewModel @Inject constructor( ) }.getOrNull() - /** The profile's "Default subtitle" setting as a language for live channels, or null when off. */ - suspend fun preferredSubtitleLanguage(): String? = runCatching { + /** 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.subtitlePreferenceLanguage( + com.arflix.tv.ui.screens.tv.live.liveSubtitleDefault( prefs[profileManager.profileStringKey("default_subtitle")] ) - }.getOrNull() + }.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 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 b546578ea..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 @@ -64,6 +66,8 @@ 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 @@ -90,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?, @@ -538,7 +542,7 @@ fun FullscreenHud( HudControlBar( modifier = Modifier .fillMaxWidth() - .height(56.dp), + .heightIn(min = 56.dp), start = { // Left Side: Time Text below Seek Bar Text( @@ -598,13 +602,18 @@ fun FullscreenHud( } }, end = { - // Right Side: Replay, LIVE, GUIDE - Row( - horizontalArrangement = Arrangement.spacedBy(if (touchControls) 8.dp else 12.dp), - verticalAlignment = Alignment.CenterVertically, + // 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() }, @@ -613,6 +622,7 @@ fun FullscreenHud( // LIVE button if (isCatchupMode) { HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), label = stringResource(R.string.live_badge_live), onClick = { onGoLiveClick?.invoke() }, ) @@ -620,6 +630,7 @@ fun FullscreenHud( if (onAudioClick != null) { HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), label = stringResource(R.string.live_audio), focusRequester = audioFocusRequester, onClick = { @@ -630,6 +641,7 @@ fun FullscreenHud( } if (onSubtitlesClick != null) { HudActionButton( + modifier = Modifier.align(Alignment.CenterVertically), label = stringResource(R.string.live_subtitles), focusRequester = subtitlesFocusRequester, onClick = { @@ -641,12 +653,14 @@ fun FullscreenHud( // 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, ) @@ -662,9 +676,9 @@ fun FullscreenHud( /** * The bottom button row: time at the start, playback controls centred, actions at the end. - * The controls stay exactly centred while there is room; when the actions grow wide enough to - * reach them (subtitle and audio buttons, the catch-up LIVE button) they slide aside instead - * of being drawn on top of each other. + * 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( @@ -681,34 +695,80 @@ private fun HudControlBar( Box { end() } }, ) { measurables, constraints -> - val loose = constraints.copy(minWidth = 0, minHeight = 0) + 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 width = constraints.maxWidth - val height = constraints.maxHeight - val gap = 16.dp.roundToPx() - val centerX = hudCenterGroupX( + val placement = hudControlBarPlacement( width = width, - startWidth = startPlaceable.width, - centerWidth = centerPlaceable.width, - endWidth = endPlaceable.width, - gap = gap, + 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, height) { - startPlaceable.placeRelative(0, (height - startPlaceable.height) / 2) - centerPlaceable.placeRelative(centerX, (height - centerPlaceable.height) / 2) - endPlaceable.placeRelative(width - endPlaceable.width, (height - endPlaceable.height) / 2) + 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 the centred group starts: the exact centre, moved only as far as needed to clear both sides. */ -internal fun hudCenterGroupX(width: Int, startWidth: Int, centerWidth: Int, endWidth: Int, gap: Int): Int { +/** 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 - val maxX = width - endWidth - gap - centerWidth + 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 - return if (maxX < minX) minX else centered.coerceIn(minX, maxX) + 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 @@ -899,11 +959,12 @@ 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 } 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 index 97227eede..443c06a2b 100644 --- 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 @@ -3,7 +3,9 @@ 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 /** @@ -23,14 +25,31 @@ internal data class LiveTrackOption( 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 as a language for the player ("Hebrew" -> "he"), - * or null when subtitles are switched off there. + * 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 subtitlePreferenceLanguage(preference: String?): String? { +internal fun liveSubtitleDefault(preference: String?): LiveSubtitleDefault { val value = preference?.trim().orEmpty() - if (value.isBlank() || value.lowercase(Locale.ROOT) in DISABLED_PREFERENCES) return null - return languageFromPreference(value) + 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)". */ @@ -58,14 +77,37 @@ internal fun applyLiveSubtitleChoice(player: Player, key: String): Boolean = internal fun applyLiveAudioChoice(player: Player, key: String): Boolean = applyLiveTrackChoice(player, C.TRACK_TYPE_AUDIO, key) -/** Back to automatic subtitle selection: the preferred language, if the profile has one. */ -internal fun resetLiveSubtitleChoice(player: Player, preferredLanguage: String?) { - player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() +/** 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) - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - .apply { if (preferredLanguage != null) setPreferredTextLanguages(preferredLanguage) } + .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?) { @@ -136,22 +178,36 @@ private fun typedTracks(tracks: Tracks, type: Int): List = buildList } } +/** + * 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? { - if (value.length in 2..3 && value.all(Char::isLetter)) return normalizeLanguage(value) + 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) } -/** ISO 639-2 and legacy codes to the two-letter form the player uses ("heb", "iw" -> "he"). */ +/** + * 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) - if (lower == "iw" || lower == "heb") return "he" - if (lower.length == 3) { - Locale.getISOLanguages().firstOrNull { Locale(it).isO3Language == lower }?.let { return it } + 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 lower + 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 index 2504cd8e6..ff1a66451 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -26,6 +27,9 @@ 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 @@ -58,7 +62,16 @@ internal fun LiveTrackPickerOverlay( if (options.isEmpty()) return val selectedIndex = options.indexOfFirst { it.selected }.coerceAtLeast(0) val firstFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } + // 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( @@ -105,6 +118,7 @@ internal fun LiveTrackPickerOverlay( } } LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() .heightIn(max = 320.dp), 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 5bb29effd..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 @@ -3116,12 +3116,12 @@ fun LiveTvScreen( // 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 preferredSubtitleLanguage by remember { mutableStateOf(null) } + var subtitleDefault by remember { mutableStateOf(LiveSubtitleDefault.Off) } var preferredAudioLanguage by remember { mutableStateOf(null) } val latestPlayingChannelId by rememberUpdatedState(playingChannelId) LaunchedEffect(exoPlayer) { - preferredSubtitleLanguage = viewModel.preferredSubtitleLanguage() - resetLiveSubtitleChoice(exoPlayer, preferredSubtitleLanguage) + subtitleDefault = viewModel.liveSubtitleDefault() + resetLiveSubtitleChoice(exoPlayer, subtitleDefault) preferredAudioLanguage = viewModel.preferredAudioLanguage() resetLiveAudioChoice(exoPlayer, preferredAudioLanguage) } @@ -3129,7 +3129,7 @@ fun LiveTvScreen( val listener = object : Player.Listener { override fun onMediaItemTransition(mediaItem: androidx.media3.common.MediaItem?, reason: Int) { if (latestPlayingChannelId?.let { liveSubtitleChoices[it] } == null) { - resetLiveSubtitleChoice(exoPlayer, preferredSubtitleLanguage) + resetLiveSubtitleChoice(exoPlayer, subtitleDefault) } if (latestPlayingChannelId?.let { liveAudioChoices[it] } == null) { resetLiveAudioChoice(exoPlayer, preferredAudioLanguage) 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/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt b/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt index 515e7334a..78140ade9 100644 --- a/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/player/iptvts/IptvTsSubtitleTracksTest.kt @@ -42,6 +42,40 @@ class IptvTsSubtitleTracksTest { 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 { @@ -79,14 +113,14 @@ class IptvTsSubtitleTracksTest { ), ) - private fun pmtPacket(): ByteArray { + private fun pmtPacket(subtitles: List> = listOf("heb" to 0x401, "rus" to 0x402)): ByteArray { val video = esEntry(streamType = 0x1B, pid = 0x100, esInfo = byteArrayOf()) - val hebrew = esEntry(0x06, 0x401, dvbSubtitleDescriptor("heb")) - val russian = esEntry(0x06, 0x402, dvbSubtitleDescriptor("rus")) val body = byteArrayOf( (0xE0 or (0x100 shr 8)).toByte(), 0x00, // PCR PID 0xF0.toByte(), 0x00, // program_info_length = 0 - ) + video + hebrew + russian + ) + 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)) } @@ -132,12 +166,13 @@ class IptvTsSubtitleTracksTest { private class RecordingOutput : ExtractorOutput { val tracks = mutableListOf() - override fun track(id: Int, type: Int): TrackOutput = RecordingTrack(type).also { tracks += it } + 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 type: Int) : TrackOutput { + 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 = 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 index 510759105..5de61a6bf 100644 --- 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 @@ -1,26 +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() { - // 1000 wide, 300-wide controls: centred at 350, clear of a 100 start and a 200 end. - assertEquals(350, hudCenterGroupX(width = 1000, startWidth = 100, centerWidth = 300, endWidth = 200, gap = 20)) + val placement = place(width = 1000, start = 100, center = 300, end = 200) + assertTrue(placement.oneLine) + assertEquals(350, placement.centerX) + assertEquals(56, placement.height) } - @Test fun wideActionsPushTheControlsAwayInsteadOfOverlapping() { - // A 450-wide action group would reach the centred controls; they move to end before it. - val x = hudCenterGroupX(width = 1000, startWidth = 100, centerWidth = 300, endWidth = 450, gap = 20) - assertEquals(230, x) - assertEquals(true, x + 300 + 20 <= 1000 - 450) + @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, hudCenterGroupX(width = 1000, startWidth = 450, centerWidth = 300, endWidth = 100, gap = 20)) + 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 whenNothingFitsTheControlsKeepClearOfTheStart() { - assertEquals(120, hudCenterGroupX(width = 1000, startWidth = 100, centerWidth = 600, endWidth = 400, gap = 20)) + @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 index 2c5a0dfa5..67fbd9c8f 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -27,13 +28,62 @@ class LiveSubtitlesTest { }) @Test fun preferenceNamesBecomePlayerLanguages() { - assertEquals("he", subtitlePreferenceLanguage("Hebrew")) - assertEquals("en", subtitlePreferenceLanguage("English")) - assertEquals("he", subtitlePreferenceLanguage("heb")) - assertEquals("ru", subtitlePreferenceLanguage("ru")) - assertNull(subtitlePreferenceLanguage("Off")) - assertNull(subtitlePreferenceLanguage("")) - assertNull(subtitlePreferenceLanguage(null)) + 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() {