diff --git a/VERIFICATION-0.25.0.md b/VERIFICATION-0.25.0.md new file mode 100644 index 0000000..7c8bf4a --- /dev/null +++ b/VERIFICATION-0.25.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 891670a..30bf485 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 = 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 } 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 4a979e1..1d9937b 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.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) 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 da57645..2356558 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -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 { + 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, 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, 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, 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, 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, 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) } } diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 8a24b56..55d8513 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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) } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt b/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt index 2a8fda6..0a69e00 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt @@ -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) 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 e075b58..1b73cb1 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.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()) { diff --git a/android/app/src/main/res/drawable-nodpi/orbit_logo.webp b/android/app/src/main/res/drawable-nodpi/orbit_logo.webp new file mode 100644 index 0000000..1fa36e1 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_logo.webp differ diff --git a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt index dd26eaa..5a88d83 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -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))