From a89c6a1391fc4236e5049846e07f89df6c794f0c Mon Sep 17 00:00:00 2001 From: Pauljd Date: Mon, 2 Nov 2026 12:38:57 +0000 Subject: [PATCH] Rework the main menu around the live picture and the Drive-In (0.70.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.70.0.md | 5 + android/app/build.gradle.kts | 4 +- .../src/main/java/tv/orbit/classic/DriveIn.kt | 2 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 2 +- .../main/java/tv/orbit/classic/HomeScreen.kt | 105 ++++++++++++------ .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../drawable-nodpi/orbit_drive_in_ticket.webp | Bin 0 -> 10376 bytes .../java/tv/orbit/classic/GuideGridTest.kt | 9 ++ 9 files changed, 90 insertions(+), 41 deletions(-) create mode 100644 VERIFICATION-0.70.0.md create mode 100644 android/app/src/main/res/drawable-nodpi/orbit_drive_in_ticket.webp diff --git a/VERIFICATION-0.70.0.md b/VERIFICATION-0.70.0.md new file mode 100644 index 0000000..4c388a0 --- /dev/null +++ b/VERIFICATION-0.70.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2673616..393efc3 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt index a4e3c7b..91f187b 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -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(null, itemId) { value = null 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 bc40707..2283ae1 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 1d2f6f1..6b69578 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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() }) diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index a55dc76..b2a6199 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -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, tunedNumber: Int, count: Int, seed: Long): List = + 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, tuned: SampleChannel, modifier: Modifier = Modifier, +internal fun HomeScreen(session: EmbySampleSession, channels: List, 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, 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, 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) } 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 083276e..2df4935 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.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()) { diff --git a/android/app/src/main/res/drawable-nodpi/orbit_drive_in_ticket.webp b/android/app/src/main/res/drawable-nodpi/orbit_drive_in_ticket.webp new file mode 100644 index 0000000000000000000000000000000000000000..c57ca6fdf503747f5421e929ff0eceb7b9829425 GIT binary patch literal 10376 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 literal 0 HcmV?d00001 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 44b9fb9..696efcb 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -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) + } }