diff --git a/VERIFICATION-0.77.0.md b/VERIFICATION-0.77.0.md new file mode 100644 index 0000000..44d9fa7 --- /dev/null +++ b/VERIFICATION-0.77.0.md @@ -0,0 +1,6 @@ +# Orbit TV 0.77.0: Snake sprites and music + +- The green snake and red apple from Paul's Snake Sprites pack by Bas de Reuver. Its readme allows personal and commercial use, and asks for a credit, which is under Emby account. A copy is in design/arcade/snake-sprites. Each piece glides between cells, and the head changes on death. +- An original 27-second chiptune loop, written and synthesised by design/arcade/build-music.py, so there's no licence to clear. It plays during a game, stops when paused or over, and can be turned off from the pause menu, which is remembered. 159 KB. + +Checked: build, lint and all unit tests pass. I can't hear the tune or see the sprites on the TV, so both are Paul's call. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d449a27..dc378b9 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 = 83 - versionName = "0.76.3-snake-backdrops" + versionCode = 84 + versionName = "0.77.0-snake-sprites-music" } 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 b095494..df3d27c 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.3\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.77.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/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index f0931a0..1c6bceb 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -23,18 +23,22 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.imageResource 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.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text @@ -85,8 +89,6 @@ internal data class SnakeGame(val width: Int, val height: Int, val body: 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 -} +/** Where each piece of the snake is drawn, partway between two steps, so it glides from cell to cell rather than jumping. */ +internal fun snakeLine(game: SnakeGame, before: List?, progress: Float): List> = + game.body.mapIndexed { index, cell -> + val from = before?.getOrNull(index)?.takeIf { game.alive } ?: cell + (from.x + (cell.x - from.x) * progress) to (from.y + (cell.y - from.y) * progress) + } @Composable private fun SnakeField(game: SnakeGame, modifier: Modifier, before: List? = null, progress: Float = 1f) { + val head = ImageBitmap.imageResource(R.drawable.snake_head) + val dead = ImageBitmap.imageResource(R.drawable.snake_dead) + val blob = ImageBitmap.imageResource(R.drawable.snake_blob) + val apple = ImageBitmap.imageResource(R.drawable.snake_apple) 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(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, line.first() + look) + fun sprite(image: ImageBitmap, x: Float, y: Float, scale: Float) { + val side = (cell * scale).toInt() + val offset = (cell - side) / 2 + drawImage(image, dstOffset = IntOffset((left + x * cell + offset).toInt(), (top + y * cell + offset).toInt()), dstSize = IntSize(side, side), filterQuality = FilterQuality.Medium) + } + sprite(apple, game.food.x.toFloat(), game.food.y.toFloat(), 1.05f) + val line = snakeLine(game, before, progress) + line.drop(1).asReversed().forEach { (x, y) -> sprite(blob, x, y, 1f) } + line.first().let { (x, y) -> sprite(if (game.alive) head else dead, x, y, 1.25f) } } } @@ -196,6 +201,13 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) { } if (!game.alive && game.score > best) onBest(game.score) } + val context = LocalContext.current + val settings = remember { OrbitSettings(context) } + var musicOn by remember { mutableStateOf(settings.snakeMusic) } + val music = remember { android.media.MediaPlayer.create(context, R.raw.snake_theme)?.apply { isLooping = true; setVolume(.5f, .5f) } } + DisposableEffect(music) { onDispose { music?.release() } } + val playing = musicOn && !paused && game.alive + LaunchedEffect(playing) { if (playing) music?.start() else if (music?.isPlaying == true) music.pause() } 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() } @@ -233,6 +245,7 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) { if (!game.alive) { game = SnakeGame.start(30, 22, random); before = null; progress = 1f } paused = false }) + OrbitButton("Music: ${if (musicOn) "On" else "Off"}", Modifier.width(200.dp), onClick = { musicOn = !musicOn; settings.snakeMusic = musicOn }) 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 930f2f6..fb8d481 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.3") + next.setRequestProperty("User-Agent", "OrbitTV/0.77.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 f980631..59c5b17 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 snakeMusic: Boolean + get() = saved.getBoolean("snake_music", true) + set(value) { saved.edit().putBoolean("snake_music", value).apply() } var snakeBest: Int get() = saved.getInt("snake_best", 0) set(value) { saved.edit().putInt("snake_best", value).apply() } @@ -137,6 +140,7 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, } } else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", onClick = { confirming = true }) } Text("Orbit TV $version", color = Muted, fontSize = 12.sp) + Text("Snake graphics by Bas de Reuver, bdrgames.nl", color = Muted, fontSize = 12.sp) } } diff --git a/android/app/src/main/res/drawable-nodpi/snake_apple.webp b/android/app/src/main/res/drawable-nodpi/snake_apple.webp new file mode 100644 index 0000000..7e2594a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/snake_apple.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/snake_blob.webp b/android/app/src/main/res/drawable-nodpi/snake_blob.webp new file mode 100644 index 0000000..e89e2cc Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/snake_blob.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/snake_dead.webp b/android/app/src/main/res/drawable-nodpi/snake_dead.webp new file mode 100644 index 0000000..1d3e34a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/snake_dead.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/snake_head.webp b/android/app/src/main/res/drawable-nodpi/snake_head.webp new file mode 100644 index 0000000..798307e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/snake_head.webp differ diff --git a/android/app/src/main/res/raw/snake_theme.ogg b/android/app/src/main/res/raw/snake_theme.ogg new file mode 100644 index 0000000..81cf747 Binary files /dev/null and b/android/app/src/main/res/raw/snake_theme.ogg 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 d314a6d..c59f97c 100644 --- a/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/SnakeGameTest.kt @@ -69,7 +69,7 @@ class SnakeGameTest { 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(2.5f 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/build-music.py b/design/arcade/build-music.py new file mode 100644 index 0000000..94f0afd --- /dev/null +++ b/design/arcade/build-music.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""An original chiptune for Snake, written and synthesised here, so there's no licence to clear. Square-wave lead and arpeggio, triangle bass, noise hats.""" +import math, random, struct, subprocess, tempfile, wave +from pathlib import Path +RATE, BPM = 22050, 144 +EIGHTH = 60 / BPM / 2 +OUT = Path(__file__).parents[2] / 'android/app/src/main/res/raw/snake_theme.ogg' +E2, Fs2, G2, A2, B2, C3, D3 = 40, 42, 43, 45, 47, 48, 50 +# Lead as (note, eighths) per bar, 0 is a rest. E minor, climbing in the second half. +E4, Fs4, G4, A4, B4, C5, D5, E5, Fs5, G5 = 64, 66, 67, 69, 71, 72, 74, 76, 78, 79 +LEAD = [ + [(E4, 2), (G4, 1), (B4, 1), (E5, 2), (D5, 1), (B4, 1)], [(C5, 2), (B4, 1), (A4, 1), (G4, 2), (A4, 2)], + [(B4, 3), (D5, 1), (G5, 2), (Fs5, 1), (E5, 1)], [(D5, 2), (A4, 2), (Fs4, 2), (A4, 2)], + [(E4, 2), (G4, 1), (B4, 1), (E5, 2), (G5, 1), (E5, 1)], [(C5, 2), (E5, 1), (C5, 1), (A4, 2), (C5, 2)], + [(A4, 2), (C5, 1), (E5, 1), (D5, 1), (C5, 1), (A4, 2)], [(B4, 3), (Fs4, 1), (B4, 2), (0, 2)], + [(G5, 1), (E5, 1), (B4, 1), (E5, 1), (G5, 1), (E5, 1), (B4, 1), (E5, 1)], [(E5, 1), (C5, 1), (G4, 1), (C5, 1), (E5, 2), (D5, 2)], + [(D5, 1), (B4, 1), (G4, 1), (B4, 1), (D5, 2), (G5, 2)], [(Fs5, 2), (D5, 1), (A4, 1), (Fs4, 2), (A4, 2)], + [(E5, 3), (G5, 1), (Fs5, 2), (E5, 2)], [(C5, 3), (E5, 1), (D5, 2), (C5, 2)], + [(A4, 1), (C5, 1), (E5, 1), (A4, 1), (C5, 1), (E5, 1), (G5, 2)], [(Fs5, 2), (E5, 1), (D5, 1), (B4, 4)], +] +# Each bar's chord, as the bass note and the notes above it. +CHORDS = [(E2, (0, 7, 12, 15)), (C3, (0, 7, 12, 16)), (G2, (0, 7, 12, 16)), (D3, (0, 7, 12, 16)), + (E2, (0, 7, 12, 15)), (C3, (0, 7, 12, 16)), (A2, (0, 7, 12, 15)), (B2, (0, 7, 12, 16))] * 2 +def freq(note): return 440 * 2 ** ((note - 69) / 12) +def square(t, f, duty): return 1.0 if (t * f) % 1 < duty else -1.0 +def triangle(t, f): return 4 * abs((t * f) % 1 - .5) - 1 +total = int(RATE * EIGHTH * 8 * len(LEAD)) +mix = [0.0] * total +def add(start, length, f, wave_fn, level, decay): + a, b = int(start * RATE), int((start + length) * RATE) + for i in range(a, min(b, total)): + t = i / RATE + age = t - start + env = min(1, age * 200) * min(1, (start + length - t) * 120) * (decay + (1 - decay) * math.exp(-age * 4)) + mix[i] += wave_fn(t, f) * level * env +beat = 0.0 +for bar, notes in enumerate(LEAD): + start = bar * EIGHTH * 8 + at = start + for note, length in notes: + if note: add(at, length * EIGHTH * .95, freq(note), lambda t, f: square(t, f, .25), .20, .55) + at += length * EIGHTH + root, shape = CHORDS[bar] + for step in range(16): + # A quick arpeggio up the chord, at sixteenths, an octave and more above the bass. + add(start + step * EIGHTH / 2, EIGHTH / 2 * .8, freq(root + 24 + shape[step % 4]), lambda t, f: square(t, f, .125), .07, .3) + for step in range(8): + add(start + step * EIGHTH, EIGHTH * .9, freq(root + (12 if step % 2 else 0)), triangle, .28, .8) +noise = random.Random(4) +for hit in range(len(LEAD) * 8): + start = hit * EIGHTH + a, b = int(start * RATE), int((start + EIGHTH * (.45 if hit % 4 == 2 else .2)) * RATE) + level = .10 if hit % 4 == 2 else .05 + held = 0.0 + for i in range(a, min(b, total)): + if i % 2 == 0: held = noise.choice((-1.0, 1.0)) + mix[i] += held * level * (1 - (i - a) / (b - a)) +peak = max(abs(s) for s in mix) +with tempfile.TemporaryDirectory() as folder: + wav = Path(folder) / 'theme.wav' + with wave.open(str(wav), 'wb') as out: + out.setnchannels(1); out.setsampwidth(2); out.setframerate(RATE) + out.writeframes(b''.join(struct.pack('