Simplify the guide: no finished programmes, no empty blocks, no time line (0.55.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m46s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m24s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 09:37:54 +00:00
co-authored by Claude Opus 5.5
parent 1185349591
commit abf00ea3dd
8 changed files with 48 additions and 42 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 55
versionName = "0.54.0-logo-fixes"
versionCode = 56
versionName = "0.55.0-guide-distilled"
}
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.54.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.55.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -61,17 +61,20 @@ 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
/**
* What the grid draws: finished programmes go, and whatever's on now fills back to the start of the window.
* Every block is then something you can watch or look forward to.
*/
internal fun guideWatchableCells(cells: List<GuideCell>, nowMs: Long): List<GuideCell> {
val left = cells.filter { it.show.endMs > nowMs }
val first = left.firstOrNull() ?: return left
return listOf(first.copy(startFraction = 0f, widthFraction = first.startFraction + first.widthFraction)) + left.drop(1)
}
/** Below this a title can't fit, so the block shows it's cut short rather than sitting empty. */
private val GuideTitleMinWidth = 34.dp
private val GuideChannelColumn = Color(0xFF0A1B3C)
private val GuidePast = Color(0xFF0D2247)
/** The first row to draw. It moves a row before the edge, so there's always a sign of the next channel. */
internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int {
@@ -84,10 +87,6 @@ internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int
}.coerceIn(0, last)
}
/** Where the now line sits across the window, or null when now isn't in view. */
internal fun guideNowFraction(nowMs: Long, windowStart: Long, windowEnd: Long): Float? =
if (nowMs in windowStart until windowEnd) (nowMs - windowStart).toFloat() / (windowEnd - windowStart) else null
/** 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))
@@ -136,7 +135,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}
}
Row(Modifier.fillMaxWidth().height(20.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) {
Text(guideDayPrefix(windowStart, now).trim().ifEmpty { title }, Modifier.width(136.dp).padding(start = 8.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(guideDayPrefix(windowStart, now).trim().ifEmpty { "$title, today" }, 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) }
}
@@ -177,25 +176,29 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
else Text(ChannelBrands.displayName(line), color = Color.White, fontSize = 13.sp, maxLines = 1)
}
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
guideCells(line, windowStart, windowEnd).forEach { cell ->
guideWatchableCells(guideCells(line, windowStart, windowEnd), now).forEach { cell ->
val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs
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 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)
val onNow = cell.show.startMs <= now
val cellWidth = maxWidth * cell.widthFraction - 3.dp
Box(Modifier.offset(x = maxWidth * cell.startFraction).width(cellWidth.coerceAtLeast(1.dp)).fillMaxHeight()
.background(if (isFocus) Yellow else if (onNow) Blue else Panel).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) {
val runsOn = cell.show.endMs > windowEnd
val ink = if (isFocus) Navy else Color.White
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (cellWidth >= GuideTitleMinWidth) guideTitleParts(cell.show.media.item.title).first else "…", Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp,
fontWeight = if (isFocus || onNow) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (runsOn && cellWidth >= GuideTitleMinWidth) Text(" ►", color = ink, fontSize = 9.sp)
}
}
}
guideNowFraction(now, windowStart, windowEnd)?.let { fraction ->
Box(Modifier.offset(x = maxWidth * fraction).width(1.dp).fillMaxHeight().background(Color.White.copy(alpha = .4f)))
}
}
}
}
}
}
Text("▲▼ Channel ◄► Time Hold ◄► Day OK Watch Back Home${if (rowsShown < channels.size) " • Channels ${firstRow + 1} to ${firstRow + rowsShown} of ${channels.size}" else ""}", color = Muted, fontSize = 11.sp)
Row(Modifier.fillMaxWidth()) {
Text("▲▼ Channel ◄► Time Hold ◄► Day OK Watch", Modifier.weight(1f), color = Muted, fontSize = 11.sp)
if (rowsShown < channels.size) Text("Channels ${firstRow + 1} to ${firstRow + rowsShown} of ${channels.size}", color = Muted, fontSize = 11.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.54.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.55.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()) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -463,10 +463,22 @@ class ChannelScheduleTest {
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))
@Test fun theGridOnlyDrawsWhatYouCanStillWatch() {
val channel = ScheduledChannel(loop, topUpPlan(null, ScheduleCursor(), pool, now)!!)
val start = guideWindowStart(now + 40 * minute, 0)
val at = start + 50 * minute
val cells = guideWatchableCells(guideCells(channel, start, start + 120 * minute), at)
assertTrue(cells.all { it.show.endMs > at })
assertEquals(0f, cells.first().startFraction, 0f)
assertEquals(1f, cells.last().startFraction + cells.last().widthFraction, .001f)
assertTrue(cells.zipWithNext().all { (a, b) -> kotlin.math.abs(a.startFraction + a.widthFraction - b.startFraction) < .001f })
}
@Test fun aWindowInTheFutureIsLeftAlone() {
val channel = ScheduledChannel(loop, topUpPlan(null, ScheduleCursor(), pool, now)!!)
val start = guideWindowStart(now + 2 * day, 0)
val cells = guideCells(channel, start, start + 120 * minute)
assertEquals(cells.drop(1), guideWatchableCells(cells, now).drop(1))
}
}
@@ -31,15 +31,6 @@ class GuideGridTest {
assertEquals("Dragon Ball Z" to "S1 E4 • Piccolo's Plan", guideTitleParts("Dragon Ball Z / S1 E4 / Piccolo's Plan"))
assertEquals("The 'Burbs" to null, guideTitleParts("The 'Burbs"))
}
@Test fun theNowLineFollowsTheClockAndLeavesWhenTheWindowScrollsPastIt() {
assertEquals(0f, guideNowFraction(start, start, end)!!, .001f)
assertEquals(.25f, guideNowFraction(start + GUIDE_SLOT_MS, start, end)!!, .001f)
assertNull(guideNowFraction(end, start, end))
val scrolled = guideWindowStart(start + minute, 4)
assertNull(guideNowFraction(start + minute, scrolled, scrolled + GUIDE_SLOTS * GUIDE_SLOT_MS))
val back = guideWindowStart(start + minute, 0)
assertEquals(minute.toFloat() / (GUIDE_SLOTS * GUIDE_SLOT_MS), guideNowFraction(start + minute, back, back + GUIDE_SLOTS * GUIDE_SLOT_MS)!!, .001f)
}
@Test fun theWindowStartsOnTheHalfHourAndMovesBySlots() {
assertEquals(0L, start % GUIDE_SLOT_MS)
assertEquals(start + 2 * GUIDE_SLOT_MS, guideWindowStart(start + minute, 2))