Rework the main menu around the live picture and the Drive-In (0.70.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m42s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m36s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 12:38:57 +00:00
co-authored by Claude Opus 5.5
parent e0418770f4
commit a89c6a1391
9 changed files with 90 additions and 41 deletions
+5
View File
@@ -0,0 +1,5 @@
# Orbit TV 0.70.0: new main menu
From Paul's ideas. The centre stack has what's on, the time, channel and next programme written over the live picture, with the picture's stream unchanged, so OK still goes full screen without re-tuning. Below it, On now shows three other channels, a different mix each half hour. The third column is the Drive-In: the ticket logo, the next bill over its own artwork from Emby, and the two bills after it.
Checked: build, lint and all unit tests pass. Not checked: how it looks on the TV, as screenshots are blocked, and whether everything fits at the box's real screen size.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 70
versionName = "0.69.0-channel-blocks"
versionCode = 71
versionName = "0.70.0-new-main-menu"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -192,7 +192,7 @@ private val BoardPink = Color(0xFFC2358F)
private val Gold = Color(0xFFF2C14E)
@Composable
private fun rememberFilmPicture(session: EmbySampleSession, itemId: String?): Bitmap? {
internal fun rememberFilmPicture(session: EmbySampleSession, itemId: String?): Bitmap? {
val context = LocalContext.current
val picture by produceState<Bitmap?>(null, itemId) {
value = null
@@ -66,7 +66,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.69.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.70.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -325,7 +325,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
channelClockOffset = offset; watchingChannel = true
}
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) }
if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture,
if (mainHome) HomeScreen(liveSession, availableChannels, liveChannel, modifier, picture,
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn, driveInPlan = driveInPlan)
else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture,
onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() })
@@ -13,7 +13,10 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -47,6 +50,11 @@ internal fun programmeProgress(startMs: Long, endMs: Long, nowMs: Long): Float =
if (endMs <= startMs) 0f else ((nowMs - startMs).toFloat() / (endMs - startMs)).coerceIn(0f, 1f)
private val HomeCorner = RoundedCornerShape(6.dp)
private const val HOME_SHUFFLE_MS = 30 * 60_000L
/** A few other channels, a different mix each half hour, so the choice doesn't shuffle under the cursor. */
internal fun homeOnNow(channels: List<SampleChannel>, tunedNumber: Int, count: Int, seed: Long): List<SampleChannel> =
channels.filter { it.number != tunedNumber }.shuffled(java.util.Random(seed)).take(count)
@Composable
internal fun HomeCard(modifier: Modifier = Modifier, focusRequester: FocusRequester? = null, onClick: () -> Unit, content: @Composable (Boolean) -> Unit) {
@@ -64,9 +72,60 @@ private fun ProgressBar(fraction: Float, modifier: Modifier = Modifier, focused:
}
}
/** What's on, written over the live picture so the picture itself never has to move or restart. */
@Composable
private fun WhatsOn(tuned: SampleChannel, now: Long, modifier: Modifier) {
val show = tuned.at(now)
val next = tuned.at(show.endMs)
val branding = ChannelBrands.forNumber(tuned.number)
val (name, detail) = guideTitleParts(show.media.item.title)
Row(modifier.background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xE603081A), Color(0xF003081A)))).padding(start = 10.dp, end = 10.dp, top = 22.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Bottom) {
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(84.dp).height(28.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${tuned.number} • NOW ${clock(show.startMs)} to ${clock(show.endMs)}", color = Color(0xFFD5DCEA), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(name, color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, color = Color.White, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
ProgressBar(programmeProgress(show.startMs, show.endMs, now), Modifier.fillMaxWidth().padding(vertical = 2.dp))
Text("NEXT ${clock(next.startMs)} ${guideTitleParts(next.media.item.title).first}", color = Color(0xFFD5DCEA), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** The ticket, then the next bill over its own artwork from the library, then what follows. */
@Composable
private fun DriveInCard(session: EmbySampleSession, plan: ChannelPlan?, now: Long, modifier: Modifier, onClick: () -> Unit) {
val minute = now / 60_000
val coming = remember(plan, minute) { plan?.let { driveInBills(it).filter { bill -> bill.endMs > now } }.orEmpty() }
val first = coming.firstOrNull()
val picture = rememberFilmPicture(session, first?.entries?.first()?.media?.item?.id)
HomeCard(modifier, onClick = onClick) { focused ->
Column(Modifier.fillMaxSize().padding(3.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Image(painterResource(R.drawable.orbit_drive_in_ticket), "Orbit Drive-In", Modifier.fillMaxWidth().aspectRatio(500f / 155f), contentScale = ContentScale.FillBounds)
Box(Modifier.fillMaxWidth().weight(1f).clip(HomeCorner).background(Navy)) {
picture?.let { Image(it.asImageBitmap(), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) }
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xE603081A), Color(0xF003081A)))).padding(start = 10.dp, end = 10.dp, top = 24.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (first != null) {
Text("${driveInDay(first, now).uppercase(java.util.Locale.UK)}${if (first.startMs > now) " • ${clock(first.startMs)}" else ""}", color = Color(0xFFF2C14E), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, maxLines = 1)
Text(first.title, color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(first.kind.label, color = Color(0xFFD5DCEA), fontSize = 11.sp, maxLines = 1)
} else Text("Tonight's bill is on its way", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
}
coming.drop(1).take(2).forEach { bill ->
Row(Modifier.fillMaxWidth().padding(horizontal = 7.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${driveInDay(bill, now).take(3)} ${clock(bill.startMs)}", color = if (focused) Navy else Color(0xFFF2C14E), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(bill.title, color = if (focused) Navy else Color.White, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/** Home opens onto the tuned channel playing, one press from anything else. */
@Composable
internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, modifier: Modifier = Modifier,
internal fun HomeScreen(session: EmbySampleSession, channels: List<SampleChannel>, tuned: SampleChannel, modifier: Modifier = Modifier,
picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onDriveIn: () -> Unit, driveInPlan: ChannelPlan? = null) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
@@ -86,32 +145,21 @@ internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, mod
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
HomeCard(Modifier.fillMaxWidth().aspectRatio(16f / 9f), onClick = { onWatch(tuned.number) }) {
Box(Modifier.fillMaxSize().padding(3.dp)) { picture() }
}
val show = tuned.at(now)
val next = tuned.at(show.endMs)
val branding = ChannelBrands.forNumber(tuned.number)
val (name, detail) = guideTitleParts(show.media.item.title)
Row(Modifier.fillMaxWidth().weight(1f).clip(HomeCorner).background(Panel).padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(92.dp).height(34.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("NOW ${clock(show.startMs)} to ${clock(show.endMs)}", color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, color = Color.White, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
ProgressBar(programmeProgress(show.startMs, show.endMs, now), Modifier.fillMaxWidth().padding(vertical = 2.dp))
Text("NEXT ${clock(next.startMs)} ${guideTitleParts(next.media.item.title).first}", color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Box(Modifier.fillMaxSize().padding(3.dp)) {
picture()
WhatsOn(tuned, now, Modifier.align(Alignment.BottomStart).fillMaxWidth())
}
}
}
Column(Modifier.width(250.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("On now", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
channels.take(3).forEach { line ->
val seed = now / HOME_SHUFFLE_MS
val others = remember(seed, tuned.number, channels.size) { homeOnNow(channels, tuned.number, 3, seed) }
others.forEach { line ->
val show = line.at(now)
val mark = ChannelBrands.forNumber(line.number)
HomeCard(Modifier.fillMaxWidth().height(50.dp), onClick = { onWatch(line.number) }) { focused ->
HomeCard(Modifier.fillMaxWidth().height(46.dp), onClick = { onWatch(line.number) }) { focused ->
Row(Modifier.fillMaxSize().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(72.dp).height(28.dp))
else Text(ChannelBrands.displayName(line), Modifier.width(72.dp), color = if (focused) Navy else Color.White, fontSize = 12.sp, maxLines = 1)
if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(84.dp).height(28.dp))
else Text(ChannelBrands.displayName(line), Modifier.width(84.dp), color = if (focused) Navy else Color.White, fontSize = 12.sp, maxLines = 1)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(guideTitleParts(show.media.item.title).first, color = if (focused) Navy else Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
ProgressBar(programmeProgress(show.startMs, show.endMs, now), Modifier.fillMaxWidth(), focused)
@@ -119,21 +167,8 @@ internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, mod
}
}
}
val nowMs = System.currentTimeMillis()
val preview = driveInPlan?.let { plan -> driveInBills(plan).firstOrNull { it.endMs > nowMs } }
HomeCard(Modifier.fillMaxWidth().weight(1f), onClick = onDriveIn) { focused ->
Column {
Row(Modifier.fillMaxWidth().background(if (focused) Yellow else Color(0xFF7A5A12)).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_icon_office), null, Modifier.size(20.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
Text("Orbit Drive-In", color = if (focused) Navy else Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
}
Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(preview?.title ?: "Tonight's bill is on its way", color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (preview != null) Text("${preview.kind.label}, ${driveInWhen(preview, nowMs)}", color = if (focused) Navy else Muted, fontSize = 12.sp, maxLines = 1)
}
}
}
}
DriveInCard(session, driveInPlan, now, Modifier.width(250.dp).fillMaxHeight(), onDriveIn)
}
Text("▲▼ Move OK Select ► Picture and events Back Close", 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.69.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.70.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.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -104,4 +104,13 @@ class GuideGridTest {
assertNull(guideNowFraction(999, 1_000, 2_000))
assertNull(guideNowFraction(2_000, 1_000, 2_000))
}
@Test fun onNowLeavesOutTheTunedChannelAndHoldsStillWithinAHalfHour() {
val channels = (1..10).map { number -> SampleChannel(listOf(media("a$number", 20), media("b$number", 20)), "Fixture", number) }
val picks = homeOnNow(channels, 3, 3, 42L)
assertEquals(3, picks.size)
assertTrue(picks.none { it.number == 3 })
assertEquals(picks.map { it.number }, homeOnNow(channels, 3, 3, 42L).map { it.number })
assertEquals(2, homeOnNow(channels.take(3), 3, 3, 42L).size)
}
}