diff --git a/VERIFICATION-0.28.0.md b/VERIFICATION-0.28.0.md new file mode 100644 index 0000000..6a29612 --- /dev/null +++ b/VERIFICATION-0.28.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fed9198..cd934b8 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt index bfb5ce4..dcf77ab 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt @@ -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 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 e937d83..b478952 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) 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 1950b92..5508f9c 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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(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 diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 0ab39af..7d2d589 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -62,7 +62,7 @@ internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forw } @Composable -internal fun GuideGrid(channels: List, selectedNumber: Int, modifier: Modifier = Modifier, picture: @Composable (SampleChannel) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) { +internal fun GuideGrid(channels: List, 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, 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, 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, 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) } } diff --git a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt index 6720df3..1b41570 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -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 } }) } diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt new file mode 100644 index 0000000..dd6fc1d --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -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): List { + 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) = + 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, 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) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 55d8513..b945145 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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" }) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt b/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt index 0a69e00..42f8a7a 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitSections.kt @@ -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", 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 731db84..4f4a1a0 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.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()) { diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp new file mode 100644 index 0000000..7039062 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_comedy.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_comedy.webp new file mode 100644 index 0000000..a89d114 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_comedy.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp new file mode 100644 index 0000000..04d3b5d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_entertainment.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_entertainment.webp new file mode 100644 index 0000000..603c3e7 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_entertainment.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_gear.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_gear.webp new file mode 100644 index 0000000..38e6b98 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_gear.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_kids.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_kids.webp new file mode 100644 index 0000000..faae6cf Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_kids.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_movies.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_movies.webp new file mode 100644 index 0000000..8962beb Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_movies.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_office.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_office.webp new file mode 100644 index 0000000..52e2d69 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_office.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_profile.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_profile.webp new file mode 100644 index 0000000..3608b96 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_profile.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_sport.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_sport.webp new file mode 100644 index 0000000..e4ccbbb Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_sport.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 b6cdd03..3296231 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -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) + } }