From 65333abf55201a85c196e5e2000bb87989dff8e2 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 7 Sep 2026 20:28:50 +0530 Subject: [PATCH 01/11] fix(tv): resolve Live TV focus highlighters and drawer re-entry navigation - Decouple focus outline from active channel state so navigating channels preserves the playing channel background and cyan indicator. - Prevent DPAD Right on channels without EPG programs from dropping focus to the row below. - Fix drawer re-entry focus trap when scrolling away from the selected playlist by implementing viewport-aware focus requester fallback in CategorySidebar. --- .../tv/ui/screens/tv/live/CategorySidebar.kt | 242 +++++++++++++++--- .../tv/ui/screens/tv/live/ChannelRow.kt | 5 +- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 81 ++++-- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 6 + 4 files changed, 278 insertions(+), 56 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index f6c201364..9d70e8654 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -150,6 +151,8 @@ fun CategorySidebar( val searchFocusRequester = remember { FocusRequester() } val selectedCategoryFocusRequester = remember { FocusRequester() } val firstCategoryFocusRequester = remember { FocusRequester() } + val categoryFocusRequesters = remember { mutableMapOf() } + var lastFocusedCategoryKey by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl @@ -320,15 +323,47 @@ fun CategorySidebar( if (activeMenu != null || (categoryHasHadFocus && sidebarHasFocus && !searchHasFocus)) return@LaunchedEffect claimingCategoryFocus = true try { - // requestFocus() in the Compose version used by ARVIO does not - // report whether a lazy item was attached. Check the actual focus - // state before accepting the selected row; otherwise retry with - // the always-composed first row as a reliable fallback. repeat(LiveTvStartup.INITIAL_FOCUS_ATTEMPTS) { - runCatching { selectedCategoryFocusRequester.requestFocus() } - delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) - if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect + // 1. If we remember the last focused item in the sidebar, try restoring focus to it + val lastKey = lastFocusedCategoryKey + if (lastKey != null) { + val req = categoryFocusRequesters[lastKey] + if (req != null && runCatching { req.requestFocus() }.isSuccess) { + delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) + if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect + } + } + + // 2. Try selected category requester + if (runCatching { selectedCategoryFocusRequester.requestFocus() }.isSuccess) { + delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) + if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect + } + val selectedReq = categoryFocusRequesters[selectedId] + if (selectedReq != null && runCatching { selectedReq.requestFocus() }.isSuccess) { + delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) + if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect + } + + // 3. Try visible items in LazyColumn + val visibleKeys = listState.layoutInfo.visibleItemsInfo.map { it.key.toString() } + for (vKey in visibleKeys) { + val req = categoryFocusRequesters[vKey] + if (req != null && runCatching { req.requestFocus() }.isSuccess) { + delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) + if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect + } + } + // 4. Try any registered category requester that is currently composed + for (req in categoryFocusRequesters.values) { + if (runCatching { req.requestFocus() }.isSuccess) { + delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) + if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect + } + } + + // 5. Fallback to first row runCatching { firstCategoryFocusRequester.requestFocus() } delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect @@ -486,6 +521,16 @@ fun CategorySidebar( itemsIndexed(tree.top.distinctBy { it.id }, key = { _, cat -> "top:${cat.id}" }) { index, cat -> val isAllGroup = cat.id == "all" && cat.children.isNotEmpty() val isOpen = isAllGroup && expandedAll + val itemKey = "top:${cat.id}" + val requester = rememberCategoryRequester( + key = itemKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = index == 0, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(cat.label), count = cat.count, @@ -494,16 +539,11 @@ fun CategorySidebar( expanded = expanded, hasChildren = isAllGroup, isOpenGroup = isOpen, - // The selected category can be nested (or scrolled out of - // the lazy list), in which case its requester is unattached - // and cannot take focus. The first row always can, so it - // acts as the guaranteed landing spot on entry. - focusRequester = when { - selectedId == cat.id -> selectedCategoryFocusRequester - index == 0 -> firstCategoryFocusRequester - else -> null + focusRequester = requester, + onFocused = { + lastFocusedCategoryKey = itemKey + onCategoryFocused() }, - onFocused = { onCategoryFocused() }, onClick = { if (isAllGroup) { expandedAll = !expandedAll @@ -513,6 +553,16 @@ fun CategorySidebar( ) if (isOpen && expanded) { cat.children.forEach { child -> + val childKey = "top:child:${child.id}" + val childRequester = rememberCategoryRequester( + key = childKey, + id = child.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(child.label), count = child.count, @@ -524,12 +574,25 @@ fun CategorySidebar( labelSize = 10.5.sp, hasChildren = child.children.isNotEmpty(), isOpenGroup = child.containsId(selectedId), - focusRequester = if (selectedId == child.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = childRequester, + onFocused = { + lastFocusedCategoryKey = childKey + onCategoryFocused() + }, onClick = { onSelect(child.id) }, ) if (child.containsId(selectedId)) { child.children.forEach { grandchild -> + val gcKey = "top:grandchild:${grandchild.id}" + val gcRequester = rememberCategoryRequester( + key = gcKey, + id = grandchild.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(grandchild.label), count = grandchild.count, @@ -538,8 +601,11 @@ fun CategorySidebar( expanded = true, indent = 48.dp, labelSize = 9.5.sp, - focusRequester = if (selectedId == grandchild.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = gcRequester, + onFocused = { + lastFocusedCategoryKey = gcKey + onCategoryFocused() + }, onClick = { onSelect(grandchild.id) }, ) } @@ -551,6 +617,16 @@ fun CategorySidebar( playlistSections.forEach { section -> item(key = "playlist-section:${section.id}") { val isOpen = section.id in expandedPlaylistIds + val sectionKey = "playlist-section:${section.id}" + val sectionRequester = rememberCategoryRequester( + key = sectionKey, + id = section.id, + selectedId = null, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = section.label, count = section.count, @@ -559,7 +635,11 @@ fun CategorySidebar( expanded = expanded, hasChildren = true, isOpenGroup = isOpen, - onFocused = { onCategoryFocused() }, + focusRequester = sectionRequester, + onFocused = { + lastFocusedCategoryKey = sectionKey + onCategoryFocused() + }, onClick = { expandedPlaylistIds = if (isOpen) { expandedPlaylistIds - section.id @@ -574,6 +654,16 @@ fun CategorySidebar( section.categories.distinctBy { it.id }, key = { _, cat -> "playlist:${section.id}:${cat.id}" }, ) { _, cat -> + val catKey = "playlist:${section.id}:${cat.id}" + val catRequester = rememberCategoryRequester( + key = catKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(cat.label), count = cat.count, @@ -581,8 +671,11 @@ fun CategorySidebar( active = selectedId == cat.id, expanded = true, indent = 28.dp, - focusRequester = if (selectedId == cat.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = catRequester, + onFocused = { + lastFocusedCategoryKey = catKey + onCategoryFocused() + }, locked = isCategoryLocked(cat), onLongClick = { openCategoryMenu(cat, hidden = false) }, onClick = { onSelect(cat.id) }, @@ -593,14 +686,27 @@ fun CategorySidebar( } else if (tree.global.categories.isNotEmpty()) { item { SectionHeader(liveSectionLabel(tree.global.label), expanded) } itemsIndexed(tree.global.categories.distinctBy { it.id }, key = { _, cat -> "global:${cat.id}" }) { _, cat -> + val catKey = "global:${cat.id}" + val catRequester = rememberCategoryRequester( + key = catKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(cat.label), count = cat.count, icon = iconFor(cat), active = selectedId == cat.id, expanded = expanded, - focusRequester = if (selectedId == cat.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = catRequester, + onFocused = { + lastFocusedCategoryKey = catKey + onCategoryFocused() + }, locked = isCategoryLocked(cat), onLongClick = { openCategoryMenu(cat, hidden = false) @@ -613,6 +719,16 @@ fun CategorySidebar( item { SectionHeader(liveSectionLabel(tree.countries.label), expanded) } itemsIndexed(tree.countries.categories.distinctBy { it.id }, key = { _, country -> "country:${country.id}" }) { _, country -> val isExpanded = expandedCountry == country.id + val countryKey = "country:${country.id}" + val countryRequester = rememberCategoryRequester( + key = countryKey, + id = country.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(country.label), count = country.count, @@ -622,8 +738,11 @@ fun CategorySidebar( expanded = expanded, hasChildren = country.children.isNotEmpty(), isOpenGroup = isExpanded, - focusRequester = if (selectedId == country.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = countryRequester, + onFocused = { + lastFocusedCategoryKey = countryKey + onCategoryFocused() + }, onClick = { // Tap always toggles expansion. Opening also selects so // the grid reflects the just-opened group; collapsing @@ -639,6 +758,16 @@ fun CategorySidebar( ) if (isExpanded && expanded) { country.children.forEach { child -> + val childKey = "country:child:${child.id}" + val childRequester = rememberCategoryRequester( + key = childKey, + id = child.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(child.label), count = child.count, @@ -647,8 +776,11 @@ fun CategorySidebar( expanded = true, indent = 40.dp, labelSize = 10.5.sp, - focusRequester = if (selectedId == child.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = childRequester, + onFocused = { + lastFocusedCategoryKey = childKey + onCategoryFocused() + }, onClick = { onSelect(child.id) }, ) } @@ -657,15 +789,28 @@ fun CategorySidebar( } if (tree.adult.categories.isNotEmpty()) { item { SectionHeader(liveSectionLabel(tree.adult.label), expanded) } - itemsIndexed(tree.adult.categories, key = { index, cat -> "adult:${cat.id}:$index" }) { _, cat -> + itemsIndexed(tree.adult.categories, key = { index, cat -> "adult:${cat.id}:$index" }) { index, cat -> + val adultKey = "adult:${cat.id}:$index" + val adultRequester = rememberCategoryRequester( + key = adultKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) SidebarRow( label = liveCategoryLabel(cat.label), count = cat.count, icon = Icons.Filled.Lock, active = selectedId == cat.id, expanded = expanded, - focusRequester = if (selectedId == cat.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + focusRequester = adultRequester, + onFocused = { + lastFocusedCategoryKey = adultKey + onCategoryFocused() + }, onClick = { onSelect(cat.id) }, ) } @@ -802,6 +947,39 @@ private fun SectionHeader(label: String, expanded: Boolean) { } } +@Composable +private fun rememberCategoryRequester( + key: String, + id: String?, + selectedId: String?, + isTopFirst: Boolean, + selectedCategoryFocusRequester: FocusRequester, + firstCategoryFocusRequester: FocusRequester, + categoryFocusRequesters: MutableMap, +): FocusRequester { + val isSelected = id != null && id == selectedId + val requester = when { + isSelected -> selectedCategoryFocusRequester + isTopFirst -> firstCategoryFocusRequester + else -> remember(key) { FocusRequester() } + } + DisposableEffect(key, requester, id) { + categoryFocusRequesters[key] = requester + if (id != null) { + categoryFocusRequesters[id] = requester + } + onDispose { + if (categoryFocusRequesters[key] === requester) { + categoryFocusRequesters.remove(key) + } + if (id != null && categoryFocusRequesters[id] === requester) { + categoryFocusRequesters.remove(id) + } + } + } + return requester +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun SidebarRow( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index 5f087cb1e..49fe4cd2e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt @@ -98,8 +98,9 @@ fun ChannelRow( // can be swallowed before combinedClickable turns them into a click. var longPressConsumed by remember { mutableStateOf(false) } val bg = when { - visuallyFocused -> LiveColors.PanelRaised + visuallyFocused && isActive -> LiveColors.FocusBg isActive -> LiveColors.FocusBg + visuallyFocused -> LiveColors.PanelRaised stripe -> LiveColors.RowStripe else -> Color.Transparent } @@ -140,7 +141,7 @@ fun ChannelRow( ) } } - .background(if (visuallyFocused) LiveColors.PanelRaised else bg) + .background(bg) .focusable() // Long-press / MENU opens the channel menu. This has to live in the PREVIEW // phase, ahead of combinedClickable: combinedClickable arms a click on the diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index c71d07f49..110884eb2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -106,6 +106,7 @@ fun EpgGrid( isGuideBackfillLoading: Boolean = false, hasGuideSource: Boolean = true, selectedChannelId: String?, + playingChannelId: String? = null, focusSelectedChannelSignal: Int, focusEpgSignal: Int = 0, focusMode: EpgGridFocusMode = EpgGridFocusMode.ChannelList, @@ -243,16 +244,16 @@ fun EpgGrid( } fun requestNearestProgramFocus(rowIdx: Int, anchorStartMin: Int): Boolean { - val channel = channels.getOrNull(rowIdx) ?: return true + val channel = channels.getOrNull(rowIdx) ?: return false requestMoreRowsIfNeeded(rowIdx) focusJob?.cancel() focusJob = scope.launch { - // The next row may not be composed yet. Reveal it before resolving - // its programme; falling back to spatial focus can jump to the rail. revealRow(rowIdx) + // Retry a few times: Compose may need a frame to mount the row and + // its programme; falling back to spatial focus can jump to the rail. repeat(8) { - val targetIdx = nearestProgramIndex(rowIdx, anchorStartMin) - val requester = targetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } + val currentTargetIdx = nearestProgramIndex(rowIdx, anchorStartMin) + val requester = currentTargetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } if (requester != null && runCatching { requester.requestFocus() }.isSuccess) { return@launch } @@ -434,10 +435,13 @@ fun EpgGrid( Text(safeTotalChannelCount.toString(), style = LiveType.NumberMono.copy(color = LiveColors.FgDim)) } + val currentPlayingOrSelectedChannel = playingChannelId?.let { id -> + channelIndexById[id]?.let { index -> channels.getOrNull(index) } + } ?: selectedChannel Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Text(stringResource(R.string.live_badge_ch), style = LiveType.SectionTag.copy(color = LiveColors.Accent)) Text( - selectedChannel?.number?.toString() ?: "—", + currentPlayingOrSelectedChannel?.number?.toString() ?: "—", style = LiveType.NumberMono.copy(color = LiveColors.Accent), ) } @@ -554,16 +558,32 @@ fun EpgGrid( } } } + val rowPrograms = remember( + ch.id, + nowNext[ch.id], + windowStartMillis, + windowEndMillis, + ) { + programsInWindow(nowNext[ch.id], windowStartMillis, windowEndMillis) + } + val hasFocusable = remember(ch, rowPrograms, clockTickMillis) { + hasFocusablePrograms(ch, rowPrograms, clockTickMillis) + } Row( modifier = Modifier .fillMaxWidth() .height(rowHeight) ) { + val isChannelActive = if (playingChannelId != null) { + ch.id == playingChannelId + } else { + ch.id == selectedChannelId + } // 1. Channel item (fixed width, doesn't scroll horizontally) ChannelRow( channel = ch, displayQuality = ch.displayQuality(playbackQuality), - isActive = ch.id == selectedChannelId || (gridFocused && locallyFocused), + isActive = isChannelActive, clockTickMillis = clockTickMillis, nowNext = nowNext[ch.id], isFavorite = ch.id in favorites, @@ -582,10 +602,10 @@ fun EpgGrid( }, onMoveLeft = onMoveLeftFromChannels, onMoveRight = { - val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() - onEnterEpg(ch) - if (requestNearestProgramFocus(idx, nowMin)) { - true + if (hasFocusable) { + val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() + onEnterEpg(ch) + requestNearestProgramFocus(idx, nowMin) } else { keepChannelFocus(idx) } @@ -621,14 +641,6 @@ fun EpgGrid( .fillMaxHeight() .horizontalScroll(hScroll) ) { - val rowPrograms = remember( - ch.id, - nowNext[ch.id], - windowStartMillis, - windowEndMillis, - ) { - programsInWindow(nowNext[ch.id], windowStartMillis, windowEndMillis) - } val isGuideLoading = hasGuideSource && rowPrograms.isEmpty() && ( @@ -656,7 +668,7 @@ fun EpgGrid( totalWidth = totalWidth, pxPerMin = pxPerMin, stripe = idx % 2 == 1, - isActive = ch.id == selectedChannelId && focusMode == EpgGridFocusMode.Epg, + isActive = isChannelActive && focusMode == EpgGridFocusMode.Epg, epgMode = focusMode == EpgGridFocusMode.Epg, rowHeight = rowHeight, renderWindow = renderWindow, @@ -671,7 +683,19 @@ fun EpgGrid( } }, onMoveVertically = { targetRowIdx, anchorStartMin -> - requestNearestProgramFocus(targetRowIdx, anchorStartMin) + val targetChannel = channels.getOrNull(targetRowIdx) + val targetPrograms = targetChannel?.let { targetCh -> + programsInWindow(nowNext[targetCh.id], windowStartMillis, windowEndMillis) + }.orEmpty() + val targetHasFocusable = targetChannel != null && + hasFocusablePrograms(targetChannel, targetPrograms, clockTickMillis) + if (targetHasFocusable) { + requestNearestProgramFocus(targetRowIdx, anchorStartMin) + } else if (targetChannel != null) { + onExitEpg(targetChannel) + keepChannelFocus(targetRowIdx) + } + true }, onMoveLeftFromStart = { onExitEpg(ch) @@ -845,7 +869,7 @@ private fun ProgramsRow( runCatching { rowFocusRequesters[focusableIndex + 1].requestFocus() } true } else { - false + true } }, onMoveUp = { @@ -986,6 +1010,19 @@ private fun effectiveCatchupDays(channel: EnrichedChannel): Int { private fun ProgramPlacement.canFocus(channel: EnrichedChannel, nowMillis: Long): Boolean = !isPlaceholder && (!isPast(nowMillis) || isCatchupSupported(channel, nowMillis)) +private fun hasFocusablePrograms( + channel: EnrichedChannel, + programs: List, + nowMillis: Long, +): Boolean { + if (programs.isEmpty()) return false + val days = effectiveCatchupDays(channel) + val catchupCutoff = nowMillis - days * 24L * 60L * 60_000L + return programs.any { p -> + p.endUtcMillis > nowMillis || (days > 0 && p.startUtcMillis >= catchupCutoff) || p.catchupAvailable == true + } +} + private fun buildProgramPlacements( programs: List, windowStartMillis: Long, 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 e11996c53..986773de6 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 @@ -1144,6 +1144,9 @@ fun LiveTvScreen( val selectedDisplayChannelId = remember(focusedChannelId, playingChannelId, visibleChannelsById, variantGroups) { displayChannelIdFor(focusedChannelId ?: playingChannelId, visibleChannelsById, variantGroups) } + val playingDisplayChannelId = remember(playingChannelId, visibleChannelsById, variantGroups) { + displayChannelIdFor(playingChannelId, visibleChannelsById, variantGroups) + } val indexedPlayingChannel = remember(playingChannelId, visibleEnrichedState.value, filteredChannels) { playingChannelId?.let { visibleEnrichedState.value.index.byId[it] } ?: filteredChannels.firstOrNull { it.id == playingChannelId } @@ -3130,6 +3133,7 @@ fun LiveTvScreen( isGuideBackfillLoading = false, hasGuideSource = state.hasPotentialGuideSource, selectedChannelId = selectedDisplayChannelId, + playingChannelId = playingDisplayChannelId ?: playingChannelId, focusSelectedChannelSignal = focusSelectedChannelSignal, focusEpgSignal = focusEpgSignal, focusMode = if (focusZone == LiveTvFocusZone.EPG) { @@ -3268,6 +3272,7 @@ fun LiveTvScreen( isGuideBackfillLoading = false, hasGuideSource = state.hasPotentialGuideSource, selectedChannelId = selectedDisplayChannelId, + playingChannelId = playingDisplayChannelId ?: playingChannelId, focusSelectedChannelSignal = focusSelectedChannelSignal, focusEpgSignal = focusEpgSignal, focusMode = if (focusZone == LiveTvFocusZone.EPG) { @@ -3303,6 +3308,7 @@ fun LiveTvScreen( .onFocusChanged { if (it.hasFocus && focusZone == LiveTvFocusZone.CATEGORY_LIST) { focusZone = LiveTvFocusZone.CHANNEL_LIST + categoryDrawerOpen = false } } .then(if (!isTouchDevice) Modifier.focusRequester(epgFocus) else Modifier), From 3f3410d3c58fb43da1a7bf7550a49543ee17f8bb Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 7 Sep 2026 20:34:28 +0530 Subject: [PATCH 02/11] fix(tv): snapshot categoryFocusRequesters values to prevent ConcurrentModificationException --- .../kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index 9d70e8654..c8182b2af 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -356,7 +356,7 @@ fun CategorySidebar( } // 4. Try any registered category requester that is currently composed - for (req in categoryFocusRequesters.values) { + for (req in categoryFocusRequesters.values.toList()) { if (runCatching { req.requestFocus() }.isSuccess) { delay(LiveTvStartup.INITIAL_FOCUS_RETRY_MS) if (sidebarHasFocus && !searchHasFocus) return@LaunchedEffect From f629d461a0689ec520e8aab3be8e2d012d9e955e Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 7 Sep 2026 21:03:17 +0530 Subject: [PATCH 03/11] fix(live-tv): fix drawer collapse jumps, focus flash, epg text clipping, and category persistence --- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 6 +- .../tv/ui/screens/tv/live/CategorySidebar.kt | 704 ++++++++++-------- .../tv/ui/screens/tv/live/ChannelRow.kt | 5 +- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 37 +- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 38 +- .../tv/ui/screens/tv/live/ProgramCell.kt | 5 +- .../ui/screens/tv/live/TouchCategoryRail.kt | 2 +- 7 files changed, 483 insertions(+), 314 deletions(-) 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 6c6e07745..69ca11711 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 @@ -2037,9 +2037,9 @@ class TvViewModel @Inject constructor( } fun rememberTvSession( - lastChannelId: String?, - lastGroupName: String?, - lastFocusedZone: String, + lastChannelId: String? = null, + lastGroupName: String? = null, + lastFocusedZone: String = "GUIDE", markOpened: Boolean = false ) { val current = _uiState.value.tvSession diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index c8182b2af..e39ca6292 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -20,8 +20,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.ui.draw.clipToBounds import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -237,15 +239,18 @@ fun CategorySidebar( } } - val categoriesLoaded = LiveTvStartup.searchIsReachable(tree.top.size) - val categoryStructureKey = remember(tree, playlistSections) { + val visibleTopCategories = remember(tree.top) { + tree.top.distinctBy { it.id }.filter { it.id != "fav" || it.count > 0 } + } + val categoriesLoaded = LiveTvStartup.searchIsReachable(visibleTopCategories.size) + val categoryStructureKey = remember(tree, playlistSections, visibleTopCategories) { buildString { fun appendSection(name: String, categories: List) { append(name).append(':') categories.forEach { category -> append(category.id).append(',') } append('|') } - appendSection("top", tree.top) + appendSection("top", visibleTopCategories) appendSection("global", tree.global.categories) appendSection("countries", tree.countries.categories) appendSection("adult", tree.adult.categories) @@ -401,6 +406,25 @@ fun CategorySidebar( } } + LaunchedEffect(selectedId, expanded, categoriesLoaded, expandedPlaylistIds) { + if (expanded && categoriesLoaded && selectedId.isNotBlank()) { + val targetIdx = findCategoryLazyIndex( + targetId = selectedId, + topCategories = visibleTopCategories, + playlistSections = playlistSections, + globalSection = tree.global, + countrySection = tree.countries, + adultSection = tree.adult, + expandedAll = expandedAll, + expandedPlaylistIds = expandedPlaylistIds, + expandedCountry = expandedCountry, + ) + if (targetIdx >= 0) { + listState.scrollToItem((targetIdx - 2).coerceAtLeast(0)) + } + } + } + Column( modifier = modifier .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) @@ -411,6 +435,7 @@ fun CategorySidebar( alpha = contentAlpha clip = true } + .clipToBounds() .onPreviewKeyEvent { ev -> // RTL mirrors the sidebar to the right edge, so the physical // Left/Right keys drive the opposite logical action here. @@ -448,6 +473,13 @@ fun CategorySidebar( true } ev.key == Key.Menu && ev.type == KeyEventType.KeyUp -> { + activeMenu = null + menuSelectArmed = false + true + } + ev.key == Key.Back && ev.type == KeyEventType.KeyUp -> { + activeMenu = null + menuSelectArmed = false true } // logicalKey, not ev.key: in RTL the sidebar sits on the right edge, @@ -488,210 +520,54 @@ fun CategorySidebar( verticalArrangement = Arrangement.spacedBy(2.dp), ) { if (!contentVisible) return@Column - SearchEntry( - onClick = onOpenSearch, - expanded = expanded, - onMoveUp = onMoveUpFromSearch, - onMoveDown = { - // Down from search is navigation, not activation. Selecting here - // closed the drawer while the same physical key was still being - // handled, so rapid D-pad input left the guide without a focusable - // row. Move focus to the first category and require OK to open it. - userChoseSearch = false - runCatching { firstCategoryFocusRequester.requestFocus() } - }, - onFocusChanged = { atTop -> - // Search taking focus *after* a category already had it means - // the user walked up into it — leave the selector alone from - // then on. Search taking it before that is Compose's default - // placement (or the player bouncing focus back), which the - // effect above corrects. - if (atTop && categoryHasHadFocus && !claimingCategoryFocus) userChoseSearch = true - searchHasFocus = atTop - onTopBoundaryFocusChanged(atTop) - }, - focusRequester = searchFocusRequester, - focusable = categoriesLoaded, - ) - Spacer(Modifier.height(8.dp)) - LazyColumn( - state = listState, + Column( + modifier = Modifier + .requiredWidth(LiveDims.SidebarExpanded - 20.dp) + .fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - itemsIndexed(tree.top.distinctBy { it.id }, key = { _, cat -> "top:${cat.id}" }) { index, cat -> - val isAllGroup = cat.id == "all" && cat.children.isNotEmpty() - val isOpen = isAllGroup && expandedAll - val itemKey = "top:${cat.id}" - val requester = rememberCategoryRequester( - key = itemKey, - id = cat.id, - selectedId = selectedId, - isTopFirst = index == 0, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = liveCategoryLabel(cat.label), - count = cat.count, - icon = iconFor(cat), - active = selectedId == cat.id, - expanded = expanded, - hasChildren = isAllGroup, - isOpenGroup = isOpen, - focusRequester = requester, - onFocused = { - lastFocusedCategoryKey = itemKey - onCategoryFocused() - }, - onClick = { - if (isAllGroup) { - expandedAll = !expandedAll - } - onSelect(cat.id) - }, - ) - if (isOpen && expanded) { - cat.children.forEach { child -> - val childKey = "top:child:${child.id}" - val childRequester = rememberCategoryRequester( - key = childKey, - id = child.id, - selectedId = selectedId, - isTopFirst = false, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = liveCategoryLabel(child.label), - count = child.count, - icon = iconFor(child), - flagEmoji = child.flagEmoji, - active = selectedId == child.id, - expanded = true, - indent = 28.dp, - labelSize = 10.5.sp, - hasChildren = child.children.isNotEmpty(), - isOpenGroup = child.containsId(selectedId), - focusRequester = childRequester, - onFocused = { - lastFocusedCategoryKey = childKey - onCategoryFocused() - }, - onClick = { onSelect(child.id) }, - ) - if (child.containsId(selectedId)) { - child.children.forEach { grandchild -> - val gcKey = "top:grandchild:${grandchild.id}" - val gcRequester = rememberCategoryRequester( - key = gcKey, - id = grandchild.id, - selectedId = selectedId, - isTopFirst = false, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = liveCategoryLabel(grandchild.label), - count = grandchild.count, - icon = iconFor(grandchild), - active = selectedId == grandchild.id, - expanded = true, - indent = 48.dp, - labelSize = 9.5.sp, - focusRequester = gcRequester, - onFocused = { - lastFocusedCategoryKey = gcKey - onCategoryFocused() - }, - onClick = { onSelect(grandchild.id) }, - ) - } - } - } - } - } - if (playlistSections.isNotEmpty()) { - playlistSections.forEach { section -> - item(key = "playlist-section:${section.id}") { - val isOpen = section.id in expandedPlaylistIds - val sectionKey = "playlist-section:${section.id}" - val sectionRequester = rememberCategoryRequester( - key = sectionKey, - id = section.id, - selectedId = null, - isTopFirst = false, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = section.label, - count = section.count, - icon = Icons.Filled.LibraryBooks, - active = section.categories.any { it.containsId(selectedId) }, - expanded = expanded, - hasChildren = true, - isOpenGroup = isOpen, - focusRequester = sectionRequester, - onFocused = { - lastFocusedCategoryKey = sectionKey - onCategoryFocused() - }, - onClick = { - expandedPlaylistIds = if (isOpen) { - expandedPlaylistIds - section.id - } else { - expandedPlaylistIds + section.id - } - }, - ) - } - if (expanded && section.id in expandedPlaylistIds) { - itemsIndexed( - section.categories.distinctBy { it.id }, - key = { _, cat -> "playlist:${section.id}:${cat.id}" }, - ) { _, cat -> - val catKey = "playlist:${section.id}:${cat.id}" - val catRequester = rememberCategoryRequester( - key = catKey, - id = cat.id, - selectedId = selectedId, - isTopFirst = false, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = liveCategoryLabel(cat.label), - count = cat.count, - icon = iconFor(cat), - active = selectedId == cat.id, - expanded = true, - indent = 28.dp, - focusRequester = catRequester, - onFocused = { - lastFocusedCategoryKey = catKey - onCategoryFocused() - }, - locked = isCategoryLocked(cat), - onLongClick = { openCategoryMenu(cat, hidden = false) }, - onClick = { onSelect(cat.id) }, - ) - } - } - } - } else if (tree.global.categories.isNotEmpty()) { - item { SectionHeader(liveSectionLabel(tree.global.label), expanded) } - itemsIndexed(tree.global.categories.distinctBy { it.id }, key = { _, cat -> "global:${cat.id}" }) { _, cat -> - val catKey = "global:${cat.id}" - val catRequester = rememberCategoryRequester( - key = catKey, + SearchEntry( + onClick = onOpenSearch, + expanded = contentVisible, + onMoveUp = onMoveUpFromSearch, + onMoveDown = { + // Down from search is navigation, not activation. Selecting here + // closed the drawer while the same physical key was still being + // handled, so rapid D-pad input left the guide without a focusable + // row. Move focus to the first category and require OK to open it. + userChoseSearch = false + runCatching { firstCategoryFocusRequester.requestFocus() } + }, + onFocusChanged = { atTop -> + // Search taking focus *after* a category already had it means + // the user walked up into it — leave the selector alone from + // then on. Search taking it before that is Compose's default + // placement (or the player bouncing focus back), which the + // effect above corrects. + if (atTop && categoryHasHadFocus && !claimingCategoryFocus) userChoseSearch = true + searchHasFocus = atTop + onTopBoundaryFocusChanged(atTop) + }, + focusRequester = searchFocusRequester, + focusable = categoriesLoaded, + ) + Spacer(Modifier.height(8.dp)) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed(visibleTopCategories, key = { _, cat -> "top:${cat.id}" }) { index, cat -> + val isAllGroup = cat.id == "all" && cat.children.isNotEmpty() + val isOpen = isAllGroup && expandedAll + val itemKey = "top:${cat.id}" + val requester = rememberCategoryRequester( + key = itemKey, id = cat.id, selectedId = selectedId, - isTopFirst = false, + isTopFirst = index == 0, selectedCategoryFocusRequester = selectedCategoryFocusRequester, firstCategoryFocusRequester = firstCategoryFocusRequester, categoryFocusRequesters = categoryFocusRequesters, @@ -701,64 +577,24 @@ fun CategorySidebar( count = cat.count, icon = iconFor(cat), active = selectedId == cat.id, - expanded = expanded, - focusRequester = catRequester, - onFocused = { - lastFocusedCategoryKey = catKey - onCategoryFocused() - }, - locked = isCategoryLocked(cat), - onLongClick = { - openCategoryMenu(cat, hidden = false) - }, - onClick = { onSelect(cat.id) }, - ) - } - } - if (tree.countries.categories.isNotEmpty()) { - item { SectionHeader(liveSectionLabel(tree.countries.label), expanded) } - itemsIndexed(tree.countries.categories.distinctBy { it.id }, key = { _, country -> "country:${country.id}" }) { _, country -> - val isExpanded = expandedCountry == country.id - val countryKey = "country:${country.id}" - val countryRequester = rememberCategoryRequester( - key = countryKey, - id = country.id, - selectedId = selectedId, - isTopFirst = false, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = liveCategoryLabel(country.label), - count = country.count, - icon = null, - leadingCode = country.id, - active = selectedId == country.id, - expanded = expanded, - hasChildren = country.children.isNotEmpty(), - isOpenGroup = isExpanded, - focusRequester = countryRequester, + expanded = contentVisible, + hasChildren = isAllGroup, + isOpenGroup = isOpen, + focusRequester = requester, onFocused = { - lastFocusedCategoryKey = countryKey + lastFocusedCategoryKey = itemKey onCategoryFocused() }, onClick = { - // Tap always toggles expansion. Opening also selects so - // the grid reflects the just-opened group; collapsing - // leaves selection alone so the user can close a group - // without losing their filter. - if (isExpanded) { - expandedCountry = null - } else { - expandedCountry = country.id - onSelect(country.id) + if (isAllGroup) { + expandedAll = !expandedAll } + onSelect(cat.id) }, ) - if (isExpanded && expanded) { - country.children.forEach { child -> - val childKey = "country:child:${child.id}" + if (isOpen && contentVisible) { + cat.children.forEach { child -> + val childKey = "top:child:${child.id}" val childRequester = rememberCategoryRequester( key = childKey, id = child.id, @@ -771,11 +607,14 @@ fun CategorySidebar( SidebarRow( label = liveCategoryLabel(child.label), count = child.count, - icon = null, + icon = iconFor(child), + flagEmoji = child.flagEmoji, active = selectedId == child.id, - expanded = true, - indent = 40.dp, + expanded = contentVisible, + indent = 28.dp, labelSize = 10.5.sp, + hasChildren = child.children.isNotEmpty(), + isOpenGroup = child.containsId(selectedId), focusRequester = childRequester, onFocused = { lastFocusedCategoryKey = childKey @@ -783,49 +622,252 @@ fun CategorySidebar( }, onClick = { onSelect(child.id) }, ) + if (child.containsId(selectedId)) { + child.children.forEach { grandchild -> + val gcKey = "top:grandchild:${grandchild.id}" + val gcRequester = rememberCategoryRequester( + key = gcKey, + id = grandchild.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = liveCategoryLabel(grandchild.label), + count = grandchild.count, + icon = iconFor(grandchild), + active = selectedId == grandchild.id, + expanded = contentVisible, + indent = 48.dp, + labelSize = 9.5.sp, + focusRequester = gcRequester, + onFocused = { + lastFocusedCategoryKey = gcKey + onCategoryFocused() + }, + onClick = { onSelect(grandchild.id) }, + ) + } + } } } } - } - if (tree.adult.categories.isNotEmpty()) { - item { SectionHeader(liveSectionLabel(tree.adult.label), expanded) } - itemsIndexed(tree.adult.categories, key = { index, cat -> "adult:${cat.id}:$index" }) { index, cat -> - val adultKey = "adult:${cat.id}:$index" - val adultRequester = rememberCategoryRequester( - key = adultKey, - id = cat.id, - selectedId = selectedId, - isTopFirst = false, - selectedCategoryFocusRequester = selectedCategoryFocusRequester, - firstCategoryFocusRequester = firstCategoryFocusRequester, - categoryFocusRequesters = categoryFocusRequesters, - ) - SidebarRow( - label = liveCategoryLabel(cat.label), - count = cat.count, - icon = Icons.Filled.Lock, - active = selectedId == cat.id, - expanded = expanded, - focusRequester = adultRequester, - onFocused = { - lastFocusedCategoryKey = adultKey - onCategoryFocused() - }, - onClick = { onSelect(cat.id) }, - ) + if (playlistSections.isNotEmpty()) { + playlistSections.forEach { section -> + item(key = "playlist-section:${section.id}") { + val isOpen = section.id in expandedPlaylistIds + val sectionKey = "playlist-section:${section.id}" + val sectionRequester = rememberCategoryRequester( + key = sectionKey, + id = section.id, + selectedId = null, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = section.label, + count = section.count, + icon = Icons.Filled.LibraryBooks, + active = section.categories.any { it.containsId(selectedId) }, + expanded = contentVisible, + hasChildren = true, + isOpenGroup = isOpen, + focusRequester = sectionRequester, + onFocused = { + lastFocusedCategoryKey = sectionKey + onCategoryFocused() + }, + onClick = { + expandedPlaylistIds = if (isOpen) { + expandedPlaylistIds - section.id + } else { + expandedPlaylistIds + section.id + } + }, + ) + } + if (contentVisible && section.id in expandedPlaylistIds) { + itemsIndexed( + section.categories.distinctBy { it.id }, + key = { _, cat -> "playlist:${section.id}:${cat.id}" }, + ) { _, cat -> + val catKey = "playlist:${section.id}:${cat.id}" + val catRequester = rememberCategoryRequester( + key = catKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = liveCategoryLabel(cat.label), + count = cat.count, + icon = iconFor(cat), + active = selectedId == cat.id, + expanded = contentVisible, + indent = 28.dp, + focusRequester = catRequester, + onFocused = { + lastFocusedCategoryKey = catKey + onCategoryFocused() + }, + locked = isCategoryLocked(cat), + onLongClick = { openCategoryMenu(cat, hidden = false) }, + onClick = { onSelect(cat.id) }, + ) + } + } + } + } else if (tree.global.categories.isNotEmpty()) { + item { SectionHeader(liveSectionLabel(tree.global.label), contentVisible) } + itemsIndexed(tree.global.categories.distinctBy { it.id }, key = { _, cat -> "global:${cat.id}" }) { _, cat -> + val catKey = "global:${cat.id}" + val catRequester = rememberCategoryRequester( + key = catKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = liveCategoryLabel(cat.label), + count = cat.count, + icon = iconFor(cat), + active = selectedId == cat.id, + expanded = contentVisible, + focusRequester = catRequester, + onFocused = { + lastFocusedCategoryKey = catKey + onCategoryFocused() + }, + locked = isCategoryLocked(cat), + onLongClick = { + openCategoryMenu(cat, hidden = false) + }, + onClick = { onSelect(cat.id) }, + ) + } + } + if (tree.countries.categories.isNotEmpty()) { + item { SectionHeader(liveSectionLabel(tree.countries.label), contentVisible) } + itemsIndexed(tree.countries.categories.distinctBy { it.id }, key = { _, country -> "country:${country.id}" }) { _, country -> + val isExpanded = expandedCountry == country.id + val countryKey = "country:${country.id}" + val countryRequester = rememberCategoryRequester( + key = countryKey, + id = country.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = liveCategoryLabel(country.label), + count = country.count, + icon = null, + leadingCode = country.id, + active = selectedId == country.id, + expanded = contentVisible, + hasChildren = country.children.isNotEmpty(), + isOpenGroup = isExpanded, + focusRequester = countryRequester, + onFocused = { + lastFocusedCategoryKey = countryKey + onCategoryFocused() + }, + onClick = { + // Tap always toggles expansion. Opening also selects so + // the grid reflects the just-opened group; collapsing + // leaves selection alone so the user can close a group + // without losing their filter. + if (isExpanded) { + expandedCountry = null + } else { + expandedCountry = country.id + onSelect(country.id) + } + }, + ) + if (isExpanded && contentVisible) { + country.children.forEach { child -> + val childKey = "country:child:${child.id}" + val childRequester = rememberCategoryRequester( + key = childKey, + id = child.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = liveCategoryLabel(child.label), + count = child.count, + icon = null, + active = selectedId == child.id, + expanded = contentVisible, + indent = 40.dp, + labelSize = 10.5.sp, + focusRequester = childRequester, + onFocused = { + lastFocusedCategoryKey = childKey + onCategoryFocused() + }, + onClick = { onSelect(child.id) }, + ) + } + } + } + } + if (tree.adult.categories.isNotEmpty()) { + item { SectionHeader(liveSectionLabel(tree.adult.label), contentVisible) } + itemsIndexed(tree.adult.categories, key = { index, cat -> "adult:${cat.id}:$index" }) { index, cat -> + val adultKey = "adult:${cat.id}:$index" + val adultRequester = rememberCategoryRequester( + key = adultKey, + id = cat.id, + selectedId = selectedId, + isTopFirst = false, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, + firstCategoryFocusRequester = firstCategoryFocusRequester, + categoryFocusRequesters = categoryFocusRequesters, + ) + SidebarRow( + label = liveCategoryLabel(cat.label), + count = cat.count, + icon = Icons.Filled.Lock, + active = selectedId == cat.id, + expanded = contentVisible, + focusRequester = adultRequester, + onFocused = { + lastFocusedCategoryKey = adultKey + onCategoryFocused() + }, + onClick = { onSelect(cat.id) }, + ) + } } } - } - if (currentMenu != null && activeMenuActions.isNotEmpty()) { - CategoryContextMenu( - onDismiss = { - activeMenu = null - menuSelectArmed = false - }, - actions = activeMenuActions, - focusedIndex = currentMenu.focusedIndex.coerceIn(0, activeMenuActions.lastIndex), - onAction = { runActiveMenuAction(it) }, - ) + if (currentMenu != null && activeMenuActions.isNotEmpty()) { + CategoryContextMenu( + onDismiss = { + activeMenu = null + menuSelectArmed = false + }, + actions = activeMenuActions, + focusedIndex = currentMenu.focusedIndex.coerceIn(0, activeMenuActions.lastIndex), + onAction = { runActiveMenuAction(it) }, + ) + } } } } @@ -1326,3 +1368,75 @@ fun formatCount(n: Int): String { val k = n / 1000.0 return if (k < 10) String.format("%.1fk", k) else "${k.toInt()}k" } + +private fun findCategoryLazyIndex( + targetId: String, + topCategories: List, + playlistSections: List, + globalSection: LiveSection, + countrySection: LiveSection, + adultSection: LiveSection, + expandedAll: Boolean, + expandedPlaylistIds: List, + expandedCountry: String?, +): Int { + var index = 0 + for (cat in topCategories) { + if (cat.id == targetId) return index + index++ + val isAllGroup = cat.id == "all" && cat.children.isNotEmpty() + if (isAllGroup && expandedAll) { + for (child in cat.children) { + if (child.id == targetId) return index + index++ + if (child.containsId(targetId)) { + for (grandchild in child.children) { + if (grandchild.id == targetId) return index + index++ + } + } + } + } + } + if (playlistSections.isNotEmpty()) { + for (section in playlistSections) { + if (section.categories.any { it.containsId(targetId) } && section.id !in expandedPlaylistIds) { + return index + } + index++ + if (section.id in expandedPlaylistIds) { + for (cat in section.categories.distinctBy { it.id }) { + if (cat.id == targetId) return index + index++ + } + } + } + } else if (globalSection.categories.isNotEmpty()) { + index++ + for (cat in globalSection.categories.distinctBy { it.id }) { + if (cat.id == targetId) return index + index++ + } + } + if (countrySection.categories.isNotEmpty()) { + index++ + for (country in countrySection.categories.distinctBy { it.id }) { + if (country.id == targetId) return index + index++ + if (expandedCountry == country.id) { + for (child in country.children) { + if (child.id == targetId) return index + index++ + } + } + } + } + if (adultSection.categories.isNotEmpty()) { + index++ + for (cat in adultSection.categories) { + if (cat.id == targetId) return index + index++ + } + } + return -1 +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index 49fe4cd2e..83c5b5622 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -229,7 +230,9 @@ fun ChannelRow( // ─ name / program / progress / time ────────────────── Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clipToBounds(), verticalArrangement = Arrangement.spacedBy(1.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 110884eb2..3e37a407d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -233,17 +233,21 @@ fun EpgGrid( channelListState.animateScrollBy(delta.toFloat(), tween(durationMillis = 100)) } } - fun nearestProgramIndex(rowIdx: Int, anchorStartMin: Int): Int? { + fun nearestProgramIndex(rowIdx: Int, anchorStartMin: Int, preferLive: Boolean = false): Int? { val channel = channels.getOrNull(rowIdx) ?: return null val targets = programFocusTargets[channel.id].orEmpty() if (targets.isEmpty()) return null + if (preferLive) { + val liveIdx = targets.indexOfFirst { it.isNow } + if (liveIdx >= 0) return liveIdx + } return targets .withIndex() .minByOrNull { (_, target) -> target.distanceTo(anchorStartMin) } ?.index } - fun requestNearestProgramFocus(rowIdx: Int, anchorStartMin: Int): Boolean { + fun requestNearestProgramFocus(rowIdx: Int, anchorStartMin: Int, preferLive: Boolean = false): Boolean { val channel = channels.getOrNull(rowIdx) ?: return false requestMoreRowsIfNeeded(rowIdx) focusJob?.cancel() @@ -252,7 +256,7 @@ fun EpgGrid( // Retry a few times: Compose may need a frame to mount the row and // its programme; falling back to spatial focus can jump to the rail. repeat(8) { - val currentTargetIdx = nearestProgramIndex(rowIdx, anchorStartMin) + val currentTargetIdx = nearestProgramIndex(rowIdx, anchorStartMin, preferLive) val requester = currentTargetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } if (requester != null && runCatching { requester.requestFocus() }.isSuccess) { return@launch @@ -605,7 +609,7 @@ fun EpgGrid( if (hasFocusable) { val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() onEnterEpg(ch) - requestNearestProgramFocus(idx, nowMin) + requestNearestProgramFocus(idx, nowMin, preferLive = true) } else { keepChannelFocus(idx) } @@ -623,7 +627,11 @@ fun EpgGrid( .background(LiveColors.PanelDeep) .focusRequester(channelFocusRequester) .then(if (idx == 0) Modifier.focusRequester(firstChannelFocusRequester) else Modifier) - .then(if (ch.id == selectedChannelId) Modifier.focusRequester(selectedChannelFocusRequester) else Modifier), + .then( + if (ch.id == (activeChannelFocusId ?: selectedChannelId ?: channels.firstOrNull()?.id)) { + Modifier.focusRequester(selectedChannelFocusRequester) + } else Modifier + ), ) // 2. Vertical Divider @@ -672,6 +680,7 @@ fun EpgGrid( epgMode = focusMode == EpgGridFocusMode.Epg, rowHeight = rowHeight, renderWindow = renderWindow, + hScrollOffsetPx = hScroll.value, onClick = { program -> onExitEpg(ch) onProgramSelect(ch, program) @@ -758,6 +767,7 @@ private fun ProgramsRow( epgMode: Boolean, rowHeight: Dp, renderWindow: GuideRenderWindow, + hScrollOffsetPx: Int = 0, onClick: (IptvProgram?) -> Unit, onFocused: () -> Unit, onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, @@ -767,6 +777,7 @@ private fun ProgramsRow( focusTargets: MutableMap>, ) { val nowMillis = clockTickMillis + val density = LocalDensity.current Box( modifier = Modifier .width(totalWidth) @@ -804,10 +815,14 @@ private fun ProgramsRow( val rowFocusRequesters = remember(channel.id, focusablePlacementIndices.size) { List(focusablePlacementIndices.size) { FocusRequester() } } - val rowFocusTargets = remember(placements, focusablePlacementIndices) { + val rowFocusTargets = remember(placements, focusablePlacementIndices, nowMillis) { focusablePlacementIndices.mapNotNull { index -> placements.getOrNull(index)?.let { placement -> - ProgramFocusTarget(placement.startMin, placement.endMin) + ProgramFocusTarget( + startMin = placement.startMin, + endMin = placement.endMin, + isNow = placement.isNow(nowMillis), + ) } } } @@ -832,6 +847,11 @@ private fun ProgramsRow( } val offset = (placement.startMin * pxPerMin).dp val width = (placement.durationMin * pxPerMin).dp + val cellOffsetPx = with(density) { offset.toPx() } + val cellWidthPx = with(density) { width.toPx() } + val scrolledPastPx = (hScrollOffsetPx - cellOffsetPx).coerceAtLeast(0f) + val maxShiftPx = (cellWidthPx - with(density) { 50.dp.toPx() }).coerceAtLeast(0f) + val shiftDp = with(density) { scrolledPastPx.coerceAtMost(maxShiftPx).toDp() } val isCatchupSupported = placement.isCatchupSupported(channel, nowMillis) val focusableIndex = focusableIndexByPlacementIndex[placementIndex] ?: -1 val isFocusable = focusableIndex >= 0 @@ -847,6 +867,7 @@ private fun ProgramsRow( isFocusTarget = placementIsNow, focusable = isFocusable, isCatchupSupported = isCatchupSupported, + contentStartOffsetDp = shiftDp, onClick = { epgProgramActionTarget( program = placement.program, @@ -969,7 +990,7 @@ private data class ProgramPlacement( fun isPast(nowMs: Long): Boolean = endMillis <= nowMs } -private data class ProgramFocusTarget(val startMin: Int, val endMin: Int) { +private data class ProgramFocusTarget(val startMin: Int, val endMin: Int, val isNow: Boolean = false) { fun distanceTo(anchorStartMin: Int): Int = when { anchorStartMin < startMin -> startMin - anchorStartMin anchorStartMin > endMin -> anchorStartMin - endMin 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 986773de6..c3f3a52f4 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 @@ -884,9 +884,11 @@ fun LiveTvScreen( } visibleEnrichedState.value = EnrichedChannels(all = visibleChannels, tree = tree, index = index) } - LaunchedEffect(hiddenGroupSet, selectedCategoryId, visibleEnrichedState.value.tree) { + LaunchedEffect(hiddenGroupSet, selectedCategoryId, visibleEnrichedState.value.tree, favSet) { val tree = visibleEnrichedState.value.tree - if (tree.top.isNotEmpty() && selectedCategoryId != "all" && + if (selectedCategoryId == "fav" && favSet.isEmpty()) { + selectedCategoryId = "all" + } else if (tree.top.isNotEmpty() && selectedCategoryId != "all" && (tree.byId(selectedCategoryId) == null || tree.hidden.categories.any { it.id == selectedCategoryId }) ) { selectedCategoryId = "all" @@ -1714,6 +1716,11 @@ fun LiveTvScreen( } } } + playlistCategorySections.forEach { section -> + section.categories.forEach { cat -> + if (cat.count > 0) list.add(cat.id) + } + } tree.global.categories.forEach { cat -> if (cat.count > 0) list.add(cat.id) } @@ -1731,10 +1738,10 @@ fun LiveTvScreen( return list.distinct() } - LaunchedEffect(state.tvSessionLoaded, state.tvSession.lastGroupName, visibleEnrichedState.value.tree, startupCategoryApplied) { + LaunchedEffect(state.tvSessionLoaded, state.tvSession.lastGroupName, visibleEnrichedState.value.tree, playlistCategorySections, startupCategoryApplied) { if (startupCategoryApplied || !state.tvSessionLoaded) return@LaunchedEffect val tree = visibleEnrichedState.value.tree - if (tree.top.isEmpty() && tree.global.categories.isEmpty()) return@LaunchedEffect + if (tree.top.isEmpty() && tree.global.categories.isEmpty() && playlistCategorySections.isEmpty()) return@LaunchedEffect selectedCategoryId = LiveTvStartup.resumeCategoryId( lastGroupName = state.tvSession.lastGroupName, availableCategoryIds = getAvailableCategoryIds(tree).toSet(), @@ -1830,6 +1837,7 @@ fun LiveTvScreen( categoryDrawerOpen = true focusCategoryAfterDrawerOpen = true focusZone = LiveTvFocusZone.CATEGORY_LIST + runCatching { sidebarFocus.requestFocus() } } // Keep focus in the sidebar while that zone is active — but NOT while the @@ -1900,6 +1908,20 @@ fun LiveTvScreen( selectedCategoryId = categoryId categoryDrawerOpen = false focusGuideAfterDrawerClose = true + viewModel.rememberTvSession( + lastGroupName = categoryId, + lastFocusedZone = "CATEGORY", + markOpened = false, + ) + } + + LaunchedEffect(selectedCategoryId, startupCategoryApplied) { + if (startupCategoryApplied && selectedCategoryId.isNotBlank()) { + viewModel.rememberTvSession( + lastGroupName = selectedCategoryId, + markOpened = false, + ) + } } fun requestCategorySelection(categoryId: String) { @@ -3206,7 +3228,13 @@ fun LiveTvScreen( }, onMoveRight = { categoryDrawerOpen = false - focusGuideAfterDrawerClose = true + focusGuideAfterDrawerClose = false + val target = rememberedChannelByCategory[categoryScope] + ?.takeIf { it in filteredChannelIndexById } + ?: playingChannelId?.let { displayChannelIdFor(it, visibleEnrichedState.value.index.byId, variantGroups) } + ?.takeIf { it in filteredChannelIndexById } + ?: filteredChannels.firstOrNull()?.id + focusChannelList(target) }, onMoveUpFromSearch = { topBarFocusIndex = topBarSelectedIndex(SidebarItem.TV, hasProfile) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index 530c5142d..022d6387f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -76,6 +76,7 @@ fun ProgramCell( onMoveUp: () -> Boolean = { false }, onMoveDown: () -> Boolean = { false }, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, + contentStartOffsetDp: androidx.compose.ui.unit.Dp = 0.dp, focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, ) { @@ -207,7 +208,9 @@ fun ProgramCell( ) } Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(start = contentStartOffsetDp), verticalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt index 39a40bb27..1ca0c55ad 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/TouchCategoryRail.kt @@ -161,7 +161,7 @@ private fun rememberTouchRailItems( expandedPlaylistIds: List, ): List { val base = buildList { - tree.top.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } + tree.top.filter { it.id != "fav" || it.count > 0 }.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } if (playlistSections.isEmpty()) { tree.global.categories.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } tree.countries.categories.forEach { add(TouchCategoryRailItem(it.id, liveCategoryLabel(it.label), it.count)) } From b70258ec3021959710b7dff760fd9a3daca13b74 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 7 Sep 2026 21:24:58 +0530 Subject: [PATCH 04/11] fix(live-tv): fix home-return playlist jump, EPG blue row background, channel 0/1 focus bounce, and program time clipping --- .../tv/ui/screens/tv/live/CategorySidebar.kt | 16 +++++++++-- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 28 ++++++++++++------- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 15 ++++------ .../tv/ui/screens/tv/live/ProgramCell.kt | 14 +++++----- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index e39ca6292..4bea65ede 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -146,7 +146,13 @@ fun CategorySidebar( val contentVisible = expanded || animatedWidth > 0.dp var expandedCountry by rememberSaveable { mutableStateOf(null) } var expandedAll by rememberSaveable { mutableStateOf(false) } - var expandedPlaylistIds by rememberSaveable { mutableStateOf(emptyList()) } + var expandedPlaylistIds by rememberSaveable { + mutableStateOf( + playlistSections.firstOrNull { section -> + section.id == selectedId || section.categories.any { it.containsId(selectedId) } + }?.id?.let { listOf(it) } ?: emptyList() + ) + } var activeMenu by remember { mutableStateOf(null) } var hiddenCategoryPendingFocus by remember { mutableStateOf(null) } var menuSelectArmed by remember { mutableStateOf(false) } @@ -398,7 +404,7 @@ fun CategorySidebar( expandedAll = true } playlistSections.firstOrNull { section -> - section.categories.any { it.containsId(selectedId) } + section.id == selectedId || section.categories.any { it.containsId(selectedId) } }?.id?.let { sectionId -> if (sectionId !in expandedPlaylistIds) { expandedPlaylistIds = expandedPlaylistIds + sectionId @@ -422,6 +428,8 @@ fun CategorySidebar( if (targetIdx >= 0) { listState.scrollToItem((targetIdx - 2).coerceAtLeast(0)) } + delay(50L) + runCatching { selectedCategoryFocusRequester.requestFocus() } } } @@ -659,10 +667,12 @@ fun CategorySidebar( item(key = "playlist-section:${section.id}") { val isOpen = section.id in expandedPlaylistIds val sectionKey = "playlist-section:${section.id}" + val isSectionSelected = section.id == selectedId || + (!isOpen && section.categories.any { it.containsId(selectedId) }) val sectionRequester = rememberCategoryRequester( key = sectionKey, id = section.id, - selectedId = null, + selectedId = if (isSectionSelected) section.id else null, isTopFirst = false, selectedCategoryFocusRequester = selectedCategoryFocusRequester, firstCategoryFocusRequester = firstCategoryFocusRequester, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 3e37a407d..05a1f6642 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -251,13 +251,18 @@ fun EpgGrid( val channel = channels.getOrNull(rowIdx) ?: return false requestMoreRowsIfNeeded(rowIdx) focusJob?.cancel() + val currentTargetIdx = nearestProgramIndex(rowIdx, anchorStartMin, preferLive) + val directRequester = currentTargetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } + if (directRequester != null && runCatching { directRequester.requestFocus() }.isSuccess) { + return true + } focusJob = scope.launch { revealRow(rowIdx) // Retry a few times: Compose may need a frame to mount the row and // its programme; falling back to spatial focus can jump to the rail. repeat(8) { - val currentTargetIdx = nearestProgramIndex(rowIdx, anchorStartMin, preferLive) - val requester = currentTargetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } + val targetIdx = nearestProgramIndex(rowIdx, anchorStartMin, preferLive) + val requester = targetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } if (requester != null && runCatching { requester.requestFocus() }.isSuccess) { return@launch } @@ -275,6 +280,14 @@ fun EpgGrid( pendingChannelFocusId = channel.id onChannelFocused(channel) focusJob?.cancel() + val directRequester = channelFocusRequesters[channel.id] + ?: if (rowIdx == 0) firstChannelFocusRequester + else if (channel.id == selectedChannelId) selectedChannelFocusRequester + else null + if (directRequester != null && runCatching { directRequester.requestFocus() }.isSuccess) { + pendingChannelFocusId = null + return true + } focusJob = scope.launch { revealRow(rowIdx) delay(16L) @@ -676,7 +689,7 @@ fun EpgGrid( totalWidth = totalWidth, pxPerMin = pxPerMin, stripe = idx % 2 == 1, - isActive = isChannelActive && focusMode == EpgGridFocusMode.Epg, + isActive = false, epgMode = focusMode == EpgGridFocusMode.Epg, rowHeight = rowHeight, renderWindow = renderWindow, @@ -784,11 +797,7 @@ private fun ProgramsRow( .height(rowHeight) .clipToBounds() .background( - when { - isActive -> LiveColors.FocusBg - stripe -> LiveColors.RowStripe - else -> Color.Transparent - } + if (stripe) LiveColors.RowStripe else Color.Transparent ), ) { // Placement geometry (cell offsets/widths + gap placeholders) does NOT depend @@ -800,8 +809,7 @@ private fun ProgramsRow( val placements = remember(programs, placeholderTitle, noProgrammeData, windowStartMillis, windowEndMillis) { buildProgramPlacements(programs, windowStartMillis, windowEndMillis, nowMillis, placeholderTitle, noProgrammeData) } - val focusablePlacementIndices = remember(placements, channel.catchupDays, nowMillis, epgMode) { - if (!epgMode) return@remember emptyList() + val focusablePlacementIndices = remember(placements, channel.catchupDays, nowMillis) { placements.mapIndexedNotNull { index, placement -> val canFocus = placement.canFocus(channel, nowMillis) if (canFocus) index else null 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 c3f3a52f4..27e38b21d 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 @@ -559,7 +559,9 @@ fun LiveTvScreen( value = System.currentTimeMillis() } } - var selectedCategoryId by rememberSaveable { mutableStateOf("all") } + var selectedCategoryId by rememberSaveable { + mutableStateOf(state.tvSession.lastGroupName.takeIf { it.isNotBlank() } ?: "all") + } var startupCategoryApplied by rememberSaveable { mutableStateOf(false) } var selectedProviderId by rememberSaveable { mutableStateOf("all") } val categoryScope = "${currentProfile?.id}|$selectedProviderId|$selectedCategoryId" @@ -1884,7 +1886,9 @@ fun LiveTvScreen( } focusZone = LiveTvFocusZone.CHANNEL_LIST focusSelectedChannelSignal += 1 - runCatching { epgFocus.requestFocus() } + if (channelId == null) { + runCatching { epgFocus.requestFocus() } + } } fun focusEpg(channelId: String) { @@ -1898,7 +1902,6 @@ fun LiveTvScreen( } focusZone = LiveTvFocusZone.EPG focusEpgSignal += 1 - runCatching { epgFocus.requestFocus() } } fun enterSelectedCategory(categoryId: String) { @@ -3333,12 +3336,6 @@ fun LiveTvScreen( channelColumnWidthOverride = guideChannelColumnWidth, modifier = Modifier .fillMaxSize() - .onFocusChanged { - if (it.hasFocus && focusZone == LiveTvFocusZone.CATEGORY_LIST) { - focusZone = LiveTvFocusZone.CHANNEL_LIST - categoryDrawerOpen = false - } - } .then(if (!isTouchDevice) Modifier.focusRequester(epgFocus) else Modifier), ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index 022d6387f..7772e899a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -191,7 +191,7 @@ fun ProgramCell( Modifier } ) - .padding(horizontal = 6.dp, vertical = 4.dp), + .padding(horizontal = 6.dp, vertical = 2.dp), ) { if (isNow) { Box( @@ -231,7 +231,7 @@ fun ProgramCell( } Text( text = program.title, - style = LiveType.CellTitle.copy(color = LiveColors.Fg, fontSize = 11.sp), + style = LiveType.CellTitle.copy(color = LiveColors.Fg, fontSize = 9.5.sp, lineHeight = 12.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), @@ -240,7 +240,7 @@ fun ProgramCell( if (!program.description.isNullOrBlank()) { Text( text = program.description!!, - style = LiveType.BodySynopsis.copy(color = LiveColors.FgDim, fontSize = 9.sp), + style = LiveType.BodySynopsis.copy(color = LiveColors.FgDim, fontSize = 8.sp, lineHeight = 10.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -251,14 +251,14 @@ fun ProgramCell( ) { Text( text = formatClock(program.startUtcMillis), - style = LiveType.TimeMono.copy(color = LiveColors.FgMute, fontSize = 9.sp), + style = LiveType.TimeMono.copy(color = LiveColors.FgMute, fontSize = 8.sp, lineHeight = 10.sp), ) val mins = ((program.endUtcMillis - program.startUtcMillis) / 60_000L) .coerceAtLeast(0L) if (mins > 0) { Text( text = stringResource(R.string.live_label_duration_min, mins), - style = LiveType.TimeMono.copy(color = LiveColors.FgMute, fontSize = 9.sp), + style = LiveType.TimeMono.copy(color = LiveColors.FgMute, fontSize = 8.sp, lineHeight = 10.sp), ) } } @@ -273,8 +273,8 @@ fun Badge(label: String, fg: Color, bg: Color) { modifier = Modifier .clip(RoundedCornerShape(3.dp)) .background(bg) - .padding(horizontal = 5.dp, vertical = 1.dp), + .padding(horizontal = 4.dp, vertical = 0.5.dp), ) { - Text(label, style = LiveType.Badge.copy(color = fg, fontSize = 9.sp)) + Text(label, style = LiveType.Badge.copy(color = fg, fontSize = 7.5.sp, lineHeight = 9.sp)) } } From 8abb0612c18675b3a697aa2f68d369aaba7d4f33 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 7 Sep 2026 22:44:13 +0530 Subject: [PATCH 05/11] feat(live-tv): cinematic 3D depth push transition & smooth mini-player fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace slide/crossfade fullscreen transition with centered 3D depth-push (0.92 → 1.0 scale) for a premium cinematic feel - Add consistent enter/exit animation using graphicsLayer with centered pivot - Implement smooth alpha fade-in on VideoCard when returning to mini-player - Fix EPG focus anchoring to last-played channel with (resolvedIdx - 2) offset - Enable native 60fps channel list scrolling via LazyColumn D-pad handling --- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 36 ++++--- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 98 ++++++++++--------- .../tv/ui/screens/tv/live/MiniPlayer.kt | 59 ++++++++++- 3 files changed, 130 insertions(+), 63 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 05a1f6642..67746d323 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -205,14 +205,19 @@ fun EpgGrid( LaunchedEffect(scrollResetKey, channelWindowIdentity) { if (channels.isEmpty() || didPositionInitialSelection) return@LaunchedEffect - if (channelListState.firstVisibleItemIndex == 0 && channelListState.firstVisibleItemScrollOffset == 0) { - channelListState.scrollToItem(selectedChannelId?.let(channelIndexById::get) ?: 0) + val resolvedIdx = selectedChannelId?.let(channelIndexById::get) + if (resolvedIdx != null) { + val targetScroll = (resolvedIdx - 2).coerceAtLeast(0) + channelListState.scrollToItem(targetScroll) + activeChannelFocusId = selectedChannelId + activeChannelFocusIndex = resolvedIdx + pendingChannelFocusId = null + didPositionInitialSelection = true + } else if (channelListState.firstVisibleItemIndex == 0 && channelListState.firstVisibleItemScrollOffset == 0) { + activeChannelFocusId = channels.firstOrNull()?.id + activeChannelFocusIndex = 0 + pendingChannelFocusId = null } - activeChannelFocusId = selectedChannelId - ?.takeIf { it in channelIndexById } - ?: channels.firstOrNull()?.id - pendingChannelFocusId = null - didPositionInitialSelection = true } val scope = rememberCoroutineScope() @@ -312,18 +317,20 @@ fun EpgGrid( val anchorId = activeChannelFocusId ?: selectedChannelId val anchorIdx = anchorId?.let(channelIndexById::get) ?: selectedChannelId?.let(channelIndexById::get) - ?: return true + ?: return false val targetIdx = anchorIdx + delta return when { targetIdx < 0 -> { - onRequestPreviousChannels() + if (channelWindowOffset > 0) { + onRequestPreviousChannels() + } true } targetIdx >= channels.size -> { onRequestNextChannels() true } - else -> keepChannelFocus(targetIdx) + else -> false } } @@ -343,7 +350,11 @@ fun EpgGrid( if (didPositionInitialSelection) return@LaunchedEffect val id = selectedChannelId ?: return@LaunchedEffect val idx = channelIndexById[id] ?: return@LaunchedEffect - channelListState.scrollToItem(idx) + val targetScroll = (idx - 2).coerceAtLeast(0) + channelListState.scrollToItem(targetScroll) + activeChannelFocusId = id + activeChannelFocusIndex = idx + pendingChannelFocusId = null didPositionInitialSelection = true } @@ -356,7 +367,8 @@ fun EpgGrid( if (channelListState.layoutInfo.visibleItemsInfo.any { it.index == idx }) { revealRow(idx) } else { - channelListState.scrollToItem(idx) + val targetScroll = (idx - 2).coerceAtLeast(0) + channelListState.scrollToItem(targetScroll) } runCatching { selectedChannelFocusRequester.requestFocus() } handledSelectedFocusSignal = focusSelectedChannelSignal 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 27e38b21d..2459a7f3a 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 @@ -53,6 +53,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -723,7 +726,7 @@ fun LiveTvScreen( tree = enrichedState.value.tree, favorites = favoriteOrderIds, recents = recents.value.toList().asReversed(), - startupAnchorId = null, + startupAnchorId = startupAnchorId, ) } val value = withContext(Dispatchers.Default) { @@ -954,6 +957,7 @@ fun LiveTvScreen( // window. Previously the outer channel-state effect and this // effect both queried/rebuilt the same category, producing several // seconds of main-thread recomposition on a 50k playlist. + val startupAnchorId = state.tvSession.lastChannelId.takeIf { state.tvSession.lastOpenedAt > 0L && it.isNotBlank() } val directChannels = withContext(Dispatchers.IO) { loadPagedChannelWindow( repository = viewModel.iptvRepository, @@ -964,7 +968,7 @@ fun LiveTvScreen( tree = tree, favorites = favoriteOrderIds, recents = recents.value.toList().asReversed(), - startupAnchorId = null, + startupAnchorId = startupAnchorId, excludedGroups = hiddenGroupSet + restrictedGroupSet, ) } @@ -1537,7 +1541,7 @@ fun LiveTvScreen( val playingVisible = playingChannelId?.let { id -> id in visibleEnrichedState.value.index.byId } == true if (!startupChannelApplied && filteredChannels.isNotEmpty() && (initialChannelId != null || startupStateReady)) { val savedId = state.tvSession.lastChannelId.takeIf { state.tvSession.lastOpenedAt > 0L && it.isNotBlank() } - val savedChannel = if (savedId != null && savedId !in filteredChannelIndexById && lastKnownPagedTotal > 10_000) { + val savedChannel = if (savedId != null && savedId !in filteredChannelIndexById) { withContext(Dispatchers.IO) { viewModel.iptvRepository.pagedChannelsByIds(listOf(savedId)).firstOrNull() }?.enrichForFastStartup(1)?.takeUnless { @@ -1631,6 +1635,13 @@ fun LiveTvScreen( var isFullScreen by rememberSaveable { mutableStateOf(initialChannelId != null || initialStreamUrl != null) } + val fsProgress by animateFloatAsState( + targetValue = if (isFullScreen) 1f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "tv-fullscreen-progress", + ) + val miniPlayerActive = !isFullScreen && fsProgress == 0f + var pendingFocusAfterFullscreenExit by remember { mutableStateOf(null) } // Set while we are still in that launched-to-play session. Backing out of it should // return to whoever launched us (Home), not strand the user in the Live TV guide // they never asked for. Cleared on the first exit so later fullscreen sessions @@ -1995,11 +2006,16 @@ fun LiveTvScreen( fullscreenGuideOpen = false isFullScreen = false hudPokeSignal++ - focusCommitScope.launch { - // Let the fullscreen layer start collapsing before returning focus - // to the large guide. On big IPTV lists this keeps Back immediate. - delay(16L) - focusChannelList(returnFocusChannelId) + pendingFocusAfterFullscreenExit = returnFocusChannelId + } + + LaunchedEffect(isFullScreen, fsProgress) { + if (!isFullScreen && fsProgress == 0f) { + val target = pendingFocusAfterFullscreenExit + if (target != null) { + pendingFocusAfterFullscreenExit = null + focusChannelList(target) + } } } @@ -3135,6 +3151,7 @@ fun LiveTvScreen( onOpenVariants = playingChannel?.let { channel -> { openVariantPicker(channel) } }, compact = true, landscapeCompact = landscapeCompactMiniPlayer, + playerActive = miniPlayerActive, modifier = Modifier.fillMaxWidth(), ) TouchCategoryRail( @@ -3290,6 +3307,7 @@ fun LiveTvScreen( variantCount = playingChannel?.let { variantCountFor(it, variantGroups) } ?: 1, onOpenVariants = playingChannel?.let { channel -> { openVariantPicker(channel) } }, compact = compactTouchLayout, + playerActive = miniPlayerActive, modifier = Modifier.fillMaxWidth(), ) EpgGrid( @@ -3344,48 +3362,36 @@ fun LiveTvScreen( } // Full-screen playback: same ExoPlayer, covers the entire screen. - // - // The overlay animates a scale+alpha transition so it looks like the - // mini-player is growing into fullscreen. The transform pivot is - // roughly the mini-player's center (sidebar ≈ 20% of width, mini- - // player sits just below the 52dp top bar), which keeps the grow - // anchored visually to where the user tapped instead of from screen - // center. fsProgress stays mounted until it reaches 0, so the - // reverse animation also plays on Back. - val fsProgress by animateFloatAsState( - targetValue = if (isFullScreen) 1f else 0f, - animationSpec = tween(durationMillis = 280, easing = FastOutSlowInEasing), - label = "tv-fullscreen-progress", - ) + // Cinematic 3D Depth Push: + // Video stays anchored at the exact screen center (0.5f, 0.5f). + // Entering punches forward from depth (0.92 -> 1.0); exiting eases back into depth (1.0 -> 0.92). LiveTvRenderBoundary { if (fsProgress > 0f && playingChannel != null) { - val scale = 0.35f + 0.65f * fsProgress - BackHandler(enabled = isFullScreen) { - if (fullscreenGuideOpen) { - fullscreenGuideOpen = false - hudPokeSignal++ - } else if (!quickZapOpen) { - if (playingCatchupProgram != null) { - returnCatchupToLive() - } else { - exitFullScreenPlayback() - } - } - } + val depthScale = 0.92f + 0.08f * fsProgress - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - transformOrigin = TransformOrigin( - pivotFractionX = 0.22f, - pivotFractionY = 0.18f, - ) - scaleX = scale - scaleY = scale - alpha = fsProgress + BackHandler(enabled = isFullScreen) { + if (fullscreenGuideOpen) { + fullscreenGuideOpen = false + hudPokeSignal++ + } else if (!quickZapOpen) { + if (playingCatchupProgram != null) { + returnCatchupToLive() + } else { + exitFullScreenPlayback() + } } - .background(Color.Black) + } + + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + transformOrigin = TransformOrigin(0.5f, 0.5f) + scaleX = depthScale + scaleY = depthScale + alpha = fsProgress + } + .background(Color.Black) .focusRequester(fsFocus) .focusable() .onPreviewKeyEvent { ev -> diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt index 1a83d34e3..77b3faca4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt @@ -2,8 +2,10 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween @@ -32,6 +34,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush @@ -105,6 +110,8 @@ fun MiniPlayerRow( onOpenVariants: (() -> Unit)? = null, compact: Boolean = false, landscapeCompact: Boolean = false, + playerActive: Boolean = true, + onVideoBoundsPositioned: ((Rect) -> Unit)? = null, modifier: Modifier = Modifier, ) { if (landscapeCompact) { @@ -120,7 +127,9 @@ fun MiniPlayerRow( exoPlayer = exoPlayer, channel = channel, landscapeCompact = true, + playerActive = playerActive, onFullscreenClick = onFullscreenClick, + onVideoBoundsPositioned = onVideoBoundsPositioned, ) InfoColumn( channel = channel, @@ -147,7 +156,9 @@ fun MiniPlayerRow( exoPlayer = exoPlayer, channel = channel, compact = true, + playerActive = playerActive, onFullscreenClick = onFullscreenClick, + onVideoBoundsPositioned = onVideoBoundsPositioned, modifier = Modifier.fillMaxWidth(), ) InfoColumn( @@ -172,7 +183,9 @@ fun MiniPlayerRow( VideoCard( exoPlayer = exoPlayer, channel = channel, + playerActive = playerActive, onFullscreenClick = onFullscreenClick, + onVideoBoundsPositioned = onVideoBoundsPositioned, ) InfoColumn( channel = channel, @@ -195,13 +208,21 @@ private fun VideoCard( channel: EnrichedChannel?, compact: Boolean = false, landscapeCompact: Boolean = false, + playerActive: Boolean = true, onFullscreenClick: (() -> Unit)? = null, + onVideoBoundsPositioned: ((Rect) -> Unit)? = null, modifier: Modifier = Modifier, ) { val deviceType = LocalDeviceType.current val isTouchDevice = deviceType.isTouchDevice() val landscapeSpec = if (landscapeCompact) landscapePhoneMiniPlayerSpec() else null + val playerAlpha by animateFloatAsState( + targetValue = if (playerActive) 1f else 0f, + animationSpec = tween(durationMillis = 280, easing = FastOutSlowInEasing), + label = "mini-player-fade", + ) + Box( modifier = modifier .then( @@ -214,6 +235,15 @@ private fun VideoCard( else -> Modifier.size(LiveDims.MiniPlayerWidth, LiveDims.MiniPlayerHeight) } ) + .then( + if (onVideoBoundsPositioned != null) { + Modifier.onGloballyPositioned { coords -> + onVideoBoundsPositioned(coords.boundsInRoot()) + } + } else { + Modifier + } + ) .clickable(enabled = isTouchDevice && onFullscreenClick != null) { onFullscreenClick?.invoke() } @@ -244,19 +274,38 @@ private fun VideoCard( AndroidView( factory = { ctx -> PlayerView(ctx).apply { - this.player = exoPlayer + if (playerActive) { + this.player = exoPlayer + } useController = false setKeepContentOnPlayerReset(true) } }, update = { view -> - if (view.player !== exoPlayer) { - view.player = exoPlayer + if (playerActive) { + if (view.player !== exoPlayer) { + view.player = exoPlayer + } + } else { + if (view.player != null) { + view.player = null + } } }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = playerAlpha + }, + ) + LiveBug( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp) + .graphicsLayer { + alpha = playerAlpha + }, ) - LiveBug(modifier = Modifier.align(Alignment.TopEnd).padding(10.dp)) } if (isTouchDevice && onFullscreenClick != null) { From 49f241f08302e99c6ac66b49e0851d0fbf7dc092 Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 8 Sep 2026 11:01:49 +0200 Subject: [PATCH 06/11] fix(tv): refine guide layout and isolate channel navigation focus --- .../tv/live/GuideRenderingDeviceTest.kt | 12 ++++++ .../tv/ui/screens/tv/live/CategorySidebar.kt | 14 +++++-- .../tv/ui/screens/tv/live/ChannelRow.kt | 32 +++++++-------- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 33 ++++++++++----- .../tv/ui/screens/tv/live/LiveTokens.kt | 14 +++---- .../tv/ui/screens/tv/live/MiniPlayer.kt | 4 +- .../tv/ui/screens/tv/live/ProgramCell.kt | 40 +++++++++++++------ 7 files changed, 95 insertions(+), 54 deletions(-) diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt index cf76dba96..0baebeed0 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt @@ -70,6 +70,18 @@ class GuideRenderingDeviceTest { compose.onNodeWithText("Programme render:0:23").assertDoesNotExist() } + @Test fun verticalChannelNavigationCannotEnterProgrammes() { + showGuide() + repeat(12) { compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } } + compose.onNodeWithTag("iptv-channel:render:12").assertIsFocused() + repeat(5) { compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } } + compose.onNodeWithTag("iptv-channel:render:7").assertIsFocused() + compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + compose.onNodeWithTag("iptv-channel:render:7").assertIsNotFocused() + compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + compose.runOnIdle { assertEquals("render:8", focused) } + } + @Test fun epgNavigationStillReachesOffscreenProgrammesAndAdjacentChannel() { showGuide() compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index 4bea65ede..74d4c88de 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -1,5 +1,7 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.compose.animation.animateColorAsState + import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState @@ -716,7 +718,7 @@ fun CategorySidebar( categoryFocusRequesters = categoryFocusRequesters, ) SidebarRow( - label = liveCategoryLabel(cat.label), + label = liveCategoryLabel(cat.playlistGroupName ?: cat.label), count = cat.count, icon = iconFor(cat), active = selectedId == cat.id, @@ -1065,6 +1067,10 @@ private fun SidebarRow( focused -> LiveColors.Panel else -> Color.Transparent } + val surface by animateColorAsState( + if (focused) LiveColors.PanelRaised else bg, + animationSpec = tween(120), label = "category-surface", + ) Box( modifier = Modifier .fillMaxWidth() @@ -1083,19 +1089,19 @@ private fun SidebarRow( modifier = Modifier .fillMaxWidth() .fillMaxHeight() - .padding(start = if (active) 12.dp else 10.dp, end = 12.dp) + .padding(start = 12.dp, end = 12.dp) .onFocusChanged { focused = it.isFocused if (it.isFocused) onFocused?.invoke() } .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .border( - width = if (focused) 3.dp else 0.dp, + width = if (focused) LiveDims.FocusBorder else 0.dp, color = if (focused) LiveColors.FocusRing else Color.Transparent, shape = RoundedCornerShape(8.dp), ) .clip(RoundedCornerShape(8.dp)) - .background(if (focused) LiveColors.PanelRaised else bg) + .background(surface) .onPreviewKeyEvent { ev -> val isSelect = ev.key == Key.DirectionCenter || ev.key == Key.Enter when { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index 83c5b5622..9ae16bb69 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt @@ -1,5 +1,8 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.compose.animation.animateColorAsState +import androidx.compose.ui.geometry.CornerRadius + import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -107,23 +110,15 @@ fun ChannelRow( } val now = nowNext?.now val animatedBorderWidth by animateDpAsState( - targetValue = if (visuallyFocused) 3.dp else 0.dp, + targetValue = if (visuallyFocused) LiveDims.FocusBorder else 0.dp, animationSpec = tween(durationMillis = 70), label = "channel-row-border", ) - val animatedScale by animateFloatAsState( - targetValue = if (visuallyFocused) 1.004f else 1f, - animationSpec = tween(durationMillis = 80), - label = "channel-row-scale", - ) + val surface by animateColorAsState(bg, tween(120), label = "channel-surface") Row( modifier = modifier .fillMaxWidth() .height(rowHeight) - .graphicsLayer { - scaleX = animatedScale - scaleY = animatedScale - } .onFocusChanged { focused = it.hasFocus if (it.hasFocus) onFocused() @@ -134,15 +129,16 @@ fun ChannelRow( // text and logo layout should not rebuild for each border frame. val stroke = animatedBorderWidth.toPx() if (visuallyFocused && stroke > 0f) { - drawRect( + drawRoundRect( color = LiveColors.FocusRing, - topLeft = Offset(stroke / 2f, stroke / 2f), - size = Size((size.width - stroke).coerceAtLeast(0f), (size.height - stroke).coerceAtLeast(0f)), + topLeft = Offset(2.dp.toPx() + stroke / 2f, 2.dp.toPx() + stroke / 2f), + size = Size((size.width - 4.dp.toPx() - stroke).coerceAtLeast(0f), (size.height - 4.dp.toPx() - stroke).coerceAtLeast(0f)), + cornerRadius = CornerRadius(5.dp.toPx()), style = Stroke(stroke), ) } } - .background(bg) + .background(surface) .focusable() // Long-press / MENU opens the channel menu. This has to live in the PREVIEW // phase, ahead of combinedClickable: combinedClickable arms a click on the @@ -211,8 +207,8 @@ fun ChannelRow( // ─ channel number ──────────────────────────────────── Box( modifier = Modifier - .width(48.dp) - .padding(start = 10.dp, end = 6.dp), + .width(36.dp) + .padding(start = 8.dp, end = 4.dp), contentAlignment = Alignment.CenterStart, ) { Text( @@ -224,9 +220,9 @@ fun ChannelRow( } // ─ logo ────────────────────────────────────────────── - ChannelLogo(channel = channel, size = 36.dp) + ChannelLogo(channel = channel, size = 32.dp) - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(8.dp)) // ─ name / program / progress / time ────────────────── Column( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 67746d323..0c3c94f19 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -483,12 +484,13 @@ fun EpgGrid( .background(LiveColors.DividerStrong) ) // Scrolling time ruler with NOW pill pinned to the current minute. - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() - .horizontalScroll(hScroll), + .clipToBounds(), ) { - Row { + val rulerWidthPx = with(density) { maxWidth.toPx() } + Row(Modifier.horizontalScroll(hScroll)) { slots.forEach { slot -> Box( modifier = Modifier @@ -510,7 +512,18 @@ fun EpgGrid( val nowOffset = (nowMin * pxPerMin).dp Box( modifier = Modifier - .offset(x = nowOffset - 46.dp, y = 6.dp) + .layout { measurable, constraints -> + val label = measurable.measure(constraints.copy(minWidth = 0, minHeight = 0)) + val nowX = nowOffset.toPx() - hScroll.value + layout(label.width, label.height) { + if (nowX in 0f..rulerWidthPx) { + label.placeRelative( + (nowX - label.width / 2f).coerceIn(0f, (rulerWidthPx - label.width).coerceAtLeast(0f)).toInt(), + 4.dp.roundToPx(), + ) + } + } + } .clip(RoundedCornerShape(4.dp)) .background(LiveColors.Accent) .padding(horizontal = 8.dp, vertical = 3.dp), @@ -705,7 +718,7 @@ fun EpgGrid( epgMode = focusMode == EpgGridFocusMode.Epg, rowHeight = rowHeight, renderWindow = renderWindow, - hScrollOffsetPx = hScroll.value, + hScrollOffsetPx = { hScroll.value }, onClick = { program -> onExitEpg(ch) onProgramSelect(ch, program) @@ -792,7 +805,7 @@ private fun ProgramsRow( epgMode: Boolean, rowHeight: Dp, renderWindow: GuideRenderWindow, - hScrollOffsetPx: Int = 0, + hScrollOffsetPx: () -> Int = { 0 }, onClick: (IptvProgram?) -> Unit, onFocused: () -> Unit, onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, @@ -869,9 +882,7 @@ private fun ProgramsRow( val width = (placement.durationMin * pxPerMin).dp val cellOffsetPx = with(density) { offset.toPx() } val cellWidthPx = with(density) { width.toPx() } - val scrolledPastPx = (hScrollOffsetPx - cellOffsetPx).coerceAtLeast(0f) val maxShiftPx = (cellWidthPx - with(density) { 50.dp.toPx() }).coerceAtLeast(0f) - val shiftDp = with(density) { scrolledPastPx.coerceAtMost(maxShiftPx).toDp() } val isCatchupSupported = placement.isCatchupSupported(channel, nowMillis) val focusableIndex = focusableIndexByPlacementIndex[placementIndex] ?: -1 val isFocusable = focusableIndex >= 0 @@ -885,9 +896,11 @@ private fun ProgramsRow( isNow = placementIsNow, isPast = placementIsPast, isFocusTarget = placementIsNow, - focusable = isFocusable, + focusable = isFocusable && epgMode, isCatchupSupported = isCatchupSupported, - contentStartOffsetDp = shiftDp, + contentStartOffsetPx = { + (hScrollOffsetPx() - cellOffsetPx).coerceIn(0f, maxShiftPx).toInt() + }, onClick = { epgProgramActionTarget( program = placement.program, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTokens.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTokens.kt index 2e8181271..c98e5d8f0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTokens.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTokens.kt @@ -21,9 +21,9 @@ object LiveColors { // the top bar. val Bg = Color(0xFF070709) val Panel = Color(0xFF121319) - val PanelDeep = Color(0xFF0B0B0F) - val PanelRaised = Color(0xFF1B1D25) - val RowStripe = Color(0xFF0D0D11) + val PanelDeep = Bg + val PanelRaised = Color(0xFF202022) + val RowStripe = Color(0xFF0B0B0D) val Divider = Color(0x992B2D36) val DividerStrong = Color(0xE6333542) @@ -80,11 +80,11 @@ object LiveDims { val SidebarCollapsed = 52.dp val SidebarRowHeight = 26.dp - val MiniPlayerWidth = 300.dp - val MiniPlayerHeight = 168.dp + val MiniPlayerWidth = 256.dp + val MiniPlayerHeight = 144.dp val EpgChannelColWidth = 220.dp - val EpgChannelWideColWidth = 292.dp + val EpgChannelWideColWidth = 256.dp val EpgRowHeight = 42.dp val EpgHeaderHeight = 26.dp val EpgPxPerMinute = 4 @@ -93,7 +93,7 @@ object LiveDims { val PanelRadius = 12.dp val CardRadius = 10.dp val CellRadius = 6.dp - val VideoRadius = 12.dp + val VideoRadius = 8.dp val FocusBorder = 2.dp val ActiveIndicator = 3.dp } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt index 77b3faca4..21ae218d8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt @@ -508,10 +508,8 @@ private fun NowCard( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(LiveDims.CardRadius)) - .background(LiveColors.PanelRaised) .padding( - horizontal = if (landscapeCompact) 8.dp else 10.dp, + horizontal = 0.dp, vertical = if (landscapeCompact) 5.dp else 8.dp, ), verticalArrangement = Arrangement.spacedBy(if (landscapeCompact) 2.dp else 4.dp), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index 7772e899a..eabfd1ad7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -1,5 +1,8 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.compose.animation.animateColorAsState +import androidx.compose.ui.layout.layout + import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -76,7 +79,7 @@ fun ProgramCell( onMoveUp: () -> Boolean = { false }, onMoveDown: () -> Boolean = { false }, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, - contentStartOffsetDp: androidx.compose.ui.unit.Dp = 0.dp, + contentStartOffsetPx: () -> Int = { 0 }, focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, ) { @@ -89,7 +92,10 @@ fun ProgramCell( isNow -> LiveColors.FocusBg else -> LiveColors.Panel } - val bg = if (focused) LiveColors.PanelRaised else baseBg + val bg by animateColorAsState( + if (focused) LiveColors.PanelRaised else baseBg, + tween(120), label = "programme-surface", + ) val borderColor = when { focused -> LiveColors.FocusRing isNow -> LiveColors.Accent.copy(alpha = 0.45f) @@ -97,17 +103,17 @@ fun ProgramCell( } val borderWidth = if (focusable) { val animated by animateDpAsState( - targetValue = if (focused) 3.dp else 1.dp, + targetValue = if (focused) LiveDims.FocusBorder else 1.dp, animationSpec = tween(durationMillis = 80), label = "program-cell-border", ) animated } else { - if (focused) 3.dp else 1.dp + if (focused) LiveDims.FocusBorder else 1.dp } val scale = if (focusable) { val animated by animateFloatAsState( - targetValue = if (focused) 1.008f else 1f, + targetValue = 1f, animationSpec = tween(durationMillis = 90), label = "program-cell-scale", ) @@ -210,15 +216,25 @@ fun ProgramCell( Column( modifier = Modifier .fillMaxSize() - .padding(start = contentStartOffsetDp), + // Read scroll position in measurement, not row composition. + .layout { measurable, constraints -> + val shift = contentStartOffsetPx().coerceIn(0, constraints.maxWidth) + val content = measurable.measure(constraints.copy( + minWidth = (constraints.minWidth - shift).coerceAtLeast(0), + maxWidth = (constraints.maxWidth - shift).coerceAtLeast(0), + )) + layout(content.width + shift, content.height) { + content.placeRelative(shift, 0) + } + }, verticalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { val nowMs = clockTickMillis - if (isNow) { + if (isNow && width >= 150.dp) { Badge(stringResource(R.string.live_badge_live), Color.White, LiveColors.LiveRed) Spacer(Modifier.size(6.dp)) - } else if (isPast && isCatchupSupported) { + } else if (isPast && isCatchupSupported && width >= 150.dp) { Badge(stringResource(R.string.live_badge_archive), LiveColors.Bg, LiveColors.Accent) Spacer(Modifier.size(6.dp)) } else if (!isPast) { @@ -231,13 +247,13 @@ fun ProgramCell( } Text( text = program.title, - style = LiveType.CellTitle.copy(color = LiveColors.Fg, fontSize = 9.5.sp, lineHeight = 12.sp), - maxLines = 1, + style = LiveType.CellTitle.copy(color = LiveColors.Fg, fontSize = 10.sp, lineHeight = 12.sp), + maxLines = if (width < 120.dp) 2 else 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } - if (!program.description.isNullOrBlank()) { + if (rowHeight >= 60.dp && width >= 150.dp && !program.description.isNullOrBlank()) { Text( text = program.description!!, style = LiveType.BodySynopsis.copy(color = LiveColors.FgDim, fontSize = 8.sp, lineHeight = 10.sp), @@ -245,7 +261,7 @@ fun ProgramCell( overflow = TextOverflow.Ellipsis, ) } - Row( + if (width >= 120.dp) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { From 933c5352b09e6bd276fb883e8f03dfee43890cf6 Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 8 Sep 2026 11:40:52 +0200 Subject: [PATCH 07/11] fix(tv): reduce guide allocations and improve programme and playback feedback --- .../tv/live/GuideRenderingDeviceTest.kt | 11 +++++ .../repository/IptvPlaybackUrlResolver.kt | 11 +++-- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 4 +- .../tv/ui/screens/tv/live/CategorySidebar.kt | 5 +- .../tv/ui/screens/tv/live/ChannelRow.kt | 4 +- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 8 ++-- .../ui/screens/tv/live/LiveTvEnhancements.kt | 9 +++- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 17 ++++++- .../tv/ui/screens/tv/live/MiniPlayer.kt | 15 ++++++ .../tv/ui/screens/tv/live/ProgramCell.kt | 46 ++++--------------- .../repository/IptvPlaybackUrlResolverTest.kt | 27 +++++++++++ 11 files changed, 105 insertions(+), 52 deletions(-) diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt index 0baebeed0..97be64441 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt @@ -42,6 +42,7 @@ class GuideRenderingDeviceTest { } } private var focused = "" + private var focusedTitle = "" private fun showGuide() { val mode = mutableStateOf(EpgGridFocusMode.ChannelList) @@ -52,6 +53,7 @@ class GuideRenderingDeviceTest { focusSelectedChannelSignal = 1, scrollResetKey = "render-test", favorites = emptySet(), onChannelSelect = {}, gridFocused = true, onChannelFocused = { focused = it.id }, focusMode = mode.value, + onProgramFocused = { _, programme -> focusedTitle = programme.title }, onEnterEpg = { mode.value = EpgGridFocusMode.Epg }, onExitEpg = { mode.value = EpgGridFocusMode.ChannelList }) } @@ -90,6 +92,15 @@ class GuideRenderingDeviceTest { compose.runOnIdle { assertEquals("render:1", focused) } compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } compose.runOnIdle { assertEquals("render:0", focused) } + compose.runOnIdle { assertTrue(focusedTitle.startsWith("Programme render:0:")) } + } + + @Test fun sustainedChannelScrollKeepsItsPosition() { + showGuide() + repeat(60) { compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } } + compose.onNodeWithTag("iptv-channel:render:60").assertIsFocused() + repeat(40) { compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } } + compose.onNodeWithTag("iptv-channel:render:20").assertIsFocused() } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt index c18a4e76a..fec12d97b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt @@ -33,13 +33,14 @@ internal class IptvPlaybackUrlResolver( rawUrl: String, headers: Map, forceRefresh: Boolean = false, + probeKnownUrl: Boolean = false, ): IptvPlaybackTarget { val url = rawUrl.trim() val inferredTarget = IptvPlaybackTarget( url = url, isHls = looksLikeHlsPlaybackUrl(url), ) - if (!shouldResolveIptvPlaybackRedirect(url)) return inferredTarget + if (!probeKnownUrl && !shouldResolveIptvPlaybackRedirect(url)) return inferredTarget val now = System.currentTimeMillis() if (!forceRefresh) { @@ -55,10 +56,11 @@ internal class IptvPlaybackUrlResolver( if (headProbe?.isConclusive == true) { headProbe.target } else { - executeProbe(url, headers, useHead = false)?.target ?: inferredTarget + executeProbe(url, headers, useHead = false)?.takeIf { it.isConclusive }?.target } } + if (resolved == null) return inferredTarget synchronized(cache) { cache[url] = CachedTarget(resolved, now) while (cache.size > maxCacheEntries) { @@ -112,9 +114,8 @@ internal class IptvPlaybackUrlResolver( ) ProbeResult( target = target, - isConclusive = finalUrl != url || - target.isHls || - contentType.isDirectMediaContentType(), + isConclusive = response.isSuccessful && (target.isHls || + contentType.isDirectMediaContentType()), ) } } catch (e: kotlinx.coroutines.CancellationException) { 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 69ca11711..4cc2d01c3 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 @@ -2080,7 +2080,8 @@ class TvViewModel @Inject constructor( channel: IptvChannel, program: IptvProgram? = null, forceRefresh: Boolean = false, - catchupAttempt: Int = 0 + catchupAttempt: Int = 0, + probeKnownUrl: Boolean = false, ): IptvPlaybackTarget { val rawUrl = if (program != null) { iptvRepository.resolvePlayableCatchupUrl(channel, program, catchupAttempt) @@ -2097,6 +2098,7 @@ class TvViewModel @Inject constructor( rawUrl = resolvedUrl, headers = channel.requestHeaders, forceRefresh = forceRefresh, + probeKnownUrl = probeKnownUrl, ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index 74d4c88de..bb8d98ce4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -1,6 +1,7 @@ package com.arflix.tv.ui.screens.tv.live import androidx.compose.animation.animateColorAsState +import androidx.compose.ui.draw.drawBehind import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize @@ -1067,7 +1068,7 @@ private fun SidebarRow( focused -> LiveColors.Panel else -> Color.Transparent } - val surface by animateColorAsState( + val surface = animateColorAsState( if (focused) LiveColors.PanelRaised else bg, animationSpec = tween(120), label = "category-surface", ) @@ -1101,7 +1102,7 @@ private fun SidebarRow( shape = RoundedCornerShape(8.dp), ) .clip(RoundedCornerShape(8.dp)) - .background(surface) + .drawBehind { drawRect(surface.value) } .onPreviewKeyEvent { ev -> val isSelect = ev.key == Key.DirectionCenter || ev.key == Key.Enter when { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index 9ae16bb69..ac904c0ee 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt @@ -114,7 +114,7 @@ fun ChannelRow( animationSpec = tween(durationMillis = 70), label = "channel-row-border", ) - val surface by animateColorAsState(bg, tween(120), label = "channel-surface") + val surface = animateColorAsState(bg, tween(120), label = "channel-surface") Row( modifier = modifier .fillMaxWidth() @@ -124,6 +124,7 @@ fun ChannelRow( if (it.hasFocus) onFocused() } .drawWithContent { + drawRect(surface.value) drawContent() // Read animation state in drawing, not composition: channel // text and logo layout should not rebuild for each border frame. @@ -138,7 +139,6 @@ fun ChannelRow( ) } } - .background(surface) .focusable() // Long-press / MENU opens the channel menu. This has to live in the PREVIEW // phase, ahead of combinedClickable: combinedClickable arms a click on the diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 0c3c94f19..dd0a8cb0b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -115,6 +115,7 @@ fun EpgGrid( onChannelSelect: (EnrichedChannel) -> Unit, onProgramSelect: (EnrichedChannel, IptvProgram?) -> Unit = { channel, _ -> onChannelSelect(channel) }, onChannelFocused: (EnrichedChannel) -> Unit = {}, + onProgramFocused: (EnrichedChannel, IptvProgram) -> Unit = { _, _ -> }, /** Long-press / MENU on a channel row — opens the channel menu. */ onChannelLongPress: (EnrichedChannel, Boolean) -> Unit = { _, _ -> }, favorites: Set, @@ -724,9 +725,10 @@ fun EpgGrid( onProgramSelect(ch, program) keepChannelFocus(idx) }, - onFocused = { + onFocused = { program -> if (focusMode == EpgGridFocusMode.Epg) { onChannelFocused(ch) + onProgramFocused(ch, program) } }, onMoveVertically = { targetRowIdx, anchorStartMin -> @@ -807,7 +809,7 @@ private fun ProgramsRow( renderWindow: GuideRenderWindow, hScrollOffsetPx: () -> Int = { 0 }, onClick: (IptvProgram?) -> Unit, - onFocused: () -> Unit, + onFocused: (IptvProgram) -> Unit, onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, onMoveLeftFromStart: () -> Boolean, rowIdx: Int, @@ -909,7 +911,7 @@ private fun ProgramsRow( isCatchupSupported = isCatchupSupported, )?.let(onClick) }, - onFocused = onFocused, + onFocused = { onFocused(placement.program) }, onMoveLeft = { if (focusableIndex > 0) { runCatching { rowFocusRequesters[focusableIndex - 1].requestFocus() } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt index 25f0c5e6e..078b2a220 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt @@ -451,8 +451,15 @@ fun PlaybackDiagnosticBanner( diagnostic: PlaybackDiagnostic?, modifier: Modifier = Modifier, ) { + var visible by remember(diagnostic) { mutableStateOf(diagnostic != null) } + LaunchedEffect(diagnostic) { + if (diagnostic != null) { + kotlinx.coroutines.delay(8_000) + visible = false + } + } AnimatedVisibility( - visible = diagnostic != null, + visible = visible, enter = fadeIn(), exit = fadeOut(), modifier = modifier, 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 2459a7f3a..1f0cf211b 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 @@ -1102,12 +1102,16 @@ fun LiveTvScreen( // lastChannelId, but nothing consumed it on entry, so Live TV always // started at the top of the list. Rules live in LiveTvStartup so they are // unit tested rather than only verifiable on a device. + val startupChannelIds = remember(state.snapshot.channels) { + LiveTvStartup.channelIds(state.snapshot.channels) + } val resumeChannelId = LiveTvStartup.resumeChannelId( explicitChannelId = initialChannelId, lastChannelId = state.tvSession.lastChannelId, - availableChannelIds = LiveTvStartup.channelIds(state.snapshot.channels), + availableChannelIds = startupChannelIds, ) var focusedChannelId by rememberSaveable { mutableStateOf(resumeChannelId) } + var focusedProgramme by remember { mutableStateOf?>(null) } // The focused row's channel object, reported by the row itself on focus. Not saveable — // it is rebuilt on the next focus event, and only the id needs to survive process death. // Only event handlers need the current row; keep it separate from settled UI selection. @@ -2763,6 +2767,7 @@ fun LiveTvScreen( return } val preparedIsHls = lastPreparedIsHls + val unsupportedContainer = error.errorCode == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED val nextAttempt = playerRetryCount + 1 playerRetryCount = nextAttempt val retryChannel = playingChannel?.source @@ -2778,6 +2783,9 @@ fun LiveTvScreen( } val maxRetryCount = if (retryProgram != null) { (catchupCandidateCount - 1).coerceAtLeast(0).coerceAtMost(2) + } else if (unsupportedContainer) { + // One bounded content-type recovery, not repeated identical prepares. + 1 } else { 3 } @@ -2805,7 +2813,8 @@ fun LiveTvScreen( channel = retryChannel, program = retryStreamProgram ?: retryProgram, forceRefresh = true, - catchupAttempt = if (retryProgram != null) nextAttempt else 0 + catchupAttempt = if (retryProgram != null) nextAttempt else 0, + probeKnownUrl = unsupportedContainer, ) } else { IptvPlaybackTarget(prepared, preparedIsHls) @@ -3140,6 +3149,7 @@ fun LiveTvScreen( ) } MiniPlayerRow( + focusedProgramme = focusedProgramme.takeIf { focusZone == LiveTvFocusZone.EPG }, exoPlayer = exoPlayer, channel = playingDisplayChannel, clockTickMillis = guideClockMillis, @@ -3196,6 +3206,7 @@ fun LiveTvScreen( program?.let { selectEpgProgram(channel, it) } }, onChannelFocused = { channel -> commitFocusedChannel(channel) }, + onProgramFocused = { channel, programme -> focusedProgramme = channel to programme }, onChannelLongPress = { channel, fromKeyHold -> openChannelMenu(channel, fromKeyHold) }, favorites = favSet, variantCountFor = { channel -> variantCountFor(channel, variantGroups) }, @@ -3297,6 +3308,7 @@ fun LiveTvScreen( ) } MiniPlayerRow( + focusedProgramme = focusedProgramme.takeIf { focusZone == LiveTvFocusZone.EPG }, exoPlayer = exoPlayer, channel = playingDisplayChannel, clockTickMillis = guideClockMillis, @@ -3341,6 +3353,7 @@ fun LiveTvScreen( program?.let { selectEpgProgram(channel, it) } }, onChannelFocused = { channel -> commitFocusedChannel(channel) }, + onProgramFocused = { channel, programme -> focusedProgramme = channel to programme }, onChannelLongPress = { channel, fromKeyHold -> openChannelMenu(channel, fromKeyHold) }, favorites = favSet, variantCountFor = { channel -> variantCountFor(channel, variantGroups) }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt index 21ae218d8..57c830138 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/MiniPlayer.kt @@ -111,6 +111,7 @@ fun MiniPlayerRow( compact: Boolean = false, landscapeCompact: Boolean = false, playerActive: Boolean = true, + focusedProgramme: Pair? = null, onVideoBoundsPositioned: ((Rect) -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -132,6 +133,7 @@ fun MiniPlayerRow( onVideoBoundsPositioned = onVideoBoundsPositioned, ) InfoColumn( + focusedProgramme = focusedProgramme, channel = channel, clockTickMillis = clockTickMillis, nowNext = nowNext, @@ -162,6 +164,7 @@ fun MiniPlayerRow( modifier = Modifier.fillMaxWidth(), ) InfoColumn( + focusedProgramme = focusedProgramme, channel = channel, clockTickMillis = clockTickMillis, nowNext = nowNext, @@ -188,6 +191,7 @@ fun MiniPlayerRow( onVideoBoundsPositioned = onVideoBoundsPositioned, ) InfoColumn( + focusedProgramme = focusedProgramme, channel = channel, clockTickMillis = clockTickMillis, nowNext = nowNext, @@ -369,6 +373,7 @@ private fun LiveBug(modifier: Modifier = Modifier) { @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun InfoColumn( + focusedProgramme: Pair? = null, channel: EnrichedChannel?, clockTickMillis: Long, nowNext: IptvNowNext?, @@ -384,6 +389,16 @@ private fun InfoColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(if (landscapeCompact) 5.dp else 8.dp), ) { + if (focusedProgramme != null) { + val (focusedChannel, programme) = focusedProgramme + Text(focusedChannel.source.name, style = LiveType.SectionTag.copy(color = LiveColors.FgDim)) + Text("${formatClock(programme.startUtcMillis)} - ${formatClock(programme.endUtcMillis)}", style = LiveType.TimeMono.copy(color = LiveColors.FgDim)) + Text(programme.title, style = LiveType.CellTitle.copy(color = LiveColors.Fg), maxLines = 2, overflow = TextOverflow.Ellipsis) + programme.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = LiveType.BodySynopsis.copy(color = LiveColors.FgDim), maxLines = if (landscapeCompact) 1 else 3, overflow = TextOverflow.Ellipsis) + } + return@Column + } ChannelIdentityRow(channel = channel, variantCount = variantCount, onOpenVariants = onOpenVariants) NowCard( channel = channel, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index eabfd1ad7..8a5e28ba7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -3,7 +3,6 @@ package com.arflix.tv.ui.screens.tv.live import androidx.compose.animation.animateColorAsState import androidx.compose.ui.layout.layout -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -32,6 +31,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -92,7 +92,7 @@ fun ProgramCell( isNow -> LiveColors.FocusBg else -> LiveColors.Panel } - val bg by animateColorAsState( + val bg = animateColorAsState( if (focused) LiveColors.PanelRaised else baseBg, tween(120), label = "programme-surface", ) @@ -101,36 +101,12 @@ fun ProgramCell( isNow -> LiveColors.Accent.copy(alpha = 0.45f) else -> Color.Transparent } - val borderWidth = if (focusable) { - val animated by animateDpAsState( - targetValue = if (focused) LiveDims.FocusBorder else 1.dp, - animationSpec = tween(durationMillis = 80), - label = "program-cell-border", - ) - animated - } else { - if (focused) LiveDims.FocusBorder else 1.dp - } - val scale = if (focusable) { - val animated by animateFloatAsState( - targetValue = 1f, - animationSpec = tween(durationMillis = 90), - label = "program-cell-scale", - ) - animated - } else { - 1f - } - val contentAlpha = if (focusable) { - val animated by animateFloatAsState( - targetValue = if (isPast && !focused && !isCatchupSupported) 0.55f else 1f, - animationSpec = tween(durationMillis = 90), - label = "program-cell-alpha", - ) - animated - } else { - if (isPast && !isCatchupSupported) 0.55f else 1f - } + val borderWidth = if (focused) LiveDims.FocusBorder else 1.dp + val contentAlpha = animateFloatAsState( + targetValue = if (isPast && !focused && !isCatchupSupported) 0.55f else 1f, + animationSpec = tween(durationMillis = 90), + label = "program-cell-alpha", + ) Box( modifier = modifier .height(rowHeight) @@ -141,8 +117,7 @@ fun ProgramCell( // blocks visually empty. Total horizontal overhead is now 8dp. .padding(horizontal = 1.dp, vertical = 3.dp) .graphicsLayer { - scaleX = scale - scaleY = scale + alpha = contentAlpha.value } .then( if (focusable && focusRequester != null) { @@ -167,8 +142,7 @@ fun ProgramCell( shape = RoundedCornerShape(LiveDims.CellRadius), ) .clip(RoundedCornerShape(LiveDims.CellRadius)) - .background(bg) - .alpha(contentAlpha) + .drawBehind { drawRect(bg.value) } .then(if (focusable) Modifier.focusable() else Modifier) .then( if (focusable) { diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolverTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolverTest.kt index 6ee3a0cc0..146d379a6 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolverTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolverTest.kt @@ -11,6 +11,33 @@ import org.junit.Test import java.util.concurrent.atomic.AtomicInteger class IptvPlaybackUrlResolverTest { + @Test fun `failed numeric stream can explicitly recover an HLS content type`() = runBlocking { + val calls = AtomicInteger() + val resolver = IptvPlaybackUrlResolver(OkHttpClient.Builder().addInterceptor { chain -> + calls.incrementAndGet() + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1) + .code(200).message("OK").header("Content-Type", "application/vnd.apple.mpegurl") + .body("".toResponseBody()).build() + }.build()) + val url = "https://provider.test/live/user/pass/123.ts" + assertThat(resolver.resolve(url, emptyMap()).isHls).isFalse() + assertThat(calls.get()).isEqualTo(0) + assertThat(resolver.resolve(url, emptyMap(), forceRefresh = true, probeKnownUrl = true).isHls).isTrue() + assertThat(calls.get()).isEqualTo(1) + } + + @Test fun `HTML error redirect is not cached as a media target`() = runBlocking { + val calls = AtomicInteger() + val resolver = IptvPlaybackUrlResolver(OkHttpClient.Builder().addInterceptor { chain -> + calls.incrementAndGet() + Response.Builder().request(chain.request().newBuilder().url("https://provider.test/error").build()) + .protocol(Protocol.HTTP_1_1).code(403).message("Forbidden") + .header("Content-Type", "text/html").body("Denied".toResponseBody()).build() + }.build()) + val url = "https://provider.test/live/user/pass/channel-slug" + repeat(2) { assertThat(resolver.resolve(url, emptyMap()).url).isEqualTo(url) } + assertThat(calls.get()).isEqualTo(4) + } @Test fun `extensionless slug live URL resolves redirect and HLS type`() = runBlocking { From 6b0ec579b49ff2b1b58bffac10429ee54f932a45 Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 8 Sep 2026 12:35:31 +0200 Subject: [PATCH 08/11] perf(tv): reduce guide semantics and graphics layer overhead --- .../tv/live/GuideRenderingDeviceTest.kt | 9 +++- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 11 ++++- .../tv/ui/screens/tv/live/ProgramCell.kt | 41 +++++++++++++---- docs/iptv-scroll-performance-2026-09-08.md | 46 +++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 docs/iptv-scroll-performance-2026-09-08.md diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt index 97be64441..c258c76fe 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt @@ -64,7 +64,7 @@ class GuideRenderingDeviceTest { @Test fun channelModeDoesNotComposeTheEntireDayForEveryVisibleRow() { showGuide() val count = compose.onAllNodes(hasText("Programme ", substring = true), - useUnmergedTree = true).fetchSemanticsNodes().size + useUnmergedTree = false).fetchSemanticsNodes().size Log.i("GuideRenderCells", "composedProgrammeCells=$count") assertTrue("Visible guide has no programmes", count > 0) assertTrue("Too many offscreen programme cells: $count", count < 90) @@ -84,6 +84,13 @@ class GuideRenderingDeviceTest { compose.runOnIdle { assertEquals("render:8", focused) } } + @Test fun liveProgrammeHasOneAccessibleEntryWithAnAction() { + showGuide() + compose.onNodeWithText("Programme render:0:4") + .assertHasClickAction() + .assert(hasText("A programme description for rendering cost.")) + } + @Test fun epgNavigationStillReachesOffscreenProgrammesAndAdjacentChannel() { showGuide() compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index dd0a8cb0b..d54ef8c55 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -593,6 +595,11 @@ fun EpgGrid( ch.id == activeChannelFocusId && focusMode == EpgGridFocusMode.ChannelList } } + val isFocusAnchor by remember(ch.id, scrollResetKey, selectedChannelId, channels.firstOrNull()?.id) { + derivedStateOf { + ch.id == (activeChannelFocusId ?: selectedChannelId ?: channels.firstOrNull()?.id) + } + } DisposableEffect(ch.id, channelFocusRequester) { channelFocusRequesters[ch.id] = channelFocusRequester onDispose { @@ -616,6 +623,8 @@ fun EpgGrid( modifier = Modifier .fillMaxWidth() .height(rowHeight) + // Keep accessibility geometry sorting local to each guide row. + .semantics { isTraversalGroup = true } ) { val isChannelActive = if (playingChannelId != null) { ch.id == playingChannelId @@ -667,7 +676,7 @@ fun EpgGrid( .focusRequester(channelFocusRequester) .then(if (idx == 0) Modifier.focusRequester(firstChannelFocusRequester) else Modifier) .then( - if (ch.id == (activeChannelFocusId ?: selectedChannelId ?: channels.firstOrNull()?.id)) { + if (isFocusAnchor) { Modifier.focusRequester(selectedChannelFocusRequester) } else Modifier ), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index 8a5e28ba7..b44654ce7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -46,6 +46,12 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -111,14 +117,23 @@ fun ProgramCell( modifier = modifier .height(rowHeight) .width(width) + // Announce the programme as one entry, not separate title/time/badge nodes. + .semantics(mergeDescendants = true) { + if (isNow || (isPast && isCatchupSupported)) { + onClick { + currentOnClick() + true + } + } + } // Outer gutter was 3dp×2 + inner 10dp×2 = 26dp of horizontal // overhead. On a 60dp min-width block that left only ~34dp for // text + badges, which the LIVE pill alone consumed — leaving // blocks visually empty. Total horizontal overhead is now 8dp. .padding(horizontal = 1.dp, vertical = 3.dp) - .graphicsLayer { + .then(if (isPast && !isCatchupSupported) Modifier.graphicsLayer { alpha = contentAlpha.value - } + } else Modifier) .then( if (focusable && focusRequester != null) { Modifier.focusRequester(focusRequester) @@ -141,8 +156,10 @@ fun ProgramCell( color = borderColor, shape = RoundedCornerShape(LiveDims.CellRadius), ) - .clip(RoundedCornerShape(LiveDims.CellRadius)) - .drawBehind { drawRect(bg.value) } + .drawBehind { + val radius = LiveDims.CellRadius.toPx() + drawRoundRect(bg.value, cornerRadius = CornerRadius(radius)) + } .then(if (focusable) Modifier.focusable() else Modifier) .then( if (focusable) { @@ -177,19 +194,27 @@ fun ProgramCell( Box( modifier = Modifier .fillMaxSize() - .background( - Brush.horizontalGradient( + .drawBehind { + val radius = LiveDims.CellRadius.toPx() + drawRoundRect(Brush.horizontalGradient( listOf( LiveColors.Accent.copy(alpha = 0.22f), Color.Transparent, ) - ) - ) + ), cornerRadius = CornerRadius(radius)) + } ) } Column( modifier = Modifier .fillMaxSize() + .clearAndSetSemantics { + this[SemanticsProperties.Text] = listOfNotNull( + AnnotatedString(program.title), + AnnotatedString(formatClock(program.startUtcMillis)), + program.description?.takeIf { it.isNotBlank() }?.let(::AnnotatedString), + ) + } // Read scroll position in measurement, not row composition. .layout { measurable, constraints -> val shift = contentStartOffsetPx().coerceIn(0, constraints.maxWidth) diff --git a/docs/iptv-scroll-performance-2026-09-08.md b/docs/iptv-scroll-performance-2026-09-08.md new file mode 100644 index 000000000..78826b329 --- /dev/null +++ b/docs/iptv-scroll-performance-2026-09-08.md @@ -0,0 +1,46 @@ +# TV guide scrolling performance + +## Changes + +- Isolate the selected focus-requester anchor with derived state so changing focus + does not invalidate every visible channel row. +- Group accessibility traversal by channel row and expose each programme as one + combined entry, including its full title, time and description. +- Keep programme accessibility actions available for live/catch-up playback. +- Draw rounded programme backgrounds directly. Avoid clip/opacity graphics layers + for ordinary opaque programme cells; retain fading where needed. + +## Verification + +Five GuideRenderingDeviceTest instrumentation tests passed on an Android 12 TV +emulator configured with 2 GB RAM. The fixture has 55,000 channels, with 144 in +the rendering window. Tests cover bounded programme entries, accessible programme +details/actions, vertical channel navigation, offscreen EPG navigation, and +60-down/40-up position retention. This is not a full provider-load benchmark. + +The signed sideload release built successfully and was installed as an update on +the TCL. The certificate matches the existing release. Live NPO 1 HD playback, +channel scrolling, programme focus and return to the channel column were checked. + +## Device measurements + +Baseline: 933c5352b. Same TCL, NL ALGEMEEN category, NPO 1 HD playing, category +drawer closed, ten down and ten up key events starting at channel 17. Statistics +were reset before each run and collected after settling. No sampling profiler ran +during frame measurements. The existing launcher accessibility service stayed on. + +| Build/run | Frames | Janky | Median | P90 | P99 | +| --- | ---: | ---: | ---: | ---: | ---: | +| Baseline 1 | 148 | 37.16% | 32 ms | 93 ms | 200 ms | +| Baseline 2 | 152 | 34.87% | 31 ms | 81 ms | 150 ms | +| Final 1 | 140 | 30.71% | 32 ms | 69 ms | 150 ms | +| Final 2 | 151 | 27.15% | 30 ms | 81 ms | 150 ms | + +These are short device comparisons, not a controlled lab benchmark. Live content, +EPG time windows and background activity can vary. Improvements are modest and +do not establish lag-free scrolling, 60 fps, or parity on every low-memory device. + +Method sampling still identifies Compose accessibility geometry processing as a +major main-thread cost. A broader rendering/runtime change needs separate +accessibility and navigation regression coverage. Near-lag-free acceptance remains +unmet; do not advertise this patch as eliminating stutter. From fc6d603eed429aa76fd0576b0f09476d42807a26 Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 8 Sep 2026 12:39:23 +0200 Subject: [PATCH 09/11] fix(tv): align channel focus outline with rounded surface --- .../tv/ui/screens/tv/live/ChannelRow.kt | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index ac904c0ee..4993d1dc1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt @@ -109,7 +109,7 @@ fun ChannelRow( else -> Color.Transparent } val now = nowNext?.now - val animatedBorderWidth by animateDpAsState( + val animatedBorderWidth = animateDpAsState( targetValue = if (visuallyFocused) LiveDims.FocusBorder else 0.dp, animationSpec = tween(durationMillis = 70), label = "channel-row-border", @@ -124,17 +124,36 @@ fun ChannelRow( if (it.hasFocus) onFocused() } .drawWithContent { - drawRect(surface.value) + val inset = 2.dp.toPx() + val radius = 6.dp.toPx() + val surfaceSize = Size( + (size.width - inset * 2).coerceAtLeast(0f), + (size.height - inset * 2).coerceAtLeast(0f), + ) + drawRoundRect( + color = surface.value, + topLeft = Offset(inset, inset), + size = surfaceSize, + cornerRadius = CornerRadius(radius), + ) + if (isActive) { + drawRoundRect( + color = LiveColors.Accent, + topLeft = Offset(inset + 2.dp.toPx(), 8.dp.toPx()), + size = Size(2.dp.toPx(), (size.height - 16.dp.toPx()).coerceAtLeast(0f)), + cornerRadius = CornerRadius(1.dp.toPx()), + ) + } drawContent() // Read animation state in drawing, not composition: channel // text and logo layout should not rebuild for each border frame. - val stroke = animatedBorderWidth.toPx() + val stroke = animatedBorderWidth.value.toPx() if (visuallyFocused && stroke > 0f) { drawRoundRect( color = LiveColors.FocusRing, - topLeft = Offset(2.dp.toPx() + stroke / 2f, 2.dp.toPx() + stroke / 2f), - size = Size((size.width - 4.dp.toPx() - stroke).coerceAtLeast(0f), (size.height - 4.dp.toPx() - stroke).coerceAtLeast(0f)), - cornerRadius = CornerRadius(5.dp.toPx()), + topLeft = Offset(inset + stroke / 2f, inset + stroke / 2f), + size = Size((surfaceSize.width - stroke).coerceAtLeast(0f), (surfaceSize.height - stroke).coerceAtLeast(0f)), + cornerRadius = CornerRadius((radius - stroke / 2f).coerceAtLeast(0f)), style = Stroke(stroke), ) } @@ -200,8 +219,7 @@ fun ChannelRow( Box( modifier = Modifier .fillMaxHeight() - .width(LiveDims.ActiveIndicator) - .background(if (isActive) LiveColors.Accent else Color.Transparent), + .width(LiveDims.ActiveIndicator), ) // ─ channel number ──────────────────────────────────── From 1c1729613f73fb8ccff2c71c6ce63356151ad048 Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 8 Sep 2026 13:38:48 +0200 Subject: [PATCH 10/11] perf(tv): draw passive guide programmes without nested layouts --- .../tv/live/GuideRenderingDeviceTest.kt | 5 + .../screens/tv/live/ChannelProgrammeCanvas.kt | 95 +++++++++++++++++++ .../tv/ui/screens/tv/live/ProgramCell.kt | 39 ++++---- docs/iptv-scroll-performance-2026-09-08.md | 22 +++++ 4 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelProgrammeCanvas.kt diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt index c258c76fe..3d7634632 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt @@ -69,6 +69,9 @@ class GuideRenderingDeviceTest { assertTrue("Visible guide has no programmes", count > 0) assertTrue("Too many offscreen programme cells: $count", count < 90) compose.onNodeWithText("Programme render:0:4").assertIsDisplayed() + // Channel-mode rendering exposes one entry without a child Text layout. + compose.onAllNodes(hasText("Programme render:0:4"), useUnmergedTree = true) + .assertCountEquals(1) compose.onNodeWithText("Programme render:0:23").assertDoesNotExist() } @@ -89,6 +92,8 @@ class GuideRenderingDeviceTest { compose.onNodeWithText("Programme render:0:4") .assertHasClickAction() .assert(hasText("A programme description for rendering cost.")) + compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + compose.onNodeWithText("Programme render:0:4").assertIsFocused() } @Test fun epgNavigationStillReachesOffscreenProgrammesAndAdjacentChannel() { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelProgrammeCanvas.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelProgrammeCanvas.kt new file mode 100644 index 000000000..8d0292e41 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelProgrammeCanvas.kt @@ -0,0 +1,95 @@ +package com.arflix.tv.ui.screens.tv.live + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.arflix.tv.R +import com.arflix.tv.data.model.IptvProgram + +/** Read-only TV guide cells need no child layouts or graphics layers. */ +@Composable +internal fun ChannelProgrammeCanvas( + program: IptvProgram, + width: Dp, + rowHeight: Dp, + isNow: Boolean, + isPast: Boolean, + isCatchupSupported: Boolean, + contentStartOffsetPx: () -> Int, + onClick: () -> Unit, + modifier: Modifier, +) { + val measurer = rememberTextMeasurer(cacheSize = 4) + val time = formatClock(program.startUtcMillis) + val minutes = ((program.endUtcMillis - program.startUtcMillis) / 60_000L).coerceAtLeast(0) + val duration = stringResource(R.string.live_label_duration_min, minutes) + val badge = when { + width < 150.dp -> null + isNow -> stringResource(R.string.live_badge_live) + isPast && isCatchupSupported -> stringResource(R.string.live_badge_archive) + else -> null + } + Box(modifier.height(rowHeight).width(width) + .clearAndSetSemantics { + this[SemanticsProperties.Text] = listOfNotNull( + AnnotatedString(program.title), AnnotatedString(time), + program.description?.takeIf { it.isNotBlank() }?.let(::AnnotatedString), + ) + if (isNow || (isPast && isCatchupSupported)) onClick { onClick(); true } + } + .drawWithCache { + val x = 7.dp.toPx() + contentStartOffsetPx() + val available = (size.width - x - 7.dp.toPx()).toInt().coerceAtLeast(1) + val dimmed = isPast && !isCatchupSupported + val titleColor = if (dimmed) LiveColors.Fg.copy(alpha = 0.55f) else LiveColors.Fg + val badgeLayout = badge?.let { + measurer.measure(it, LiveType.Badge.copy(fontSize = 7.5.sp, lineHeight = 9.sp)) + } + val badgeWidth = badgeLayout?.let { it.size.width + 8.dp.toPx() } ?: 0f + val titleX = if (badgeLayout != null) badgeWidth + 6.dp.toPx() else 0f + val title = measurer.measure(program.title, + LiveType.CellTitle.copy(color = titleColor, fontSize = 10.sp, lineHeight = 12.sp), + overflow = TextOverflow.Ellipsis, maxLines = if (width < 120.dp) 2 else 1, + constraints = Constraints(maxWidth = (available - titleX).toInt().coerceAtLeast(1))) + val footer = if (width >= 120.dp) measurer.measure( + if (minutes > 0) "$time $duration" else time, + LiveType.TimeMono.copy(color = LiveColors.FgMute, fontSize = 8.sp, lineHeight = 10.sp), + maxLines = 1, overflow = TextOverflow.Ellipsis, + constraints = Constraints(maxWidth = available)) else null + onDrawBehind { + val origin = Offset(1.dp.toPx(), 3.dp.toPx()) + val cellSize = Size((size.width - 2.dp.toPx()).coerceAtLeast(0f), (size.height - 6.dp.toPx()).coerceAtLeast(0f)) + val radius = CornerRadius(LiveDims.CellRadius.toPx()) + drawRoundRect(if (isNow) LiveColors.FocusBg else LiveColors.Panel, origin, cellSize, radius) + if (isNow) drawRoundRect(LiveColors.Accent.copy(alpha = 0.45f), origin, cellSize, radius, style = Stroke(1.dp.toPx())) + if (badgeLayout != null) { + drawRoundRect(if (isNow) LiveColors.LiveRed else LiveColors.Accent, + Offset(x, 5.dp.toPx()), Size(badgeWidth, badgeLayout.size.height + 1.dp.toPx()), CornerRadius(3.dp.toPx())) + drawText(badgeLayout, color = if (isNow) Color.White else LiveColors.Bg, + topLeft = Offset(x + 4.dp.toPx(), 5.5.dp.toPx())) + } + drawText(title, topLeft = Offset(x + titleX, 5.dp.toPx())) + if (footer != null) drawText(footer, topLeft = Offset(x, size.height - 5.dp.toPx() - footer.size.height)) + } + }) +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt index b44654ce7..03b29415b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ProgramCell.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.onClick -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.geometry.CornerRadius @@ -91,6 +90,13 @@ fun ProgramCell( ) { val deviceType = LocalDeviceType.current val isTouchDevice = deviceType.isTouchDevice() + // Retain the standard layout for touch, expanded rows and RTL text layout. + if (!focusable && !isTouchDevice && rowHeight < 60.dp && + LocalLayoutDirection.current == LayoutDirection.Ltr) { + ChannelProgrammeCanvas(program, width, rowHeight, isNow, isPast, + isCatchupSupported, contentStartOffsetPx, onClick, modifier) + return + } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val currentOnClick by rememberUpdatedState(onClick) var focused by remember { mutableStateOf(false) } @@ -117,15 +123,6 @@ fun ProgramCell( modifier = modifier .height(rowHeight) .width(width) - // Announce the programme as one entry, not separate title/time/badge nodes. - .semantics(mergeDescendants = true) { - if (isNow || (isPast && isCatchupSupported)) { - onClick { - currentOnClick() - true - } - } - } // Outer gutter was 3dp×2 + inner 10dp×2 = 26dp of horizontal // overhead. On a 60dp min-width block that left only ~34dp for // text + badges, which the LIVE pill alone consumed — leaving @@ -188,6 +185,21 @@ fun ProgramCell( Modifier } ) + // Keep focus semantics above this node, but stop accessibility from + // walking the decorative/text layout beneath each programme. + .clearAndSetSemantics { + this[SemanticsProperties.Text] = listOfNotNull( + AnnotatedString(program.title), + AnnotatedString(formatClock(program.startUtcMillis)), + program.description?.takeIf { it.isNotBlank() }?.let(::AnnotatedString), + ) + if (isNow || (isPast && isCatchupSupported)) { + onClick { + currentOnClick() + true + } + } + } .padding(horizontal = 6.dp, vertical = 2.dp), ) { if (isNow) { @@ -208,13 +220,6 @@ fun ProgramCell( Column( modifier = Modifier .fillMaxSize() - .clearAndSetSemantics { - this[SemanticsProperties.Text] = listOfNotNull( - AnnotatedString(program.title), - AnnotatedString(formatClock(program.startUtcMillis)), - program.description?.takeIf { it.isNotBlank() }?.let(::AnnotatedString), - ) - } // Read scroll position in measurement, not row composition. .layout { measurable, constraints -> val shift = contentStartOffsetPx().coerceIn(0, constraints.maxWidth) diff --git a/docs/iptv-scroll-performance-2026-09-08.md b/docs/iptv-scroll-performance-2026-09-08.md index 78826b329..b276d7bb4 100644 --- a/docs/iptv-scroll-performance-2026-09-08.md +++ b/docs/iptv-scroll-performance-2026-09-08.md @@ -44,3 +44,25 @@ Method sampling still identifies Compose accessibility geometry processing as a major main-thread cost. A broader rendering/runtime change needs separate accessibility and navigation regression coverage. Near-lag-free acceptance remains unmet; do not advertise this patch as eliminating stutter. + +## Follow-up: lightweight channel-mode programme rendering + +Compact LTR TV programme cells now draw their read-only content on a canvas with +one accessible entry. Entering EPG mode restores the interactive programme layout; +touch devices, RTL layouts and taller rows retain the existing renderer. Programme +focus, full accessible descriptions and live/archive accessibility actions remain. + +Two warmed TCL channel-scroll runs with live NPO 1 HD playing measured: + +| Run | Frames | Janky | Median | P90 | P99 | +| --- | ---: | ---: | ---: | ---: | ---: | +| Canvas 1 | 151 | 21.85% | 23 ms | 77 ms | 150 ms | +| Canvas 2 | 155 | 21.29% | 24 ms | 61 ms | 113 ms | +| Final compatibility-guard build | 160 | 21.88% | 29 ms | 61 ms | 121 ms | + +The EPG clock/content changed during builds, so these are directional measurements, +not a strict A/B percentage claim. Earlier runs in this session varied from 14.71% +in a future-time viewport to 32-36% in other guide windows before the canvas change. +The five emulator guide tests passed with canvas rendering, including the switch +to an actually focused interactive programme. Remaining jank is still noticeable +under rapid scrolling; there is no claim of lag-free operation on all devices. From 52e4067871ea6eb50e026188377c8008ddb6c67c Mon Sep 17 00:00:00 2001 From: Arvin Date: Tue, 8 Sep 2026 15:19:21 +0200 Subject: [PATCH 11/11] perf(tv): bound guide ruler and use indexed channel focus --- .../tv/live/GuideRenderingDeviceTest.kt | 47 +++++++++++++++- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 23 ++++++-- docs/iptv-scroll-performance-2026-09-08.md | 55 +++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt index 3d7634632..01820cb72 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/GuideRenderingDeviceTest.kt @@ -5,8 +5,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.dp @@ -20,7 +27,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalTestApi::class) +@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class) class GuideRenderingDeviceTest { @get:Rule val compose = createComposeRule() private val now = 1_783_000_000_000L / 1_800_000L * 1_800_000L @@ -43,16 +50,22 @@ class GuideRenderingDeviceTest { } private var focused = "" private var focusedTitle = "" + private var longPressed = false private fun showGuide() { val mode = mutableStateOf(EpgGridFocusMode.ChannelList) compose.setContent { + val inputModeManager = LocalInputModeManager.current + LaunchedEffect(inputModeManager) { + check(inputModeManager.requestInputMode(InputMode.Keyboard)) + } Box(Modifier.width(900.dp).height(400.dp)) { EpgGrid(channels = rows.take(144), totalChannelCount = 55_000, clockTickMillis = now, nowNext = guide, selectedChannelId = "render:0", focusSelectedChannelSignal = 1, scrollResetKey = "render-test", favorites = emptySet(), onChannelSelect = {}, gridFocused = true, onChannelFocused = { focused = it.id }, focusMode = mode.value, + onChannelLongPress = { _, _ -> longPressed = true }, onProgramFocused = { _, programme -> focusedTitle = programme.title }, onEnterEpg = { mode.value = EpgGridFocusMode.Epg }, onExitEpg = { mode.value = EpgGridFocusMode.ChannelList }) @@ -73,6 +86,11 @@ class GuideRenderingDeviceTest { compose.onAllNodes(hasText("Programme render:0:4"), useUnmergedTree = true) .assertCountEquals(1) compose.onNodeWithText("Programme render:0:23").assertDoesNotExist() + val rulerCount = compose.onAllNodes(SemanticsMatcher("time ruler label") { + it.config.getOrNull(SemanticsProperties.TestTag)?.startsWith("iptv-time-slot:") == true + }).fetchSemanticsNodes().size + assertTrue("Ruler labels should be viewport-bounded: $rulerCount", rulerCount in 1..15) + compose.onNodeWithTag("iptv-time-slot:23").assertDoesNotExist() } @Test fun verticalChannelNavigationCannotEnterProgrammes() { @@ -96,10 +114,21 @@ class GuideRenderingDeviceTest { compose.onNodeWithText("Programme render:0:4").assertIsFocused() } + @Test fun channelKeepsFocusAndAccessibleLongPress() { + showGuide() + compose.onNodeWithTag("iptv-channel:render:0") + .assertIsFocused().assertHasClickAction().assert(hasText("Channel 0")) + .performSemanticsAction(SemanticsActions.OnLongClick) + compose.runOnIdle { assertTrue(longPressed) } + compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithTag("iptv-channel:render:1").assertIsFocused() + } + @Test fun epgNavigationStillReachesOffscreenProgrammesAndAdjacentChannel() { showGuide() compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } repeat(10) { compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } } + compose.onNodeWithTag("iptv-time-slot:14").assertExists() compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } compose.runOnIdle { assertEquals("render:1", focused) } compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } @@ -115,4 +144,20 @@ class GuideRenderingDeviceTest { compose.onNodeWithTag("iptv-channel:render:20").assertIsFocused() } + @Test fun rapidChannelKeysRetainTheRequestedIndex() { + showGuide() + compose.onRoot().performKeyInput { repeat(26) { pressKey(Key.DirectionDown) } } + compose.waitUntil(5_000) { + compose.onAllNodes(hasTestTag("iptv-channel:render:26") and isFocused()) + .fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithTag("iptv-channel:render:26").assertIsFocused() + compose.onRoot().performKeyInput { repeat(10) { pressKey(Key.DirectionUp) } } + compose.waitUntil(5_000) { + compose.onAllNodes(hasTestTag("iptv-channel:render:16") and isFocused()) + .fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithTag("iptv-channel:render:16").assertIsFocused() + } + } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index d54ef8c55..f0f95daae 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -287,7 +287,6 @@ fun EpgGrid( activeChannelFocusId = channel.id activeChannelFocusIndex = rowIdx pendingChannelFocusId = channel.id - onChannelFocused(channel) focusJob?.cancel() val directRequester = channelFocusRequesters[channel.id] ?: if (rowIdx == 0) firstChannelFocusRequester @@ -334,7 +333,10 @@ fun EpgGrid( onRequestNextChannels() true } - else -> false + // The target is already known by channel index. Avoid a spatial + // search through the programme tree, and retain pending key repeats + // when the next row has not been composed yet. + else -> keepChannelFocus(targetIdx) } } @@ -493,13 +495,23 @@ fun EpgGrid( .clipToBounds(), ) { val rulerWidthPx = with(density) { maxWidth.toPx() } - Row(Modifier.horizontalScroll(hScroll)) { - slots.forEach { slot -> + val rulerWindow by remember(hScroll, density, rulerWidthPx, pxPerMin) { + derivedStateOf { + guideRenderWindow(hScroll.value, rulerWidthPx, with(density) { pxPerMin.dp.toPx() }) + } + } + // Retain the full scroll extent without laying out and visiting + // accessibility bounds for every offscreen label on each frame. + Box(Modifier.horizontalScroll(hScroll).width(halfHourWidth * slots.size).fillMaxHeight()) { + slots.forEachIndexed { index, slot -> + if (!rulerWindow.intersects(index * 30, (index + 1) * 30)) return@forEachIndexed Box( modifier = Modifier + .offset(x = halfHourWidth * index) .width(halfHourWidth) .fillMaxHeight() - .padding(start = 12.dp), + .padding(start = 12.dp) + .testTag("iptv-time-slot:$index"), contentAlignment = Alignment.CenterStart, ) { Text( @@ -831,7 +843,6 @@ private fun ProgramsRow( modifier = Modifier .width(totalWidth) .height(rowHeight) - .clipToBounds() .background( if (stripe) LiveColors.RowStripe else Color.Transparent ), diff --git a/docs/iptv-scroll-performance-2026-09-08.md b/docs/iptv-scroll-performance-2026-09-08.md index b276d7bb4..f8a19b321 100644 --- a/docs/iptv-scroll-performance-2026-09-08.md +++ b/docs/iptv-scroll-performance-2026-09-08.md @@ -66,3 +66,58 @@ in a future-time viewport to 32-36% in other guide windows before the canvas cha The five emulator guide tests passed with canvas rendering, including the switch to an actually focused interactive programme. Remaining jank is still noticeable under rapid scrolling; there is no claim of lag-free operation on all devices. + +## Follow-up: indexed focus and bounded time ruler + +Channel up/down now requests the known adjacent channel directly instead of +performing a spatial search through the programme tree. Pending repeats retain +their requested channel index; the focused-channel callback is emitted by actual +focus acquisition rather than speculatively before it. The time ruler retains its +full horizontal scroll extent but only composes labels near the viewport. A +redundant inner programme clipping layer was removed; the viewport still clips. + +Seven instrumentation tests passed on the 2 GB Android 12 TV emulator, including +rapid 26-down/10-up input, long-click accessibility, viewport-bounded ruler labels, +and offscreen programme navigation. The tests request Android's actual keyboard +input mode. The 55,000-channel fixture still uses a 144-row rendering window; +these are not end-to-end provider import or EPG loading-time tests. + +The release-signed candidate was installed over the existing TCL installation, +without clearing data. Repeated 10-down/10-up checks returned from channel 17 to +27 and back to 17 with the outline fully visible after settling. The long-press +menu stayed open; right entered a programme and Back restored channel focus. +An earlier capture showed a partially clipped row/different channel number; +this was not reproduced in the three subsequently checked settled sequences. +Duplicate focus targets were considered but not established as the cause, and +no change to ChannelRow's focus modifiers was retained. + +### Measurements and acceptance caveat + +| Sequence | Frames | Janky | Median | P90 | P99 | +| --- | ---: | ---: | ---: | ---: | ---: | +| Initial 1 | 161 | 18.63% | 23 ms | 53 ms | 69 ms | +| Initial 2 | 153 | 21.57% | 23 ms | 57 ms | 81 ms | +| Initial 3 | 160 | 13.12% | 24 ms | 40 ms | 97 ms | +| Settled repeat 1 | 157 | 12.74% | 23 ms | 40 ms | 73 ms | +| Settled repeat 2 | 156 | 9.62% | 23 ms | 38 ms | 73 ms | +| Settled repeat 3 | 155 | 10.97% | 23 ms | 38 ms | 81 ms | + +IMPORTANT: later screenshot comparisons exposed a retained, unmoving mini-player +frame. Android audio diagnostics recorded playback stopping at 14:56:50, before +the settled repeats at 15:08-15:09. The repeats' combined 11.1% is a guide-only +result, NOT a valid live-playback acceptance score. The initial runs were not +continuously checked for video motion either and must not establish a live-video +pass. The under-10% target remains unmet. + +After restarting the app, cached guide queries reported 18/18 matched rows in +18-65 ms (one initial query 191 ms), but stream preparation logged live URLs on +the FlixStreams addon host and video did not restart. Existing playlist data was +not changed. These query timings are not total TV-page launch times, nor proof +that every provider channel has guide coverage. + +Method sampling identifies accessibility semantics/coordinate traversal as a +remaining cost. Trials of consolidated channel semantics, canvas channel labels, +and full package compilation did not demonstrate useful gains and were not kept. +The existing launcher accessibility service remained enabled throughout. Further +live-playback measurements require a working stream and frame-motion checks; +do not advertise this candidate as sub-10% or lag-free.