Build the main menu from the approved design (0.28.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m39s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m28s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 22:55:21 +00:00
co-authored by Claude Opus 5.5
parent 4ae5f8d8ed
commit 247262b726
22 changed files with 241 additions and 34 deletions
+19
View File
@@ -0,0 +1,19 @@
# Orbit TV 0.28.0 verification
Main menu from the approved design. The numbered navbar goes once real channels exist.
## Changed
- New home screen: category list, the tuned channel playing live with now and next, an On now list and an OrbitOffice card.
- The app opens on home when real channels are available. Back on home goes full screen. Back in the guide returns to home.
- Categories appear only if they hold a channel. Today that is All Channels, Entertainment (Orbit 1, Vortex) and Movies (After Dark). Choosing one opens the guide filtered to it.
- Gear at the top right opens the old menu of setup screens. The profile icon is shown but can't be selected until profiles exist.
- The custom icons are bundled at 96px.
- The demo guide, used before sign-in, keeps its numbered tabs.
## Checked
- assembleDebug, testDebugUnitTest and lintDebug pass; 132 tests, 0 failures.
- APK signature verified with apksigner.
## Not checked
- Not yet run on the box. Layout at the box's size, remote focus movement between the columns and the gear, and Back at each level all need Paul's on-TV check.
- The OrbitOffice card uses the existing fictional event and says so. Real events aren't built.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 28
versionName = "0.27.0-guide-picture"
versionCode = 29
versionName = "0.28.0-home"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -1,12 +1,12 @@
package tv.orbit.classic
internal data class ChannelBranding(val presetId: String, val displayName: String, val logo: Int, val background: Int, val bug: Int)
internal data class ChannelBranding(val presetId: String, val displayName: String, val logo: Int, val background: Int, val bug: Int, val category: ChannelCategory)
internal object ChannelBrands {
private val brands = mapOf(
101 to ChannelBranding("orbit-1", "Orbit 1", R.drawable.orbit_one_epg, R.drawable.orbit_one_background, R.drawable.orbit_one_bug),
102 to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug),
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug))
101 to ChannelBranding("orbit-1", "Orbit 1", R.drawable.orbit_one_epg, R.drawable.orbit_one_background, R.drawable.orbit_one_bug, ChannelCategory.Entertainment),
102 to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug, ChannelCategory.Movies),
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug, ChannelCategory.Entertainment))
fun forNumber(number: Int): ChannelBranding? = brands[number]
/** Saved channels keep their original names; what the viewer sees comes from the brand. */
fun displayName(channel: SampleChannel) = brands[channel.number]?.displayName ?: channel.name
@@ -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.27.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.28.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -50,8 +50,8 @@ import kotlinx.coroutines.withContext
@Composable
internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean,
mainGuide: Boolean, onChannelAvailable: (Boolean) -> Unit, onGuide: () -> Unit,
onBack: () -> Unit) {
mainGuide: Boolean, mainHome: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onGuide: () -> Unit,
onHome: () -> Unit = {}, onOffice: () -> Unit = {}, onBack: () -> Unit) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
val savedStore = remember { RememberedConnectionStore.get(appContext) }
var setupSaved by remember { mutableStateOf(false) }
@@ -88,6 +88,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var selectedNumber by remember { mutableIntStateOf(101) }
var channel by remember { mutableStateOf<SampleChannel?>(null) }
var watchingChannel by remember { mutableStateOf(false) }
var guideCategory by remember { mutableStateOf(ChannelCategory.All) }
var channelClockOffset by remember { mutableLongStateOf(0) }
var busyLabel by remember { mutableStateOf("Connecting…") }
val sessionSlot = remember { EmbySessionSlot() }
@@ -211,15 +212,15 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
playback = null
}
}
LaunchedEffect(mainGuide) {
if (!mainGuide) { watchingChannel = false; channelClockOffset = 0 }
LaunchedEffect(mainGuide, mainHome) {
if (!mainGuide && !mainHome) { watchingChannel = false; channelClockOffset = 0 }
}
val ready = !busy && !restoring && now >= nextAttemptAt && username.isNotBlank() && password.text.isNotEmpty()
playback?.let { plan -> session?.let { active -> EmbyPlaybackDialog(active, plan, onClose = { playback = null }) } }
val liveSession = session
val availableChannels = availableSampleChannels(channel, afterDarkChannel, toonamiChannel)
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if (mainGuide && liveSession != null && liveChannel != null) {
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
onClose = { watchingChannel = false; channelClockOffset = 0 },
onStep = { step ->
@@ -230,10 +231,15 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val previous = lastNumber
if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 }
})
GuideGrid(availableChannels, selectedNumber, modifier, picture = { GuidePicture(liveSession, it, live = !watchingChannel) }, onWatch = { number, offset ->
fun tune(number: Int, offset: Long) {
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
channelClockOffset = offset; watchingChannel = true
}, onBack = onBack)
}
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel) }
if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture,
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onOffice = onOffice)
else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture,
onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() })
return
}
if (!visible) return
@@ -62,7 +62,7 @@ internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forw
}
@Composable
internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, picture: @Composable (SampleChannel) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, title: String = "All Channels", picture: @Composable () -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
var row by remember { mutableIntStateOf(channels.indexOfFirst { it.number == selectedNumber }.coerceAtLeast(0)) }
@@ -90,7 +90,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
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)) {
picture(channels.firstOrNull { it.number == selectedNumber } ?: channel)
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)) {
@@ -108,7 +108,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}
}
Row(Modifier.fillMaxWidth().height(20.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) {
Text("All Channels", Modifier.width(130.dp).padding(start = 8.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(title, Modifier.width(130.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) }
}
@@ -156,6 +156,6 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
}
}
}
Text("▲▼ Channel ◄► Time OK Watch Back Menu", color = Muted, fontSize = 11.sp)
Text("▲▼ Channel ◄► Time OK Watch Back Home", color = Muted, fontSize = 11.sp)
}
}
@@ -113,6 +113,6 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
}
// The grid keeps the remote; the picture never takes focus.
AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext ->
PlayerView(nativeContext).apply { useController = false; isFocusable = false; this.player = player }
PlayerView(nativeContext).apply { useController = false; isFocusable = false; descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS; this.player = player }
})
}
@@ -0,0 +1,138 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
internal enum class ChannelCategory(val label: String, val icon: Int) {
All("All Channels", R.drawable.orbit_icon_all),
Entertainment("Entertainment", R.drawable.orbit_icon_entertainment),
Movies("Movies", R.drawable.orbit_icon_movies),
Comedy("Comedy", R.drawable.orbit_icon_comedy),
Kids("Kids", R.drawable.orbit_icon_kids),
Documentaries("Documentaries", R.drawable.orbit_icon_documentaries),
Sport("Sport", R.drawable.orbit_icon_sport)
}
/** All Channels, then only the categories that have a channel in them. */
internal fun homeCategories(numbers: List<Int>): List<ChannelCategory> {
val used = numbers.mapNotNull { ChannelBrands.forNumber(it)?.category }.toSet()
return ChannelCategory.entries.filter { it == ChannelCategory.All || it in used }
}
internal fun channelsIn(category: ChannelCategory, channels: List<SampleChannel>) =
if (category == ChannelCategory.All) channels else channels.filter { ChannelBrands.forNumber(it.number)?.category == category }
/** How far through a programme now is, from 0 to 1. */
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)
@Composable
internal fun HomeCard(modifier: Modifier = Modifier, focusRequester: FocusRequester? = null, onClick: () -> Unit, content: @Composable (Boolean) -> Unit) {
var focused by remember { mutableStateOf(false) }
val focus = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier
Box(modifier.then(focus).onFocusChanged { focused = it.isFocused }.clip(HomeCorner).background(if (focused) Yellow else Panel).clickable(onClick = onClick)) {
content(focused)
}
}
@Composable
private fun ProgressBar(fraction: Float, modifier: Modifier = Modifier, focused: Boolean = false) {
Box(modifier.height(4.dp).clip(RoundedCornerShape(2.dp)).background(if (focused) Navy.copy(alpha = .25f) else Navy)) {
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(if (focused) Navy else Color.White))
}
}
/** Home opens onto the tuned channel playing, one press from anything else. Back takes it full screen. */
@Composable
internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, modifier: Modifier = Modifier,
picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onOffice: () -> Unit) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
BackHandler { onWatch(tuned.number) }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.width(176.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
homeCategories(channels.map { it.number }).forEachIndexed { index, category ->
HomeCard(Modifier.fillMaxWidth().height(46.dp), if (index == 0) firstFocus else null, onClick = { onCategory(category) }) { focused ->
Row(Modifier.fillMaxSize().padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Image(painterResource(category.icon), null, Modifier.size(26.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
Text(category.label, color = if (focused) Navy else Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
}
}
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(HomeCorner).background(Panel)) { 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)
}
}
}
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 show = line.at(now)
val mark = ChannelBrands.forNumber(line.number)
HomeCard(Modifier.fillMaxWidth().height(50.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)
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)
}
}
}
}
val preview = officeEvents.first()
HomeCard(Modifier.fillMaxWidth().weight(1f), onClick = onOffice) { 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("OrbitOffice preview", 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, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("Fictional event, for now", color = if (focused) Navy else Muted, fontSize = 12.sp, maxLines = 1)
}
}
}
}
}
Text("▲▼ Move OK Select ► Picture and events Back Full screen", color = Muted, fontSize = 11.sp)
}
}
@@ -80,6 +80,11 @@ private fun OrbitApp() {
val selected = Schedule.guide(Schedule.channels[selectedChannel], now)[selectedColumn]
var pinned by remember { mutableStateOf(false) }
val tabFocus = remember { List(3) { FocusRequester() } }
val gearFocus = remember { FocusRequester() }
LaunchedEffect(realChannelAvailable) {
if (realChannelAvailable && section == "TV Guide") section = "Home"
if (!realChannelAvailable && section == "Home") section = "TV Guide"
}
val programme = Schedule.programme(Schedule.channels[tuned], now)
val context = androidx.compose.ui.platform.LocalContext.current
val player = remember { ExoPlayer.Builder(context).build().apply {
@@ -122,12 +127,14 @@ private fun OrbitApp() {
onDispose { lifecycle.removeObserver(observer) }
}
BackHandler(fullscreen || pinned || section != "TV Guide") {
when { fullscreen -> fullscreen = false; pinned -> pinned = false; section == "Services" || section == "Connect Emby" -> section = "Menu"; else -> section = "TV Guide" }
when { fullscreen -> fullscreen = false; pinned -> pinned = false; section == "Services" || section == "Connect Emby" -> section = "Menu"; else -> section = if (realChannelAvailable) "Home" else "TV Guide" }
}
LaunchedEffect(section, fullscreen) {
LaunchedEffect(section, fullscreen, realChannelAvailable) {
if (!fullscreen) {
val tab = when (section) { "TV Guide" -> 1; "Box Office" -> 2; else -> 0 }
tabFocus[tab].requestFocus()
if (!realChannelAvailable) {
val tab = when (section) { "TV Guide" -> 1; "Box Office" -> 2; else -> 0 }
tabFocus[tab].requestFocus()
} else if (section != "Home" && section != "TV Guide") gearFocus.requestFocus()
}
}
Box(Modifier.fillMaxSize().background(Navy)) {
@@ -143,15 +150,28 @@ private fun OrbitApp() {
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(144.dp).height(36.dp))
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Menu", "TV Guide", "Box Office").forEachIndexed { index, title ->
OrbitButton("${index + 1} $title", Modifier.focusRequester(tabFocus[index]),
section == title, onClick = { section = title; pinned = false })
if (realChannelAvailable) {
Spacer(Modifier.width(12.dp))
HomeCard(Modifier.size(38.dp, 32.dp), gearFocus, onClick = { section = "Menu"; pinned = false }) { focused ->
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp),
colorFilter = if (focused) androidx.compose.ui.graphics.ColorFilter.tint(Navy) else null)
}
Spacer(Modifier.width(8.dp))
// Shown from the design, but there's nothing to select until profiles exist.
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_icon_profile), "Profile", Modifier.size(38.dp, 32.dp).background(Panel).padding(5.dp))
}
}
Spacer(Modifier.height(8.dp))
// The numbered tabs only serve the demo guide. With real channels, home and the gear replace them.
if (!realChannelAvailable) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Menu", "TV Guide", "Box Office").forEachIndexed { index, title ->
OrbitButton("${index + 1} $title", Modifier.focusRequester(tabFocus[index]),
section == title, onClick = { section = title; pinned = false })
}
}
Spacer(Modifier.height(8.dp))
}
if (section == "TV Guide" && !realChannelAvailable) {
Row(Modifier.fillMaxWidth().height(120.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Box(Modifier.width(256.dp).fillMaxHeight().border(1.dp, Muted)) {
@@ -202,7 +222,7 @@ private fun OrbitApp() {
// Connection content is kept composed below to retain the temporary session.
} else if (section == "Box Office") {
OrbOffice(Modifier.weight(1f), onGuide = { section = "TV Guide" })
} else if (section != "TV Guide") {
} else if (section != "TV Guide" && section != "Home") {
Column(Modifier.fillMaxWidth().weight(1f).background(Panel).padding(28.dp)) {
Text("Welcome to Orbit Interactive", color = Yellow, fontSize = 27.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(20.dp))
@@ -215,8 +235,9 @@ private fun OrbitApp() {
}
EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby",
mainGuide = section == "TV Guide" && realChannelAvailable,
mainHome = section == "Home" && realChannelAvailable,
onChannelAvailable = { realChannelAvailable = it },
onGuide = { section = "TV Guide" }, onBack = { section = "Menu" })
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onOffice = { section = "Box Office" }, onBack = { section = "Menu" })
}
}
}
@@ -20,9 +20,9 @@ internal val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow
private val TicketType = FontFamily(Font(R.font.barlow_condensed_medium))
private val Paper = Color(0xFFF7EAC8)
private data class OfficeEvent(val title: String, val collection: String, val time: String,
internal data class OfficeEvent(val title: String, val collection: String, val time: String,
val synopsis: String, val reference: String)
private val officeEvents = listOf(
internal val officeEvents = listOf(
OfficeEvent("The Last Shuttle Home", "Orbit premieres", "Tonight at 21:30",
"One final journey brings an unlikely crew together. A fictional premiere for your own front-row seat.", "ORB-001"),
OfficeEvent("Visitors at Dawn", "After dark", "Tonight at 23:00",
@@ -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.27.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.28.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: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

@@ -59,4 +59,27 @@ class GuideGridTest {
assertFalse(scheduledRuntimeMatches(1_000_000, 1_002_001))
assertFalse(scheduledRuntimeMatches(null, 1_000_000))
}
@Test
fun homeOnlyListsCategoriesWithChannels() {
assertEquals(listOf(ChannelCategory.All, ChannelCategory.Entertainment, ChannelCategory.Movies), homeCategories(listOf(101, 102, 103)))
assertEquals(listOf(ChannelCategory.All, ChannelCategory.Movies), homeCategories(listOf(102)))
assertEquals(listOf(ChannelCategory.All), homeCategories(listOf(999)))
}
@Test
fun aCategoryOpensOnlyItsOwnChannels() {
val dark = SampleChannel(listOf(media("a", 20), media("b", 20)), "Fixture", 102)
val vortex = SampleChannel(listOf(media("c", 20), media("d", 20)), "Fixture", 103)
assertEquals(listOf(102), channelsIn(ChannelCategory.Movies, listOf(dark, vortex)).map { it.number })
assertEquals(listOf(102, 103), channelsIn(ChannelCategory.All, listOf(dark, vortex)).map { it.number })
}
@Test
fun programmeProgressStaysBetweenStartAndEnd() {
assertEquals(.5f, programmeProgress(100, 200, 150), .001f)
assertEquals(0f, programmeProgress(100, 200, 50), .001f)
assertEquals(1f, programmeProgress(100, 200, 300), .001f)
assertEquals(0f, programmeProgress(100, 100, 100), .001f)
}
}