diff --git a/VERIFICATION-0.76.1.md b/VERIFICATION-0.76.1.md new file mode 100644 index 0000000..e5f8c2a --- /dev/null +++ b/VERIFICATION-0.76.1.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7d9559b..525350c 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 = 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 } 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 94d7388..4e47606 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.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) diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 441ccb3..1af7e30 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -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?, progress: Float): List> { + 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? = 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?>(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) } 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 e39b56c..b611970 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.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()) { 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 index 6de09c4..8e9a504 100644 Binary files a/android/app/src/main/res/drawable-nodpi/orbit_arcade_logo.webp 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 index a96bc47..d4e1b2d 100644 --- a/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt @@ -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()) + } } diff --git a/design/arcade/arcade_logo_clear.svg b/design/arcade/arcade_logo_clear.svg new file mode 100644 index 0000000..fa0dac8 --- /dev/null +++ b/design/arcade/arcade_logo_clear.svg @@ -0,0 +1 @@ +ORBITARCADE \ No newline at end of file diff --git a/design/arcade/build-arcade.py b/design/arcade/build-arcade.py index c4fac36..de27dba 100644 --- a/design/arcade/build-arcade.py +++ b/design/arcade/build-arcade.py @@ -44,6 +44,12 @@ def write(name, w, h, body): write('arcade_logo', 1024, 360, '' + 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''