Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Boolean> {
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<String> {
val raw = prefs[hiddenPreinstalledKey(profileId)]
if (raw.isNullOrBlank()) return emptySet()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]) +
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6184,6 +6184,7 @@ private fun IptvSettings(
progressPercent: Int,
focusedIndex: Int,
focusedActionIndex: Int,
iptvOnlyMode: Boolean,
onConfigure: () -> Unit,
onEditPlaylist: (Int) -> Unit,
onTogglePlaylist: (Int) -> Unit,
Expand All @@ -6193,13 +6194,29 @@ private fun IptvSettings(
onRefresh: () -> Unit,
onDelete: () -> Unit,
onManageCategories: (String) -> Unit = {}
onToggleIptvOnlyMode: (Boolean) -> Unit
) {
val isMobile = LocalDeviceType.current.isTouchDevice()
var selectionMode by remember { mutableStateOf(false) }
var selectedIndices by remember { mutableStateOf(setOf<Int>()) }

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),
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -6357,7 +6429,6 @@ private fun IptvSettings(
}
}
}
}

@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IptvPlaylistEntry> = emptyList(),
Expand Down Expand Up @@ -384,6 +385,7 @@ class SettingsViewModel @Inject constructor(
observeAuthState()
observeIptvConfig()
observeIptvGroupPrefs()
observeIptvMode()
initializeCatalogs()
observeCatalogs()
initializeUpdaterState()
Expand All @@ -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()
Expand Down
Loading