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 0000000..c57ca6f Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_drive_in_ticket.webp differ 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) + } }