Simplify the guide: no finished programmes, no empty blocks, no time line (0.55.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user