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..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 @@ -42,16 +49,24 @@ 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 }) } @@ -62,22 +77,87 @@ 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) 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() + 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() { + 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 liveProgrammeHasOneAccessibleEntryWithAnAction() { + showGuide() + 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 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) } 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() + } + + @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/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 6c6e07745..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 @@ -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 @@ -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 f6c201364..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,5 +1,8 @@ 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 import androidx.compose.animation.core.animateDpAsState @@ -20,8 +23,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 @@ -49,6 +54,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 @@ -143,13 +149,21 @@ 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) } 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 @@ -234,15 +248,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) @@ -320,15 +337,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.toList()) { + 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 @@ -358,7 +407,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 @@ -366,6 +415,27 @@ 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)) + } + delay(50L) + runCatching { selectedCategoryFocusRequester.requestFocus() } + } + } + Column( modifier = modifier .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) @@ -376,6 +446,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. @@ -413,6 +484,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, @@ -453,234 +531,356 @@ 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 - SidebarRow( - label = liveCategoryLabel(cat.label), - count = cat.count, - icon = iconFor(cat), - active = selectedId == cat.id, - 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 - }, - onFocused = { onCategoryFocused() }, - onClick = { - if (isAllGroup) { - expandedAll = !expandedAll - } - onSelect(cat.id) - }, - ) - if (isOpen && expanded) { - cat.children.forEach { child -> - 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 = if (selectedId == child.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, - onClick = { onSelect(child.id) }, - ) - if (child.containsId(selectedId)) { - child.children.forEach { grandchild -> - 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 = if (selectedId == grandchild.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, - onClick = { onSelect(grandchild.id) }, - ) - } - } - } - } - } - if (playlistSections.isNotEmpty()) { - playlistSections.forEach { section -> - item(key = "playlist-section:${section.id}") { - val isOpen = section.id in expandedPlaylistIds - SidebarRow( - label = section.label, - count = section.count, - icon = Icons.Filled.LibraryBooks, - active = section.categories.any { it.containsId(selectedId) }, - expanded = expanded, - hasChildren = true, - isOpenGroup = isOpen, - onFocused = { 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 -> - SidebarRow( - label = liveCategoryLabel(cat.label), - count = cat.count, - icon = iconFor(cat), - active = selectedId == cat.id, - expanded = true, - indent = 28.dp, - focusRequester = if (selectedId == cat.id) selectedCategoryFocusRequester else null, - onFocused = { 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 -> + 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 = 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, - focusRequester = if (selectedId == cat.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, - locked = isCategoryLocked(cat), - onLongClick = { - openCategoryMenu(cat, hidden = false) + expanded = contentVisible, + hasChildren = isAllGroup, + isOpenGroup = isOpen, + focusRequester = requester, + onFocused = { + lastFocusedCategoryKey = itemKey + onCategoryFocused() }, - 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 - 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 = if (selectedId == country.id) selectedCategoryFocusRequester else null, - onFocused = { 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 -> + if (isOpen && contentVisible) { + 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 = 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, - focusRequester = if (selectedId == child.id) selectedCategoryFocusRequester else null, - onFocused = { onCategoryFocused() }, + 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 = 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" }) { _, cat -> - 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() }, - 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 isSectionSelected = section.id == selectedId || + (!isOpen && section.categories.any { it.containsId(selectedId) }) + val sectionRequester = rememberCategoryRequester( + key = sectionKey, + id = section.id, + selectedId = if (isSectionSelected) section.id else 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.playlistGroupName ?: 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) }, + ) + } } } } @@ -802,6 +1002,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( @@ -835,6 +1068,10 @@ private fun SidebarRow( focused -> LiveColors.Panel else -> Color.Transparent } + val surface = animateColorAsState( + if (focused) LiveColors.PanelRaised else bg, + animationSpec = tween(120), label = "category-surface", + ) Box( modifier = Modifier .fillMaxWidth() @@ -853,19 +1090,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) + .drawBehind { drawRect(surface.value) } .onPreviewKeyEvent { ev -> val isSelect = ev.key == Key.DirectionCenter || ev.key == Key.Enter when { @@ -1148,3 +1385,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/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/ChannelRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/ChannelRow.kt index 5f087cb1e..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 @@ -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 @@ -32,6 +35,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 @@ -98,49 +102,62 @@ 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 } val now = nowNext?.now - val animatedBorderWidth by animateDpAsState( - targetValue = if (visuallyFocused) 3.dp else 0.dp, + val animatedBorderWidth = animateDpAsState( + 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 = 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() } .drawWithContent { + 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) { - 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(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), ) } } - .background(if (visuallyFocused) LiveColors.PanelRaised else 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 @@ -202,15 +219,14 @@ fun ChannelRow( Box( modifier = Modifier .fillMaxHeight() - .width(LiveDims.ActiveIndicator) - .background(if (isActive) LiveColors.Accent else Color.Transparent), + .width(LiveDims.ActiveIndicator), ) // ─ 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( @@ -222,13 +238,15 @@ 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( - 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 c71d07f49..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 @@ -56,8 +56,11 @@ 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.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 @@ -106,6 +109,7 @@ fun EpgGrid( isGuideBackfillLoading: Boolean = false, hasGuideSource: Boolean = true, selectedChannelId: String?, + playingChannelId: String? = null, focusSelectedChannelSignal: Int, focusEpgSignal: Int = 0, focusMode: EpgGridFocusMode = EpgGridFocusMode.ChannelList, @@ -113,6 +117,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, @@ -204,14 +209,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() @@ -232,26 +242,35 @@ 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 { - val channel = channels.getOrNull(rowIdx) ?: return true + fun requestNearestProgramFocus(rowIdx: Int, anchorStartMin: Int, preferLive: Boolean = false): Boolean { + 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 { - // 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 targetIdx = nearestProgramIndex(rowIdx, anchorStartMin, preferLive) val requester = targetIdx?.let { programFocusRequesters[channel.id]?.getOrNull(it) } if (requester != null && runCatching { requester.requestFocus() }.isSuccess) { return@launch @@ -268,8 +287,15 @@ fun EpgGrid( activeChannelFocusId = channel.id activeChannelFocusIndex = rowIdx 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) @@ -294,17 +320,22 @@ 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 } + // 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) } } @@ -325,7 +356,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 } @@ -338,7 +373,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 @@ -434,10 +470,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), ) } @@ -450,18 +489,29 @@ 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 { - slots.forEach { slot -> + val rulerWidthPx = with(density) { maxWidth.toPx() } + 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( @@ -477,7 +527,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), @@ -546,6 +607,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 { @@ -554,16 +620,34 @@ 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) + // Keep accessibility geometry sorting local to each guide row. + .semantics { isTraversalGroup = true } ) { + 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 +666,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, preferLive = true) } else { keepChannelFocus(idx) } @@ -603,7 +687,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 (isFocusAnchor) { + Modifier.focusRequester(selectedChannelFocusRequester) + } else Modifier + ), ) // 2. Vertical Divider @@ -621,14 +709,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,22 +736,36 @@ fun EpgGrid( totalWidth = totalWidth, pxPerMin = pxPerMin, stripe = idx % 2 == 1, - isActive = ch.id == selectedChannelId && focusMode == EpgGridFocusMode.Epg, + isActive = false, epgMode = focusMode == EpgGridFocusMode.Epg, rowHeight = rowHeight, renderWindow = renderWindow, + hScrollOffsetPx = { hScroll.value }, onClick = { program -> onExitEpg(ch) onProgramSelect(ch, program) keepChannelFocus(idx) }, - onFocused = { + onFocused = { program -> if (focusMode == EpgGridFocusMode.Epg) { onChannelFocused(ch) + onProgramFocused(ch, program) } }, 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) @@ -734,8 +828,9 @@ private fun ProgramsRow( epgMode: Boolean, rowHeight: Dp, renderWindow: GuideRenderWindow, + hScrollOffsetPx: () -> Int = { 0 }, onClick: (IptvProgram?) -> Unit, - onFocused: () -> Unit, + onFocused: (IptvProgram) -> Unit, onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, onMoveLeftFromStart: () -> Boolean, rowIdx: Int, @@ -743,17 +838,13 @@ private fun ProgramsRow( focusTargets: MutableMap>, ) { val nowMillis = clockTickMillis + val density = LocalDensity.current Box( modifier = Modifier .width(totalWidth) .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 @@ -765,8 +856,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 @@ -780,10 +870,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), + ) } } } @@ -808,6 +902,9 @@ 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 maxShiftPx = (cellWidthPx - with(density) { 50.dp.toPx() }).coerceAtLeast(0f) val isCatchupSupported = placement.isCatchupSupported(channel, nowMillis) val focusableIndex = focusableIndexByPlacementIndex[placementIndex] ?: -1 val isFocusable = focusableIndex >= 0 @@ -821,8 +918,11 @@ private fun ProgramsRow( isNow = placementIsNow, isPast = placementIsPast, isFocusTarget = placementIsNow, - focusable = isFocusable, + focusable = isFocusable && epgMode, isCatchupSupported = isCatchupSupported, + contentStartOffsetPx = { + (hScrollOffsetPx() - cellOffsetPx).coerceIn(0f, maxShiftPx).toInt() + }, onClick = { epgProgramActionTarget( program = placement.program, @@ -831,7 +931,7 @@ private fun ProgramsRow( isCatchupSupported = isCatchupSupported, )?.let(onClick) }, - onFocused = onFocused, + onFocused = { onFocused(placement.program) }, onMoveLeft = { if (focusableIndex > 0) { runCatching { rowFocusRequesters[focusableIndex - 1].requestFocus() } @@ -845,7 +945,7 @@ private fun ProgramsRow( runCatching { rowFocusRequesters[focusableIndex + 1].requestFocus() } true } else { - false + true } }, onMoveUp = { @@ -945,7 +1045,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 @@ -986,6 +1086,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/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/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 e11996c53..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 @@ -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 @@ -559,7 +562,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" @@ -721,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) { @@ -884,9 +889,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" @@ -950,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, @@ -960,7 +968,7 @@ fun LiveTvScreen( tree = tree, favorites = favoriteOrderIds, recents = recents.value.toList().asReversed(), - startupAnchorId = null, + startupAnchorId = startupAnchorId, excludedGroups = hiddenGroupSet + restrictedGroupSet, ) } @@ -1094,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. @@ -1144,6 +1156,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 } @@ -1530,7 +1545,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 { @@ -1624,6 +1639,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 @@ -1711,6 +1733,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) } @@ -1728,10 +1755,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(), @@ -1827,6 +1854,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 @@ -1873,7 +1901,9 @@ fun LiveTvScreen( } focusZone = LiveTvFocusZone.CHANNEL_LIST focusSelectedChannelSignal += 1 - runCatching { epgFocus.requestFocus() } + if (channelId == null) { + runCatching { epgFocus.requestFocus() } + } } fun focusEpg(channelId: String) { @@ -1887,7 +1917,6 @@ fun LiveTvScreen( } focusZone = LiveTvFocusZone.EPG focusEpgSignal += 1 - runCatching { epgFocus.requestFocus() } } fun enterSelectedCategory(categoryId: String) { @@ -1897,6 +1926,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) { @@ -1967,11 +2010,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) + } } } @@ -2719,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 @@ -2734,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 } @@ -2761,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) @@ -3096,6 +3149,7 @@ fun LiveTvScreen( ) } MiniPlayerRow( + focusedProgramme = focusedProgramme.takeIf { focusZone == LiveTvFocusZone.EPG }, exoPlayer = exoPlayer, channel = playingDisplayChannel, clockTickMillis = guideClockMillis, @@ -3107,6 +3161,7 @@ fun LiveTvScreen( onOpenVariants = playingChannel?.let { channel -> { openVariantPicker(channel) } }, compact = true, landscapeCompact = landscapeCompactMiniPlayer, + playerActive = miniPlayerActive, modifier = Modifier.fillMaxWidth(), ) TouchCategoryRail( @@ -3130,6 +3185,7 @@ fun LiveTvScreen( isGuideBackfillLoading = false, hasGuideSource = state.hasPotentialGuideSource, selectedChannelId = selectedDisplayChannelId, + playingChannelId = playingDisplayChannelId ?: playingChannelId, focusSelectedChannelSignal = focusSelectedChannelSignal, focusEpgSignal = focusEpgSignal, focusMode = if (focusZone == LiveTvFocusZone.EPG) { @@ -3150,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) }, @@ -3202,7 +3259,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) @@ -3245,6 +3308,7 @@ fun LiveTvScreen( ) } MiniPlayerRow( + focusedProgramme = focusedProgramme.takeIf { focusZone == LiveTvFocusZone.EPG }, exoPlayer = exoPlayer, channel = playingDisplayChannel, clockTickMillis = guideClockMillis, @@ -3255,6 +3319,7 @@ fun LiveTvScreen( variantCount = playingChannel?.let { variantCountFor(it, variantGroups) } ?: 1, onOpenVariants = playingChannel?.let { channel -> { openVariantPicker(channel) } }, compact = compactTouchLayout, + playerActive = miniPlayerActive, modifier = Modifier.fillMaxWidth(), ) EpgGrid( @@ -3268,6 +3333,7 @@ fun LiveTvScreen( isGuideBackfillLoading = false, hasGuideSource = state.hasPotentialGuideSource, selectedChannelId = selectedDisplayChannelId, + playingChannelId = playingDisplayChannelId ?: playingChannelId, focusSelectedChannelSignal = focusSelectedChannelSignal, focusEpgSignal = focusEpgSignal, focusMode = if (focusZone == LiveTvFocusZone.EPG) { @@ -3287,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) }, @@ -3300,11 +3367,6 @@ fun LiveTvScreen( channelColumnWidthOverride = guideChannelColumnWidth, modifier = Modifier .fillMaxSize() - .onFocusChanged { - if (it.hasFocus && focusZone == LiveTvFocusZone.CATEGORY_LIST) { - focusZone = LiveTvFocusZone.CHANNEL_LIST - } - } .then(if (!isTouchDevice) Modifier.focusRequester(epgFocus) else Modifier), ) } @@ -3313,48 +3375,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..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 @@ -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,9 @@ fun MiniPlayerRow( onOpenVariants: (() -> Unit)? = null, compact: Boolean = false, landscapeCompact: Boolean = false, + playerActive: Boolean = true, + focusedProgramme: Pair? = null, + onVideoBoundsPositioned: ((Rect) -> Unit)? = null, modifier: Modifier = Modifier, ) { if (landscapeCompact) { @@ -120,9 +128,12 @@ fun MiniPlayerRow( exoPlayer = exoPlayer, channel = channel, landscapeCompact = true, + playerActive = playerActive, onFullscreenClick = onFullscreenClick, + onVideoBoundsPositioned = onVideoBoundsPositioned, ) InfoColumn( + focusedProgramme = focusedProgramme, channel = channel, clockTickMillis = clockTickMillis, nowNext = nowNext, @@ -147,10 +158,13 @@ fun MiniPlayerRow( exoPlayer = exoPlayer, channel = channel, compact = true, + playerActive = playerActive, onFullscreenClick = onFullscreenClick, + onVideoBoundsPositioned = onVideoBoundsPositioned, modifier = Modifier.fillMaxWidth(), ) InfoColumn( + focusedProgramme = focusedProgramme, channel = channel, clockTickMillis = clockTickMillis, nowNext = nowNext, @@ -172,9 +186,12 @@ fun MiniPlayerRow( VideoCard( exoPlayer = exoPlayer, channel = channel, + playerActive = playerActive, onFullscreenClick = onFullscreenClick, + onVideoBoundsPositioned = onVideoBoundsPositioned, ) InfoColumn( + focusedProgramme = focusedProgramme, channel = channel, clockTickMillis = clockTickMillis, nowNext = nowNext, @@ -195,13 +212,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 +239,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 +278,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) { @@ -320,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?, @@ -335,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, @@ -459,10 +523,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 530c5142d..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 @@ -1,6 +1,8 @@ package com.arflix.tv.ui.screens.tv.live -import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.ui.layout.layout + import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -29,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 @@ -43,6 +46,11 @@ 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.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 @@ -76,11 +84,19 @@ fun ProgramCell( onMoveUp: () -> Boolean = { false }, onMoveDown: () -> Boolean = { false }, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, + contentStartOffsetPx: () -> Int = { 0 }, focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, ) { 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) } @@ -88,42 +104,21 @@ fun ProgramCell( isNow -> LiveColors.FocusBg else -> LiveColors.Panel } - val bg = if (focused) LiveColors.PanelRaised else baseBg + val bg = 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) else -> Color.Transparent } - val borderWidth = if (focusable) { - val animated by animateDpAsState( - targetValue = if (focused) 3.dp else 1.dp, - animationSpec = tween(durationMillis = 80), - label = "program-cell-border", - ) - animated - } else { - if (focused) 3.dp else 1.dp - } - val scale = if (focusable) { - val animated by animateFloatAsState( - targetValue = if (focused) 1.008f else 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) @@ -133,10 +128,9 @@ fun ProgramCell( // 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 { - scaleX = scale - scaleY = scale - } + .then(if (isPast && !isCatchupSupported) Modifier.graphicsLayer { + alpha = contentAlpha.value + } else Modifier) .then( if (focusable && focusRequester != null) { Modifier.focusRequester(focusRequester) @@ -159,9 +153,10 @@ fun ProgramCell( color = borderColor, shape = RoundedCornerShape(LiveDims.CellRadius), ) - .clip(RoundedCornerShape(LiveDims.CellRadius)) - .background(bg) - .alpha(contentAlpha) + .drawBehind { + val radius = LiveDims.CellRadius.toPx() + drawRoundRect(bg.value, cornerRadius = CornerRadius(radius)) + } .then(if (focusable) Modifier.focusable() else Modifier) .then( if (focusable) { @@ -190,32 +185,60 @@ fun ProgramCell( Modifier } ) - .padding(horizontal = 6.dp, vertical = 4.dp), + // 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) { 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(), + modifier = Modifier + .fillMaxSize() + // 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) { @@ -228,34 +251,34 @@ fun ProgramCell( } Text( text = program.title, - style = LiveType.CellTitle.copy(color = LiveColors.Fg, fontSize = 11.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 = 9.sp), + style = LiveType.BodySynopsis.copy(color = LiveColors.FgDim, fontSize = 8.sp, lineHeight = 10.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - Row( + if (width >= 120.dp) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { 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), ) } } @@ -270,8 +293,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)) } } 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)) } 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 { 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..f8a19b321 --- /dev/null +++ b/docs/iptv-scroll-performance-2026-09-08.md @@ -0,0 +1,123 @@ +# 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. + +## 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. + +## 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.