diff --git a/VERIFICATION-0.79.3.md b/VERIFICATION-0.79.3.md
new file mode 100644
index 0000000..707f852
--- /dev/null
+++ b/VERIFICATION-0.79.3.md
@@ -0,0 +1,5 @@
+# Orbit TV 0.79.3
+
+- OrbitHive drew nothing on the box: blue screen with sound. Log showed canvas box 960x0 with real game pixels inside.
+- Fix: canvas sized with position:fixed and 100%, size re-checked each frame. WebView can read app assets. Window not secure while the game is open.
+- Confirmed on the TV by Paul before the version bump: full screen and working. Temporary pixel logging removed for this release.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 852c9e9..d967765 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 = 86
- versionName = "0.79.0-orbithive"
+ versionCode = 89
+ versionName = "0.79.3-orbithive-canvas"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
diff --git a/android/app/src/main/assets/orbithive/game.js b/android/app/src/main/assets/orbithive/game.js
index c34b309..52f4aa8 100644
--- a/android/app/src/main/assets/orbithive/game.js
+++ b/android/app/src/main/assets/orbithive/game.js
@@ -36,9 +36,9 @@
// ---------- canvas ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
- let scale = 1, ox = 0, oy = 0, dpr = 1;
+ let scale = 1, ox = 0, oy = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
function resize() {
- dpr = Math.min(window.devicePixelRatio || 1, 2);
+ dpr = 1;
canvas.width = Math.round(innerWidth * dpr);
canvas.height = Math.round(innerHeight * dpr);
scale = Math.min(canvas.width / W, canvas.height / H);
@@ -1110,6 +1110,8 @@
// ---------- main loop ----------
let last = performance.now();
function frame(now) {
+ // In the app the page can start before it has a size, and may not be told when it gets one.
+ if (innerWidth > 0 && (canvas.width !== Math.round(innerWidth * dpr) || canvas.height !== Math.round(innerHeight * dpr))) resize();
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
update(dt);
diff --git a/android/app/src/main/assets/orbithive/index.html b/android/app/src/main/assets/orbithive/index.html
index 382f8dc..bd1f873 100644
--- a/android/app/src/main/assets/orbithive/index.html
+++ b/android/app/src/main/assets/orbithive/index.html
@@ -7,7 +7,7 @@
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 0b47c10..d23335a 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.79.0\"")
+ connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.79.3\"")
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 703b626..3e55758 100644
--- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt
+++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt
@@ -42,7 +42,10 @@ 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.ConsoleMessage
import android.webkit.JavascriptInterface
+import android.webkit.WebChromeClient
+import android.webkit.WebViewClient
import android.webkit.WebView
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
@@ -260,14 +263,33 @@ private fun SnakeScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) {
@Composable
private fun OrbitHiveScreen(onLeave: () -> Unit) {
var web by remember { mutableStateOf(null) }
+ // No protected picture here, and leaving the window open to screenshots lets the game be checked on the box.
+ val activity = androidx.activity.compose.LocalActivity.current
+ androidx.compose.runtime.DisposableEffect(Unit) {
+ activity?.window?.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
+ onDispose { activity?.window?.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) }
+ }
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.allowFileAccess = true
settings.mediaPlaybackRequiresUserGesture = false
+ // The page is the app's own and asks for nothing outside it. Its problems go to the log.
+ webChromeClient = object : WebChromeClient() {
+ override fun onConsoleMessage(message: ConsoleMessage): Boolean {
+ android.util.Log.i("OrbitHive", "${message.messageLevel()} ${message.message()} line ${message.lineNumber()}")
+ return true
+ }
+ }
+ webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, url: String) { android.util.Log.i("OrbitHive", "loaded size=${view.width}x${view.height}") }
+ override fun onReceivedError(view: WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError) {
+ android.util.Log.w("OrbitHive", "failed ${request.url.lastPathSegment}: ${error.description}")
+ }
+ }
isFocusable = true; isFocusableInTouchMode = true
addJavascriptInterface(object {
@JavascriptInterface fun leave() { post { 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 8a34f89..bc93542 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.79.0")
+ next.setRequestProperty("User-Agent", "OrbitTV/0.79.3")
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/design/channels/blender/build_3d_ident.py b/design/channels/blender/build_3d_ident.py
index fdc3b79..fd66797 100644
--- a/design/channels/blender/build_3d_ident.py
+++ b/design/channels/blender/build_3d_ident.py
@@ -504,12 +504,12 @@ def haunted(windows_colour='#FF2A1E'):
moon, n, l = material('moon'); out = n['Material Output']; n.remove(n['Principled BSDF'])
coord = n.new('ShaderNodeTexCoord'); tone = noise_ramp(n, l, coord.outputs['Object'], .22, 10, [(.32, '#7A0A10'), (.7, '#E8261E')])
emit = n.new('ShaderNodeEmission'); l.new(tone.outputs['Color'], emit.inputs['Color']); emit.inputs['Strength'].default_value = 2.4; l.new(emit.outputs[0], out.inputs['Surface'])
- ball = sphere('moon', 26); ball.location = (46, 150, 40); ball.data.materials.append(moon); ball.visible_shadow = False
+ ball = sphere('moon', 26); ball.location = (92, 150, 44); ball.data.materials.append(moon); ball.visible_shadow = False
data = bpy.data.lights.new('rim', 'SUN'); data.energy = 1.5; data.color = hexrgb('#FF7A6A')[:3]; rim = bpy.data.objects.new('rim', data); bpy.context.collection.objects.link(rim); rim.rotation_euler = Vector((.45, .75, -.5)).to_track_quat('-Z', 'Y').to_euler()
scene.world = bpy.data.worlds.new('fog'); scene.world.use_nodes = True
bg = scene.world.node_tree.nodes['Background']; bg.inputs['Strength'].default_value = 1.1
w = scene.world.node_tree; tex = w.nodes.new('ShaderNodeTexCoord'); sep = w.nodes.new('ShaderNodeSeparateXYZ'); w.links.new(tex.outputs['Generated'], sep.inputs[0])
- sky = ramp(w.nodes, [(.47, '#7A2030'), (.56, '#3A1020'), (.72, '#0E0614')]); w.links.new(sep.outputs['Z'], sky.inputs['Fac']); w.links.new(sky.outputs['Color'], bg.inputs['Color'])
+ sky = ramp(w.nodes, [(0, '#5E1622'), (.1, '#2A0A14'), (.32, '#120A1A')]); w.links.new(sep.outputs['Z'], sky.inputs['Fac']); w.links.new(sky.outputs['Color'], bg.inputs['Color'])
if SCENE == 'orbit-1':
world_tint = ('#061A3C', '#0E3A6E')
diff --git a/design/channels/blender/compose_ident.sh b/design/channels/blender/compose_ident.sh
index 9b48d5c..2f844ba 100755
--- a/design/channels/blender/compose_ident.sh
+++ b/design/channels/blender/compose_ident.sh
@@ -1,12 +1,17 @@
#!/bin/bash
# Frames from build_3d_ident.py into a finished ident: a soft start, then the channel's logo fading in over the end.
# compose_ident.sh [overlay folder of over_%04d.png, drawn on top]
+# LOGO_SHADOW=0.8 lays a soft dark shadow under the logo, for scenes busy behind it.
+LOGO="[1:v]scale=992:-1,format=rgba,fade=t=in:st=4.4:d=0.8:alpha=1[logo];[0:v]fade=t=in:st=0:d=0.3[scene];[scene][logo]overlay=x=(W-w)/2-110:y=(H-h)/2-30:shortest=1"
+if [ -n "$LOGO_SHADOW" ]; then
+ LOGO="[1:v]scale=992:-1,format=rgba,fade=t=in:st=4.4:d=0.8:alpha=1,split[logo][under];[under]colorchannelmixer=rr=0:gg=0:bb=0:aa=$LOGO_SHADOW,boxblur=26:2[shadow];[0:v]fade=t=in:st=0:d=0.3[scene];[scene][shadow]overlay=x=(W-w)/2-110:y=(H-h)/2-30:shortest=1[dim];[dim][logo]overlay=x=(W-w)/2-110:y=(H-h)/2-30:shortest=1"
+fi
if [ -n "$4" ]; then
ffmpeg -nostdin -y -loglevel error -framerate 50 -i "$1/frame_%04d.png" -loop 1 -i "$2" -framerate 50 -i "$4/over_%04d.png" -filter_complex \
- "[1:v]scale=992:-1,format=rgba,fade=t=in:st=4.4:d=0.8:alpha=1[logo];[0:v]fade=t=in:st=0:d=0.3[scene];[scene][logo]overlay=x=(W-w)/2-110:y=(H-h)/2-30:shortest=1[lit];[lit][2:v]overlay=0:0:shortest=1,format=yuv420p" \
+ "$LOGO[lit];[lit][2:v]overlay=0:0:shortest=1,format=yuv420p" \
-t 7 -c:v libx264 -preset slow -crf 18 -movflags +faststart -an "$3"
exit
fi
ffmpeg -nostdin -y -loglevel error -framerate 50 -i "$1/frame_%04d.png" -loop 1 -i "$2" -filter_complex \
- "[1:v]scale=992:-1,format=rgba,fade=t=in:st=4.4:d=0.8:alpha=1[logo];[0:v]fade=t=in:st=0:d=0.3[scene];[scene][logo]overlay=x=(W-w)/2-110:y=(H-h)/2-30:shortest=1,format=yuv420p" \
+ "$LOGO,format=yuv420p" \
-t 7 -c:v libx264 -preset slow -crf 18 -movflags +faststart -an "$3"
diff --git a/docs/refinement-notes.md b/docs/refinement-notes.md
index a2e04ac..369d04f 100644
--- a/docs/refinement-notes.md
+++ b/docs/refinement-notes.md
@@ -51,3 +51,7 @@ Paul, 2 November 2026: keep an eye on optimisation, size and cache management.
- **One screen at a time:** sign in, channels ticked from what the library holds, then the main menu.
- **Smart schedules on by default.** Today a fresh sign-in starts every channel off, and needs 29 presses.
- **Numbers in blocks like Sky** from the start, so there's never a move to make. The one move we made missed two channels until a test caught it.
+
+## OrbitHive blue screen (0.79.3)
+
+Inside the app's WebView on the box, `100vh` came out as 0, so the game's canvas had no height while the game ran and played sound behind it. Only the page's blue showed. Canvas sized with `position:fixed` and `100%` instead. Don't use viewport units in pages hosted in the app's WebView. Screenshots stay allowed while OrbitHive is open, because they are what gets a future problem like this checked on the box.