diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt index f056a5b9..75d18ffd 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt @@ -57,3 +57,19 @@ data class IptvSnapshot( val epgWarning: String? = null, val loadedAt: Instant = Instant.now() ) + +/** + * Lightweight helper to handle playlistId|groupName composite keys without + * unnecessary string allocations in UI loops. + */ +@JvmInline +value class PlaylistGroupKey(val key: String) { + val playlistId: String get() = key.substringBefore('|') + val groupName: String get() = key.substringAfter('|', missingDelimiterValue = key) + + companion object { + fun build(playlistId: String, groupName: String): String { + return "$playlistId|$groupName" + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index 8ec0538b..4cbc421c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -195,8 +195,6 @@ class CloudSyncRepository @Inject constructor( val dnsProvider: String = "system", val subtitleUsageJson: String = "", val subtitleSettingsUpdatedAt: Long = 0L, - val iptvHiddenGroups: String = "", - val iptvGroupOrder: String = "", val secondarySubtitle: String = "Off", val filterSubtitlesByLanguage: Boolean = true, val homeServerConnectionJson: String? = null, @@ -362,8 +360,6 @@ class CloudSyncRepository @Inject constructor( subtitleOffset = prefs[subtitleOffsetKeyFor(profile.id)] ?: "Bottom", subtitleStyle = prefs[subtitleStyleKeyFor(profile.id)] ?: "Bold", subtitleStylized = prefs[subtitleStylizedKeyFor(profile.id)] ?: true, - iptvHiddenGroups = prefs[iptvHiddenGroupsKeyFor(profile.id)] ?: "", - iptvGroupOrder = prefs[iptvGroupOrderKeyFor(profile.id)] ?: "", secondarySubtitle = prefs[secondarySubtitleKeyFor(profile.id)] ?: "Off", filterSubtitlesByLanguage = prefs[filterSubtitlesByLanguageKeyFor(profile.id)] ?: true, homeServerConnectionJson = homeServerRepository.exportCloudConnectionsJsonForProfile(profile.id), @@ -811,8 +807,6 @@ class CloudSyncRepository @Inject constructor( prefs[subtitleOffsetKeyFor(profileId)] = state.subtitleOffset prefs[subtitleStyleKeyFor(profileId)] = state.subtitleStyle prefs[subtitleStylizedKeyFor(profileId)] = state.subtitleStylized - if (state.iptvHiddenGroups.isNotBlank()) prefs[iptvHiddenGroupsKeyFor(profileId)] = state.iptvHiddenGroups - if (state.iptvGroupOrder.isNotBlank()) prefs[iptvGroupOrderKeyFor(profileId)] = state.iptvGroupOrder prefs[secondarySubtitleKeyFor(profileId)] = state.secondarySubtitle.ifBlank { "Off" } prefs[filterSubtitlesByLanguageKeyFor(profileId)] = state.filterSubtitlesByLanguage state.homeServerConnectionJson?.let { homeServerConnectionJson -> diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 5fac43a6..2d3bcece 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.Deferred +import com.arflix.tv.data.model.PlaylistGroupKey import kotlinx.coroutines.launch import com.arflix.tv.network.OkHttpProvider import okhttp3.OkHttpClient @@ -830,9 +831,9 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "toggle favorite group") } - suspend fun toggleHiddenGroup(groupName: String) { - val trimmed = groupName.trim() - if (trimmed.isEmpty()) return + suspend fun toggleHiddenGroup(playlistId: String, groupName: String) { + val trimmed = PlaylistGroupKey.build(playlistId, groupName.trim()) + if (groupName.trim().isEmpty()) return context.settingsDataStore.edit { prefs -> val existing = decodeHiddenGroups(prefs).toMutableList() if (existing.contains(trimmed)) { @@ -845,11 +846,12 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "toggle hidden group") } - suspend fun moveGroupUp(groupName: String, currentGroups: List = emptyList()) { - val target = groupName.trim() - if (target.isEmpty()) return + suspend fun moveGroupUp(playlistId: String, groupName: String, currentGroups: List = emptyList()) { + val target = PlaylistGroupKey.build(playlistId, groupName.trim()) + if (groupName.trim().isEmpty()) return + val currentKeys = currentGroups.map { PlaylistGroupKey.build(playlistId, it.trim()) } context.settingsDataStore.edit { prefs -> - val order = mergedGroupOrder(decodeGroupOrder(prefs), currentGroups) + val order = mergedGroupOrder(decodeGroupOrder(prefs), currentKeys) if (order.isEmpty()) return@edit val idx = order.indexOf(target) if (idx > 0) { order.removeAt(idx); order.add(idx - 1, target) } @@ -858,13 +860,14 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "move group up") } - suspend fun moveGroupToTop(groupName: String, currentGroups: List = emptyList()) { - val target = groupName.trim() - if (target.isEmpty()) return + suspend fun moveGroupToTop(playlistId: String, groupName: String, currentGroups: List = emptyList()) { + val target = PlaylistGroupKey.build(playlistId, groupName.trim()) + if (groupName.trim().isEmpty()) return + val currentKeys = currentGroups.map { PlaylistGroupKey.build(playlistId, it.trim()) } context.settingsDataStore.edit { prefs -> - val order = mergedGroupOrder(decodeGroupOrder(prefs), currentGroups) + val order = mergedGroupOrder(decodeGroupOrder(prefs), currentKeys) if (order.isEmpty()) return@edit - if (target !in order && currentGroups.map { it.trim() }.contains(target)) order.add(target) + if (target !in order && currentKeys.contains(target)) order.add(target) order.remove(target) order.add(0, target) prefs[groupOrderKey()] = gson.toJson(order) @@ -872,11 +875,12 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "move group to top") } - suspend fun moveGroupDown(groupName: String, currentGroups: List = emptyList()) { - val target = groupName.trim() - if (target.isEmpty()) return + suspend fun moveGroupDown(playlistId: String, groupName: String, currentGroups: List = emptyList()) { + val target = PlaylistGroupKey.build(playlistId, groupName.trim()) + if (groupName.trim().isEmpty()) return + val currentKeys = currentGroups.map { PlaylistGroupKey.build(playlistId, it.trim()) } context.settingsDataStore.edit { prefs -> - val order = mergedGroupOrder(decodeGroupOrder(prefs), currentGroups) + val order = mergedGroupOrder(decodeGroupOrder(prefs), currentKeys) if (order.isEmpty()) return@edit val idx = order.indexOf(target) if (idx >= 0 && idx < order.size - 1) { order.removeAt(idx); order.add(idx + 1, target) } @@ -885,6 +889,15 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "move group down") } + suspend fun resetGroupOrder(playlistId: String) { + context.settingsDataStore.edit { prefs -> + val existing = decodeGroupOrder(prefs).toMutableList() + existing.removeAll { PlaylistGroupKey(it).playlistId == playlistId } + prefs[groupOrderKey()] = gson.toJson(existing) + } + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "reset group order") + } + suspend fun toggleFavoriteChannel(channelId: String) { val trimmed = channelId.trim() if (trimmed.isEmpty()) return @@ -1746,7 +1759,19 @@ class IptvRepository @Inject constructor( if (raw.isBlank()) return emptyList() return runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type - gson.fromJson>(raw, type)?.map { it.trim() }?.filter { it.isNotBlank() }?.distinct() ?: emptyList() + val list = gson.fromJson>(raw, type)?.map { it.trim() }?.filter { it.isNotBlank() }?.distinct() ?: emptyList() + if (list.any { !it.contains('|') }) { + val playlistsRaw = prefs[playlistsKey()].orEmpty() + if (playlistsRaw.isBlank()) { + list + } else { + val playlists = decodePlaylists(playlistsRaw) + val firstId = playlists.firstOrNull()?.id + if (firstId != null) { + list.map { if (it.contains('|')) it else "$firstId|$it" }.distinct() + } else list + } + } else list }.getOrDefault(emptyList()) } @@ -1755,7 +1780,19 @@ class IptvRepository @Inject constructor( if (raw.isBlank()) return emptyList() return runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type - gson.fromJson>(raw, type)?.map { it.trim() }?.filter { it.isNotBlank() }?.distinct() ?: emptyList() + val list = gson.fromJson>(raw, type)?.map { it.trim() }?.filter { it.isNotBlank() }?.distinct() ?: emptyList() + if (list.any { !it.contains('|') }) { + val playlistsRaw = prefs[playlistsKey()].orEmpty() + if (playlistsRaw.isBlank()) { + list + } else { + val playlists = decodePlaylists(playlistsRaw) + val firstId = playlists.firstOrNull()?.id + if (firstId != null) { + list.map { if (it.contains('|')) it else "$firstId|$it" }.distinct() + } else list + } + } else list }.getOrDefault(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 2f5eb824..71d912e4 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 @@ -40,6 +40,8 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Refresh import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -302,8 +304,10 @@ fun SettingsScreen( var addonActionIndex by remember { mutableIntStateOf(0) } // Sub-focus for catalog rows: 0 = edit, 1 = up, 2 = down, 3 = layout, 4 = delete var catalogActionIndex by remember { mutableIntStateOf(0) } - // Sub-focus for IPTV rows: 0 = enable, 1 = edit, 2 = up, 3 = down, 4 = delete + // Sub-focus for IPTV playlist rows: 0 = categories, 1 = enable, 2 = edit, 3 = up, 4 = down, 5 = delete + // For IPTV category rows: 0 = visibility, 1 = up, 2 = down var iptvActionIndex by remember { mutableIntStateOf(0) } + var showIptvCategoriesSettings by remember { mutableStateOf(false) } // Rename dialog state var showCatalogRename by remember { mutableStateOf(false) } var renameCatalogId by remember { mutableStateOf("") } @@ -373,7 +377,11 @@ fun SettingsScreen( val sectionMaxIndex: (String) -> Int = { section -> when (section) { in tvGeneralSectionIds -> (tvGeneralRowsForSection(section).size - 1).coerceAtLeast(0) - "iptv" -> 2 + uiState.iptvPlaylists.size // Add + rows + refresh + clear + "iptv" -> if (showIptvCategoriesSettings) { + uiState.iptvAvailableGroups.size // Reset row + category rows + } else { + 2 + uiState.iptvPlaylists.size // Add + rows + refresh + clear + } "home_server" -> uiState.homeServerConnections.size + 3 "catalogs" -> uiState.catalogs.size // Add + rows "stremio" -> stremioAddons.size // rows + add button @@ -413,6 +421,13 @@ fun SettingsScreen( .coerceAtLeast(0) showDnsProviderPicker = true } + val openIptvCategories: (String) -> Unit = { playlistId -> + viewModel.setIptvSelectedPlaylistId(playlistId) + showIptvCategoriesSettings = true + activeZone = Zone.CONTENT + contentFocusIndex = 0 + iptvActionIndex = 0 + } val openContentLanguagePicker = { contentLanguagePickerIndex = TMDB_LANGUAGES.indexOfFirst { it.first == uiState.contentLanguage }.coerceAtLeast(0) showContentLanguagePicker = true @@ -640,7 +655,13 @@ fun SettingsScreen( isSidebarFocused = true } Zone.CONTENT -> { - activeZone = Zone.SECTION + if (currentSection == "iptv" && showIptvCategoriesSettings) { + showIptvCategoriesSettings = false + contentFocusIndex = 0 + iptvActionIndex = 0 + } else { + activeZone = Zone.SECTION + } } } true @@ -650,7 +671,13 @@ fun SettingsScreen( Zone.CONTENT -> { if (currentSection == "stremio" && contentFocusIndex < stremioAddons.size && addonActionIndex > 0) { addonActionIndex = 0 - } else if (currentSection == "iptv" && contentFocusIndex in 1..uiState.iptvPlaylists.size && iptvActionIndex > 0) { + } else if (currentSection == "iptv" && + iptvActionIndex > 0 && + ( + showIptvCategoriesSettings && contentFocusIndex > 0 || + !showIptvCategoriesSettings && contentFocusIndex in 1..uiState.iptvPlaylists.size + ) + ) { iptvActionIndex-- } else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex > 0) { catalogActionIndex-- @@ -692,9 +719,11 @@ fun SettingsScreen( focusedStremioAddonCanDelete ) { addonActionIndex = 1 - } else if (currentSection == "iptv" && contentFocusIndex in 1..uiState.iptvPlaylists.size && iptvActionIndex < 4) { + } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex > 0 && iptvActionIndex < 2) { iptvActionIndex++ -} else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex < 4) { + } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 1..uiState.iptvPlaylists.size && iptvActionIndex < 5) { + iptvActionIndex++ + } else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex < 4) { catalogActionIndex++ } } @@ -711,6 +740,7 @@ fun SettingsScreen( addonActionIndex = 0 iptvActionIndex = 0 catalogActionIndex = 0 + showIptvCategoriesSettings = false } else { activeZone = Zone.SIDEBAR isSidebarFocused = true @@ -742,6 +772,7 @@ fun SettingsScreen( addonActionIndex = 0 iptvActionIndex = 0 catalogActionIndex = 0 + showIptvCategoriesSettings = false } } Zone.CONTENT -> { @@ -816,7 +847,30 @@ fun SettingsScreen( } } "iptv" -> { - when { + if (showIptvCategoriesSettings) { + val playlistId = uiState.iptvSelectedPlaylistId.orEmpty() + val orderedGroups = ( + uiState.iptvGroupOrder + .map { com.arflix.tv.data.model.PlaylistGroupKey(it) } + .filter { it.playlistId == playlistId } + .map { it.groupName } + uiState.iptvAvailableGroups + ).distinct() + when { + contentFocusIndex == 0 -> { + viewModel.resetIptvGroupOrder(playlistId) + } + contentFocusIndex in 1..orderedGroups.size -> { + val group = orderedGroups.getOrNull(contentFocusIndex - 1) + if (!group.isNullOrBlank()) { + when (iptvActionIndex) { + 0 -> viewModel.toggleIptvHiddenGroup(playlistId, group) + 1 -> viewModel.moveIptvGroupUp(playlistId, group) + 2 -> viewModel.moveIptvGroupDown(playlistId, group) + } + } + } + } + } else when { contentFocusIndex == 0 -> { editingIptvIndex = -1 showIptvInput = true @@ -828,21 +882,24 @@ fun SettingsScreen( if (playlist != null) { when (iptvActionIndex) { 0 -> { + openIptvCategories(playlist.id) + } + 1 -> { updated[idx] = playlist.copy(enabled = !playlist.enabled) viewModel.saveIptvPlaylists(updated) } - 1 -> { + 2 -> { editingIptvIndex = idx showIptvInput = true } - 2 -> { + 3 -> { if (idx > 0) { val item = updated.removeAt(idx) updated.add(idx - 1, item) viewModel.saveIptvPlaylists(updated) } } - 3 -> { + 4 -> { if (idx < updated.lastIndex) { val item = updated.removeAt(idx) updated.add(idx + 1, item) @@ -1100,6 +1157,8 @@ fun SettingsScreen( onClick = { sectionIndex = index contentFocusIndex = 0 + iptvActionIndex = 0 + showIptvCategoriesSettings = false activeZone = Zone.SECTION } ) @@ -1250,7 +1309,20 @@ fun SettingsScreen( ) } } // end "general" block - "iptv" -> IptvSettings( + "iptv" -> if (showIptvCategoriesSettings) { + IptvCategoriesSettings( + playlistId = uiState.iptvSelectedPlaylistId ?: "", + availableGroups = uiState.iptvAvailableGroups, + hiddenGroups = uiState.iptvHiddenGroups, + groupOrder = uiState.iptvGroupOrder, + focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, + focusedActionIndex = iptvActionIndex, + onToggleHidden = { viewModel.toggleIptvHiddenGroup(uiState.iptvSelectedPlaylistId ?: "", it) }, + onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, + onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, + onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") } + ) + } else IptvSettings( playlists = uiState.iptvPlaylists, channelCount = uiState.iptvChannelCount, isLoading = uiState.isIptvLoading, @@ -1291,7 +1363,8 @@ fun SettingsScreen( } }, onRefresh = { viewModel.refreshIptv() }, - onDelete = { viewModel.clearIptvConfig() } + onDelete = { viewModel.clearIptvConfig() }, + onManageCategories = openIptvCategories ) "TV" -> IptvSettings( playlists = uiState.iptvPlaylists, @@ -1334,7 +1407,8 @@ fun SettingsScreen( } }, onRefresh = { viewModel.refreshIptv() }, - onDelete = { viewModel.clearIptvConfig() } + onDelete = { viewModel.clearIptvConfig() }, + onManageCategories = openIptvCategories ) "home_server" -> HomeServerSettings( connections = uiState.homeServerConnections, @@ -3144,6 +3218,7 @@ private fun MobileSettingsLayout( } MobileSettingsSubPage( page = page, + onNavigate = onNavigate, uiState = uiState, viewModel = viewModel, stremioAddons = stremioAddons, @@ -3307,6 +3382,7 @@ private fun MobileSettingsMainPage( @Composable private fun MobileSettingsSubPage( page: String, + onNavigate: (String) -> Unit, uiState: SettingsUiState, viewModel: SettingsViewModel, stremioAddons: List, @@ -3666,7 +3742,25 @@ private fun MobileSettingsSubPage( } }, onRefresh = { viewModel.refreshIptv() }, - onDelete = { viewModel.clearIptvConfig() } + onDelete = { viewModel.clearIptvConfig() }, + onManageCategories = { playlistId -> + viewModel.setIptvSelectedPlaylistId(playlistId) + onNavigate("IPTV_CATEGORIES") + } + ) + } + "IPTV_CATEGORIES" -> { + IptvCategoriesSettings( + playlistId = uiState.iptvSelectedPlaylistId ?: "", + availableGroups = uiState.iptvAvailableGroups, + hiddenGroups = uiState.iptvHiddenGroups, + groupOrder = uiState.iptvGroupOrder, + focusedIndex = -1, + focusedActionIndex = 0, + onToggleHidden = { viewModel.toggleIptvHiddenGroup(uiState.iptvSelectedPlaylistId ?: "", it) }, + onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, + onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, + onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") } ) } "Home Server" -> { @@ -5631,7 +5725,8 @@ private fun IptvSettings( onMovePlaylistDown: (Int) -> Unit, onDeletePlaylist: (Int) -> Unit, onRefresh: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + onManageCategories: (String) -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } @@ -5690,6 +5785,16 @@ private fun IptvSettings( detectVerticalDragGestures(onDragEnd = { dragOffset = 0f }, onDragCancel = { dragOffset = 0f }) { change, dragAmount -> change.consume(); dragOffset += dragAmount; if (dragOffset > itemHeight) { onMovePlaylistDown(index); dragOffset -= itemHeight } else if (dragOffset < -itemHeight) { onMovePlaylistUp(index); dragOffset += itemHeight } } }) } else if (!selectionMode) { + Icon( + imageVector = Icons.Default.List, + contentDescription = "Manage Categories", + tint = TextSecondary, + modifier = Modifier + .size(36.dp) + .clickable { onManageCategories(playlist.id) } + .padding(6.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) // Toggle chip Box(modifier = Modifier.width(44.dp).height(24.dp).background(color = if (playlist.enabled) SuccessGreen else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(13.dp)).clickable { onTogglePlaylist(index) }.padding(3.dp), contentAlignment = if (playlist.enabled) Alignment.CenterEnd else Alignment.CenterStart) { Box(modifier = Modifier.size(18.dp).background(color = Color.White, shape = RoundedCornerShape(10.dp))) @@ -5729,15 +5834,42 @@ private fun IptvSettings( Spacer(modifier = Modifier.height(4.dp)) Text(buildString { append(playlist.m3uUrl.take(56)); when { epgSourceCount > 1 -> append(" • $epgSourceCount EPGs"); epgSourceCount == 1 -> append(" • EPG") } }, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) } - CatalogActionChip(icon = if (playlist.enabled) Icons.Default.Check else Icons.Default.VisibilityOff, isFocused = focusedIndex == rowIndex && focusedActionIndex == 0, onClick = { onTogglePlaylist(index) }) + CatalogActionChip( + icon = Icons.Default.List, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 0, + onClick = { onManageCategories(playlist.id) } + ) Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip(icon = Icons.Default.Edit, isFocused = focusedIndex == rowIndex && focusedActionIndex == 1, onClick = { onEditPlaylist(index) }) + CatalogActionChip( + icon = if (playlist.enabled) Icons.Default.Check else Icons.Default.VisibilityOff, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 1, + onClick = { onTogglePlaylist(index) } + ) Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip(icon = Icons.Default.ArrowUpward, isFocused = focusedIndex == rowIndex && focusedActionIndex == 2, onClick = { onMovePlaylistUp(index) }) + CatalogActionChip( + icon = Icons.Default.Edit, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 2, + onClick = { onEditPlaylist(index) } + ) Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip(icon = Icons.Default.ArrowDownward, isFocused = focusedIndex == rowIndex && focusedActionIndex == 3, onClick = { onMovePlaylistDown(index) }) + CatalogActionChip( + icon = Icons.Default.ArrowUpward, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 3, + onClick = { onMovePlaylistUp(index) } + ) Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip(icon = Icons.Default.Delete, isFocused = focusedIndex == rowIndex && focusedActionIndex == 4, isDestructive = true, onClick = { onDeletePlaylist(index) }) + CatalogActionChip( + icon = Icons.Default.ArrowDownward, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 4, + onClick = { onMovePlaylistDown(index) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Delete, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 5, + isDestructive = true, + onClick = { onDeletePlaylist(index) } + ) } Spacer(modifier = Modifier.height(10.dp)) } @@ -8728,3 +8860,128 @@ val TMDB_LANGUAGES = listOf( "sw-KE" to "Swahili", "sq-AL" to "Albanian (Shqip)" ) + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +private fun IptvCategoriesSettings( + playlistId: String, + availableGroups: List, + hiddenGroups: List, + groupOrder: List, + focusedIndex: Int, + focusedActionIndex: Int, + onToggleHidden: (String) -> Unit, + onMoveUp: (String) -> Unit, + onMoveDown: (String) -> Unit, + onReset: () -> Unit +) { + val isMobile = LocalDeviceType.current.isTouchDevice() + val orderedGroups = remember(groupOrder, availableGroups, playlistId) { + val explicitOrder = groupOrder.map { com.arflix.tv.data.model.PlaylistGroupKey(it) }.filter { it.playlistId == playlistId }.map { it.groupName } + (explicitOrder + availableGroups).distinct() + } + + Column { + if (!isMobile) { + Text( + text = "IPTV CATEGORIES", + style = ArflixTypography.sectionTitle, + color = TextPrimary, + modifier = Modifier.padding(bottom = 12.dp) + ) + } + + SettingsRow( + icon = Icons.Default.Refresh, + title = "Reset Order", + subtitle = "Restore default category order", + value = "RESET", + isFocused = focusedIndex == 0, + onClick = onReset, + modifier = Modifier.settingsFocusSlot(0) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (isMobile) { + MobileSettingsCategory(title = "CATEGORIES") { + if (orderedGroups.isEmpty()) { + Text( + text = "No categories available", + style = ArflixTypography.body, + color = TextSecondary, + modifier = Modifier.padding(16.dp) + ) + } else { + orderedGroups.forEachIndexed { index, group -> + val groupKey = com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, group) + val isHidden = hiddenGroups.contains(groupKey) + MobileSettingsRow( + icon = if (isHidden) Icons.Default.VisibilityOff else Icons.Default.Check, + title = group, + subtitle = if (isHidden) "Hidden" else "Visible", + value = "", + onClick = { onToggleHidden(group) }, + showDivider = index < orderedGroups.lastIndex + ) + } + } + } + } else { + orderedGroups.forEachIndexed { index, group -> + val rowFocusIndex = index + 1 + val isRowFocused = focusedIndex == rowFocusIndex + val groupKey = com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, group) + val isHidden = hiddenGroups.contains(groupKey) + + Row( + modifier = Modifier + .settingsFocusSlot(rowFocusIndex) + .fillMaxWidth() + .background( + if (isRowFocused) Color.White.copy(alpha = 0.08f) + else Color.Transparent, + RoundedCornerShape(12.dp) + ) + .clickable { onToggleHidden(group) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = group, + style = ArflixTypography.body, + color = if (isRowFocused) TextPrimary else TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (isHidden) "Hidden" else "Visible", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.7f) + ) + } + + CatalogActionChip( + icon = if (isHidden) Icons.Default.VisibilityOff else Icons.Default.Check, + isFocused = isRowFocused && focusedActionIndex == 0, + onClick = { onToggleHidden(group) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.ArrowUpward, + isFocused = isRowFocused && focusedActionIndex == 1, + onClick = { onMoveUp(group) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.ArrowDownward, + isFocused = isRowFocused && focusedActionIndex == 2, + onClick = { onMoveDown(group) } + ) + } + } + } + } +} 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 af04c1ad..f32f5405 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 @@ -150,6 +150,10 @@ data class SettingsUiState( val iptvStatusType: ToastType = ToastType.INFO, val iptvProgressText: String? = null, val iptvProgressPercent: Int = 0, + val iptvSelectedPlaylistId: String? = null, + val iptvAvailableGroups: List = emptyList(), + val iptvHiddenGroups: List = emptyList(), + val iptvGroupOrder: List = emptyList(), // App updates val isSelfUpdateSupported: Boolean = true, val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle, @@ -355,12 +359,28 @@ class SettingsViewModel @Inject constructor( observeSyncState() observeAuthState() observeIptvConfig() + observeIptvGroupPrefs() initializeCatalogs() observeCatalogs() initializeUpdaterState() checkForAppUpdates(force = false, showNoUpdateFeedback = false) } + private fun observeIptvGroupPrefs() { + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + iptvRepository.observeHiddenGroups(), + iptvRepository.observeGroupOrder() + ) { hidden, order -> Pair(hidden, order) } + .collect { (hidden, order) -> + _uiState.value = _uiState.value.copy( + iptvHiddenGroups = hidden, + iptvGroupOrder = order + ) + } + } + } + private fun initializeUpdaterState() { _uiState.value = _uiState.value.copy( isSelfUpdateSupported = appUpdateRepository.supportsSelfUpdate() @@ -626,7 +646,54 @@ class SettingsViewModel @Inject constructor( } } - // ========== Trakt Sync ========== + fun resetIptvGroupOrder(playlistId: String) { + viewModelScope.launch { + iptvRepository.resetGroupOrder(playlistId) + } + } + + fun setIptvSelectedPlaylistId(playlistId: String?) { + _uiState.value = _uiState.value.copy(iptvSelectedPlaylistId = playlistId) + if (playlistId != null) { + viewModelScope.launch { + val snapshot = iptvRepository.getMemoryCachedSnapshot() + val groups = snapshot?.channels + ?.filter { it.id.startsWith("$playlistId:") } + ?.map { it.group.trim().ifBlank { "Ungrouped" } } + ?.distinct() + .orEmpty() + _uiState.value = _uiState.value.copy(iptvAvailableGroups = groups) + } + } else { + _uiState.value = _uiState.value.copy(iptvAvailableGroups = emptyList()) + } + } + + fun toggleIptvHiddenGroup(playlistId: String, groupName: String) { + viewModelScope.launch { + iptvRepository.toggleHiddenGroup(playlistId, groupName) + } + } + + fun moveIptvGroupUp(playlistId: String, groupName: String) { + viewModelScope.launch { + iptvRepository.moveGroupUp(playlistId, groupName, _uiState.value.iptvAvailableGroups) + } + } + + fun moveIptvGroupDown(playlistId: String, groupName: String) { + viewModelScope.launch { + iptvRepository.moveGroupDown(playlistId, groupName, _uiState.value.iptvAvailableGroups) + } + } + + fun moveIptvGroupToTop(playlistId: String, groupName: String) { + viewModelScope.launch { + iptvRepository.moveGroupToTop(playlistId, groupName, _uiState.value.iptvAvailableGroups) + } + } + + // ========== App Updates ========== fun performFullSync(silent: Boolean = false) { viewModelScope.launch { 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 ddcbc805..d638b938 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 @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.tv +package com.arflix.tv.ui.screens.tv import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -625,7 +625,14 @@ class TvViewModel @Inject constructor( } fun toggleHiddenGroup(groupName: String) { - viewModelScope.launch { iptvRepository.toggleHiddenGroup(groupName); scheduleIptvCloudSync() } + viewModelScope.launch { + val config = iptvRepository.observeConfig().first() + val activePlaylists = config.playlists.filter { it.enabled }.map { it.id } + activePlaylists.forEach { playlistId -> + iptvRepository.toggleHiddenGroup(playlistId, groupName) + } + scheduleIptvCloudSync() + } } fun prefetchVisibleCategoryEpg( @@ -735,7 +742,11 @@ class TvViewModel @Inject constructor( fun moveGroupUp(groupName: String) { viewModelScope.launch { val current = currentVisiblePlaylistGroups() - iptvRepository.moveGroupUp(groupName, current) + val config = iptvRepository.observeConfig().first() + val activePlaylists = config.playlists.filter { it.enabled }.map { it.id } + activePlaylists.forEach { playlistId -> + iptvRepository.moveGroupUp(playlistId, groupName, current) + } scheduleIptvCloudSync() } } @@ -743,7 +754,11 @@ class TvViewModel @Inject constructor( fun moveGroupToTop(groupName: String) { viewModelScope.launch { val current = currentVisiblePlaylistGroups() - iptvRepository.moveGroupToTop(groupName, current) + val config = iptvRepository.observeConfig().first() + val activePlaylists = config.playlists.filter { it.enabled }.map { it.id } + activePlaylists.forEach { playlistId -> + iptvRepository.moveGroupToTop(playlistId, groupName, current) + } scheduleIptvCloudSync() } } @@ -751,7 +766,11 @@ class TvViewModel @Inject constructor( fun moveGroupDown(groupName: String) { viewModelScope.launch { val current = currentVisiblePlaylistGroups() - iptvRepository.moveGroupDown(groupName, current) + val config = iptvRepository.observeConfig().first() + val activePlaylists = config.playlists.filter { it.enabled }.map { it.id } + activePlaylists.forEach { playlistId -> + iptvRepository.moveGroupDown(playlistId, groupName, current) + } scheduleIptvCloudSync() } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt index f29f8043..7088ccde 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt @@ -307,9 +307,9 @@ private fun playlistGroupLabel(group: String): String { return group.trim().ifBlank { "Ungrouped" } } -fun playlistGroupCategoryId(group: String): String { +fun playlistGroupCategoryId(playlistId: String, group: String): String { val normalized = playlistGroupLabel(group).lowercase() - return "grp:${normalized.hashCode().toUInt().toString(16)}" + return "grp:$playlistId:${normalized.hashCode().toUInt().toString(16)}" } /** @@ -348,12 +348,14 @@ fun buildCategoryTree( val countryAccumulators = LinkedHashMap() val playlistGroupCounts = LinkedHashMap>() val hiddenPlaylistGroupCounts = LinkedHashMap>() - val hiddenPlaylistGroups = hiddenGroups.mapTo(HashSet()) { playlistGroupLabel(it) } + val hiddenPlaylistGroups = hiddenGroups.toHashSet() channels.forEach { channel -> + val playlistId = channel.id.substringBefore(':') val groupLabel = playlistGroupLabel(channel.source.group) - val groupId = playlistGroupCategoryId(channel.source.group) - val targetCounts = if (groupLabel in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts + val groupKey = com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, groupLabel) + val groupId = playlistGroupCategoryId(playlistId, channel.source.group) + val targetCounts = if (groupKey in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts val groupCount = targetCounts[groupId]?.second ?: 0 targetCounts[groupId] = groupLabel to (groupCount + 1) @@ -502,13 +504,15 @@ fun buildCategoryTree( val countryAccumulators = LinkedHashMap() val playlistGroupCounts = LinkedHashMap>() val hiddenPlaylistGroupCounts = LinkedHashMap>() - val hiddenPlaylistGroups = hiddenGroups.mapTo(HashSet()) { playlistGroupLabel(it) } + val hiddenPlaylistGroups = hiddenGroups.toHashSet() channels.forEach { channel -> + val playlistId = channel.id.substringBefore(':') val traits = channel.traits() val groupLabel = playlistGroupLabel(channel.group) - val groupId = playlistGroupCategoryId(channel.group) - val targetCounts = if (groupLabel in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts + val groupKey = com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, groupLabel) + val groupId = playlistGroupCategoryId(playlistId, channel.group) + val targetCounts = if (groupKey in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts val groupCount = targetCounts[groupId]?.second ?: 0 targetCounts[groupId] = groupLabel to (groupCount + 1) @@ -646,7 +650,7 @@ private fun rawCategoryMatcher( if (categoryId == "fav") return { ch -> ch.id in favorites && !ch.traits().isAdult } if (categoryId == "recent") return { ch -> ch.id in recents && !ch.traits().isAdult } if (categoryId == "adult") return { ch -> ch.traits().isAdult } - if (categoryId.startsWith("grp:")) return { ch -> playlistGroupCategoryId(ch.group) == categoryId } + if (categoryId.startsWith("grp:")) return { ch -> playlistGroupCategoryId(ch.id.substringBefore(':'), ch.group) == categoryId } if (categoryId == "g-4k") return { ch -> ch.traits().let { !it.isAdult && it.quality == Quality.K4 } } if (categoryId == "g-sports") return { ch -> ch.traits().let { !it.isAdult && it.genre == Genre.Sports } } if (categoryId == "g-movies") return { ch -> ch.traits().let { !it.isAdult && it.genre == Genre.Movies } } @@ -747,7 +751,7 @@ fun buildCategoryIndex(channels: List): LiveCategoryIndex { channels.forEach { channel -> byId[channel.id] = channel - add(playlistGroupCategoryId(channel.source.group), channel) + add(playlistGroupCategoryId(channel.source.id.substringBefore(':'), channel.source.group), channel) if (channel.isAdult) { add("adult", channel) return@forEach @@ -797,7 +801,7 @@ fun bestCategoryIdForChannel( channel: EnrichedChannel, tree: LiveCategoryTree, ): String { - val playlistGroupId = playlistGroupCategoryId(channel.source.group) + val playlistGroupId = playlistGroupCategoryId(channel.source.id.substringBefore(':'), channel.source.group) if (tree.byId(playlistGroupId) != null) return playlistGroupId if (channel.isAdult) return "adult" @@ -829,7 +833,7 @@ fun categoryMatcher( categoryId == "fav" -> { ch -> ch.id in favorites && !ch.isAdult } categoryId == "recent" -> { ch -> ch.id in recents && !ch.isAdult } categoryId == "adult" -> { ch -> ch.isAdult } - categoryId.startsWith("grp:") -> { ch -> playlistGroupCategoryId(ch.source.group) == categoryId } + categoryId.startsWith("grp:") -> { ch -> playlistGroupCategoryId(ch.source.id.substringBefore(':'), ch.source.group) == categoryId } categoryId == "g-4k" -> { ch -> ch.quality == Quality.K4 && !ch.isAdult } categoryId == "g-sports" -> { ch -> ch.genre == Genre.Sports && !ch.isAdult } categoryId == "g-movies" -> { ch -> ch.genre == Genre.Movies && !ch.isAdult }