diff --git a/VERIFICATION-0.76.0.md b/VERIFICATION-0.76.0.md new file mode 100644 index 0000000..46249ac --- /dev/null +++ b/VERIFICATION-0.76.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d45346d..7d9559b 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt index a4a4c7e..b47ecd2 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -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 } 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 bba1944..94d7388 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.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) 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 5a11943..bb7bd2a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index 84df1b1..63bd35d 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -128,7 +128,7 @@ internal fun HomeScreen(session: EmbySampleSession, channels: List { null } +/** The first place on the top bar, so the top of any screen can step straight up to it. */ +internal val LocalNavFocus = staticCompositionLocalOf { 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" }) - } } } } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt new file mode 100644 index 0000000..441ccb3 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -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, 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, 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) + } +} 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 bfa766c..e39b56c 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.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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 0a4f750..f980631 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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() } diff --git a/android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp b/android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp new file mode 100644 index 0000000..6de09c4 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp differ diff --git a/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt b/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt new file mode 100644 index 0000000..a96bc47 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt @@ -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, food: Pair = 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) + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index cb3e121..daf156e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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