From 11500dfae69d32968f87bc7613b2ea0937c8e99d Mon Sep 17 00:00:00 2001 From: Pauljd Date: Mon, 2 Nov 2026 09:23:06 +0000 Subject: [PATCH] Rework the guide grid and stop loop programmes overlapping the plan (0.52.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- android/app/build.gradle.kts | 4 +- .../java/tv/orbit/classic/ChannelSchedule.kt | 6 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../main/java/tv/orbit/classic/GuideGrid.kt | 54 ++++++++++-------- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../main/res/drawable-nodpi/rewind_epg.webp | Bin 6816 -> 6862 bytes .../tv/orbit/classic/ChannelScheduleTest.kt | 19 ++++++ 7 files changed, 58 insertions(+), 29 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0990da9..515c076 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 52 - versionName = "0.51.0-channel-list" + versionCode = 53 + versionName = "0.52.0-grid-rework" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index f489645..231cee9 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -244,6 +244,10 @@ internal class ScheduledChannel(val loop: SampleChannel, plan: ChannelPlan) : Sa else -> return SampleBroadcast(entry.media, entry.startMs) } } - return loop.at(timeMs) + // Between plans the loop fills in, cut short so it never runs over the next planned programme. + val fill = loop.at(timeMs) + val next = entries.getOrNull(low) ?: return fill + if (fill.endMs <= next.startMs) return fill + return fill.copy(media = fill.media.copy(durationMs = next.startMs - fill.startMs)) } } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 8e9a720..974dc03 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -41,7 +41,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.51.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.52.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 2a9c4d7..fce2f1c 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -61,6 +61,18 @@ internal fun guideDayPrefix(timeMs: Long, nowMs: Long): String { internal fun guideDayJump(cursorMs: Long, nowMs: Long, forward: Boolean): Long = (maxOf(cursorMs, nowMs) + if (forward) 24 * 60 * 60_000L else -24 * 60 * 60_000L).coerceIn(nowMs, nowMs + GUIDE_MAX_SLOTS_AHEAD * GUIDE_SLOT_MS - 1) +internal enum class GuideCellTime { Past, Now, Later } + +/** Only the programme actually on air is lit, so neighbouring programmes can't read as one. */ +internal fun guideCellTime(startMs: Long, endMs: Long, nowMs: Long) = when { + endMs <= nowMs -> GuideCellTime.Past + startMs <= nowMs -> GuideCellTime.Now + else -> GuideCellTime.Later +} + +private val GuideChannelColumn = Color(0xFF0A1B3C) +private val GuidePast = Color(0xFF0D2247) + /** The first row to draw, moving only as far as it takes to keep the chosen row on screen. */ internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int { val last = (count - visible).coerceAtLeast(0) @@ -90,7 +102,6 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif var slotsAhead by remember { mutableIntStateOf(0) } var firstRow by remember { mutableIntStateOf(0) } val gridFocus = remember { FocusRequester() } - val toolsFocus = remember { FocusRequester() } var gridFocused by remember { mutableStateOf(true) } LaunchedEffect(Unit) { gridFocus.requestFocus() } BackHandler(onBack = onBack) @@ -107,41 +118,36 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif slotsAhead = ahead } Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { - Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth().height(135.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { val branding = ChannelBrands.forNumber(channel.number) // The picture stays on the tuned channel while the cursor browses, so moving rows never re-tunes. - Box(Modifier.width(170.dp).fillMaxHeight().background(Panel)) { + Box(Modifier.width(240.dp).fillMaxHeight().background(Panel)) { picture() } - // Four lines fill the panel's height. OK Watch is already in the legend below. - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(90.dp).height(28.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(115.dp).height(36.dp)) else Text(ChannelBrands.displayName(channel), color = Color.White, fontSize = 16.sp) val (focusedName, focusedDetail) = guideTitleParts(focused.media.item.title) - Text(focusedName, color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (focusedDetail != null) Text(focusedDetail, color = Color.White, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 12.sp, maxLines = 1) - } - Column(verticalArrangement = Arrangement.Top) { - OrbitButton("Test transition", Modifier.focusRequester(toolsFocus).onPreviewKeyEvent { event -> - if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_DOWN && event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { gridFocus.requestFocus(); true } else false - }, onClick = { onWatch(channel.number, channel.transitionCheckOffset(System.currentTimeMillis())) }) + Text(focusedName, color = Color.White, fontSize = 21.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (focusedDetail != null) Text(focusedDetail, color = Color.White, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 13.sp, maxLines = 1) } } Row(Modifier.fillMaxWidth().height(20.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) { - Text(guideDayPrefix(windowStart, now).trim().ifEmpty { title }, Modifier.width(130.dp).padding(start = 8.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(guideDayPrefix(windowStart, now).trim().ifEmpty { title }, Modifier.width(136.dp).padding(start = 8.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Row(Modifier.weight(1f)) { repeat(GUIDE_SLOTS) { Text(clock(windowStart + it * GUIDE_SLOT_MS), Modifier.weight(1f).padding(start = 4.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } } } BoxWithConstraints(Modifier.fillMaxWidth().weight(1f)) { - val visibleRows = ((maxHeight + 3.dp) / 43.dp).toInt().coerceAtLeast(1) + val visibleRows = ((maxHeight + 3.dp) / 41.dp).toInt().coerceAtLeast(1) firstRow = guideFirstRow(row, firstRow, visibleRows, channels.size) Column(Modifier.fillMaxWidth().focusRequester(gridFocus).onFocusChanged { gridFocused = it.isFocused }.focusable().onPreviewKeyEvent { event -> val native = event.nativeKeyEvent if (native.action != KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent false when (native.keyCode) { - KeyEvent.KEYCODE_DPAD_UP -> { if (row > 0) row-- else toolsFocus.requestFocus(); true } + // At the top row, up leaves the grid for the gear. + KeyEvent.KEYCODE_DPAD_UP -> if (row > 0) { row--; true } else false KeyEvent.KEYCODE_DPAD_DOWN -> { if (row < channels.lastIndex) row++; true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT -> { val forward = native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT @@ -160,9 +166,9 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif }, verticalArrangement = Arrangement.spacedBy(3.dp)) { channels.subList(firstRow, minOf(firstRow + visibleRows, channels.size)).forEachIndexed { offset, line -> val index = firstRow + offset - Row(Modifier.fillMaxWidth().height(40.dp), horizontalArrangement = Arrangement.spacedBy(0.dp)) { + Row(Modifier.fillMaxWidth().height(38.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { val mark = ChannelBrands.forNumber(line.number) - Row(Modifier.width(130.dp).fillMaxHeight().background(Panel).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.width(130.dp).fillMaxHeight().background(if (index == row) Blue else GuideChannelColumn).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically) { Text(line.number.toString(), Modifier.width(30.dp), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(88.dp).height(28.dp)) else Text(ChannelBrands.displayName(line), color = Color.White, fontSize = 13.sp, maxLines = 1) @@ -170,17 +176,17 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { guideCells(line, windowStart, windowEnd).forEach { cell -> val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs - val onNow = cell.show.startMs <= now - Box(Modifier.offset(x = maxWidth * cell.startFraction).width((maxWidth * cell.widthFraction - 2.dp).coerceAtLeast(1.dp)).fillMaxHeight() - .background(if (isFocus) Yellow else if (onNow) Blue else Panel).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) { + val time = guideCellTime(cell.show.startMs, cell.show.endMs, now) + Box(Modifier.offset(x = maxWidth * cell.startFraction).width((maxWidth * cell.widthFraction - 3.dp).coerceAtLeast(1.dp)).fillMaxHeight() + .background(if (isFocus) Yellow else when (time) { GuideCellTime.Past -> GuidePast; GuideCellTime.Now -> Blue; GuideCellTime.Later -> Panel }).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) { // Too narrow for a readable word, so the panel above carries the name. if (cell.widthFraction >= .09f) { - Text(guideTitleParts(cell.show.media.item.title).first, color = if (isFocus) Navy else Color.White, fontSize = 13.sp, fontWeight = if (isFocus) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(guideTitleParts(cell.show.media.item.title).first, color = if (isFocus) Navy else if (time == GuideCellTime.Past) Muted else Color.White, fontSize = 13.sp, fontWeight = if (isFocus || time == GuideCellTime.Now) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } guideNowFraction(now, windowStart, windowEnd)?.let { fraction -> - Box(Modifier.offset(x = maxWidth * fraction).width(2.dp).fillMaxHeight().background(Color.White.copy(alpha = .6f))) + Box(Modifier.offset(x = maxWidth * fraction).width(1.dp).fillMaxHeight().background(Color.White.copy(alpha = .4f))) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 4d91e7b..c587d6c 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.51.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.52.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/main/res/drawable-nodpi/rewind_epg.webp b/android/app/src/main/res/drawable-nodpi/rewind_epg.webp index 5a0aefabe65b4b2eb63b649954cfbc3b950bfe8e..d652a62952e3cb3d497f8e1de870275fedef342b 100644 GIT binary patch literal 6862 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 OR)?V7m4?6o0002dICXOX diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt index d74750d..005494d 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt @@ -450,4 +450,23 @@ class ChannelScheduleTest { topUpPlan(first, ScheduleCursor(), pool, now + minute, avoid = { _, _, _ -> asked = true; false }) assertFalse(asked) } + + @Test fun theLoopNeverRunsOverThePlansFirstProgramme() { + val longLoop = SampleChannel(listOf(ScheduledMedia(SampleItem("x", "Episode", 60, "", "x", 60 * minute), "s-x", 60 * minute), + ScheduledMedia(SampleItem("y", "Episode", 60, "", "y", 60 * minute), "s-y", 60 * minute)), "Paradox", 152) + val plan = topUpPlan(null, ScheduleCursor(), pool, now)!! + val channel = ScheduledChannel(longLoop, plan) + val before = channel.at(now - minute) + assertEquals(now, before.endMs) + val cells = guideCells(channel, now - 30 * minute, now + 60 * minute) + assertTrue(cells.zipWithNext().all { (a, b) -> a.show.endMs <= b.show.startMs }) + assertEquals(plan.entries.first().media, cells.first { it.show.startMs >= now }.show.media) + } + + @Test fun onlyTheProgrammeOnAirIsLit() { + assertEquals(GuideCellTime.Past, guideCellTime(100, 200, 200)) + assertEquals(GuideCellTime.Now, guideCellTime(100, 200, 100)) + assertEquals(GuideCellTime.Now, guideCellTime(100, 200, 199)) + assertEquals(GuideCellTime.Later, guideCellTime(100, 200, 99)) + } }