Add the real grid guide (0.24.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m50s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m21s

Channels down the side and times across, from each channel's own clock,
replacing the one-channel guide. Live picture-in-picture comes later.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 22:22:58 +00:00
co-authored by Claude Opus 5.5
parent afa177c550
commit 7e6fbad5d7
8 changed files with 209 additions and 7 deletions
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.24.0 — grid guide
Paul confirmed on 0.23 that Vortex tunes, and up, down and left work while watching.
TV Guide now shows the real channels down the side and a two-hour window of half-hour slots across, drawn from each channel's own clock, with a now line. Up and down move between channels, left and right between programmes, and the window moves with the cursor, up to a day ahead. It never goes earlier than now. OK watches when the highlighted programme is on now; future programmes only show their details. A highlighted channel's title, time and logo sit at the top.
The picture slot shows the channel's artwork for now, not live video. Live picture-in-picture is a separate step. "Test transition" is kept, above the grid, as a developer tool. Programme descriptions aren't shown, because channels don't store them yet. The old single-channel guide function is unused and can go in a later tidy-up.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 126 unit tests, zero failures or errors, no lint errors. New tests cover cells filling the window, agreement with the clock, clipping, slots and cursor moves. Layout, focus and remote feel have only been built, not seen, and need the Xiaomi test.
Rollback: reinstall outputs/revision-0.23.0, or revert this commit. Data formats are unchanged.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 23
versionName = "0.23.0-tuning-retry"
versionCode = 24
versionName = "0.24.0-grid-guide"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.23.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.24.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -230,10 +230,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val previous = lastNumber
if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 }
})
SampleChannelGuide(liveChannel, modifier, onTune = { offset ->
GuideGrid(availableChannels, selectedNumber, modifier, onWatch = { number, offset ->
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
channelClockOffset = offset; watchingChannel = true
}, onBack = onBack, otherChannels = availableChannels.filter { it.number != selectedNumber },
onSwitch = { lastNumber = selectedNumber; selectedNumber = it; watchingChannel = false; channelClockOffset = 0 })
}, onBack = onBack)
return
}
if (!visible) return
@@ -0,0 +1,145 @@
package tv.orbit.classic
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
internal const val GUIDE_SLOT_MS = 30 * 60_000L
internal const val GUIDE_SLOTS = 4
private const val GUIDE_MAX_SLOTS_AHEAD = 48
internal data class GuideCell(val show: SampleBroadcast, val startFraction: Float, val widthFraction: Float)
/** The programmes a channel airs inside a window, clipped to it. The channel's own clock decides everything. */
internal fun guideCells(channel: SampleChannel, windowStart: Long, windowEnd: Long): List<GuideCell> {
require(windowEnd > windowStart)
val span = (windowEnd - windowStart).toFloat()
val cells = mutableListOf<GuideCell>()
var show = channel.at(windowStart)
while (show.startMs < windowEnd) {
val from = maxOf(show.startMs, windowStart)
val to = minOf(show.endMs, windowEnd)
cells += GuideCell(show, (from - windowStart) / span, (to - from) / span)
show = channel.at(show.endMs)
}
return cells
}
internal fun guideWindowStart(nowMs: Long, slotsAhead: Int) = Math.floorDiv(nowMs, GUIDE_SLOT_MS) * GUIDE_SLOT_MS + slotsAhead * GUIDE_SLOT_MS
/** Cursor time after moving one programme along a channel, never earlier than now. */
internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forward: Boolean): Long {
val show = channel.at(maxOf(cursorMs, nowMs))
return if (forward) show.endMs else maxOf(show.startMs - 1, nowMs)
}
@Composable
internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
var row by remember { mutableIntStateOf(channels.indexOfFirst { it.number == selectedNumber }.coerceAtLeast(0)) }
var cursor by remember { mutableLongStateOf(now) }
var slotsAhead 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)
val windowStart = guideWindowStart(now, slotsAhead)
val windowEnd = windowStart + GUIDE_SLOTS * GUIDE_SLOT_MS
val channel = channels[row.coerceIn(channels.indices)]
val focused = channel.at(maxOf(cursor, now))
fun keepCursorInWindow(time: Long) {
cursor = time
val base = guideWindowStart(now, 0)
var ahead = slotsAhead
while (time >= base + (ahead + GUIDE_SLOTS) * GUIDE_SLOT_MS && ahead < GUIDE_MAX_SLOTS_AHEAD) ahead++
while (time < base + ahead * GUIDE_SLOT_MS && ahead > 0) ahead--
slotsAhead = ahead
}
Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth().height(126.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val branding = ChannelBrands.forNumber(channel.number)
Box(Modifier.width(224.dp).fillMaxHeight().background(Panel)) {
if (branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(112.dp).height(35.dp))
else Text(ChannelBrands.displayName(channel), color = Color.White, fontSize = 16.sp)
Text(focused.media.item.title, color = Color.White, fontSize = 21.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 13.sp, maxLines = 1)
Text(if (focused.startMs <= now) "OK Watch" else "On later", color = Muted, fontSize = 12.sp)
}
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())) })
}
}
Row(Modifier.fillMaxWidth().height(24.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) {
Text("All Channels", Modifier.width(150.dp).padding(start = 8.dp), color = Color.White, fontSize = 13.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 = 13.sp, fontWeight = FontWeight.SemiBold) }
}
}
Column(Modifier.fillMaxWidth().weight(1f).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 }
KeyEvent.KEYCODE_DPAD_DOWN -> { if (row < channels.lastIndex) row++; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, true)); true
}
KeyEvent.KEYCODE_DPAD_LEFT -> { keepCursorInWindow(guideStep(channel, cursor, now, false)); true }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (native.repeatCount == 0 && focused.startMs <= now) onWatch(channel.number, 0); true
}
else -> false
}
}, verticalArrangement = Arrangement.spacedBy(4.dp)) {
channels.forEachIndexed { index, line ->
Row(Modifier.fillMaxWidth().weight(1f).widthIn(max = 2000.dp), horizontalArrangement = Arrangement.spacedBy(0.dp)) {
val mark = ChannelBrands.forNumber(line.number)
Row(Modifier.width(150.dp).fillMaxHeight().background(Panel).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Text(line.number.toString(), Modifier.width(34.dp), color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(104.dp).height(32.dp))
else Text(ChannelBrands.displayName(line), color = Color.White, fontSize = 13.sp, maxLines = 1)
}
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) {
Text(cell.show.media.item.title, color = if (isFocus) Navy else Color.White, fontSize = 14.sp, fontWeight = if (isFocus) FontWeight.SemiBold else FontWeight.Normal, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (now in windowStart until windowEnd)
Box(Modifier.offset(x = maxWidth * ((now - windowStart).toFloat() / (windowEnd - windowStart))).width(2.dp).fillMaxHeight().background(Color.White))
}
}
}
}
Text("▲▼ Channel ◄► Time OK Watch Back Menu", color = Muted, fontSize = 12.sp)
}
}
@@ -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.23.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.24.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()) {
@@ -0,0 +1,42 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
class GuideGridTest {
private val minute = 60_000L
private fun media(id: String, minutes: Long) = ScheduledMedia(SampleItem("Fixture $id", "Movie", minutes, "mkv", id, minutes * minute), "$id-source", minutes * minute)
private val channel = SampleChannel(listOf(media("a", 20), media("b", 50), media("c", 90)), "Fixture", 101)
private val start = guideWindowStart(1_800_000_000_000L, 0)
private val end = start + GUIDE_SLOTS * GUIDE_SLOT_MS
@Test fun cellsFillTheWindowWithNoGapsAndAddUpToIt() {
val cells = guideCells(channel, start, end)
assertEquals(1f, cells.sumOf { it.widthFraction.toDouble() }.toFloat(), .001f)
assertEquals(0f, cells.first().startFraction, .001f)
cells.zipWithNext().forEach { (a, b) -> assertEquals(a.startFraction + a.widthFraction, b.startFraction, .001f) }
}
@Test fun cellsAgreeWithTheChannelClock() {
guideCells(channel, start, end).forEach { cell ->
assertEquals(channel.at(cell.show.startMs).media.item.id, cell.show.media.item.id)
assertTrue(cell.show.startMs < end && cell.show.endMs > start)
}
}
@Test fun aLongFilmIsClippedToTheWindow() {
val longOne = SampleChannel(listOf(media("x", 600), media("y", 600)), "Fixture", 102)
val cells = guideCells(longOne, start, start + GUIDE_SLOT_MS)
assertEquals(1, cells.size); assertEquals(1f, cells[0].widthFraction, .001f)
}
@Test fun theWindowStartsOnTheHalfHourAndMovesBySlots() {
assertEquals(0L, start % GUIDE_SLOT_MS)
assertEquals(start + 2 * GUIDE_SLOT_MS, guideWindowStart(start + minute, 2))
}
@Test fun movingRightGoesToTheNextProgrammeAndLeftNeverPassesNow() {
val now = start + minute
val current = channel.at(now)
assertEquals(current.endMs, guideStep(channel, now, now, true))
assertEquals(now, guideStep(channel, now, now, false))
val later = current.endMs + minute
assertEquals(current.endMs - 1, guideStep(channel, later, now, false))
}
}
+4
View File
@@ -69,6 +69,10 @@ Profiles and PINs, more providers, authorised IPTV and external guide data, them
Audit and phased plan are in review, from `docs/briefs/indexing-programming-engine-2026-11-01.md`: https://claude.ai/artifact/YYGHnyn7fhxfM1NctEtexw. Nothing from it is approved or built yet.
## Channel numbers
Paul's direction, 1 November 2026: 101, 102 and 103 should become Orbit 1, Orbit 2 and Orbit Three. Today 102 is After Dark and 103 is Vortex, so they'll move when the generic channel list arrives, with a tested migration. Other numbers aren't mapped yet.
## Interface direction
Paul's idea, 1 November 2026, for after the 25 channels work. Proposed, not built.