diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5ab94eb..bd31b8d 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 = 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 } 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 3d80698..a6d38ae 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.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) 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 d272f0d..54f134f 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -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, nowMs: Long): List { + 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, 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, 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) + } } } 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 7c5d7d2..f3d55b1 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.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()) { diff --git a/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp index 56cbfbc..6648c5a 100644 Binary files a/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp and b/android/app/src/main/res/drawable-nodpi/orbit_x_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp b/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp index 29b98f3..69e0cb8 100644 Binary files a/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp and b/android/app/src/main/res/drawable-nodpi/orbit_x_epg.webp differ diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt index 005494d..1cc574a 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt @@ -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)) } } 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 0ca3811..d2b51fe 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -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))