Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" -> {
Expand Down Expand Up @@ -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 = ""
) {
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -186,6 +187,7 @@ data class SettingsUiState(
val iptvAvailableGroups: List<String> = emptyList(),
val iptvHiddenGroups: List<String> = emptyList(),
val iptvGroupOrder: List<String> = emptyList(),
val epgVodActionsEnabled: Boolean = true,
// App updates
val isSelfUpdateSupported: Boolean = true,
val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down
48 changes: 47 additions & 1 deletion app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -72,6 +80,7 @@ data class TvUiState(
val epgLoadingChannelIds: Set<String> = emptySet(),
val epgAttemptedChannelIds: Set<String> = emptySet(),
val epgBackfillInProgress: Boolean = false,
val epgVodActionsEnabled: Boolean = true,
) {
val isConfigured: Boolean get() =
config.m3uUrl.isNotBlank() ||
Expand All @@ -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<TvUiState> = _uiState.asStateFlow()
private var refreshJob: Job? = null
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 20 additions & 8 deletions app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading