From 2484555c04a57573184f23b6c99702f029815908 Mon Sep 17 00:00:00 2001 From: Bhuvanesh S Date: Fri, 5 Jun 2026 11:02:27 +0530 Subject: [PATCH 1/2] perf: optimize Android TV navigation and Compose rendering performance --- .../tv/ui/screens/details/DetailsScreen.kt | 251 +++++++++++------- .../arflix/tv/ui/screens/home/HomeScreen.kt | 46 ++-- 2 files changed, 180 insertions(+), 117 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index a236c584..da1773bb 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -361,6 +361,126 @@ fun DetailsScreen( } } + val currentUiState = rememberUpdatedState(uiState) + val currentEpisodeIndex = rememberUpdatedState(episodeIndex) + + val onButtonClickRemembered = remember(isMobile, mediaType, mediaId) { + { idx: Int -> + val state = currentUiState.value + val currentEpIdx = currentEpisodeIndex.value + when (idx) { + 0 -> { // Play + val season = if (mediaType == MediaType.TV) { + state.playSeason + ?: state.episodes.getOrNull(currentEpIdx)?.seasonNumber + ?: 1 + } else null + val episode = if (mediaType == MediaType.TV) { + state.playEpisode + ?: state.episodes.getOrNull(currentEpIdx)?.episodeNumber + ?: 1 + } else null + val startPositionMs = if ( + mediaType == MediaType.TV && + season == state.playSeason && + episode == state.playEpisode + ) { + state.playPositionMs + } else if (mediaType == MediaType.MOVIE) { + state.playPositionMs + } else null + + if (!state.autoPlaySingleSource) { + // Autoplay OFF → open the source picker; never auto-play. + showStreamSelector = true + viewModel.loadStreams(state.imdbId, season, episode) + } else { + // Autoplay ON → go straight to the player; PlayerScreen auto-picks. + onNavigateToPlayer( + mediaType, mediaId, season, episode, + state.imdbId, null, null, null, startPositionMs + ) + } + } + 1 -> { // Sources + showStreamSelector = true + val ep = state.episodes.getOrNull(currentEpIdx) + viewModel.loadStreams(state.imdbId, ep?.seasonNumber, ep?.episodeNumber) + } + 2 -> { // Trailer + state.trailerKey?.let { showTrailerPlayer = true } + } + 3 -> viewModel.toggleWatched(currentEpIdx) + 4 -> viewModel.toggleWatchlist() + 5 -> { // View Collection — scroll to and focus the collection row on this page + focusedSection = FocusSection.COLLECTION + collectionIndex = 0 + } + } + } + } + + val onSeasonClickRemembered = remember { + { idx: Int -> + seasonIndex = idx + episodeIndex = 0 + viewModel.loadSeason(idx + 1) + } + } + + val onSeasonLongClickRemembered = remember { + { idx: Int -> + contextMenuSeason = idx + 1 + showSeasonContextMenu = true + } + } + + val onEpisodeClickRemembered = remember(isMobile, mediaType, mediaId) { + { idx: Int -> + val state = currentUiState.value + val ep = state.episodes.getOrNull(idx) + if (ep != null) { + episodeIndex = idx + if (isMobile || !state.autoPlaySingleSource) { + showStreamSelector = true + viewModel.loadStreams(state.imdbId, ep.seasonNumber, ep.episodeNumber) + } else { + onNavigateToPlayer( + mediaType, mediaId, + ep.seasonNumber, ep.episodeNumber, state.imdbId, null, null, null, null + ) + } + } + } + } + + val onCastClickRemembered = remember { + { idx: Int -> + val member = currentUiState.value.cast.getOrNull(idx) + if (member != null) { + viewModel.loadPerson(member.id) + } + } + } + + val onSimilarClickRemembered = remember { + { idx: Int -> + val sim = currentUiState.value.similar.getOrNull(idx) + if (sim != null) { + onNavigateToDetails(sim.mediaType, sim.id) + } + } + } + + val onCollectionClickRemembered = remember { + { idx: Int -> + val item = currentUiState.value.collectionItems.getOrNull(idx) + if (item != null) { + onNavigateToDetails(item.mediaType, item.id) + } + } + } + // D-pad key handler — only used on TV (skipped on mobile/touch devices) val dpadRepeatGate = rememberArvioDpadRepeatGate( horizontalMinRepeatIntervalMs = 80L, @@ -749,99 +869,13 @@ fun DetailsScreen( isMobile = isMobile, spoilerBlurEnabled = spoilerBlurEnabled, onBack = onBack, - onButtonClick = { idx -> - when (idx) { - 0 -> { // Play - val season = if (mediaType == MediaType.TV) { - uiState.playSeason - ?: uiState.episodes.getOrNull(episodeIndex)?.seasonNumber - ?: 1 - } else null - val episode = if (mediaType == MediaType.TV) { - uiState.playEpisode - ?: uiState.episodes.getOrNull(episodeIndex)?.episodeNumber - ?: 1 - } else null - val startPositionMs = if ( - mediaType == MediaType.TV && - season == uiState.playSeason && - episode == uiState.playEpisode - ) { - uiState.playPositionMs - } else if (mediaType == MediaType.MOVIE) { - uiState.playPositionMs - } else null - - if (!uiState.autoPlaySingleSource) { - // Autoplay OFF → open the source picker; never auto-play. - showStreamSelector = true - viewModel.loadStreams(uiState.imdbId, season, episode) - } else { - // Autoplay ON → go straight to the player; PlayerScreen auto-picks. - onNavigateToPlayer( - mediaType, mediaId, season, episode, - uiState.imdbId, null, null, null, startPositionMs - ) - } - } - 1 -> { // Sources - showStreamSelector = true - val ep = uiState.episodes.getOrNull(episodeIndex) - viewModel.loadStreams(uiState.imdbId, ep?.seasonNumber, ep?.episodeNumber) - } - 2 -> { // Trailer - uiState.trailerKey?.let { showTrailerPlayer = true } - } - 3 -> viewModel.toggleWatched(episodeIndex) - 4 -> viewModel.toggleWatchlist() - 5 -> { // View Collection — scroll to and focus the collection row on this page - focusedSection = FocusSection.COLLECTION - collectionIndex = 0 - } - } - }, - onSeasonClick = { idx -> - seasonIndex = idx - episodeIndex = 0 - viewModel.loadSeason(idx + 1) - }, - onSeasonLongClick = { idx -> - contextMenuSeason = idx + 1 - showSeasonContextMenu = true - }, - onEpisodeClick = { idx -> - val ep = uiState.episodes.getOrNull(idx) - if (ep != null) { - episodeIndex = idx - if (isMobile || !uiState.autoPlaySingleSource) { - showStreamSelector = true - viewModel.loadStreams(uiState.imdbId, ep.seasonNumber, ep.episodeNumber) - } else { - onNavigateToPlayer( - mediaType, mediaId, - ep.seasonNumber, ep.episodeNumber, uiState.imdbId, null, null, null, null - ) - } - } - }, - onCastClick = { idx -> - val member = uiState.cast.getOrNull(idx) - if (member != null) { - viewModel.loadPerson(member.id) - } - }, - onSimilarClick = { idx -> - val sim = uiState.similar.getOrNull(idx) - if (sim != null) { - onNavigateToDetails(sim.mediaType, sim.id) - } - }, - onCollectionClick = { idx -> - val item = uiState.collectionItems.getOrNull(idx) - if (item != null) { - onNavigateToDetails(item.mediaType, item.id) - } - } + onButtonClick = onButtonClickRemembered, + onSeasonClick = onSeasonClickRemembered, + onSeasonLongClick = onSeasonLongClickRemembered, + onEpisodeClick = onEpisodeClickRemembered, + onCastClick = onCastClickRemembered, + onSimilarClick = onSimilarClickRemembered, + onCollectionClick = onCollectionClickRemembered ) } } @@ -2343,6 +2377,8 @@ private fun DetailsSeasonRail( } } + val currentOnSeasonClick = rememberUpdatedState(onSeasonClick) + TvLazyRow( state = seasonRowState, modifier = Modifier.arvioDpadFocusGroup(enableFocusRestorer = false), @@ -2366,8 +2402,8 @@ private fun DetailsSeasonRail( } else { null } - val onClickForSeason = remember(index, onSeasonClick) { - { onSeasonClick(index) } + val onClickForSeason = remember(index) { + { currentOnSeasonClick.value(index) } } SeasonButton( season = season, @@ -2411,6 +2447,8 @@ private fun DetailsEpisodeRail( val currentFocusedSection by rememberUpdatedState(focusSectionForUi) val currentEpisodeIndex by rememberUpdatedState(episodeIndex) + val currentOnEpisodeClick = rememberUpdatedState(onEpisodeClick) + Box(modifier = Modifier.fillMaxWidth()) { TvLazyRow( state = episodeRowState, @@ -2433,8 +2471,8 @@ private fun DetailsEpisodeRail( key = { index, ep -> "${ep.seasonNumber}_${ep.episodeNumber}_$index" } ) { index, episode -> val isFocused = currentFocusedSection == FocusSection.EPISODES && index == currentEpisodeIndex - val onClickForEpisode = remember(index, onEpisodeClick) { - { onEpisodeClick(index) } + val onClickForEpisode = remember(index) { + { currentOnEpisodeClick.value(index) } } EpisodeCard( episode = episode, @@ -2475,6 +2513,8 @@ private fun DetailsCastRail( itemSpacing = 16.dp ) + val currentOnCastClick = rememberUpdatedState(onCastClick) + Column { Text( text = stringResource(R.string.cast), @@ -2506,8 +2546,8 @@ private fun DetailsCastRail( cast, key = { index, c -> "${c.id}_${c.character}_$index" } ) { index, castMember -> - val onClickForCast = remember(index, onCastClick) { - { onCastClick(index) } + val onClickForCast = remember(index) { + { currentOnCastClick.value(index) } } CircularCastCard( castMember = castMember, @@ -2605,6 +2645,8 @@ private fun DetailsSimilarRail( itemSpacing = 14.dp ) + val currentOnSimilarClick = rememberUpdatedState(onSimilarClick) + Column { Text( text = stringResource(R.string.more_like_this), @@ -2637,8 +2679,8 @@ private fun DetailsSimilarRail( similar, key = { index, m -> "${m.mediaType.name}_${m.id}_$index" } ) { index, mediaItem -> - val onClickForSimilar = remember(index, onSimilarClick) { - { onSimilarClick(index) } + val onClickForSimilar = remember(index) { + { currentOnSimilarClick.value(index) } } SimilarMediaCard( item = mediaItem, @@ -2689,6 +2731,8 @@ private fun DetailsCollectionRail( itemSpacing = 14.dp ) + val currentOnCollectionClick = rememberUpdatedState(onCollectionClick) + Column { val displayName = collectionName ?: stringResource(R.string.more_like_this) Text( @@ -2722,12 +2766,15 @@ private fun DetailsCollectionRail( collectionItems, key = { index, m -> "col_${m.mediaType.name}_${m.id}_$index" } ) { index, mediaItem -> + val onClickForCollection = remember(index) { + { currentOnCollectionClick.value(index) } + } SimilarMediaCard( item = mediaItem, logoImageUrl = null, usePosterCards = usePosterCards, isFocused = focusSectionForUi == FocusSection.COLLECTION && index == collectionIndex && !collectionFixedFocus, - onClick = { onCollectionClick(index) } + onClick = onClickForCollection ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index d96cf3c3..0da5076a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -2734,6 +2734,15 @@ private fun MobileHomeRowsLayer( onLoadMoreCategory(category.id) } } + val currentItem = rememberUpdatedState(item) + val onCardClick = remember { + { onItemClick(currentItem.value) } + } + val onCardLongClick = if (onItemLongClick != null) { + remember { + { onItemLongClick(currentItem.value, isContinueWatching) } + } + } else null if (isRanked && index < 10) { Box( modifier = Modifier.width(rowMobileItemWidth) @@ -2750,8 +2759,8 @@ private fun MobileHomeRowsLayer( isFocusedOverride = false, enableSystemFocus = false, onFocused = {}, - onClick = { onItemClick(item) }, - onLongClick = onItemLongClick?.let { callback -> { callback(item, isContinueWatching) } }, + onClick = onCardClick, + onLongClick = onCardLongClick, ) TopRankRibbon( rank = index + 1, @@ -2776,8 +2785,8 @@ private fun MobileHomeRowsLayer( isFocusedOverride = false, enableSystemFocus = false, onFocused = {}, - onClick = { onItemClick(item) }, - onLongClick = onItemLongClick?.let { callback -> { callback(item, isContinueWatching) } }, + onClick = onCardClick, + onLongClick = onCardLongClick, ) } } @@ -2973,6 +2982,17 @@ private fun TvHomeRowsLayer( val rowKey = remember(category.id) { "home:${category.id}" } val rowUsePosterCards = rememberCatalogueRowLayoutMode(rowKey) == CardLayoutMode.POSTER val rowHeight = if (rowUsePosterCards) 245.dp else 202.dp + val onRowLoadMore = remember(category.id) { + { onLoadMoreCategory(category.id) } + } + val onRowItemFocused = remember(actualRowIndex) { + { item: MediaItem, itemIdx: Int -> + focusState.currentRowIndex = actualRowIndex + focusState.currentItemIndex = itemIdx + focusState.isSidebarFocused = false + focusState.lastNavEventTime = SystemClock.elapsedRealtime() + } + } Box( modifier = Modifier .fillMaxWidth() @@ -2988,16 +3008,11 @@ private fun TvHomeRowsLayer( startPadding = contentStartPadding, categoryHasMore = categoryHasMoreMap[category.id] == true, smoothScrolling = smoothScrolling, - onLoadMore = { onLoadMoreCategory(category.id) }, + onLoadMore = onRowLoadMore, focusedItemIndex = if (rowIsFocused) focusState.currentItemIndex else -1, isFastScrolling = rowIsFocused && isFastScrolling, onItemClick = onItemClick, - onItemFocused = { item, itemIdx -> - focusState.currentRowIndex = actualRowIndex - focusState.currentItemIndex = itemIdx - focusState.isSidebarFocused = false - focusState.lastNavEventTime = SystemClock.elapsedRealtime() - } + onItemFocused = onRowItemFocused ) } } @@ -3394,11 +3409,12 @@ private fun ContentRow( } } val itemIsFocused = isCurrentRow && index == focusedCardIndex - val onCardFocused = remember(item, index) { - { latestOnItemFocused.value(item, index) } + val currentItem = rememberUpdatedState(item) + val onCardFocused = remember(index) { + { latestOnItemFocused.value(currentItem.value, index) } } - val onCardClick = remember(item) { - { latestOnItemClick.value(item) } + val onCardClick = remember { + { latestOnItemClick.value(currentItem.value) } } if (isRanked && index < 10) { // Top 10 rows should use the SAME card sizing as every other row. From 82c37bb13a63c2fc8712b591d3ccc099588c3cb0 Mon Sep 17 00:00:00 2001 From: Bhuvanesh S Date: Fri, 5 Jun 2026 12:05:44 +0530 Subject: [PATCH 2/2] perf: optimize memory usage and cache efficiency for large datasets --- .../arflix/tv/data/repository/IptvEpgIndex.kt | 101 +++++++++--------- .../tv/data/repository/IptvRepository.kt | 60 ++++++++--- .../tv/ui/screens/details/DetailsScreen.kt | 42 ++++---- .../arflix/tv/ui/screens/home/HomeScreen.kt | 43 ++++---- 4 files changed, 138 insertions(+), 108 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvEpgIndex.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvEpgIndex.kt index 8fec69b4..aa7f9d4e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvEpgIndex.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvEpgIndex.kt @@ -54,20 +54,16 @@ internal class IptvEpgIndex(context: Context) : SQLiteOpenHelper( fun replaceAll(sourceKey: String, nowNext: Map, updatedAtMs: Long) { if (sourceKey.isBlank() || nowNext.isEmpty()) return - val rows = flatten(nowNext) - if (rows.isEmpty()) return writableDatabase.runInTransaction { delete("epg_programs", "source_key = ?", arrayOf(sourceKey)) - insertRows(sourceKey, rows) + insertNowNextRows(sourceKey, nowNext) upsertSource(sourceKey, updatedAtMs) } } fun replaceChannels(sourceKey: String, nowNext: Map, updatedAtMs: Long) { if (sourceKey.isBlank() || nowNext.isEmpty()) return - val rows = flatten(nowNext) - if (rows.isEmpty()) return writableDatabase.runInTransaction { nowNext.keys @@ -79,7 +75,7 @@ internal class IptvEpgIndex(context: Context) : SQLiteOpenHelper( val args = arrayOf(sourceKey) + channelIds.toTypedArray() delete("epg_programs", "source_key = ? AND channel_id IN ($placeholders)", args) } - insertRows(sourceKey, rows) + insertNowNextRows(sourceKey, nowNext) upsertSource(sourceKey, updatedAtMs) } } @@ -197,7 +193,21 @@ internal class IptvEpgIndex(context: Context) : SQLiteOpenHelper( } } - private fun SQLiteDatabase.insertRows(sourceKey: String, rows: List) { + private class ProgramDedupKey(val start: Long, val end: Long, val title: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ProgramDedupKey) return false + return start == other.start && end == other.end && title == other.title + } + override fun hashCode(): Int { + var result = start.hashCode() + result = 31 * result + end.hashCode() + result = 31 * result + title.hashCode() + return result + } + } + + private fun SQLiteDatabase.insertNowNextRows(sourceKey: String, nowNext: Map) { val statement = compileStatement( """ INSERT OR REPLACE INTO epg_programs @@ -206,20 +216,38 @@ internal class IptvEpgIndex(context: Context) : SQLiteOpenHelper( """.trimIndent() ) try { - rows.forEach { row -> - statement.clearBindings() - statement.bindString(1, sourceKey) - statement.bindString(2, row.channelId) - statement.bindLong(3, row.program.startUtcMillis) - statement.bindLong(4, row.program.endUtcMillis) - statement.bindString(5, row.program.title) - val description = row.program.description - if (description.isNullOrBlank()) { - statement.bindNull(6) - } else { - statement.bindString(6, description) + val seenPrograms = HashSet(128) + nowNext.forEach { (channelId, item) -> + val normalizedId = channelId.trim() + if (normalizedId.isBlank()) return@forEach + seenPrograms.clear() + + fun insertProgram(program: IptvProgram) { + if (program.title.isBlank() || program.endUtcMillis <= program.startUtcMillis) return + val titleTrimmed = program.title.trim() + val key = ProgramDedupKey(program.startUtcMillis, program.endUtcMillis, titleTrimmed) + if (!seenPrograms.add(key)) return + + val description = program.description?.trim()?.take(MAX_DESCRIPTION_CHARS) + statement.clearBindings() + statement.bindString(1, sourceKey) + statement.bindString(2, normalizedId) + statement.bindLong(3, program.startUtcMillis) + statement.bindLong(4, program.endUtcMillis) + statement.bindString(5, titleTrimmed) + if (description.isNullOrBlank()) { + statement.bindNull(6) + } else { + statement.bindString(6, description) + } + statement.executeInsert() } - statement.executeInsert() + + item.now?.let(::insertProgram) + item.next?.let(::insertProgram) + item.later?.let(::insertProgram) + item.upcoming.forEach(::insertProgram) + item.recent.forEach(::insertProgram) } } finally { statement.close() @@ -236,35 +264,6 @@ internal class IptvEpgIndex(context: Context) : SQLiteOpenHelper( } } - private fun flatten(nowNext: Map): List { - return buildList { - nowNext.forEach { (channelId, item) -> - val normalizedId = channelId.trim() - if (normalizedId.isBlank()) return@forEach - item.allPrograms() - .filter { it.title.isNotBlank() && it.endUtcMillis > it.startUtcMillis } - .distinctBy { "${it.startUtcMillis}|${it.endUtcMillis}|${it.title}" } - .forEach { program -> add(ProgramRow(normalizedId, program.compactForIndex())) } - } - } - } - - private fun IptvNowNext.allPrograms(): List { - return buildList { - now?.let(::add) - next?.let(::add) - later?.let(::add) - addAll(upcoming) - addAll(recent) - } - } - - private fun IptvProgram.compactForIndex(): IptvProgram = - copy( - title = title.trim(), - description = description?.trim()?.take(MAX_DESCRIPTION_CHARS) - ) - private fun buildNowNext(programs: List, nowMs: Long): IptvNowNext? { if (programs.isEmpty()) return null val sorted = programs @@ -315,10 +314,6 @@ internal class IptvEpgIndex(context: Context) : SQLiteOpenHelper( } } - private data class ProgramRow( - val channelId: String, - val program: IptvProgram - ) private companion object { const val DATABASE_NAME = "arvio_iptv_epg_index.db" 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 435e107c..99091a80 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 @@ -187,7 +187,7 @@ class IptvRepository @Inject constructor( private val guideKeyCandidatesCache = java.util.Collections.synchronizedMap( object : java.util.LinkedHashMap>(512, 0.75f, true) { - override fun removeEldestEntry(eldest: Map.Entry>?): Boolean = size > 16384 + override fun removeEldestEntry(eldest: Map.Entry>?): Boolean = size > 4096 } ) @@ -1442,13 +1442,13 @@ class IptvRepository @Inject constructor( ) } if (playlistChannels.isNotEmpty()) { - aggregatedChannels += playlistChannels - val currentList = synchronized(aggregatedChannels) { aggregatedChannels.toList() } + aggregatedChannels.addAll(playlistChannels) + val currentList = synchronized(aggregatedChannels) { ArrayList(aggregatedChannels) } runCatching { onChannelsReady(currentList) } } - playlistChannels } - }.awaitAll().flatten() + }.awaitAll() + synchronized(aggregatedChannels) { ArrayList(aggregatedChannels) } }.also { cachedChannels = it cachedGroupedChannels = buildGroupedChannels(it) @@ -1637,7 +1637,7 @@ class IptvRepository @Inject constructor( if (resolved) { shortEpgResult?.let { mergedXmlNowNext.putAll(it) } // Short EPG wins for channels it covers resolvedNowNext = mergedXmlNowNext - cachedNowNext = ConcurrentHashMap(mergedXmlNowNext) + cachedNowNext = mergedXmlNowNext cachedEpgAt = System.currentTimeMillis() if (xmltvChanged) { persistEpgIndexAll(config, mergedXmlNowNext, cachedEpgAt) @@ -2447,6 +2447,8 @@ class IptvRepository @Inject constructor( fun invalidateCache() { cachedChannels = emptyList() + cachedChannelsLookupSource = null + cachedChannelsById = emptyMap() cachedGroupedChannels = emptyMap() cachedNowNext = ConcurrentHashMap() cachedPlaylistAt = 0L @@ -6235,7 +6237,20 @@ class IptvRepository @Inject constructor( val country = extractFirstAttr(metadata, "tvg-country", "country") val qualityLabel = extractFirstAttr(metadata, "quality", "tvg-quality", "resolution") ?: inferQualityLabel(channelName, groupTitle) - val requestHeaders = (pendingHeaders + extractInlineRequestHeaders(metadata)).filterValues { it.isNotBlank() } + val inlineHeaders = extractInlineRequestHeaders(metadata) + val requestHeaders = if (pendingHeaders.isEmpty()) { + if (inlineHeaders.isEmpty()) { + emptyMap() + } else { + inlineHeaders.filterValues { it.isNotBlank() } + } + } else { + if (inlineHeaders.isEmpty()) { + pendingHeaders.filterValues { it.isNotBlank() } + } else { + (pendingHeaders + inlineHeaders).filterValues { it.isNotBlank() } + } + } channels += IptvChannel( id = id, @@ -6320,7 +6335,10 @@ class IptvRepository @Inject constructor( if (!xmlId.isNullOrBlank()) { val display = normalizeChannelKey(parser.nextText().orEmpty()) if (display.isNotBlank()) { - xmlChannelNameMap.getOrPut(xmlId) { mutableSetOf() }.add(display) + val isUseful = guideKeyCandidates(display).any { it in keyLookup } + if (isUseful) { + xmlChannelNameMap.getOrPut(xmlId) { mutableSetOf() }.add(display) + } } } } @@ -6490,7 +6508,10 @@ class IptvRepository @Inject constructor( if (!xmlId.isNullOrBlank()) { val display = normalizeChannelKey(textBuffer.toString()) if (display.isNotBlank()) { - xmlChannelNameMap.getOrPut(xmlId) { mutableSetOf() }.add(display) + val isUseful = guideKeyCandidates(display).any { it in keyLookup } + if (isUseful) { + xmlChannelNameMap.getOrPut(xmlId) { mutableSetOf() }.add(display) + } } } readingDisplayName = false @@ -6561,8 +6582,9 @@ class IptvRepository @Inject constructor( nowCandidates: Map, upcomingCandidates: Map>, recentCandidates: Map> - ): Map { - return channels.mapNotNull { channel -> + ): ConcurrentHashMap { + val result = ConcurrentHashMap(channels.size) + channels.forEach { channel -> val future = upcomingCandidates[channel.id].orEmpty() val recent = recentCandidates[channel.id].orEmpty().sortedBy { it.startUtcMillis } val nowNext = IptvNowNext( @@ -6572,8 +6594,11 @@ class IptvRepository @Inject constructor( upcoming = future, recent = recent ) - if (hasProgramData(nowNext)) channel.id to nowNext else null - }.toMap() + if (hasProgramData(nowNext)) { + result[channel.id] = nowNext + } + } + return result } private fun pickNow(existing: IptvProgram?, candidate: IptvProgram, nowUtcMillis: Long): IptvProgram? { @@ -6909,9 +6934,12 @@ class IptvRepository @Inject constructor( private fun extractInlineRequestHeaders(metadata: String?): Map { if (metadata.isNullOrBlank()) return emptyMap() - val headers = linkedMapOf() - extractFirstAttr(metadata, "http-user-agent", "user-agent")?.let { headers["User-Agent"] = it } - extractFirstAttr(metadata, "http-referrer", "http-referer", "referrer", "referer")?.let { headers["Referer"] = it } + val userAgent = extractFirstAttr(metadata, "http-user-agent", "user-agent") + val referrer = extractFirstAttr(metadata, "http-referrer", "http-referer", "referrer", "referer") + if (userAgent == null && referrer == null) return emptyMap() + val headers = LinkedHashMap(2) + userAgent?.let { headers["User-Agent"] = it } + referrer?.let { headers["Referer"] = it } return headers } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index da1773bb..f7a32a6c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -1,6 +1,7 @@ package com.arflix.tv.ui.screens.details import android.content.Context +import android.graphics.Bitmap import android.content.Intent import android.net.Uri import android.os.Build @@ -120,6 +121,7 @@ import androidx.tv.material3.Text import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import coil.ImageLoader +import coil.imageLoader import coil.compose.AsyncImage import coil.compose.SubcomposeAsyncImage import coil.decode.SvgDecoder @@ -1147,22 +1149,7 @@ private fun handleRight( return true } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun rememberMetadataLogoImageLoader(context: Context): ImageLoader { - return remember(context) { - ImageLoader.Builder(context) - .okHttpClient(OkHttpProvider.coilClient) - .components { - add(SvgDecoder.Factory()) - } - .allowRgb565(false) - .crossfade(false) - .placeholder(android.R.color.transparent) - .error(android.R.color.transparent) - .build() - } -} + @Composable private fun DetailsContent( @@ -1208,7 +1195,7 @@ private fun DetailsContent( onCollectionClick: (Int) -> Unit = {} ) { val context = LocalContext.current - val metadataLogoImageLoader = rememberMetadataLogoImageLoader(context) + val metadataLogoImageLoader = context.imageLoader val focusSectionForUi = if (contentHasFocus) focusedSection else null // === PREMIUM LAYERED TEXT SHADOWS === val textShadow = Shadow( @@ -1929,8 +1916,15 @@ private fun DetailsContent( if (primaryNetworkLogo != null) { Text(text = "|", style = separatorStyle, color = Color.White.copy(alpha = 0.7f)) + val networkLogoRequest = remember(primaryNetworkLogo, context) { + ImageRequest.Builder(context) + .data(primaryNetworkLogo) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .allowRgb565(false) + .build() + } AsyncImage( - model = primaryNetworkLogo, + model = networkLogoRequest, imageLoader = metadataLogoImageLoader, contentDescription = "Primary streaming provider", contentScale = ContentScale.Fit, @@ -2967,13 +2961,21 @@ private fun DetailsImdbSvgRatingBadge( logoHeight: Dp, textShadow: Shadow ) { + val context = LocalContext.current val imdbLogoUri = remember { "android.resource://com.arvio.tv/${R.raw.logo_imdb_rectangle}" } + val request = remember(imdbLogoUri, context) { + ImageRequest.Builder(context) + .data(imdbLogoUri) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .allowRgb565(false) + .build() + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { AsyncImage( - model = imdbLogoUri, + model = request, imageLoader = imageLoader, contentDescription = "IMDb", contentScale = ContentScale.Fit, @@ -3300,7 +3302,7 @@ private fun EpisodeCard( val aspectRatio = 16f / 9f val context = LocalContext.current val density = LocalDensity.current - val metadataLogoImageLoader = rememberMetadataLogoImageLoader(context) + val metadataLogoImageLoader = context.imageLoader val shape = rememberArvioCardShape(ArvioSkin.radius.md) val scale by animateFloatAsState( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 0da5076a..a1b639e7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -3,6 +3,7 @@ package com.arflix.tv.ui.screens.home import android.content.Context +import android.graphics.Bitmap import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FastOutSlowInEasing @@ -120,6 +121,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import coil.ImageLoader +import coil.imageLoader import coil.compose.AsyncImage import coil.decode.SvgDecoder import coil.request.ImageRequest @@ -413,21 +415,7 @@ private suspend fun androidx.compose.foundation.lazy.LazyListState.animateHomeSc } } -@Composable -private fun rememberMetadataLogoImageLoader(context: Context): ImageLoader { - return remember(context) { - ImageLoader.Builder(context) - .okHttpClient(OkHttpProvider.coilClient) - .components { - add(SvgDecoder.Factory()) - } - .allowRgb565(false) - .crossfade(false) - .placeholder(android.R.color.transparent) - .error(android.R.color.transparent) - .build() - } -} + @Composable private fun HomeBackdropCrossfade( @@ -1291,7 +1279,7 @@ private fun HeroSection( modifier: Modifier = Modifier ) { val context = LocalContext.current - val metadataLogoImageLoader = rememberMetadataLogoImageLoader(context) + val metadataLogoImageLoader = context.imageLoader val density = LocalDensity.current val logoSize = remember(density) { val widthPx = with(density) { 320.dp.roundToPx() } @@ -1343,6 +1331,8 @@ private fun HeroSection( val cacheKey = "$currentLogoUrl|${logoWidthPx}x$logoHeightPx" ImageRequest.Builder(context) .data(currentLogoUrl) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .allowRgb565(false) .size(logoWidthPx, logoHeightPx) .precision(Precision.INEXACT) .allowHardware(true) @@ -1539,8 +1529,15 @@ private fun HeroSection( modifier = Modifier.fillMaxWidth() ) { if (primaryNetworkLogo != null) { + val networkLogoRequest = remember(primaryNetworkLogo, context) { + ImageRequest.Builder(context) + .data(primaryNetworkLogo) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .allowRgb565(false) + .build() + } AsyncImage( - model = primaryNetworkLogo, + model = networkLogoRequest, imageLoader = metadataLogoImageLoader, contentDescription = "Primary streaming provider", contentScale = ContentScale.Fit, @@ -1749,7 +1746,7 @@ private fun MobileHeroOverlay( onDetails: () -> Unit ) { val context = LocalContext.current - val metadataLogoImageLoader = rememberMetadataLogoImageLoader(context) + val metadataLogoImageLoader = context.imageLoader val mobileHeroGradient = remember { Brush.verticalGradient( listOf( @@ -3139,13 +3136,21 @@ private fun ImdbSvgRatingBadge( logoHeight: Dp, textShadow: Shadow ) { + val context = LocalContext.current val imdbLogoUri = remember { "android.resource://com.arvio.tv/${R.raw.logo_imdb_rectangle}" } + val request = remember(imdbLogoUri, context) { + ImageRequest.Builder(context) + .data(imdbLogoUri) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .allowRgb565(false) + .build() + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { AsyncImage( - model = imdbLogoUri, + model = request, imageLoader = imageLoader, contentDescription = "IMDb", contentScale = ContentScale.Fit,