From 3e293a5f813fb7c870f80eac3b0fd2adabee4e72 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sat, 16 May 2026 08:42:31 +0530 Subject: [PATCH 1/4] feat: implement Catchup TV support (IPTV Archive) - Extended IptvChannel and Xtream models with catchup metadata. - Updated IptvRepository to parse catchup tags from M3U and Xtream sources. - Implemented getCatchupUrl for Xtream, Flussonic, and M3U timeshift modes. - Extended EPG retention to store up to 7 days of historical programs. - Added 'Day Selector' to EpgGrid for jumping back in time. - Updated ProgramCell and LiveTvScreen to support archive playback flow. --- .../com/arflix/tv/data/model/IptvModels.kt | 5 +- .../tv/data/repository/IptvRepository.kt | 97 +++++++++++++++++-- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 81 ++++++++++++++-- .../tv/ui/screens/tv/live/LiveCategory.kt | 1 + .../tv/ui/screens/tv/live/LiveTvScreen.kt | 36 +++++-- .../tv/ui/screens/tv/live/ProgramCell.kt | 3 +- 6 files changed, 194 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt index abf4ad5d4..f056a5b95 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt @@ -13,7 +13,10 @@ data class IptvChannel( val logo: String? = null, val epgId: String? = null, val rawTitle: String = name, - val xtreamStreamId: Int? = null + val xtreamStreamId: Int? = null, + val catchupDays: Int = 0, + val catchupType: String? = null, + val catchupSource: String? = null ) /** diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 0094dfcd1..df995d4ad 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -578,6 +578,55 @@ class IptvRepository @Inject constructor( return "$safeBase/xmltv.php?username=$u&password=$p" } + fun getCatchupUrl(channel: IptvChannel, program: IptvProgram): String { + val startUnix = program.startUtcMillis / 1000L + val endUnix = program.endUtcMillis / 1000L + val durationMin = (program.endUtcMillis - program.startUtcMillis) / 60_000L + + return when (channel.catchupType?.lowercase(Locale.US)) { + "xtream" -> { + val creds = resolveXtreamCredentials(channel.streamUrl) ?: return channel.streamUrl + val startDt = LocalDateTime.ofInstant(Instant.ofEpochMilli(program.startUtcMillis), ZoneId.of("UTC")) + val startStr = startDt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd:HH-mm")) + "${creds.baseUrl}/timeshift/${creds.username}/${creds.password}/$durationMin/$startStr/${channel.xtreamStreamId}.ts" + } + "flussonic", "ts" -> { + val connector = if (channel.streamUrl.contains("?")) "&" else "?" + "${channel.streamUrl}${connector}utc=$startUnix" + } + "append", "shift" -> { + val connector = if (channel.streamUrl.contains("?")) "&" else "?" + "${channel.streamUrl}${connector}utc=$startUnix&lutc=$endUnix" + } + "default", "source" -> { + val source = channel.catchupSource ?: return channel.streamUrl + val startDt = LocalDateTime.ofInstant(Instant.ofEpochMilli(program.startUtcMillis), ZoneId.of("UTC")) + source + .replace("{Y}", startDt.format(DateTimeFormatter.ofPattern("yyyy"))) + .replace("{m}", startDt.format(DateTimeFormatter.ofPattern("MM"))) + .replace("{d}", startDt.format(DateTimeFormatter.ofPattern("dd"))) + .replace("{H}", startDt.format(DateTimeFormatter.ofPattern("HH"))) + .replace("{M}", startDt.format(DateTimeFormatter.ofPattern("mm"))) + .replace("{S}", startDt.format(DateTimeFormatter.ofPattern("ss"))) + } + else -> { + // If catchup-source is present but type is unknown, try placeholder replacement anyway + if (!channel.catchupSource.isNullOrBlank()) { + val startDt = LocalDateTime.ofInstant(Instant.ofEpochMilli(program.startUtcMillis), ZoneId.of("UTC")) + channel.catchupSource + .replace("{Y}", startDt.format(DateTimeFormatter.ofPattern("yyyy"))) + .replace("{m}", startDt.format(DateTimeFormatter.ofPattern("MM"))) + .replace("{d}", startDt.format(DateTimeFormatter.ofPattern("dd"))) + .replace("{H}", startDt.format(DateTimeFormatter.ofPattern("HH"))) + .replace("{M}", startDt.format(DateTimeFormatter.ofPattern("mm"))) + .replace("{S}", startDt.format(DateTimeFormatter.ofPattern("ss"))) + } else { + channel.streamUrl + } + } + } + } + suspend fun clearConfig() { context.settingsDataStore.edit { prefs -> prefs.remove(m3uUrlKey()) @@ -1753,7 +1802,9 @@ class IptvRepository @Inject constructor( val name: String? = null, @SerializedName("stream_icon") val streamIcon: String? = null, @SerializedName("epg_channel_id") val epgChannelId: String? = null, - @SerializedName("category_id") val categoryId: String? = null + @SerializedName("category_id") val categoryId: String? = null, + @SerializedName("tv_archive") val tvArchive: Int? = null, + @SerializedName("tv_archive_duration") val tvArchiveDuration: Int? = null ) private data class XtreamVodStream( @@ -3573,14 +3624,24 @@ class IptvRepository @Inject constructor( private fun resolveXtreamCredentials(url: String): XtreamCredentials? { if (url.isBlank()) return null val parsed = url.toHttpUrlOrNull() ?: return null - val username = parsed.queryParameter("username")?.trim()?.ifBlank { null } + var username = parsed.queryParameter("username")?.trim()?.ifBlank { null } ?: parsed.queryParameter("user")?.trim()?.ifBlank { null } ?: parsed.queryParameter("uname")?.trim()?.ifBlank { null } ?: "" - val password = parsed.queryParameter("password")?.trim()?.ifBlank { null } + var password = parsed.queryParameter("password")?.trim()?.ifBlank { null } ?: parsed.queryParameter("pass")?.trim()?.ifBlank { null } ?: parsed.queryParameter("pwd")?.trim()?.ifBlank { null } ?: "" + + // Try extracting from path if query params are missing (common for /live/user/pass/id format) + if (username.isBlank() || password.isBlank()) { + val segments = parsed.pathSegments + if (segments.size >= 4) { + username = segments[segments.size - 3] + password = segments[segments.size - 2] + } + } + if (username.isBlank() || password.isBlank()) return null // Accept any URL with username/password params; derive baseUrl from scheme+host+port val path = parsed.encodedPath.lowercase(Locale.US) @@ -3683,7 +3744,9 @@ class IptvRepository @Inject constructor( logo = stream.streamIcon?.takeIf { it.isNotBlank() }, epgId = stream.epgChannelId?.trim()?.takeIf { it.isNotBlank() }, rawTitle = name, - xtreamStreamId = streamId + xtreamStreamId = streamId, + catchupDays = (stream.tvArchive ?: stream.tvArchiveDuration ?: 0).coerceAtLeast(0), + catchupType = if ((stream.tvArchive ?: 0) > 0 || (stream.tvArchiveDuration ?: 0) > 0) "xtream" else null ) } } @@ -4315,6 +4378,9 @@ class IptvRepository @Inject constructor( val channelName = extractChannelName(metadata) val groupTitle = extractAttr(metadata, "group-title")?.takeIf { it.isNotBlank() } ?: "Uncategorized" val logo = extractAttr(metadata, "tvg-logo") + val catchupType = extractAttr(metadata, "catchup") + val catchupDays = extractAttr(metadata, "catchup-days")?.toIntOrNull() ?: 0 + val catchupSource = extractAttr(metadata, "catchup-source") channels += IptvChannel( id = id, @@ -4323,7 +4389,10 @@ class IptvRepository @Inject constructor( group = groupTitle, logo = logo, epgId = epgId, - rawTitle = metadata ?: channelName + rawTitle = metadata ?: channelName, + catchupDays = catchupDays, + catchupType = catchupType, + catchupSource = catchupSource ) parsedCount++ if (parsedCount % 5000 == 0) { @@ -4343,7 +4412,10 @@ class IptvRepository @Inject constructor( if (channels.isEmpty()) return emptyMap() val nowUtc = System.currentTimeMillis() - val recentCutoff = nowUtc - (30 * 60_000L) // Keep programs that ended within past 30 min + val maxCatchupDays = channels.maxOfOrNull { it.catchupDays } ?: 0 + val catchupCutoff = nowUtc - (maxCatchupDays.coerceAtMost(7) * 24 * 60 * 60_000L) + val recentCutoff = minOf(nowUtc - (30 * 60_000L), catchupCutoff) + val keyLookup = buildChannelKeyLookup(channels) val xmlChannelNameMap = mutableMapOf>() val nowCandidates = mutableMapOf() @@ -4427,7 +4499,8 @@ class IptvRepository @Inject constructor( addUpcomingCandidate(future, program, limit = epgUpcomingProgramLimit) } else if (program.endUtcMillis <= nowUtc && program.endUtcMillis > recentCutoff) { val recent = recentCandidates.getOrPut(channel.id) { mutableListOf() } - if (recent.size < epgRecentProgramLimit) recent.add(program) + val limit = if (channel.catchupDays > 0) 1000 else epgRecentProgramLimit + if (recent.size < limit) recent.add(program) } } currentChannelKey = null @@ -4457,13 +4530,16 @@ class IptvRepository @Inject constructor( ): Map { if (channels.isEmpty()) return emptyMap() + val nowUtc = System.currentTimeMillis() + val maxCatchupDays = channels.maxOfOrNull { it.catchupDays } ?: 0 + val catchupCutoff = nowUtc - (maxCatchupDays.coerceAtMost(7) * 24 * 60 * 60_000L) + val recentCutoff = minOf(nowUtc - (30 * 60_000L), catchupCutoff) + val keyLookup = buildChannelKeyLookup(channels) val xmlChannelNameMap = mutableMapOf>() val nowCandidates = mutableMapOf() val upcomingCandidates = mutableMapOf>() val recentCandidates = mutableMapOf>() - val nowUtc = System.currentTimeMillis() - val recentCutoff = nowUtc - (30 * 60_000L) // Keep programs that ended within past 30 min val factory = SAXParserFactory.newInstance().apply { isNamespaceAware = false @@ -4576,7 +4652,8 @@ class IptvRepository @Inject constructor( } else if (program.endUtcMillis <= nowUtc && program.endUtcMillis > recentCutoff) { // Recently ended program – keep for the past-window in the EPG guide val recent = recentCandidates.getOrPut(channel.id) { mutableListOf() } - if (recent.size < epgRecentProgramLimit) recent.add(program) + val limit = if (channel.catchupDays > 0) 1000 else epgRecentProgramLimit + if (recent.size < limit) recent.add(program) } } currentChannelKey = null 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 4ceb5da19..9407f80e5 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 @@ -85,7 +85,7 @@ fun EpgGrid( nowNext: Map, selectedChannelId: String?, focusSelectedChannelSignal: Int, - onChannelSelect: (EnrichedChannel) -> Unit, + onChannelSelect: (EnrichedChannel, IptvProgram?) -> Unit, onChannelFocused: (EnrichedChannel) -> Unit = {}, onChannelFavoriteToggle: (String) -> Unit, favorites: Set, @@ -105,7 +105,7 @@ fun EpgGrid( // Window: now − 30 min → now + 2 h = 2.5 h total. // Past is limited to 30 min so most of the ruler is future programmes // (what you're about to watch), not what already aired. - val windowStartMillis = remember { roundedWindowStart() } + var windowStartMillis by remember { mutableStateOf(roundedWindowStart()) } val windowEndMillis = remember(windowStartMillis) { windowStartMillis + EpgWindowMinutes * 60L * 1000L } @@ -197,9 +197,65 @@ fun EpgGrid( runCatching { selectedChannelFocusRequester.requestFocus() } } + val days = remember { + val base = roundedWindowStart() + (0..6).map { i -> + val start = base - i * 24 * 60 * 60_000L + val label = when (i) { + 0 -> "TODAY" + 1 -> "YESTERDAY" + else -> { + val cal = java.util.Calendar.getInstance() + cal.timeInMillis = start + cal.getDisplayName(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.SHORT, java.util.Locale.US)?.uppercase() ?: "DAY" + } + } + EpgDay(label, start) + } + } + var selectedDayIdx by remember { mutableIntStateOf(0) } + LaunchedEffect(selectedDayIdx) { + windowStartMillis = days[selectedDayIdx].startMillis + } + Column( modifier = modifier.fillMaxSize().background(LiveColors.Bg), ) { + // ─── Day Selector ─────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .background(LiveColors.PanelDeep) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("ARCHIVE", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) + days.forEachIndexed { idx, day -> + val isSelected = selectedDayIdx == idx + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(if (isSelected) LiveColors.Accent else LiveColors.Panel) + .border(1.dp, if (isSelected) LiveColors.Accent else LiveColors.Divider, RoundedCornerShape(4.dp)) + .pointerInput(Unit) { detectTapGestures { selectedDayIdx = idx } } + .focusable() + .onKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && (ev.key == Key.DirectionCenter || ev.key == Key.Enter)) { + selectedDayIdx = idx; true + } else false + } + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = day.label, + style = LiveType.Badge.copy(color = if (isSelected) LiveColors.Bg else LiveColors.Fg) + ) + } + } + } + // ─── Header row ───────────────────────────────────────────── Row( modifier = Modifier @@ -310,7 +366,7 @@ fun EpgGrid( nowNext = nowNext[ch.id], isFavorite = ch.id in favorites, stripe = idx % 2 == 1, - onClick = { onChannelSelect(ch) }, + onClick = { onChannelSelect(ch, null) }, onFocused = { onChannelFocused(ch) }, onMoveLeft = onMoveLeftFromChannels, onFavoriteToggle = { onChannelFavoriteToggle(ch.id) }, @@ -368,7 +424,7 @@ fun EpgGrid( stripe = idx % 2 == 1, isActive = ch.id == selectedChannelId, rowHeight = rowHeight, - onClick = { onChannelSelect(ch) }, + onClick = { program -> onChannelSelect(ch, program) }, onFocused = { onChannelFocused(ch) }, ) } @@ -399,7 +455,7 @@ private fun ProgramsRow( stripe: Boolean, isActive: Boolean, rowHeight: Dp, - onClick: () -> Unit, + onClick: (IptvProgram?) -> Unit, onFocused: () -> Unit, ) { val nowMillis = clockTickMillis @@ -423,6 +479,8 @@ private fun ProgramsRow( placements.forEach { placement -> val offset = (placement.startMin * pxPerMin).dp val width = (placement.durationMin * pxPerMin).dp + val isCatchupSupported = channel.catchupDays > 0 && + placement.program.startUtcMillis >= System.currentTimeMillis() - channel.catchupDays * 24 * 60 * 60_000L ProgramCell( program = placement.program, clockTickMillis = clockTickMillis, @@ -430,8 +488,15 @@ private fun ProgramsRow( isNow = placement.isNow, isPast = placement.isPast, isFocusTarget = placement.isNow, - focusable = false, - onClick = onClick, + focusable = !placement.isPast || isCatchupSupported, + isCatchupSupported = isCatchupSupported, + onClick = { + if (placement.isPast && isCatchupSupported) { + onClick(placement.program) + } else if (!placement.isPast) { + onClick(null) + } + }, onFocused = onFocused, rowHeight = rowHeight, modifier = Modifier.offset(x = offset), @@ -472,6 +537,8 @@ private fun NowLine( private data class TimeSlot(val millis: Long, val label: String, val isNow: Boolean) +private data class EpgDay(val label: String, val startMillis: Long) + private fun buildHalfHourSlots(startMillis: Long, count: Int): List { val out = ArrayList(count) val now = System.currentTimeMillis() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt index 8aaadcde7..f29f80434 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt @@ -34,6 +34,7 @@ data class EnrichedChannel( val name: String get() = source.name val streamUrl: String get() = source.streamUrl val logo: String? get() = source.logo + val catchupDays: Int get() = source.catchupDays } data class LiveCategoryIndex( 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 710a6567c..8d99ccfc3 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 @@ -68,6 +68,7 @@ import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.arflix.tv.data.model.IptvChannel +import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.Profile import com.arflix.tv.ui.screens.tv.TvUiState import com.arflix.tv.ui.screens.tv.TvViewModel @@ -290,6 +291,7 @@ fun LiveTvScreen( // channel of the first non-empty category. var playingChannelId by rememberSaveable { mutableStateOf(initialChannelId) } var focusedChannelId by rememberSaveable { mutableStateOf(initialChannelId) } + var playingCatchupProgram by remember { mutableStateOf(null) } val playingChannel = remember(playingChannelId, enrichedState.value, filteredChannels) { playingChannelId?.let { enrichedState.value.index.byId[it] } ?: filteredChannels.firstOrNull { it.id == playingChannelId } @@ -463,7 +465,15 @@ fun LiveTvScreen( } // When the selected channel changes, swap media item. - val currentStreamUrl by rememberUpdatedState(playingChannel?.streamUrl ?: initialStreamUrl) + val currentStreamUrl = remember(playingChannel, playingCatchupProgram) { + val ch = playingChannel ?: return@remember initialStreamUrl + val pr = playingCatchupProgram + if (pr != null) { + viewModel.iptvRepository.getCatchupUrl(ch.source, pr) + } else { + ch.streamUrl + } + } val openFullScreenPlayer = remember(playingChannelId, currentStreamUrl) { { if (playingChannelId != null || currentStreamUrl != null) { @@ -478,11 +488,15 @@ fun LiveTvScreen( exoPlayer.setMediaItem( MediaItem.Builder() .setUri(stream) - .setLiveConfiguration( - MediaItem.LiveConfiguration.Builder() - .setMinPlaybackSpeed(1.0f).setMaxPlaybackSpeed(1.0f) - .setTargetOffsetMs(4_000).build() - ) + .apply { + if (playingCatchupProgram == null) { + setLiveConfiguration( + MediaItem.LiveConfiguration.Builder() + .setMinPlaybackSpeed(1.0f).setMaxPlaybackSpeed(1.0f) + .setTargetOffsetMs(4_000).build() + ) + } + } .build() ) exoPlayer.prepare() @@ -634,12 +648,13 @@ fun LiveTvScreen( focusSelectedChannelSignal = focusSelectedChannelSignal, compact = true, gridFocused = focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel -> + onChannelSelect = { channel, program -> focusedChannelId = channel.id - if (channel.id == playingChannelId && !isFullScreen) { + if (channel.id == playingChannelId && playingCatchupProgram == program && !isFullScreen) { isFullScreen = true } else { playingChannelId = channel.id + playingCatchupProgram = program } }, onChannelFocused = { channel -> focusedChannelId = channel.id }, @@ -716,7 +731,7 @@ fun LiveTvScreen( focusSelectedChannelSignal = focusSelectedChannelSignal, compact = compactTouchLayout, gridFocused = focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel -> + onChannelSelect = { channel, program -> // Two-step activation: // 1st tap on a channel → tune it in the mini- // player so the user can preview without @@ -726,10 +741,11 @@ fun LiveTvScreen( // Picking a different channel while already full- // screen swaps the stream but keeps fullscreen. focusedChannelId = channel.id - if (channel.id == playingChannelId && !isFullScreen) { + if (channel.id == playingChannelId && playingCatchupProgram == program && !isFullScreen) { isFullScreen = true } else { playingChannelId = channel.id + playingCatchupProgram = program } }, onChannelFocused = { channel -> focusedChannelId = channel.id }, 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 5da131937..4db6f113f 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 @@ -59,6 +59,7 @@ fun ProgramCell( isPast: Boolean, isFocusTarget: Boolean, focusable: Boolean = true, + isCatchupSupported: Boolean = false, onClick: () -> Unit, onFocused: () -> Unit = {}, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, @@ -86,7 +87,7 @@ fun ProgramCell( label = "program-cell-scale", ) val contentAlpha by animateFloatAsState( - targetValue = if (isPast && !focused) 0.55f else 1f, + targetValue = if (isPast && !focused && !isCatchupSupported) 0.55f else 1f, animationSpec = tween(durationMillis = 150), label = "program-cell-alpha", ) From e5bcd5bc21e9286b923ade2ba98a446137c471bf Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sat, 16 May 2026 19:18:47 +0530 Subject: [PATCH 2/4] feat: enhance catchup TV functionality with recent program management and UI updates --- .../tv/data/repository/IptvRepository.kt | 133 +++++++-- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 9 +- .../tv/ui/screens/tv/live/ChannelRow.kt | 21 +- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 254 ++++++++++++------ .../tv/ui/screens/tv/live/LiveTvScreen.kt | 3 +- .../tv/ui/screens/tv/live/ProgramCell.kt | 24 +- .../repository/PreinstalledServicesTest.kt | 6 +- 7 files changed, 325 insertions(+), 125 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index df995d4ad..61197cbbf 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -205,6 +205,7 @@ class IptvRepository @Inject constructor( private val xtreamShortEpgConcurrency = 32 private val cacheUpcomingProgramLimit = 8 private val cacheRecentProgramLimit = 1 + private val catchupRecentProgramLimit = 1000 private val xtreamVodCacheMs = 6 * 60 * 60_000L private val iptvHttpClient: OkHttpClient by lazy { // Used for full playlist/EPG loading – generous timeouts for large @@ -581,14 +582,15 @@ class IptvRepository @Inject constructor( fun getCatchupUrl(channel: IptvChannel, program: IptvProgram): String { val startUnix = program.startUtcMillis / 1000L val endUnix = program.endUtcMillis / 1000L - val durationMin = (program.endUtcMillis - program.startUtcMillis) / 60_000L + val durationMin = ((program.endUtcMillis - program.startUtcMillis) / 60_000L).coerceAtLeast(1L) return when (channel.catchupType?.lowercase(Locale.US)) { "xtream" -> { val creds = resolveXtreamCredentials(channel.streamUrl) ?: return channel.streamUrl + val streamId = channel.xtreamStreamId ?: return channel.streamUrl val startDt = LocalDateTime.ofInstant(Instant.ofEpochMilli(program.startUtcMillis), ZoneId.of("UTC")) val startStr = startDt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd:HH-mm")) - "${creds.baseUrl}/timeshift/${creds.username}/${creds.password}/$durationMin/$startStr/${channel.xtreamStreamId}.ts" + "${creds.baseUrl}/timeshift/${creds.username}/${creds.password}/$durationMin/$startStr/$streamId.ts" } "flussonic", "ts" -> { val connector = if (channel.streamUrl.contains("?")) "&" else "?" @@ -602,6 +604,9 @@ class IptvRepository @Inject constructor( val source = channel.catchupSource ?: return channel.streamUrl val startDt = LocalDateTime.ofInstant(Instant.ofEpochMilli(program.startUtcMillis), ZoneId.of("UTC")) source + .replace("{utc}", startUnix.toString()) + .replace("{lutc}", endUnix.toString()) + .replace("{duration}", durationMin.toString()) .replace("{Y}", startDt.format(DateTimeFormatter.ofPattern("yyyy"))) .replace("{m}", startDt.format(DateTimeFormatter.ofPattern("MM"))) .replace("{d}", startDt.format(DateTimeFormatter.ofPattern("dd"))) @@ -614,6 +619,9 @@ class IptvRepository @Inject constructor( if (!channel.catchupSource.isNullOrBlank()) { val startDt = LocalDateTime.ofInstant(Instant.ofEpochMilli(program.startUtcMillis), ZoneId.of("UTC")) channel.catchupSource + .replace("{utc}", startUnix.toString()) + .replace("{lutc}", endUnix.toString()) + .replace("{duration}", durationMin.toString()) .replace("{Y}", startDt.format(DateTimeFormatter.ofPattern("yyyy"))) .replace("{m}", startDt.format(DateTimeFormatter.ofPattern("MM"))) .replace("{d}", startDt.format(DateTimeFormatter.ofPattern("dd"))) @@ -1260,11 +1268,12 @@ class IptvRepository @Inject constructor( val cached = cachedNowNext if (cached.isEmpty()) return null val nowMs = System.currentTimeMillis() - val recentCutoff = nowMs - (30L * 60_000L) + val channelsById = cachedChannels.associateBy { it.id } val result = mutableMapOf() for (channelId in channelIds) { val existing = cached[channelId] ?: continue + val recentCutoff = recentCutoffForChannel(channelsById[channelId], nowMs) // Collect all known programs from the cached entry efficiently val allPrograms = java.util.ArrayList( (if (existing.now != null) 1 else 0) + @@ -1300,7 +1309,9 @@ class IptvRepository @Inject constructor( for (i in startIndex until allPrograms.size) { val p = allPrograms[i] when { - p.endUtcMillis <= nowMs && p.endUtcMillis > recentCutoff -> recent.add(p) + p.endUtcMillis <= nowMs && p.endUtcMillis > recentCutoff -> { + addRecentCandidate(recent, p, recentProgramLimitForChannel(channelsById[channelId])) + } p.isLive(nowMs) -> now = p p.startUtcMillis > nowMs && next == null -> next = p p.startUtcMillis > nowMs && later == null -> later = p @@ -1385,7 +1396,12 @@ class IptvRepository @Inject constructor( if (allListings.isEmpty()) return@withContext null - val freshNowNext = buildNowNextFromXtreamListings(allListings, epgIdToChannelIds, streamIdToChannelIds) + val freshNowNext = buildNowNextFromXtreamListings( + listings = allListings, + epgIdToChannelIds = epgIdToChannelIds, + streamIdToChannelIds = streamIdToChannelIds, + channelsById = channels.associateBy { it.id } + ) if (freshNowNext.isEmpty()) return@withContext null // Merge into cache (in-place, no copy) @@ -3745,7 +3761,7 @@ class IptvRepository @Inject constructor( epgId = stream.epgChannelId?.trim()?.takeIf { it.isNotBlank() }, rawTitle = name, xtreamStreamId = streamId, - catchupDays = (stream.tvArchive ?: stream.tvArchiveDuration ?: 0).coerceAtLeast(0), + catchupDays = (stream.tvArchiveDuration ?: stream.tvArchive ?: 0).coerceAtLeast(0), catchupType = if ((stream.tvArchive ?: 0) > 0 || (stream.tvArchiveDuration ?: 0) > 0) "xtream" else null ) } @@ -4063,7 +4079,12 @@ class IptvRepository @Inject constructor( if (allListings.isEmpty()) return null onProgress(IptvLoadProgress("Parsing EPG data (${allListings.size} listings)...", 98)) - return buildNowNextFromXtreamListings(allListings, epgIdToChannelIds, streamIdToChannelIds) + return buildNowNextFromXtreamListings( + listings = allListings, + epgIdToChannelIds = epgIdToChannelIds, + streamIdToChannelIds = streamIdToChannelIds, + channelsById = channels.associateBy { it.id } + ) } @@ -4163,10 +4184,11 @@ class IptvRepository @Inject constructor( private fun buildNowNextFromXtreamListings( listings: List, epgIdToChannelIds: Map>, - streamIdToChannelIds: Map> + streamIdToChannelIds: Map>, + channelsById: Map = emptyMap() ): Map { val nowMs = System.currentTimeMillis() - val recentCutoff = nowMs - (30L * 60_000L) // 30 min ago (covers expanded timeline window) + val oldestRecentCutoff = oldestRecentCutoff(channelsById.values, nowMs) // Group listings by channel. // Try matching by: epg_id (channelId field), then stream_id. @@ -4181,8 +4203,8 @@ class IptvRepository @Inject constructor( ?: parseXtreamDateTime(listing.end) ?: continue - // Skip programs that ended well before now (keep recent ones) - if (stopMs < recentCutoff) continue + // Skip programs that ended before the oldest possible catchup window. + if (stopMs < oldestRecentCutoff) continue val title = decodeBase64Field(listing.title).ifBlank { "No Title" } val description = decodeBase64Field(listing.description).takeIf { it.isNotBlank() } @@ -4235,6 +4257,7 @@ class IptvRepository @Inject constructor( val recent = mutableListOf() if (sorted.isNotEmpty()) { + val recentCutoff = recentCutoffForChannel(channelsById[channelId], nowMs) var startIndex = sorted.binarySearch { it.startUtcMillis.compareTo(recentCutoff) } if (startIndex < 0) { startIndex = -(startIndex + 1) @@ -4248,7 +4271,9 @@ class IptvRepository @Inject constructor( for (i in startIndex until sorted.size) { val p = sorted[i] when { - p.endUtcMillis <= nowMs && p.endUtcMillis > recentCutoff -> recent.add(p) + p.endUtcMillis <= nowMs && p.endUtcMillis > recentCutoff -> { + addRecentCandidate(recent, p, recentProgramLimitForChannel(channelsById[channelId])) + } p.isLive(nowMs) -> now = p p.startUtcMillis > nowMs && next == null -> next = p p.startUtcMillis > nowMs && later == null -> later = p @@ -4412,9 +4437,7 @@ class IptvRepository @Inject constructor( if (channels.isEmpty()) return emptyMap() val nowUtc = System.currentTimeMillis() - val maxCatchupDays = channels.maxOfOrNull { it.catchupDays } ?: 0 - val catchupCutoff = nowUtc - (maxCatchupDays.coerceAtMost(7) * 24 * 60 * 60_000L) - val recentCutoff = minOf(nowUtc - (30 * 60_000L), catchupCutoff) + val recentCutoff = oldestRecentCutoff(channels, nowUtc) val keyLookup = buildChannelKeyLookup(channels) val xmlChannelNameMap = mutableMapOf>() @@ -4497,10 +4520,10 @@ class IptvRepository @Inject constructor( if (program.startUtcMillis > nowUtc) { val future = upcomingCandidates.getOrPut(channel.id) { mutableListOf() } addUpcomingCandidate(future, program, limit = epgUpcomingProgramLimit) - } else if (program.endUtcMillis <= nowUtc && program.endUtcMillis > recentCutoff) { + } else if (program.endUtcMillis <= nowUtc && program.endUtcMillis > recentCutoffForChannel(channel, nowUtc)) { val recent = recentCandidates.getOrPut(channel.id) { mutableListOf() } - val limit = if (channel.catchupDays > 0) 1000 else epgRecentProgramLimit - if (recent.size < limit) recent.add(program) + val limit = recentProgramLimitForChannel(channel) + addRecentCandidate(recent, program, limit) } } currentChannelKey = null @@ -4531,9 +4554,7 @@ class IptvRepository @Inject constructor( if (channels.isEmpty()) return emptyMap() val nowUtc = System.currentTimeMillis() - val maxCatchupDays = channels.maxOfOrNull { it.catchupDays } ?: 0 - val catchupCutoff = nowUtc - (maxCatchupDays.coerceAtMost(7) * 24 * 60 * 60_000L) - val recentCutoff = minOf(nowUtc - (30 * 60_000L), catchupCutoff) + val recentCutoff = oldestRecentCutoff(channels, nowUtc) val keyLookup = buildChannelKeyLookup(channels) val xmlChannelNameMap = mutableMapOf>() @@ -4649,11 +4670,11 @@ class IptvRepository @Inject constructor( if (program.startUtcMillis > nowUtc) { val future = upcomingCandidates.getOrPut(channel.id) { mutableListOf() } addUpcomingCandidate(future, program, limit = epgUpcomingProgramLimit) - } else if (program.endUtcMillis <= nowUtc && program.endUtcMillis > recentCutoff) { + } else if (program.endUtcMillis <= nowUtc && program.endUtcMillis > recentCutoffForChannel(channel, nowUtc)) { // Recently ended program – keep for the past-window in the EPG guide val recent = recentCandidates.getOrPut(channel.id) { mutableListOf() } - val limit = if (channel.catchupDays > 0) 1000 else epgRecentProgramLimit - if (recent.size < limit) recent.add(program) + val limit = recentProgramLimitForChannel(channel) + addRecentCandidate(recent, program, limit) } } currentChannelKey = null @@ -4714,13 +4735,67 @@ class IptvRepository @Inject constructor( } } + private fun addRecentCandidate( + recent: MutableList, + candidate: IptvProgram, + limit: Int + ) { + val duplicate = recent.any { + it.startUtcMillis == candidate.startUtcMillis && + it.endUtcMillis == candidate.endUtcMillis && + it.title.equals(candidate.title, ignoreCase = true) + } + if (duplicate) return + + val insertIndex = recent.indexOfFirst { + candidate.startUtcMillis < it.startUtcMillis || + (candidate.startUtcMillis == it.startUtcMillis && candidate.endUtcMillis > it.endUtcMillis) + } + if (insertIndex >= 0) { + recent.add(insertIndex, candidate) + } else { + recent.add(candidate) + } + while (recent.size > limit) { + recent.removeAt(0) + } + } + + private fun recentProgramLimitForChannel(channel: IptvChannel?): Int { + return if ((channel?.catchupDays ?: 0) > 0) catchupRecentProgramLimit else epgRecentProgramLimit + } + + private fun recentCutoffForChannel(channel: IptvChannel?, nowUtcMillis: Long): Long { + val catchupDays = channel?.catchupDays?.coerceIn(0, 7) ?: 0 + return if (catchupDays > 0) { + nowUtcMillis - catchupDays * 24L * 60L * 60_000L + } else { + nowUtcMillis - 30L * 60_000L + } + } + + private fun oldestRecentCutoff(channels: Collection, nowUtcMillis: Long): Long { + val maxCatchupDays = channels.maxOfOrNull { it.catchupDays }?.coerceIn(0, 7) ?: 0 + return if (maxCatchupDays > 0) { + nowUtcMillis - maxCatchupDays * 24L * 60L * 60_000L + } else { + nowUtcMillis - 30L * 60_000L + } + } + private fun hasAnyProgramData(nowNext: Map): Boolean { if (nowNext.isEmpty()) return false return nowNext.values.any { item -> hasProgramData(item) } } private fun hasProgramData(item: IptvNowNext?): Boolean { - return item != null && (item.now != null || item.next != null || item.later != null || item.upcoming.isNotEmpty()) + return item != null && ( + item.now != null || + item.next != null || + item.later != null || + item.upcoming.isNotEmpty() || + item.recent.isNotEmpty() + ) } private fun epgCoverageRatio(channels: List, nowNext: Map): Float { @@ -4997,10 +5072,16 @@ class IptvRepository @Inject constructor( rawTitle = channel.name ) } + val channelsById = compactChannels.associateBy { it.id } val compactNowNext = nowNext .asSequence() .filter { (_, value) -> hasProgramData(value) } .associate { (channelId, value) -> + val recentLimit = if ((channelsById[channelId]?.catchupDays ?: 0) > 0) { + catchupRecentProgramLimit + } else { + cacheRecentProgramLimit + } channelId to IptvNowNext( now = value.now?.compactForCache(), next = value.next?.compactForCache(), @@ -5011,7 +5092,7 @@ class IptvRepository @Inject constructor( .take(cacheUpcomingProgramLimit) .toList(), recent = value.recent - .takeLast(cacheRecentProgramLimit) + .takeLast(recentLimit) .map { it.compactForCache() } ) } 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 9f26de1fd..ddcbc805b 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 @@ -367,7 +367,11 @@ class TvViewModel @Inject constructor( private fun hasAnyEpgData(snapshot: IptvSnapshot): Boolean { if (snapshot.nowNext.isEmpty()) return false return snapshot.nowNext.values.any { item -> - item.now != null || item.next != null || item.later != null || item.upcoming.isNotEmpty() + item.now != null || + item.next != null || + item.later != null || + item.upcoming.isNotEmpty() || + item.recent.isNotEmpty() } } @@ -376,7 +380,8 @@ class TvViewModel @Inject constructor( item.now != null || item.next != null || item.later != null || - item.upcoming.isNotEmpty() + item.upcoming.isNotEmpty() || + item.recent.isNotEmpty() ) } 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 33bd6dff1..24b68e783 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 @@ -11,7 +11,7 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Row2 import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Star import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -67,6 +68,7 @@ fun ChannelRow( onClick: () -> Unit, onFavoriteToggle: () -> Unit, onMoveLeft: () -> Unit = {}, + onMoveUp: () -> Boolean = { false }, onFocused: () -> Unit = {}, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, forceFocused: Boolean = false, @@ -119,9 +121,11 @@ fun ChannelRow( // repeatCount == 1) on CENTER / ENTER / MENU triggers favorite // toggle, giving the user the "hold OK" gesture everywhere. .onKeyEvent { ev -> - if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionLeft) { - onMoveLeft() - return@onKeyEvent true + if (ev.type == KeyEventType.KeyDown) { + when (ev.key) { + Key.DirectionLeft -> { onMoveLeft(); return@onKeyEvent true } + Key.DirectionUp -> if (onMoveUp()) return@onKeyEvent true + } } val isLongHoldCenter = ev.type == KeyEventType.KeyDown && (ev.key == Key.DirectionCenter || ev.key == Key.Enter) && @@ -185,6 +189,15 @@ fun ChannelRow( modifier = Modifier.size(11.dp), ) } + if (channel.catchupDays > 0) { + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.History, + contentDescription = "Catchup available", + tint = LiveColors.Accent.copy(alpha = 0.8f), + modifier = Modifier.size(11.dp), + ) + } } // Only the thin progress underline stays here — programme info // itself is shown exclusively in the time-aligned grid cells to 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 9407f80e5..33e6f614c 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 @@ -95,11 +95,11 @@ fun EpgGrid( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val pxPerMin = LiveDims.EpgPxPerMinute + val pxPerMin = if (compact) 96f / 30f else LiveDims.EpgPxPerMinute.toFloat() val selectedChannelFocusRequester = remember { FocusRequester() } val headerHeight = if (compact) 32.dp else LiveDims.EpgHeaderHeight val channelColumnWidth = if (compact) 164.dp else LiveDims.EpgChannelColWidth - val halfHourWidth = if (compact) 96.dp else LiveDims.EpgHalfHourWidth + val halfHourWidth = (pxPerMin * 30f).dp val rowHeight = if (compact) 52.dp else LiveDims.EpgRowHeight // Window: now − 30 min → now + 2 h = 2.5 h total. @@ -161,16 +161,6 @@ fun EpgGrid( val scope = rememberCoroutineScope() - // Park NOW ~30 dp from the left so only a thin slice of the past is - // visible and the rest of the viewport holds upcoming programmes. - LaunchedEffect(Unit) { - with(density) { - val nowOffsetMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() - val targetPx = (nowOffsetMin * pxPerMin).dp.toPx().toInt() - 30.dp.toPx().toInt() - hScroll.scrollTo(targetPx.coerceAtLeast(0)) - } - } - // Scroll the grid to the active channel whenever the selection changes // from outside (e.g. search result picked). Uses a keyed LaunchedEffect // on both selection and channel list identity so a late-arriving list @@ -197,9 +187,12 @@ fun EpgGrid( runCatching { selectedChannelFocusRequester.requestFocus() } } - val days = remember { + val maxCatchupDays = remember(channels) { + (channels.maxOfOrNull { it.catchupDays } ?: 0).coerceIn(0, 7) + } + val days = remember(maxCatchupDays) { val base = roundedWindowStart() - (0..6).map { i -> + (0..maxCatchupDays).map { i -> val start = base - i * 24 * 60 * 60_000L val label = when (i) { 0 -> "TODAY" @@ -214,44 +207,85 @@ fun EpgGrid( } } var selectedDayIdx by remember { mutableIntStateOf(0) } - LaunchedEffect(selectedDayIdx) { - windowStartMillis = days[selectedDayIdx].startMillis + val activeDayIdx = selectedDayIdx.coerceIn(0, days.lastIndex) + val dayFocusRequesters = remember(days.size) { List(days.size) { FocusRequester() } } + var focusedDayIdx by remember { mutableStateOf(null) } + val showDaySelector = days.size > 1 + + LaunchedEffect(days.size, activeDayIdx) { + if (selectedDayIdx != activeDayIdx) selectedDayIdx = activeDayIdx + } + + LaunchedEffect(activeDayIdx, days) { + windowStartMillis = days[activeDayIdx].startMillis + if (activeDayIdx == 0) { + with(density) { + val nowOffsetMin = ((clockTickMillis - days[0].startMillis) / 60_000L).toInt() + val targetPx = (nowOffsetMin * pxPerMin).dp.toPx().toInt() - 30.dp.toPx().toInt() + hScroll.scrollTo(targetPx.coerceAtLeast(0)) + } + } else { + hScroll.scrollTo(0) + } } Column( modifier = modifier.fillMaxSize().background(LiveColors.Bg), ) { - // ─── Day Selector ─────────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .background(LiveColors.PanelDeep) - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("ARCHIVE", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) - days.forEachIndexed { idx, day -> - val isSelected = selectedDayIdx == idx - Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(if (isSelected) LiveColors.Accent else LiveColors.Panel) - .border(1.dp, if (isSelected) LiveColors.Accent else LiveColors.Divider, RoundedCornerShape(4.dp)) - .pointerInput(Unit) { detectTapGestures { selectedDayIdx = idx } } - .focusable() - .onKeyEvent { ev -> - if (ev.type == KeyEventType.KeyDown && (ev.key == Key.DirectionCenter || ev.key == Key.Enter)) { - selectedDayIdx = idx; true - } else false - } - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = day.label, - style = LiveType.Badge.copy(color = if (isSelected) LiveColors.Bg else LiveColors.Fg) - ) + if (showDaySelector) { + // ─── Day Selector ─────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .background(LiveColors.PanelDeep) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("ARCHIVE", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) + days.forEachIndexed { idx, day -> + val isSelected = activeDayIdx == idx + val isFocused = focusedDayIdx == idx + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(if (isSelected) LiveColors.Accent else LiveColors.Panel) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = when { + isFocused -> LiveColors.FocusRing + isSelected -> LiveColors.Accent + else -> LiveColors.Divider + }, + shape = RoundedCornerShape(4.dp), + ) + .pointerInput(Unit) { detectTapGestures { selectedDayIdx = idx } } + .focusRequester(dayFocusRequesters[idx]) + .onFocusChanged { if (it.hasFocus) focusedDayIdx = idx } + .focusable() + .onKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown) { + when (ev.key) { + Key.DirectionCenter, Key.Enter -> { + selectedDayIdx = idx + true + } + Key.DirectionDown -> { + selectedChannelFocusRequester.requestFocus() + true + } + else -> false + } + } else false + } + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = day.label, + style = LiveType.Badge.copy(color = if (isSelected) LiveColors.Bg else LiveColors.Fg) + ) + } } } } @@ -317,19 +351,21 @@ fun EpgGrid( } } // Cyan "NOW hh:mm" pill hovering above the now-line inside the header. - val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() - val nowOffset = (nowMin * pxPerMin).dp - Box( - modifier = Modifier - .offset(x = nowOffset - 46.dp, y = 6.dp) - .clip(RoundedCornerShape(4.dp)) - .background(LiveColors.Accent) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - Text( - text = "NOW " + formatClock(clockTickMillis), - style = LiveType.Badge.copy(color = LiveColors.Bg), - ) + if (clockTickMillis in windowStartMillis until windowEndMillis) { + val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() + val nowOffset = (nowMin * pxPerMin).dp + Box( + modifier = Modifier + .offset(x = nowOffset - 46.dp, y = 6.dp) + .clip(RoundedCornerShape(4.dp)) + .background(LiveColors.Accent) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = "NOW " + formatClock(clockTickMillis), + style = LiveType.Badge.copy(color = LiveColors.Bg), + ) + } } } } @@ -369,6 +405,14 @@ fun EpgGrid( onClick = { onChannelSelect(ch, null) }, onFocused = { onChannelFocused(ch) }, onMoveLeft = onMoveLeftFromChannels, + onMoveUp = { + if (showDaySelector && idx == channelListState.firstVisibleItemIndex) { + dayFocusRequesters.getOrNull(activeDayIdx)?.requestFocus() + true + } else { + false + } + }, onFavoriteToggle = { onChannelFavoriteToggle(ch.id) }, rowHeight = rowHeight, forceFocused = gridFocused && ch.id == selectedChannelId, @@ -390,10 +434,15 @@ fun EpgGrid( Box(modifier = Modifier.fillMaxSize()) { LazyColumn( state = programListState, - userScrollEnabled = false, modifier = Modifier .fillMaxSize() - .horizontalScroll(hScroll), + .horizontalScroll(hScroll) + .onKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Back) { + selectedChannelFocusRequester.requestFocus() + true + } else false + }, ) { itemsIndexed( channels, @@ -430,12 +479,14 @@ fun EpgGrid( } } // NOW glow line across full body - NowLine( - clockTickMillis = clockTickMillis, - windowStartMillis = windowStartMillis, - pxPerMin = pxPerMin, - hScrollOffsetPx = hScroll.value, - ) + if (clockTickMillis in windowStartMillis until windowEndMillis) { + NowLine( + clockTickMillis = clockTickMillis, + windowStartMillis = windowStartMillis, + pxPerMin = pxPerMin, + hScrollOffsetPx = hScroll.value, + ) + } } } } @@ -451,7 +502,7 @@ private fun ProgramsRow( windowStartMillis: Long, windowEndMillis: Long, totalWidth: Dp, - pxPerMin: Int, + pxPerMin: Float, stripe: Boolean, isActive: Boolean, rowHeight: Dp, @@ -480,6 +531,7 @@ private fun ProgramsRow( val offset = (placement.startMin * pxPerMin).dp val width = (placement.durationMin * pxPerMin).dp val isCatchupSupported = channel.catchupDays > 0 && + !placement.isPlaceholder && placement.program.startUtcMillis >= System.currentTimeMillis() - channel.catchupDays * 24 * 60 * 60_000L ProgramCell( program = placement.program, @@ -511,7 +563,7 @@ private fun ProgramsRow( private fun NowLine( clockTickMillis: Long, windowStartMillis: Long, - pxPerMin: Int, + pxPerMin: Float, hScrollOffsetPx: Int, ) { val density = LocalDensity.current @@ -550,16 +602,16 @@ private fun buildHalfHourSlots(startMillis: Long, count: Int): List { return out } -/** Round down to the nearest half-hour, shifted 30 min back so the user - * can still see what just aired without the past dominating the viewport. */ +/** Round down to the start of the current day (00:00) so the user can + * scroll back through the full daily timeline for catchup. */ private fun roundedWindowStart(): Long { val cal = java.util.Calendar.getInstance() cal.timeInMillis = System.currentTimeMillis() + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) cal.set(java.util.Calendar.SECOND, 0) cal.set(java.util.Calendar.MILLISECOND, 0) - val min = cal.get(java.util.Calendar.MINUTE) - cal.set(java.util.Calendar.MINUTE, if (min >= 30) 30 else 0) - return cal.timeInMillis - 30L * 60_000L + return cal.timeInMillis } private fun programsInWindow( @@ -587,7 +639,8 @@ private data class ProgramPlacement( val startMin: Int, val durationMin: Int, val isNow: Boolean, - val isPast: Boolean + val isPast: Boolean, + val isPlaceholder: Boolean = false, ) private fun buildProgramPlacements( @@ -596,23 +649,54 @@ private fun buildProgramPlacements( windowEndMillis: Long, nowMillis: Long ): List { - if (programs.isEmpty()) return emptyList() - val placements = mutableListOf() var cursor = windowStartMillis + programs.forEach { program -> + // 1. Fill gap before this program + if (program.startUtcMillis > cursor) { + val gapEnd = minOf(program.startUtcMillis, windowEndMillis) + if (gapEnd > cursor) { + placements += ProgramPlacement( + program = IptvProgram("No Information", startUtcMillis = cursor, endUtcMillis = gapEnd), + startMin = ((cursor - windowStartMillis) / 60_000L).toInt(), + durationMin = ((gapEnd - cursor) / 60_000L).toInt().coerceAtLeast(1), + isNow = nowMillis in cursor until gapEnd, + isPast = gapEnd <= nowMillis, + isPlaceholder = true, + ) + cursor = gapEnd + } + } + + if (cursor >= windowEndMillis) return@forEach + + // 2. Add the actual program val clampedStart = maxOf(program.startUtcMillis, windowStartMillis, cursor) val clampedEnd = minOf(program.endUtcMillis, windowEndMillis) - if (clampedEnd <= clampedStart) return@forEach + if (clampedEnd > clampedStart) { + placements += ProgramPlacement( + program = program, + startMin = ((clampedStart - windowStartMillis) / 60_000L).toInt(), + durationMin = ((clampedEnd - clampedStart) / 60_000L).toInt().coerceAtLeast(1), + isNow = nowMillis in clampedStart until clampedEnd, + isPast = clampedEnd <= nowMillis + ) + cursor = clampedEnd + } + } + // 3. Fill trailing gap + if (cursor < windowEndMillis) { placements += ProgramPlacement( - program = program, - startMin = ((clampedStart - windowStartMillis) / 60_000L).toInt().coerceAtLeast(0), - durationMin = ((clampedEnd - clampedStart) / 60_000L).toInt().coerceAtLeast(1), - isNow = nowMillis in clampedStart until clampedEnd, - isPast = clampedEnd <= nowMillis + program = IptvProgram("No Information", startUtcMillis = cursor, endUtcMillis = windowEndMillis), + startMin = ((cursor - windowStartMillis) / 60_000L).toInt(), + durationMin = ((windowEndMillis - cursor) / 60_000L).toInt().coerceAtLeast(1), + isNow = nowMillis in cursor until windowEndMillis, + isPast = windowEndMillis <= nowMillis, + isPlaceholder = true, ) - cursor = clampedEnd } + return placements } 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 8d99ccfc3..6ef4ca071 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 @@ -403,6 +403,7 @@ fun LiveTvScreen( val nextIdx = ((start + delta) % size + size) % size playingChannelId = all[nextIdx].id focusedChannelId = all[nextIdx].id + playingCatchupProgram = null } // ExoPlayer lifecycle — mirrors the legacy screen's setup verbatim so live @@ -482,7 +483,7 @@ fun LiveTvScreen( } } } - LaunchedEffect(currentStreamUrl) { + LaunchedEffect(currentStreamUrl, playingCatchupProgram) { val stream = currentStreamUrl ?: return@LaunchedEffect delay(90L) exoPlayer.setMediaItem( 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 4db6f113f..bf7a36a18 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 @@ -135,7 +135,13 @@ fun ProgramCell( Modifier } ) - .pointerInput(Unit) { detectTapGestures(onTap = { onClick() }) } + .then( + if (focusable) { + Modifier.pointerInput(Unit) { detectTapGestures(onTap = { onClick() }) } + } else { + Modifier + } + ) .padding(horizontal = 6.dp, vertical = 4.dp), ) { if (isNow) { @@ -158,11 +164,19 @@ fun ProgramCell( ) { Row(verticalAlignment = Alignment.CenterVertically) { val nowMs = clockTickMillis - val isNewTag = (nowMs - program.startUtcMillis) in 0..24L * 60 * 60 * 1000L && - !program.isLive(nowMs) - if (isNewTag) { - Badge("NEW", LiveColors.Bg, LiveColors.Accent) + if (isNow) { + Badge("LIVE", Color.White, LiveColors.LiveRed) Spacer(Modifier.size(6.dp)) + } else if (isPast && isCatchupSupported) { + Badge("ARCHIVE", LiveColors.Bg, LiveColors.Accent) + Spacer(Modifier.size(6.dp)) + } else if (!isPast) { + val isNewTag = (nowMs - program.startUtcMillis) in 0..24L * 60 * 60 * 1000L && + !program.isLive(nowMs) + if (isNewTag) { + Badge("NEW", LiveColors.Bg, LiveColors.Accent) + Spacer(Modifier.size(6.dp)) + } } Text( text = program.title, diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/PreinstalledServicesTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/PreinstalledServicesTest.kt index 4daac6f11..243a8c2d4 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/PreinstalledServicesTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/PreinstalledServicesTest.kt @@ -40,7 +40,8 @@ class PreinstalledServicesTest { "collection_service_prime_video", "collection_service_hbo_max", "collection_service_hulu", - "collection_service_paramountplus" + "collection_service_paramountplus", + "collection_service_crunchyroll" ) private val servicesWithoutHeroVideo = serviceOrder.toSet() - serviceVideoIds @@ -92,7 +93,8 @@ class PreinstalledServicesTest { "collection_service_prime_video" to "networks%20videos/amazonprime.mp4", "collection_service_hbo_max" to "networks%20videos/hbomax.mp4", "collection_service_hulu" to "networks%20videos/hulu.mp4", - "collection_service_paramountplus" to "networks%20videos/paramount.mp4" + "collection_service_paramountplus" to "networks%20videos/paramount.mp4", + "collection_service_crunchyroll" to "networks%20videos/crunchyroll.mp4" ) services.forEach { cfg -> val video = cfg.collectionHeroVideoUrl From c474653a2304e4669166d84bf0e20aaace5c13b0 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 17 May 2026 22:01:28 +0530 Subject: [PATCH 3/4] feat: enhance navigation and focus management in Live TV and EPG screens --- .../tv/ui/screens/tv/live/CategorySidebar.kt | 16 + .../tv/ui/screens/tv/live/ChannelRow.kt | 20 +- .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 314 ++++++++++-------- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 178 ++++++---- .../tv/ui/screens/tv/live/ProgramCell.kt | 30 +- 5 files changed, 347 insertions(+), 211 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index dc31c7961..c025f81d6 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 @@ -99,6 +99,7 @@ fun CategorySidebar( onFocusEnter: () -> Unit = {}, onMoveRight: () -> Unit = {}, onTopBoundaryFocusChanged: (Boolean) -> Unit = {}, + focusSearchSignal: Int = 0, modifier: Modifier = Modifier, ) { val targetWidth = if (expanded) LiveDims.SidebarExpanded else LiveDims.SidebarCollapsed @@ -110,6 +111,16 @@ fun CategorySidebar( var expandedCountry by rememberSaveable { mutableStateOf(null) } var expandedAll by rememberSaveable { mutableStateOf(false) } var menuForGroup by rememberSaveable { mutableStateOf(null) } + val searchFocusRequester = remember { FocusRequester() } + + LaunchedEffect(focusSearchSignal) { + if (focusSearchSignal > 0) { + repeat(3) { + runCatching { searchFocusRequester.requestFocus() } + delay(50L) + } + } + } LaunchedEffect(selectedId, tree) { val countryId = selectedCountryGroupId(selectedId, tree) @@ -149,6 +160,7 @@ fun CategorySidebar( onClick = onOpenSearch, expanded = expanded, onFocusChanged = onTopBoundaryFocusChanged, + focusRequester = searchFocusRequester, ) Spacer(Modifier.height(8.dp)) LazyColumn( @@ -316,6 +328,7 @@ private fun SearchEntry( onClick: () -> Unit, expanded: Boolean, onFocusChanged: (Boolean) -> Unit = {}, + focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } Row( @@ -326,6 +339,7 @@ private fun SearchEntry( focused = it.isFocused onFocusChanged(it.isFocused) } + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .border( width = if (focused) 3.dp else 0.dp, color = if (focused) LiveColors.FocusRing else Color.Transparent, @@ -411,6 +425,7 @@ private fun SidebarRow( isOpenGroup: Boolean = false, indent: androidx.compose.ui.unit.Dp = 0.dp, labelSize: androidx.compose.ui.unit.TextUnit = 11.sp, + focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } var consumedLongPress by remember { mutableStateOf(false) } @@ -446,6 +461,7 @@ private fun SidebarRow( 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, color = if (focused) LiveColors.FocusRing else Color.Transparent, 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 24b68e783..48caf15da 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 @@ -11,7 +11,7 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row2 +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -40,6 +40,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -68,6 +69,7 @@ fun ChannelRow( onClick: () -> Unit, onFavoriteToggle: () -> Unit, onMoveLeft: () -> Unit = {}, + onMoveRight: () -> Boolean = { false }, onMoveUp: () -> Boolean = { false }, onFocused: () -> Unit = {}, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, @@ -111,6 +113,16 @@ fun ChannelRow( ) .background(if (visuallyFocused) LiveColors.PanelRaised else bg) .focusable() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown) { + when (ev.key) { + Key.DirectionLeft -> { onMoveLeft(); return@onPreviewKeyEvent true } + Key.DirectionRight -> if (onMoveRight()) return@onPreviewKeyEvent true + Key.DirectionUp -> if (onMoveUp()) return@onPreviewKeyEvent true + } + } + false + } .combinedClickable( onClick = onClick, onLongClick = onFavoriteToggle, @@ -121,12 +133,6 @@ fun ChannelRow( // repeatCount == 1) on CENTER / ENTER / MENU triggers favorite // toggle, giving the user the "hold OK" gesture everywhere. .onKeyEvent { ev -> - if (ev.type == KeyEventType.KeyDown) { - when (ev.key) { - Key.DirectionLeft -> { onMoveLeft(); return@onKeyEvent true } - Key.DirectionUp -> if (onMoveUp()) return@onKeyEvent true - } - } val isLongHoldCenter = ev.type == KeyEventType.KeyDown && (ev.key == Key.DirectionCenter || ev.key == Key.Enter) && ev.nativeKeyEvent.repeatCount == 1 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 33e6f614c..c72f91e47 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 @@ -2,40 +2,30 @@ package com.arflix.tv.ui.screens.tv.live import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.StarOutline -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -54,7 +44,6 @@ import androidx.compose.ui.input.key.KeyEventType 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -64,12 +53,17 @@ import androidx.tv.material3.Text import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.ui.focus.arvioDpadFocusGroup +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch private const val EpgWindowMinutes = 24 * 60 -private const val EpgWindowSlotCount = EpgWindowMinutes / 30 + +enum class EpgGridFocusMode { + ChannelList, + Epg, +} /** * EPG grid per spec §3.4. @@ -85,32 +79,45 @@ fun EpgGrid( nowNext: Map, selectedChannelId: String?, focusSelectedChannelSignal: Int, + focusEpgSignal: Int = 0, + focusMode: EpgGridFocusMode = EpgGridFocusMode.ChannelList, onChannelSelect: (EnrichedChannel, IptvProgram?) -> Unit, + onProgramSelect: (EnrichedChannel, IptvProgram?) -> Unit = onChannelSelect, onChannelFocused: (EnrichedChannel) -> Unit = {}, onChannelFavoriteToggle: (String) -> Unit, favorites: Set, compact: Boolean = false, gridFocused: Boolean = false, onMoveLeftFromChannels: () -> Unit = {}, + onEnterEpg: (EnrichedChannel) -> Unit = {}, + onExitEpg: (EnrichedChannel?) -> Unit = {}, modifier: Modifier = Modifier, ) { val density = LocalDensity.current val pxPerMin = if (compact) 96f / 30f else LiveDims.EpgPxPerMinute.toFloat() val selectedChannelFocusRequester = remember { FocusRequester() } + val firstChannelFocusRequester = remember { FocusRequester() } val headerHeight = if (compact) 32.dp else LiveDims.EpgHeaderHeight val channelColumnWidth = if (compact) 164.dp else LiveDims.EpgChannelColWidth val halfHourWidth = (pxPerMin * 30f).dp val rowHeight = if (compact) 52.dp else LiveDims.EpgRowHeight + val programFocusRequesters = remember { mutableStateMapOf>() } + val programFocusTargets = remember { mutableStateMapOf>() } - // Window: now − 30 min → now + 2 h = 2.5 h total. - // Past is limited to 30 min so most of the ruler is future programmes - // (what you're about to watch), not what already aired. - var windowStartMillis by remember { mutableStateOf(roundedWindowStart()) } - val windowEndMillis = remember(windowStartMillis) { - windowStartMillis + EpgWindowMinutes * 60L * 1000L + val maxCatchupDays = remember(channels) { + (channels.maxOfOrNull { it.catchupDays } ?: 0).coerceIn(0, 7) + } + val todayStartMillis = remember { roundedWindowStart() } + val windowStartMillis = remember(todayStartMillis, maxCatchupDays) { + todayStartMillis - maxCatchupDays * 24L * 60L * 60_000L } - // 5 half-hour slots across the window. - val slots = remember(windowStartMillis) { buildHalfHourSlots(windowStartMillis, EpgWindowSlotCount) } + val windowEndMillis = remember(todayStartMillis) { + todayStartMillis + EpgWindowMinutes * 60L * 1000L + } + val slotCount = remember(windowStartMillis, windowEndMillis) { + (((windowEndMillis - windowStartMillis) / 60_000L) / 30L).toInt().coerceAtLeast(1) + } + val slots = remember(windowStartMillis, slotCount) { buildHalfHourSlots(windowStartMillis, slotCount) } // Shared horizontal scroll state between header and body rows. val hScroll = rememberScrollState() @@ -160,6 +167,50 @@ fun EpgGrid( } val scope = rememberCoroutineScope() + fun requestProgramFocus(rowIdx: Int, targetIdx: Int): Boolean { + val channel = channels.getOrNull(rowIdx) ?: return false + val requesters = programFocusRequesters[channel.id].orEmpty() + if (requesters.isEmpty()) return false + val safeTargetIdx = targetIdx.coerceIn(0, requesters.lastIndex) + scope.launch { + leader = 0 + programListState.scrollToItem(rowIdx) + channelListState.scrollToItem(rowIdx) + runCatching { requesters[safeTargetIdx].requestFocus() } + } + return true + } + + fun nearestProgramIndex(rowIdx: Int, anchorStartMin: Int): Int? { + val channel = channels.getOrNull(rowIdx) ?: return null + val targets = programFocusTargets[channel.id].orEmpty() + if (targets.isEmpty()) return null + return targets + .withIndex() + .minByOrNull { (_, target) -> target.distanceTo(anchorStartMin) } + ?.index + } + + fun requestNearestProgramFocus(rowIdx: Int, anchorStartMin: Int): Boolean { + val targetIdx = nearestProgramIndex(rowIdx, anchorStartMin) ?: return false + return requestProgramFocus(rowIdx, targetIdx) + } + + fun keepChannelFocus(rowIdx: Int): Boolean { + val channel = channels.getOrNull(rowIdx) ?: return true + scope.launch { + leader = 0 + channelListState.scrollToItem(rowIdx) + programListState.scrollToItem(rowIdx) + val requester = when { + rowIdx == 0 -> firstChannelFocusRequester + channel.id == selectedChannelId -> selectedChannelFocusRequester + else -> null + } + requester?.let { runCatching { it.requestFocus() } } + } + return true + } // Scroll the grid to the active channel whenever the selection changes // from outside (e.g. search result picked). Uses a keyed LaunchedEffect @@ -187,109 +238,30 @@ fun EpgGrid( runCatching { selectedChannelFocusRequester.requestFocus() } } - val maxCatchupDays = remember(channels) { - (channels.maxOfOrNull { it.catchupDays } ?: 0).coerceIn(0, 7) - } - val days = remember(maxCatchupDays) { - val base = roundedWindowStart() - (0..maxCatchupDays).map { i -> - val start = base - i * 24 * 60 * 60_000L - val label = when (i) { - 0 -> "TODAY" - 1 -> "YESTERDAY" - else -> { - val cal = java.util.Calendar.getInstance() - cal.timeInMillis = start - cal.getDisplayName(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.SHORT, java.util.Locale.US)?.uppercase() ?: "DAY" - } - } - EpgDay(label, start) + LaunchedEffect(focusEpgSignal, selectedChannelId, channels, windowStartMillis) { + if (focusEpgSignal == 0) return@LaunchedEffect + val id = selectedChannelId ?: return@LaunchedEffect + val idx = channels.indexOfFirst { it.id == id } + if (idx < 0) return@LaunchedEffect + val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() + repeat(6) { + if (requestNearestProgramFocus(idx, nowMin)) return@LaunchedEffect + delay(50L) } - } - var selectedDayIdx by remember { mutableIntStateOf(0) } - val activeDayIdx = selectedDayIdx.coerceIn(0, days.lastIndex) - val dayFocusRequesters = remember(days.size) { List(days.size) { FocusRequester() } } - var focusedDayIdx by remember { mutableStateOf(null) } - val showDaySelector = days.size > 1 - - LaunchedEffect(days.size, activeDayIdx) { - if (selectedDayIdx != activeDayIdx) selectedDayIdx = activeDayIdx + keepChannelFocus(idx) } - LaunchedEffect(activeDayIdx, days) { - windowStartMillis = days[activeDayIdx].startMillis - if (activeDayIdx == 0) { - with(density) { - val nowOffsetMin = ((clockTickMillis - days[0].startMillis) / 60_000L).toInt() - val targetPx = (nowOffsetMin * pxPerMin).dp.toPx().toInt() - 30.dp.toPx().toInt() - hScroll.scrollTo(targetPx.coerceAtLeast(0)) - } - } else { - hScroll.scrollTo(0) + LaunchedEffect(windowStartMillis) { + with(density) { + val nowOffsetMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() + val targetPx = (nowOffsetMin * pxPerMin).dp.toPx().toInt() - 30.dp.toPx().toInt() + hScroll.scrollTo(targetPx.coerceAtLeast(0)) } } Column( modifier = modifier.fillMaxSize().background(LiveColors.Bg), ) { - if (showDaySelector) { - // ─── Day Selector ─────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .background(LiveColors.PanelDeep) - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("ARCHIVE", style = LiveType.SectionTag.copy(color = LiveColors.FgMute)) - days.forEachIndexed { idx, day -> - val isSelected = activeDayIdx == idx - val isFocused = focusedDayIdx == idx - Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(if (isSelected) LiveColors.Accent else LiveColors.Panel) - .border( - width = if (isFocused) 2.dp else 1.dp, - color = when { - isFocused -> LiveColors.FocusRing - isSelected -> LiveColors.Accent - else -> LiveColors.Divider - }, - shape = RoundedCornerShape(4.dp), - ) - .pointerInput(Unit) { detectTapGestures { selectedDayIdx = idx } } - .focusRequester(dayFocusRequesters[idx]) - .onFocusChanged { if (it.hasFocus) focusedDayIdx = idx } - .focusable() - .onKeyEvent { ev -> - if (ev.type == KeyEventType.KeyDown) { - when (ev.key) { - Key.DirectionCenter, Key.Enter -> { - selectedDayIdx = idx - true - } - Key.DirectionDown -> { - selectedChannelFocusRequester.requestFocus() - true - } - else -> false - } - } else false - } - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = day.label, - style = LiveType.Badge.copy(color = if (isSelected) LiveColors.Bg else LiveColors.Fg) - ) - } - } - } - } - // ─── Header row ───────────────────────────────────────────── Row( modifier = Modifier @@ -405,22 +377,24 @@ fun EpgGrid( onClick = { onChannelSelect(ch, null) }, onFocused = { onChannelFocused(ch) }, onMoveLeft = onMoveLeftFromChannels, - onMoveUp = { - if (showDaySelector && idx == channelListState.firstVisibleItemIndex) { - dayFocusRequesters.getOrNull(activeDayIdx)?.requestFocus() + onMoveRight = { + val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() + onEnterEpg(ch) + if (requestNearestProgramFocus(idx, nowMin)) { true } else { - false + keepChannelFocus(idx) } + true }, onFavoriteToggle = { onChannelFavoriteToggle(ch.id) }, rowHeight = rowHeight, - forceFocused = gridFocused && ch.id == selectedChannelId, - modifier = if (ch.id == selectedChannelId) { - Modifier.focusRequester(selectedChannelFocusRequester) - } else { - Modifier - }, + forceFocused = gridFocused && + focusMode == EpgGridFocusMode.ChannelList && + ch.id == selectedChannelId, + modifier = Modifier + .then(if (idx == 0) Modifier.focusRequester(firstChannelFocusRequester) else Modifier) + .then(if (ch.id == selectedChannelId) Modifier.focusRequester(selectedChannelFocusRequester) else Modifier), ) } } @@ -439,6 +413,7 @@ fun EpgGrid( .horizontalScroll(hScroll) .onKeyEvent { ev -> if (ev.type == KeyEventType.KeyDown && ev.key == Key.Back) { + onExitEpg(selectedChannelId?.let { id -> channels.firstOrNull { it.id == id } }) selectedChannelFocusRequester.requestFocus() true } else false @@ -473,8 +448,14 @@ fun EpgGrid( stripe = idx % 2 == 1, isActive = ch.id == selectedChannelId, rowHeight = rowHeight, - onClick = { program -> onChannelSelect(ch, program) }, + onClick = { program -> onProgramSelect(ch, program) }, onFocused = { onChannelFocused(ch) }, + onMoveVertically = { targetRowIdx, anchorStartMin -> + requestNearestProgramFocus(targetRowIdx, anchorStartMin) + }, + rowIdx = idx, + focusRequesters = programFocusRequesters, + focusTargets = programFocusTargets, ) } } @@ -508,6 +489,10 @@ private fun ProgramsRow( rowHeight: Dp, onClick: (IptvProgram?) -> Unit, onFocused: () -> Unit, + onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, + rowIdx: Int, + focusRequesters: MutableMap>, + focusTargets: MutableMap>, ) { val nowMillis = clockTickMillis Box( @@ -526,13 +511,33 @@ private fun ProgramsRow( val placements = remember(programs, windowStartMillis, windowEndMillis, nowMillis) { buildProgramPlacements(programs, windowStartMillis, windowEndMillis, nowMillis) } + val focusablePlacementIndices = remember(placements, channel.catchupDays, nowMillis) { + placements.mapIndexedNotNull { index, placement -> + val canFocus = placement.canFocus(channel, nowMillis) + if (canFocus) index else null + } + } + val rowFocusRequesters = remember(channel.id, focusablePlacementIndices.size) { + List(focusablePlacementIndices.size) { FocusRequester() } + } + val rowFocusTargets = remember(placements, focusablePlacementIndices) { + focusablePlacementIndices.mapNotNull { index -> + placements.getOrNull(index)?.let { placement -> + ProgramFocusTarget(placement.startMin, placement.endMin) + } + } + } + SideEffect { + focusRequesters[channel.id] = rowFocusRequesters + focusTargets[channel.id] = rowFocusTargets + } if (placements.isNotEmpty()) { - placements.forEach { placement -> + placements.forEachIndexed { placementIndex, placement -> val offset = (placement.startMin * pxPerMin).dp val width = (placement.durationMin * pxPerMin).dp - val isCatchupSupported = channel.catchupDays > 0 && - !placement.isPlaceholder && - placement.program.startUtcMillis >= System.currentTimeMillis() - channel.catchupDays * 24 * 60 * 60_000L + val isCatchupSupported = placement.isCatchupSupported(channel, nowMillis) + val focusableIndex = focusablePlacementIndices.indexOf(placementIndex) + val isFocusable = focusableIndex >= 0 ProgramCell( program = placement.program, clockTickMillis = clockTickMillis, @@ -540,7 +545,7 @@ private fun ProgramsRow( isNow = placement.isNow, isPast = placement.isPast, isFocusTarget = placement.isNow, - focusable = !placement.isPast || isCatchupSupported, + focusable = isFocusable, isCatchupSupported = isCatchupSupported, onClick = { if (placement.isPast && isCatchupSupported) { @@ -550,7 +555,30 @@ private fun ProgramsRow( } }, onFocused = onFocused, + onMoveLeft = { + if (focusableIndex > 0) { + rowFocusRequesters[focusableIndex - 1].requestFocus() + true + } else { + true + } + }, + onMoveRight = { + if (focusableIndex in 0 until rowFocusRequesters.lastIndex) { + rowFocusRequesters[focusableIndex + 1].requestFocus() + true + } else { + false + } + }, + onMoveUp = { + onMoveVertically(rowIdx - 1, placement.startMin) + }, + onMoveDown = { + onMoveVertically(rowIdx + 1, placement.startMin) + }, rowHeight = rowHeight, + focusRequester = rowFocusRequesters.getOrNull(focusableIndex), modifier = Modifier.offset(x = offset), ) } @@ -589,8 +617,6 @@ private fun NowLine( private data class TimeSlot(val millis: Long, val label: String, val isNow: Boolean) -private data class EpgDay(val label: String, val startMillis: Long) - private fun buildHalfHourSlots(startMillis: Long, count: Int): List { val out = ArrayList(count) val now = System.currentTimeMillis() @@ -641,7 +667,25 @@ private data class ProgramPlacement( val isNow: Boolean, val isPast: Boolean, val isPlaceholder: Boolean = false, -) +) { + val endMin: Int get() = startMin + durationMin +} + +private data class ProgramFocusTarget(val startMin: Int, val endMin: Int) { + fun distanceTo(anchorStartMin: Int): Int = when { + anchorStartMin < startMin -> startMin - anchorStartMin + anchorStartMin > endMin -> anchorStartMin - endMin + else -> 0 + } +} + +private fun ProgramPlacement.isCatchupSupported(channel: EnrichedChannel, nowMillis: Long): Boolean = + channel.catchupDays > 0 && + !isPlaceholder && + program.startUtcMillis >= nowMillis - channel.catchupDays * 24L * 60L * 60_000L + +private fun ProgramPlacement.canFocus(channel: EnrichedChannel, nowMillis: Long): Boolean = + !isPast || isCatchupSupported(channel, nowMillis) private fun buildProgramPlacements( programs: List, 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 6ef4ca071..281633009 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -92,7 +92,8 @@ import java.util.concurrent.TimeUnit private enum class LiveTvFocusZone { TOPBAR, - SIDEBAR, + CATEGORY_LIST, + CHANNEL_LIST, EPG, } @@ -266,7 +267,7 @@ fun LiveTvScreen( // Selected category (persist across nav). Defaults to "all". val hasProfile = currentProfile != null val maxTopBarIndex = topBarMaxIndex(hasProfile) - var focusZone by rememberSaveable { mutableStateOf(LiveTvFocusZone.EPG) } + var focusZone by rememberSaveable { mutableStateOf(LiveTvFocusZone.CATEGORY_LIST) } var topBarFocusIndex by rememberSaveable { mutableIntStateOf(topBarSelectedIndex(SidebarItem.TV, hasProfile).coerceIn(0, maxTopBarIndex)) } @@ -346,7 +347,9 @@ fun LiveTvScreen( val sidebarExpanded = !useTouchRail var searchOpen by rememberSaveable { mutableStateOf(false) } var focusSelectedChannelSignal by remember { mutableIntStateOf(0) } - var sidebarAtTopBoundary by remember { mutableStateOf(false) } + var focusEpgSignal by remember { mutableIntStateOf(0) } + var focusSearchCategorySignal by remember { mutableIntStateOf(1) } + val rememberedChannelByCategory = remember { mutableMapOf() } // Full-screen playback mode — pressing OK on an EPG row expands the // mini-player to cover the whole screen. Back collapses back to the grid. var isFullScreen by rememberSaveable { mutableStateOf(initialStreamUrl != null) } @@ -358,7 +361,6 @@ fun LiveTvScreen( } // Focus requesters for the three regions. val sidebarFocus = remember { FocusRequester() } - val miniFocus = remember { FocusRequester() } val epgFocus = remember { FocusRequester() } val fsFocus = remember { FocusRequester() } @@ -403,9 +405,51 @@ fun LiveTvScreen( val nextIdx = ((start + delta) % size + size) % size playingChannelId = all[nextIdx].id focusedChannelId = all[nextIdx].id + rememberedChannelByCategory[selectedCategoryId] = all[nextIdx].id playingCatchupProgram = null } + fun focusPlaylistSearch() { + focusZone = LiveTvFocusZone.CATEGORY_LIST + focusSearchCategorySignal += 1 + runCatching { sidebarFocus.requestFocus() } + } + + fun focusChannelList(channelId: String? = focusedChannelId ?: playingChannelId) { + channelId?.let { + focusedChannelId = it + rememberedChannelByCategory[selectedCategoryId] = it + } + focusZone = LiveTvFocusZone.CHANNEL_LIST + focusSelectedChannelSignal += 1 + runCatching { epgFocus.requestFocus() } + } + + fun focusEpg(channelId: String) { + focusedChannelId = channelId + rememberedChannelByCategory[selectedCategoryId] = channelId + focusZone = LiveTvFocusZone.EPG + focusEpgSignal += 1 + runCatching { epgFocus.requestFocus() } + } + + fun playChannelFullscreen(channel: EnrichedChannel) { + focusedChannelId = channel.id + rememberedChannelByCategory[selectedCategoryId] = channel.id + playingChannelId = channel.id + playingCatchupProgram = null + isFullScreen = true + hudPokeSignal++ + } + + fun playProgramInMini(channel: EnrichedChannel, program: IptvProgram?) { + focusedChannelId = channel.id + rememberedChannelByCategory[selectedCategoryId] = channel.id + playingChannelId = channel.id + playingCatchupProgram = program + focusChannelList(channel.id) + } + // ExoPlayer lifecycle — mirrors the legacy screen's setup verbatim so live // IPTV behaviour (buffer, retries, chunkless HLS) stays identical. val iptvHttpClient = remember { @@ -517,17 +561,30 @@ fun LiveTvScreen( } } - // Make sure focus lands on the EPG when the screen settles — matches the - // spec's default-focus diagram ("mini → EPG on DPAD_DOWN"). + // Default IPTV entry is the playlist/category rail, focused on Search. LaunchedEffect(enrichedState.value !== EnrichedChannels.Empty) { if (!isTouchDevice && enrichedState.value !== EnrichedChannels.Empty) { - runCatching { epgFocus.requestFocus() } + focusPlaylistSearch() } } BackHandler(enabled = searchOpen) { searchOpen = false } - BackHandler(enabled = !searchOpen && isFullScreen) { isFullScreen = false } - BackHandler(enabled = !searchOpen && !isFullScreen) { onBack() } + BackHandler(enabled = !searchOpen && isFullScreen) { + isFullScreen = false + focusChannelList(playingChannelId ?: focusedChannelId) + } + BackHandler(enabled = !searchOpen && !isFullScreen) { + when (focusZone) { + LiveTvFocusZone.EPG -> focusChannelList(focusedChannelId ?: playingChannelId) + LiveTvFocusZone.CHANNEL_LIST -> focusPlaylistSearch() + LiveTvFocusZone.CATEGORY_LIST -> { + topBarFocusIndex = topBarSelectedIndex(SidebarItem.TV, hasProfile) + .coerceIn(0, maxTopBarIndex) + focusZone = LiveTvFocusZone.TOPBAR + } + LiveTvFocusZone.TOPBAR -> onBack() + } + } Box( modifier = Modifier @@ -555,8 +612,7 @@ fun LiveTvScreen( true } Key.DirectionDown -> { - focusZone = LiveTvFocusZone.SIDEBAR - runCatching { sidebarFocus.requestFocus() } + focusPlaylistSearch() true } Key.DirectionCenter, Key.Enter -> { @@ -577,16 +633,8 @@ fun LiveTvScreen( else -> false } } - LiveTvFocusZone.SIDEBAR -> { - if (event.key == Key.DirectionUp && sidebarAtTopBoundary) { - topBarFocusIndex = topBarSelectedIndex(SidebarItem.TV, hasProfile) - .coerceIn(0, maxTopBarIndex) - focusZone = LiveTvFocusZone.TOPBAR - true - } else { - false - } - } + LiveTvFocusZone.CATEGORY_LIST -> false + LiveTvFocusZone.CHANNEL_LIST -> false LiveTvFocusZone.EPG -> false } } @@ -647,24 +695,25 @@ fun LiveTvScreen( nowNext = state.snapshot.nowNext, selectedChannelId = focusedChannelId ?: playingChannelId, focusSelectedChannelSignal = focusSelectedChannelSignal, + focusEpgSignal = focusEpgSignal, + focusMode = if (focusZone == LiveTvFocusZone.EPG) { + EpgGridFocusMode.Epg + } else { + EpgGridFocusMode.ChannelList + }, compact = true, gridFocused = focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel, program -> + onChannelSelect = { channel, _ -> playChannelFullscreen(channel) }, + onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, + onChannelFocused = { channel -> focusedChannelId = channel.id - if (channel.id == playingChannelId && playingCatchupProgram == program && !isFullScreen) { - isFullScreen = true - } else { - playingChannelId = channel.id - playingCatchupProgram = program - } + rememberedChannelByCategory[selectedCategoryId] = channel.id }, - onChannelFocused = { channel -> focusedChannelId = channel.id }, onChannelFavoriteToggle = { id -> viewModel.toggleFavoriteChannel(id) }, favorites = favSet, - onMoveLeftFromChannels = { - focusZone = LiveTvFocusZone.SIDEBAR - runCatching { sidebarFocus.requestFocus() } - }, + onMoveLeftFromChannels = { focusPlaylistSearch() }, + onEnterEpg = { channel -> focusEpg(channel.id) }, + onExitEpg = { channel -> focusChannelList(channel?.id ?: focusedChannelId ?: playingChannelId) }, modifier = Modifier.fillMaxSize(), ) } @@ -693,13 +742,21 @@ fun LiveTvScreen( onMoveCategoryDown = { groupName -> viewModel.moveGroupDown(groupName) }, - onFocusEnter = { focusZone = LiveTvFocusZone.SIDEBAR }, + onFocusEnter = { + if (focusZone != LiveTvFocusZone.TOPBAR) { + focusZone = LiveTvFocusZone.CATEGORY_LIST + } + }, onMoveRight = { - focusZone = LiveTvFocusZone.EPG - focusSelectedChannelSignal += 1 - runCatching { epgFocus.requestFocus() } + val remembered = rememberedChannelByCategory[selectedCategoryId] + ?.takeIf { id -> filteredChannels.any { it.id == id } } + val target = remembered + ?: focusedChannelId?.takeIf { id -> filteredChannels.any { it.id == id } } + ?: playingChannelId?.takeIf { id -> filteredChannels.any { it.id == id } } + ?: filteredChannels.firstOrNull()?.id + focusChannelList(target) }, - onTopBoundaryFocusChanged = { sidebarAtTopBoundary = it }, + focusSearchSignal = focusSearchCategorySignal, modifier = Modifier .fillMaxHeight() .padding(top = contentTopPadding) @@ -720,9 +777,7 @@ fun LiveTvScreen( favoriteSet = favSet, onFullscreenClick = openFullScreenPlayer, compact = compactTouchLayout, - modifier = Modifier - .fillMaxWidth() - .then(if (!isTouchDevice) Modifier.focusRequester(miniFocus) else Modifier), + modifier = Modifier.fillMaxWidth(), ) EpgGrid( channels = filteredChannels, @@ -730,37 +785,30 @@ fun LiveTvScreen( nowNext = state.snapshot.nowNext, selectedChannelId = focusedChannelId ?: playingChannelId, focusSelectedChannelSignal = focusSelectedChannelSignal, + focusEpgSignal = focusEpgSignal, + focusMode = if (focusZone == LiveTvFocusZone.EPG) { + EpgGridFocusMode.Epg + } else { + EpgGridFocusMode.ChannelList + }, compact = compactTouchLayout, - gridFocused = focusZone == LiveTvFocusZone.EPG, - onChannelSelect = { channel, program -> - // Two-step activation: - // 1st tap on a channel → tune it in the mini- - // player so the user can preview without - // committing to fullscreen. - // 2nd tap on the same (already-playing) channel - // → enlarge to fullscreen. - // Picking a different channel while already full- - // screen swaps the stream but keeps fullscreen. + gridFocused = focusZone == LiveTvFocusZone.CHANNEL_LIST || focusZone == LiveTvFocusZone.EPG, + onChannelSelect = { channel, _ -> playChannelFullscreen(channel) }, + onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, + onChannelFocused = { channel -> focusedChannelId = channel.id - if (channel.id == playingChannelId && playingCatchupProgram == program && !isFullScreen) { - isFullScreen = true - } else { - playingChannelId = channel.id - playingCatchupProgram = program - } + rememberedChannelByCategory[selectedCategoryId] = channel.id }, - onChannelFocused = { channel -> focusedChannelId = channel.id }, onChannelFavoriteToggle = { id -> viewModel.toggleFavoriteChannel(id) }, favorites = favSet, - onMoveLeftFromChannels = { - focusZone = LiveTvFocusZone.SIDEBAR - runCatching { sidebarFocus.requestFocus() } - }, + onMoveLeftFromChannels = { focusPlaylistSearch() }, + onEnterEpg = { channel -> focusEpg(channel.id) }, + onExitEpg = { channel -> focusChannelList(channel?.id ?: focusedChannelId ?: playingChannelId) }, modifier = Modifier .fillMaxSize() .onFocusChanged { - if (it.hasFocus) { - focusZone = LiveTvFocusZone.EPG + if (it.hasFocus && focusZone == LiveTvFocusZone.CATEGORY_LIST) { + focusZone = LiveTvFocusZone.CHANNEL_LIST } } .then(if (!isTouchDevice) Modifier.focusRequester(epgFocus) else Modifier), @@ -867,8 +915,8 @@ fun LiveTvScreen( selectedCategoryId = bestCategoryIdForChannel(channel, enrichedState.value.tree) playingChannelId = channel.id focusedChannelId = channel.id - focusSelectedChannelSignal += 1 searchOpen = false + focusChannelList(channel.id) }, ) } 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 bf7a36a18..caa72440a 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 @@ -28,6 +28,8 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -62,7 +64,12 @@ fun ProgramCell( isCatchupSupported: Boolean = false, onClick: () -> Unit, onFocused: () -> Unit = {}, + onMoveLeft: () -> Boolean = { false }, + onMoveRight: () -> Boolean = { false }, + onMoveUp: () -> Boolean = { false }, + onMoveDown: () -> Boolean = { false }, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, + focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, ) { var focused by remember { mutableStateOf(false) } @@ -104,6 +111,13 @@ fun ProgramCell( scaleX = scale scaleY = scale } + .then( + if (focusable && focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + ) .then( if (focusable) { Modifier.onFocusChanged { @@ -126,10 +140,18 @@ fun ProgramCell( .then( if (focusable) { Modifier.onKeyEvent { ev -> - if (ev.type == KeyEventType.KeyDown && - (ev.key == Key.DirectionCenter || ev.key == Key.Enter)) { - onClick(); true - } else false + if (ev.type != KeyEventType.KeyDown) return@onKeyEvent false + when (ev.key) { + Key.DirectionLeft -> onMoveLeft() + Key.DirectionRight -> onMoveRight() + Key.DirectionUp -> onMoveUp() + Key.DirectionDown -> onMoveDown() + Key.DirectionCenter, Key.Enter -> { + onClick() + true + } + else -> false + } } } else { Modifier From cc5e8ce460a43cc10b7ce25edfdd26f879f86531 Mon Sep 17 00:00:00 2001 From: Arvin Date: Mon, 18 May 2026 10:57:46 +0200 Subject: [PATCH 4/4] Fix catchup EPG left navigation --- .../kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 c72f91e47..9cc695101 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 @@ -453,6 +453,10 @@ fun EpgGrid( onMoveVertically = { targetRowIdx, anchorStartMin -> requestNearestProgramFocus(targetRowIdx, anchorStartMin) }, + onMoveLeftFromStart = { + onExitEpg(ch) + true + }, rowIdx = idx, focusRequesters = programFocusRequesters, focusTargets = programFocusTargets, @@ -490,6 +494,7 @@ private fun ProgramsRow( onClick: (IptvProgram?) -> Unit, onFocused: () -> Unit, onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, + onMoveLeftFromStart: () -> Boolean, rowIdx: Int, focusRequesters: MutableMap>, focusTargets: MutableMap>, @@ -560,7 +565,7 @@ private fun ProgramsRow( rowFocusRequesters[focusableIndex - 1].requestFocus() true } else { - true + onMoveLeftFromStart() } }, onMoveRight = {