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 61154847e..28f895f7b 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 @@ -161,8 +161,7 @@ data class IptvConfig( val m3uUrl: String = "", val epgUrl: String = "", val playlists: List = emptyList(), - val stalkerPortalUrl: String = "", - val stalkerMacAddress: String = "", + val stalkerPortals: List = emptyList(), val sortOrder: String = "provider" ) @@ -175,6 +174,20 @@ data class IptvPlaylistEntry( val epgUrls: List = emptyList() ) +/** + * A single Stalker/Ministra portal configuration. Mirrors [IptvPlaylistEntry] + * so Stalker portals can be managed (add/edit/remove/reorder/toggle) like M3U + * playlists. Channel ids are prefixed `stalker::` so the existing + * `startsWith("stalker:")` checks keep matching. + */ +data class StalkerPortalEntry( + val id: String, + val name: String, + val portalUrl: String, + val macAddress: String, + val enabled: Boolean = true +) + data class IptvLoadProgress( val message: String, val percent: Int? = null @@ -183,8 +196,7 @@ data class IptvLoadProgress( data class IptvCloudProfileState( val m3uUrl: String = "", val epgUrl: String = "", - val stalkerPortalUrl: String = "", - val stalkerMacAddress: String = "", + val stalkerPortals: List = emptyList(), val favoriteGroups: List = emptyList(), val favoriteChannels: List = emptyList(), val hiddenGroups: List = emptyList(), @@ -228,7 +240,30 @@ class IptvRepository @Inject constructor( private var cachedChannelsById: Map = emptyMap() @Volatile private var cachedGroupedChannels: Map> = emptyMap() - @Volatile var cachedStalkerApi: com.arflix.tv.data.api.StalkerApi? = null + @Volatile + private var cachedStalkerApis: Map = emptyMap() + + /** + * Public accessor kept for compatibility with code that previously read the + * single cached Stalker API instance. Returns the first cached portal API. + */ + val cachedStalkerApi: com.arflix.tv.data.api.StalkerApi? + get() = cachedStalkerApis.values.firstOrNull() + + internal fun portalIdFromChannelId(channelId: String): String? = + StalkerPortalSupport.portalIdFromChannelId(channelId) + + private suspend fun getOrCreateStalkerApi(portal: StalkerPortalEntry): com.arflix.tv.data.api.StalkerApi? { + cachedStalkerApis[portal.id]?.let { return it } + return stalkerApiMutex.withLock { + cachedStalkerApis[portal.id]?.let { return it } + com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + .takeIf { it.handshake() } + ?.also { api -> + cachedStalkerApis = cachedStalkerApis + (portal.id to api) + } + } + } /** (store key, channel count) — see [pagedChannelStoreCount]. */ @Volatile @@ -337,7 +372,7 @@ class IptvRepository @Inject constructor( ) private fun hasAnyConfiguredSource(config: IptvConfig): Boolean = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.enabled && it.portalUrl.isNotBlank() } || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } private fun activePlaylists(config: IptvConfig): List = config.playlists.filter { it.enabled }.ifEmpty { @@ -357,6 +392,11 @@ class IptvRepository @Inject constructor( emptyList() } } + + /** Enabled Stalker portals with a non-blank URL — the ones that load channels. */ + private fun activeStalkerPortals(config: IptvConfig): List = + config.stalkerPortals.filter { it.enabled && it.portalUrl.isNotBlank() } + @Volatile private var xtreamSeriesLoadedAtMs: Long = 0L @Volatile @@ -469,8 +509,7 @@ class IptvRepository @Inject constructor( m3uUrl = primary?.m3uUrl ?: legacyM3uUrl, epgUrl = primary?.epgUrl ?: legacyEpgUrls.firstOrNull().orEmpty(), playlists = playlists, - stalkerPortalUrl = decryptConfigValue(prefs[stalkerPortalUrlKey()].orEmpty()), - stalkerMacAddress = prefs[stalkerMacAddressKey()].orEmpty(), + stalkerPortals = readStalkerPortals(prefs), sortOrder = normalizeIptvSortOrder(prefs[sortOrderKey()]) ) } @@ -610,17 +649,26 @@ class IptvRepository @Inject constructor( val normalizedMac = macAddress.trim().uppercase().let { mac -> if (mac.isNotEmpty() && !mac.startsWith("00:1A:79:")) mac else mac } - val nextConfig = previousConfig.copy( - stalkerPortalUrl = normalizedUrl, - stalkerMacAddress = normalizedMac, - ) + // Legacy single-portal save: writes/updates the first portal entry. + val existing = previousConfig.stalkerPortals + val nextPortals = if (existing.isEmpty()) { + listOf(StalkerPortalEntry("stalker1", "Portal 1", normalizedUrl, normalizedMac)) + } else { + existing.mapIndexed { index, portal -> + if (index == 0) portal.copy(portalUrl = normalizedUrl, macAddress = normalizedMac) + else portal + } + } + val nextConfig = previousConfig.copy(stalkerPortals = nextPortals) val previousSourceKey = epgIndexKey(profileId, previousConfig) val sourceChanged = buildSourceSignature(previousConfig) != buildSourceSignature(nextConfig) context.settingsDataStore.edit { prefs -> - prefs[stalkerPortalUrlKey()] = encryptConfigValue(normalizedUrl) - prefs[stalkerMacAddressKey()] = normalizedMac + prefs[stalkerPortalsKey()] = gson.toJson(nextPortals) + // Clear legacy single-portal keys so the list store becomes the source of truth. + prefs.remove(stalkerPortalUrlKey()) + prefs.remove(stalkerMacAddressKey()) } - cachedStalkerApi = null + cachedStalkerApis = emptyMap() groupOrderLocallyDirty = true if (sourceChanged) { withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } @@ -633,12 +681,15 @@ class IptvRepository @Inject constructor( val profileId = profileManager.getProfileIdSync() val previousConfig = observeConfig().first() val previousSourceKey = epgIndexKey(profileId, previousConfig) - val sourceChanged = previousConfig.stalkerPortalUrl.isNotBlank() || previousConfig.stalkerMacAddress.isNotBlank() + val sourceChanged = previousConfig.stalkerPortals.isNotEmpty() context.settingsDataStore.edit { prefs -> + prefs.remove(stalkerPortalsKey()) prefs.remove(stalkerPortalUrlKey()) prefs.remove(stalkerMacAddressKey()) } - cachedStalkerApi = null + // Drop Stalker group preferences so re-adding a portal starts clean. + clearGroupPreferences(STALKER_PLAYLIST_ID) + cachedStalkerApis = emptyMap() groupOrderLocallyDirty = true if (sourceChanged) { withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } @@ -647,16 +698,65 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear stalker config") } - suspend fun resolveStalkerStreamUrl(command: String): String? { - val config = observeConfig().first() - if (config.stalkerPortalUrl.isBlank() || config.stalkerMacAddress.isBlank()) return null + /** + * Persists the full Stalker portal list — the Stalker counterpart of + * [savePlaylists]. The caller (SettingsViewModel) builds the mutated list + * (add / edit / remove / reorder / toggle / rename) and hands it here. + * + * - Normalizes every entry (trims URL, uppercases MAC, assigns default + * id/name) and caps the list at [MAX_STALKER_PORTALS]. + * - Drops group preferences for portals that were removed so no stale + * `stalker|...` keys linger. + * - Invalidates the Stalker API cache and persisted source caches when the + * source signature changes. + */ + suspend fun saveStalkerPortals(portals: List) { + val profileId = profileManager.getProfileIdSync() + val previousConfig = observeConfig().first() + val normalized = portals.mapIndexed { index, portal -> + normalizeStalkerPortalEntry(portal, index) + }.filterNotNull().take(MAX_STALKER_PORTALS) + val nextConfig = previousConfig.copy(stalkerPortals = normalized) + val previousSourceKey = epgIndexKey(profileId, previousConfig) + val sourceChanged = buildSourceSignature(previousConfig) != buildSourceSignature(nextConfig) + + val removedIds = previousConfig.stalkerPortals + .map { it.id } + .filterNot { id -> normalized.any { it.id == id } } - val stalker = cachedStalkerApi ?: stalkerApiMutex.withLock { - cachedStalkerApi ?: com.arflix.tv.data.api.StalkerApi( - config.stalkerPortalUrl, - config.stalkerMacAddress, - ).takeIf { it.handshake() }?.also { cachedStalkerApi = it } + context.settingsDataStore.edit { prefs -> + prefs[stalkerPortalsKey()] = gson.toJson(normalized) + // Clear legacy single-portal keys so the list store stays authoritative. + prefs.remove(stalkerPortalUrlKey()) + prefs.remove(stalkerMacAddressKey()) + } + // Drop group preferences for removed portals (independent sets, decision #3). + removedIds.forEach { clearGroupPreferences(it) } + cachedStalkerApis = emptyMap() + groupOrderLocallyDirty = true + if (sourceChanged) { + withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } } + invalidateCache() + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "save stalker portals") + } + + /** + * Resolves a Stalker `cmd` stream argument into an authenticated URL. When + * [channelId] carries the `stalker::` prefix the matching portal + * API is used; otherwise the first configured portal is used as a fallback + * (keeps the legacy single-portal behavior intact). + */ + suspend fun resolveStalkerStreamUrl(channelId: String?, command: String): String? { + val config = observeConfig().first() + val portals = config.stalkerPortals + if (portals.isEmpty()) return null + val portalId = channelId?.let { portalIdFromChannelId(it) } + val portal = portalId + ?.let { id -> portals.firstOrNull { it.id == id } } + ?: portals.first() + if (portal.portalUrl.isBlank() || portal.macAddress.isBlank()) return null + val stalker = getOrCreateStalkerApi(portal) return stalker?.resolveStreamUrl(command) } @@ -1414,6 +1514,7 @@ class IptvRepository @Inject constructor( prefs.remove(m3uUrlKey()) prefs.remove(epgUrlKey()) prefs.remove(playlistsKey()) + prefs.remove(stalkerPortalsKey()) prefs.remove(stalkerPortalUrlKey()) prefs.remove(stalkerMacAddressKey()) prefs.remove(favoriteGroupsKey()) @@ -1424,6 +1525,7 @@ class IptvRepository @Inject constructor( prefs.remove(tvSessionKey()) } groupOrderLocallyDirty = true + cachedStalkerApis = emptyMap() withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } invalidateCache() invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear iptv config") @@ -1519,6 +1621,57 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "toggle hidden group") } + /** + * Show or hide all groups of a playlist in one operation. Used by the + * "show all / hide all" bulk toggle in the categories screen. A group is + * hidden when its playlistId|groupName key is present in the hidden set; + * `hidden=true` adds the missing keys, `hidden=false` removes them. + */ + suspend fun setGroupsHidden(playlistId: String, groups: List, hidden: Boolean) { + val trimmedId = playlistId.trim() + if (trimmedId.isEmpty()) return + val targetKeys = groups + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { PlaylistGroupKey.build(trimmedId, it) } + .toHashSet() + if (targetKeys.isEmpty()) return + context.settingsDataStore.edit { prefs -> + val existing = decodeHiddenGroups(prefs).toMutableList() + if (hidden) { + existing.addAll(targetKeys) + } else { + existing.removeAll { it in targetKeys } + } + prefs[hiddenGroupsKey()] = gson.toJson(existing.distinct()) + } + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "set groups hidden") + } + + /** + * Remove every hidden-group and group-order entry that belongs to the + * given playlist. Called when a Stalker portal is removed so no stale + * `stalker|...` preferences linger after the source is gone. + */ + suspend fun clearGroupPreferences(playlistId: String) { + val trimmedId = playlistId.trim() + if (trimmedId.isEmpty()) return + context.settingsDataStore.edit { prefs -> + val retainedHidden = decodeHiddenGroups(prefs) + .filterNot { PlaylistGroupKey(it).playlistId == trimmedId } + if (retainedHidden.isEmpty()) prefs.remove(hiddenGroupsKey()) + else prefs[hiddenGroupsKey()] = gson.toJson(retainedHidden) + + val retainedOrder = decodeGroupOrder(prefs) + .filterNot { PlaylistGroupKey(it).playlistId == trimmedId } + if (retainedOrder.isEmpty()) prefs.remove(groupOrderKey()) + else prefs[groupOrderKey()] = gson.toJson(retainedOrder) + prefs[groupOrderSchemaKey()] = IPTV_GROUP_ORDER_SCHEMA.toString() + } + groupOrderLocallyDirty = true + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear group preferences") + } + suspend fun moveGroupUp(playlistId: String, groupName: String, currentGroups: List = emptyList()) { val target = PlaylistGroupKey.build(playlistId, groupName.trim()) if (groupName.trim().isEmpty()) return @@ -1613,7 +1766,8 @@ class IptvRepository @Inject constructor( ensureCacheOwnership(profileId, config) cleanupIptvCacheDirectory() val activePlaylists = activePlaylists(config) - if (activePlaylists.isEmpty() && config.stalkerPortalUrl.isBlank()) { + val stalkerPortals = activeStalkerPortals(config) + if (activePlaylists.isEmpty() && stalkerPortals.isEmpty()) { return@withContext IptvSnapshot( channels = emptyList(), grouped = emptyMap(), @@ -1625,24 +1779,34 @@ class IptvRepository @Inject constructor( } // ── Stalker Portal path ── - // Load Stalker in parallel with M3U/Xtream playlists when both are configured (hybrid mode). - // Stalker-only (no playlists) keeps the legacy early-return behavior. - val stalkerConfigured = config.stalkerPortalUrl.isNotBlank() - val stalkerChannelsDeferred = if (stalkerConfigured) { + // Load every enabled Stalker portal in parallel with M3U/Xtream + // playlists when both are configured (hybrid mode). Stalker-only + // (no playlists) keeps the legacy early-return behavior. + val stalkerChannelsDeferred = if (stalkerPortals.isNotEmpty()) { async { - runCatching { - onProgress(IptvLoadProgress(context.getString(R.string.iptv_connecting_stalker), 10)) - val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) - if (!stalker.handshake()) { - return@runCatching null to emptyList() + onProgress(IptvLoadProgress(context.getString(R.string.iptv_connecting_stalker), 10)) + stalkerPortals.map { portal -> + async { + runCatching { + val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + if (!stalker.handshake()) { + return@runCatching Triple>(portal.id, null, emptyList()) + } + stalker.getProfile() + val channels = stalker.getChannels() + // Prefix with stalker:: so the + // portal can be identified for playback routing. + Triple>(portal.id, stalker, channels.map { it.copy(id = "stalker:${portal.id}:${it.id}") }) + }.getOrElse { Triple>(portal.id, null, emptyList()) } + } + }.awaitAll().let { results -> + val apis = HashMap() + val channels = ArrayList() + for ((portalId, api, chs) in results) { + channels.addAll(chs) + api?.let { apis[portalId] = it } } - onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_channels), 30)) - stalker.getProfile() - val channels = stalker.getChannels() - // Prefix IDs to avoid collisions with M3U/Xtream channel IDs. - stalker to channels.map { it.copy(id = "stalker:${it.id}") } - }.getOrElse { e -> - null to emptyList() + apis to channels } } } else { @@ -1650,8 +1814,9 @@ class IptvRepository @Inject constructor( } // Stalker-only mode: no playlists configured → return Stalker channels directly. - if (activePlaylists.isEmpty() && stalkerConfigured) { - val (stalkerApi, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (null to emptyList()) + if (activePlaylists.isEmpty() && stalkerPortals.isNotEmpty()) { + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() + ?: (emptyMap() to emptyList()) if (stalkerChannels.isEmpty()) { return@withContext IptvSnapshot( epgWarning = "Stalker handshake failed. Check Portal URL and MAC.", @@ -1666,7 +1831,7 @@ class IptvRepository @Inject constructor( val groupOrder = observeGroupOrder().first() cachedChannels = stalkerChannels cachedGroupedChannels = grouped - cachedStalkerApi = stalkerApi + cachedStalkerApis = stalkerApis val snapshot = IptvSnapshot( channels = stalkerChannels, grouped = grouped, @@ -1717,9 +1882,9 @@ class IptvRepository @Inject constructor( ) // If Stalker is configured but not yet in cache, await and merge. if (stalkerChannelsDeferred != null) { - val (stalkerApi, stalkerChannels) = stalkerChannelsDeferred.await() - if (stalkerApi != null) { - cachedStalkerApi = stalkerApi + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred.await() + if (stalkerApis.isNotEmpty()) { + cachedStalkerApis = stalkerApis } if (stalkerChannels.isNotEmpty()) { // cachedChannels may already contain Stalker entries merged by a @@ -1770,9 +1935,10 @@ class IptvRepository @Inject constructor( } }.let { playlistChannels -> // Merge Stalker channels (if configured) into the final list. - val (stalkerApi, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (null to emptyList()) - if (stalkerApi != null) { - cachedStalkerApi = stalkerApi + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() + ?: (emptyMap() to emptyList()) + if (stalkerApis.isNotEmpty()) { + cachedStalkerApis = stalkerApis } val merged = playlistChannels + stalkerChannels cachedChannels = merged @@ -2205,6 +2371,8 @@ class IptvRepository @Inject constructor( val favoriteGroups = observeFavoriteGroups().first() val favoriteChannels = observeFavoriteChannels().first() + val hiddenGroups = observeHiddenGroups().first() + val groupOrder = observeGroupOrder().first() val grouped = cachedGroupedChannels.ifEmpty { buildGroupedChannels(cachedChannels).also { cachedGroupedChannels = it } } @@ -2216,6 +2384,8 @@ class IptvRepository @Inject constructor( nowNext = cachedNowNext, favoriteGroups = favoriteGroups, favoriteChannels = favoriteChannels, + hiddenGroups = hiddenGroups, + groupOrder = groupOrder, epgWarning = null, loadedAt = Instant.ofEpochMilli(loadedAtMillis) ) @@ -2245,6 +2415,8 @@ class IptvRepository @Inject constructor( if (channels.isEmpty()) return null val favoriteGroups = observeFavoriteGroups().first() val favoriteChannels = observeFavoriteChannels().first() + val hiddenGroups = observeHiddenGroups().first() + val groupOrder = observeGroupOrder().first() val grouped = cachedGroupedChannels.ifEmpty { buildGroupedChannels(channels).also { cachedGroupedChannels = it } } @@ -2255,6 +2427,8 @@ class IptvRepository @Inject constructor( nowNext = cachedNowNext, favoriteGroups = favoriteGroups, favoriteChannels = favoriteChannels, + hiddenGroups = hiddenGroups, + groupOrder = groupOrder, epgWarning = null, loadedAt = Instant.ofEpochMilli(loadedAtMillis) ) @@ -2808,29 +2982,50 @@ class IptvRepository @Inject constructor( return runCatching { epgIndex.countPrograms(indexKey) }.getOrDefault(0) } - private suspend fun fetchFreshChannelsForStartup(config: IptvConfig): Pair, com.arflix.tv.data.api.StalkerApi?>? { + private suspend fun fetchFreshChannelsForStartup(config: IptvConfig): Pair, Map>? { val activeLists = activePlaylists(config) - if (activeLists.isEmpty() && config.stalkerPortalUrl.isBlank()) return null + val stalkerPortals = activeStalkerPortals(config) + if (activeLists.isEmpty() && stalkerPortals.isEmpty()) return null // Stalker-only mode: no playlists configured. - if (activeLists.isEmpty() && config.stalkerPortalUrl.isNotBlank()) { - val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) - if (!stalker.handshake()) return null - stalker.getProfile() - val channels = stalker.getChannels().map { it.copy(id = "stalker:${it.id}") } - return if (channels.isNotEmpty()) channels to stalker else null + if (activeLists.isEmpty() && stalkerPortals.isNotEmpty()) { + val apis = HashMap() + val channels = ArrayList() + for (portal in stalkerPortals) { + runCatching { + val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + if (!stalker.handshake()) return@runCatching + stalker.getProfile() + stalker.getChannels().map { it.copy(id = "stalker:${portal.id}:${it.id}") } + .also { channels.addAll(it) } + apis[portal.id] = stalker + } + } + return if (channels.isNotEmpty()) channels to apis else null } // Load playlists and Stalker in parallel when both are configured. - val (playlistChannels, stalkerApi, stalkerChannels) = coroutineScope { - val stalkerDeferred = if (config.stalkerPortalUrl.isNotBlank()) { + val (playlistChannels, stalkerApis, stalkerChannels) = coroutineScope { + val stalkerDeferred = if (stalkerPortals.isNotEmpty()) { async { - runCatching { - val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) - if (!stalker.handshake()) return@runCatching null to emptyList() - stalker.getProfile() - stalker to stalker.getChannels().map { it.copy(id = "stalker:${it.id}") } - }.getOrElse { null to emptyList() } + stalkerPortals.map { portal -> + async { + runCatching { + val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + if (!stalker.handshake()) return@runCatching Triple>(portal.id, null, emptyList()) + stalker.getProfile() + Triple>(portal.id, stalker, stalker.getChannels().map { it.copy(id = "stalker:${portal.id}:${it.id}") }) + }.getOrElse { Triple>(portal.id, null, emptyList()) } + } + }.awaitAll().let { results -> + val apis = HashMap() + val channels = ArrayList() + for ((portalId, api, chs) in results) { + channels.addAll(chs) + api?.let { apis[portalId] = it } + } + apis to channels + } } } else { null @@ -2846,25 +3041,26 @@ class IptvRepository @Inject constructor( } } }.awaitAll().flatten() - val (sa, sc) = stalkerDeferred?.await() ?: (null to emptyList()) + val (sa, sc) = stalkerDeferred?.await() + ?: (emptyMap() to emptyList()) Triple(playlists, sa, sc) } val merged = playlistChannels + stalkerChannels - return if (merged.isNotEmpty()) merged to stalkerApi else null + return if (merged.isNotEmpty()) merged to stalkerApis else null } private suspend fun storeStartupChannels( config: IptvConfig, channels: List, - stalkerApi: com.arflix.tv.data.api.StalkerApi? + stalkerApis: Map ) { loadMutex.withLock { cachedChannels = channels cachedGroupedChannels = buildGroupedChannels(channels) cachedPlaylistAt = System.currentTimeMillis() - if (stalkerApi != null) { - cachedStalkerApi = stalkerApi + if (stalkerApis.isNotEmpty()) { + cachedStalkerApis = stalkerApis } val validIds = channels.asSequence().map { it.id }.toSet() if (cachedNowNext.isNotEmpty()) { @@ -2974,6 +3170,10 @@ class IptvRepository @Inject constructor( profileManager.profileStringKeyFor(profileId, "iptv_m3u_url") private fun epgUrlKey(): Preferences.Key = profileManager.profileStringKey("iptv_epg_url") private fun playlistsKey(): Preferences.Key = profileManager.profileStringKey("iptv_playlists_json") + private fun stalkerPortalsKey(): Preferences.Key = profileManager.profileStringKey("iptv_stalker_portals_json") + private fun stalkerPortalsKeyFor(profileId: String): Preferences.Key = + profileManager.profileStringKeyFor(profileId, "iptv_stalker_portals_json") + // Legacy single-portal keys kept only to read+ migrate pre-list data. private fun stalkerPortalUrlKey(): Preferences.Key = profileManager.profileStringKey("iptv_stalker_portal_url") private fun stalkerPortalUrlKeyFor(profileId: String): Preferences.Key = profileManager.profileStringKeyFor(profileId, "iptv_stalker_portal_url") @@ -3018,6 +3218,38 @@ class IptvRepository @Inject constructor( }.getOrDefault(emptyList()) } + private fun normalizeStalkerPortalEntry( + portal: StalkerPortalEntry, + index: Int + ): StalkerPortalEntry? = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, index) + + private fun decodeStalkerPortals(raw: String): List = + StalkerPortalSupport.decodeStalkerPortals(raw, MAX_STALKER_PORTALS) + + /** + * Reads the persisted Stalker portal list. When the list store is empty but + * the legacy single-portal fields are set, migrates them into `Portal 1` + * (id `stalker1`) once so existing users keep their portal after the update. + */ + private fun readStalkerPortals(prefs: Preferences): List { + val listRaw = prefs[stalkerPortalsKey()].orEmpty() + if (listRaw.isNotBlank()) return decodeStalkerPortals(listRaw) + val legacyUrl = decryptConfigValue(prefs[stalkerPortalUrlKey()].orEmpty()).trim().trimEnd('/') + val legacyMac = prefs[stalkerMacAddressKey()].orEmpty().trim().uppercase() + return StalkerPortalSupport.migratedPortalFromLegacy(legacyUrl, legacyMac)?.let { listOf(it) } ?: emptyList() + } + + private fun readStalkerPortalsFor( + prefs: Preferences, + profileId: String + ): List { + val listRaw = prefs[stalkerPortalsKeyFor(profileId)].orEmpty() + if (listRaw.isNotBlank()) return decodeStalkerPortals(listRaw) + val legacyUrl = decryptConfigValue(prefs[stalkerPortalUrlKeyFor(profileId)].orEmpty()).trim().trimEnd('/') + val legacyMac = prefs[stalkerMacAddressKeyFor(profileId)].orEmpty().trim().uppercase() + return StalkerPortalSupport.migratedPortalFromLegacy(legacyUrl, legacyMac)?.let { listOf(it) } ?: emptyList() + } + private fun hiddenGroupsKey(): Preferences.Key = profileManager.profileStringKey("iptv_hidden_groups") private fun hiddenGroupsKeyFor(profileId: String): Preferences.Key = profileManager.profileStringKeyFor(profileId, "iptv_hidden_groups") @@ -3169,8 +3401,7 @@ class IptvRepository @Inject constructor( return IptvCloudProfileState( m3uUrl = primary?.m3uUrl ?: legacyM3uUrl, epgUrl = primary?.epgUrl ?: legacyEpgUrls.firstOrNull().orEmpty(), - stalkerPortalUrl = decryptConfigValue(prefs[stalkerPortalUrlKeyFor(safeProfileId)].orEmpty()), - stalkerMacAddress = prefs[stalkerMacAddressKeyFor(safeProfileId)].orEmpty(), + stalkerPortals = readStalkerPortalsFor(prefs, safeProfileId), favoriteGroups = decodeFavoriteGroups(prefs[favoriteGroupsKeyFor(safeProfileId)].orEmpty()), favoriteChannels = decodeFavoriteChannels(prefs[favoriteChannelsKeyFor(safeProfileId)].orEmpty()), hiddenGroups = if (hiddenRaw.isNotBlank()) { @@ -3197,8 +3428,10 @@ class IptvRepository @Inject constructor( val normalizedM3u = normalizeStoredIptvUrl(state.m3uUrl) val normalizedEpgUrls = normalizeStoredEpgInputs(state.epgUrl) val normalizedEpg = normalizedEpgUrls.firstOrNull().orEmpty() - val normalizedStalkerPortal = state.stalkerPortalUrl.trim().trimEnd('/') - val normalizedStalkerMac = state.stalkerMacAddress.trim().uppercase() + val normalizedStalkerPortals = state.stalkerPortals + .mapIndexed { index, portal -> normalizeStalkerPortalEntry(portal, index) } + .filterNotNull() + .take(MAX_STALKER_PORTALS) val normalizedPlaylists = state.playlists.mapIndexed { index, playlist -> normalizePlaylistEntry(playlist, index) }.filterNotNull().take(3) @@ -3223,10 +3456,14 @@ class IptvRepository @Inject constructor( context.settingsDataStore.edit { prefs -> prefs[m3uUrlKeyFor(safeProfileId)] = encryptConfigValue(normalizedM3u) prefs[epgUrlKeyFor(safeProfileId)] = encryptConfigValue(normalizedEpg) - if (normalizedStalkerPortal.isBlank()) prefs.remove(stalkerPortalUrlKeyFor(safeProfileId)) - else prefs[stalkerPortalUrlKeyFor(safeProfileId)] = encryptConfigValue(normalizedStalkerPortal) - if (normalizedStalkerMac.isBlank()) prefs.remove(stalkerMacAddressKeyFor(safeProfileId)) - else prefs[stalkerMacAddressKeyFor(safeProfileId)] = normalizedStalkerMac + if (normalizedStalkerPortals.isEmpty()) { + prefs.remove(stalkerPortalsKeyFor(safeProfileId)) + } else { + prefs[stalkerPortalsKeyFor(safeProfileId)] = gson.toJson(normalizedStalkerPortals) + } + // Clear legacy single-portal keys so the list store is authoritative. + prefs.remove(stalkerPortalUrlKeyFor(safeProfileId)) + prefs.remove(stalkerMacAddressKeyFor(safeProfileId)) prefs[favoriteGroupsKeyFor(safeProfileId)] = gson.toJson(state.favoriteGroups.distinct()) prefs[favoriteChannelsKeyFor(safeProfileId)] = gson.toJson(state.favoriteChannels.distinct()) if (state.hiddenGroups.isNotEmpty()) { @@ -3257,7 +3494,7 @@ class IptvRepository @Inject constructor( } groupOrderLocallyDirty = false if (profileManager.getProfileIdSync() == safeProfileId) { - cachedStalkerApi = null + cachedStalkerApis = emptyMap() invalidateCache() } } @@ -7792,6 +8029,17 @@ class IptvRepository @Inject constructor( } } + private fun stalkerPortalSignature(config: IptvConfig): String = + config.stalkerPortals.joinToString(separator = "||") { portal -> + listOf( + portal.id.trim(), + portal.name.trim(), + portal.portalUrl.trim(), + portal.macAddress.trim(), + portal.enabled.toString() + ).joinToString("|") + } + private fun buildConfigSignature(config: IptvConfig): String { val digest = MessageDigest.getInstance("SHA-256") val playlistSignature = config.playlists.joinToString(separator = "||") { playlist -> @@ -7805,11 +8053,10 @@ class IptvRepository @Inject constructor( ).joinToString("|") } val raw = listOf( - "playlist-group-prefix-v5-xtream-category-order-catchup-history-48h", + "playlist-group-prefix-v6-stalker-portal-list-xtream-category-order-catchup-history-48h", config.m3uUrl.trim(), config.epgUrl.trim(), - config.stalkerPortalUrl.trim(), - config.stalkerMacAddress.trim(), + stalkerPortalSignature(config), playlistSignature ).joinToString("||") return digest.digest(raw.toByteArray(StandardCharsets.UTF_8)) @@ -7827,10 +8074,9 @@ class IptvRepository @Inject constructor( ).joinToString("|") } val raw = listOf( - "playlist-sources-v4-xtream-category-order-catchup-history-48h", + "playlist-sources-v5-stalker-portal-list-xtream-category-order-catchup-history-48h", config.m3uUrl.trim(), - config.stalkerPortalUrl.trim(), - config.stalkerMacAddress.trim(), + stalkerPortalSignature(config), playlistSignature ).joinToString("||") return digest.digest(raw.toByteArray(StandardCharsets.UTF_8)) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt new file mode 100644 index 000000000..1311e9eb1 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt @@ -0,0 +1,74 @@ +package com.arflix.tv.data.repository + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +/** Pseudo playlist id for the Stalker/Ministra portal source. */ +const val STALKER_PLAYLIST_ID = "stalker" + +/** Maximum number of Stalker portals a user can configure. */ +const val MAX_STALKER_PORTALS = 3 + +/** + * Pure helpers for the Stalker multi-portal model. Kept dependency-free so they + * can be unit-tested without an Android context. + */ +internal object StalkerPortalSupport { + + private val gson = Gson() + + /** + * Channel ids use the `stalker::` shape. Returns the + * portal id segment, or null when the id is not a Stalker channel or the + * portal segment is missing. + */ + fun portalIdFromChannelId(channelId: String): String? { + if (!channelId.startsWith("stalker:")) return null + val afterPrefix = channelId.removePrefix("stalker:") + val portalId = afterPrefix.substringBefore(':', missingDelimiterValue = "") + return portalId.takeIf { it.isNotBlank() } + } + + fun normalizeStalkerPortalEntry( + portal: StalkerPortalEntry, + index: Int + ): StalkerPortalEntry? { + val portalUrl = runCatching { portal.portalUrl }.getOrNull().orEmpty().trim().trimEnd('/') + val macAddress = runCatching { portal.macAddress }.getOrNull().orEmpty().trim().uppercase() + if (portalUrl.isBlank() || macAddress.isBlank()) return null + return StalkerPortalEntry( + id = runCatching { portal.id }.getOrNull().orEmpty().trim().ifBlank { "stalker${index + 1}" }, + name = runCatching { portal.name }.getOrNull().orEmpty().trim().ifBlank { "Portal ${index + 1}" }, + portalUrl = portalUrl, + macAddress = macAddress, + enabled = runCatching { portal.enabled }.getOrDefault(true) + ) + } + + fun decodeStalkerPortals(raw: String, maxPortals: Int): List { + if (raw.isBlank()) return emptyList() + return runCatching { + val type = TypeToken.getParameterized(List::class.java, StalkerPortalEntry::class.java).type + gson.fromJson>(raw, type) + ?.mapIndexed { index, portal -> normalizeStalkerPortalEntry(portal, index) } + ?.filterNotNull() + ?.take(maxPortals) + ?: emptyList() + }.getOrDefault(emptyList()) + } + + /** + * Builds the migrated Portal 1 entry from legacy single-portal fields. + */ + fun migratedPortalFromLegacy(portalUrl: String, macAddress: String): StalkerPortalEntry? { + val url = portalUrl.trim().trimEnd('/') + val mac = macAddress.trim().uppercase() + if (url.isBlank() || mac.isBlank()) return null + return StalkerPortalEntry( + id = "stalker1", + name = "Portal 1", + portalUrl = url, + macAddress = mac + ) + } +} 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..327b6f82f 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 @@ -77,6 +77,8 @@ import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Widgets import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete @@ -89,6 +91,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Speaker @@ -189,6 +192,8 @@ import com.arflix.tv.data.model.RuntimeKind import com.arflix.tv.data.repository.HomeServerConnection import com.arflix.tv.data.repository.HomeServerKind import com.arflix.tv.data.repository.IptvPlaylistEntry +import com.arflix.tv.data.repository.STALKER_PLAYLIST_ID +import com.arflix.tv.data.repository.StalkerPortalEntry import com.arflix.tv.ui.components.AppTopBar import com.arflix.tv.ui.components.AppTopBarContentTopInset import com.arflix.tv.ui.components.CatalogueRowLayoutToggleButton @@ -236,6 +241,14 @@ val LocalSettingsFocusTracker = compositionLocalOf { null private const val ACCOUNT_DELETION_URL = "https://auth.arvio.tv/delete" private const val PRIVACY_POLICY_URL = "https://arvio.tv/privacy" +/** + * Pseudo playlist id used for the Stalker/Ministra portal source. Stalker + * channels are stored with the `stalker:` id prefix, so the paged channel + * store reports them under this playlist id. Treating Stalker as a virtual + * playlist lets it reuse the same group-management UI as M3U playlists + * (categories / hide / sort / bulk toggle) without a separate code path. + */ + private val tvGeneralSectionIds = setOf( "language", "subtitles", @@ -274,6 +287,24 @@ internal fun orderedIptvGroups( return (explicitOrder + available).distinct() } +/** + * Highest sub-focus action index of a content row in the IPTV settings grid. + * Both M3U playlist rows and Stalker portal rows carry the same full chip row + * (categories / toggle / edit / up / down / delete) → max action index 5. The + * "add" rows (index 0 = add M3U, add Stalker portal) have no sub-focus chips. + */ +internal fun iptvRowMaxAction(): Int = 5 + +/** + * Focus index of the first category row inside the IPTV categories screen. + * Every source that has a bulk-toggle "show all / hide all" row (all Stalker + * portals and the legacy single Stalker source) gets it at index 1, between + * Reset and the categories, so its categories start at 2; sources without a + * bulk toggle start at 1. + */ +internal fun firstIptvGroupIndex(playlistId: String, orderedGroups: List, stalkerPortalIds: Set = emptySet()): Int = + if (orderedGroups.isNotEmpty() && (playlistId == STALKER_PLAYLIST_ID || playlistId in stalkerPortalIds)) 2 else 1 + private fun openExternalUrl(context: Context, url: String) { runCatching { context.startActivity( @@ -432,6 +463,12 @@ fun SettingsScreen( var showStalkerInput by remember { mutableStateOf(false) } var stalkerEditPortal by remember { mutableStateOf("") } var stalkerEditMac by remember { mutableStateOf("") } + var stalkerEditName by remember { mutableStateOf("") } + // null = adding a new portal; non-null = editing the portal with this id. + var stalkerEditId by remember { mutableStateOf(null) } + var showStalkerRename by remember { mutableStateOf(false) } + var stalkerRenameId by remember { mutableStateOf("") } + var stalkerRenameName by remember { mutableStateOf("") } var showCatalogInput by remember { mutableStateOf(false) } var catalogInputUrl by remember { mutableStateOf("") } var showSubtitlePicker by remember { mutableStateOf(false) } @@ -470,13 +507,17 @@ fun SettingsScreen( when (section) { in tvGeneralSectionIds -> (tvGeneralRowsForSection(section).size - 1).coerceAtLeast(0) "iptv" -> if (showIptvCategoriesSettings) { - orderedIptvGroups( + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() + val groups = orderedIptvGroups( playlistId = uiState.iptvSelectedPlaylistId.orEmpty(), availableGroups = uiState.iptvAvailableGroups, groupOrder = uiState.iptvGroupOrder - ).size // Reset row + category rows + ) + // Reset row + (bulk-toggle row) + category rows. + groups.size + (firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), groups, stalkerIds) - 1) } else { - 4 + uiState.iptvPlaylists.size // Add + rows + stalker + order + refresh + clear + // Add-M3U + M3U rows + Add-Stalker + Stalker rows + order + refresh + clear + 4 + uiState.iptvPlaylists.size + uiState.iptvStalkerPortals.size } "home_server" -> uiState.homeServerConnections.size + 3 "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs @@ -711,6 +752,7 @@ fun SettingsScreen( showCustomAddonInput || showIptvInput || showStalkerInput || + showStalkerRename || showHomeServerInput || showPlexHomeServerInput || showCatalogInput || @@ -812,13 +854,17 @@ fun SettingsScreen( Key.DirectionLeft -> { when (activeZone) { Zone.CONTENT -> { + val m3uCount = uiState.iptvPlaylists.size + val stalkerCount = uiState.iptvStalkerPortals.size + val stalkerStart = m3uCount + 2 + val stalkerEnd = m3uCount + 1 + stalkerCount if (currentSection == "stremio" && contentFocusIndex < stremioAddons.size && addonActionIndex > 0) { addonActionIndex-- } else if (currentSection == "iptv" && iptvActionIndex > 0 && ( showIptvCategoriesSettings && contentFocusIndex > 0 || - !showIptvCategoriesSettings && contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) + !showIptvCategoriesSettings && (contentFocusIndex in 1..m3uCount || contentFocusIndex in stalkerStart..stalkerEnd) ) ) { iptvActionIndex-- @@ -856,14 +902,21 @@ fun SettingsScreen( catalogActionIndex = 0 } Zone.CONTENT -> { + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() + val m3uCount = uiState.iptvPlaylists.size + val stalkerCount = uiState.iptvStalkerPortals.size + val stalkerStart = m3uCount + 2 + val stalkerEnd = m3uCount + 1 + stalkerCount if (currentSection == "stremio" && contentFocusIndex in 0 until stremioAddons.size && addonActionIndex < focusedStremioAddonMaxAction ) { addonActionIndex++ - } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex > 0 && iptvActionIndex < 2) { + } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex >= firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), orderedIptvGroups(uiState.iptvSelectedPlaylistId.orEmpty(), uiState.iptvAvailableGroups, uiState.iptvGroupOrder), stalkerIds) && iptvActionIndex < 2) { + iptvActionIndex++ + } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 1..m3uCount && iptvActionIndex < iptvRowMaxAction()) { iptvActionIndex++ - } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) && iptvActionIndex < 5) { + } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in stalkerStart..stalkerEnd && iptvActionIndex < iptvRowMaxAction()) { iptvActionIndex++ } else if (currentSection == "catalogs" && contentFocusIndex > 1 && catalogActionIndex < 5) { catalogActionIndex++ @@ -995,6 +1048,7 @@ fun SettingsScreen( } } "iptv" -> { + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() if (showIptvCategoriesSettings) { val playlistId = uiState.iptvSelectedPlaylistId.orEmpty() val orderedGroups = orderedIptvGroups( @@ -1002,12 +1056,20 @@ fun SettingsScreen( availableGroups = uiState.iptvAvailableGroups, groupOrder = uiState.iptvGroupOrder ) + val firstGroupIdx = firstIptvGroupIndex(playlistId, orderedGroups, stalkerIds) + val hasBulkToggle = orderedGroups.isNotEmpty() && (playlistId == STALKER_PLAYLIST_ID || playlistId in stalkerIds) when { contentFocusIndex == 0 -> { viewModel.resetIptvGroupOrder(playlistId) } - contentFocusIndex in 1..orderedGroups.size -> { - val group = orderedGroups.getOrNull(contentFocusIndex - 1) + contentFocusIndex == 1 && hasBulkToggle -> { + val allHidden = orderedGroups.all { + com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, it) in uiState.iptvHiddenGroups + } + viewModel.setAllIptvGroupsVisible(playlistId, visible = allHidden) + } + contentFocusIndex in firstGroupIdx..orderedGroups.size + (firstGroupIdx - 1) -> { + val group = orderedGroups.getOrNull(contentFocusIndex - firstGroupIdx) if (!group.isNullOrBlank()) { when (iptvActionIndex) { 0 -> viewModel.toggleIptvHiddenGroup(playlistId, group) @@ -1017,67 +1079,91 @@ fun SettingsScreen( } } } - } else when { - contentFocusIndex == 0 -> { - editingIptvIndex = -1 - showIptvInput = true - } - contentFocusIndex == 1 -> { - stalkerEditPortal = uiState.iptvStalkerUrl - stalkerEditMac = uiState.iptvStalkerMac - showStalkerInput = true - } - contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) -> { - val idx = contentFocusIndex - 2 - val updated = uiState.iptvPlaylists.toMutableList() - val playlist = updated.getOrNull(idx) - if (playlist != null) { - when (iptvActionIndex) { - 0 -> { - openIptvCategories(playlist.id) - } - 1 -> { - updated[idx] = playlist.copy(enabled = !playlist.enabled) - viewModel.saveIptvPlaylists(updated) - } - 2 -> { - editingIptvIndex = idx - showIptvInput = true - } - 3 -> { - if (idx > 0) { - val item = updated.removeAt(idx) - updated.add(idx - 1, item) + } else { + val m3uCount = uiState.iptvPlaylists.size + val stalkerCount = uiState.iptvStalkerPortals.size + val stalkerStart = m3uCount + 2 + val stalkerEnd = m3uCount + 1 + stalkerCount + when { + contentFocusIndex == 0 -> { + editingIptvIndex = -1 + showIptvInput = true + } + contentFocusIndex in 1..m3uCount -> { + val idx = contentFocusIndex - 1 + val updated = uiState.iptvPlaylists.toMutableList() + val playlist = updated.getOrNull(idx) + if (playlist != null) { + when (iptvActionIndex) { + 0 -> openIptvCategories(playlist.id) + 1 -> { + updated[idx] = playlist.copy(enabled = !playlist.enabled) viewModel.saveIptvPlaylists(updated) } - } - 4 -> { - if (idx < updated.lastIndex) { - val item = updated.removeAt(idx) - updated.add(idx + 1, item) + 2 -> { editingIptvIndex = idx; showIptvInput = true } + 3 -> { + if (idx > 0) { + val item = updated.removeAt(idx) + updated.add(idx - 1, item) + viewModel.saveIptvPlaylists(updated) + } + } + 4 -> { + if (idx < updated.lastIndex) { + val item = updated.removeAt(idx) + updated.add(idx + 1, item) + viewModel.saveIptvPlaylists(updated) + } + } + else -> { + updated.removeAt(idx) viewModel.saveIptvPlaylists(updated) } } - else -> { - updated.removeAt(idx) - viewModel.saveIptvPlaylists(updated) + } + } + contentFocusIndex == m3uCount + 1 -> { + // Add Stalker portal + stalkerEditId = null + stalkerEditPortal = "" + stalkerEditMac = "" + stalkerEditName = "" + showStalkerInput = true + } + contentFocusIndex in stalkerStart..stalkerEnd -> { + val sIdx = contentFocusIndex - stalkerStart + val portal = uiState.iptvStalkerPortals.getOrNull(sIdx) + if (portal != null) { + when (iptvActionIndex) { + 0 -> openIptvCategories(portal.id) + 1 -> viewModel.onToggleStalkerPortal(portal.id) + 2 -> { + stalkerEditId = portal.id + stalkerEditPortal = portal.portalUrl + stalkerEditMac = portal.macAddress + stalkerEditName = portal.name + showStalkerInput = true + } + 3 -> viewModel.onMoveStalkerPortalUp(portal.id) + 4 -> viewModel.onMoveStalkerPortalDown(portal.id) + else -> viewModel.onRemoveStalkerPortal(portal.id) } } } - } - contentFocusIndex == uiState.iptvPlaylists.size + 2 -> { - val next = when (uiState.iptvSortOrder) { - "provider" -> "number" - "number" -> "name" - else -> "provider" + contentFocusIndex == m3uCount + stalkerCount + 2 -> { + val next = when (uiState.iptvSortOrder) { + "provider" -> "number" + "number" -> "name" + else -> "provider" + } + viewModel.setIptvSortOrder(next) + } + contentFocusIndex == m3uCount + stalkerCount + 3 -> { + viewModel.refreshIptv(force = true) + } + contentFocusIndex == m3uCount + stalkerCount + 4 -> { + viewModel.clearIptvConfig() } - viewModel.setIptvSortOrder(next) - } - contentFocusIndex == uiState.iptvPlaylists.size + 3 -> { - viewModel.refreshIptv(force = true) - } - contentFocusIndex == uiState.iptvPlaylists.size + 4 -> { - viewModel.clearIptvConfig() } } } @@ -1286,7 +1372,6 @@ fun SettingsScreen( onSubtitleAiApiKeyClick = { showAiApiKeyDialog = true }, onSubtitleAiQrClick = { viewModel.startAiKeyServer() }, onAddIptvClick = { editingIptvIndex = -1; showIptvInput = true }, - onConfigureStalkerClick = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, onEditIptvClick = { idx -> editingIptvIndex = idx; showIptvInput = true }, onAddCatalogClick = { showCatalogInput = true }, onImportCatalogPackClick = { showCatalogPackInput = true }, @@ -1580,7 +1665,9 @@ fun SettingsScreen( onToggleHidden = { viewModel.toggleIptvHiddenGroup(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, - onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") } + onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") }, + showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID || (uiState.iptvSelectedPlaylistId ?: "") in uiState.iptvStalkerPortals.map { it.id }, + onBulkToggle = { visible -> viewModel.setAllIptvGroupsVisible(uiState.iptvSelectedPlaylistId ?: "", visible) } ) } else IptvSettings( playlists = uiState.iptvPlaylists, @@ -1594,8 +1681,15 @@ fun SettingsScreen( focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = iptvActionIndex, onConfigure = { editingIptvIndex = -1; showIptvInput = true }, - onConfigureStalker = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, - stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), + stalkerPortals = uiState.iptvStalkerPortals, + onAddStalkerPortal = { stalkerEditId = null; stalkerEditPortal = ""; stalkerEditMac = ""; stalkerEditName = ""; showStalkerInput = true }, + onEditStalkerPortal = { portal -> stalkerEditId = portal.id; stalkerEditPortal = portal.portalUrl; stalkerEditMac = portal.macAddress; stalkerEditName = portal.name; showStalkerInput = true }, + onToggleStalkerPortal = { id -> viewModel.onToggleStalkerPortal(id) }, + onMoveStalkerPortalUp = { id -> viewModel.onMoveStalkerPortalUp(id) }, + onMoveStalkerPortalDown = { id -> viewModel.onMoveStalkerPortalDown(id) }, + onRemoveStalkerPortal = { id -> viewModel.onRemoveStalkerPortal(id) }, + onManageStalkerCategories = { id -> openIptvCategories(id) }, + onRenameStalkerPortal = { portal -> stalkerRenameId = portal.id; stalkerRenameName = portal.name; showStalkerRename = true }, onEditPlaylist = { idx -> editingIptvIndex = idx; showIptvInput = true }, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -1642,8 +1736,15 @@ fun SettingsScreen( focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = iptvActionIndex, onConfigure = { editingIptvIndex = -1; showIptvInput = true }, - onConfigureStalker = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, - stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), + stalkerPortals = uiState.iptvStalkerPortals, + onAddStalkerPortal = { stalkerEditId = null; stalkerEditPortal = ""; stalkerEditMac = ""; stalkerEditName = ""; showStalkerInput = true }, + onEditStalkerPortal = { portal -> stalkerEditId = portal.id; stalkerEditPortal = portal.portalUrl; stalkerEditMac = portal.macAddress; stalkerEditName = portal.name; showStalkerInput = true }, + onToggleStalkerPortal = { id -> viewModel.onToggleStalkerPortal(id) }, + onMoveStalkerPortalUp = { id -> viewModel.onMoveStalkerPortalUp(id) }, + onMoveStalkerPortalDown = { id -> viewModel.onMoveStalkerPortalDown(id) }, + onRemoveStalkerPortal = { id -> viewModel.onRemoveStalkerPortal(id) }, + onManageStalkerCategories = { id -> openIptvCategories(id) }, + onRenameStalkerPortal = { portal -> stalkerRenameId = portal.id; stalkerRenameName = portal.name; showStalkerRename = true }, onEditPlaylist = { idx -> editingIptvIndex = idx; showIptvInput = true }, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -1986,10 +2087,16 @@ fun SettingsScreen( ) } if (showStalkerInput) { + val isEditing = stalkerEditId != null InputModal( - title = stringResource(R.string.settings_stalker_title), - supportingText = if (uiState.iptvStalkerUrl.isNotBlank()) stringResource(R.string.settings_stalker_remove_hint) else null, + title = stringResource(if (isEditing) R.string.settings_cd_edit_stalker_config else R.string.settings_add_stalker_portal_button), fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerEditName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerEditName = it } + ), InputField( label = stringResource(R.string.settings_stalker_label_portal_url), value = stalkerEditPortal, @@ -2004,12 +2111,35 @@ fun SettingsScreen( ) ), onConfirm = { - viewModel.saveStalkerConfig(stalkerEditPortal.trim(), stalkerEditMac.trim()) + val id = stalkerEditId + if (id != null) { + viewModel.onEditStalkerPortal(id, stalkerEditPortal, stalkerEditMac, stalkerEditName) + } else { + viewModel.onAddStalkerPortal(stalkerEditPortal, stalkerEditMac, stalkerEditName) + } showStalkerInput = false }, onDismiss = { showStalkerInput = false } ) } + if (showStalkerRename) { + InputModal( + title = stringResource(R.string.settings_stalker_rename_title), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerRenameName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerRenameName = it } + ) + ), + onConfirm = { + viewModel.onRenameStalkerPortal(stalkerRenameId, stalkerRenameName) + showStalkerRename = false + }, + onDismiss = { showStalkerRename = false } + ) + } @@ -4166,6 +4296,14 @@ private fun MobileSettingsSubPage( ) { val scrollState = rememberScrollState() + var showStalkerInput by remember { mutableStateOf(false) } + var stalkerEditId by remember { mutableStateOf(null) } + var stalkerEditPortal by remember { mutableStateOf("") } + var stalkerEditMac by remember { mutableStateOf("") } + var stalkerEditName by remember { mutableStateOf("") } + var showStalkerRename by remember { mutableStateOf(false) } + var stalkerRenameId by remember { mutableStateOf("") } + var stalkerRenameName by remember { mutableStateOf("") } Column( modifier = Modifier .fillMaxSize() @@ -4548,8 +4686,15 @@ private fun MobileSettingsSubPage( focusedIndex = -1, focusedActionIndex = 0, onConfigure = onAddIptvClick, - onConfigureStalker = onConfigureStalkerClick, - stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), + stalkerPortals = uiState.iptvStalkerPortals, + onAddStalkerPortal = { stalkerEditId = null; stalkerEditPortal = ""; stalkerEditMac = ""; stalkerEditName = ""; showStalkerInput = true }, + onEditStalkerPortal = { portal -> stalkerEditId = portal.id; stalkerEditPortal = portal.portalUrl; stalkerEditMac = portal.macAddress; stalkerEditName = portal.name; showStalkerInput = true }, + onToggleStalkerPortal = { id -> viewModel.onToggleStalkerPortal(id) }, + onMoveStalkerPortalUp = { id -> viewModel.onMoveStalkerPortalUp(id) }, + onMoveStalkerPortalDown = { id -> viewModel.onMoveStalkerPortalDown(id) }, + onRemoveStalkerPortal = { id -> viewModel.onRemoveStalkerPortal(id) }, + onManageStalkerCategories = { id -> viewModel.setIptvSelectedPlaylistId(id); onNavigate("IPTV_CATEGORIES") }, + onRenameStalkerPortal = { portal -> stalkerRenameId = portal.id; stalkerRenameName = portal.name; showStalkerRename = true }, onEditPlaylist = onEditIptvClick, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -4599,7 +4744,9 @@ private fun MobileSettingsSubPage( onToggleHidden = { viewModel.toggleIptvHiddenGroup(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, - onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") } + onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") }, + showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID || (uiState.iptvSelectedPlaylistId ?: "") in uiState.iptvStalkerPortals.map { it.id }, + onBulkToggle = { visible -> viewModel.setAllIptvGroupsVisible(uiState.iptvSelectedPlaylistId ?: "", visible) } ) } "Home Server" -> { @@ -4650,6 +4797,61 @@ private fun MobileSettingsSubPage( } } } + + if (showStalkerInput) { + val isEditing = stalkerEditId != null + InputModal( + title = stringResource(if (isEditing) R.string.settings_cd_edit_stalker_config else R.string.settings_add_stalker_portal_button), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerEditName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerEditName = it } + ), + InputField( + label = stringResource(R.string.settings_stalker_label_portal_url), + value = stalkerEditPortal, + placeholder = stringResource(R.string.settings_stalker_ph_portal_url), + onValueChange = { stalkerEditPortal = it } + ), + InputField( + label = stringResource(R.string.settings_stalker_label_mac), + value = stalkerEditMac, + placeholder = stringResource(R.string.settings_stalker_ph_mac), + onValueChange = { stalkerEditMac = it } + ) + ), + onConfirm = { + val id = stalkerEditId + if (id != null) { + viewModel.onEditStalkerPortal(id, stalkerEditPortal, stalkerEditMac, stalkerEditName) + } else { + viewModel.onAddStalkerPortal(stalkerEditPortal, stalkerEditMac, stalkerEditName) + } + showStalkerInput = false + }, + onDismiss = { showStalkerInput = false } + ) + } + if (showStalkerRename) { + InputModal( + title = stringResource(R.string.settings_stalker_rename_title), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerRenameName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerRenameName = it } + ) + ), + onConfirm = { + viewModel.onRenameStalkerPortal(stalkerRenameId, stalkerRenameName) + showStalkerRename = false + }, + onDismiss = { showStalkerRename = false } + ) + } } @Composable @@ -6754,12 +6956,21 @@ private fun IptvSettings( onManageCategories: (String) -> Unit = {}, sortOrder: String = "provider", onSortOrderChange: (String) -> Unit = {}, - onConfigureStalker: () -> Unit = {}, - stalkerSubtitle: String = "" + stalkerPortals: List = emptyList(), + onAddStalkerPortal: () -> Unit = {}, + onEditStalkerPortal: (StalkerPortalEntry) -> Unit = {}, + onToggleStalkerPortal: (String) -> Unit = {}, + onMoveStalkerPortalUp: (String) -> Unit = {}, + onMoveStalkerPortalDown: (String) -> Unit = {}, + onRemoveStalkerPortal: (String) -> Unit = {}, + onManageStalkerCategories: (String) -> Unit = {}, + onRenameStalkerPortal: (StalkerPortalEntry) -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } var selectedIndices by remember { mutableStateOf(setOf()) } + // Tracks which section the current mobile selection belongs to ("m3u" or "stalker") + var selectionSection by remember { mutableStateOf("m3u") } if (!isMobile) { LaunchedEffect(focusedIndex) { @@ -6779,7 +6990,16 @@ private fun IptvSettings( Text(stringResource(R.string.settings_n_selected, selectedIndices.size), style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.weight(1f)) if (selectedIndices.isNotEmpty()) { - Box(modifier = Modifier.size(36.dp).clickable { selectedIndices.sortedDescending().forEach { onDeletePlaylist(it) }; selectionMode = false; selectedIndices = emptySet() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(36.dp).clickable { + selectedIndices.sortedDescending().forEach { i -> + if (selectionSection == "stalker") { + stalkerPortals.getOrNull(i)?.let { onRemoveStalkerPortal(it.id) } + } else { + onDeletePlaylist(i) + } + } + selectionMode = false; selectedIndices = emptySet() + }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete), tint = Color.White, modifier = Modifier.size(20.dp)) } Spacer(modifier = Modifier.width(12.dp)) @@ -6791,7 +7011,6 @@ private fun IptvSettings( } MobileSettingsCategory(title = stringResource(R.string.settings_section_playlists)) { MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_tv_lists_hint) else stringResource(R.string.settings_create_another_tv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full_short) else "", isFocused = false, showDivider = true, onClick = onConfigure) - MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.settings_add_stalker_portal), subtitle = stalkerSubtitle, value = "", isFocused = false, showDivider = playlists.isNotEmpty(), onClick = onConfigureStalker) playlists.forEachIndexed { index, playlist -> val isSelected = selectedIndices.contains(index) val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } @@ -6801,7 +7020,7 @@ private fun IptvSettings( .background(if (isSelected) Pink.copy(alpha = 0.15f) else Color.Transparent) .then(Modifier.combinedClickable( onClick = { if (selectionMode) { selectedIndices = if (isSelected) selectedIndices - index else selectedIndices + index; if (selectedIndices.isEmpty()) selectionMode = false } else onEditPlaylist(index) }, - onLongClick = { if (!selectionMode) { selectionMode = true; selectedIndices = setOf(index) } } + onLongClick = { if (!selectionMode) { selectionSection = "m3u"; selectionMode = true; selectedIndices = setOf(index) } } )) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically @@ -6845,6 +7064,68 @@ private fun IptvSettings( } } } + MobileSettingsCategory(title = stringResource(R.string.settings_section_stalker_portals)) { + val stalkerFull = stalkerPortals.size >= 3 + MobileSettingsRow( + icon = Icons.Default.Add, + title = stringResource(R.string.settings_add_stalker_portal_button), + subtitle = if (stalkerPortals.isEmpty()) stringResource(R.string.settings_stalker_add_hint) else stringResource(R.string.settings_stalker_create_another), + value = if (stalkerFull) stringResource(R.string.settings_badge_full_short) else "", + isFocused = false, + showDivider = stalkerPortals.isNotEmpty(), + onClick = { if (!stalkerFull) onAddStalkerPortal() } + ) + stalkerPortals.forEachIndexed { index, portal -> + val isSelected = selectedIndices.contains(index) + Column { + Row( + modifier = Modifier.fillMaxWidth() + .background(if (isSelected) Pink.copy(alpha = 0.15f) else Color.Transparent) + .then(Modifier.combinedClickable( + onClick = { if (selectionMode) { selectedIndices = if (isSelected) selectedIndices - index else selectedIndices + index; if (selectedIndices.isEmpty()) selectionMode = false } else onEditStalkerPortal(portal) }, + onLongClick = { if (!selectionMode) { selectionSection = "stalker"; selectionMode = true; selectedIndices = setOf(index) } } + )) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (selectionMode) { + Icon(imageVector = if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, contentDescription = null, tint = if (isSelected) Pink else TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + } else { + Icon(imageVector = Icons.Default.LiveTv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text(portal.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(portal.portalUrl.take(56), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (selectionMode && selectedIndices.size == 1 && isSelected) { + Icon(imageVector = Icons.Default.DragHandle, contentDescription = stringResource(R.string.settings_cd_drag_reorder), tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(index) { + var dragOffset = 0f; val itemHeight = 64.dp.toPx() + detectVerticalDragGestures(onDragEnd = { dragOffset = 0f }, onDragCancel = { dragOffset = 0f }) { change, dragAmount -> change.consume(); dragOffset += dragAmount; if (dragOffset > itemHeight) { onMoveStalkerPortalDown(portal.id); dragOffset -= itemHeight } else if (dragOffset < -itemHeight) { onMoveStalkerPortalUp(portal.id); dragOffset += itemHeight } } + }) + } else if (!selectionMode) { + Icon( + imageVector = Icons.Default.List, + contentDescription = stringResource(R.string.settings_cd_manage_stalker_categories), + tint = TextSecondary, + modifier = Modifier + .size(36.dp) + .clickable { onManageStalkerCategories(portal.id) } + .padding(6.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.width(44.dp).height(24.dp).background(color = if (portal.enabled) SuccessGreen else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(13.dp)).clickable { onToggleStalkerPortal(portal.id) }.padding(3.dp), contentAlignment = if (portal.enabled) Alignment.CenterEnd else Alignment.CenterStart) { + Box(modifier = Modifier.size(18.dp).background(color = Color.White, shape = RoundedCornerShape(10.dp))) + } + } + } + if (index < stalkerPortals.size - 1) { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Color.White.copy(alpha = 0.05f))) + } + } + } + } MobileSettingsCategory(title = stringResource(R.string.settings_section_options)) { val sortDisplayValue = when (sortOrder) { "number" -> stringResource(R.string.settings_iptv_order_number) @@ -6884,13 +7165,13 @@ private fun IptvSettings( 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)) Spacer(modifier = Modifier.height(6.dp)) - SettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.settings_add_stalker_portal), subtitle = stalkerSubtitle, value = stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 1, onClick = onConfigureStalker, modifier = Modifier.settingsFocusSlot(1)) - Spacer(modifier = Modifier.height(16.dp)) playlists.forEachIndexed { index, playlist -> - val rowIndex = index + 2 + val rowIndex = index + 1 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) { + Icon(imageVector = Icons.Default.LiveTv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) 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)) @@ -6936,6 +7217,71 @@ private fun IptvSettings( Spacer(modifier = Modifier.height(10.dp)) } Spacer(modifier = Modifier.height(6.dp)) + // ── Stalker portals section (separate from M3U, decision #4) ── + val stalkerAddIndex = playlists.size + 1 + val stalkerFull = stalkerPortals.size >= 3 + SettingsRow( + icon = Icons.Default.Add, + title = stringResource(R.string.settings_add_stalker_portal_button), + subtitle = if (stalkerPortals.isEmpty()) stringResource(R.string.settings_stalker_add_hint) else stringResource(R.string.settings_stalker_create_another), + value = if (stalkerFull) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), + isFocused = focusedIndex == stalkerAddIndex, + onClick = { if (!stalkerFull) onAddStalkerPortal() }, + modifier = Modifier.settingsFocusSlot(stalkerAddIndex) + ) + Spacer(modifier = Modifier.height(6.dp)) + stalkerPortals.forEachIndexed { index, portal -> + val rowIndex = playlists.size + 2 + index + 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 { onEditStalkerPortal(portal) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = Icons.Default.LiveTv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(portal.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)) + Text(portal.portalUrl.take(56), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + CatalogActionChip( + icon = Icons.Default.List, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 0, + onClick = { onManageStalkerCategories(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = if (portal.enabled) Icons.Default.Check else Icons.Default.VisibilityOff, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 1, + onClick = { onToggleStalkerPortal(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Edit, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 2, + onClick = { onEditStalkerPortal(portal) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.ArrowUpward, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 3, + onClick = { onMoveStalkerPortalUp(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.ArrowDownward, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 4, + onClick = { onMoveStalkerPortalDown(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Delete, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 5, + isDestructive = true, + onClick = { onRemoveStalkerPortal(portal.id) } + ) + } + Spacer(modifier = Modifier.height(10.dp)) + } + Spacer(modifier = Modifier.height(6.dp)) + val trailingBase = playlists.size + stalkerPortals.size + 2 SettingsRow( icon = Icons.Default.List, title = stringResource(R.string.settings_iptv_channel_order), @@ -6945,7 +7291,7 @@ private fun IptvSettings( "name" -> stringResource(R.string.settings_iptv_order_name) else -> stringResource(R.string.settings_iptv_order_provider) }, - isFocused = focusedIndex == playlists.size + 2, + isFocused = focusedIndex == trailingBase, onClick = { val next = when (sortOrder) { "provider" -> "number" @@ -6954,13 +7300,14 @@ private fun IptvSettings( } onSortOrderChange(next) }, - modifier = Modifier.settingsFocusSlot(playlists.size + 2) + modifier = Modifier.settingsFocusSlot(trailingBase) ) 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 == trailingBase + 1, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(trailingBase + 1)) 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)) + val hasNoSources = playlists.isEmpty() && stalkerPortals.isEmpty() + SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (hasNoSources) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = if (hasNoSources) stringResource(R.string.settings_badge_empty) else stringResource(R.string.settings_badge_delete), isFocused = focusedIndex == trailingBase + 2, onClick = onDelete, modifier = Modifier.settingsFocusSlot(trailingBase + 2)) 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) @@ -10880,7 +11227,9 @@ private fun IptvCategoriesSettings( onToggleHidden: (String) -> Unit, onMoveUp: (String) -> Unit, onMoveDown: (String) -> Unit, - onReset: () -> Unit + onReset: () -> Unit, + showBulkToggle: Boolean = false, + onBulkToggle: (visible: Boolean) -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() val orderedGroups = remember(groupOrder, availableGroups, playlistId) { @@ -10891,10 +11240,20 @@ private fun IptvCategoriesSettings( ) } val categoryListState = rememberLazyListState() + // When the bulk toggle is shown it occupies focus index 1, so the first + // category row shifts to index 2 (was 1). Reset stays at index 0. + val firstGroupIndex = if (showBulkToggle) 2 else 1 + + // Bulk button reflects the current state: when every category is hidden + // it offers "Show all", otherwise "Hide all". + val allCategoriesHidden = remember(orderedGroups, hiddenGroups, playlistId) { + if (orderedGroups.isEmpty()) false + else orderedGroups.all { com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, it) in hiddenGroups } + } LaunchedEffect(isMobile, focusedIndex, orderedGroups.size) { - if (!isMobile && focusedIndex > 0 && orderedGroups.isNotEmpty()) { - categoryListState.animateScrollToItem((focusedIndex - 1).coerceIn(0, orderedGroups.lastIndex)) + if (!isMobile && focusedIndex >= firstGroupIndex && orderedGroups.isNotEmpty()) { + categoryListState.animateScrollToItem((focusedIndex - firstGroupIndex).coerceIn(0, orderedGroups.lastIndex)) } } @@ -10918,6 +11277,33 @@ private fun IptvCategoriesSettings( modifier = Modifier.settingsFocusSlot(0) ) + if (showBulkToggle && orderedGroups.isNotEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + val bulkTitle = if (allCategoriesHidden) stringResource(R.string.settings_iptv_show_all) else stringResource(R.string.settings_iptv_hide_all) + val bulkSubtitle = if (allCategoriesHidden) stringResource(R.string.settings_iptv_show_all_desc) else stringResource(R.string.settings_iptv_hide_all_desc) + val bulkIcon = if (allCategoriesHidden) Icons.Default.Visibility else Icons.Default.VisibilityOff + if (isMobile) { + MobileSettingsRow( + icon = bulkIcon, + title = bulkTitle, + subtitle = bulkSubtitle, + value = "", + onClick = { onBulkToggle(allCategoriesHidden) }, + showDivider = true + ) + } else { + SettingsRow( + icon = bulkIcon, + title = bulkTitle, + subtitle = bulkSubtitle, + value = stringResource(R.string.settings_badge_reset), + isFocused = focusedIndex == 1, + onClick = { onBulkToggle(allCategoriesHidden) }, + modifier = Modifier.settingsFocusSlot(1) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) if (isMobile) { @@ -10965,7 +11351,7 @@ private fun IptvCategoriesSettings( items = orderedGroups, key = { _, group -> com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, group) } ) { index, group -> - val rowFocusIndex = index + 1 + val rowFocusIndex = index + firstGroupIndex val isRowFocused = focusedIndex == rowFocusIndex val groupKey = com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, group) val isHidden = hiddenGroups.contains(groupKey) 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 3dc49da2f..b2f41820b 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 @@ -34,8 +34,10 @@ import com.arflix.tv.data.repository.HomeServerRepository import com.arflix.tv.data.repository.PlexPinAuthSession import com.arflix.tv.data.repository.IptvConfig import com.arflix.tv.data.repository.IptvRepository +import com.arflix.tv.data.repository.MAX_STALKER_PORTALS import com.arflix.tv.data.repository.normalizeIptvSortOrder import com.arflix.tv.data.repository.IptvPlaylistEntry +import com.arflix.tv.data.repository.StalkerPortalEntry import com.arflix.tv.data.repository.LauncherContinueWatchingRepository import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.ProfileManager @@ -172,8 +174,7 @@ data class SettingsUiState( val iptvM3uUrl: String = "", val iptvEpgUrl: String = "", val iptvPlaylists: List = emptyList(), - val iptvStalkerUrl: String = "", - val iptvStalkerMac: String = "", + val iptvStalkerPortals: List = emptyList(), val iptvSortOrder: String = "provider", val iptvChannelCount: Int = 0, val isIptvLoading: Boolean = false, @@ -882,22 +883,33 @@ class SettingsViewModel @Inject constructor( } private suspend fun loadIptvGroupsForPlaylist(playlistId: String): List { - val pagedGroups = withContext(Dispatchers.IO) { - iptvRepository.pagedPlaylistGroupCounts() - .asSequence() - .filter { (id, _, count) -> id == playlistId && count > 0 } - .map { (_, group, _) -> group.trim().ifBlank { "Ungrouped" } } - .distinct() - .toList() + // Stalker channel ids use the `stalker::` shape, so the + // paged channel store reports them under playlist_id "stalker" (the text + // before the first colon) — not the individual portal id. For Stalker + // portals we therefore skip the paged path and read groups from the + // in-memory snapshot, filtering by the full `stalker::` prefix. + val stalkerPortalIds = _uiState.value.iptvStalkerPortals.map { it.id }.toSet() + val isStalkerPortal = playlistId in stalkerPortalIds + + if (!isStalkerPortal) { + val pagedGroups = withContext(Dispatchers.IO) { + iptvRepository.pagedPlaylistGroupCounts() + .asSequence() + .filter { (id, _, count) -> id == playlistId && count > 0 } + .map { (_, group, _) -> group.trim().ifBlank { "Ungrouped" } } + .distinct() + .toList() + } + if (pagedGroups.isNotEmpty()) return pagedGroups } - if (pagedGroups.isNotEmpty()) return pagedGroups val snapshot = iptvRepository.getMemoryCachedSnapshot() ?: iptvRepository.getCachedSnapshotOrNull() + val prefix = if (isStalkerPortal) "stalker:$playlistId:" else "$playlistId:" return withContext(Dispatchers.Default) { snapshot?.channels ?.asSequence() - ?.filter { it.id.startsWith("$playlistId:") } + ?.filter { it.id.startsWith(prefix) } ?.map { it.group.trim().ifBlank { "Ungrouped" } } ?.distinct() ?.toList() @@ -929,6 +941,20 @@ class SettingsViewModel @Inject constructor( } } + /** + * Bulk-show or bulk-hide all groups of a playlist at once. Drives the + * "show all / hide all" button in the categories screen. The current + * available groups are taken from the UI state so the operation only + * touches groups that actually belong to the selected playlist. + */ + fun setAllIptvGroupsVisible(playlistId: String, visible: Boolean) { + viewModelScope.launch { + val groups = _uiState.value.iptvAvailableGroups + if (groups.isEmpty()) return@launch + iptvRepository.setGroupsHidden(playlistId, groups, hidden = !visible) + } + } + // ========== App Updates ========== fun performFullSync(silent: Boolean = false) { @@ -1940,23 +1966,23 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { iptvRepository.observeConfig().collect { config -> val current = _uiState.value - if (current.iptvM3uUrl != config.m3uUrl || current.iptvEpgUrl != config.epgUrl || current.iptvStalkerUrl != config.stalkerPortalUrl || current.iptvStalkerMac != config.stalkerMacAddress || current.iptvPlaylists != config.playlists || current.iptvSortOrder != config.sortOrder) { + val stalkerConfigured = config.stalkerPortals.any { it.portalUrl.isNotBlank() } + if (current.iptvM3uUrl != config.m3uUrl || current.iptvEpgUrl != config.epgUrl || current.iptvStalkerPortals != config.stalkerPortals || current.iptvPlaylists != config.playlists || current.iptvSortOrder != config.sortOrder) { _uiState.value = current.copy( iptvM3uUrl = config.m3uUrl, iptvEpgUrl = config.epgUrl, iptvPlaylists = config.playlists, - iptvStalkerUrl = config.stalkerPortalUrl, - iptvStalkerMac = config.stalkerMacAddress, + iptvStalkerPortals = config.stalkerPortals, iptvSortOrder = config.sortOrder ) } if (!hasObservedIptvConfig) { hasObservedIptvConfig = true lastObservedIptvM3u = config.m3uUrl - lastObservedStalkerUrl = config.stalkerPortalUrl + lastObservedStalkerUrl = if (stalkerConfigured) "stalker" else "" lastObservedIptvConfigSignature = config.syncSignature() val hasAnyIptvConfig = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + stalkerConfigured || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } if (!hasAnyIptvConfig) { _uiState.value = _uiState.value.copy( @@ -1973,12 +1999,12 @@ class SettingsViewModel @Inject constructor( } val hasAnyConfig = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + stalkerConfigured || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } val configSignature = config.syncSignature() if (hasAnyConfig && configSignature != lastObservedIptvConfigSignature) { lastObservedIptvM3u = config.m3uUrl - lastObservedStalkerUrl = config.stalkerPortalUrl + lastObservedStalkerUrl = if (stalkerConfigured) "stalker" else "" lastObservedIptvConfigSignature = configSignature if (iptvLoadJob?.isActive != true) { refreshIptv(showToast = false, force = false) @@ -2292,38 +2318,136 @@ class SettingsViewModel @Inject constructor( } } - fun saveStalkerConfig(portalUrl: String, macAddress: String) { + /** + * Add a new Stalker portal at the end of the list (capped at + * [MAX_STALKER_PORTALS]). Returns false (with a toast) when + * the limit is reached or the URL/MAC are blank. + */ + fun onAddStalkerPortal(portalUrl: String, macAddress: String, name: String? = null) { + val trimmedUrl = portalUrl.trim().trimEnd('/') + val trimmedMac = macAddress.trim().uppercase() + if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { + _uiState.value = _uiState.value.copy( + toastMessage = "Portal URL and MAC address are required", + toastType = ToastType.ERROR + ) + return + } + val current = _uiState.value.iptvStalkerPortals + if (current.size >= MAX_STALKER_PORTALS) { + _uiState.value = _uiState.value.copy( + toastMessage = "Maximum number of Stalker portals reached", + toastType = ToastType.ERROR + ) + return + } + val index = current.size + val portal = StalkerPortalEntry( + id = "stalker${index + 1}", + name = name?.trim()?.ifBlank { null } ?: "Portal ${index + 1}", + portalUrl = trimmedUrl, + macAddress = trimmedMac + ) + persistStalkerPortals(current + portal) + } + + /** + * Update an existing portal's URL/MAC (and optionally its name). The edit + * dialog calls this with the portal's id. + */ + fun onEditStalkerPortal(portalId: String, portalUrl: String, macAddress: String, name: String? = null) { + val trimmedUrl = portalUrl.trim().trimEnd('/') + val trimmedMac = macAddress.trim().uppercase() + if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { + _uiState.value = _uiState.value.copy( + toastMessage = "Portal URL and MAC address are required", + toastType = ToastType.ERROR + ) + return + } + val updated = _uiState.value.iptvStalkerPortals.map { portal -> + if (portal.id == portalId) portal.copy( + portalUrl = trimmedUrl, + macAddress = trimmedMac, + name = name?.trim()?.ifBlank { portal.name } ?: portal.name + ) else portal + } + if (updated == _uiState.value.iptvStalkerPortals) return + persistStalkerPortals(updated) + } + + /** + * Rename a portal without touching its URL/MAC. + */ + fun onRenameStalkerPortal(portalId: String, name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + val updated = _uiState.value.iptvStalkerPortals.map { portal -> + if (portal.id == portalId) portal.copy(name = trimmed) else portal + } + if (updated == _uiState.value.iptvStalkerPortals) return viewModelScope.launch { - val trimmedUrl = portalUrl.trim() - val trimmedMac = macAddress.trim() - if (trimmedUrl.isBlank() && trimmedMac.isBlank()) { - removeStalkerConfigInternal() - return@launch - } - if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { - _uiState.value = _uiState.value.copy(toastMessage = "Portal URL and MAC address are required", toastType = ToastType.ERROR) - return@launch - } - iptvRepository.saveStalkerConfig(trimmedUrl, trimmedMac) + iptvRepository.saveStalkerPortals(updated) + _uiState.value = _uiState.value.copy(iptvStalkerPortals = updated) syncLocalStateToCloud(silent = true) - refreshIptv(showToast = true, configured = true, force = true) } } - fun removeStalkerConfig() { - viewModelScope.launch { removeStalkerConfigInternal() } + /** Enable / disable a portal. */ + fun onToggleStalkerPortal(portalId: String) { + val updated = _uiState.value.iptvStalkerPortals.map { portal -> + if (portal.id == portalId) portal.copy(enabled = !portal.enabled) else portal + } + if (updated == _uiState.value.iptvStalkerPortals) return + persistStalkerPortals(updated) + } + + fun onMoveStalkerPortalUp(portalId: String) { + val current = _uiState.value.iptvStalkerPortals.toMutableList() + val idx = current.indexOfFirst { it.id == portalId } + if (idx <= 0) return + val item = current.removeAt(idx) + current.add(idx - 1, item) + persistStalkerPortals(current) } - private suspend fun removeStalkerConfigInternal() { - iptvRepository.clearStalkerConfig() - _uiState.value = _uiState.value.copy( - iptvStalkerUrl = "", - iptvStalkerMac = "", - toastMessage = "Stalker portal removed", - toastType = ToastType.SUCCESS - ) - syncLocalStateToCloud(silent = true) - refreshIptv(showToast = false, configured = true, force = true) + fun onMoveStalkerPortalDown(portalId: String) { + val current = _uiState.value.iptvStalkerPortals.toMutableList() + val idx = current.indexOfFirst { it.id == portalId } + if (idx !in 0 until current.lastIndex) return + val item = current.removeAt(idx) + current.add(idx + 1, item) + persistStalkerPortals(current) + } + + fun onRemoveStalkerPortal(portalId: String) { + val updated = _uiState.value.iptvStalkerPortals.filterNot { it.id == portalId } + if (updated == _uiState.value.iptvStalkerPortals) return + persistStalkerPortals(updated) + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + toastMessage = "Stalker portal removed", + toastType = ToastType.SUCCESS + ) + } + } + + /** + * Open the categories dialog for a specific Stalker portal. Each portal has + * its own independent group set (decision #3) — the portal id is used as + * the playlist id for [PlaylistGroupKey] and hidden-group filtering. + */ + fun onManageStalkerCategories(portalId: String) { + setIptvSelectedPlaylistId(portalId) + } + + private fun persistStalkerPortals(portals: List) { + viewModelScope.launch { + iptvRepository.saveStalkerPortals(portals) + _uiState.value = _uiState.value.copy(iptvStalkerPortals = portals) + syncLocalStateToCloud(silent = true) + refreshIptv(showToast = true, configured = true, force = true) + } } /** @@ -2375,9 +2499,10 @@ class SettingsViewModel @Inject constructor( fun refreshIptv(showToast: Boolean = true, configured: Boolean = false, force: Boolean = true) { viewModelScope.launch { val currentConfig = iptvRepository.observeConfig().first() - // Check legacy m3uUrl, multi-playlist entries, and Stalker portal + // Check legacy m3uUrl, multi-playlist entries, and Stalker portals val hasPlaylists = currentConfig.playlists.any { it.m3uUrl.isNotBlank() && it.enabled } - if (currentConfig.m3uUrl.isBlank() && currentConfig.stalkerPortalUrl.isBlank() && !hasPlaylists) { + val hasStalker = currentConfig.stalkerPortals.any { it.portalUrl.isNotBlank() } + if (currentConfig.m3uUrl.isBlank() && !hasStalker && !hasPlaylists) { return@launch } @@ -3953,11 +4078,20 @@ private fun IptvConfig.syncSignature(): String { playlist.enabled.toString() ).joinToString("~") } + val stalkerSignature = stalkerPortals + .joinToString("|") { portal -> + listOf( + portal.id, + portal.name, + portal.portalUrl, + portal.macAddress, + portal.enabled.toString() + ).joinToString("~") + } return listOf( m3uUrl, epgUrl, - stalkerPortalUrl, - stalkerMacAddress, + stalkerSignature, playlistsSignature ).joinToString("||") } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index eb96a3e3d..11ec9e560 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -705,10 +705,11 @@ fun TvScreen( LaunchedEffect(playingChannelId, playingChannel?.streamUrl) { var stream = playingChannel?.streamUrl ?: return@LaunchedEffect + val playingId = playingChannel?.id if (isPlayerReleased) return@LaunchedEffect // Resolve Stalker portal cmd to an authenticated stream URL. - if (playingChannel?.id?.startsWith("stalker:") == true) { - val resolved = viewModel.iptvRepository.resolveStalkerStreamUrl(stream) + if (playingId?.startsWith("stalker:") == true) { + val resolved = viewModel.iptvRepository.resolveStalkerStreamUrl(playingId, stream) if (resolved != null) stream = resolved else return@LaunchedEffect } if (stream == lastPreparedStreamUrl) return@LaunchedEffect 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..5338ba410 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 @@ -7,6 +7,7 @@ import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvSnapshot +import com.arflix.tv.data.model.PlaylistGroupKey import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.IptvConfig import com.arflix.tv.ui.screens.tv.live.LiveTvGuideSources @@ -75,7 +76,7 @@ data class TvUiState( ) { val isConfigured: Boolean get() = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.enabled && it.portalUrl.isNotBlank() } || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } val hasPotentialGuideSource: Boolean get() = config.hasConfiguredEpgSource() @@ -272,7 +273,7 @@ class TvViewModel @Inject constructor( startFullEpgWarmup() val hasAnyIptvConfig = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.enabled && it.portalUrl.isNotBlank() } || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } // Auto-heal cases where the app has IPTV config but an empty in-memory snapshot. @@ -1886,7 +1887,10 @@ class TvViewModel @Inject constructor( private fun currentVisiblePlaylistGroups(playlistId: String? = null): List { val targetPlaylistId = playlistId?.trim().orEmpty() if (targetPlaylistId.isNotBlank() && iptvRepository.pagedChannelsReady()) { - val hidden = _uiState.value.snapshot.hiddenGroups.mapTo(HashSet()) { it.trim() } + // hiddenGroups are `playlistId|groupName` keys; narrow to this + // playlist's hidden names so cross-source groups are unaffected. + val hidden = _uiState.value.snapshot.hiddenGroups + .mapTo(HashSet()) { PlaylistGroupKey(it).groupName.trim() } return iptvRepository.pagedPlaylistGroupCounts() .asSequence() .filter { (id, _, _) -> id == targetPlaylistId } @@ -1896,7 +1900,8 @@ class TvViewModel @Inject constructor( .toList() } val snapshot = _uiState.value.snapshot - val hidden = snapshot.hiddenGroups.mapTo(HashSet()) { it.trim() } + val hidden = snapshot.hiddenGroups + .mapTo(HashSet()) { PlaylistGroupKey(it).groupName.trim() } return snapshot.grouped.keys .asSequence() .map { it.trim() } @@ -1958,6 +1963,7 @@ class TvViewModel @Inject constructor( } val resolvedUrl = resolveStalkerStreamIfNeeded( rawUrl = rawUrl, + channelId = channel.id, isStalkerChannel = channel.id.startsWith("stalker:"), forceRefresh = forceRefresh, ) @@ -1970,6 +1976,7 @@ class TvViewModel @Inject constructor( private suspend fun resolveStalkerStreamIfNeeded( rawUrl: String, + channelId: String, isStalkerChannel: Boolean, forceRefresh: Boolean, ): String { @@ -1983,7 +1990,7 @@ class TvViewModel @Inject constructor( } val resolved = withContext(Dispatchers.IO) { - iptvRepository.resolveStalkerStreamUrl(trimmed) + iptvRepository.resolveStalkerStreamUrl(channelId, trimmed) }?.trim().orEmpty() val playable = resolved.ifBlank { trimmed.removePrefix("ffmpeg").trim() } if (playable.isNotBlank()) { @@ -2215,12 +2222,18 @@ private fun setPreparedContent(state: TvUiState): TvUiState { private fun buildPreparedGroups(snapshot: IptvSnapshot): List { val dynamicGroups = snapshot.grouped.keys.toList() - val hiddenSet = snapshot.hiddenGroups.toHashSet() - val visibleGroups = dynamicGroups.filterNot { hiddenSet.contains(it) } + // hiddenGroups are stored as `playlistId|groupName` keys (PlaylistGroupKey), + // but the TV grid merges channels from every source under plain group + // names. A group is therefore hidden when any source marks it hidden. + val hiddenGroupNames = snapshot.hiddenGroups + .mapTo(HashSet()) { PlaylistGroupKey(it).groupName.trim() } + val visibleGroups = dynamicGroups.filterNot { hiddenGroupNames.contains(it.trim()) } val favorites = snapshot.favoriteGroups.filter { visibleGroups.contains(it) } val others = visibleGroups.filterNot { snapshot.favoriteGroups.contains(it) } val baseOrdered = if (snapshot.groupOrder.isNotEmpty()) { - val orderMap = snapshot.groupOrder.withIndex().associate { (i, groupName) -> groupName to i } + val orderMap = snapshot.groupOrder + .mapIndexed { i, key -> PlaylistGroupKey(key).groupName to i } + .associate { (groupName, i) -> groupName to i } (favorites + others).sortedBy { orderMap[it] ?: Int.MAX_VALUE } } else { favorites + others @@ -2299,7 +2312,7 @@ private fun filterTvChannels( private fun hasNetworkEpgSource(config: IptvConfig): Boolean { return config.epgUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.portalUrl.isNotBlank() } || config.m3uUrl.isNotBlank() || looksLikeXtream(config.m3uUrl) || config.playlists.any { playlist -> @@ -2315,7 +2328,7 @@ private fun hasNetworkEpgSource(config: IptvConfig): Boolean { private fun IptvConfig.hasConfiguredEpgSource(): Boolean { return epgUrl.isNotBlank() || - stalkerPortalUrl.isNotBlank() || + stalkerPortals.any { it.portalUrl.isNotBlank() } || m3uUrl.isNotBlank() || looksLikeXtream(m3uUrl) || playlists.any { playlist -> @@ -2347,11 +2360,20 @@ internal fun IptvConfig.syncSignature(): String { playlist.enabled.toString() ).joinToString("~") } + val stalkerSignature = stalkerPortals + .joinToString("|") { portal -> + listOf( + portal.id, + portal.name, + portal.portalUrl, + portal.macAddress, + portal.enabled.toString() + ).joinToString("~") + } return listOf( m3uUrl, epgUrl, - stalkerPortalUrl, - stalkerMacAddress, + stalkerSignature, playlistsSignature ).joinToString("||") } 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 6af31cc82..f0ed26474 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 @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel +import com.arflix.tv.data.repository.StalkerPortalSupport /** Broad channel genre derived from M3U group name. */ enum class Genre { @@ -394,19 +395,32 @@ private fun playlistGroupLabel(group: String): String { return group.trim().ifBlank { "Ungrouped" } } +/** + * Resolves the playlist id used to build a hidden-group key (`playlistId|group`) + * for a given channel id. M3U/Xtream ids are shaped `:`, so + * the first segment is the playlist id. Stalker ids are shaped + * `stalker::`; hidden groups for Stalker portals are stored + * under the *portal* id (e.g. `stalker1|group`), not the literal `stalker` + * prefix, so the portal segment must be returned for the key to match. + */ +private fun hiddenGroupPlaylistId(channelId: String): String { + StalkerPortalSupport.portalIdFromChannelId(channelId)?.let { return it } + return channelId.substringBefore(':') +} + private fun playlistGroupKey(playlistId: String, group: String): String { return com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, playlistGroupLabel(group)) } fun isHiddenPlaylistGroup(channel: EnrichedChannel, hiddenGroups: Set): Boolean { if (hiddenGroups.isEmpty()) return false - val playlistId = channel.id.substringBefore(':') + val playlistId = hiddenGroupPlaylistId(channel.id) return playlistGroupKey(playlistId, channel.source.group) in hiddenGroups } private fun isHiddenPlaylistGroup(channel: IptvChannel, hiddenGroups: Set): Boolean { if (hiddenGroups.isEmpty()) return false - val playlistId = channel.id.substringBefore(':') + val playlistId = hiddenGroupPlaylistId(channel.id) return playlistGroupKey(playlistId, channel.group) in hiddenGroups } @@ -455,8 +469,9 @@ fun buildCategoryTree( channels.forEach { channel -> val playlistId = channel.id.substringBefore(':') + val hiddenPlaylistId = hiddenGroupPlaylistId(channel.id) val groupLabel = playlistGroupLabel(channel.source.group) - val groupKey = playlistGroupKey(playlistId, groupLabel) + val groupKey = playlistGroupKey(hiddenPlaylistId, groupLabel) val groupId = playlistGroupCategoryId(playlistId, channel.source.group) val targetCounts = if (groupKey in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts val groupCount = targetCounts[groupId]?.second ?: 0 @@ -631,9 +646,10 @@ fun buildCategoryTree( channels.forEach { channel -> val playlistId = channel.id.substringBefore(':') + val hiddenPlaylistId = hiddenGroupPlaylistId(channel.id) val traits = channel.traits() val groupLabel = playlistGroupLabel(channel.group) - val groupKey = playlistGroupKey(playlistId, groupLabel) + val groupKey = playlistGroupKey(hiddenPlaylistId, groupLabel) val groupId = playlistGroupCategoryId(playlistId, channel.group) val targetCounts = if (groupKey in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts val groupCount = targetCounts[groupId]?.second ?: 0 @@ -884,8 +900,9 @@ fun buildFastStartupChannelState( channels.forEachIndexed { index, rawChannel -> val channel = rawChannel.enrichForFastStartup(index + 1) val playlistId = rawChannel.id.substringBefore(':') + val hiddenPlaylistId = hiddenGroupPlaylistId(rawChannel.id) val groupLabel = playlistGroupLabel(rawChannel.group) - val groupKey = playlistGroupKey(playlistId, groupLabel) + val groupKey = playlistGroupKey(hiddenPlaylistId, groupLabel) val groupId = playlistGroupCategoryId(playlistId, rawChannel.group) val hidden = groupKey in hiddenPlaylistGroups @@ -1001,7 +1018,7 @@ fun buildPagedStartupChannelState( if (channel.id in favorites) add("fav", channel) if (channel.id in recents) add("recent", channel) val playlistId = channel.source.id.substringBefore(':') - val groupKey = playlistGroupKey(playlistId, channel.source.group) + val groupKey = playlistGroupKey(hiddenGroupPlaylistId(channel.source.id), channel.source.group) val groupId = playlistGroupCategoryId(playlistId, channel.source.group) add(groupId, channel) if (groupKey in hiddenPlaylistGroups) { 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..fb9987597 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 @@ -2980,7 +2980,9 @@ fun LiveTvScreen( .asSequence() .filterNot { channel -> isAdultGroup(channel.group, channel.name) } .filterNot { channel -> - val playlistId = channel.id.substringBefore(':') + val playlistId = com.arflix.tv.data.repository.StalkerPortalSupport + .portalIdFromChannelId(channel.id) + ?: channel.id.substringBefore(':') val groupKey = com.arflix.tv.data.model.PlaylistGroupKey .build(playlistId, channel.group.ifBlank { "Ungrouped" }) groupKey in hiddenGroupSet diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01c0af6c8..a5ff4163d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -737,6 +737,28 @@ No categories available Hidden Visible + Show all + Hide all + Unhide every category of this source + Hide every category of this source + Stalker categories + Configured + Not configured + Remove portal + Manage Stalker categories + Edit Stalker portal + STALKER PORTALS + Add Stalker portal + Add up to 3 Stalker / Ministra portals + Add another Stalker portal + Portal name + e.g. Portal 1 + Enable or disable Stalker portal + Move Stalker portal up + Move Stalker portal down + Remove Stalker portal + Rename portal + Maximum of 3 Stalker portals reached Default Enter %1$s... 12-hour diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt new file mode 100644 index 000000000..b2b8b6870 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt @@ -0,0 +1,178 @@ +package com.arflix.tv.data.repository + +import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import org.junit.Test + +class StalkerPortalSupportTest { + + private val gson = Gson() + + @Test + fun portalIdFromChannelIdExtractsPortalSegment() { + assertThat(StalkerPortalSupport.portalIdFromChannelId("stalker:stalker1:42")) + .isEqualTo("stalker1") + assertThat(StalkerPortalSupport.portalIdFromChannelId("stalker:stalker2:abc")) + .isEqualTo("stalker2") + } + + @Test + fun portalIdFromChannelIdReturnsNullForNonStalkerIds() { + assertThat(StalkerPortalSupport.portalIdFromChannelId("list_1:42")).isNull() + assertThat(StalkerPortalSupport.portalIdFromChannelId("plain")).isNull() + } + + @Test + fun portalIdFromChannelIdReturnsNullWhenPortalSegmentMissing() { + // Legacy single-portal id shape "stalker:42" has no portal segment. + assertThat(StalkerPortalSupport.portalIdFromChannelId("stalker:42")).isNull() + } + + @Test + fun startsWithStalkerStillMatchesNewPrefixedIds() { + // The existing checks rely on startsWith("stalker:") and must keep matching. + assertThat("stalker:stalker1:42".startsWith("stalker:")).isTrue() + assertThat("stalker:stalker2:abc".startsWith("stalker:")).isTrue() + } + + @Test + fun migratedPortalFromLegacyProducesPortal1() { + val portal = StalkerPortalSupport.migratedPortalFromLegacy( + "http://portal.example/stalker_portal/", + "00:1A:79:12:34:56" + ) + assertThat(portal).isNotNull() + assertThat(portal!!.id).isEqualTo("stalker1") + assertThat(portal.name).isEqualTo("Portal 1") + assertThat(portal.portalUrl).isEqualTo("http://portal.example/stalker_portal") + assertThat(portal.macAddress).isEqualTo("00:1A:79:12:34:56") + } + + @Test + fun migratedPortalFromLegacyReturnsNullWhenFieldsBlank() { + assertThat(StalkerPortalSupport.migratedPortalFromLegacy("", "00:1A:79:12:34:56")).isNull() + assertThat(StalkerPortalSupport.migratedPortalFromLegacy("http://portal", "")).isNull() + } + + @Test + fun migrateAndDecodeRoundTripsThroughJson() { + val migrated = StalkerPortalSupport.migratedPortalFromLegacy( + "http://portal.example/", + "00:1a:79:aa:bb:cc" + )!! + // MAC is uppercased during migration. + assertThat(migrated.macAddress).isEqualTo("00:1A:79:AA:BB:CC") + + val json = gson.toJson(listOf(migrated)) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(1) + assertThat(decoded[0].id).isEqualTo("stalker1") + assertThat(decoded[0].portalUrl).isEqualTo("http://portal.example") + assertThat(decoded[0].macAddress).isEqualTo("00:1A:79:AA:BB:CC") + } + + @Test + fun decodeStalkerPortalsPreservesMultiplePortalsAndOrder() { + val portals = listOf( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker2", "Portal 2", "http://b/", "00:1A:79:22:22:22"), + StalkerPortalEntry("stalker3", "Portal 3", "http://c/", "00:1A:79:33:33:33") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded.map { it.id }) + .containsExactly("stalker1", "stalker2", "stalker3").inOrder() + assertThat(decoded.map { it.portalUrl }) + .containsExactly("http://a", "http://b", "http://c").inOrder() + } + + @Test + fun decodeStalkerPortalsCapsAtMaxPortals() { + val portals = listOf( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker2", "Portal 2", "http://b/", "00:1A:79:22:22:22"), + StalkerPortalEntry("stalker3", "Portal 3", "http://c/", "00:1A:79:33:33:33"), + StalkerPortalEntry("stalker4", "Portal 4", "http://d/", "00:1A:79:44:44:44") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(3) + assertThat(decoded.map { it.id }) + .containsExactly("stalker1", "stalker2", "stalker3").inOrder() + } + + @Test + fun decodeStalkerPortalsDropsEntriesWithBlankFields() { + val portals = listOf( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker2", "Portal 2", "", "00:1A:79:22:22:22"), + StalkerPortalEntry("stalker3", "Portal 3", "http://c/", "") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(1) + assertThat(decoded[0].id).isEqualTo("stalker1") + } + + @Test + fun decodeStalkerPortalsAssignsDefaultIdAndNameWhenBlank() { + val portals = listOf( + StalkerPortalEntry("", "", "http://a/", "00:1A:79:11:11:11") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(1) + assertThat(decoded[0].id).isEqualTo("stalker1") + assertThat(decoded[0].name).isEqualTo("Portal 1") + } + + @Test + fun decodeStalkerPortalsReturnsEmptyForBlankOrMalformedInput() { + assertThat(StalkerPortalSupport.decodeStalkerPortals("", maxPortals = 3)).isEmpty() + assertThat(StalkerPortalSupport.decodeStalkerPortals("not json", maxPortals = 3)).isEmpty() + } + + @Test + fun multiPortalChannelIdsResolveToDistinctPortals() { + val ids = listOf("stalker:stalker1:10", "stalker:stalker2:20", "stalker:stalker3:30") + val portalIds = ids.mapNotNull { StalkerPortalSupport.portalIdFromChannelId(it) } + assertThat(portalIds).containsExactly("stalker1", "stalker2", "stalker3").inOrder() + } + + @Test + fun normalizeStalkerPortalEntryTrimsUrlAndUppercasesMac() { + val portal = StalkerPortalEntry("stalker1", "Portal 1", " http://a/ ", " 00:1a:79:aa:bb:cc ") + val normalized = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, 0) + assertThat(normalized).isNotNull() + assertThat(normalized!!.portalUrl).isEqualTo("http://a") + assertThat(normalized.macAddress).isEqualTo("00:1A:79:AA:BB:CC") + assertThat(normalized.enabled).isTrue() + } + + @Test + fun normalizeStalkerPortalEntryAssignsDefaultIdAndNameByIndex() { + val portal = StalkerPortalEntry("", "", "http://a/", "00:1A:79:AA:BB:CC") + val normalized = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, 2) + assertThat(normalized).isNotNull() + assertThat(normalized!!.id).isEqualTo("stalker3") + assertThat(normalized.name).isEqualTo("Portal 3") + } + + @Test + fun normalizeStalkerPortalEntryReturnsNullForBlankFields() { + assertThat(StalkerPortalSupport.normalizeStalkerPortalEntry( + StalkerPortalEntry("stalker1", "Portal 1", "", "00:1A:79:AA:BB:CC"), 0 + )).isNull() + assertThat(StalkerPortalSupport.normalizeStalkerPortalEntry( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", ""), 0 + )).isNull() + } + + @Test + fun normalizeStalkerPortalEntryPreservesEnabledFlag() { + val portal = StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:AA:BB:CC", enabled = false) + val normalized = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, 0) + assertThat(normalized).isNotNull() + assertThat(normalized!!.enabled).isFalse() + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt index 667e16d34..51eb2ef4a 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt @@ -17,3 +17,47 @@ class SettingsIptvGroupOrderTest { assertThat(ordered).doesNotContain("[B] Kids") } } + +class StalkerDpadIndexTest { + + @Test + fun iptvRowMaxActionIsAlwaysFive() { + // Both M3U playlist rows and Stalker portal rows carry the same full + // chip row (categories / toggle / edit / up / down / delete). + assertThat(iptvRowMaxAction()).isEqualTo(5) + } + + @Test + fun firstIptvGroupIndexStartsAtOneForM3UPlaylists() { + assertThat( + firstIptvGroupIndex("list_1", listOf("Movies", "Kids")) + ).isEqualTo(1) + } + + @Test + fun firstIptvGroupIndexStartsAtTwoForStalkerPortals() { + // Each Stalker portal gets a bulk-toggle row at index 1. + assertThat( + firstIptvGroupIndex("stalker1", listOf("Movies", "Kids"), setOf("stalker1", "stalker2")) + ).isEqualTo(2) + assertThat( + firstIptvGroupIndex("stalker2", listOf("News"), setOf("stalker1", "stalker2")) + ).isEqualTo(2) + } + + @Test + fun firstIptvGroupIndexIsOneWhenGroupsEmptyEvenForStalker() { + // No bulk-toggle row when there are no groups to toggle. + assertThat( + firstIptvGroupIndex("stalker1", emptyList(), setOf("stalker1")) + ).isEqualTo(1) + } + + @Test + fun firstIptvGroupIndexIsOneForUnknownStalkerPlaylistId() { + // Legacy STALKER_PLAYLIST_ID ("stalker") still gets bulk toggle. + assertThat( + firstIptvGroupIndex("stalker", listOf("Movies"), emptySet()) + ).isEqualTo(2) + } +}