From 25424187f952c8d42982d668d5b6e79874235596 Mon Sep 17 00:00:00 2001 From: Robbie <205481179+tormox@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:36:43 +0100 Subject: [PATCH 1/7] [verified] fix(iptv): make guide selections respect programme state Separate channel-row and programme-cell actions, keep main and fullscreen guides on one clock, play catch-up only when available, and leave unavailable past/future programmes as no-ops. --- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 22 +++-- .../ui/screens/tv/live/EpgProgramActions.kt | 36 ++++++++ .../screens/tv/live/FullscreenGuideOverlay.kt | 16 ++-- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 86 +++++++++++++++---- .../screens/tv/live/EpgProgramActionsTest.kt | 74 ++++++++++++++++ 5 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index ab3ee84c2..9f194ad6a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -732,11 +732,12 @@ private fun ProgramsRow( focusable = isFocusable, isCatchupSupported = isCatchupSupported, onClick = { - if (placementIsPast && isCatchupSupported) { - onClick(placement.program) - } else if (!placementIsPast) { - onClick(null) - } + epgProgramActionTarget( + program = placement.program, + isPast = placementIsPast, + isLive = placementIsNow, + isCatchupSupported = isCatchupSupported, + )?.let(onClick) }, onFocused = onFocused, onMoveLeft = { @@ -859,6 +860,17 @@ private data class ProgramFocusTarget(val startMin: Int, val endMin: Int) { } } +internal fun epgProgramActionTarget( + program: IptvProgram, + isPast: Boolean, + isLive: Boolean, + isCatchupSupported: Boolean, +): IptvProgram? = when { + isPast && isCatchupSupported -> program + isLive -> program + else -> null +} + private fun ProgramPlacement.isCatchupSupported(channel: EnrichedChannel, nowMillis: Long): Boolean { if (program.catchupAvailable == true) return true val days = effectiveCatchupDays(channel) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt new file mode 100644 index 000000000..755e1184c --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt @@ -0,0 +1,36 @@ +package com.arflix.tv.ui.screens.tv.live + +internal enum class EpgTemporalState { + Live, + Past, + Future, +} + +internal enum class EpgInteractionAction { + PlayLiveMini, + PlayLiveFullscreen, + PlayCatchup, + NoOp, +} + +internal fun channelRowInteractionAction( + isSamePlayingChannel: Boolean, +): EpgInteractionAction = if (isSamePlayingChannel) { + EpgInteractionAction.PlayLiveFullscreen +} else { + EpgInteractionAction.PlayLiveMini +} + +internal fun epgProgramInteractionAction( + temporalState: EpgTemporalState, + isSamePlayingChannel: Boolean, + isCatchupSupported: Boolean, +): EpgInteractionAction = when (temporalState) { + EpgTemporalState.Live -> channelRowInteractionAction(isSamePlayingChannel) + EpgTemporalState.Past -> if (isCatchupSupported) { + EpgInteractionAction.PlayCatchup + } else { + EpgInteractionAction.NoOp + } + EpgTemporalState.Future -> EpgInteractionAction.NoOp +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt index 1fb01d332..0370f4895 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/FullscreenGuideOverlay.kt @@ -46,7 +46,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -98,6 +97,7 @@ internal fun FullscreenGuideOverlay( channel: EnrichedChannel?, guide: IptvNowNext?, selectedProgram: IptvProgram?, + clockTickMillis: Long, isTouchDevice: Boolean, onDismiss: () -> Unit, onProgramSelect: (IptvProgram?) -> Unit, @@ -108,13 +108,7 @@ internal fun FullscreenGuideOverlay( BackHandler(enabled = visible, onBack = onDismiss) - var nowMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { - while (true) { - nowMillis = System.currentTimeMillis() - delay(30_000) - } - } + val nowMillis = clockTickMillis val catchupSupported = remember(channel) { channel.supportsFullscreenCatchup() } val pastWindowStart = nowMillis - 48L * 60L * 60_000L @@ -381,9 +375,9 @@ private fun FullscreenGuideContent( isTouchDevice = isTouchDevice, onClick = { when (item.state) { - GuideProgramState.PastPlayable -> onProgramSelect(item.program) - GuideProgramState.PastUnavailable -> Unit - GuideProgramState.Live -> onProgramSelect(null) + GuideProgramState.PastPlayable, + GuideProgramState.Live -> onProgramSelect(item.program) + GuideProgramState.PastUnavailable, GuideProgramState.Future -> Unit } }, 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 ece1d9e61..d7645f96b 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 @@ -1644,25 +1644,36 @@ fun LiveTvScreen( } } + fun isSamePlayingChannel(channel: EnrichedChannel): Boolean { + val currentDisplayId = displayChannelIdFor( + playingChannelId, + visibleEnrichedState.value.index.byId, + variantGroups, + ) + return channel.id == playingChannelId || channel.id == currentDisplayId + } + fun selectChannel(channel: EnrichedChannel) { noteGuideUserNavigation() focusedChannelId = channel.id epgPrefetchAnchorId = channel.id rememberedChannelByCategory[selectedCategoryId] = channel.id - val currentDisplayId = displayChannelIdFor(playingChannelId, visibleEnrichedState.value.index.byId, variantGroups) - val isSamePlayingChannel = channel.id == playingChannelId || channel.id == currentDisplayId - if (isSamePlayingChannel && !isFullScreen) { - // Second tap on the already-playing channel → fullscreen - playingCatchupProgram = null - catchupPlaybackOffsetMs = 0L - isFullScreen = true - hudPokeSignal++ - } else { - // First tap or different channel → tune in mini-player - playingChannelId = channel.id - playingCatchupProgram = null - catchupPlaybackOffsetMs = 0L - fullscreenGuideOpen = false + when (channelRowInteractionAction(isSamePlayingChannel(channel))) { + EpgInteractionAction.PlayLiveMini -> { + playingChannelId = channel.id + playingCatchupProgram = null + catchupPlaybackOffsetMs = 0L + fullscreenGuideOpen = false + } + EpgInteractionAction.PlayLiveFullscreen -> { + playingChannelId = channel.id + playingCatchupProgram = null + catchupPlaybackOffsetMs = 0L + fullscreenGuideOpen = false + isFullScreen = true + hudPokeSignal++ + } + else -> Unit } } @@ -1733,6 +1744,40 @@ fun LiveTvScreen( hudPokeSignal++ } + fun selectEpgProgram( + channel: EnrichedChannel, + program: IptvProgram, + fullscreenContext: Boolean = false, + ) { + val temporalState = when { + program.isLive(guideClockMillis) -> EpgTemporalState.Live + program.endUtcMillis <= guideClockMillis -> EpgTemporalState.Past + else -> EpgTemporalState.Future + } + val catchupSupported = program.catchupAvailable == true || channel.supportsCatchupHistory() + when ( + epgProgramInteractionAction( + temporalState = temporalState, + isSamePlayingChannel = isSamePlayingChannel(channel), + isCatchupSupported = catchupSupported, + ) + ) { + EpgInteractionAction.PlayLiveMini -> { + isFullScreen = false + playProgramInMini(channel, null) + } + EpgInteractionAction.PlayLiveFullscreen -> selectChannel(channel) + EpgInteractionAction.PlayCatchup -> { + if (fullscreenContext) { + playProgramInFullscreen(program, channel) + } else { + playProgramInMini(channel, program) + } + } + EpgInteractionAction.NoOp -> Unit + } + } + // ExoPlayer lifecycle — mirrors the legacy screen's setup verbatim so live // IPTV behaviour (buffer, retries, chunkless HLS) stays identical. var channelNumberBuffer by remember { mutableStateOf("") } @@ -2476,7 +2521,9 @@ fun LiveTvScreen( focusZone = LiveTvFocusZone.CHANNEL_LIST selectChannel(channel) }, - onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, + onProgramSelect = { channel, program -> + program?.let { selectEpgProgram(channel, it) } + }, onChannelFocused = { channel -> commitFocusedChannel(channel) }, onChannelFavoriteToggle = { id -> viewModel.toggleFavoriteChannel(id) }, favorites = favSet, @@ -2609,7 +2656,9 @@ fun LiveTvScreen( compact = compactTouchLayout, gridFocused = focusZone == LiveTvFocusZone.CHANNEL_LIST || focusZone == LiveTvFocusZone.EPG, onChannelSelect = { channel, _ -> selectChannel(channel) }, - onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, + onProgramSelect = { channel, program -> + program?.let { selectEpgProgram(channel, it) } + }, onChannelFocused = { channel -> commitFocusedChannel(channel) }, onChannelFavoriteToggle = { id -> viewModel.toggleFavoriteChannel(id) }, favorites = favSet, @@ -2890,6 +2939,7 @@ fun LiveTvScreen( channel = guideChannel ?: playingChannel, guide = guideForChannel(guideChannel ?: playingChannel), selectedProgram = playingCatchupProgram, + clockTickMillis = guideClockMillis, isTouchDevice = isTouchDevice, onDismiss = { fullscreenGuideOpen = false @@ -2903,7 +2953,9 @@ fun LiveTvScreen( onProgramSelect = { program -> val target = guideChannel ?: playingChannel guideOpenedFromQuickZap = false - playProgramInFullscreen(program, target) + if (program != null && target != null) { + selectEpgProgram(target, program, fullscreenContext = true) + } }, onLeftClick = { fullscreenGuideOpen = false diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt new file mode 100644 index 000000000..d3b12b11d --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt @@ -0,0 +1,74 @@ +package com.arflix.tv.ui.screens.tv.live + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class EpgProgramActionsTest { + + @Test + fun channelRowFirstClickTunesLiveMiniPlayer() { + assertThat(channelRowInteractionAction(isSamePlayingChannel = false)) + .isEqualTo(EpgInteractionAction.PlayLiveMini) + } + + @Test + fun channelRowSecondClickOpensLiveFullscreen() { + assertThat(channelRowInteractionAction(isSamePlayingChannel = true)) + .isEqualTo(EpgInteractionAction.PlayLiveFullscreen) + } + + @Test + fun liveEpgCellFirstClickTunesLiveMiniPlayer() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Live, + isSamePlayingChannel = false, + isCatchupSupported = false, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveMini) + } + + @Test + fun liveEpgCellSecondClickOpensLiveFullscreen() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Live, + isSamePlayingChannel = true, + isCatchupSupported = false, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveFullscreen) + } + + @Test + fun pastEpgCellWithCatchupStartsCatchup() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Past, + isSamePlayingChannel = false, + isCatchupSupported = true, + ) + ).isEqualTo(EpgInteractionAction.PlayCatchup) + } + + @Test + fun pastEpgCellWithoutCatchupDoesNothing() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Past, + isSamePlayingChannel = false, + isCatchupSupported = false, + ) + ).isEqualTo(EpgInteractionAction.NoOp) + } + + @Test + fun futureEpgCellDoesNothing() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Future, + isSamePlayingChannel = false, + isCatchupSupported = false, + ) + ).isEqualTo(EpgInteractionAction.NoOp) + } +} From b3f787debf8303f1fcd436a6839f43d283bec08f Mon Sep 17 00:00:00 2001 From: Robbie <205481179+tormox@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:48:05 +0100 Subject: [PATCH 2/7] feat(iptv): offer Stream Now from live guide programmes Keep live, catch-up, and future guide actions explicit while resolving second selections on the playing live programme to a conservative TMDB match. Show Watch Live and Stream Now only for confident movie or series matches, fail closed for ambiguous or non-VOD channels, guard stale lookups, and expose a device-local setting. --- .../com/arflix/tv/navigation/AppNavigation.kt | 3 + .../tv/ui/screens/settings/SettingsScreen.kt | 43 ++- .../ui/screens/settings/SettingsViewModel.kt | 13 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 44 ++- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 6 +- .../ui/screens/tv/live/EpgProgramActions.kt | 130 +++++++- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 303 +++++++++++++----- .../arflix/tv/util/IptvGuidePreferences.kt | 6 + app/src/main/res/values/strings.xml | 4 + .../ui/screens/tv/live/EpgGridActionTest.kt | 44 +++ .../screens/tv/live/EpgProgramActionsTest.kt | 225 ++++++++++++- 11 files changed, 722 insertions(+), 99 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/util/IptvGuidePreferences.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGridActionTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index 50247257c..1a88b69ff 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -291,6 +291,9 @@ fun AppNavigation( onNavigateToWatchlist = { navigateTopLevel(Screen.Watchlist.route) }, onNavigateToSettings = { navigateTopLevel(Screen.Settings.route) }, onNavigateToIptvSettings = { navigateTopLevel(Screen.Settings.createRoute(initialSection = "iptv")) }, + onNavigateToDetails = { mediaType, mediaId -> + navController.navigate(Screen.Details.createRoute(mediaType, mediaId)) + }, onSwitchProfile = { onSwitchProfile() navController.navigate(Screen.ProfileSelection.route) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 93756425d..ba82da5fa 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -476,7 +476,7 @@ fun SettingsScreen( groupOrder = uiState.iptvGroupOrder ).size // Reset row + category rows } else { - 4 + uiState.iptvPlaylists.size // Add + rows + stalker + order + refresh + clear + 5 + uiState.iptvPlaylists.size // Add + stalker + playlists + order + EPG actions + refresh + clear } "home_server" -> uiState.homeServerConnections.size + 3 "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs @@ -1074,9 +1074,12 @@ fun SettingsScreen( viewModel.setIptvSortOrder(next) } contentFocusIndex == uiState.iptvPlaylists.size + 3 -> { - viewModel.refreshIptv(force = true) + viewModel.setEpgVodActionsEnabled(!uiState.epgVodActionsEnabled) } contentFocusIndex == uiState.iptvPlaylists.size + 4 -> { + viewModel.refreshIptv(force = true) + } + contentFocusIndex == uiState.iptvPlaylists.size + 5 -> { viewModel.clearIptvConfig() } } @@ -1628,7 +1631,9 @@ fun SettingsScreen( onDelete = { viewModel.clearIptvConfig() }, onManageCategories = openIptvCategories, sortOrder = uiState.iptvSortOrder, - onSortOrderChange = { viewModel.setIptvSortOrder(it) } + onSortOrderChange = { viewModel.setIptvSortOrder(it) }, + epgVodActionsEnabled = uiState.epgVodActionsEnabled, + onEpgVodActionsToggle = viewModel::setEpgVodActionsEnabled, ) "TV" -> IptvSettings( playlists = uiState.iptvPlaylists, @@ -1676,7 +1681,9 @@ fun SettingsScreen( onDelete = { viewModel.clearIptvConfig() }, onManageCategories = openIptvCategories, sortOrder = uiState.iptvSortOrder, - onSortOrderChange = { viewModel.setIptvSortOrder(it) } + onSortOrderChange = { viewModel.setIptvSortOrder(it) }, + epgVodActionsEnabled = uiState.epgVodActionsEnabled, + onEpgVodActionsToggle = viewModel::setEpgVodActionsEnabled, ) "home_server" -> HomeServerSettings( connections = uiState.homeServerConnections, @@ -4585,7 +4592,9 @@ private fun MobileSettingsSubPage( onNavigate("IPTV_CATEGORIES") }, sortOrder = uiState.iptvSortOrder, - onSortOrderChange = { viewModel.setIptvSortOrder(it) } + onSortOrderChange = { viewModel.setIptvSortOrder(it) }, + epgVodActionsEnabled = uiState.epgVodActionsEnabled, + onEpgVodActionsToggle = viewModel::setEpgVodActionsEnabled, ) } "IPTV_CATEGORIES" -> { @@ -6754,6 +6763,8 @@ private fun IptvSettings( onManageCategories: (String) -> Unit = {}, sortOrder: String = "provider", onSortOrderChange: (String) -> Unit = {}, + epgVodActionsEnabled: Boolean = true, + onEpgVodActionsToggle: (Boolean) -> Unit = {}, onConfigureStalker: () -> Unit = {}, stalkerSubtitle: String = "" ) { @@ -6866,6 +6877,15 @@ private fun IptvSettings( onSortOrderChange(next) } ) + MobileSettingsRow( + icon = Icons.Default.LiveTv, + title = stringResource(R.string.settings_epg_vod_actions), + subtitle = stringResource(R.string.settings_epg_vod_actions_desc), + value = stringResource(if (epgVodActionsEnabled) R.string.on else R.string.off), + isFocused = false, + showDivider = false, + onClick = { onEpgVodActionsToggle(!epgVodActionsEnabled) }, + ) } MobileSettingsCategory(title = stringResource(R.string.settings_section_actions)) { val refreshSubtitle = when { isLoading -> stringResource(R.string.settings_refreshing_channels_epg); error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> stringResource(R.string.settings_reload_playlists_now); else -> stringResource(R.string.settings_reload_playlist_epg_now) } @@ -6957,10 +6977,19 @@ private fun IptvSettings( modifier = Modifier.settingsFocusSlot(playlists.size + 2) ) Spacer(modifier = Modifier.height(16.dp)) + SettingsToggleRow( + title = stringResource(R.string.settings_epg_vod_actions), + subtitle = stringResource(R.string.settings_epg_vod_actions_desc), + isEnabled = epgVodActionsEnabled, + isFocused = focusedIndex == playlists.size + 3, + onToggle = onEpgVodActionsToggle, + modifier = Modifier.settingsFocusSlot(playlists.size + 3), + ) + Spacer(modifier = Modifier.height(16.dp)) val refreshSubtitle = when { isLoading -> stringResource(R.string.settings_refreshing_channels_epg); error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> stringResource(R.string.settings_reload_playlists_now); else -> stringResource(R.string.settings_reload_playlist_epg_now) } - SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) stringResource(R.string.settings_badge_loading) else stringResource(R.string.settings_badge_refresh), isFocused = focusedIndex == playlists.size + 3, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 3)) + SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) stringResource(R.string.settings_badge_loading) else stringResource(R.string.settings_badge_refresh), isFocused = focusedIndex == playlists.size + 4, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 4)) Spacer(modifier = Modifier.height(16.dp)) - SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = if (playlists.isEmpty()) stringResource(R.string.settings_badge_empty) else stringResource(R.string.settings_badge_delete), isFocused = focusedIndex == playlists.size + 4, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 4)) + SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = if (playlists.isEmpty()) stringResource(R.string.settings_badge_empty) else stringResource(R.string.settings_badge_delete), isFocused = focusedIndex == playlists.size + 5, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 5)) if (isLoading && !progressText.isNullOrBlank()) { Spacer(modifier = Modifier.height(12.dp)) Text(stringResource(R.string.settings_progress_format, progressText, progressPercent.coerceIn(0, 100)), style = ArflixTypography.caption, color = TextSecondary) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 8935dfbdd..7ca02180f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -61,6 +61,7 @@ import com.arflix.tv.updater.UpdatePreferences import com.arflix.tv.updater.VersionUtils import com.arflix.tv.util.AuthEmailValidator import com.arflix.tv.util.LAST_APP_LANGUAGE_KEY +import com.arflix.tv.util.IPTV_EPG_VOD_ACTIONS_ENABLED_KEY import com.arflix.tv.util.settingsDataStore import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -186,6 +187,7 @@ data class SettingsUiState( val iptvAvailableGroups: List = emptyList(), val iptvHiddenGroups: List = emptyList(), val iptvGroupOrder: List = emptyList(), + val epgVodActionsEnabled: Boolean = true, // App updates val isSelfUpdateSupported: Boolean = true, val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle, @@ -525,6 +527,7 @@ class SettingsViewModel @Inject constructor( val volumeBoostDb = prefs[volumeBoostDbKey()]?.toIntOrNull()?.coerceIn(0, 15) ?: 0 val showLoadingStats = prefs[showLoadingStatsKey()] ?: true val smoothScrolling = prefs[smoothScrollingKey()] ?: true + val epgVodActionsEnabled = prefs[IPTV_EPG_VOD_ACTIONS_ENABLED_KEY] ?: true val subtitleSize = prefs[subtitleSizeKey()] ?: "Medium" val subtitleColor = prefs[subtitleColorKey()] ?: "White" @@ -664,7 +667,8 @@ class SettingsViewModel @Inject constructor( subtitleAiApiKey = subtitleAiApiKey, subtitleAiModel = subtitleAiModel, subtitleRemoveHearingImpaired = subtitleRemoveHearingImpaired, - smoothScrolling = smoothScrolling + smoothScrolling = smoothScrolling, + epgVodActionsEnabled = epgVodActionsEnabled, ) refreshIntegrationUsernames(loadProfileId, isTrakt, isMdbList, isSimkl) @@ -1399,6 +1403,13 @@ class SettingsViewModel @Inject constructor( } } + fun setEpgVodActionsEnabled(enabled: Boolean) { + viewModelScope.launch { + context.settingsDataStore.edit { it[IPTV_EPG_VOD_ACTIONS_ENABLED_KEY] = enabled } + _uiState.value = _uiState.value.copy(epgVodActionsEnabled = enabled) + } + } + fun setShowLoadingStats(enabled: Boolean) { viewModelScope.launch { context.settingsDataStore.edit { it[showLoadingStatsKey()] = enabled } 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 d19a7f076..08eab40ac 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 @@ -14,8 +14,12 @@ import com.arflix.tv.data.repository.IptvPlaybackTarget import com.arflix.tv.data.repository.IptvPlaybackUrlResolver import com.arflix.tv.data.repository.IptvRepository import com.arflix.tv.data.repository.IptvTvSessionState +import com.arflix.tv.ui.screens.tv.live.epgChannelAllowsVodSearch +import com.arflix.tv.ui.screens.tv.live.selectConfidentEpgVodMatch import com.arflix.tv.network.OkHttpProvider import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.IPTV_EPG_VOD_ACTIONS_ENABLED_KEY +import com.arflix.tv.util.settingsDataStore import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -24,9 +28,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.Job +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit @@ -72,6 +78,7 @@ data class TvUiState( val epgLoadingChannelIds: Set = emptySet(), val epgAttemptedChannelIds: Set = emptySet(), val epgBackfillInProgress: Boolean = false, + val epgVodActionsEnabled: Boolean = true, ) { val isConfigured: Boolean get() = config.m3uUrl.isNotBlank() || @@ -85,9 +92,32 @@ data class TvUiState( class TvViewModel @Inject constructor( @ApplicationContext private val context: Context, val iptvRepository: IptvRepository, - private val cloudSyncRepository: CloudSyncRepository + private val cloudSyncRepository: CloudSyncRepository, + private val mediaRepository: com.arflix.tv.data.repository.MediaRepository, ) : ViewModel() { + /** + * Resolve an EPG title to a confident TMDB movie/series match. + * Sports/news/shopping channel names are rejected before any metadata call, + * and fuzzy first-result matches are not treated as VOD. + */ + suspend fun findEpgVodMatch( + title: String, + description: String?, + channelName: String, + channelGroup: String, + ): com.arflix.tv.data.model.MediaItem? { + if (!epgChannelAllowsVodSearch(channelName, channelGroup)) return null + val results = try { + mediaRepository.search(title) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + emptyList() + } + return selectConfidentEpgVodMatch(title, results, description) + } + private val _uiState = MutableStateFlow(TvUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var refreshJob: Job? = null @@ -160,6 +190,7 @@ class TvViewModel @Inject constructor( init { observeConfigAndFavorites() observeTvSession() + observeEpgVodActionsPreference() viewModelScope.launch { runCatching { iptvRepository.warmupFromCacheOnly() } // Try fast non-blocking in-memory read first; fall back to mutex-guarded disk read @@ -224,6 +255,17 @@ class TvViewModel @Inject constructor( } } + private fun observeEpgVodActionsPreference() { + viewModelScope.launch { + context.settingsDataStore.data + .map { preferences -> preferences[IPTV_EPG_VOD_ACTIONS_ENABLED_KEY] ?: true } + .distinctUntilChanged() + .collect { enabled -> + setUiState(_uiState.value.copy(epgVodActionsEnabled = enabled)) + } + } + } + private fun observeTvSession() { viewModelScope.launch { iptvRepository.observeTvSessionState() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 9f194ad6a..76f740e63 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -97,8 +97,8 @@ fun EpgGrid( focusEpgSignal: Int = 0, focusMode: EpgGridFocusMode = EpgGridFocusMode.ChannelList, scrollResetKey: String = "", - onChannelSelect: (EnrichedChannel, IptvProgram?) -> Unit, - onProgramSelect: (EnrichedChannel, IptvProgram?) -> Unit = onChannelSelect, + onChannelSelect: (EnrichedChannel) -> Unit, + onProgramSelect: (EnrichedChannel, IptvProgram?) -> Unit = { channel, _ -> onChannelSelect(channel) }, onChannelFocused: (EnrichedChannel) -> Unit = {}, onChannelFavoriteToggle: (String) -> Unit, favorites: Set, @@ -487,7 +487,7 @@ fun EpgGrid( nowNext = nowNext[ch.id], isFavorite = ch.id in favorites, stripe = idx % 2 == 1, - onClick = { onChannelSelect(ch, null) }, + onClick = { onChannelSelect(ch) }, onFocused = { val pendingId = pendingChannelFocusId if (pendingId != null && pendingId != ch.id) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt index 755e1184c..37e4213a0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt @@ -1,5 +1,47 @@ package com.arflix.tv.ui.screens.tv.live +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType + +private val NON_VOD_EPG_CHANNEL_TERMS = setOf( + "sport", + "sports", + "football", + "soccer", + "basketball", + "tennis", + "motorsport", + "racing", + "rugby", + "hockey", + "baseball", + "boxing", + "ufc", + "mma", + "cricket", + "golf", + "nfl", + "nba", + "mlb", + "nhl", + "f1", + "news", + "weather", + "shopping", + "teleshopping", +) + +private val MIXED_ENTERTAINMENT_GROUP_TERMS = setOf( + "entertainment", + "movie", + "movies", + "film", + "films", + "cinema", + "series", + "general", +) + internal enum class EpgTemporalState { Live, Past, @@ -10,27 +52,105 @@ internal enum class EpgInteractionAction { PlayLiveMini, PlayLiveFullscreen, PlayCatchup, + ResolveVodOrPlayFullscreen, + ShowVodDialog, NoOp, } internal fun channelRowInteractionAction( isSamePlayingChannel: Boolean, -): EpgInteractionAction = if (isSamePlayingChannel) { - EpgInteractionAction.PlayLiveFullscreen -} else { - EpgInteractionAction.PlayLiveMini + hasCurrentProgram: Boolean, + vodActionsEnabled: Boolean, +): EpgInteractionAction = when { + !isSamePlayingChannel -> EpgInteractionAction.PlayLiveMini + vodActionsEnabled && hasCurrentProgram -> EpgInteractionAction.ResolveVodOrPlayFullscreen + else -> EpgInteractionAction.PlayLiveFullscreen } internal fun epgProgramInteractionAction( temporalState: EpgTemporalState, isSamePlayingChannel: Boolean, isCatchupSupported: Boolean, + vodActionsEnabled: Boolean, ): EpgInteractionAction = when (temporalState) { - EpgTemporalState.Live -> channelRowInteractionAction(isSamePlayingChannel) EpgTemporalState.Past -> if (isCatchupSupported) { EpgInteractionAction.PlayCatchup } else { EpgInteractionAction.NoOp } EpgTemporalState.Future -> EpgInteractionAction.NoOp + EpgTemporalState.Live -> when { + !isSamePlayingChannel -> EpgInteractionAction.PlayLiveMini + vodActionsEnabled -> EpgInteractionAction.ResolveVodOrPlayFullscreen + else -> EpgInteractionAction.PlayLiveFullscreen + } +} + +internal fun vodLookupResolution(hasVodMatch: Boolean): EpgInteractionAction = + if (hasVodMatch) EpgInteractionAction.ShowVodDialog else EpgInteractionAction.PlayLiveFullscreen + +internal class EpgVodLookupGuard { + private var generation = 0 + + fun beginLookup(): Int = ++generation + + fun invalidate() { + generation++ + } + + fun isCurrent(lookupGeneration: Int): Boolean = lookupGeneration == generation +} + +internal fun epgChannelAllowsVodSearch( + channelName: String, + channelGroup: String, +): Boolean { + // Playlist group labels are often broad combinations such as + // "News & Entertainment". A broad mixed label must not reject a movie + // channel, while a dedicated Sports/News group should still fail closed. + // Exact title matching remains the final guard against false positives. + val channelTokens = channelName + .lowercase() + .split(Regex("[^a-z0-9]+")) + .filterTo(mutableSetOf()) { it.isNotBlank() } + if (channelTokens.any { it in NON_VOD_EPG_CHANNEL_TERMS }) return false + + val groupTokens = channelGroup + .lowercase() + .split(Regex("[^a-z0-9]+")) + .filterTo(mutableSetOf()) { it.isNotBlank() } + val dedicatedNonVodGroup = + groupTokens.any { it in NON_VOD_EPG_CHANNEL_TERMS } && + groupTokens.none { it in MIXED_ENTERTAINMENT_GROUP_TERMS } + return !dedicatedNonVodGroup +} + +private val EPG_TITLE_YEAR_SUFFIX = Regex("""\s*\(?\b(?:19|20)\d{2}\b\)?\s*$""") +private val EPG_YEAR_HINT = Regex("""\b(?:19|20)\d{2}\b""") +private val EPG_TITLE_NON_ALPHANUMERIC = Regex("""[^a-z0-9]+""") + +private fun normalizedEpgVodTitle(title: String): String = title + .trim() + .replace(EPG_TITLE_YEAR_SUFFIX, "") + .lowercase() + .replace("&", "and") + .replace(EPG_TITLE_NON_ALPHANUMERIC, "") + +internal fun selectConfidentEpgVodMatch( + programTitle: String, + results: List, + programDescription: String? = null, +): MediaItem? { + val expectedTitle = normalizedEpgVodTitle(programTitle) + if (expectedTitle.length < 2) return null + val exactMatches = results.filter { candidate -> + candidate.mediaType in setOf(MediaType.MOVIE, MediaType.TV) && + normalizedEpgVodTitle(candidate.title) == expectedTitle + } + val yearHint = EPG_YEAR_HINT.find("$programTitle ${programDescription.orEmpty()}")?.value + return if (yearHint != null) { + exactMatches.singleOrNull { it.year == yearHint } + } else { + exactMatches.singleOrNull() + } } 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 d7645f96b..84a98a748 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 @@ -80,9 +80,13 @@ import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.arflix.tv.R +import com.arflix.tv.ui.theme.Pink +import com.arflix.tv.ui.theme.ArflixTypography +import com.arflix.tv.ui.theme.TextSecondary import com.arflix.tv.data.model.IptvChannel import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram +import com.arflix.tv.data.model.MediaItem as ArvioMediaItem import com.arflix.tv.data.model.Profile import com.arflix.tv.data.repository.IptvPlaybackTarget import com.arflix.tv.ui.screens.tv.TvUiState @@ -407,6 +411,7 @@ fun LiveTvScreen( onNavigateToWatchlist: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToIptvSettings: (() -> Unit)? = null, + onNavigateToDetails: (com.arflix.tv.data.model.MediaType, Int) -> Unit = { _, _ -> }, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {}, ) { @@ -1449,12 +1454,42 @@ fun LiveTvScreen( // mini-player to cover the whole screen. Back collapses back to the grid. var isFullScreen by rememberSaveable { mutableStateOf(initialStreamUrl != null) } var fullscreenGuideOpen by remember { mutableStateOf(false) } + var quickZapOpen by remember { mutableStateOf(false) } var variantPickerChannel by remember { mutableStateOf(null) } + // A second selection on the currently playing programme offers Watch Live + // and, only after a confident movie/series match, Stream Now. + var programActionDialog by remember { mutableStateOf(null) } + var programActionVodMatch by remember { mutableStateOf(null) } + val programActionLookupGuard = remember { EpgVodLookupGuard() } + val programActionLookupJob = remember { arrayOf(null) } + fun invalidateProgramActionLookup() { + programActionLookupJob[0]?.cancel() + programActionLookupJob[0] = null + programActionLookupGuard.invalidate() + programActionDialog = null + programActionVodMatch = null + } + LaunchedEffect( + selectedCategoryId, + selectedProviderId, + focusedChannelId, + searchOpen, + variantPickerChannel, + isFullScreen, + fullscreenGuideOpen, + quickZapOpen, + ) { + invalidateProgramActionLookup() + } LaunchedEffect(isFullScreen) { onFullscreenChanged(isFullScreen) } DisposableEffect(Unit) { - onDispose { onFullscreenChanged(false) } + onDispose { + programActionLookupJob[0]?.cancel() + programActionLookupGuard.invalidate() + onFullscreenChanged(false) + } } // Focus requesters for the three regions. val sidebarFocus = remember { FocusRequester() } @@ -1465,7 +1500,6 @@ fun LiveTvScreen( val sidebarListState = rememberLazyListState() var hudPokeSignal by remember { mutableStateOf(0) } - var quickZapOpen by remember { mutableStateOf(false) } var isHudVisible by remember { mutableStateOf(false) } var guideOpenedFromQuickZap by remember { mutableStateOf(false) } var guideChannel by remember { mutableStateOf(null) } @@ -1644,39 +1678,6 @@ fun LiveTvScreen( } } - fun isSamePlayingChannel(channel: EnrichedChannel): Boolean { - val currentDisplayId = displayChannelIdFor( - playingChannelId, - visibleEnrichedState.value.index.byId, - variantGroups, - ) - return channel.id == playingChannelId || channel.id == currentDisplayId - } - - fun selectChannel(channel: EnrichedChannel) { - noteGuideUserNavigation() - focusedChannelId = channel.id - epgPrefetchAnchorId = channel.id - rememberedChannelByCategory[selectedCategoryId] = channel.id - when (channelRowInteractionAction(isSamePlayingChannel(channel))) { - EpgInteractionAction.PlayLiveMini -> { - playingChannelId = channel.id - playingCatchupProgram = null - catchupPlaybackOffsetMs = 0L - fullscreenGuideOpen = false - } - EpgInteractionAction.PlayLiveFullscreen -> { - playingChannelId = channel.id - playingCatchupProgram = null - catchupPlaybackOffsetMs = 0L - fullscreenGuideOpen = false - isFullScreen = true - hudPokeSignal++ - } - else -> Unit - } - } - fun openVariantPicker(channel: EnrichedChannel) { noteGuideUserNavigation() if (variantCountFor(channel, variantGroups) > 1) { @@ -1720,6 +1721,96 @@ fun LiveTvScreen( focusChannelList(playbackChannel.id) } + fun isSamePlayingChannel(channel: EnrichedChannel): Boolean { + val currentDisplayId = displayChannelIdFor( + playingChannelId, + visibleEnrichedState.value.index.byId, + variantGroups, + ) + return channel.id == playingChannelId || channel.id == currentDisplayId + } + + fun playLiveFullscreen(channel: EnrichedChannel) { + invalidateProgramActionLookup() + noteGuideUserNavigation() + playingChannelId = channel.id + focusedChannelId = channel.id + epgPrefetchAnchorId = channel.id + rememberedChannelByCategory[selectedCategoryId] = channel.id + playingCatchupProgram = null + catchupPlaybackOffsetMs = 0L + fullscreenGuideOpen = false + isFullScreen = true + hudPokeSignal++ + } + + fun resolveVodOrPlayFullscreen(channel: EnrichedChannel, program: IptvProgram) { + invalidateProgramActionLookup() + if (!epgChannelAllowsVodSearch(channel.name, channel.source.group)) { + playLiveFullscreen(channel) + return + } + val lookupGeneration = programActionLookupGuard.beginLookup() + programActionLookupJob[0] = coroutineScope.launch { + val match = viewModel.findEpgVodMatch( + title = program.title, + description = program.description, + channelName = channel.name, + channelGroup = channel.source.group, + ) + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + when (vodLookupResolution(match != null)) { + EpgInteractionAction.ShowVodDialog -> { + programActionVodMatch = match + programActionDialog = ProgramActionData(channel, program) + } + EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + else -> Unit + } + } + } + + fun selectChannel(channel: EnrichedChannel, currentProgram: IptvProgram? = null) { + val sameChannel = isSamePlayingChannel(channel) + when ( + channelRowInteractionAction( + isSamePlayingChannel = sameChannel, + hasCurrentProgram = currentProgram != null, + vodActionsEnabled = state.epgVodActionsEnabled, + ) + ) { + EpgInteractionAction.PlayLiveMini -> playProgramInMini(channel, null) + EpgInteractionAction.ResolveVodOrPlayFullscreen -> + resolveVodOrPlayFullscreen(channel, currentProgram ?: return) + EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + else -> Unit + } + } + + fun selectEpgProgram(channel: EnrichedChannel, program: IptvProgram) { + val temporalState = when { + program.isLive(guideClockMillis) -> EpgTemporalState.Live + program.endUtcMillis <= guideClockMillis -> EpgTemporalState.Past + else -> EpgTemporalState.Future + } + // EpgGrid only forwards past programmes when catch-up is supported. + val catchupSupported = temporalState == EpgTemporalState.Past + when ( + epgProgramInteractionAction( + temporalState = temporalState, + isSamePlayingChannel = isSamePlayingChannel(channel), + isCatchupSupported = catchupSupported, + vodActionsEnabled = state.epgVodActionsEnabled, + ) + ) { + EpgInteractionAction.PlayLiveMini -> playProgramInMini(channel, null) + EpgInteractionAction.PlayCatchup -> playProgramInMini(channel, program) + EpgInteractionAction.ResolveVodOrPlayFullscreen -> resolveVodOrPlayFullscreen(channel, program) + EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + EpgInteractionAction.NoOp, + EpgInteractionAction.ShowVodDialog -> Unit + } + } fun playProgramInFullscreen(program: IptvProgram?, targetChannel: EnrichedChannel? = null) { val channel = targetChannel ?: playingChannel if (program != playingCatchupProgram) { @@ -1744,40 +1835,6 @@ fun LiveTvScreen( hudPokeSignal++ } - fun selectEpgProgram( - channel: EnrichedChannel, - program: IptvProgram, - fullscreenContext: Boolean = false, - ) { - val temporalState = when { - program.isLive(guideClockMillis) -> EpgTemporalState.Live - program.endUtcMillis <= guideClockMillis -> EpgTemporalState.Past - else -> EpgTemporalState.Future - } - val catchupSupported = program.catchupAvailable == true || channel.supportsCatchupHistory() - when ( - epgProgramInteractionAction( - temporalState = temporalState, - isSamePlayingChannel = isSamePlayingChannel(channel), - isCatchupSupported = catchupSupported, - ) - ) { - EpgInteractionAction.PlayLiveMini -> { - isFullScreen = false - playProgramInMini(channel, null) - } - EpgInteractionAction.PlayLiveFullscreen -> selectChannel(channel) - EpgInteractionAction.PlayCatchup -> { - if (fullscreenContext) { - playProgramInFullscreen(program, channel) - } else { - playProgramInMini(channel, program) - } - } - EpgInteractionAction.NoOp -> Unit - } - } - // ExoPlayer lifecycle — mirrors the legacy screen's setup verbatim so live // IPTV behaviour (buffer, retries, chunkless HLS) stays identical. var channelNumberBuffer by remember { mutableStateOf("") } @@ -2517,9 +2574,12 @@ fun LiveTvScreen( scrollResetKey = "$selectedProviderId|$selectedCategoryId|$filteredChannelsWindowKey|$normalizedGuideStart", compact = true, gridFocused = focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel, _ -> + onChannelSelect = { channel -> focusZone = LiveTvFocusZone.CHANNEL_LIST - selectChannel(channel) + val currentProgram = effectiveGuideNowNext[channel.id] + ?.now + ?.takeIf { it.isLive(guideClockMillis) } + selectChannel(channel, currentProgram) }, onProgramSelect = { channel, program -> program?.let { selectEpgProgram(channel, it) } @@ -2655,7 +2715,12 @@ fun LiveTvScreen( scrollResetKey = "$selectedProviderId|$selectedCategoryId|$filteredChannelsWindowKey|$normalizedGuideStart", compact = compactTouchLayout, gridFocused = focusZone == LiveTvFocusZone.CHANNEL_LIST || focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel, _ -> selectChannel(channel) }, + onChannelSelect = { channel -> + val currentProgram = effectiveGuideNowNext[channel.id] + ?.now + ?.takeIf { it.isLive(guideClockMillis) } + selectChannel(channel, currentProgram) + }, onProgramSelect = { channel, program -> program?.let { selectEpgProgram(channel, it) } }, @@ -2954,7 +3019,21 @@ fun LiveTvScreen( val target = guideChannel ?: playingChannel guideOpenedFromQuickZap = false if (program != null && target != null) { - selectEpgProgram(target, program, fullscreenContext = true) + when { + program.endUtcMillis <= guideClockMillis -> + playProgramInFullscreen(program, target) + program.isLive(guideClockMillis) && isSamePlayingChannel(target) && state.epgVodActionsEnabled -> + resolveVodOrPlayFullscreen(target, program) + program.isLive(guideClockMillis) && isSamePlayingChannel(target) -> + playProgramInFullscreen(null, target) + program.isLive(guideClockMillis) -> { + // First selection of another live channel follows the same + // guide contract: tune it in the mini-player. + fullscreenGuideOpen = false + isFullScreen = false + playProgramInMini(target, null) + } + } } }, onLeftClick = { @@ -3077,6 +3156,75 @@ fun LiveTvScreen( .align(Alignment.BottomCenter) .padding(bottom = if (isFullScreen) 72.dp else 24.dp), ) + + val actionData = programActionDialog + if (actionData != null) { + val program = actionData.program + val channel = actionData.channel + val isNow = program.isLive(guideClockMillis) + androidx.compose.material3.AlertDialog( + onDismissRequest = { programActionDialog = null }, + title = { + androidx.tv.material3.Text( + text = program.title, + style = ArflixTypography.cardTitle, + color = Color.White, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + androidx.tv.material3.Text( + text = channel.name, + style = ArflixTypography.caption, + color = TextSecondary, + ) + androidx.tv.material3.Text( + text = "${formatClock(program.startUtcMillis)} - ${formatClock(program.endUtcMillis)}", + style = ArflixTypography.body, + color = TextSecondary, + ) + if (isNow) { + Badge(stringResource(R.string.live_badge_live), Color.White, LiveColors.LiveRed) + } + } + }, + confirmButton = { + val vodMatch = programActionVodMatch + if (vodMatch != null) { + androidx.compose.material3.TextButton( + onClick = { + invalidateProgramActionLookup() + onNavigateToDetails(vodMatch.mediaType, vodMatch.id) + }, + ) { + androidx.tv.material3.Text( + text = stringResource(R.string.epg_search_sources), + style = ArflixTypography.button, + color = Pink, + ) + } + } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { + programActionDialog = null + playProgramInMini(channel, epgWatchLivePlaybackProgram(program)) + }, + ) { + androidx.tv.material3.Text( + text = stringResource(R.string.epg_watch_live), + style = ArflixTypography.button, + color = TextSecondary, + ) + } + }, + containerColor = Color(0xFF1A1A1A), + tonalElevation = 8.dp, + ) + } } } @@ -3209,3 +3357,12 @@ private tailrec fun Context.findActivity(): Activity? { else -> null } } + +internal fun epgWatchLivePlaybackProgram( + @Suppress("UNUSED_PARAMETER") selectedProgram: IptvProgram, +): IptvProgram? = null + +internal data class ProgramActionData( + val channel: EnrichedChannel, + val program: IptvProgram, +) diff --git a/app/src/main/kotlin/com/arflix/tv/util/IptvGuidePreferences.kt b/app/src/main/kotlin/com/arflix/tv/util/IptvGuidePreferences.kt new file mode 100644 index 000000000..86962b9ba --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/util/IptvGuidePreferences.kt @@ -0,0 +1,6 @@ +package com.arflix.tv.util + +import androidx.datastore.preferences.core.booleanPreferencesKey + +/** Device-local preference for the optional EPG VOD action lookup. */ +val IPTV_EPG_VOD_ACTIONS_ENABLED_KEY = booleanPreferencesKey("iptv_epg_vod_actions_enabled") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01c0af6c8..6ad1b9374 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -591,6 +591,8 @@ OPTIONS Channel order Choose how channels are ordered within each group + TV guide streaming options + On a second click, offer Stream Now for matched movies and shows; otherwise open live TV fullscreen Provider order Channel number Alphabetical (A-Z) @@ -870,6 +872,8 @@ ARCHIVE NEW CH + Watch Live + Stream Now CH %1$d %1$d sources %1$d matches diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGridActionTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGridActionTest.kt new file mode 100644 index 000000000..591647aaf --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGridActionTest.kt @@ -0,0 +1,44 @@ +package com.arflix.tv.ui.screens.tv.live + +import com.arflix.tv.data.model.IptvProgram +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class EpgGridActionTest { + + @Test + fun currentProgramKeepsMetadataForActionDialog() { + val program = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + + assertThat( + epgProgramActionTarget( + program = program, + isPast = false, + isLive = true, + isCatchupSupported = false, + ) + ).isEqualTo(program) + } + + @Test + fun futureProgramDoesNotLeaveGuideUntilRecordingOrReminderExists() { + val program = IptvProgram( + title = "Tomorrow's Movie", + startUtcMillis = 3_000L, + endUtcMillis = 4_000L, + ) + + assertThat( + epgProgramActionTarget( + program = program, + isPast = false, + isLive = false, + isCatchupSupported = false, + ) + ).isNull() + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt index d3b12b11d..def4dfed2 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt @@ -1,20 +1,203 @@ package com.arflix.tv.ui.screens.tv.live +import com.arflix.tv.data.model.IptvProgram +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType import com.google.common.truth.Truth.assertThat import org.junit.Test class EpgProgramActionsTest { + @Test + fun watchLiveClearsSelectedProgramSoPlaybackUsesLiveStream() { + val selectedProgram = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + + assertThat(epgWatchLivePlaybackProgram(selectedProgram)).isNull() + } + + + @Test + fun sportsChannelDoesNotOfferVodSearch() { + assertThat( + epgChannelAllowsVodSearch( + channelName = "Sky Sports Main Event", + channelGroup = "UK Sports", + ) + ).isFalse() + } + + @Test + fun onlyMatchingMovieOrSeriesTitleIsEligibleForStreaming() { + val unrelated = MediaItem(id = 1, title = "Football Highlights", mediaType = MediaType.TV) + val exactMovie = MediaItem(id = 2, title = "The Lord of the Rings", year = "2001", mediaType = MediaType.MOVIE) + + assertThat( + selectConfidentEpgVodMatch( + programTitle = "The Lord of the Rings (2001)", + results = listOf(unrelated, exactMovie), + ) + ).isEqualTo(exactMovie) + } + + @Test + fun spidermanDoesNotSelectBrandNewDay() { + val brandNewDay = MediaItem( + id = 969681, + title = "Spider-Man: Brand New Day", + year = "2026", + mediaType = MediaType.MOVIE, + ) + val original = MediaItem( + id = 557, + title = "Spider-Man", + year = "2002", + mediaType = MediaType.MOVIE, + ) + + assertThat( + selectConfidentEpgVodMatch( + programTitle = "Spiderman", + results = listOf(brandNewDay, original), + ) + ).isEqualTo(original) + } + + @Test + fun descriptionYearDisambiguatesSameTitleRemakes() { + val animatedSeries = MediaItem( + id = 888, + title = "Spider-Man", + year = "1994", + mediaType = MediaType.TV, + ) + val originalMovie = MediaItem( + id = 557, + title = "Spider-Man", + year = "2002", + mediaType = MediaType.MOVIE, + ) + + assertThat( + selectConfidentEpgVodMatch( + programTitle = "Spider-Man", + programDescription = "The 2002 superhero film starring Tobey Maguire.", + results = listOf(animatedSeries, originalMovie), + ) + ).isEqualTo(originalMovie) + } + + @Test + fun conflictingYearRejectsOtherwiseExactTitle() { + val remake = MediaItem( + id = 609, + title = "The Thing", + year = "2011", + mediaType = MediaType.MOVIE, + ) + + assertThat( + selectConfidentEpgVodMatch( + programTitle = "The Thing (1982)", + results = listOf(remake), + ) + ).isNull() + } + + + @Test + fun broadPlaylistGroupDoesNotBlockMovieChannelVodLookup() { + assertThat( + epgChannelAllowsVodSearch( + channelName = "Lifetime Movies", + channelGroup = "News & Entertainment", + ) + ).isTrue() + } + + @Test + fun dedicatedSportsGroupBlocksAcronymChannelVodLookup() { + assertThat( + epgChannelAllowsVodSearch( + channelName = "ESPN", + channelGroup = "US Sports", + ) + ).isFalse() + } + + @Test + fun fatalAttractionUsesDescriptionYearToSelectMovie() { + val olderTvShow = MediaItem(id = 1, title = "Fatal Attraction", year = "2013", mediaType = MediaType.TV) + val movie = MediaItem(id = 2, title = "Fatal Attraction", year = "1987", mediaType = MediaType.MOVIE) + val newerTvShow = MediaItem(id = 3, title = "Fatal Attraction", year = "2023", mediaType = MediaType.TV) + + assertThat( + selectConfidentEpgVodMatch( + programTitle = "Fatal Attraction", + programDescription = "Michael Douglas and Glenn Close star in the 1987 thriller.", + results = listOf(olderTvShow, movie, newerTvShow), + ) + ).isEqualTo(movie) + } + + @Test + fun ambiguousExactTitlesWithoutYearAreRejected() { + val movie = MediaItem(id = 1, title = "Fatal Attraction", year = "1987", mediaType = MediaType.MOVIE) + val series = MediaItem(id = 2, title = "Fatal Attraction", year = "2023", mediaType = MediaType.TV) + + assertThat( + selectConfidentEpgVodMatch( + programTitle = "Fatal Attraction", + results = listOf(movie, series), + ) + ).isNull() + } + @Test fun channelRowFirstClickTunesLiveMiniPlayer() { - assertThat(channelRowInteractionAction(isSamePlayingChannel = false)) - .isEqualTo(EpgInteractionAction.PlayLiveMini) + assertThat( + channelRowInteractionAction( + isSamePlayingChannel = false, + hasCurrentProgram = true, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveMini) } @Test - fun channelRowSecondClickOpensLiveFullscreen() { - assertThat(channelRowInteractionAction(isSamePlayingChannel = true)) - .isEqualTo(EpgInteractionAction.PlayLiveFullscreen) + fun channelRowSecondClickResolvesVodWhenCurrentProgramExists() { + assertThat( + channelRowInteractionAction( + isSamePlayingChannel = true, + hasCurrentProgram = true, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.ResolveVodOrPlayFullscreen) + } + + @Test + fun channelRowSecondClickWithoutEpgPlaysLiveFullscreen() { + assertThat( + channelRowInteractionAction( + isSamePlayingChannel = true, + hasCurrentProgram = false, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveFullscreen) + } + + @Test + fun disabledVodActionsMakeSecondChannelClickPlayFullscreen() { + assertThat( + channelRowInteractionAction( + isSamePlayingChannel = true, + hasCurrentProgram = true, + vodActionsEnabled = false, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveFullscreen) } @Test @@ -24,19 +207,21 @@ class EpgProgramActionsTest { temporalState = EpgTemporalState.Live, isSamePlayingChannel = false, isCatchupSupported = false, + vodActionsEnabled = true, ) ).isEqualTo(EpgInteractionAction.PlayLiveMini) } @Test - fun liveEpgCellSecondClickOpensLiveFullscreen() { + fun liveEpgCellSecondClickResolvesVod() { assertThat( epgProgramInteractionAction( temporalState = EpgTemporalState.Live, isSamePlayingChannel = true, isCatchupSupported = false, + vodActionsEnabled = true, ) - ).isEqualTo(EpgInteractionAction.PlayLiveFullscreen) + ).isEqualTo(EpgInteractionAction.ResolveVodOrPlayFullscreen) } @Test @@ -44,8 +229,9 @@ class EpgProgramActionsTest { assertThat( epgProgramInteractionAction( temporalState = EpgTemporalState.Past, - isSamePlayingChannel = false, + isSamePlayingChannel = true, isCatchupSupported = true, + vodActionsEnabled = true, ) ).isEqualTo(EpgInteractionAction.PlayCatchup) } @@ -57,18 +243,39 @@ class EpgProgramActionsTest { temporalState = EpgTemporalState.Past, isSamePlayingChannel = false, isCatchupSupported = false, + vodActionsEnabled = true, ) ).isEqualTo(EpgInteractionAction.NoOp) } @Test - fun futureEpgCellDoesNothing() { + fun futureEpgCellDoesNothingUntilRecordingOrReminderExists() { assertThat( epgProgramInteractionAction( temporalState = EpgTemporalState.Future, isSamePlayingChannel = false, isCatchupSupported = false, + vodActionsEnabled = true, ) ).isEqualTo(EpgInteractionAction.NoOp) } + + @Test + fun vodLookupOnlyShowsDialogForConfidentMatch() { + assertThat(vodLookupResolution(hasVodMatch = true)) + .isEqualTo(EpgInteractionAction.ShowVodDialog) + assertThat(vodLookupResolution(hasVodMatch = false)) + .isEqualTo(EpgInteractionAction.PlayLiveFullscreen) + } + + @Test + fun invalidatedVodLookupCannotPublishItsResult() { + val guard = EpgVodLookupGuard() + val staleLookup = guard.beginLookup() + + guard.invalidate() + + assertThat(guard.isCurrent(staleLookup)).isFalse() + assertThat(guard.isCurrent(guard.beginLookup())).isTrue() + } } From 0dcf58b0a0f26612eea2a74e1a7d3d55cec3b732 Mon Sep 17 00:00:00 2001 From: Robbie <205481179+tormox@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:06:13 +0100 Subject: [PATCH 3/7] [verified] fix(iptv): reject stale EPG VOD lookup results Revalidate the selected programme against the latest live guide slot before publishing Watch Live or Stream Now actions. Suppress results after a programme ends, rolls over, or disappears from the current guide state. --- .../ui/screens/tv/live/EpgProgramActions.kt | 11 +++++++ .../tv/ui/screens/tv/live/LiveTvScreen.kt | 8 +++++ .../screens/tv/live/EpgProgramActionsTest.kt | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt index 37e4213a0..587d841b1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt @@ -2,6 +2,7 @@ package com.arflix.tv.ui.screens.tv.live import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.IptvProgram private val NON_VOD_EPG_CHANNEL_TERMS = setOf( "sport", @@ -101,6 +102,16 @@ internal class EpgVodLookupGuard { fun isCurrent(lookupGeneration: Int): Boolean = lookupGeneration == generation } +internal fun epgVodLookupCanPublish( + selectedProgram: IptvProgram, + currentProgram: IptvProgram?, + nowMillis: Long, +): Boolean = currentProgram != null && + currentProgram.title == selectedProgram.title && + currentProgram.startUtcMillis == selectedProgram.startUtcMillis && + currentProgram.endUtcMillis == selectedProgram.endUtcMillis && + currentProgram.isLive(nowMillis) + internal fun epgChannelAllowsVodSearch( channelName: String, channelGroup: String, 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 84a98a748..3cf7034fc 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 @@ -1209,6 +1209,7 @@ fun LiveTvScreen( } } } + val currentEffectiveGuideNowNext by rememberUpdatedState(effectiveGuideNowNext) val epgAnchorChannelId = epgPrefetchAnchorId ?: selectedDisplayChannelId @@ -1759,6 +1760,13 @@ fun LiveTvScreen( channelGroup = channel.source.group, ) if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + if ( + !epgVodLookupCanPublish( + selectedProgram = program, + currentProgram = currentEffectiveGuideNowNext[channel.id]?.now, + nowMillis = System.currentTimeMillis(), + ) + ) return@launch when (vodLookupResolution(match != null)) { EpgInteractionAction.ShowVodDialog -> { programActionVodMatch = match diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt index def4dfed2..52620e433 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt @@ -278,4 +278,34 @@ class EpgProgramActionsTest { assertThat(guard.isCurrent(staleLookup)).isFalse() assertThat(guard.isCurrent(guard.beginLookup())).isTrue() } + + @Test + fun vodLookupPublishesOnlyWhileSelectedProgrammeIsStillCurrentAndLive() { + val selected = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + val refreshedSameProgramme = selected.copy(description = "Updated description") + + assertThat(epgVodLookupCanPublish(selected, refreshedSameProgramme, nowMillis = 1_500L)).isTrue() + assertThat(epgVodLookupCanPublish(selected, refreshedSameProgramme, nowMillis = 2_000L)).isFalse() + } + + @Test + fun vodLookupCannotPublishAfterGuideRollsToNextProgramme() { + val selected = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + val next = IptvProgram( + title = "Next Movie", + startUtcMillis = 2_000L, + endUtcMillis = 3_000L, + ) + + assertThat(epgVodLookupCanPublish(selected, next, nowMillis = 2_100L)).isFalse() + assertThat(epgVodLookupCanPublish(selected, currentProgram = null, nowMillis = 1_500L)).isFalse() + } } From cfca6c4677d6ba0f267950a3711c39a0a938f9fa Mon Sep 17 00:00:00 2001 From: Robbie <205481179+tormox@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:47:58 +0100 Subject: [PATCH 4/7] [verified] fix(iptv): resolve guide actions across playlists Resolve current programme metadata through normalized EPG and channel identities when duplicate channels live on different playlists, while preserving direct-channel precedence and stale-result guards. --- .../ui/screens/tv/live/EpgProgramActions.kt | 29 ++++++++++++ .../tv/ui/screens/tv/live/LiveTvScreen.kt | 35 ++++++++++++--- .../screens/tv/live/EpgProgramActionsTest.kt | 44 +++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt index 587d841b1..2d0df9aa1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt @@ -3,6 +3,7 @@ package com.arflix.tv.ui.screens.tv.live import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.IptvProgram +import com.arflix.tv.data.model.IptvNowNext private val NON_VOD_EPG_CHANNEL_TERMS = setOf( "sport", @@ -112,6 +113,34 @@ internal fun epgVodLookupCanPublish( currentProgram.endUtcMillis == selectedProgram.endUtcMillis && currentProgram.isLive(nowMillis) +internal fun guideIdentityKeys(vararg values: String?): Set = values + .asSequence() + .mapNotNull { value -> + value + ?.trim() + ?.lowercase() + ?.filter { it.isLetterOrDigit() } + ?.takeIf { it.isNotBlank() } + } + .toSet() + +internal fun guideProgramForAction( + channelId: String, + guideIdentityKeys: Set, + guideByChannelId: Map, + guideIdentityKeysByChannelId: Map>, +): IptvProgram? { + guideByChannelId[channelId]?.now?.let { return it } + if (guideIdentityKeys.isEmpty()) return null + return guideIdentityKeysByChannelId.entries.firstNotNullOfOrNull { (candidateId, candidateKeys) -> + if (candidateId != channelId && candidateKeys.any(guideIdentityKeys::contains)) { + guideByChannelId[candidateId]?.now + } else { + null + } + } +} + internal fun epgChannelAllowsVodSearch( channelName: String, channelGroup: String, 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 3cf7034fc..fcba6b7ae 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 @@ -1209,7 +1209,32 @@ fun LiveTvScreen( } } } - val currentEffectiveGuideNowNext by rememberUpdatedState(effectiveGuideNowNext) + val actionGuideNowNext = remember(state.snapshot.nowNext, effectiveGuideNowNext) { + HashMap(state.snapshot.nowNext).apply { putAll(effectiveGuideNowNext) } + } + val guideIdentityKeysByChannelId = remember(visibleChannels) { + visibleChannels.associate { channel -> + channel.id to guideIdentityKeys( + channel.source.epgId, + channel.source.tvgName, + channel.source.rawTitle, + channel.name, + ) + } + } + val currentActionGuideNowNext by rememberUpdatedState(actionGuideNowNext) + val currentGuideIdentityKeysByChannelId by rememberUpdatedState(guideIdentityKeysByChannelId) + fun currentProgramForAction(channel: EnrichedChannel): IptvProgram? = guideProgramForAction( + channelId = channel.id, + guideIdentityKeys = guideIdentityKeys( + channel.source.epgId, + channel.source.tvgName, + channel.source.rawTitle, + channel.name, + ), + guideByChannelId = currentActionGuideNowNext, + guideIdentityKeysByChannelId = currentGuideIdentityKeysByChannelId, + ) val epgAnchorChannelId = epgPrefetchAnchorId ?: selectedDisplayChannelId @@ -1763,7 +1788,7 @@ fun LiveTvScreen( if ( !epgVodLookupCanPublish( selectedProgram = program, - currentProgram = currentEffectiveGuideNowNext[channel.id]?.now, + currentProgram = currentProgramForAction(channel), nowMillis = System.currentTimeMillis(), ) ) return@launch @@ -2584,8 +2609,7 @@ fun LiveTvScreen( gridFocused = focusZone == LiveTvFocusZone.EPG, onChannelSelect = { channel -> focusZone = LiveTvFocusZone.CHANNEL_LIST - val currentProgram = effectiveGuideNowNext[channel.id] - ?.now + val currentProgram = currentProgramForAction(channel) ?.takeIf { it.isLive(guideClockMillis) } selectChannel(channel, currentProgram) }, @@ -2724,8 +2748,7 @@ fun LiveTvScreen( compact = compactTouchLayout, gridFocused = focusZone == LiveTvFocusZone.CHANNEL_LIST || focusZone == LiveTvFocusZone.EPG, onChannelSelect = { channel -> - val currentProgram = effectiveGuideNowNext[channel.id] - ?.now + val currentProgram = currentProgramForAction(channel) ?.takeIf { it.isLive(guideClockMillis) } selectChannel(channel, currentProgram) }, diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt index 52620e433..8899cf848 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt @@ -1,6 +1,7 @@ package com.arflix.tv.ui.screens.tv.live import com.arflix.tv.data.model.IptvProgram +import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.google.common.truth.Truth.assertThat @@ -308,4 +309,47 @@ class EpgProgramActionsTest { assertThat(epgVodLookupCanPublish(selected, next, nowMillis = 2_100L)).isFalse() assertThat(epgVodLookupCanPublish(selected, currentProgram = null, nowMillis = 1_500L)).isFalse() } + + @Test + fun secondPlaylistReusesCurrentProgrammeFromMatchingGuideIdentity() { + val movie = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + val identities = mapOf( + "playlist-a:101" to guideIdentityKeys("movie-channel", "Movie Channel"), + "playlist-b:202" to guideIdentityKeys("other-epg-id", "Movie Channel"), + ) + + assertThat( + guideProgramForAction( + channelId = "playlist-b:202", + guideIdentityKeys = identities.getValue("playlist-b:202"), + guideByChannelId = mapOf("playlist-a:101" to IptvNowNext(now = movie)), + guideIdentityKeysByChannelId = identities, + ) + ).isEqualTo(movie) + } + + @Test + fun unrelatedPlaylistChannelCannotBorrowProgramme() { + val movie = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + + assertThat( + guideProgramForAction( + channelId = "playlist-b:news", + guideIdentityKeys = guideIdentityKeys("News Channel"), + guideByChannelId = mapOf("playlist-a:movie" to IptvNowNext(now = movie)), + guideIdentityKeysByChannelId = mapOf( + "playlist-a:movie" to guideIdentityKeys("Movie Channel"), + "playlist-b:news" to guideIdentityKeys("News Channel"), + ), + ) + ).isNull() + } } From 50bfbf543b17ea4141be3e159d5bbf8f03245340 Mon Sep 17 00:00:00 2001 From: Robbie <205481179+tormox@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:49:56 +0100 Subject: [PATCH 5/7] [verified] fix(iptv): resolve aliases from all loaded playlists Build guide identity aliases from the full enriched channel set rather than the currently visible provider filter, so duplicate channels on hidden playlists can supply current programme metadata. --- .../kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt | 4 ++-- 1 file changed, 2 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 fcba6b7ae..a7ca6b8c4 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 @@ -1212,8 +1212,8 @@ fun LiveTvScreen( val actionGuideNowNext = remember(state.snapshot.nowNext, effectiveGuideNowNext) { HashMap(state.snapshot.nowNext).apply { putAll(effectiveGuideNowNext) } } - val guideIdentityKeysByChannelId = remember(visibleChannels) { - visibleChannels.associate { channel -> + val guideIdentityKeysByChannelId = remember(enrichedState.value.all) { + enrichedState.value.all.associate { channel -> channel.id to guideIdentityKeys( channel.source.epgId, channel.source.tvgName, From ae58e4aaae77f00a200c260701c333025dc5b23e Mon Sep 17 00:00:00 2001 From: Robbie <205481179+tormox@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:23:31 +0100 Subject: [PATCH 6/7] [verified] fix(iptv): trigger Watch Live from displayed EPG data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use displayedCurrentProgram() to source the current programme directly from effectiveGuideNowNext — the same data the EPG grid is already showing — instead of the identity-key aliasing that failed across playlists. Add resolveVodWithEagerFetch() for second-click on channels without prefetched EPG data: triggers an immediate network fetch, then proceeds to VOD resolution. --- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 84 +++++++++++++++++-- 1 file changed, 79 insertions(+), 5 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 a7ca6b8c4..85895294e 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 @@ -1770,6 +1770,69 @@ fun LiveTvScreen( hudPokeSignal++ } + /** + * Get the current live programme for a channel, using the same guide data + * the EPG grid is already displaying. This is the direct source — no identity- + * key aliasing or cross-playlist matching. If the grid shows a programme, + * this returns it; if the grid shows "guide pending", this returns null. + */ + fun displayedCurrentProgram(channel: EnrichedChannel): IptvProgram? = + effectiveGuideNowNext[channel.id]?.now?.takeIf { it.isLive(guideClockMillis) } + + /** + * When the user second-clicks a playing channel but EPG data hasn't loaded + * yet (common when switching to a different playlist), trigger an immediate + * network EPG fetch for that channel, then attempt the VOD resolution. + * This prevents the feature from silently falling back to fullscreen. + */ + fun resolveVodWithEagerFetch(channel: EnrichedChannel) { + invalidateProgramActionLookup() + if (!epgChannelAllowsVodSearch(channel.name, channel.source.group)) { + playLiveFullscreen(channel) + return + } + val lookupGeneration = programActionLookupGuard.beginLookup() + programActionLookupJob[0] = coroutineScope.launch { + // Force a network EPG fetch for this specific channel via the public API. + // refreshCurrentChannelEpg does cache-first, then network, then merges. + viewModel.refreshCurrentChannelEpg(channel.id, forceNetworkForLargeList = true) + // Wait briefly for the refresh to land in state.snapshot.nowNext. + kotlinx.coroutines.delay(300L) + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + // After the fetch, try to get the current programme from the displayed guide. + val program = displayedCurrentProgram(channel) + ?: currentProgramForAction(channel)?.takeIf { it.isLive(guideClockMillis) } + if (program == null) { + // Still no EPG data — fall back to fullscreen, not a dead end. + playLiveFullscreen(channel) + return@launch + } + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + val match = viewModel.findEpgVodMatch( + title = program.title, + description = program.description, + channelName = channel.name, + channelGroup = channel.source.group, + ) + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + if (!epgVodLookupCanPublish( + selectedProgram = program, + currentProgram = displayedCurrentProgram(channel) + ?: currentProgramForAction(channel), + nowMillis = System.currentTimeMillis(), + ) + ) return@launch + when (vodLookupResolution(match != null)) { + EpgInteractionAction.ShowVodDialog -> { + programActionVodMatch = match + programActionDialog = ProgramActionData(channel, program) + } + EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + else -> Unit + } + } + } + fun resolveVodOrPlayFullscreen(channel: EnrichedChannel, program: IptvProgram) { invalidateProgramActionLookup() if (!epgChannelAllowsVodSearch(channel.name, channel.source.group)) { @@ -1815,7 +1878,20 @@ fun LiveTvScreen( EpgInteractionAction.PlayLiveMini -> playProgramInMini(channel, null) EpgInteractionAction.ResolveVodOrPlayFullscreen -> resolveVodOrPlayFullscreen(channel, currentProgram ?: return) - EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + EpgInteractionAction.PlayLiveFullscreen -> { + // Second click on the playing channel but no current EPG programme. + // Instead of going straight to fullscreen, try an eager EPG fetch + // so the Watch Live / Stream Now dialog can still appear. This is + // the key fix for channels on non-first playlists where EPG data + // hasn't been prefetched yet. + if (sameChannel && state.epgVodActionsEnabled && + epgChannelAllowsVodSearch(channel.name, channel.source.group) + ) { + resolveVodWithEagerFetch(channel) + } else { + playLiveFullscreen(channel) + } + } else -> Unit } } @@ -2609,8 +2685,7 @@ fun LiveTvScreen( gridFocused = focusZone == LiveTvFocusZone.EPG, onChannelSelect = { channel -> focusZone = LiveTvFocusZone.CHANNEL_LIST - val currentProgram = currentProgramForAction(channel) - ?.takeIf { it.isLive(guideClockMillis) } + val currentProgram = displayedCurrentProgram(channel) selectChannel(channel, currentProgram) }, onProgramSelect = { channel, program -> @@ -2748,8 +2823,7 @@ fun LiveTvScreen( compact = compactTouchLayout, gridFocused = focusZone == LiveTvFocusZone.CHANNEL_LIST || focusZone == LiveTvFocusZone.EPG, onChannelSelect = { channel -> - val currentProgram = currentProgramForAction(channel) - ?.takeIf { it.isLive(guideClockMillis) } + val currentProgram = displayedCurrentProgram(channel) selectChannel(channel, currentProgram) }, onProgramSelect = { channel, program -> From 16514e3df82a7aaa971aed15c91bc640bf73256e Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 25 Aug 2026 15:58:27 +0200 Subject: [PATCH 7/7] [verified] fix(iptv): bound guide streaming lookups --- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 6 +- .../ui/screens/tv/live/EpgProgramActions.kt | 19 +++ .../tv/ui/screens/tv/live/LiveTvScreen.kt | 143 +++++++++++------- .../screens/tv/live/EpgProgramActionsTest.kt | 47 ++++++ 4 files changed, 161 insertions(+), 54 deletions(-) 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 08eab40ac..3f9545513 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 @@ -10,6 +10,7 @@ import com.arflix.tv.data.model.IptvSnapshot import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.IptvConfig import com.arflix.tv.ui.screens.tv.live.LiveTvGuideSources +import com.arflix.tv.ui.screens.tv.live.runEpgLookupWithTimeout import com.arflix.tv.data.repository.IptvPlaybackTarget import com.arflix.tv.data.repository.IptvPlaybackUrlResolver import com.arflix.tv.data.repository.IptvRepository @@ -56,6 +57,7 @@ private const val RichCatchupRecentTarget = 6 private const val CatchupHistoryWindowMs = 48L * 60L * 60_000L private const val RichCatchupRefreshThrottleMs = 45_000L private const val CurrentChannelEpgRefreshThrottleMs = 12_000L +private const val EpgVodLookupTimeoutMs = 2_500L private const val LargeListCompleteGuideCoverageTarget = 0.75f private const val PlaybackEpgBackfillResumeDelayMs = 90_000L private const val LargeListCompleteEpgBackfillStartupDelayMs = 180_000L @@ -109,7 +111,9 @@ class TvViewModel @Inject constructor( ): com.arflix.tv.data.model.MediaItem? { if (!epgChannelAllowsVodSearch(channelName, channelGroup)) return null val results = try { - mediaRepository.search(title) + runEpgLookupWithTimeout(EpgVodLookupTimeoutMs) { + mediaRepository.search(title) + }.orEmpty() } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt index 2d0df9aa1..180ec5059 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt @@ -4,6 +4,10 @@ import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvNowNext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull private val NON_VOD_EPG_CHANNEL_TERMS = setOf( "sport", @@ -59,6 +63,21 @@ internal enum class EpgInteractionAction { NoOp, } +internal suspend fun runEpgLookupWithTimeout( + timeoutMillis: Long, + lookup: suspend () -> T, +): T? = withTimeoutOrNull(timeoutMillis) { lookup() } + +internal suspend fun awaitLiveEpgProgram( + programUpdates: Flow, + timeoutMillis: Long, + nowMillis: () -> Long = System::currentTimeMillis, +): IptvProgram? = withTimeoutOrNull(timeoutMillis) { + programUpdates + .filterNotNull() + .firstOrNull { program -> program.isLive(nowMillis()) } +} + internal fun channelRowInteractionAction( isSamePlayingChannel: Boolean, hasCurrentProgram: Boolean, 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 85895294e..54c8a0ebe 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 @@ -33,6 +33,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -137,6 +139,7 @@ private const val CatchupUrlAnchorGranularityMs = 60_000L private const val IptvPlaybackUserAgent = "VLC/3.0.20 LibVLC/3.0.20" private const val VisibleGuidePastWindowMs = 48L * 60L * 60_000L private const val VisibleGuideFutureWindowMs = 48L * 60L * 60_000L +private const val EpgGuideLookupTimeoutMs = 2_500L private fun digitForTvKeyCode(keyCode: Int): Int? = when (keyCode) { AndroidKeyEvent.KEYCODE_0, AndroidKeyEvent.KEYCODE_NUMPAD_0 -> 0 @@ -1486,6 +1489,7 @@ fun LiveTvScreen( // and, only after a confident movie/series match, Stream Now. var programActionDialog by remember { mutableStateOf(null) } var programActionVodMatch by remember { mutableStateOf(null) } + var programActionLookupInProgress by remember { mutableStateOf(false) } val programActionLookupGuard = remember { EpgVodLookupGuard() } val programActionLookupJob = remember { arrayOf(null) } fun invalidateProgramActionLookup() { @@ -1494,6 +1498,7 @@ fun LiveTvScreen( programActionLookupGuard.invalidate() programActionDialog = null programActionVodMatch = null + programActionLookupInProgress = false } LaunchedEffect( selectedCategoryId, @@ -1792,43 +1797,51 @@ fun LiveTvScreen( return } val lookupGeneration = programActionLookupGuard.beginLookup() + programActionLookupInProgress = true programActionLookupJob[0] = coroutineScope.launch { - // Force a network EPG fetch for this specific channel via the public API. - // refreshCurrentChannelEpg does cache-first, then network, then merges. - viewModel.refreshCurrentChannelEpg(channel.id, forceNetworkForLargeList = true) - // Wait briefly for the refresh to land in state.snapshot.nowNext. - kotlinx.coroutines.delay(300L) - if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch - // After the fetch, try to get the current programme from the displayed guide. - val program = displayedCurrentProgram(channel) - ?: currentProgramForAction(channel)?.takeIf { it.isLive(guideClockMillis) } - if (program == null) { - // Still no EPG data — fall back to fullscreen, not a dead end. - playLiveFullscreen(channel) - return@launch - } - if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch - val match = viewModel.findEpgVodMatch( - title = program.title, - description = program.description, - channelName = channel.name, - channelGroup = channel.source.group, - ) - if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch - if (!epgVodLookupCanPublish( - selectedProgram = program, - currentProgram = displayedCurrentProgram(channel) - ?: currentProgramForAction(channel), - nowMillis = System.currentTimeMillis(), + try { + viewModel.refreshCurrentChannelEpg(channel.id, forceNetworkForLargeList = true) + val program = displayedCurrentProgram(channel) + ?: currentProgramForAction(channel)?.takeIf { it.isLive(guideClockMillis) } + ?: awaitLiveEpgProgram( + programUpdates = viewModel.uiState.map { uiState -> + uiState.snapshot.nowNext[channel.id]?.now + }, + timeoutMillis = EpgGuideLookupTimeoutMs, + ) + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + if (program == null) { + playLiveFullscreen(channel) + return@launch + } + val match = viewModel.findEpgVodMatch( + title = program.title, + description = program.description, + channelName = channel.name, + channelGroup = channel.source.group, ) - ) return@launch - when (vodLookupResolution(match != null)) { - EpgInteractionAction.ShowVodDialog -> { - programActionVodMatch = match - programActionDialog = ProgramActionData(channel, program) + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + if (!epgVodLookupCanPublish( + selectedProgram = program, + currentProgram = viewModel.uiState.value.snapshot.nowNext[channel.id]?.now + ?: displayedCurrentProgram(channel) + ?: currentProgramForAction(channel), + nowMillis = System.currentTimeMillis(), + ) + ) return@launch + when (vodLookupResolution(match != null)) { + EpgInteractionAction.ShowVodDialog -> { + programActionVodMatch = match + programActionDialog = ProgramActionData(channel, program) + } + EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + else -> Unit + } + } finally { + if (programActionLookupGuard.isCurrent(lookupGeneration)) { + programActionLookupInProgress = false + programActionLookupJob[0] = null } - EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) - else -> Unit } } } @@ -1840,28 +1853,37 @@ fun LiveTvScreen( return } val lookupGeneration = programActionLookupGuard.beginLookup() + programActionLookupInProgress = true programActionLookupJob[0] = coroutineScope.launch { - val match = viewModel.findEpgVodMatch( - title = program.title, - description = program.description, - channelName = channel.name, - channelGroup = channel.source.group, - ) - if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch - if ( - !epgVodLookupCanPublish( - selectedProgram = program, - currentProgram = currentProgramForAction(channel), - nowMillis = System.currentTimeMillis(), + try { + val match = viewModel.findEpgVodMatch( + title = program.title, + description = program.description, + channelName = channel.name, + channelGroup = channel.source.group, ) - ) return@launch - when (vodLookupResolution(match != null)) { - EpgInteractionAction.ShowVodDialog -> { - programActionVodMatch = match - programActionDialog = ProgramActionData(channel, program) + if (!programActionLookupGuard.isCurrent(lookupGeneration)) return@launch + if ( + !epgVodLookupCanPublish( + selectedProgram = program, + currentProgram = viewModel.uiState.value.snapshot.nowNext[channel.id]?.now + ?: currentProgramForAction(channel), + nowMillis = System.currentTimeMillis(), + ) + ) return@launch + when (vodLookupResolution(match != null)) { + EpgInteractionAction.ShowVodDialog -> { + programActionVodMatch = match + programActionDialog = ProgramActionData(channel, program) + } + EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) + else -> Unit + } + } finally { + if (programActionLookupGuard.isCurrent(lookupGeneration)) { + programActionLookupInProgress = false + programActionLookupJob[0] = null } - EpgInteractionAction.PlayLiveFullscreen -> playLiveFullscreen(channel) - else -> Unit } } } @@ -3262,6 +3284,21 @@ fun LiveTvScreen( .padding(bottom = if (isFullScreen) 72.dp else 24.dp), ) + if (programActionLookupInProgress) { + Box( + modifier = Modifier + .align(Alignment.Center) + .background(Color(0xE61A1A1A), RoundedCornerShape(8.dp)) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = Pink, + strokeWidth = 3.dp, + ) + } + } + val actionData = programActionDialog if (actionData != null) { val program = actionData.program diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt index 8899cf848..65a231d22 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt @@ -5,10 +5,57 @@ import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest import org.junit.Test class EpgProgramActionsTest { + @Test + fun stalledVodLookupStopsAtDeadline() = runTest { + val result = runEpgLookupWithTimeout(timeoutMillis = 100L) { + delay(1_000L) + "late result" + } + + assertThat(result).isNull() + } + + @Test + fun eagerGuideLookupWaitsForLiveProgrammeUpdate() = runTest { + val updates = MutableStateFlow(null) + val expected = IptvProgram( + title = "Live Movie", + startUtcMillis = 1_000L, + endUtcMillis = 2_000L, + ) + backgroundScope.launch { + delay(250L) + updates.value = expected + } + + val result = awaitLiveEpgProgram( + programUpdates = updates, + timeoutMillis = 1_000L, + nowMillis = { 1_500L }, + ) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun eagerGuideLookupStopsWhenNoProgrammeArrives() = runTest { + val result = awaitLiveEpgProgram( + programUpdates = MutableStateFlow(null), + timeoutMillis = 100L, + nowMillis = { 1_500L }, + ) + + assertThat(result).isNull() + } + @Test fun watchLiveClearsSelectedProgramSoPlaybackUsesLiveStream() { val selectedProgram = IptvProgram(