diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index 35aa2abbe..716961d37 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.booleanPreferencesKey import com.arflix.tv.data.api.TraktApi import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.AddonCatalog @@ -79,6 +80,26 @@ class CatalogRepository @Inject constructor( private val listType = TypeToken.getParameterized(List::class.java, CatalogConfig::class.java).type private val hiddenListType = TypeToken.getParameterized(List::class.java, String::class.java).type +// Key for the IPTV Toggle + private val iptvOnlyModeKey = booleanPreferencesKey("iptv_only_mode_v1") + + // Function for the UI to check whether the mode is enabled + fun isIptvOnlyMode(): Flow { + return context.settingsDataStore.data.map { prefs -> + prefs[iptvOnlyModeKey] ?: false + }.distinctUntilChanged() + } + + // Function for the UI to change the mode status + suspend fun setIptvOnlyMode(enabled: Boolean) { + context.settingsDataStore.edit { prefs -> + prefs[iptvOnlyModeKey] = enabled + } + // We force the catalogs to reload on the home screen + val activeProfile = activeProfileId() + invalidationBus.markDirty(CloudSyncScope.CATALOGS, activeProfile, "toggled iptv mode") + } + private fun decodeHiddenPreinstalled(profileId: String, prefs: Preferences): Set { val raw = prefs[hiddenPreinstalledKey(profileId)] if (raw.isNullOrBlank()) return emptySet() @@ -1371,6 +1392,9 @@ class CatalogRepository @Inject constructor( val hiddenPreinstalled = decodeHiddenPreinstalled(profileId, prefs) val hiddenAddon = decodeHiddenAddon(profileId, prefs) val hiddenHomeServer = decodeHiddenHomeServer(profileId, prefs) + + // 1. We read the switch's status + val isIptvOnly = prefs[iptvOnlyModeKey] ?: false fun CatalogConfig.isHidden(): Boolean { if (isPreinstalledCatalog(this) && id in hiddenPreinstalled) return true @@ -1379,8 +1403,7 @@ class CatalogRepository @Inject constructor( return false } - // Strict profile-first lookup to avoid leaking or prioritizing - // catalogs from other profiles. + // Strict profile-first lookup val primary = parseCatalogsJson(prefs[catalogsKey(profileId)]) if (primary.isNotEmpty()) { val base = primary @@ -1390,7 +1413,6 @@ class CatalogRepository @Inject constructor( .toMutableList() val existingKeys = base.map { "${it.id}|${it.sourceUrl.orEmpty()}" }.toMutableSet() - // Legacy recovery applies only to the default profile to avoid cross-profile leakage. if (profileId == "default") { val legacyCustom = ( parseCatalogsJson(prefs[legacyDefaultKey]) + @@ -1408,24 +1430,34 @@ class CatalogRepository @Inject constructor( } } } - return base + + // 2. We apply the filter here + return if (isIptvOnly) { + base.filter { it.sourceType == CatalogSourceType.HOME_SERVER } + } else { + base + } } - // Legacy fallback keys (pre profile-scoping). + // Legacy fallback keys val legacyDefault = parseCatalogsJson(prefs[legacyDefaultKey]) if (legacyDefault.isNotEmpty()) { - return legacyDefault + val result = legacyDefault .distinctBy { it.id } .map { refreshBundledPreinstalledCatalog(it) } .filterNot { it.isHidden() } + + return if (isIptvOnly) result.filter { it.sourceType == CatalogSourceType.HOME_SERVER } else result } val legacyGlobal = parseCatalogsJson(prefs[legacyGlobalKey]) if (legacyGlobal.isNotEmpty()) { - return legacyGlobal + val result = legacyGlobal .distinctBy { it.id } .map { refreshBundledPreinstalledCatalog(it) } .filterNot { it.isHidden() } + + return if (isIptvOnly) result.filter { it.sourceType == CatalogSourceType.HOME_SERVER } else result } return emptyList() 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 6e6197936..626fdc763 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 @@ -6184,6 +6184,7 @@ private fun IptvSettings( progressPercent: Int, focusedIndex: Int, focusedActionIndex: Int, + iptvOnlyMode: Boolean, onConfigure: () -> Unit, onEditPlaylist: (Int) -> Unit, onTogglePlaylist: (Int) -> Unit, @@ -6193,6 +6194,7 @@ private fun IptvSettings( onRefresh: () -> Unit, onDelete: () -> Unit, onManageCategories: (String) -> Unit = {} + onToggleIptvOnlyMode: (Boolean) -> Unit ) { val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } @@ -6200,6 +6202,21 @@ private fun IptvSettings( if (isMobile) { Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + MobileSettingsCategory(title = "Content Mode") { + MobileSettingsRow( + icon = Icons.Default.Tv, + title = "Exclusive IPTV Mode", + subtitle = "Hide external content and show only IPTV", + value = "", + isFocused = false, + showDivider = false, + onClick = { onToggleIptvOnlyMode(!iptvOnlyMode) } + ) { + Box(modifier = Modifier.width(44.dp).height(24.dp).background(color = if (iptvOnlyMode) SuccessGreen else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(13.dp)).clickable { onToggleIptvOnlyMode(!iptvOnlyMode) }.padding(3.dp), contentAlignment = if (iptvOnlyMode) Alignment.CenterEnd else Alignment.CenterStart) { + Box(modifier = Modifier.size(18.dp).background(color = Color.White, shape = RoundedCornerShape(10.dp))) + } + } + } if (selectionMode) { Row( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), @@ -6288,13 +6305,45 @@ private fun IptvSettings( } else { // TV UI Column { - SettingsRow(icon = Icons.Default.LiveTv, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_iptv_lists_hint) else stringResource(R.string.settings_create_another_iptv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onConfigure, modifier = Modifier.settingsFocusSlot(0)) + // 0. Add List Button (Remains at index 0) + SettingsRow( + icon = Icons.Default.LiveTv, + title = stringResource(R.string.add_playlist), + subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_iptv_lists_hint) else stringResource(R.string.settings_create_another_iptv), + value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), + isFocused = focusedIndex == 0, + onClick = onConfigure, + modifier = Modifier.settingsFocusSlot(0) + ) Spacer(modifier = Modifier.height(16.dp)) + + // 1. NEW ROW: Exclusive IPTV Mode (Occupies index 1) + SettingsRow( + icon = Icons.Default.Tv, + title = "Exclusive IPTV Mode", + subtitle = "Hide external content and show only IPTV", + value = if (iptvOnlyMode) "Enabled" else "Disabled", + isFocused = focusedIndex == 1, + onClick = { onToggleIptvOnlyMode(!iptvOnlyMode) }, + modifier = Modifier.settingsFocusSlot(1) + ) + Spacer(modifier = Modifier.height(16.dp)) + + // 2. Existing playlists (We increment their index by 1 by adding 2 to rowIndex) playlists.forEachIndexed { index, playlist -> - val rowIndex = index + 1 + val rowIndex = index + 2 val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } val focusRingColor = resolveAccentColor(fallback = Pink) - Row(modifier = Modifier.settingsFocusSlot(rowIndex).fillMaxWidth().background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onEditPlaylist(index) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + + Row( + modifier = Modifier.settingsFocusSlot(rowIndex) + .fillMaxWidth() + .background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)) + .border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)) + .clickable { onEditPlaylist(index) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { Column(modifier = Modifier.weight(1f)) { Text(playlist.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == rowIndex) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) @@ -6340,10 +6389,33 @@ private fun IptvSettings( Spacer(modifier = Modifier.height(10.dp)) } Spacer(modifier = Modifier.height(6.dp)) + + // 3. Action buttons (Refresh and Clear), which also adjust their slot indices by adding playlists.size 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 + 1, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 1)) + + val refreshIndex = playlists.size + 2 + val deleteIndex = 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 == refreshIndex, + onClick = onRefresh, + modifier = Modifier.settingsFocusSlot(refreshIndex) + ) 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 + 2, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 2)) + 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 == deleteIndex, + onClick = onDelete, + modifier = Modifier.settingsFocusSlot(deleteIndex) + ) + 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) @@ -6357,7 +6429,6 @@ private fun IptvSettings( } } } -} @OptIn(ExperimentalTvMaterial3Api::class) @Composable 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 69386dbec..e735ffd1a 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 @@ -146,6 +146,7 @@ data class SettingsUiState( val syncedMovies: Int = 0, val syncedEpisodes: Int = 0, // IPTV + val iptvOnlyMode: Boolean = false, val iptvM3uUrl: String = "", val iptvEpgUrl: String = "", val iptvPlaylists: List = emptyList(), @@ -384,6 +385,7 @@ class SettingsViewModel @Inject constructor( observeAuthState() observeIptvConfig() observeIptvGroupPrefs() + observeIptvMode() initializeCatalogs() observeCatalogs() initializeUpdaterState() @@ -405,6 +407,21 @@ class SettingsViewModel @Inject constructor( } } + private fun observeIptvMode() { + viewModelScope.launch { + catalogRepository.isIptvOnlyMode().collect { enabled -> + _uiState.value = _uiState.value.copy(iptvOnlyMode = enabled) + } + } + } + + fun setIptvOnlyMode(enabled: Boolean) { + viewModelScope.launch { + catalogRepository.setIptvOnlyMode(enabled) + syncLocalStateToCloud(silent = true) // We sync with the cloud in case you use multiple devices + } + } + private fun initializeUpdaterState() { _uiState.value = _uiState.value.copy( isSelfUpdateSupported = appUpdateRepository.supportsSelfUpdate()