Smooth Snake, fix Play again and Carry on, and blend the Arcade logo (0.76.1)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m43s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m32s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 14:31:02 +00:00
co-authored by Claude Opus 5.5
parent 2630215430
commit aa4337de22
9 changed files with 65 additions and 21 deletions
+5
View File
@@ -0,0 +1,5 @@
# Orbit TV 0.76.1: smoother Snake
From Paul's play. Play again and Carry on no longer drop back to the pause menu: buttons disappearing looked like losing the remote, so now only the whole window losing focus pauses a game. The snake glides between cells, drawn every frame, rather than jumping. The Arcade logo has no background, so it sits into the panel.
Checked: build, lint and all unit tests pass, including the glide. Not checked until installed: how smooth it is on the box.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 80
versionName = "0.76.0-top-nav-arcade"
versionCode = 81
versionName = "0.76.1-smoother-snake"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.76.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.76.1\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -24,6 +24,7 @@ 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.platform.LocalWindowInfo
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -91,46 +92,65 @@ private fun hexagon(center: Offset, radius: Float) = Path().apply {
close()
}
/** The snake's line from head to tail, partway between two steps, so it glides from cell to cell rather than jumping. */
internal fun snakeLine(game: SnakeGame, before: List<SnakeCell>?, progress: Float): List<Pair<Float, Float>> {
val cells = game.body.map { it.x.toFloat() to it.y.toFloat() }.toMutableList()
if (before.isNullOrEmpty() || !game.alive) return cells
fun between(from: SnakeCell, to: SnakeCell) = (from.x + (to.x - from.x) * progress) to (from.y + (to.y - from.y) * progress)
cells[0] = between(before.first(), game.body.first())
cells[cells.lastIndex] = between(before.last(), game.body.last())
return cells
}
@Composable
private fun SnakeField(game: SnakeGame, modifier: Modifier) {
private fun SnakeField(game: SnakeGame, modifier: Modifier, before: List<SnakeCell>? = null, progress: Float = 1f) {
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()))
fun center(x: Float, y: Float) = Offset(left + (x + .5f) * cell, top + (y + .5f) * cell)
drawPath(hexagon(center(game.food.x.toFloat(), game.food.y.toFloat()), cell * .46f), Berry)
val line = snakeLine(game, before, progress).map { center(it.first, it.second) }
line.zipWithNext { a, b -> drawLine(Sky, a, b, strokeWidth = cell * .68f, cap = StrokeCap.Round) }
drawCircle(Color.White, cell * .42f, line.first())
val look = Offset(game.heading.dx * cell * .16f, game.heading.dy * cell * .16f)
drawCircle(Navy, cell * .13f, center(game.body.first()) + look)
drawCircle(Navy, cell * .13f, line.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. */
/** Runs until it's left. A reminder taking over 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 before by remember { mutableStateOf<List<SnakeCell>?>(null) }
var progress by remember { mutableFloatStateOf(1f) }
var paused by remember { mutableStateOf(false) }
var hadFocus by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
// Only the whole window losing focus counts: buttons coming and going move focus around too.
val windowFocused = LocalWindowInfo.current.isWindowFocused
LaunchedEffect(windowFocused) { if (!windowFocused && game.alive) paused = true }
// Steps on the clock, drawn on every frame between them.
LaunchedEffect(paused, game.alive) {
var last = withFrameNanos { it }
while (!paused && game.alive) {
delay(game.tickMs)
game = game.step(random)
val time = withFrameNanos { it }
val tick = game.tickMs * 1_000_000f
if (time - last >= tick) {
last += tick.toLong()
before = game.body
game = game.step(random)
}
progress = ((time - last) / tick).coerceIn(0f, 1f)
}
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 ->
Row(Modifier.fillMaxSize().background(Navy).padding(20.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN || paused || !game.alive) return@onPreviewKeyEvent false
val direction = when (native.keyCode) {
@@ -155,12 +175,13 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
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))
SnakeField(game, Modifier.fillMaxSize().padding(6.dp), before, progress)
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 }
if (!game.alive) { game = SnakeGame.start(30, 22, random); before = null; progress = 1f }
paused = false
})
OrbitButton("Leave the game", Modifier.width(200.dp), onClick = onLeave)
}
@@ -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.76.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.76.1")
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()) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -62,4 +62,15 @@ class SnakeGameTest {
assertFalse(start.food in start.body)
assertTrue(start.alive)
}
@Test fun theSnakeGlidesBetweenSteps() {
val before = listOf(SnakeCell(3, 5), SnakeCell(2, 5), SnakeCell(1, 5))
val moved = game(3 to 5, 2 to 5, 1 to 5).step(random)
val halfway = snakeLine(moved, before, .5f)
assertEquals(3.5f to 5f, halfway.first())
assertEquals(3f to 5f, halfway[1])
assertEquals(1.5f to 5f, halfway.last())
assertEquals(4f to 5f, snakeLine(moved, before, 1f).first())
assertEquals(4f to 5f, snakeLine(moved, null, .5f).first())
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="360" viewBox="0 0 1024 360"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="honey" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF1A8"/><stop offset=".5" stop-color="#FFD84A"/><stop offset="1" stop-color="#F2A516"/></linearGradient><linearGradient id="space" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#071632"/><stop offset=".6" stop-color="#0E1F52"/><stop offset="1" stop-color="#2A1760"/></linearGradient><radialGradient id="planet" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#6C8CFF"/><stop offset=".6" stop-color="#3A3FA8"/><stop offset="1" stop-color="#1A1650"/></radialGradient><radialGradient id="glass" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#FFFFFF" stop-opacity=".55"/><stop offset="1" stop-color="#BFE3FF" stop-opacity=".12"/></radialGradient></defs><g transform="translate(30,20) scale(0.58)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FFD84A" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#FFD84A"/></g><text x="366" y="108" font-family="Barlow" font-size="44" fill="#F5F7FF" font-weight="600" letter-spacing="16">ORBIT</text><text x="362" y="262" font-family="Anton" font-size="150" fill="url(#honey)" letter-spacing="4">ARCADE</text><path d="M378.0 280.0L400.5 293.0L400.5 319.0L378.0 332.0L355.5 319.0L355.5 293.0Z" fill="#FF5A4F"/><path d="M378.0 288.3L393.3 297.2L393.3 314.8L378.0 323.7L362.7 314.8L362.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M374.4 294.6L379.8 297.7L379.8 303.9L374.4 307.0L369.0 303.9L369.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M440.0 280.0L462.5 293.0L462.5 319.0L440.0 332.0L417.5 319.0L417.5 293.0Z" fill="#3FC1FF"/><path d="M440.0 288.3L455.3 297.2L455.3 314.8L440.0 323.7L424.7 314.8L424.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M436.4 294.6L441.8 297.7L441.8 303.9L436.4 307.0L431.0 303.9L431.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M502.0 280.0L524.5 293.0L524.5 319.0L502.0 332.0L479.5 319.0L479.5 293.0Z" fill="#5BD46A"/><path d="M502.0 288.3L517.3 297.2L517.3 314.8L502.0 323.7L486.7 314.8L486.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M498.4 294.6L503.8 297.7L503.8 303.9L498.4 307.0L493.0 303.9L493.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M564.0 280.0L586.5 293.0L586.5 319.0L564.0 332.0L541.5 319.0L541.5 293.0Z" fill="#C77DFF"/><path d="M564.0 288.3L579.3 297.2L579.3 314.8L564.0 323.7L548.7 314.8L548.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M560.4 294.6L565.8 297.7L565.8 303.9L560.4 307.0L555.0 303.9L555.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><path d="M626.0 280.0L648.5 293.0L648.5 319.0L626.0 332.0L603.5 319.0L603.5 293.0Z" fill="#FF8A3D"/><path d="M626.0 288.3L641.3 297.2L641.3 314.8L626.0 323.7L610.7 314.8L610.7 297.2Z" fill="#FFFFFF" fill-opacity=".2"/><path d="M622.4 294.6L627.8 297.7L627.8 303.9L622.4 307.0L617.0 303.9L617.0 297.7Z" fill="#FFFFFF" fill-opacity=".55"/><g transform="translate(858,306) scale(0.52)"><ellipse cx="-28" cy="-54" rx="26" ry="42" transform="rotate(-24 -28 -54)" fill="#CFE3FF" fill-opacity=".8"/><ellipse cx="14" cy="-58" rx="22" ry="36" transform="rotate(16 14 -58)" fill="#CFE3FF" fill-opacity=".6"/><ellipse cx="-30" cy="4" rx="64" ry="46" fill="url(#honey)"/><path d="M-64 -30C-72 -8 -72 18 -62 34L-44 44C-54 14 -54 -14 -44 -42ZM-22 -46C-32 -14 -32 22 -22 50L-2 46C-12 16 -12 -14 -4 -44Z" fill="#071632"/><circle cx="46" cy="-8" r="34" fill="#FFD84A"/><circle cx="56" cy="-14" r="7" fill="#071632"/><path d="M50 8C58 13 66 12 72 6" fill="none" stroke="#071632" stroke-width="4" stroke-linecap="round"/><circle cx="46" cy="-8" r="46" fill="url(#glass)" stroke="#E8EDF5" stroke-width="5"/><path d="M34 -52L26 -80M58 -52L68 -80" stroke="#E8EDF5" stroke-width="4"/><circle cx="26" cy="-84" r="6" fill="#FF5A4F"/><circle cx="68" cy="-84" r="6" fill="#3FC1FF"/></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+6
View File
@@ -44,6 +44,12 @@ def write(name, w, h, body):
write('arcade_logo', 1024, 360, '<rect width="1024" height="360" fill="#071632"/>' + stars(1024, 360, 45) + mark(30, 20, .58) + text(366, 108, 'ORBIT', 44, '#F5F7FF', 'Barlow', 'font-weight="600" letter-spacing="16"') +
text(362, 262, 'ARCADE', 150, 'url(#honey)', 'Anton', 'letter-spacing="4"') + ''.join(piece(378 + i * 62, 306, 26, c) for i, c in enumerate(PIECES[:5])) + bee(858, 306, .52))
# The app's copy has no background, so it sits on whatever's behind it.
write('arcade_logo_clear', 1024, 360, mark(30, 20, .58) + text(366, 108, 'ORBIT', 44, '#F5F7FF', 'Barlow', 'font-weight="600" letter-spacing="16"') +
text(362, 262, 'ARCADE', 150, 'url(#honey)', 'Anton', 'letter-spacing="4"') + ''.join(piece(378 + i * 62, 306, 26, c) for i, c in enumerate(PIECES[:5])) + bee(858, 306, .52))
from PIL import Image
Image.open(HERE / 'arcade_logo_clear.png').convert('RGBA').resize((512, 180), Image.LANCZOS).save(HERE.parents[1] / 'android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp', quality=92)
# Icons for the menu, drawn in a box 100 wide around 0,0.
def snake(c):
return f'<path d="M-40 26H-6V-8H30V-34" fill="none" stroke="{c}" stroke-width="13" stroke-linejoin="round" stroke-linecap="round"/><circle cx="30" cy="-36" r="9" fill="{c}"/><rect x="-40" y="-30" width="15" height="15" fill="#FF5A4F"/>'