Rework the guide grid and stop loop programmes overlapping the plan (0.52.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 = 52
|
||||
versionName = "0.51.0-channel-list"
|
||||
versionCode = 53
|
||||
versionName = "0.52.0-grid-rework"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -244,6 +244,10 @@ internal class ScheduledChannel(val loop: SampleChannel, plan: ChannelPlan) : Sa
|
||||
else -> return SampleBroadcast(entry.media, entry.startMs)
|
||||
}
|
||||
}
|
||||
return loop.at(timeMs)
|
||||
// Between plans the loop fills in, cut short so it never runs over the next planned programme.
|
||||
val fill = loop.at(timeMs)
|
||||
val next = entries.getOrNull(low) ?: return fill
|
||||
if (fill.endMs <= next.startMs) return fill
|
||||
return fill.copy(media = fill.media.copy(durationMs = next.startMs - fill.startMs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.51.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.52.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -61,6 +61,18 @@ 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
|
||||
}
|
||||
|
||||
private val GuideChannelColumn = Color(0xFF0A1B3C)
|
||||
private val GuidePast = Color(0xFF0D2247)
|
||||
|
||||
/** The first row to draw, moving only as far as it takes to keep the chosen row on screen. */
|
||||
internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int {
|
||||
val last = (count - visible).coerceAtLeast(0)
|
||||
@@ -90,7 +102,6 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
|
||||
var slotsAhead by remember { mutableIntStateOf(0) }
|
||||
var firstRow by remember { mutableIntStateOf(0) }
|
||||
val gridFocus = remember { FocusRequester() }
|
||||
val toolsFocus = remember { FocusRequester() }
|
||||
var gridFocused by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) { gridFocus.requestFocus() }
|
||||
BackHandler(onBack = onBack)
|
||||
@@ -107,41 +118,36 @@ 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(96.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(Modifier.fillMaxWidth().height(135.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
val branding = ChannelBrands.forNumber(channel.number)
|
||||
// The picture stays on the tuned channel while the cursor browses, so moving rows never re-tunes.
|
||||
Box(Modifier.width(170.dp).fillMaxHeight().background(Panel)) {
|
||||
Box(Modifier.width(240.dp).fillMaxHeight().background(Panel)) {
|
||||
picture()
|
||||
}
|
||||
// Four lines fill the panel's height. OK Watch is already in the legend below.
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(90.dp).height(28.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(115.dp).height(36.dp))
|
||||
else Text(ChannelBrands.displayName(channel), color = Color.White, fontSize = 16.sp)
|
||||
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("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 12.sp, maxLines = 1)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.Top) {
|
||||
OrbitButton("Test transition", Modifier.focusRequester(toolsFocus).onPreviewKeyEvent { event ->
|
||||
if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_DOWN && event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { gridFocus.requestFocus(); true } else false
|
||||
}, onClick = { onWatch(channel.number, channel.transitionCheckOffset(System.currentTimeMillis())) })
|
||||
Text(focusedName, color = Color.White, fontSize = 21.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (focusedDetail != null) Text(focusedDetail, color = Color.White, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 13.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().height(20.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(guideDayPrefix(windowStart, now).trim().ifEmpty { title }, Modifier.width(130.dp).padding(start = 8.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(guideDayPrefix(windowStart, now).trim().ifEmpty { title }, 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) }
|
||||
}
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().weight(1f)) {
|
||||
val visibleRows = ((maxHeight + 3.dp) / 43.dp).toInt().coerceAtLeast(1)
|
||||
val visibleRows = ((maxHeight + 3.dp) / 41.dp).toInt().coerceAtLeast(1)
|
||||
firstRow = guideFirstRow(row, firstRow, visibleRows, channels.size)
|
||||
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) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> { if (row > 0) row-- else toolsFocus.requestFocus(); true }
|
||||
// At the top row, up leaves the grid for the gear.
|
||||
KeyEvent.KEYCODE_DPAD_UP -> if (row > 0) { row--; true } else false
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> { if (row < channels.lastIndex) row++; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
val forward = native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
@@ -160,9 +166,9 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
|
||||
}, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
channels.subList(firstRow, minOf(firstRow + visibleRows, channels.size)).forEachIndexed { offset, line ->
|
||||
val index = firstRow + offset
|
||||
Row(Modifier.fillMaxWidth().height(40.dp), horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
Row(Modifier.fillMaxWidth().height(38.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val mark = ChannelBrands.forNumber(line.number)
|
||||
Row(Modifier.width(130.dp).fillMaxHeight().background(Panel).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.width(130.dp).fillMaxHeight().background(if (index == row) Blue else GuideChannelColumn).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)
|
||||
@@ -170,17 +176,17 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
|
||||
guideCells(line, windowStart, windowEnd).forEach { cell ->
|
||||
val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs
|
||||
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) {
|
||||
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 Color.White, fontSize = 13.sp, fontWeight = if (isFocus) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
guideNowFraction(now, windowStart, windowEnd)?.let { fraction ->
|
||||
Box(Modifier.offset(x = maxWidth * fraction).width(2.dp).fillMaxHeight().background(Color.White.copy(alpha = .6f)))
|
||||
Box(Modifier.offset(x = maxWidth * fraction).width(1.dp).fillMaxHeight().background(Color.White.copy(alpha = .4f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.51.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.52.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: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -450,4 +450,23 @@ class ChannelScheduleTest {
|
||||
topUpPlan(first, ScheduleCursor(), pool, now + minute, avoid = { _, _, _ -> asked = true; false })
|
||||
assertFalse(asked)
|
||||
}
|
||||
|
||||
@Test fun theLoopNeverRunsOverThePlansFirstProgramme() {
|
||||
val longLoop = SampleChannel(listOf(ScheduledMedia(SampleItem("x", "Episode", 60, "", "x", 60 * minute), "s-x", 60 * minute),
|
||||
ScheduledMedia(SampleItem("y", "Episode", 60, "", "y", 60 * minute), "s-y", 60 * minute)), "Paradox", 152)
|
||||
val plan = topUpPlan(null, ScheduleCursor(), pool, now)!!
|
||||
val channel = ScheduledChannel(longLoop, plan)
|
||||
val before = channel.at(now - minute)
|
||||
assertEquals(now, before.endMs)
|
||||
val cells = guideCells(channel, now - 30 * minute, now + 60 * minute)
|
||||
assertTrue(cells.zipWithNext().all { (a, b) -> a.show.endMs <= b.show.startMs })
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user