Add a top nav and Orbit Arcade with Snake (0.76.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m36s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m22s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 14:22:35 +00:00
co-authored by Claude Opus 5.5
parent cd57325500
commit 2630215430
13 changed files with 348 additions and 34 deletions
+7
View File
@@ -0,0 +1,7 @@
# Orbit TV 0.76.0: top nav and Orbit Arcade
- A bar along the top of every screen, between the Orbit logo and the gear: All Channels, Drive-In and Arcade. It sits over the Drive-In's sky, and hides during a game. The logo goes to the main menu.
- Orbit Arcade has its own screen with Snake, and room for more. OK plays.
- Snake: steer with the arrows, eat to grow, and hit a wall or yourself to lose. It quickens as you score. Back pauses, and a reminder pauses it too. Your best score stays on the box.
Checked: build, lint and 288 unit tests pass, including the game's rules. Not checked until installed: moving around the bar by remote, the bar over the Drive-In's scenes, and how the game plays and looks. Screenshots are blocked, so looks need Paul's eye.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 79
versionName = "0.75.0-bigger-film-lists"
versionCode = 80
versionName = "0.76.0-top-nav-arcade"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -19,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -388,12 +389,15 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit
val focus = remember { FocusRequester() }
LaunchedEffect(active, ticket) { if (active && ticket == null) focus.requestFocus() }
val scene = Math.floorMod(visit, DRIVE_IN_SCENES)
Box(modifier.fillMaxSize().background(Navy).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val nav = LocalNavFocus.current
Box(modifier.fillMaxSize().background(Navy).focusRequester(focus).focusProperties { if (nav != null) up = nav }.focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
// With the ticket open, the keys are for its buttons.
if (native.action != KeyEvent.ACTION_DOWN || lineup.isEmpty() || ticket != null) return@onPreviewKeyEvent false
when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> { chosenKind = lineup[Math.floorMod(index - 1, lineup.size)].kind; true }
// The list runs across the bottom, or down the side on the CRT. Up off the top goes to the bar.
KeyEvent.KEYCODE_DPAD_UP -> if (scene == 2 && index > 0) { chosenKind = lineup[index - 1].kind; true } else false
KeyEvent.KEYCODE_DPAD_LEFT -> { chosenKind = lineup[Math.floorMod(index - 1, lineup.size)].kind; true }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> { chosenKind = lineup[Math.floorMod(index + 1, lineup.size)].kind; true }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (native.repeatCount == 0) selected?.let { if (it.startMs <= now) onWatch() else ticket = it }
@@ -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.75.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.76.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -54,7 +54,7 @@ private const val RESTORE_QUIET_TRIES = 4
@Composable
internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean,
mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, allChannelsPresses: Int = 0, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
val savedStore = remember { RememberedConnectionStore.get(appContext) }
@@ -279,6 +279,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
LaunchedEffect(mainGuide, mainHome) {
if (!mainGuide && !mainHome) { watchingChannel = false; channelClockOffset = 0 }
}
LaunchedEffect(allChannelsPresses) { guideCategory = ChannelCategory.All }
LaunchedEffect(mainDriveIn) {
if (mainDriveIn) { driveInVisits++; if (pendingDriveInWatch) { pendingDriveInWatch = false; watchingDriveIn = true } } else watchingDriveIn = false
}
@@ -128,7 +128,7 @@ internal fun HomeScreen(session: EmbySampleSession, channels: List<SampleChannel
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val firstFocus = remember { FocusRequester() }
val gear = LocalGearFocus.current
val gear = LocalNavFocus.current
LaunchedEffect(Unit) { firstFocus.requestFocus() }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
@@ -14,6 +14,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@@ -33,8 +34,8 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** The gear, so the menu's top row can reach it directly. Left to itself, up finds nothing above the first column. */
internal val LocalGearFocus = staticCompositionLocalOf<FocusRequester?> { null }
/** The first place on the top bar, so the top of any screen can step straight up to it. */
internal val LocalNavFocus = staticCompositionLocalOf<FocusRequester?> { null }
internal val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow_semibold, FontWeight.SemiBold), Font(R.font.barlow_semibold, FontWeight.Bold))
internal val Navy = Color(0xFF071632)
internal val Panel = Color(0xFF12356B)
@@ -65,6 +66,17 @@ internal fun OrbitButton(label: String, modifier: Modifier = Modifier, selected:
}
}
/** One place along the top, always a press away. */
@Composable
private fun NavItem(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
val corner = androidx.compose.foundation.shape.RoundedCornerShape(5.dp)
Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).height(32.dp).onFocusChanged { focused = it.isFocused }
.background(if (focused) Yellow else if (selected) Blue else Navy.copy(alpha = .72f), corner).clickable(onClick = onClick).padding(horizontal = 14.dp), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) Navy else Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
}
}
@Composable
private fun OrbitApp() {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
@@ -72,6 +84,8 @@ private fun OrbitApp() {
var realChannelAvailable by remember { mutableStateOf(false) }
var section by remember { mutableStateOf("Home") }
var restoreChecked by remember { mutableStateOf(false) }
var allChannelsPresses by remember { mutableIntStateOf(0) }
var playingGame by remember { mutableStateOf(false) }
// With nothing to watch yet, the only place to be is signing in or choosing channels.
LaunchedEffect(realChannelAvailable, restoreChecked) { if (!realChannelAvailable && restoreChecked) section = "Connect Emby" }
val lifecycle = LocalLifecycleOwner.current.lifecycle
@@ -85,33 +99,38 @@ private fun OrbitApp() {
}
// Back on the main menu closes the app.
BackHandler(realChannelAvailable && section != "TV Guide" && section != "Home") { section = "Home" }
val gearFocus = remember { FocusRequester() }
val navFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize().background(Navy)) {
CompositionLocalProvider(LocalGearFocus provides gearFocus) {
// The Drive-In takes the whole screen.
Column(if (section == "Drive-In") Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
if (section != "Drive-In") {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Image(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)
if (realChannelAvailable) {
Spacer(Modifier.width(12.dp))
HomeCard(Modifier.size(38.dp, 32.dp), gearFocus, onClick = { section = "Connect Emby" }) { focused ->
Image(painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
}
}
CompositionLocalProvider(LocalNavFocus provides navFocus) {
// The Drive-In and a game in play take the whole screen; the bar sits over the Drive-In's sky.
val fullBleed = section == "Drive-In" || playingGame
Column(if (fullBleed) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 56.dp, bottom = 16.dp)) {
if (!restoreChecked) Text("Tuning in…", color = Muted, fontSize = 15.sp)
if (section == "Arcade" && realChannelAvailable) OrbitArcade(Modifier.weight(1f), onPlaying = { playingGame = it })
EmbyConnectionScreen(if (section == "Arcade") Modifier else Modifier.weight(1f), visible = section == "Connect Emby",
mainGuide = section == "TV Guide" && realChannelAvailable,
mainHome = section == "Home" && realChannelAvailable,
mainDriveIn = section == "Drive-In" && realChannelAvailable,
allChannelsPresses = allChannelsPresses,
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" })
}
if (!playingGame) Row(Modifier.fillMaxWidth().padding(start = 36.dp, end = 36.dp, top = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HomeCard(Modifier.width(144.dp).height(36.dp), onClick = { if (realChannelAvailable) section = "Home" }) { focused ->
Image(painterResource(R.drawable.orbit_logo), "Orbit", Modifier.fillMaxSize(), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
}
Spacer(Modifier.height(8.dp))
if (realChannelAvailable) {
Spacer(Modifier.width(8.dp))
NavItem("All Channels", section == "TV Guide", navFocus) { allChannelsPresses++; section = "TV Guide" }
NavItem("Drive-In", section == "Drive-In", null) { section = "Drive-In" }
NavItem("Arcade", section == "Arcade", null) { section = "Arcade" }
}
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), Modifier.background(if (section == "Drive-In") Navy.copy(alpha = .72f) else Color.Transparent).padding(horizontal = 6.dp, vertical = 3.dp), color = Color.White, fontSize = 17.sp)
if (realChannelAvailable) HomeCard(Modifier.size(38.dp, 32.dp), onClick = { section = "Connect Emby" }) { focused ->
Image(painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
}
}
if (!restoreChecked) Text("Tuning in…", color = Muted, fontSize = 15.sp)
EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby",
mainGuide = section == "TV Guide" && realChannelAvailable,
mainHome = section == "Home" && realChannelAvailable,
mainDriveIn = section == "Drive-In" && realChannelAvailable,
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" })
}
}
}
}
@@ -0,0 +1,214 @@
package tv.orbit.classic
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
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.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlin.math.cos
import kotlin.math.sin
internal enum class SnakeDirection(val dx: Int, val dy: Int) {
Up(0, -1), Down(0, 1), Left(-1, 0), Right(1, 0);
val opposite get() = when (this) { Up -> Down; Down -> Up; Left -> Right; Right -> Left }
}
internal data class SnakeCell(val x: Int, val y: Int)
/** The whole game, with no screen in it. Hit a wall or yourself and it's over. */
internal data class SnakeGame(val width: Int, val height: Int, val body: List<SnakeCell>, val heading: SnakeDirection, val wanted: SnakeDirection, val food: SnakeCell, val score: Int = 0, val alive: Boolean = true) {
/** A turn back on yourself is ignored, as it could only ever end the game. */
fun turn(direction: SnakeDirection) = if (direction == heading.opposite) this else copy(wanted = direction)
fun step(random: java.util.Random): SnakeGame {
if (!alive) return this
val head = body.first()
val next = SnakeCell(head.x + wanted.dx, head.y + wanted.dy)
val eating = next == food
// The tail moves away this step, so it's safe to follow it unless it's about to grow.
val solid = if (eating) body else body.dropLast(1)
if (next.x !in 0 until width || next.y !in 0 until height || next in solid) return copy(heading = wanted, alive = false)
val grown = listOf(next) + solid
return copy(body = grown, heading = wanted, food = if (eating) nextFood(width, height, grown, random) ?: food else food, score = if (eating) score + 10 else score,
alive = grown.size < width * height)
}
/** Starts at a walking pace and quickens with every bite, up to a limit. */
val tickMs get() = (150L - score / 10 * 3).coerceAtLeast(70L)
companion object {
fun start(width: Int, height: Int, random: java.util.Random): SnakeGame {
val y = height / 2
val body = List(4) { SnakeCell(width / 4 - it, y) }
return SnakeGame(width, height, body, SnakeDirection.Right, SnakeDirection.Right, nextFood(width, height, body, random)!!)
}
fun nextFood(width: Int, height: Int, body: List<SnakeCell>, random: java.util.Random): SnakeCell? {
val free = (0 until height).flatMap { y -> (0 until width).map { x -> SnakeCell(x, y) } } - body.toSet()
return if (free.isEmpty()) null else free[random.nextInt(free.size)]
}
}
}
private val Marquee = FontFamily(Font(R.font.anton_regular))
private val Honey = Color(0xFFFFD84A)
private val Sky = Color(0xFF3FC1FF)
private val Berry = Color(0xFFFF5A4F)
private fun hexagon(center: Offset, radius: Float) = Path().apply {
for (i in 0..5) {
val angle = Math.toRadians(60.0 * i)
val x = center.x + radius * sin(angle).toFloat()
val y = center.y - radius * cos(angle).toFloat()
if (i == 0) moveTo(x, y) else lineTo(x, y)
}
close()
}
@Composable
private fun SnakeField(game: SnakeGame, modifier: Modifier) {
Canvas(modifier) {
val cell = minOf(size.width / game.width, size.height / game.height)
val left = (size.width - cell * game.width) / 2
val top = (size.height - cell * game.height) / 2
for (y in 0 until game.height) for (x in 0 until game.width) if ((x + y) % 2 == 0)
drawRect(Color.White.copy(alpha = .025f), Offset(left + x * cell, top + y * cell), Size(cell, cell))
fun center(c: SnakeCell) = Offset(left + (c.x + .5f) * cell, top + (c.y + .5f) * cell)
drawPath(hexagon(center(game.food), cell * .46f), Berry)
game.body.zipWithNext { a, b -> drawLine(Sky, center(a), center(b), strokeWidth = cell * .68f, cap = StrokeCap.Round) }
drawCircle(Color.White, cell * .42f, center(game.body.first()))
val look = Offset(game.heading.dx * cell * .16f, game.heading.dy * cell * .16f)
drawCircle(Navy, cell * .13f, center(game.body.first()) + look)
}
}
/** Runs until it's left. A reminder or anything else taking the remote pauses it, so nobody loses a game to a pop-up. */
@Composable
private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
val random = remember { java.util.Random() }
var game by remember { mutableStateOf(SnakeGame.start(30, 22, random)) }
var paused by remember { mutableStateOf(false) }
var hadFocus by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
LaunchedEffect(paused, game.alive) {
while (!paused && game.alive) {
delay(game.tickMs)
game = game.step(random)
}
if (!game.alive && game.score > best) onBest(game.score)
}
val menuFocus = remember { FocusRequester() }
LaunchedEffect(paused, game.alive) { if (paused || !game.alive) menuFocus.requestFocus() else focus.requestFocus() }
BackHandler { if (game.alive) paused = !paused else onLeave() }
Row(Modifier.fillMaxSize().background(Navy).padding(20.dp).focusRequester(focus).onFocusChanged {
if (hadFocus && !it.hasFocus && game.alive) paused = true
hadFocus = it.hasFocus
}.focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || paused || !game.alive) return@onPreviewKeyEvent false
val direction = when (native.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> SnakeDirection.Up
KeyEvent.KEYCODE_DPAD_DOWN -> SnakeDirection.Down
KeyEvent.KEYCODE_DPAD_LEFT -> SnakeDirection.Left
KeyEvent.KEYCODE_DPAD_RIGHT -> SnakeDirection.Right
else -> null
}
if (direction != null) game = game.turn(direction)
direction != null
}, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Column(Modifier.width(190.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("SNAKE", color = Honey, fontFamily = Marquee, fontSize = 40.sp)
Spacer(Modifier.height(10.dp))
Text("SCORE", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(game.score.toString(), color = Color.White, fontFamily = Marquee, fontSize = 36.sp)
Text("BEST", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(maxOf(best, game.score).toString(), color = Color.White, fontFamily = Marquee, fontSize = 36.sp)
Spacer(Modifier.weight(1f))
Text("◄►▲▼ Steer", color = Muted, fontSize = 13.sp)
Text("Back Pause", color = Muted, fontSize = 13.sp)
}
Box(Modifier.weight(1f).fillMaxHeight().background(Color(0xFF050E2A)).border(2.dp, Blue), contentAlignment = Alignment.Center) {
SnakeField(game, Modifier.fillMaxSize().padding(6.dp))
if (paused || !game.alive) Column(Modifier.background(Navy, RoundedCornerShape(6.dp)).border(2.dp, Honey, RoundedCornerShape(6.dp)).padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (game.alive) "PAUSED" else "GAME OVER", color = Honey, fontFamily = Marquee, fontSize = 30.sp)
if (!game.alive) Text(if (game.score > best) "New best: ${game.score}" else "Score: ${game.score}", color = Color.White, fontSize = 16.sp)
OrbitButton(if (game.alive) "Carry on" else "Play again", Modifier.width(200.dp).focusRequester(menuFocus), onClick = {
if (game.alive) paused = false else { game = SnakeGame.start(30, 22, random); paused = false }
})
OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave)
}
}
}
}
/** The arcade: the game on the left, and a honeycomb of what's here, with room for more. */
@Composable
internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
val context = LocalContext.current
val settings = remember { OrbitSettings(context) }
var best by remember { mutableIntStateOf(settings.snakeBest) }
var playing by remember { mutableStateOf(false) }
DisposableEffect(playing) { onPlaying(playing); onDispose { onPlaying(false) } }
if (playing) {
SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = false })
return
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { first.requestFocus() }
val nav = LocalNavFocus.current
val preview = remember { SnakeGame(16, 9, listOf(SnakeCell(9, 4), SnakeCell(8, 4), SnakeCell(7, 4), SnakeCell(6, 4), SnakeCell(6, 5), SnakeCell(5, 5), SnakeCell(4, 5)), SnakeDirection.Right, SnakeDirection.Right, SnakeCell(12, 4)) }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Column(Modifier.weight(1f).fillMaxHeight().background(Panel, RoundedCornerShape(6.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_arcade_logo), "Orbit Arcade", Modifier.width(256.dp).height(90.dp))
Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A))) { SnakeField(preview, Modifier.fillMaxSize()) }
Text("SNAKE", color = Honey, fontFamily = Marquee, fontSize = 30.sp)
Text(if (best > 0) "Your best: $best" else "Eat, grow, and don't hit the walls or yourself.", color = Color.White, fontSize = 15.sp)
}
Column(Modifier.width(270.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
var focused by remember { mutableStateOf(false) }
Box(Modifier.size(170.dp).focusRequester(first).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused }.clickable { playing = true }, contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) }
Text("Snake", color = if (focused) Navy else Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
repeat(2) {
Box(Modifier.size(110.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), Panel.copy(alpha = .4f)) }
Text("More soon", color = Muted, fontSize = 12.sp)
}
}
}
}
}
Text("OK Play Back Main menu", color = Muted, fontSize = 11.sp)
}
}
@@ -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.75.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.76.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()) {
@@ -28,6 +28,9 @@ import androidx.tv.material3.Text
/** Choices about how Orbit behaves on this box. Nothing here is private, so it needs no encrypted storage. */
internal class OrbitSettings(context: Context) {
private val saved = context.getSharedPreferences("orbit_settings", Context.MODE_PRIVATE)
var snakeBest: Int
get() = saved.getInt("snake_best", 0)
set(value) { saved.edit().putInt("snake_best", value).apply() }
var subtitlesOn: Boolean
get() = saved.getBoolean("subtitles_on", false)
set(value) { saved.edit().putBoolean("subtitles_on", value).apply() }
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,65 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
import java.util.Random
class SnakeGameTest {
private val random = Random(1)
private fun game(vararg cells: Pair<Int, Int>, food: Pair<Int, Int> = 9 to 9, heading: SnakeDirection = SnakeDirection.Right) =
SnakeGame(10, 10, cells.map { SnakeCell(it.first, it.second) }, heading, heading, SnakeCell(food.first, food.second))
@Test fun itMovesForwardKeepingItsLength() {
val moved = game(3 to 5, 2 to 5, 1 to 5).step(random)
assertEquals(listOf(SnakeCell(4, 5), SnakeCell(3, 5), SnakeCell(2, 5)), moved.body)
assertTrue(moved.alive)
}
@Test fun eatingGrowsScoresAndPlacesNewFoodClearOfTheBody() {
val fed = game(3 to 5, 2 to 5, food = 4 to 5).step(random)
assertEquals(3, fed.body.size)
assertEquals(10, fed.score)
assertNotEquals(SnakeCell(4, 5), fed.food)
assertFalse(fed.food in fed.body)
}
@Test fun aWallEndsTheGame() {
assertFalse(game(9 to 5, 8 to 5).step(random).alive)
}
@Test fun hittingItselfEndsTheGame() {
val coiled = game(3 to 3, 3 to 4, 2 to 4, 2 to 3, 2 to 2, heading = SnakeDirection.Up).turn(SnakeDirection.Left)
assertFalse(coiled.step(random).alive)
}
@Test fun followingItsOwnTailIsSafe() {
val loop = SnakeGame(10, 10, listOf(SnakeCell(3, 3), SnakeCell(3, 4), SnakeCell(4, 4), SnakeCell(4, 3)), SnakeDirection.Up, SnakeDirection.Right, SnakeCell(9, 9))
assertTrue(loop.step(random).alive)
}
@Test fun aTurnBackOnItselfIsIgnored() {
val game = game(3 to 5, 2 to 5)
assertEquals(SnakeDirection.Right, game.turn(SnakeDirection.Left).wanted)
assertEquals(SnakeDirection.Up, game.turn(SnakeDirection.Up).wanted)
}
@Test fun twoQuickTurnsCantReverseIt() {
// Up then left before it moves: left is judged against where it's really heading.
val game = game(3 to 5, 2 to 5).turn(SnakeDirection.Up).turn(SnakeDirection.Left)
assertEquals(SnakeDirection.Right, game.heading)
assertEquals(SnakeDirection.Up, game.wanted)
}
@Test fun itQuickensUpToALimit() {
assertEquals(150L, game(3 to 5, 2 to 5).tickMs)
assertTrue(game(3 to 5, 2 to 5).copy(score = 100).tickMs < 150L)
assertEquals(70L, game(3 to 5, 2 to 5).copy(score = 10_000).tickMs)
}
@Test fun aNewGameStartsWithFoodOffTheSnake() {
val start = SnakeGame.start(30, 22, Random(2))
assertEquals(4, start.body.size)
assertFalse(start.food in start.body)
assertTrue(start.alive)
}
}
+1
View File
@@ -100,6 +100,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled.
- **Orbit Drive-In name (Paul, 1 November 2026):** decided, replacing OrbitOffice. Event types inside it are Orbit Marquee, Orbit Double Bill, Orbit Triple Bill and The Late Show. The briefs and design/interface still show the old name.
- **Orbit Drive-In (Paul, 2 November 2026):** built in 0.67 with three scenes that take turns: his theatre and drive-in pictures, and the CRT. Marquee nights, Friday double bills, Saturday triple bills and late shows from his films, booked a week ahead. Tickets and in-app reminders came in 0.71, 5 minutes before and at the start.
- **Top nav (Paul, 2 November 2026):** All Channels, Drive-In and Arcade along the top of every screen, in 0.76, each with its own screen, keeping the main menu clear. Snake is the Arcade's first game.
- **Orbit Arcade (Paul, 2 November 2026):** the look in design/arcade is approved: logo, honeycomb menu and Bedlam screen. Games are OrbitHive Bedlam, Snake, Block Drop, Brick Break, Pairs and Patience. Small games only, so the app stays small: drawn in code with no game engine, and measured for size when built. Not scheduled.
## Channel numbers