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..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 @@ -578,6 +579,62 @@ 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).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/$streamId.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("{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"))) + .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("{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"))) + .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()) @@ -1211,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) + @@ -1251,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 @@ -1336,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) @@ -1753,7 +1818,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 +3640,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 +3760,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.tvArchiveDuration ?: stream.tvArchive ?: 0).coerceAtLeast(0), + catchupType = if ((stream.tvArchive ?: 0) > 0 || (stream.tvArchiveDuration ?: 0) > 0) "xtream" else null ) } } @@ -4000,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 } + ) } @@ -4100,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. @@ -4118,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() } @@ -4172,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) @@ -4185,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 @@ -4315,6 +4403,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 +4414,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 +4437,8 @@ 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 recentCutoff = oldestRecentCutoff(channels, nowUtc) + val keyLookup = buildChannelKeyLookup(channels) val xmlChannelNameMap = mutableMapOf>() val nowCandidates = mutableMapOf() @@ -4425,9 +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() } - if (recent.size < epgRecentProgramLimit) recent.add(program) + val limit = recentProgramLimitForChannel(channel) + addRecentCandidate(recent, program, limit) } } currentChannelKey = null @@ -4457,13 +4553,14 @@ class IptvRepository @Inject constructor( ): Map { if (channels.isEmpty()) return emptyMap() + val nowUtc = System.currentTimeMillis() + val recentCutoff = oldestRecentCutoff(channels, nowUtc) + 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 @@ -4573,10 +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() } - if (recent.size < epgRecentProgramLimit) recent.add(program) + val limit = recentProgramLimitForChannel(channel) + addRecentCandidate(recent, program, limit) } } currentChannelKey = null @@ -4637,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 { @@ -4920,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(), @@ -4934,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/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 33bd6dff1..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 @@ -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 @@ -39,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 @@ -67,6 +69,8 @@ fun ChannelRow( onClick: () -> Unit, onFavoriteToggle: () -> Unit, onMoveLeft: () -> Unit = {}, + onMoveRight: () -> Boolean = { false }, + onMoveUp: () -> Boolean = { false }, onFocused: () -> Unit = {}, rowHeight: androidx.compose.ui.unit.Dp = LiveDims.EpgRowHeight, forceFocused: Boolean = false, @@ -109,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, @@ -119,10 +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 && ev.key == Key.DirectionLeft) { - onMoveLeft() - return@onKeyEvent true - } val isLongHoldCenter = ev.type == KeyEventType.KeyDown && (ev.key == Key.DirectionCenter || ev.key == Key.Enter) && ev.nativeKeyEvent.repeatCount == 1 @@ -185,6 +195,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 4ceb5da19..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 @@ -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, - onChannelSelect: (EnrichedChannel) -> Unit, + 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 = LiveDims.EpgPxPerMinute + 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 = if (compact) 96.dp else LiveDims.EpgHalfHourWidth + 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. - val windowStartMillis = remember { 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 + } + val windowEndMillis = remember(todayStartMillis) { + todayStartMillis + EpgWindowMinutes * 60L * 1000L + } + val slotCount = remember(windowStartMillis, windowEndMillis) { + (((windowEndMillis - windowStartMillis) / 60_000L) / 30L).toInt().coerceAtLeast(1) } - // 5 half-hour slots across the window. - val slots = remember(windowStartMillis) { buildHalfHourSlots(windowStartMillis, EpgWindowSlotCount) } + val slots = remember(windowStartMillis, slotCount) { buildHalfHourSlots(windowStartMillis, slotCount) } // Shared horizontal scroll state between header and body rows. val hScroll = rememberScrollState() @@ -160,15 +167,49 @@ 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 + } - // 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)) + 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 @@ -197,6 +238,27 @@ fun EpgGrid( runCatching { selectedChannelFocusRequester.requestFocus() } } + 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) + } + keepChannelFocus(idx) + } + + 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), ) { @@ -261,19 +323,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), + ) + } } } } @@ -310,17 +374,27 @@ 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, + onMoveRight = { + val nowMin = ((clockTickMillis - windowStartMillis) / 60_000L).toInt() + onEnterEpg(ch) + if (requestNearestProgramFocus(idx, nowMin)) { + true + } else { + 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), ) } } @@ -334,10 +408,16 @@ 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) { + onExitEpg(selectedChannelId?.let { id -> channels.firstOrNull { it.id == id } }) + selectedChannelFocusRequester.requestFocus() + true + } else false + }, ) { itemsIndexed( channels, @@ -368,18 +448,30 @@ fun EpgGrid( stripe = idx % 2 == 1, isActive = ch.id == selectedChannelId, rowHeight = rowHeight, - onClick = { onChannelSelect(ch) }, + onClick = { program -> onProgramSelect(ch, program) }, onFocused = { onChannelFocused(ch) }, + onMoveVertically = { targetRowIdx, anchorStartMin -> + requestNearestProgramFocus(targetRowIdx, anchorStartMin) + }, + onMoveLeftFromStart = { + onExitEpg(ch) + true + }, + rowIdx = idx, + focusRequesters = programFocusRequesters, + focusTargets = programFocusTargets, ) } } // 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, + ) + } } } } @@ -395,12 +487,17 @@ private fun ProgramsRow( windowStartMillis: Long, windowEndMillis: Long, totalWidth: Dp, - pxPerMin: Int, + pxPerMin: Float, stripe: Boolean, isActive: Boolean, rowHeight: Dp, - onClick: () -> Unit, + onClick: (IptvProgram?) -> Unit, onFocused: () -> Unit, + onMoveVertically: (rowIdx: Int, anchorStartMin: Int) -> Boolean, + onMoveLeftFromStart: () -> Boolean, + rowIdx: Int, + focusRequesters: MutableMap>, + focusTargets: MutableMap>, ) { val nowMillis = clockTickMillis Box( @@ -419,10 +516,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 = placement.isCatchupSupported(channel, nowMillis) + val focusableIndex = focusablePlacementIndices.indexOf(placementIndex) + val isFocusable = focusableIndex >= 0 ProgramCell( program = placement.program, clockTickMillis = clockTickMillis, @@ -430,10 +550,40 @@ private fun ProgramsRow( isNow = placement.isNow, isPast = placement.isPast, isFocusTarget = placement.isNow, - focusable = false, - onClick = onClick, + focusable = isFocusable, + isCatchupSupported = isCatchupSupported, + onClick = { + if (placement.isPast && isCatchupSupported) { + onClick(placement.program) + } else if (!placement.isPast) { + onClick(null) + } + }, onFocused = onFocused, + onMoveLeft = { + if (focusableIndex > 0) { + rowFocusRequesters[focusableIndex - 1].requestFocus() + true + } else { + onMoveLeftFromStart() + } + }, + 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), ) } @@ -446,7 +596,7 @@ private fun ProgramsRow( private fun NowLine( clockTickMillis: Long, windowStartMillis: Long, - pxPerMin: Int, + pxPerMin: Float, hScrollOffsetPx: Int, ) { val density = LocalDensity.current @@ -483,16 +633,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( @@ -520,8 +670,27 @@ private data class ProgramPlacement( val startMin: Int, val durationMin: Int, val isNow: Boolean, - val isPast: 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, @@ -529,23 +698,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/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..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 @@ -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 @@ -91,7 +92,8 @@ import java.util.concurrent.TimeUnit private enum class LiveTvFocusZone { TOPBAR, - SIDEBAR, + CATEGORY_LIST, + CHANNEL_LIST, EPG, } @@ -265,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)) } @@ -290,6 +292,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 } @@ -344,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) } @@ -356,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() } @@ -401,6 +405,49 @@ 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 @@ -463,7 +510,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) { @@ -472,17 +527,21 @@ fun LiveTvScreen( } } } - LaunchedEffect(currentStreamUrl) { + LaunchedEffect(currentStreamUrl, playingCatchupProgram) { val stream = currentStreamUrl ?: return@LaunchedEffect delay(90L) 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() @@ -502,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 @@ -540,8 +612,7 @@ fun LiveTvScreen( true } Key.DirectionDown -> { - focusZone = LiveTvFocusZone.SIDEBAR - runCatching { sidebarFocus.requestFocus() } + focusPlaylistSearch() true } Key.DirectionCenter, Key.Enter -> { @@ -562,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 } } @@ -632,23 +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 -> + onChannelSelect = { channel, _ -> playChannelFullscreen(channel) }, + onProgramSelect = { channel, program -> playProgramInMini(channel, program) }, + onChannelFocused = { channel -> focusedChannelId = channel.id - if (channel.id == playingChannelId && !isFullScreen) { - isFullScreen = true - } else { - playingChannelId = channel.id - } + 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(), ) } @@ -677,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) @@ -704,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, @@ -714,36 +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 -> - // 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 && !isFullScreen) { - isFullScreen = true - } else { - playingChannelId = channel.id - } + 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), @@ -850,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 5da131937..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 @@ -59,9 +61,15 @@ fun ProgramCell( isPast: Boolean, isFocusTarget: Boolean, focusable: Boolean = true, + 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) } @@ -86,7 +94,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", ) @@ -103,6 +111,13 @@ fun ProgramCell( scaleX = scale scaleY = scale } + .then( + if (focusable && focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + } + ) .then( if (focusable) { Modifier.onFocusChanged { @@ -125,16 +140,30 @@ 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 } ) - .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) { @@ -157,11 +186,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