Add a top nav and Orbit Arcade with Snake (0.76.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user