diff --git a/VERIFICATION-0.26.0.md b/VERIFICATION-0.26.0.md new file mode 100644 index 0000000..52d470a --- /dev/null +++ b/VERIFICATION-0.26.0.md @@ -0,0 +1,7 @@ +# Orbit TV 0.26.0 — guide titles and now line + +Includes 0.25, which wasn't installed on its own. From Paul's review: episode numbers leave the grid and appear only in the panel above, as the series name then "S1 E4 • Name". Grid cells show one line, cut with an ellipsis inside their width. The now line's position is now a tested function: it follows the clock, leaves when the window scrolls past now, and returns to the same place when scrolling back. + +Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed, with tests for the line and for title splitting. On-screen truncation and the line while scrolling need the Xiaomi test. + +Rollback: reinstall outputs/revision-0.24.0, or revert the commits. Data formats are unchanged. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 30bf485..a7ac53e 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 = 25 - versionName = "0.25.0-guide-sizing" + versionCode = 26 + versionName = "0.26.0-guide-titles" } 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 1d9937b..8e00f01 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.25.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.26.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 2356558..4be4d2d 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -46,12 +46,16 @@ 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. */ +/** Episodes arrive as "Series / S1 E4 / Name". The grid shows only the series; the panel above has the episode. */ internal fun guideTitleParts(title: String): Pair { val parts = title.split(" / ") - return parts[0] to parts.getOrNull(1) + return parts[0] to parts.drop(1).joinToString(" • ").ifBlank { null } } +/** 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)) @@ -91,7 +95,9 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.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 = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + 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("${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) } @@ -139,17 +145,13 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif .background(if (isFocus) Yellow else if (onNow) Blue else 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) { - 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) - } + 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) } } } - if (now in windowStart until windowEnd) - Box(Modifier.offset(x = maxWidth * ((now - windowStart).toFloat() / (windowEnd - windowStart))).width(2.dp).fillMaxHeight().background(Color.White)) + guideNowFraction(now, windowStart, windowEnd)?.let { fraction -> + Box(Modifier.offset(x = maxWidth * fraction).width(2.dp).fillMaxHeight().background(Color.White)) + } } } } 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 1b73cb1..b4827a9 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.25.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.26.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/test/java/tv/orbit/classic/GuideGridTest.kt b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt index 5a88d83..c8dc1e9 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -28,9 +28,18 @@ class GuideGridTest { 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("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))