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..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,12 +10,17 @@ 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 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 +29,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 @@ -50,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 @@ -72,6 +80,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 +94,34 @@ 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 { + runEpgLookupWithTimeout(EpgVodLookupTimeoutMs) { + mediaRepository.search(title) + }.orEmpty() + } 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 +194,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 +259,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 ab3ee84c2..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) { @@ -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..180ec5059 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActions.kt @@ -0,0 +1,215 @@ +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 +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", + "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, + Future, +} + +internal enum class EpgInteractionAction { + PlayLiveMini, + PlayLiveFullscreen, + PlayCatchup, + ResolveVodOrPlayFullscreen, + ShowVodDialog, + 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, + 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.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 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 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, +): 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/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..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 @@ -80,9 +82,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 @@ -133,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 @@ -407,6 +414,7 @@ fun LiveTvScreen( onNavigateToWatchlist: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToIptvSettings: (() -> Unit)? = null, + onNavigateToDetails: (com.arflix.tv.data.model.MediaType, Int) -> Unit = { _, _ -> }, onSwitchProfile: () -> Unit = {}, onBack: () -> Unit = {}, ) { @@ -1204,6 +1212,32 @@ fun LiveTvScreen( } } } + val actionGuideNowNext = remember(state.snapshot.nowNext, effectiveGuideNowNext) { + HashMap(state.snapshot.nowNext).apply { putAll(effectiveGuideNowNext) } + } + val guideIdentityKeysByChannelId = remember(enrichedState.value.all) { + enrichedState.value.all.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 @@ -1449,12 +1483,44 @@ 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) } + var programActionLookupInProgress by remember { mutableStateOf(false) } + val programActionLookupGuard = remember { EpgVodLookupGuard() } + val programActionLookupJob = remember { arrayOf(null) } + fun invalidateProgramActionLookup() { + programActionLookupJob[0]?.cancel() + programActionLookupJob[0] = null + programActionLookupGuard.invalidate() + programActionDialog = null + programActionVodMatch = null + programActionLookupInProgress = false + } + 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 +1531,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,28 +1709,6 @@ fun LiveTvScreen( } } - 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 - } - } - fun openVariantPicker(channel: EnrichedChannel) { noteGuideUserNavigation() if (variantCountFor(channel, variantGroups) > 1) { @@ -1709,6 +1752,196 @@ 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++ + } + + /** + * 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() + programActionLookupInProgress = true + programActionLookupJob[0] = coroutineScope.launch { + 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, + ) + 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 + } + } + } + } + + fun resolveVodOrPlayFullscreen(channel: EnrichedChannel, program: IptvProgram) { + invalidateProgramActionLookup() + if (!epgChannelAllowsVodSearch(channel.name, channel.source.group)) { + playLiveFullscreen(channel) + return + } + val lookupGeneration = programActionLookupGuard.beginLookup() + programActionLookupInProgress = true + programActionLookupJob[0] = coroutineScope.launch { + try { + 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 = 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 + } + } + } + } + + 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 -> { + // 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 + } + } + + 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) { @@ -2472,11 +2705,14 @@ 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 = displayedCurrentProgram(channel) + selectChannel(channel, currentProgram) + }, + onProgramSelect = { channel, program -> + program?.let { selectEpgProgram(channel, it) } }, - onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, onChannelFocused = { channel -> commitFocusedChannel(channel) }, onChannelFavoriteToggle = { id -> viewModel.toggleFavoriteChannel(id) }, favorites = favSet, @@ -2608,8 +2844,13 @@ fun LiveTvScreen( scrollResetKey = "$selectedProviderId|$selectedCategoryId|$filteredChannelsWindowKey|$normalizedGuideStart", compact = compactTouchLayout, gridFocused = focusZone == LiveTvFocusZone.CHANNEL_LIST || focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel, _ -> selectChannel(channel) }, - onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, + onChannelSelect = { channel -> + val currentProgram = displayedCurrentProgram(channel) + selectChannel(channel, currentProgram) + }, + onProgramSelect = { channel, program -> + program?.let { selectEpgProgram(channel, it) } + }, onChannelFocused = { channel -> commitFocusedChannel(channel) }, onChannelFavoriteToggle = { id -> viewModel.toggleFavoriteChannel(id) }, favorites = favSet, @@ -2890,6 +3131,7 @@ fun LiveTvScreen( channel = guideChannel ?: playingChannel, guide = guideForChannel(guideChannel ?: playingChannel), selectedProgram = playingCatchupProgram, + clockTickMillis = guideClockMillis, isTouchDevice = isTouchDevice, onDismiss = { fullscreenGuideOpen = false @@ -2903,7 +3145,23 @@ fun LiveTvScreen( onProgramSelect = { program -> val target = guideChannel ?: playingChannel guideOpenedFromQuickZap = false - playProgramInFullscreen(program, target) + if (program != null && target != null) { + 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 = { fullscreenGuideOpen = false @@ -3025,6 +3283,90 @@ fun LiveTvScreen( .align(Alignment.BottomCenter) .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 + 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, + ) + } } } @@ -3157,3 +3499,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 new file mode 100644 index 000000000..65a231d22 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/EpgProgramActionsTest.kt @@ -0,0 +1,402 @@ +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 +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( + 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, + hasCurrentProgram = true, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveMini) + } + + @Test + 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 + fun liveEpgCellFirstClickTunesLiveMiniPlayer() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Live, + isSamePlayingChannel = false, + isCatchupSupported = false, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.PlayLiveMini) + } + + @Test + fun liveEpgCellSecondClickResolvesVod() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Live, + isSamePlayingChannel = true, + isCatchupSupported = false, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.ResolveVodOrPlayFullscreen) + } + + @Test + fun pastEpgCellWithCatchupStartsCatchup() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Past, + isSamePlayingChannel = true, + isCatchupSupported = true, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.PlayCatchup) + } + + @Test + fun pastEpgCellWithoutCatchupDoesNothing() { + assertThat( + epgProgramInteractionAction( + temporalState = EpgTemporalState.Past, + isSamePlayingChannel = false, + isCatchupSupported = false, + vodActionsEnabled = true, + ) + ).isEqualTo(EpgInteractionAction.NoOp) + } + + @Test + 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() + } + + @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() + } + + @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() + } +}