diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt index 993c7502a..f9d108489 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt @@ -208,7 +208,7 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( val byPlaylist = !playlistId.isNullOrBlank() val sql = buildString { append("SELECT * FROM channels WHERE source_key = ?") - if (byPlaylist) append(" AND id LIKE ?") + if (byPlaylist) append(" AND (id LIKE ? OR id LIKE ?)") if (byGroup) { if (normalizedGroup) append(" AND trim(group_title) = ?") else append(" AND group_title = ?") } @@ -217,7 +217,10 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( } val args = buildList { add(sourceKey) - if (byPlaylist) add("${playlistId}:%") + if (byPlaylist) { + add("${playlistId}:%") + add("stalker:${playlistId}:%") + } if (byGroup) add(if (normalizedGroup) groupTitle!!.trim() else groupTitle!!) }.toTypedArray() return readableDatabase.rawQuery(sql, args).use { cursor -> @@ -242,12 +245,15 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( if (!byGroup && !byPlaylist) return count(sourceKey) val sql = buildString { append("SELECT COUNT(*) FROM channels WHERE source_key = ?") - if (byPlaylist) append(" AND id LIKE ?") + if (byPlaylist) append(" AND (id LIKE ? OR id LIKE ?)") if (byGroup) append(" AND group_title = ?") } val args = buildList { add(sourceKey) - if (byPlaylist) add("${playlistId}:%") + if (byPlaylist) { + add("${playlistId}:%") + add("stalker:${playlistId}:%") + } if (byGroup) add(groupTitle!!) }.toTypedArray() return readableDatabase.rawQuery(sql, args) @@ -323,7 +329,16 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( return readableDatabase.rawQuery( """ SELECT - CASE WHEN instr(id, ':') > 0 THEN substr(id, 1, instr(id, ':') - 1) ELSE '' END AS playlist_id, + CASE + WHEN id LIKE 'stalker:%:%' THEN + substr( + substr(id, instr(id, ':') + 1), + 1, + instr(substr(id, instr(id, ':') + 1), ':') - 1 + ) + WHEN instr(id, ':') > 0 THEN substr(id, 1, instr(id, ':') - 1) + ELSE '' + END AS playlist_id, group_title, COUNT(*), MIN(ord) AS first_ord 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..f3d1edd73 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,6 +196,8 @@ data class IptvLoadProgress( data class IptvCloudProfileState( val m3uUrl: String = "", val epgUrl: String = "", + val stalkerPortals: List = emptyList(), + // Retained for importing cloud snapshots written before multi-portal support. val stalkerPortalUrl: String = "", val stalkerMacAddress: String = "", val favoriteGroups: List = emptyList(), @@ -228,7 +243,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 +375,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 +395,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 +512,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()]) ) } @@ -507,11 +549,11 @@ class IptvRepository @Inject constructor( } else { prefs[tvSessionKey()] = gson.toJson( state.copy( - lastChannelId = state.lastChannelId.trim(), + lastChannelId = StalkerPortalSupport.migrateLegacyChannelId(state.lastChannelId), lastGroupName = state.lastGroupName.trim(), lastFocusedZone = state.lastFocusedZone.trim().ifBlank { "GUIDE" }, recentChannelIds = state.recentChannelIds - .map { it.trim() } + .map(StalkerPortalSupport::migrateLegacyChannelId) .filter { it.isNotBlank() } .distinct() .takeLast(40) @@ -610,17 +652,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 +684,17 @@ 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 both legacy and portal-scoped group preferences. + (previousConfig.stalkerPortals.map { it.id } + STALKER_PLAYLIST_ID) + .distinct() + .forEach { clearGroupPreferences(it) } + cachedStalkerApis = emptyMap() groupOrderLocallyDirty = true if (sourceChanged) { withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } @@ -647,16 +703,66 @@ 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 = StalkerPortalSupport.normalizeStalkerPortals( + portals, + MAX_STALKER_PORTALS, + ) + val nextConfig = previousConfig.copy(stalkerPortals = normalized) + val previousSourceKey = epgIndexKey(profileId, previousConfig) + val sourceChanged = buildSourceSignature(previousConfig) != buildSourceSignature(nextConfig) - val stalker = cachedStalkerApi ?: stalkerApiMutex.withLock { - cachedStalkerApi ?: com.arflix.tv.data.api.StalkerApi( - config.stalkerPortalUrl, - config.stalkerMacAddress, - ).takeIf { it.handshake() }?.also { cachedStalkerApi = it } + val removedIds = previousConfig.stalkerPortals + .map { it.id } + .filterNot { id -> normalized.any { it.id == id } } + + 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 +1520,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 +1531,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 +1627,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 +1772,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 +1785,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 +1820,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 +1837,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 +1888,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 +1941,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 +2377,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 +2390,8 @@ class IptvRepository @Inject constructor( nowNext = cachedNowNext, favoriteGroups = favoriteGroups, favoriteChannels = favoriteChannels, + hiddenGroups = hiddenGroups, + groupOrder = groupOrder, epgWarning = null, loadedAt = Instant.ofEpochMilli(loadedAtMillis) ) @@ -2245,6 +2421,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 +2433,8 @@ class IptvRepository @Inject constructor( nowNext = cachedNowNext, favoriteGroups = favoriteGroups, favoriteChannels = favoriteChannels, + hiddenGroups = hiddenGroups, + groupOrder = groupOrder, epgWarning = null, loadedAt = Instant.ofEpochMilli(loadedAtMillis) ) @@ -2808,29 +2988,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 +3047,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 +3176,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 +3224,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") @@ -3040,7 +3278,7 @@ class IptvRepository @Inject constructor( return runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type val list = gson.fromJson>(raw, type)?.map { it.trim() }?.filter { it.isNotBlank() }?.distinct() ?: emptyList() - if (list.any { !it.contains('|') }) { + val scoped = if (list.any { !it.contains('|') }) { val playlistsRaw = prefs[playlistsKey()].orEmpty() if (playlistsRaw.isBlank()) { list @@ -3052,6 +3290,7 @@ class IptvRepository @Inject constructor( } else list } } else list + StalkerPortalSupport.normalizePlaylistGroupKeys(scoped) }.getOrDefault(emptyList()) } @@ -3062,7 +3301,7 @@ class IptvRepository @Inject constructor( return runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type val list = gson.fromJson>(raw, type)?.map { it.trim() }?.filter { it.isNotBlank() }?.distinct() ?: emptyList() - if (list.any { !it.contains('|') }) { + val scoped = if (list.any { !it.contains('|') }) { val playlistsRaw = prefs[playlistsKey()].orEmpty() if (playlistsRaw.isBlank()) { list @@ -3074,6 +3313,7 @@ class IptvRepository @Inject constructor( } else list } } else list + StalkerPortalSupport.normalizePlaylistGroupKeys(scoped) }.getOrDefault(emptyList()) } @@ -3115,13 +3355,13 @@ class IptvRepository @Inject constructor( return runCatching { gson.fromJson(raw, IptvTvSessionState::class.java)?.let { session -> session.copy( - lastChannelId = session.lastChannelId.trim(), + lastChannelId = StalkerPortalSupport.migrateLegacyChannelId(session.lastChannelId), lastGroupName = session.lastGroupName.trim(), lastFocusedZone = session.lastFocusedZone.trim().ifBlank { "GUIDE" }, recentChannelIds = runCatching { session.recentChannelIds } .getOrNull() .orEmpty() - .map { it.trim() } + .map(StalkerPortalSupport::migrateLegacyChannelId) .filter { it.isNotBlank() } .distinct() .takeLast(40) @@ -3147,7 +3387,7 @@ class IptvRepository @Inject constructor( return runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type gson.fromJson>(raw, type) - ?.map { it.trim() } + ?.map(StalkerPortalSupport::migrateLegacyChannelId) ?.filter { it.isNotBlank() } ?.distinct() ?: emptyList() @@ -3163,26 +3403,36 @@ class IptvRepository @Inject constructor( val playlistsRaw = prefs[playlistsKeyFor(safeProfileId)].orEmpty() val tvSessionRaw = prefs[tvSessionKeyFor(safeProfileId)].orEmpty() val playlists = decodePlaylists(playlistsRaw) + val stalkerPortals = readStalkerPortalsFor(prefs, safeProfileId) + val validSourceIds = buildSet { + playlists.forEach { add(it.id) } + stalkerPortals.forEach { add(it.id) } + } val primary = playlists.firstOrNull() val legacyM3uUrl = normalizeStoredIptvUrl(decryptConfigValue(prefs[m3uUrlKeyFor(safeProfileId)].orEmpty())) val legacyEpgUrls = normalizeStoredEpgInputs(decryptConfigValue(prefs[epgUrlKeyFor(safeProfileId)].orEmpty())) return IptvCloudProfileState( m3uUrl = primary?.m3uUrl ?: legacyM3uUrl, epgUrl = primary?.epgUrl ?: legacyEpgUrls.firstOrNull().orEmpty(), - stalkerPortalUrl = decryptConfigValue(prefs[stalkerPortalUrlKeyFor(safeProfileId)].orEmpty()), - stalkerMacAddress = prefs[stalkerMacAddressKeyFor(safeProfileId)].orEmpty(), + stalkerPortals = stalkerPortals, favoriteGroups = decodeFavoriteGroups(prefs[favoriteGroupsKeyFor(safeProfileId)].orEmpty()), favoriteChannels = decodeFavoriteChannels(prefs[favoriteChannelsKeyFor(safeProfileId)].orEmpty()), hiddenGroups = if (hiddenRaw.isNotBlank()) { runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type - gson.fromJson>(hiddenRaw, type) ?: emptyList() + StalkerPortalSupport.normalizePlaylistGroupKeys( + gson.fromJson>(hiddenRaw, type).orEmpty(), + validSourceIds, + ) }.getOrDefault(emptyList()) } else emptyList(), groupOrder = if (orderSchema == IPTV_GROUP_ORDER_SCHEMA && orderRaw.isNotBlank()) { runCatching { val type = TypeToken.getParameterized(List::class.java, String::class.java).type - gson.fromJson>(orderRaw, type) ?: emptyList() + StalkerPortalSupport.normalizePlaylistGroupKeys( + gson.fromJson>(orderRaw, type).orEmpty(), + validSourceIds, + ) }.getOrDefault(emptyList()) } else emptyList(), groupOrderSchema = IPTV_GROUP_ORDER_SCHEMA, @@ -3197,8 +3447,19 @@ 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 importedStalkerPortals = runCatching { state.stalkerPortals } + .getOrNull() + .orEmpty() + .ifEmpty { + StalkerPortalSupport.migratedPortalFromLegacy( + runCatching { state.stalkerPortalUrl }.getOrNull().orEmpty(), + runCatching { state.stalkerMacAddress }.getOrNull().orEmpty(), + )?.let(::listOf).orEmpty() + } + val normalizedStalkerPortals = StalkerPortalSupport.normalizeStalkerPortals( + importedStalkerPortals, + MAX_STALKER_PORTALS, + ) val normalizedPlaylists = state.playlists.mapIndexed { index, playlist -> normalizePlaylistEntry(playlist, index) }.filterNotNull().take(3) @@ -3213,51 +3474,68 @@ class IptvRepository @Inject constructor( ) ) } - val validPlaylistIds = effectivePlaylists.mapTo(HashSet()) { it.id } + val validSourceIds = buildSet { + effectivePlaylists.forEach { add(it.id) } + normalizedStalkerPortals.forEach { add(it.id) } + } + val defaultSourceId = effectivePlaylists.firstOrNull()?.id + ?: normalizedStalkerPortals.firstOrNull()?.id + fun normalizeCloudGroupKeys(keys: List): List { + val scoped = keys.map { raw -> + val trimmed = raw.trim() + if ('|' !in trimmed && defaultSourceId != null) "$defaultSourceId|$trimmed" else trimmed + } + return StalkerPortalSupport.normalizePlaylistGroupKeys(scoped, validSourceIds) + } + val normalizedHiddenGroups = normalizeCloudGroupKeys(state.hiddenGroups) val normalizedGroupOrder = state.groupOrder .takeIf { state.groupOrderSchema >= IPTV_GROUP_ORDER_SCHEMA } .orEmpty() - .map { it.trim() } - .filter { it.isNotBlank() && PlaylistGroupKey(it).playlistId in validPlaylistIds } + .let(::normalizeCloudGroupKeys) + val normalizedFavoriteChannels = state.favoriteChannels + .map(StalkerPortalSupport::migrateLegacyChannelId) + .filter { it.isNotBlank() } .distinct() + val normalizedTvSession = state.tvSession.copy( + lastChannelId = StalkerPortalSupport.migrateLegacyChannelId(state.tvSession.lastChannelId), + lastGroupName = state.tvSession.lastGroupName.trim(), + lastFocusedZone = state.tvSession.lastFocusedZone.trim().ifBlank { "GUIDE" }, + recentChannelIds = state.tvSession.recentChannelIds + .map(StalkerPortalSupport::migrateLegacyChannelId) + .filter { it.isNotBlank() } + .distinct() + .takeLast(40), + ) 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 - prefs[favoriteGroupsKeyFor(safeProfileId)] = gson.toJson(state.favoriteGroups.distinct()) - prefs[favoriteChannelsKeyFor(safeProfileId)] = gson.toJson(state.favoriteChannels.distinct()) - if (state.hiddenGroups.isNotEmpty()) { - prefs[hiddenGroupsKeyFor(safeProfileId)] = gson.toJson(state.hiddenGroups.distinct()) + 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(normalizedFavoriteChannels) + if (normalizedHiddenGroups.isEmpty()) prefs.remove(hiddenGroupsKeyFor(safeProfileId)) + else prefs[hiddenGroupsKeyFor(safeProfileId)] = gson.toJson(normalizedHiddenGroups) if (normalizedGroupOrder.isEmpty()) prefs.remove(groupOrderKeyFor(safeProfileId)) else prefs[groupOrderKeyFor(safeProfileId)] = gson.toJson(normalizedGroupOrder) prefs[groupOrderSchemaKeyFor(safeProfileId)] = IPTV_GROUP_ORDER_SCHEMA.toString() prefs[sortOrderKeyFor(safeProfileId)] = normalizeIptvSortOrder(state.sortOrder) if (effectivePlaylists.isEmpty()) prefs.remove(playlistsKeyFor(safeProfileId)) else prefs[playlistsKeyFor(safeProfileId)] = gson.toJson(effectivePlaylists) - if (state.tvSession != IptvTvSessionState()) { - prefs[tvSessionKeyFor(safeProfileId)] = gson.toJson( - state.tvSession.copy( - lastChannelId = state.tvSession.lastChannelId.trim(), - lastGroupName = state.tvSession.lastGroupName.trim(), - lastFocusedZone = state.tvSession.lastFocusedZone.trim().ifBlank { "GUIDE" }, - recentChannelIds = state.tvSession.recentChannelIds - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - .takeLast(40) - ) - ) + if (normalizedTvSession != IptvTvSessionState()) { + prefs[tvSessionKeyFor(safeProfileId)] = gson.toJson(normalizedTvSession) } else { prefs.remove(tvSessionKeyFor(safeProfileId)) } } groupOrderLocallyDirty = false if (profileManager.getProfileIdSync() == safeProfileId) { - cachedStalkerApi = null + cachedStalkerApis = emptyMap() invalidateCache() } } @@ -7792,6 +8070,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 +8094,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 +8115,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..7263d633f --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt @@ -0,0 +1,151 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.model.PlaylistGroupKey +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() } + } + + /** Returns the owning source id for playlist/group and cache scoping. */ + fun playlistIdFromChannelId(channelId: String): String { + return portalIdFromChannelId(channelId) + ?: channelId.substringBefore(':').trim() + } + + fun streamCacheKey(channelId: String, command: String): String { + return "${playlistIdFromChannelId(channelId)}|${command.trim()}" + } + + /** Upgrades the old `stalker:` shape to Portal 1. */ + fun migrateLegacyChannelId(channelId: String): String { + val normalized = channelId.trim() + if (!normalized.startsWith("stalker:") || portalIdFromChannelId(normalized) != null) { + return normalized + } + val originalId = normalized.removePrefix("stalker:") + return if (originalId.isBlank()) normalized else "stalker:stalker1:$originalId" + } + + /** Upgrades old single-portal group keys from `stalker|group` to Portal 1. */ + fun migrateLegacyPlaylistGroupKey(rawKey: String): String { + val normalized = rawKey.trim() + if ('|' !in normalized) return normalized + val key = PlaylistGroupKey(normalized) + return if (key.playlistId == STALKER_PLAYLIST_ID) { + PlaylistGroupKey.build("stalker1", key.groupName) + } else { + normalized + } + } + + fun normalizePlaylistGroupKeys( + keys: Collection, + validSourceIds: Set = emptySet(), + ): List { + return keys.asSequence() + .map(::migrateLegacyPlaylistGroupKey) + .filter { key -> + key.isNotBlank() && + (validSourceIds.isEmpty() || ('|' in key && PlaylistGroupKey(key).playlistId in validSourceIds)) + } + .distinct() + .toList() + } + + fun nextAvailablePortalId(existingIds: Collection, maxPortals: Int): String? { + val used = existingIds.mapTo(HashSet()) { it.trim() } + if (maxPortals <= 0 || used.size >= maxPortals) return null + var suffix = used.asSequence() + .mapNotNull { id -> id.removePrefix("stalker").toIntOrNull() } + .maxOrNull() + ?.plus(1) + ?: 1 + while ("stalker$suffix" in used) suffix += 1 + return "stalker$suffix" + } + + 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 + normalizeStalkerPortals( + gson.fromJson>(raw, type).orEmpty(), + maxPortals, + ) + }.getOrDefault(emptyList()) + } + + fun normalizeStalkerPortals( + portals: List, + maxPortals: Int, + ): List { + if (maxPortals <= 0) return emptyList() + val usedIds = LinkedHashSet() + return buildList { + portals.forEachIndexed { index, rawPortal -> + if (size >= maxPortals) return@forEachIndexed + val portal = normalizeStalkerPortalEntry(rawPortal, index) ?: return@forEachIndexed + val id = portal.id.takeIf { it !in usedIds } + ?: nextAvailablePortalId(usedIds, maxPortals) + ?: return@forEachIndexed + usedIds += id + add(portal.copy(id = id)) + } + } + } + + /** + * 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 c6344b95b..4b13396ce 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 { - 5 + uiState.iptvPlaylists.size // Add + stalker + playlists + order + EPG actions + refresh + clear + // Add-M3U + M3U rows + Add-Stalker + Stalker rows + order + EPG actions + refresh + clear + 5 + 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++ @@ -996,6 +1049,7 @@ fun SettingsScreen( } } "iptv" -> { + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() if (showIptvCategoriesSettings) { val playlistId = uiState.iptvSelectedPlaylistId.orEmpty() val orderedGroups = orderedIptvGroups( @@ -1003,12 +1057,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) @@ -1018,70 +1080,94 @@ 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.setEpgVodActionsEnabled(!uiState.epgVodActionsEnabled) + } + contentFocusIndex == m3uCount + stalkerCount + 4 -> { + viewModel.refreshIptv(force = true) + } + contentFocusIndex == m3uCount + stalkerCount + 5 -> { + viewModel.clearIptvConfig() } - viewModel.setIptvSortOrder(next) - } - contentFocusIndex == uiState.iptvPlaylists.size + 3 -> { - viewModel.setEpgVodActionsEnabled(!uiState.epgVodActionsEnabled) - } - contentFocusIndex == uiState.iptvPlaylists.size + 4 -> { - viewModel.refreshIptv(force = true) - } - contentFocusIndex == uiState.iptvPlaylists.size + 5 -> { - viewModel.clearIptvConfig() } } } @@ -1290,7 +1376,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 }, @@ -1586,7 +1671,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, @@ -1600,8 +1687,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() @@ -1650,8 +1744,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() @@ -1996,10 +2097,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, @@ -2014,12 +2121,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 } + ) + } @@ -4176,6 +4306,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() @@ -4567,8 +4705,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() @@ -4620,7 +4765,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" -> { @@ -4677,6 +4824,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 @@ -6785,14 +6987,23 @@ private fun IptvSettings( onManageCategories: (String) -> Unit = {}, sortOrder: String = "provider", onSortOrderChange: (String) -> Unit = {}, + 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 = {}, epgVodActionsEnabled: Boolean = true, onEpgVodActionsToggle: (Boolean) -> Unit = {}, - onConfigureStalker: () -> Unit = {}, - stalkerSubtitle: String = "" ) { 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) { @@ -6812,7 +7023,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)) @@ -6824,7 +7044,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() } @@ -6834,7 +7053,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 @@ -6878,6 +7097,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) @@ -6926,13 +7207,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)) @@ -6978,6 +7259,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), @@ -6987,7 +7333,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" @@ -6996,22 +7342,23 @@ private fun IptvSettings( } onSortOrderChange(next) }, - modifier = Modifier.settingsFocusSlot(playlists.size + 2) + modifier = Modifier.settingsFocusSlot(trailingBase) ) Spacer(modifier = Modifier.height(16.dp)) SettingsToggleRow( title = stringResource(R.string.settings_epg_vod_actions), subtitle = stringResource(R.string.settings_epg_vod_actions_desc), isEnabled = epgVodActionsEnabled, - isFocused = focusedIndex == playlists.size + 3, + isFocused = focusedIndex == trailingBase + 1, onToggle = onEpgVodActionsToggle, - modifier = Modifier.settingsFocusSlot(playlists.size + 3), + modifier = Modifier.settingsFocusSlot(trailingBase + 1), ) 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 + 4, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 4)) + 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 + 2, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(trailingBase + 2)) 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 + 5, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 5)) + 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 + 3, onClick = onDelete, modifier = Modifier.settingsFocusSlot(trailingBase + 3)) 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) @@ -10931,7 +11278,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) { @@ -10942,10 +11291,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)) } } @@ -10969,6 +11328,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) { @@ -11016,7 +11402,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 b7f8de408..f02e764c9 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 @@ -35,8 +35,11 @@ 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.StalkerPortalSupport import com.arflix.tv.data.repository.LauncherContinueWatchingRepository import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.ProfileManager @@ -188,8 +191,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, @@ -937,6 +939,9 @@ class SettingsViewModel @Inject constructor( } private suspend fun loadIptvGroupsForPlaylist(playlistId: String): List { + val stalkerPortalIds = _uiState.value.iptvStalkerPortals.map { it.id }.toSet() + val isStalkerPortal = playlistId in stalkerPortalIds + val pagedGroups = withContext(Dispatchers.IO) { iptvRepository.pagedPlaylistGroupCounts() .asSequence() @@ -949,10 +954,11 @@ class SettingsViewModel @Inject constructor( 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() @@ -984,6 +990,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 syncAllTrackingProviders(silent: Boolean = false) { @@ -2065,23 +2085,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( @@ -2098,12 +2118,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) @@ -2417,38 +2437,140 @@ 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 portalId = StalkerPortalSupport.nextAvailablePortalId( + current.map { it.id }, + MAX_STALKER_PORTALS, + ) ?: return + val portalNumber = portalId.removePrefix("stalker").toIntOrNull() ?: (current.size + 1) + val portal = StalkerPortalEntry( + id = portalId, + name = name?.trim()?.ifBlank { null } ?: "Portal $portalNumber", + 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) + } } /** @@ -2500,9 +2622,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 } @@ -4066,11 +4189,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 55539ec34..58a4d3323 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 @@ -15,6 +16,7 @@ import com.arflix.tv.data.repository.IptvPlaybackTarget import com.arflix.tv.data.repository.IptvPlaybackUrlResolver import com.arflix.tv.data.repository.IptvRepository import com.arflix.tv.data.repository.IptvTvSessionState +import com.arflix.tv.data.repository.StalkerPortalSupport import com.arflix.tv.ui.screens.tv.live.epgChannelAllowsVodSearch import com.arflix.tv.ui.screens.tv.live.selectConfidentEpgVodMatch import com.arflix.tv.network.OkHttpProvider @@ -84,7 +86,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() @@ -318,7 +320,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. @@ -1936,7 +1938,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 } @@ -1946,7 +1951,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() } @@ -2008,6 +2014,7 @@ class TvViewModel @Inject constructor( } val resolvedUrl = resolveStalkerStreamIfNeeded( rawUrl = rawUrl, + channelId = channel.id, isStalkerChannel = channel.id.startsWith("stalker:"), forceRefresh = forceRefresh, ) @@ -2020,25 +2027,27 @@ class TvViewModel @Inject constructor( private suspend fun resolveStalkerStreamIfNeeded( rawUrl: String, + channelId: String, isStalkerChannel: Boolean, forceRefresh: Boolean, ): String { val trimmed = rawUrl.trim() if (!isStalkerChannel) return trimmed + val cacheKey = StalkerPortalSupport.streamCacheKey(channelId, trimmed) if (!forceRefresh) { synchronized(resolvedStalkerStreamCache) { - resolvedStalkerStreamCache[trimmed]?.let { return it } + resolvedStalkerStreamCache[cacheKey]?.let { return it } } } 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()) { synchronized(resolvedStalkerStreamCache) { - resolvedStalkerStreamCache[trimmed] = playable + resolvedStalkerStreamCache[cacheKey] = playable while (resolvedStalkerStreamCache.size > 200) { val firstKey = resolvedStalkerStreamCache.keys.firstOrNull() ?: break resolvedStalkerStreamCache.remove(firstKey) @@ -2265,12 +2274,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 @@ -2349,7 +2364,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 -> @@ -2365,7 +2380,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 -> @@ -2397,11 +2412,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..5b18a94f0 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,30 @@ 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. + */ +internal fun channelPlaylistId(channelId: String): String = + StalkerPortalSupport.playlistIdFromChannelId(channelId) + 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 = channelPlaylistId(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 = channelPlaylistId(channel.id) return playlistGroupKey(playlistId, channel.group) in hiddenGroups } @@ -454,9 +466,10 @@ fun buildCategoryTree( val hiddenPlaylistGroups = hiddenGroups.toHashSet() channels.forEach { channel -> - val playlistId = channel.id.substringBefore(':') + val playlistId = channelPlaylistId(channel.id) + val hiddenPlaylistId = playlistId 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 @@ -630,10 +643,11 @@ fun buildCategoryTree( val hiddenPlaylistGroups = hiddenGroups.toHashSet() channels.forEach { channel -> - val playlistId = channel.id.substringBefore(':') + val playlistId = channelPlaylistId(channel.id) + val hiddenPlaylistId = playlistId 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 @@ -771,7 +785,7 @@ fun buildCategoryTree( return LiveCategoryTree(top = top, global = global, countries = countries, adult = adult, hidden = hidden) } -private fun playlistIdFromGroupCategoryId(id: String): String? { +internal fun playlistIdFromGroupCategoryId(id: String): String? { if (!id.startsWith("grp:")) return null return id.removePrefix("grp:") .substringBefore(':', missingDelimiterValue = "") @@ -814,7 +828,7 @@ private fun rawCategoryMatcher( if (categoryId == "fav") return { ch -> ch.id in favorites && !ch.traits().isAdult } if (categoryId == "recent") return { ch -> ch.id in recents && !ch.traits().isAdult } if (categoryId == "adult") return { ch -> ch.traits().isAdult } - if (categoryId.startsWith("grp:")) return { ch -> playlistGroupCategoryId(ch.id.substringBefore(':'), ch.group) == categoryId } + if (categoryId.startsWith("grp:")) return { ch -> playlistGroupCategoryId(channelPlaylistId(ch.id), ch.group) == categoryId } if (categoryId == "g-4k") return { ch -> ch.traits().let { !it.isAdult && it.quality == Quality.K4 } } if (categoryId == "g-sports") return { ch -> ch.traits().let { !it.isAdult && it.genre == Genre.Sports } } if (categoryId == "g-movies") return { ch -> ch.traits().let { !it.isAdult && it.genre == Genre.Movies } } @@ -883,9 +897,10 @@ fun buildFastStartupChannelState( channels.forEachIndexed { index, rawChannel -> val channel = rawChannel.enrichForFastStartup(index + 1) - val playlistId = rawChannel.id.substringBefore(':') + val playlistId = channelPlaylistId(rawChannel.id) + val hiddenPlaylistId = playlistId 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 @@ -1000,7 +1015,7 @@ fun buildPagedStartupChannelState( byId[channel.id] = channel if (channel.id in favorites) add("fav", channel) if (channel.id in recents) add("recent", channel) - val playlistId = channel.source.id.substringBefore(':') + val playlistId = channelPlaylistId(channel.source.id) val groupKey = playlistGroupKey(playlistId, channel.source.group) val groupId = playlistGroupCategoryId(playlistId, channel.source.group) add(groupId, channel) @@ -1128,7 +1143,7 @@ fun buildInitialCategoryChannels( return buildList(limit.coerceAtMost(channels.size)) { channels.forEachIndexed { index, channel -> val isSelectedHiddenGroup = categoryId.startsWith("grp:") && - playlistGroupCategoryId(channel.id.substringBefore(':'), channel.group) == categoryId + playlistGroupCategoryId(channelPlaylistId(channel.id), channel.group) == categoryId if (matcher(channel) && (isSelectedHiddenGroup || !isHiddenPlaylistGroup(channel, hiddenGroups))) { add(channel.enrich(index + 1)) if (size >= limit) return@buildList @@ -1153,7 +1168,7 @@ fun buildCategoryIndex( channels.forEach { channel -> byId[channel.id] = channel - add(playlistGroupCategoryId(channel.source.id.substringBefore(':'), channel.source.group), channel) + add(playlistGroupCategoryId(channelPlaylistId(channel.source.id), channel.source.group), channel) if (isHiddenPlaylistGroup(channel, hiddenGroups)) { hiddenIds += channel.id return@forEach @@ -1208,7 +1223,7 @@ fun bestCategoryIdForChannel( channel: EnrichedChannel, tree: LiveCategoryTree, ): String { - val playlistGroupId = playlistGroupCategoryId(channel.source.id.substringBefore(':'), channel.source.group) + val playlistGroupId = playlistGroupCategoryId(channelPlaylistId(channel.source.id), channel.source.group) if (tree.byId(playlistGroupId) != null) return playlistGroupId if (channel.isAdult) return "adult" @@ -1240,7 +1255,7 @@ fun categoryMatcher( categoryId == "fav" -> { ch -> ch.id in favorites && !ch.isAdult } categoryId == "recent" -> { ch -> ch.id in recents && !ch.isAdult } categoryId == "adult" -> { ch -> ch.isAdult } - categoryId.startsWith("grp:") -> { ch -> playlistGroupCategoryId(ch.source.id.substringBefore(':'), ch.source.group) == categoryId } + categoryId.startsWith("grp:") -> { ch -> playlistGroupCategoryId(channelPlaylistId(ch.source.id), ch.source.group) == categoryId } categoryId == "g-4k" -> { ch -> ch.quality == Quality.K4 && !ch.isAdult } categoryId == "g-sports" -> { ch -> ch.genre == Genre.Sports && !ch.isAdult } categoryId == "g-movies" -> { ch -> ch.genre == Genre.Movies && !ch.isAdult } 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 36803964e..937d3f024 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 @@ -92,6 +92,7 @@ import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.MediaItem as ArvioMediaItem import com.arflix.tv.data.model.Profile import com.arflix.tv.data.repository.IptvPlaybackTarget +import com.arflix.tv.data.repository.StalkerPortalSupport import com.arflix.tv.ui.screens.tv.TvUiState import com.arflix.tv.ui.screens.tv.TvViewModel import com.arflix.tv.network.OkHttpProvider @@ -286,7 +287,7 @@ private fun mergeGuideSlices( } private fun EnrichedChannel.guideFallbackKeys(): List { - val playlistId = id.substringBefore(':', missingDelimiterValue = "").trim() + val playlistId = StalkerPortalSupport.playlistIdFromChannelId(id) val prefix = playlistId.ifBlank { "default" } val keys = LinkedHashSet() @@ -589,6 +590,7 @@ fun LiveTvScreen( val pageLimit = pagedLoadedLimit.coerceAtLeast(GuideMaxWindowRows) fun scanCategoryWindow(categoryId: String, targetGroupTitle: String?): List { if (!categoryId.startsWith("grp:")) return emptyList() + val targetPlaylistId = playlistIdFromGroupCategoryId(categoryId) val targetGroupKey = looseIptvGroupKey(targetGroupTitle) val targetCompactGroupKey = compactIptvGroupKey(targetGroupTitle) val out = ArrayList(pageLimit) @@ -598,11 +600,12 @@ fun LiveTvScreen( val chunk = viewModel.iptvRepository.pagedChannelWindow(null, null, offset, chunkSize) if (chunk.isEmpty()) break chunk.forEach { channel -> - val rawPlaylistId = channel.id.substringBefore(':') + val rawPlaylistId = channelPlaylistId(channel.id) val categoryMatches = playlistGroupCategoryId(rawPlaylistId, channel.group) == categoryId - val looseGroupMatches = targetGroupKey.isNotBlank() && + val samePlaylist = targetPlaylistId == null || rawPlaylistId == targetPlaylistId + val looseGroupMatches = samePlaylist && targetGroupKey.isNotBlank() && looseIptvGroupKey(channel.group) == targetGroupKey - val compactGroupMatches = targetCompactGroupKey.isNotBlank() && + val compactGroupMatches = samePlaylist && targetCompactGroupKey.isNotBlank() && compactIptvGroupKey(channel.group) == targetCompactGroupKey if (categoryMatches || looseGroupMatches || compactGroupMatches) { out += channel @@ -883,6 +886,7 @@ fun LiveTvScreen( val groupTitle = resolvedGroup?.second fun scanCategoryWindow(targetGroupTitle: String?): List { if (!selectedCategoryId.startsWith("grp:")) return emptyList() + val targetPlaylistId = playlistIdFromGroupCategoryId(selectedCategoryId) val targetGroupKey = looseIptvGroupKey(targetGroupTitle) val targetCompactGroupKey = compactIptvGroupKey(targetGroupTitle) val out = ArrayList(pagedLoadedLimit) @@ -892,11 +896,12 @@ fun LiveTvScreen( val chunk = viewModel.iptvRepository.pagedChannelWindow(null, null, offset, chunkSize) if (chunk.isEmpty()) break chunk.forEach { channel -> - val rawPlaylistId = channel.id.substringBefore(':') + val rawPlaylistId = channelPlaylistId(channel.id) val categoryMatches = playlistGroupCategoryId(rawPlaylistId, channel.group) == selectedCategoryId - val looseGroupMatches = targetGroupKey.isNotBlank() && + val samePlaylist = targetPlaylistId == null || rawPlaylistId == targetPlaylistId + val looseGroupMatches = samePlaylist && targetGroupKey.isNotBlank() && looseIptvGroupKey(channel.group) == targetGroupKey - val compactGroupMatches = targetCompactGroupKey.isNotBlank() && + val compactGroupMatches = samePlaylist && targetCompactGroupKey.isNotBlank() && compactIptvGroupKey(channel.group) == targetCompactGroupKey if (categoryMatches || looseGroupMatches || compactGroupMatches) { out += channel @@ -913,8 +918,7 @@ fun LiveTvScreen( if (exact.isNotEmpty()) { exact } else { - val fallback = viewModel.iptvRepository.pagedChannelWindow(null, groupTitle, 0, pagedLoadedLimit) - if (fallback.isNotEmpty()) fallback else scanCategoryWindow(groupTitle) + scanCategoryWindow(groupTitle) } } else { scanCategoryWindow(tree.byId(selectedCategoryId)?.playlistGroupName) @@ -3269,7 +3273,7 @@ fun LiveTvScreen( .asSequence() .filterNot { channel -> isAdultGroup(channel.group, channel.name) } .filterNot { channel -> - val playlistId = channel.id.substringBefore(':') + val playlistId = StalkerPortalSupport.playlistIdFromChannelId(channel.id) 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 389b2012c..6e1c2f9a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -741,6 +741,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..7bca2ce44 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt @@ -0,0 +1,233 @@ +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 playlistAndCacheKeysAreScopedPerPortal() { + val firstId = "stalker:stalker1:10" + val secondId = "stalker:stalker2:10" + + assertThat(StalkerPortalSupport.playlistIdFromChannelId(firstId)).isEqualTo("stalker1") + assertThat(StalkerPortalSupport.playlistIdFromChannelId(secondId)).isEqualTo("stalker2") + assertThat(StalkerPortalSupport.streamCacheKey(firstId, "ffmpeg http://same")) + .isNotEqualTo(StalkerPortalSupport.streamCacheKey(secondId, "ffmpeg http://same")) + } + + @Test + fun nextAvailablePortalIdDoesNotReuseDeletedPortalIdentity() { + assertThat( + StalkerPortalSupport.nextAvailablePortalId( + listOf("stalker1", "stalker3"), + maxPortals = 3, + ) + ).isEqualTo("stalker4") + } + + @Test + fun decodeStalkerPortalsRepairsDuplicateIds() { + val json = gson.toJson( + listOf( + StalkerPortalEntry("stalker1", "A", "http://a", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker1", "B", "http://b", "00:1A:79:22:22:22"), + ) + ) + + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + + assertThat(decoded.map { it.id }).containsExactly("stalker1", "stalker2").inOrder() + } + + @Test + fun legacyChannelAndGroupKeysMigrateToPortal1() { + assertThat(StalkerPortalSupport.migrateLegacyChannelId("stalker:42")) + .isEqualTo("stalker:stalker1:42") + assertThat(StalkerPortalSupport.migrateLegacyChannelId("stalker:stalker2:42")) + .isEqualTo("stalker:stalker2:42") + assertThat(StalkerPortalSupport.migrateLegacyPlaylistGroupKey("stalker|News")) + .isEqualTo("stalker1|News") + } + + @Test + fun cloudGroupKeysKeepConfiguredStalkerPortalOrdering() { + val normalized = StalkerPortalSupport.normalizePlaylistGroupKeys( + listOf("stalker2|Sports", "stalker1|News", "removed|Kids"), + validSourceIds = setOf("stalker1", "stalker2"), + ) + + assertThat(normalized).containsExactly("stalker2|Sports", "stalker1|News").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) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategoryIndexTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategoryIndexTest.kt index f17012ba9..b5fc9b912 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategoryIndexTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategoryIndexTest.kt @@ -201,6 +201,27 @@ class LiveCategoryIndexTest { .inOrder() } + @Test + fun stalkerPortalsWithSameGroupRemainSeparateCategories() { + val state = buildFastStartupChannelState( + channels = listOf( + channel("stalker:stalker1:10", "Portal One News", "News"), + channel("stalker:stalker2:10", "Portal Two News", "News"), + ), + favorites = emptySet(), + recents = emptySet(), + ) + + val newsCategories = state.tree.global.categories.filter { it.label == "News" } + assertThat(newsCategories.map { it.playlistId }) + .containsExactly("stalker1", "stalker2") + .inOrder() + assertThat(state.index.channelsFor(newsCategories[0].id, emptyList(), emptyList()).map { it.id }) + .containsExactly("stalker:stalker1:10") + assertThat(state.index.channelsFor(newsCategories[1].id, emptyList(), emptyList()).map { it.id }) + .containsExactly("stalker:stalker2:10") + } + private fun channel(id: String, name: String, group: String): IptvChannel = IptvChannel( id = id,