Add OrbitHive to the Arcade with Orbit and Classic skins (0.79.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m57s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m31s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 18:57:17 +00:00
co-authored by Claude Opus 5.5
parent b366610007
commit 022d27c0d8
9 changed files with 1241 additions and 27 deletions
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.79.0: OrbitHive in the Arcade
Paul's Hive Harvest game, renamed OrbitHive, runs in the Arcade beside Snake. It's JavaScript he had built, all drawn in code, about 50 KB plus a font, shown in a WebView with network loads blocked. Progress stays on the box.
- Two skins, under Help & Settings: Orbit, the default, with starry space and the bee in a helmet, and Classic, the game as it was.
- Names that echoed Beehive Bedlam are gone from the game's text: no Buzz and no Bedlam.
- Back goes through the game's own menus, then leaves to the Arcade.
Checked: both skins and a level start in a headless browser with screenshots. Build, lint and 294 unit tests pass.
Not checked until installed: the remote and Back in the WebView on the box, sound, and how smoothly it runs. Rules resemble Beehive Bedlam's: fine for Paul's own use, but they want a proper look before any public release.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 85
versionName = "0.78.0-idents"
versionCode = 86
versionName = "0.79.0-orbithive"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>OrbitHive</title>
<style>
@font-face{font-family:Barlow;font-weight:100 900;src:url(barlow-semibold.ttf)}
html,body{margin:0;height:100%;background:#071632;overflow:hidden;touch-action:none;user-select:none}
canvas{display:block;width:100vw;height:100vh;outline:none}
</style>
</head>
<body>
<canvas id="game" tabindex="0"></canvas>
<script src="game.js"></script>
</body>
</html>
@@ -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.78.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.79.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -41,6 +41,9 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlin.math.cos
@@ -252,17 +255,57 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
}
}
/** The arcade: the game on the left, and a honeycomb of what's here, with room for more. */
/** OrbitHive runs as a small web page from inside the app, with no network, and handles the remote itself. */
@android.annotation.SuppressLint("SetJavaScriptEnabled")
@Composable
private fun OrbitHiveScreen(onLeave: () -> Unit) {
var web by remember { mutableStateOf<WebView?>(null) }
BackHandler { web?.evaluateJavascript("window.orbitBack()", null) }
AndroidView(modifier = Modifier.fillMaxSize(), factory = { context ->
WebView(context).apply {
setBackgroundColor(android.graphics.Color.rgb(7, 22, 50))
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.blockNetworkLoads = true
settings.mediaPlaybackRequiresUserGesture = false
isFocusable = true; isFocusableInTouchMode = true
addJavascriptInterface(object {
@JavascriptInterface fun leave() { post { onLeave() } }
}, "OrbitApp")
loadUrl("file:///android_asset/orbithive/index.html")
requestFocus()
web = this
}
}, onRelease = { it.destroy() })
}
private enum class ArcadeGame(val title: String, val blurb: String) {
Snake("SNAKE", "Eat, grow, and don't hit the walls or yourself."),
Hive("ORBITHIVE", "Fire flowers into the honeycomb, and clear the hive before it reaches you.")
}
@Composable
private fun GameTile(game: ArcadeGame, focus: FocusRequester?, nav: FocusRequester?, onFocus: () -> Unit, onPlay: () -> Unit) {
var focused by remember { mutableStateOf(false) }
Box((if (focus != null) Modifier.focusRequester(focus) else Modifier).size(135.dp).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }.clickable(onClick = onPlay), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) { drawPath(hexagon(center, size.minDimension / 2), if (focused) Yellow else Panel) }
Text(if (game == ArcadeGame.Snake) "Snake" else "OrbitHive", color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
}
/** The arcade: the chosen 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
var playing by remember { mutableStateOf<ArcadeGame?>(null) }
var chosen by remember { mutableStateOf(ArcadeGame.Snake) }
DisposableEffect(playing) { onPlaying(playing != null); onDispose { onPlaying(false) } }
when (playing) {
ArcadeGame.Snake -> { SnakeScreen(best, onBest = { best = it; settings.snakeBest = it }, onLeave = { playing = null }); return }
ArcadeGame.Hive -> { OrbitHiveScreen(onLeave = { playing = null }); return }
null -> {}
}
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { first.requestFocus() }
@@ -272,27 +315,32 @@ internal fun OrbitArcade(modifier: Modifier, onPlaying: (Boolean) -> Unit) {
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)
Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF050E2A))) {
if (chosen == ArcadeGame.Snake) SnakeField(preview, Modifier.fillMaxSize())
else Canvas(Modifier.fillMaxSize()) {
val colours = listOf(Color(0xFFE53935), Color(0xFF1E66D0), Color(0xFF2E9D3A), Color(0xFFF4F4F4), Color(0xFFF5A623), Color(0xFF8E44AD))
val r = size.height / 9
for (row in 0..3) for (col in 0 until (size.width / (r * 1.8f)).toInt()) {
val at = Offset(r + col * r * 1.8f + (row % 2) * r * .9f, r + row * r * 1.56f)
drawPath(hexagon(at, r * .92f), colours[(col * 3 + row * 5 + col * row) % colours.size])
}
}
}
Text(chosen.title, color = Honey, fontFamily = Marquee, fontSize = 30.sp)
Text(if (chosen == ArcadeGame.Snake && best > 0) "Your best: $best" else chosen.blurb, color = Color.White, fontSize = 15.sp, maxLines = 2)
}
Column(Modifier.width(290.dp).fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
GameTile(ArcadeGame.Snake, first, nav, { chosen = ArcadeGame.Snake }, { playing = ArcadeGame.Snake })
GameTile(ArcadeGame.Hive, null, nav, { chosen = ArcadeGame.Hive }, { playing = ArcadeGame.Hive })
}
Spacer(Modifier.height(4.dp))
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)
Text("◄► Choose 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.78.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.79.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()) {
+4
View File
@@ -33,6 +33,10 @@ Paul's approach, 2 November 2026: build in pieces, learn by using it, and note e
- **Check them in motion.** Paul caught what stills hid: planets outside their rings, a burst covering a letter.
- **Flat layers look blocky.** Real 3D in Blender is a big step up, and Blender's free. It takes about 25 minutes per ident on a mid-range graphics card.
## Games
- **OrbitHive recreates Beehive Bedlam's rules.** The names are changed and the art is its own, but check this properly before any public release.
## Size and upkeep
Paul, 2 November 2026: keep an eye on optimisation, size and cache management.