Build the main menu from the approved design (0.28.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -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.
|
||||
@@ -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()) {
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
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)
|
||||
}
|
||||
}
|
||||
|
||||