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.