Fix guide sizing and use one font across the app (0.25.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m3s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m23s

Guide rows no longer stretch to fill the screen, the top panel is smaller,
and Barlow replaces the system font so text matches the logos. Adds the
Orbit logo to the header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 22:30:39 +00:00
co-authored by Claude Opus 5.5
parent 4afbdaf957
commit 6a90c18a74
9 changed files with 54 additions and 25 deletions
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.25.0 — guide sizing and one font
Paul's feedback on 0.24, with a photo: the grid worked, but rows were huge because they shared the free height between only two channels; the top panel was slightly big; and text didn't match the logos.
Changes: guide rows are a fixed 40 dp, so they don't grow with fewer channels, and the top panel is smaller. Barlow is now the default font across the app, matching the logos and designs, where most text used the system font. The header uses the new Orbit logo in place of the placeholder and "Classic". In the grid, episodes show series and number, with the full title in the panel above, and cells too narrow to read show no text.
Not changed: the colours in code. In Paul's photos the navy looks much brighter and bluer than its value, so the TV's picture mode or the camera may be involved. That's unconfirmed.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 127 unit tests, zero failures or errors, no lint errors. The global font change touches every screen, and its look needs the Xiaomi test, particularly anywhere text may now wrap differently.
Rollback: reinstall outputs/revision-0.24.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 = 24
versionName = "0.24.0-grid-guide"
versionCode = 25
versionName = "0.25.0-guide-sizing"
}
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.24.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.25.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -46,6 +46,12 @@ internal fun guideCells(channel: SampleChannel, windowStart: Long, windowEnd: Lo
internal fun guideWindowStart(nowMs: Long, slotsAhead: Int) = Math.floorDiv(nowMs, GUIDE_SLOT_MS) * GUIDE_SLOT_MS + slotsAhead * GUIDE_SLOT_MS
/** Episodes arrive as "Series / S1 E4 / Name". The grid shows the series and the number; the panel above has the rest. */
internal fun guideTitleParts(title: String): Pair<String, String?> {
val parts = title.split(" / ")
return parts[0] to parts.getOrNull(1)
}
/** 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))
@@ -77,17 +83,17 @@ internal fun GuideGrid(channels: List<SampleChannel>, 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(126.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val branding = ChannelBrands.forNumber(channel.number)
Box(Modifier.width(224.dp).fillMaxHeight().background(Panel)) {
Box(Modifier.width(170.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))
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(90.dp).height(28.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)
Text(focused.media.item.title, color = Color.White, fontSize = 17.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 = 12.sp, maxLines = 1)
Text(if (focused.startMs <= now) "OK Watch" else "On later", color = Muted, fontSize = 11.sp)
}
Column(verticalArrangement = Arrangement.Top) {
OrbitButton("Test transition", Modifier.focusRequester(toolsFocus).onPreviewKeyEvent { event ->
@@ -95,13 +101,13 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}, 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.fillMaxWidth().height(20.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) {
Text("All Channels", Modifier.width(130.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 = 13.sp, fontWeight = FontWeight.SemiBold) }
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) }
}
}
Column(Modifier.fillMaxWidth().weight(1f).focusRequester(gridFocus).onFocusChanged { gridFocused = it.isFocused }.focusable().onPreviewKeyEvent { event ->
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) {
@@ -116,13 +122,13 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}
else -> false
}
}, verticalArrangement = Arrangement.spacedBy(4.dp)) {
}, verticalArrangement = Arrangement.spacedBy(3.dp)) {
channels.forEachIndexed { index, line ->
Row(Modifier.fillMaxWidth().weight(1f).widthIn(max = 2000.dp), horizontalArrangement = Arrangement.spacedBy(0.dp)) {
Row(Modifier.fillMaxWidth().height(40.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))
Row(Modifier.width(130.dp).fillMaxHeight().background(Panel).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)
}
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
@@ -131,7 +137,15 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
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)
// Too narrow for a readable word, so the panel above carries the name.
if (cell.widthFraction >= .09f) {
val (name, detail) = guideTitleParts(cell.show.media.item.title)
val ink = if (isFocus) Navy else Color.White
Column {
Text(name, color = ink, fontSize = 13.sp, fontWeight = if (isFocus) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, color = if (isFocus) Navy else Muted, fontSize = 11.sp, maxLines = 1)
}
}
}
}
if (now in windowStart until windowEnd)
@@ -140,6 +154,6 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}
}
}
Text("▲▼ Channel ◄► Time OK Watch Back Menu", color = Muted, fontSize = 12.sp)
Text("▲▼ Channel ◄► Time OK Watch Back Menu", color = Muted, fontSize = 11.sp)
}
}
@@ -49,7 +49,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { MaterialTheme { OrbitApp() } }
// One typeface everywhere, matching the channel logos.
setContent { MaterialTheme { CompositionLocalProvider(androidx.tv.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle(fontFamily = Barlow)) { OrbitApp() } } }
}
}
@@ -139,8 +140,7 @@ private fun OrbitApp() {
}
} else Column(Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("◉ orbit", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text(" / Classic", color = Muted, fontSize = 19.sp)
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(144.dp).height(36.dp))
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp)
}
@@ -16,7 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
private val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow_semibold, FontWeight.SemiBold))
internal val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow_semibold, FontWeight.SemiBold), Font(R.font.barlow_semibold, FontWeight.Bold))
private val TicketType = FontFamily(Font(R.font.barlow_condensed_medium))
private val Paper = Color(0xFFF7EAC8)
@@ -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.24.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.25.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.

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -27,6 +27,10 @@ class GuideGridTest {
val cells = guideCells(longOne, start, start + GUIDE_SLOT_MS)
assertEquals(1, cells.size); assertEquals(1f, cells[0].widthFraction, .001f)
}
@Test fun episodeTitlesSplitIntoSeriesAndNumberAndFilmsStayWhole() {
assertEquals("Dragon Ball Z" to "S1 E4", guideTitleParts("Dragon Ball Z / S1 E4 / Piccolo's Plan"))
assertEquals("The 'Burbs" to null, guideTitleParts("The 'Burbs"))
}
@Test fun theWindowStartsOnTheHalfHourAndMovesBySlots() {
assertEquals(0L, start % GUIDE_SLOT_MS)
assertEquals(start + 2 * GUIDE_SLOT_MS, guideWindowStart(start + minute, 2))