clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Add the Android TV app: a WebView shell for the server's TV page

FelineStateMachine committed c792546
android/ is a small Kotlin app (dev.airwaves.tv) for Google TV and other
Android TV devices. It shows <server>/tv/ fullscreen, with a native
setup screen for the server address, Back handed to the page first (a
second press exits), remote and media keys passed through, the stream
paused in the background, renderer crashes recovered, and a banner and
icon in the Airwaves style. scripts/android.sh builds and installs it
over adb.
commit
c7925462e64b23d60a265e4dacf8744ff1e093da
parent
3febc87e2cc69b4c3bf7ca3bd1e3e33ca717aaa0
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
39 files changed, +1777 -0
Aandroid/.gitignore7+
Aandroid/README.md75+++++
Aandroid/app/build.gradle.kts33++
Aandroid/app/src/main/AndroidManifest.xml58++++
Aandroid/app/src/main/kotlin/dev/airwaves/tv/Bridge.kt122+++++++++
Aandroid/app/src/main/kotlin/dev/airwaves/tv/MainActivity.kt411++++++++++++++++++++++++++++++
Aandroid/app/src/main/kotlin/dev/airwaves/tv/Server.kt116++++++++
Aandroid/app/src/main/kotlin/dev/airwaves/tv/Setup.kt101+++++++
Aandroid/app/src/main/res/color/button_text.xml7+
Aandroid/app/src/main/res/drawable-nodpi/mark.pngbin
Aandroid/app/src/main/res/drawable-xhdpi/banner.pngbin
Aandroid/app/src/main/res/drawable/button.xml28++
Aandroid/app/src/main/res/drawable/cursor.xml5+
Aandroid/app/src/main/res/drawable/field.xml17+
Aandroid/app/src/main/res/drawable/setup_bg.xml11+
Aandroid/app/src/main/res/layout/main.xml6+
Aandroid/app/src/main/res/layout/setup.xml105++++++++
Aandroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml7+
Aandroid/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.pngbin
Aandroid/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.pngbin
Aandroid/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.pngbin
Aandroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.pngbin
Aandroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.pngbin
Aandroid/app/src/main/res/values/colors.xml15+
Aandroid/app/src/main/res/values/strings.xml11+
Aandroid/app/src/main/res/values/themes.xml33++
Aandroid/app/src/main/res/xml/network_security_config.xml11+
Aandroid/art/banner.html34++
Aandroid/art/icon.html48++++
Aandroid/art/mark.html27++
Aandroid/art/render.mjs67+++++
Aandroid/build.gradle.kts6+
Aandroid/gradle.properties4+
Aandroid/gradle/wrapper/gradle-wrapper.jarbin
Aandroid/gradle/wrapper/gradle-wrapper.properties10+
Aandroid/gradlew248++++++++++++++++++
Aandroid/gradlew.bat82++++++
Aandroid/settings.gradle.kts18+
Ascripts/android.sh54++++
A android/.gitignore +7 -0
A android/README.md +75 -0
@@ -0,0 +1,75 @@
1+# Airwaves for Android TV2+3+A thin shell around the server's TV page: a fullscreen WebView on4+`<server>/tv/` (airwavesd serves it), with a native setup screen for the server5+address. Android 9 or later, for Google TV and Android TV.6+7+## Build8+9+Needs a JDK 17 or later and the Android SDK (platform 36), found through10+`ANDROID_HOME` or `sdk.dir` in `local.properties`.11+12+```sh13+cd android14+./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk15+```16+17+## Install18+19+```sh20+adb connect 192.168.1.50:5555 # the TV, over wireless debugging (below)21+adb -s 192.168.1.50:5555 install -r app/build/outputs/apk/debug/app-debug.apk22+adb -s 192.168.1.50:5555 shell am start -n dev.airwaves.tv/.MainActivity --es server nas:808923+```24+25+Or all of it, from the repo: `scripts/android.sh 192.168.1.50:5555 [nas:8089]`.26+The `server` extra is optional: without one, the app asks at first run.27+28+### Wireless debugging on a Google TV29+30+1. Settings > System > About: select **Android TV OS build** seven times, until31+ it says you're a developer.32+2. Settings > System > Developer options: turn on **Wireless debugging**33+ (USB debugging too, if it asks). The TV and the computer must be on the34+ same network.35+3. In Wireless debugging, choose **Pair device with pairing code**. It shows an36+ address with a pairing port and a six-digit code: run37+ `adb pair 192.168.1.50:37123` and enter the code. Pairing is once per computer.38+4. Connect to the address and port listed under Wireless debugging itself39+ (not the pairing port): `adb connect 192.168.1.50:41235`. That port changes40+ when the TV restarts; `adb devices` lists what's connected.41+42+Older Android TV (before Android 11) has **Network debugging** instead:43+`adb connect 192.168.1.50:5555` and accept the prompt on the TV.44+45+## Using it46+47+- First run, or when the page can't load: setup. Enter the server (`nas:8089`;48+ no port means 8089) and Connect; it checks `/api/info` before saving.49+- Back goes to the page (`window.airwavesBack()`); when the page has nothing50+ to close, Back twice exits. Hold Back, or press Menu, for setup.51+- The remote's keys reach the page as keydown events: arrows, Enter (center),52+ digits, `MediaPlayPause`, `MediaPlay`, `MediaPause`, `MediaFastForward`,53+ `MediaRewind`, `MediaTrackNext`, `MediaTrackPrevious`, `MediaStop`,54+ `MediaRecord`, `ChannelUp`, `ChannelDown`, `MediaLast`, `Guide`, `Info`,55+ `ClosedCaptionToggle`, `ColorF0Red` and the other colors. The app keeps these56+ keys even when the page ignores them.57+- In the background the page is hidden (`visibilitychange`) and what's playing58+ pauses; it resumes on return. A page can take that over with59+ `window.airwavesPause()` and `window.airwavesResume()`. If the WebView's60+ renderer dies, the page reloads.61+62+The page talks to the app through `window.AirwavesAndroid`: `getServer()`,63+`setServer(url)` (saves it and loads its TV page; `""` opens setup),64+`openSetup()`, `getVersion()`, `copyText(text)`, `openUrl(url)` (false when no65+app opens it, as for web links on a Google TV, which has no browser),66+`log(level, msg)` (logcat tag `Airwaves`) and `exit()`. The user agent ends in67+` AirwavesTV/<version>`.68+69+Debug builds can be inspected from Chrome's `chrome://inspect` on the computer.70+71+## Art72+73+The banner, the launcher icon and the setup wordmark are rendered from the HTML74+in `art/` (the Steam art's look and the desktop icon's W):75+`PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs node android/art/render.mjs`.
A android/app/build.gradle.kts +33 -0
A android/app/src/main/AndroidManifest.xml +58 -0
@@ -0,0 +1,58 @@
1+<?xml version="1.0" encoding="utf-8"?>2+<manifest xmlns:android="http://schemas.android.com/apk/res/android"3+ xmlns:tools="http://schemas.android.com/tools">4+5+ <uses-permission android:name="android.permission.INTERNET" />6+7+ <!-- To tell a real browser from the framework stub that claims links on8+ TVs without one (Bridge.view). -->9+ <queries>10+ <intent>11+ <action android:name="android.intent.action.VIEW" />12+ <category android:name="android.intent.category.BROWSABLE" />13+ <data android:scheme="https" />14+ </intent>15+ <intent>16+ <action android:name="android.intent.action.VIEW" />17+ <category android:name="android.intent.category.BROWSABLE" />18+ <data android:scheme="http" />19+ </intent>20+ </queries>21+22+ <uses-feature23+ android:name="android.software.leanback"24+ android:required="false" />25+ <uses-feature26+ android:name="android.hardware.touchscreen"27+ android:required="false" />28+29+ <!-- enableOnBackInvokedCallback=false keeps Back a key event (Android 1630+ stops sending KEYCODE_BACK to apps that target it otherwise): the31+ page decides what Back does, and a long press opens setup. -->32+ <application33+ android:allowBackup="true"34+ android:banner="@drawable/banner"35+ android:enableOnBackInvokedCallback="false"36+ android:hardwareAccelerated="true"37+ android:icon="@mipmap/ic_launcher"38+ android:label="@string/app_name"39+ android:networkSecurityConfig="@xml/network_security_config"40+ android:supportsRtl="true"41+ android:theme="@style/Theme.Airwaves"42+ tools:targetApi="33">43+44+ <activity45+ android:name=".MainActivity"46+ android:configChanges="density|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"47+ android:exported="true"48+ android:launchMode="singleTask"49+ android:screenOrientation="landscape"50+ android:windowSoftInputMode="stateHidden|adjustPan">51+ <intent-filter>52+ <action android:name="android.intent.action.MAIN" />53+ <category android:name="android.intent.category.LAUNCHER" />54+ <category android:name="android.intent.category.LEANBACK_LAUNCHER" />55+ </intent-filter>56+ </activity>57+ </application>58+</manifest>
A android/app/src/main/kotlin/dev/airwaves/tv/Bridge.kt +122 -0
@@ -0,0 +1,122 @@
1+package dev.airwaves.tv2+3+import android.content.ActivityNotFoundException4+import android.content.ClipData5+import android.content.ClipboardManager6+import android.content.Context7+import android.content.Intent8+import android.content.pm.PackageManager9+import android.net.Uri10+import android.util.Log11+import android.webkit.JavascriptInterface12+import java.util.concurrent.FutureTask13+import java.util.concurrent.TimeUnit14+15+/**16+ * window.AirwavesAndroid, the page's way to the shell. Methods run on a17+ * WebView binder thread; anything touching views goes to the UI thread.18+ */19+class Bridge(private val activity: MainActivity) {20+21+ /** The saved server's base URL ("http://nas:8089"), or "". */22+ @JavascriptInterface23+ fun getServer(): String = Server.saved(activity) ?: ""24+25+ /** Saves a server and loads its TV page. "" opens setup. False if it isn't an address. */26+ @JavascriptInterface27+ fun setServer(url: String?): Boolean {28+ if (url.isNullOrBlank()) {29+ activity.runOnUiThread { activity.showSetup(null) }30+ return true31+ }32+ val server = Server.normalize(url) ?: return false33+ activity.runOnUiThread { activity.useServer(server) }34+ return true35+ }36+37+ /** Opens the native setup screen (server address). */38+ @JavascriptInterface39+ fun openSetup() {40+ activity.runOnUiThread { activity.showSetup(null) }41+ }42+43+ /** The app's versionName, as in the user agent. */44+ @JavascriptInterface45+ fun getVersion(): String = BuildConfig.VERSION_NAME46+47+ @JavascriptInterface48+ fun copyText(text: String?): Boolean {49+ val clip = activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return false50+ return try {51+ clip.setPrimaryClip(ClipData.newPlainText("Airwaves", text ?: ""))52+ true53+ } catch (e: Exception) {54+ Log.w(TAG, "copyText: $e")55+ false56+ }57+ }58+59+ /** Opens a link in whatever app handles it. False if none does (Google TV often has no browser). */60+ @JavascriptInterface61+ fun openUrl(url: String?): Boolean {62+ val uri = url?.let { Uri.parse(it.trim()) } ?: return false63+ val task = FutureTask { activity.openExternal(uri) }64+ activity.runOnUiThread(task)65+ return try {66+ task.get(3, TimeUnit.SECONDS)67+ } catch (e: Exception) {68+ false69+ }70+ }71+72+ /** Logs to logcat under the tag Airwaves. level: debug, info, warn or error. */73+ @JavascriptInterface74+ fun log(level: String?, msg: String?) {75+ val m = msg ?: ""76+ when (level?.lowercase()) {77+ "debug", "verbose", "trace" -> Log.d(TAG, m)78+ "warn", "warning" -> Log.w(TAG, m)79+ "error" -> Log.e(TAG, m)80+ else -> Log.i(TAG, m)81+ }82+ }83+84+ /** Closes the app. */85+ @JavascriptInterface86+ fun exit() {87+ activity.runOnUiThread { activity.finish() }88+ }89+90+ companion object {91+ const val TAG = "Airwaves"92+ const val NAME = "AirwavesAndroid"93+94+ /** Schemes openUrl and links never hand to other apps. */95+ private val BLOCKED = setOf("file", "content", "javascript", "data", "intent", "about", "chrome")96+97+ /** Answers links on TVs without a browser, with a "no app" message. */98+ private const val STUBS = "com.android.tv.frameworkpackagestubs"99+100+ /** ACTION_VIEW for a link from the page; false if it's refused or nothing handles it. */101+ fun view(ctx: Context, uri: Uri): Boolean {102+ val scheme = uri.scheme?.lowercase() ?: return false103+ if (scheme in BLOCKED) return false104+ val intent = Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE)105+ val handlers = ctx.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)106+ if (handlers.isNotEmpty() && handlers.all { it.activityInfo.packageName == STUBS }) {107+ Log.i(TAG, "no app opens $uri")108+ return false109+ }110+ return try {111+ ctx.startActivity(intent)112+ true113+ } catch (e: ActivityNotFoundException) {114+ Log.i(TAG, "no app opens $uri")115+ false116+ } catch (e: SecurityException) {117+ Log.w(TAG, "can't open $uri: $e")118+ false119+ }120+ }121+ }122+}
A android/app/src/main/kotlin/dev/airwaves/tv/MainActivity.kt +411 -0
@@ -0,0 +1,411 @@
1+package dev.airwaves.tv2+3+import android.annotation.SuppressLint4+import android.app.Activity5+import android.content.Intent6+import android.graphics.Bitmap7+import android.graphics.Color8+import android.net.Uri9+import android.os.Build10+import android.os.Bundle11+import android.os.Handler12+import android.os.Looper13+import android.os.SystemClock14+import android.util.Log15+import android.view.KeyEvent16+import android.view.View17+import android.view.ViewConfiguration18+import android.view.ViewGroup19+import android.view.WindowInsets20+import android.view.WindowInsetsController21+import android.view.WindowManager22+import android.webkit.ConsoleMessage23+import android.webkit.RenderProcessGoneDetail24+import android.webkit.WebChromeClient25+import android.webkit.WebResourceError26+import android.webkit.WebResourceRequest27+import android.webkit.WebResourceResponse28+import android.webkit.WebView29+import android.webkit.WebViewClient30+import android.widget.FrameLayout31+import android.widget.Toast32+33+/**34+ * Airwaves for Android TV: the server's TV page (<server>/tv/) in a fullscreen35+ * WebView, and a native setup screen for the server address.36+ */37+class MainActivity : Activity() {38+ private val ui = Handler(Looper.getMainLooper())39+ private lateinit var root: FrameLayout40+ private lateinit var setup: Setup41+ private var web: WebView? = null42+43+ /** The TV page is up: its main frame loaded without an error. */44+ private var pageOk = false45+ private var loadFailed = false46+ private var resumed = false47+ private var reloadOnResume = false48+ private val crashes = ArrayDeque<Long>()49+50+ private var backLong = false51+ private var swallowBackUp = false52+ private var backPending = 053+ private var lastExitPress = 0L54+ private var checking = 055+56+ override fun onCreate(savedInstanceState: Bundle?) {57+ super.onCreate(savedInstanceState)58+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)59+ setContentView(R.layout.main)60+ root = findViewById(android.R.id.content)61+ setup = Setup(findViewById(R.id.setup), ::connect, ::leaveSetup)62+ if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)63+64+ takeServerExtra(intent)65+ val server = Server.saved(this)66+ if (server == null) showSetup(null) else load(server)67+ }68+69+ override fun onNewIntent(intent: Intent) {70+ super.onNewIntent(intent)71+ setIntent(intent)72+ if (takeServerExtra(intent)) Server.saved(this)?.let { load(it) }73+ }74+75+ /** `am start ... --es server http://nas:8089` sets the server (scripts/android.sh). */76+ private fun takeServerExtra(intent: Intent?): Boolean {77+ val server = intent?.getStringExtra(EXTRA_SERVER)?.let { Server.normalize(it) } ?: return false78+ intent.removeExtra(EXTRA_SERVER)79+ Server.save(this, server)80+ Log.i(TAG, "server set from the launch intent: $server")81+ return true82+ }83+84+ override fun onResume() {85+ super.onResume()86+ resumed = true87+ immersive()88+ web?.let {89+ it.onResume()90+ if (!setup.visible) it.evaluateJavascript(RESUME_JS, null)91+ }92+ if (reloadOnResume) {93+ reloadOnResume = false94+ Server.saved(this)?.let { load(it) }95+ }96+ }97+98+ override fun onPause() {99+ resumed = false100+ web?.let {101+ it.evaluateJavascript(PAUSE_JS, null)102+ it.onPause()103+ }104+ super.onPause()105+ }106+107+ override fun onDestroy() {108+ ui.removeCallbacksAndMessages(null)109+ dropWebView()110+ super.onDestroy()111+ }112+113+ override fun onWindowFocusChanged(hasFocus: Boolean) {114+ super.onWindowFocusChanged(hasFocus)115+ if (hasFocus) immersive()116+ }117+118+ private fun immersive() {119+ if (Build.VERSION.SDK_INT >= 30) {120+ @Suppress("DEPRECATION") // edge to edge is the default from API 35121+ if (Build.VERSION.SDK_INT < 35) window.setDecorFitsSystemWindows(false)122+ window.insetsController?.let {123+ it.hide(WindowInsets.Type.systemBars())124+ it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE125+ }126+ } else {127+ @Suppress("DEPRECATION")128+ window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY129+ or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION130+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN131+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)132+ }133+ }134+135+ // ---------- the page ----------136+137+ /** Switches to a server (saved) and loads its TV page. */138+ fun useServer(server: String) {139+ Server.save(this, server)140+ load(server)141+ }142+143+ private fun load(server: String) {144+ setup.hide()145+ pageOk = false146+ val w = web ?: newWebView()147+ w.visibility = View.VISIBLE148+ w.loadUrl(Server.tvUrl(server))149+ w.requestFocus()150+ }151+152+ @SuppressLint("SetJavaScriptEnabled")153+ private fun newWebView(): WebView {154+ val w = WebView(this)155+ w.setBackgroundColor(Color.BLACK)156+ w.isFocusable = true157+ w.isFocusableInTouchMode = true158+ w.defaultFocusHighlightEnabled = false159+ w.isVerticalScrollBarEnabled = false160+ w.isHorizontalScrollBarEnabled = false161+ w.overScrollMode = View.OVER_SCROLL_NEVER162+ w.settings.apply {163+ javaScriptEnabled = true164+ domStorageEnabled = true165+ mediaPlaybackRequiresUserGesture = false166+ setSupportZoom(false)167+ builtInZoomControls = false168+ displayZoomControls = false169+ textZoom = 100 // the TV layout ignores the system font scale170+ allowFileAccess = false171+ allowContentAccess = false172+ setSupportMultipleWindows(false)173+ userAgentString = "$userAgentString AirwavesTV/${BuildConfig.VERSION_NAME}"174+ }175+ w.webViewClient = Client()176+ w.webChromeClient = Chrome()177+ w.addJavascriptInterface(Bridge(this), Bridge.NAME)178+ root.addView(w, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))179+ if (!resumed) w.onPause()180+ web = w181+ return w182+ }183+184+ private fun dropWebView() {185+ val w = web ?: return186+ web = null187+ root.removeView(w)188+ w.destroy()189+ }190+191+ /** Opens a link outside, in whatever app handles it. */192+ fun openExternal(uri: Uri): Boolean = Bridge.view(this, uri)193+194+ private fun sameServer(uri: Uri): Boolean {195+ val server = Server.saved(this)?.let { Uri.parse(it) } ?: return false196+ return uri.scheme == server.scheme && uri.host == server.host && uri.port == server.port197+ }198+199+ private inner class Client : WebViewClient() {200+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {201+ if (!request.isForMainFrame || request.isRedirect) return false202+ val uri = request.url203+ // The server's own pages stay here; links elsewhere go to other apps.204+ if ((uri.scheme == "http" || uri.scheme == "https") && sameServer(uri)) return false205+ if (!openExternal(uri)) Toast.makeText(this@MainActivity, "Nothing here opens $uri", Toast.LENGTH_LONG).show()206+ return true207+ }208+209+ override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {210+ loadFailed = false211+ }212+213+ override fun onPageFinished(view: WebView, url: String?) {214+ if (view == web && !loadFailed) pageOk = true215+ }216+217+ override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {218+ if (!request.isForMainFrame || view != web) return219+ failed("Couldn't load ${request.url}: ${error.description}")220+ }221+222+ override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {223+ if (!request.isForMainFrame || view != web) return224+ val hint = if (response.statusCode == 404) " Does this server have the TV app?" else ""225+ failed("${request.url} answered HTTP ${response.statusCode}.$hint")226+ }227+228+ override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {229+ Log.w(TAG, "the page's renderer " + if (detail.didCrash()) "crashed" else "was killed")230+ if (view != web) return true231+ dropWebView()232+ val now = SystemClock.elapsedRealtime()233+ crashes.addLast(now)234+ while (crashes.first() < now - 60_000) crashes.removeFirst()235+ when {236+ crashes.size >= 3 -> showSetup("The TV page crashed ${crashes.size} times in a minute.")237+ resumed -> Server.saved(this@MainActivity)?.let { load(it) }238+ else -> reloadOnResume = true239+ }240+ return true241+ }242+ }243+244+ private inner class Chrome : WebChromeClient() {245+ /** No grey play-button poster on videos without one. */246+ override fun getDefaultVideoPoster(): Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)247+248+ override fun onConsoleMessage(m: ConsoleMessage): Boolean {249+ if (!BuildConfig.DEBUG && m.messageLevel() != ConsoleMessage.MessageLevel.ERROR) return true250+ val line = "console: ${m.message()} (${m.sourceId()}:${m.lineNumber()})"251+ when (m.messageLevel()) {252+ ConsoleMessage.MessageLevel.ERROR -> Log.e(TAG, line)253+ ConsoleMessage.MessageLevel.WARNING -> Log.w(TAG, line)254+ else -> Log.d(TAG, line)255+ }256+ return true257+ }258+ }259+260+ private fun failed(reason: String) {261+ loadFailed = true262+ pageOk = false263+ Log.w(TAG, reason)264+ setup.say(reason, error = true)265+ showSetup(null)266+ }267+268+ // ---------- setup ----------269+270+ /** Shows setup over the page (pausing it), with an error line if there's one. */271+ fun showSetup(error: String?) {272+ if (error != null) setup.say(error, error = true)273+ web?.let {274+ if (!setup.visible) it.evaluateJavascript(PAUSE_JS, null)275+ it.visibility = View.INVISIBLE // nor focusable under setup276+ }277+ setup.show(Server.saved(this), canGoBack = pageOk && web != null)278+ }279+280+ /** Back to the page from setup, if it's up. */281+ private fun leaveSetup() {282+ val w = web283+ if (!pageOk || w == null) return284+ checking++285+ setup.hide()286+ w.visibility = View.VISIBLE287+ w.requestFocus()288+ w.evaluateJavascript(RESUME_JS, null)289+ }290+291+ private fun connect(server: String) {292+ val id = ++checking293+ setup.busy(true)294+ setup.say("Checking ${Server.label(server)} ...")295+ Thread {296+ val result = Server.check(server)297+ ui.post {298+ if (id != checking || isDestroyed) return@post299+ when (result) {300+ is Server.Check.Ok -> {301+ setup.say("Found Airwaves ${result.version}" + if (result.name.isEmpty()) "" else " (${result.name})")302+ setup.busy(false)303+ useServer(server)304+ }305+ is Server.Check.Failed -> {306+ setup.say(result.reason, error = true)307+ setup.busy(false)308+ }309+ }310+ }311+ }.start()312+ }313+314+ // ---------- keys ----------315+316+ override fun dispatchKeyEvent(e: KeyEvent): Boolean {317+ if (setup.visible || web == null) {318+ if (e.keyCode == KeyEvent.KEYCODE_BACK) {319+ if (e.action == KeyEvent.ACTION_UP && !e.isCanceled) {320+ if (swallowBackUp) swallowBackUp = false321+ else if (setup.visible && pageOk && web != null) leaveSetup()322+ else exitPress()323+ }324+ return true325+ }326+ return super.dispatchKeyEvent(e)327+ }328+ when (e.keyCode) {329+ KeyEvent.KEYCODE_BACK -> return backKey(e)330+ KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS -> {331+ if (e.action == KeyEvent.ACTION_UP) showSetup(null)332+ return true333+ }334+ }335+ // The rest go to the page. WebView names the remote's keys as the page336+ // expects (ChannelUp, MediaPlayPause, Guide, Info, ClosedCaptionToggle,337+ // digits). TV keys are ours even when the page ignores them, so none338+ // falls through to another app's media session or the system's guide.339+ return super.dispatchKeyEvent(e) || e.keyCode in TV_KEYS340+ }341+342+ /** Back asks the page (window.airwavesBack); a long press opens setup. */343+ private fun backKey(e: KeyEvent): Boolean {344+ when (e.action) {345+ KeyEvent.ACTION_DOWN -> if (e.repeatCount == 0) {346+ backLong = false347+ } else if (!backLong && (e.isLongPress || e.eventTime - e.downTime >= ViewConfiguration.getLongPressTimeout())) {348+ backLong = true349+ swallowBackUp = true350+ showSetup(null)351+ }352+ KeyEvent.ACTION_UP -> if (!backLong && !e.isCanceled) webBack()353+ }354+ return true355+ }356+357+ private fun webBack() {358+ val w = web ?: return exitPress()359+ val id = ++backPending360+ // A page that doesn't answer (loading, hung) counts as "not handled".361+ val timeout = Runnable { if (backPending == id) { backPending++; exitPress() } }362+ ui.postDelayed(timeout, 1500)363+ w.evaluateJavascript(BACK_JS) { result ->364+ if (backPending != id) return@evaluateJavascript365+ backPending++366+ ui.removeCallbacks(timeout)367+ if (result == "true") lastExitPress = 0 else exitPress()368+ }369+ }370+371+ /** Back with nothing left to close: twice within 2 s exits. */372+ private fun exitPress() {373+ val now = SystemClock.uptimeMillis()374+ if (now - lastExitPress < 2000) {375+ finish()376+ return377+ }378+ lastExitPress = now379+ Toast.makeText(this, R.string.press_back_again, Toast.LENGTH_SHORT).show()380+ }381+382+ companion object {383+ private const val TAG = Bridge.TAG384+ const val EXTRA_SERVER = "server"385+386+ /** Remote keys the app keeps, handled by the page or not. */387+ private val TV_KEYS = setOf(388+ KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,389+ KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, KeyEvent.KEYCODE_MEDIA_REWIND,390+ KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_RECORD,391+ KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK, KeyEvent.KEYCODE_CHANNEL_UP, KeyEvent.KEYCODE_CHANNEL_DOWN,392+ KeyEvent.KEYCODE_LAST_CHANNEL, KeyEvent.KEYCODE_GUIDE, KeyEvent.KEYCODE_INFO, KeyEvent.KEYCODE_CAPTIONS,393+ KeyEvent.KEYCODE_DVR, KeyEvent.KEYCODE_PROG_RED, KeyEvent.KEYCODE_PROG_GREEN,394+ KeyEvent.KEYCODE_PROG_YELLOW, KeyEvent.KEYCODE_PROG_BLUE,395+ )396+397+ private const val BACK_JS =398+ "(function(){try{return window.airwavesBack?window.airwavesBack()===true:false}catch(e){return false}})()"399+400+ // Leaving the app: the page hears visibilitychange (WebView.onPause);401+ // this also pauses what's playing, unless the page has its own hook.402+ private const val PAUSE_JS =403+ "(function(){if(window.airwavesPause){try{window.airwavesPause()}catch(e){}return}" +404+ "document.querySelectorAll('video,audio').forEach(function(m){" +405+ "if(!m.paused){m.dataset.awResume='1';m.pause()}})})()"406+ private const val RESUME_JS =407+ "(function(){if(window.airwavesResume){try{window.airwavesResume()}catch(e){}return}" +408+ "document.querySelectorAll('[data-aw-resume]').forEach(function(m){" +409+ "delete m.dataset.awResume;if(m.paused&&m.readyState>0){var p=m.play();if(p&&p.catch)p.catch(function(){})}})})()"410+ }411+}
A android/app/src/main/kotlin/dev/airwaves/tv/Server.kt +116 -0
@@ -0,0 +1,116 @@
1+package dev.airwaves.tv2+3+import android.content.Context4+import org.json.JSONObject5+import java.io.IOException6+import java.net.ConnectException7+import java.net.HttpURLConnection8+import java.net.NoRouteToHostException9+import java.net.SocketTimeoutException10+import java.net.URI11+import java.net.URL12+import java.net.UnknownHostException13+import javax.net.ssl.SSLException14+15+/** The Airwaves server: its address, where it's saved, and the check before saving one. */16+object Server {17+ /** Where airwavesd listens (internal/api.DefaultPort). */18+ const val DEFAULT_PORT = 808919+20+ private const val PREFS = "airwaves"21+ private const val KEY = "server"22+23+ fun saved(ctx: Context): String? =24+ ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null)?.takeIf { it.isNotEmpty() }25+26+ fun save(ctx: Context, server: String) {27+ ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, server).apply()28+ }29+30+ /** The TV page of a server. */31+ fun tvUrl(server: String) = "$server/tv/"32+33+ /**34+ * A base URL like "http://nas:8089" from what was typed, as the desktop app35+ * reads it: no scheme means http and, without a port, 8089. A pasted TV page36+ * address ("http://nas:8089/tv/") gives its server. Null if it isn't one.37+ */38+ fun normalize(input: String): String? {39+ var s = input.trim().trimEnd('/')40+ if (s.isEmpty()) return null41+ val bare = !s.contains("://")42+ if (bare) s = "http://$s"43+ val u = try {44+ URI(s)45+ } catch (e: Exception) {46+ return null47+ }48+ val scheme = u.scheme?.lowercase()49+ if (scheme != "http" && scheme != "https") return null50+ val host = u.host ?: return null51+ val port = if (u.port == -1 && bare) DEFAULT_PORT else u.port52+ val path = (u.rawPath ?: "").trimEnd('/').removeSuffix("/tv").trimEnd('/')53+ val h = if (host.contains(':') && !host.startsWith("[")) "[$host]" else host54+ return "$scheme://$h" + (if (port == -1) "" else ":$port") + path55+ }56+57+ /** host:port of a server, for messages. */58+ fun label(server: String): String = server.substringAfter("://")59+60+ sealed class Check {61+ data class Ok(val name: String, val version: String) : Check()62+ data class Failed(val reason: String) : Check()63+ }64+65+ /** Asks a server for /api/info. Blocks: call it off the UI thread. */66+ fun check(server: String): Check {67+ val where = label(server)68+ val host = try {69+ URI(server).host ?: where70+ } catch (e: Exception) {71+ where72+ }73+ val conn = try {74+ URL("$server/api/info").openConnection() as HttpURLConnection75+ } catch (e: Exception) {76+ return Check.Failed("Not an address: $server")77+ }78+ return try {79+ conn.connectTimeout = 600080+ conn.readTimeout = 1000081+ conn.setRequestProperty("Accept", "application/json")82+ val code = conn.responseCode83+ when {84+ code == 401 || code == 403 -> Check.Failed("$where wants a password (HTTP $code).")85+ code != 200 -> Check.Failed("$where answered HTTP $code: not an Airwaves server?")86+ else -> {87+ val body = conn.inputStream.bufferedReader().use { it.readText() }88+ val info = try {89+ JSONObject(body)90+ } catch (e: Exception) {91+ null92+ }93+ if (info == null || !info.has("version")) {94+ Check.Failed("$where answered, but it isn't an Airwaves server.")95+ } else {96+ Check.Ok(info.optString("name"), info.optString("version"))97+ }98+ }99+ }100+ } catch (e: UnknownHostException) {101+ Check.Failed("Can't find $host. Check the name, or use its IP address.")102+ } catch (e: ConnectException) {103+ Check.Failed("$where refused the connection. Is airwavesd running?")104+ } catch (e: NoRouteToHostException) {105+ Check.Failed("No route to $host.")106+ } catch (e: SocketTimeoutException) {107+ Check.Failed("No answer from $where.")108+ } catch (e: SSLException) {109+ Check.Failed("TLS error from $where: ${e.message}")110+ } catch (e: IOException) {111+ Check.Failed("Can't reach $where: ${e.message ?: e.javaClass.simpleName}")112+ } finally {113+ conn.disconnect()114+ }115+ }116+}
A android/app/src/main/kotlin/dev/airwaves/tv/Setup.kt +101 -0
@@ -0,0 +1,101 @@
1+package dev.airwaves.tv2+3+import android.text.SpannableStringBuilder4+import android.text.Spanned5+import android.text.style.ForegroundColorSpan6+import android.view.KeyEvent7+import android.view.View8+import android.view.inputmethod.EditorInfo9+import android.widget.Button10+import android.widget.EditText11+import android.widget.TextView12+13+/**14+ * The setup screen: a server address, Connect, and a log of what happened,15+ * like the desktop app's boot screen. D-pad friendly: the address field, then16+ * the buttons to its right.17+ */18+class Setup(19+ private val view: View,20+ private val onConnect: (String) -> Unit,21+ private val onBack: () -> Unit,22+) {23+ private val ctx = view.context24+ private val server: EditText = view.findViewById(R.id.setup_server)25+ private val connect: Button = view.findViewById(R.id.setup_connect)26+ private val back: Button = view.findViewById(R.id.setup_back)27+ private val log: TextView = view.findViewById(R.id.setup_log)28+ private val lines = ArrayList<Pair<String, Boolean>>()29+30+ val visible get() = view.visibility == View.VISIBLE31+32+ init {33+ view.findViewById<TextView>(R.id.setup_version).text =34+ ctx.getString(R.string.setup_version, BuildConfig.VERSION_NAME)35+ connect.setOnClickListener { submit() }36+ back.setOnClickListener { onBack() }37+ server.setOnEditorActionListener { _, action, event ->38+ val enter = event != null && event.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN39+ if (action == EditorInfo.IME_ACTION_GO || action == EditorInfo.IME_ACTION_DONE || enter) {40+ submit()41+ true42+ } else {43+ false44+ }45+ }46+ }47+48+ /** Shows setup with the address filled in; canGoBack offers a way back to a working page. */49+ fun show(address: String?, canGoBack: Boolean) {50+ if (!visible || server.text.isNullOrEmpty()) server.setText(address ?: "")51+ back.visibility = if (canGoBack) View.VISIBLE else View.GONE52+ busy(false)53+ if (lines.isEmpty()) {54+ say(if (address == null) "No server yet: enter its address." else "Server: ${Server.label(address)}")55+ }56+ view.visibility = View.VISIBLE57+ (if (server.text.isNullOrEmpty()) server else connect).requestFocus()58+ }59+60+ fun hide() {61+ view.visibility = View.GONE62+ }63+64+ fun busy(on: Boolean) {65+ connect.isEnabled = !on66+ server.isEnabled = !on67+ if (!on && visible && !connect.hasFocus() && !back.hasFocus() && !server.hasFocus()) connect.requestFocus()68+ }69+70+ /** Adds a line to the log; the last few stay. */71+ fun say(text: String, error: Boolean = false) {72+ lines.add(text to error)73+ while (lines.size > 4) lines.removeAt(0)74+ val amber = ctx.getColor(R.color.amber)75+ val red = ctx.getColor(R.color.red)76+ val out = SpannableStringBuilder()77+ for ((i, line) in lines.withIndex()) {78+ if (i > 0) out.append('\n')79+ val start = out.length80+ out.append("> ")81+ out.setSpan(ForegroundColorSpan(amber), start, out.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)82+ val s = out.length83+ out.append(line.first)84+ if (line.second) out.setSpan(ForegroundColorSpan(red), s, out.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)85+ }86+ log.text = out87+ }88+89+ private fun submit() {90+ if (!connect.isEnabled) return91+ val typed = server.text?.toString() ?: ""92+ val normalized = Server.normalize(typed)93+ if (normalized == null) {94+ say(if (typed.isBlank()) "Enter the server's address first." else "Not an address: $typed", error = true)95+ server.requestFocus()96+ return97+ }98+ server.setText(normalized)99+ onConnect(normalized)100+ }101+}
A android/app/src/main/res/color/button_text.xml +7 -0
A android/app/src/main/res/drawable-nodpi/mark.png +0 -0

Binary file.

A android/app/src/main/res/drawable-xhdpi/banner.png +0 -0

Binary file.

A android/app/src/main/res/drawable/button.xml +28 -0
A android/app/src/main/res/drawable/cursor.xml +5 -0
A android/app/src/main/res/drawable/field.xml +17 -0
A android/app/src/main/res/drawable/setup_bg.xml +11 -0
A android/app/src/main/res/layout/main.xml +6 -0
A android/app/src/main/res/layout/setup.xml +105 -0
@@ -0,0 +1,105 @@
1+<?xml version="1.0" encoding="utf-8"?>2+<!-- Setup: the server address, laid out like the desktop app's boot screen. -->3+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"4+ android:id="@+id/setup"5+ android:layout_width="match_parent"6+ android:layout_height="match_parent"7+ android:background="@drawable/setup_bg"8+ android:clickable="true"9+ android:visibility="gone">10+11+ <LinearLayout12+ android:layout_width="600dp"13+ android:layout_height="wrap_content"14+ android:layout_gravity="center_horizontal"15+ android:layout_marginTop="72dp"16+ android:orientation="vertical">17+18+ <ImageView19+ android:layout_width="wrap_content"20+ android:layout_height="84dp"21+ android:adjustViewBounds="true"22+ android:contentDescription="@string/app_name"23+ android:importantForAccessibility="no"24+ android:src="@drawable/mark" />25+26+ <TextView27+ style="@style/Setup.Mono"28+ android:layout_width="wrap_content"29+ android:layout_height="wrap_content"30+ android:layout_marginTop="16dp"31+ android:letterSpacing="0.2"32+ android:text="@string/setup_sub"33+ android:textAllCaps="true"34+ android:textColor="@color/amber"35+ android:textSize="12sp" />36+37+ <TextView38+ android:id="@+id/setup_log"39+ style="@style/Setup.Mono"40+ android:layout_width="match_parent"41+ android:layout_height="wrap_content"42+ android:layout_marginTop="22dp"43+ android:lineSpacingMultiplier="1.5"44+ android:minLines="4"45+ android:textColor="@color/muted"46+ android:textSize="13sp" />47+48+ <LinearLayout49+ android:layout_width="match_parent"50+ android:layout_height="wrap_content"51+ android:layout_marginTop="18dp"52+ android:orientation="horizontal">53+54+ <EditText55+ android:id="@+id/setup_server"56+ style="@style/Setup.Mono"57+ android:layout_width="0dp"58+ android:layout_height="44dp"59+ android:layout_weight="1"60+ android:autofillHints="url"61+ android:background="@drawable/field"62+ android:defaultFocusHighlightEnabled="false"63+ android:hint="@string/setup_hint"64+ android:imeOptions="actionGo|flagNoExtractUi|flagNoFullscreen"65+ android:inputType="textUri"66+ android:paddingStart="14dp"67+ android:paddingEnd="14dp"68+ android:singleLine="true"69+ android:textColor="@color/paper"70+ android:textColorHint="@color/faint"71+ android:textCursorDrawable="@drawable/cursor"72+ android:textSize="15sp" />73+74+ <Button75+ android:id="@+id/setup_connect"76+ style="@style/Setup.Button"77+ android:text="@string/setup_connect" />78+79+ <Button80+ android:id="@+id/setup_back"81+ style="@style/Setup.Button"82+ android:text="@string/setup_back"83+ android:visibility="gone" />84+ </LinearLayout>85+86+ <TextView87+ android:layout_width="match_parent"88+ android:layout_height="wrap_content"89+ android:layout_marginTop="18dp"90+ android:lineSpacingMultiplier="1.3"91+ android:text="@string/setup_help"92+ android:textColor="@color/faint"93+ android:textSize="13sp" />94+ </LinearLayout>95+96+ <TextView97+ android:id="@+id/setup_version"98+ style="@style/Setup.Mono"99+ android:layout_width="wrap_content"100+ android:layout_height="wrap_content"101+ android:layout_gravity="bottom|start"102+ android:layout_margin="32dp"103+ android:textColor="@color/faint"104+ android:textSize="11sp" />105+</FrameLayout>
A android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +7 -0
A android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0

Binary file.

A android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0

Binary file.

A android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0

Binary file.

A android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0

Binary file.

A android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0

Binary file.

A android/app/src/main/res/values/colors.xml +15 -0
A android/app/src/main/res/values/strings.xml +11 -0
A android/app/src/main/res/values/themes.xml +33 -0
@@ -0,0 +1,33 @@
1+<?xml version="1.0" encoding="utf-8"?>2+<resources>3+ <style name="Theme.Airwaves" parent="android:Theme.Material.NoActionBar.Fullscreen">4+ <item name="android:windowBackground">@android:color/black</item>5+ <item name="android:colorBackground">@color/ink</item>6+ <item name="android:colorPrimary">@color/ink</item>7+ <item name="android:colorAccent">@color/amber</item>8+ <item name="android:textColorHighlight">@color/amber_select</item>9+ <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>10+ </style>11+12+ <style name="Setup.Mono" parent="">13+ <item name="android:fontFamily">monospace</item>14+ <item name="android:includeFontPadding">false</item>15+ </style>16+17+ <style name="Setup.Button" parent="Setup.Mono">18+ <item name="android:layout_width">wrap_content</item>19+ <item name="android:layout_height">44dp</item>20+ <item name="android:layout_marginStart">10dp</item>21+ <item name="android:minWidth">120dp</item>22+ <item name="android:paddingStart">22dp</item>23+ <item name="android:paddingEnd">22dp</item>24+ <item name="android:gravity">center</item>25+ <item name="android:background">@drawable/button</item>26+ <item name="android:textColor">@color/button_text</item>27+ <item name="android:textSize">15sp</item>28+ <item name="android:textAllCaps">false</item>29+ <item name="android:stateListAnimator">@null</item>30+ <item name="android:focusable">true</item>31+ <item name="android:defaultFocusHighlightEnabled">false</item>32+ </style>33+</resources>
A android/app/src/main/res/xml/network_security_config.xml +11 -0
A android/art/banner.html +34 -0
A android/art/icon.html +48 -0
@@ -0,0 +1,48 @@
1+<!doctype html>2+<!-- Launcher icon foreground (?px=size): the W of the desktop icon (build/appicon.png),3+ lifted off its white tile onto a transparent 108dp adaptive-icon layer; the tile4+ is the layer below (ic_launcher.xml). The W stays inside the 66dp safe zone.5+ It's the monochrome (themed) layer too. -->6+<meta charset="utf-8">7+<style>8+ html, body { margin: 0; background: transparent; }9+ canvas { display: block; }10+</style>11+<canvas id="art"></canvas>12+<script type="module">13+ const px = Number(new URLSearchParams(location.search).get("px") || 432);14+ const img = new Image();15+ img.src = "../../build/appicon.png";16+ await img.decode();17+18+ // The glyph: dark ink on the white tile. Its alpha is how dark each pixel is.19+ const src = document.createElement("canvas");20+ src.width = img.width; src.height = img.height;21+ const sctx = src.getContext("2d");22+ sctx.drawImage(img, 0, 0);23+ const d = sctx.getImageData(0, 0, src.width, src.height);24+ const INK = 0x21;25+ let x0 = src.width, y0 = src.height, x1 = 0, y1 = 0;26+ for (let y = 0; y < src.height; y++) {27+ for (let x = 0; x < src.width; x++) {28+ const i = (y * src.width + x) * 4;29+ const lum = Math.min(d.data[i], d.data[i + 1], d.data[i + 2]);30+ // Only the tile's opaque inside: its edge and drop shadow are grey too.31+ const a = d.data[i + 3] === 255 ? Math.max(0, Math.min(1, (255 - lum) / (255 - INK))) : 0;32+ d.data[i] = d.data[i + 1] = d.data[i + 2] = INK;33+ d.data[i + 3] = Math.round(a * 255);34+ if (a > 0.5) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }35+ }36+ }37+ sctx.putImageData(d, 0, 0);38+39+ // Shadows and the tile's rounded edge sit far from the glyph: keep the glyph's box only.40+ const pad = 4;41+ const gw = x1 - x0 + 1 + pad * 2, gh = y1 - y0 + 1 + pad * 2;42+ const h = px * 0.40, w = h * gw / gh;43+ const c = document.getElementById("art"), ctx = c.getContext("2d");44+ c.width = c.height = px;45+ ctx.imageSmoothingQuality = "high";46+ ctx.drawImage(src, x0 - pad, y0 - pad, gw, gh, (px - w) / 2, (px - h) / 2, w, h);47+ document.body.dataset.ready = "1";48+</script>
A android/art/mark.html +27 -0
A android/art/render.mjs +67 -0
@@ -0,0 +1,67 @@
1+// Renders the Android TV app's banner, launcher icon and setup wordmark from2+// the HTML sources next to this file, into android/app/src/main/res. They share3+// the Steam art's look (build/linux/steam-art: art.css, art.js, the app's fonts).4+//5+// node android/art/render.mjs [banner|mark|icon ...] (needs the playwright package resolvable)6+// PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs node android/art/render.mjs7+//8+// Serves the repo over a local HTTP server so the pages can load the app's fonts and art.js.9+10+import { createServer } from "node:http";11+import { mkdir, readFile } from "node:fs/promises";12+import { dirname, extname, join, normalize } from "node:path";13+import { fileURLToPath, pathToFileURL } from "node:url";14+15+const here = dirname(fileURLToPath(import.meta.url));16+const root = join(here, "..", "..");17+const res = join(here, "..", "app", "src", "main", "res");18+const spec = process.env.PLAYWRIGHT;19+const { chromium } = await import(spec ? pathToFileURL(spec).href : "playwright");20+21+// The launcher icon's foreground layer is 108dp square, per density.22+const ICON = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };23+24+// name: [page, width, height, transparent, output]; width/height null = size of #art25+const ART = {26+ banner: ["banner.html", 640, 360, false, "drawable-xhdpi/banner.png"],27+ mark: ["mark.html", null, null, true, "drawable-nodpi/mark.png"],28+ ...Object.fromEntries(Object.entries(ICON).map(([d, px]) =>29+ [`icon-${d}`, [`icon.html?px=${px}`, px, px, true, `mipmap-${d}/ic_launcher_foreground.png`]])),30+};31+32+const TYPES = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript", ".woff2": "font/woff2", ".png": "image/png" };33+const server = createServer(async (req, res) => {34+ const path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname));35+ try {36+ const body = await readFile(join(root, path));37+ res.writeHead(200, { "content-type": TYPES[extname(path)] ?? "application/octet-stream" });38+ res.end(body);39+ } catch {40+ res.writeHead(404).end();41+ }42+});43+await new Promise((ok) => server.listen(0, "127.0.0.1", ok));44+const base = `http://127.0.0.1:${server.address().port}/android/art/`;45+46+const only = process.argv.slice(2);47+const browser = await chromium.launch();48+try {49+ for (const [name, [page, w, h, transparent, out]] of Object.entries(ART)) {50+ if (only.length && !only.some((o) => name === o || name.startsWith(`${o}-`))) continue;51+ const ctx = await browser.newContext({ viewport: { width: w ?? 2400, height: h ?? 800 } });52+ const tab = await ctx.newPage();53+ tab.on("pageerror", (e) => console.error(`${name}: ${e.message}`));54+ tab.on("requestfailed", (r) => console.error(`${name}: failed ${r.url()}`));55+ await tab.goto(base + page);56+ await tab.waitForSelector("body[data-ready]", { timeout: 15000 });57+ const file = join(res, out);58+ await mkdir(dirname(file), { recursive: true });59+ await tab.locator("#art").screenshot({ path: file, omitBackground: transparent });60+ const box = await tab.locator("#art").boundingBox();61+ console.log(`${out} ${Math.round(box.width)}x${Math.round(box.height)}`);62+ await ctx.close();63+ }64+} finally {65+ await browser.close();66+ server.close();67+}
A android/build.gradle.kts +6 -0
A android/gradle.properties +4 -0
A android/gradle/wrapper/gradle-wrapper.jar +0 -0

Binary file.

A android/gradle/wrapper/gradle-wrapper.properties +10 -0
A android/gradlew +248 -0
@@ -0,0 +1,248 @@
1+#!/bin/sh2+3+#4+# Copyright © 2015 the original authors.5+#6+# Licensed under the Apache License, Version 2.0 (the "License");7+# you may not use this file except in compliance with the License.8+# You may obtain a copy of the License at9+#10+# https://www.apache.org/licenses/LICENSE-2.011+#12+# Unless required by applicable law or agreed to in writing, software13+# distributed under the License is distributed on an "AS IS" BASIS,14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.15+# See the License for the specific language governing permissions and16+# limitations under the License.17+#18+# SPDX-License-Identifier: Apache-2.019+#20+21+##############################################################################22+#23+# gradlew start up script for POSIX generated by Gradle.24+#25+# Important for running:26+#27+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is28+# noncompliant, but you have some other compliant shell such as ksh or29+# bash, then to run this script, type that shell name before the whole30+# command line, like:31+#32+# ksh gradlew33+#34+# Busybox and similar reduced shells will NOT work, because this script35+# requires all of these POSIX shell features:36+# * functions;37+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,38+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;39+# * compound commands having a testable exit status, especially «case»;40+# * various built-in commands including «command», «set», and «ulimit».41+#42+# Important for patching:43+#44+# (2) This script targets any POSIX shell, so it avoids extensions provided45+# by Bash, Ksh, etc; in particular arrays are avoided.46+#47+# The "traditional" practice of packing multiple parameters into a48+# space-separated string is a well documented source of bugs and security49+# problems, so this is (mostly) avoided, by progressively accumulating50+# options in "$@", and eventually passing that to Java.51+#52+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,53+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;54+# see the in-line comments for details.55+#56+# There are tweaks for specific operating systems such as AIX, CygWin,57+# Darwin, MinGW, and NonStop.58+#59+# (3) This script is generated from the Groovy template60+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt61+# within the Gradle project.62+#63+# You can find Gradle at https://github.com/gradle/gradle/.64+#65+##############################################################################66+67+# Attempt to set APP_HOME68+69+# Resolve links: $0 may be a link70+app_path=$071+72+# Need this for daisy-chained symlinks.73+while74+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path75+ [ -h "$app_path" ]76+do77+ ls=$( ls -ld "$app_path" )78+ link=${ls#*' -> '}79+ case $link in #(80+ /*) app_path=$link ;; #(81+ *) app_path=$APP_HOME$link ;;82+ esac83+done84+85+# This is normally unused86+# shellcheck disable=SC203487+APP_BASE_NAME=${0##*/}88+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)89+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit90+91+# Use the maximum available, or set MAX_FD != -1 to use that value.92+MAX_FD=maximum93+94+warn () {95+ echo "$*"96+} >&297+98+die () {99+ echo100+ echo "$*"101+ echo102+ exit 1103+} >&2104+105+# OS specific support (must be 'true' or 'false').106+cygwin=false107+msys=false108+darwin=false109+nonstop=false110+case "$( uname )" in #(111+ CYGWIN* ) cygwin=true ;; #(112+ Darwin* ) darwin=true ;; #(113+ MSYS* | MINGW* ) msys=true ;; #(114+ NONSTOP* ) nonstop=true ;;115+esac116+117+118+119+# Determine the Java command to use to start the JVM.120+if [ -n "$JAVA_HOME" ] ; then121+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then122+ # IBM's JDK on AIX uses strange locations for the executables123+ JAVACMD=$JAVA_HOME/jre/sh/java124+ else125+ JAVACMD=$JAVA_HOME/bin/java126+ fi127+ if [ ! -x "$JAVACMD" ] ; then128+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME129+130+Please set the JAVA_HOME variable in your environment to match the131+location of your Java installation."132+ fi133+else134+ JAVACMD=java135+ if ! command -v java >/dev/null 2>&1136+ then137+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.138+139+Please set the JAVA_HOME variable in your environment to match the140+location of your Java installation."141+ fi142+fi143+144+# Increase the maximum file descriptors if we can.145+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then146+ case $MAX_FD in #(147+ max*)148+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.149+ # shellcheck disable=SC2039,SC3045150+ MAX_FD=$( ulimit -H -n ) ||151+ warn "Could not query maximum file descriptor limit"152+ esac153+ case $MAX_FD in #(154+ '' | soft) :;; #(155+ *)156+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.157+ # shellcheck disable=SC2039,SC3045158+ ulimit -n "$MAX_FD" ||159+ warn "Could not set maximum file descriptor limit to $MAX_FD"160+ esac161+fi162+163+# Collect all arguments for the java command, stacking in reverse order:164+# * args from the command line165+# * the main class name166+# * -classpath167+# * -D...appname settings168+# * --module-path (only if needed)169+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.170+171+# For Cygwin or MSYS, switch paths to Windows format before running java172+if "$cygwin" || "$msys" ; then173+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )174+175+ JAVACMD=$( cygpath --unix "$JAVACMD" )176+177+ # Now convert the arguments - kludge to limit ourselves to /bin/sh178+ for arg do179+ if180+ case $arg in #(181+ -*) false ;; # don't mess with options #(182+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath183+ [ -e "$t" ] ;; #(184+ *) false ;;185+ esac186+ then187+ arg=$( cygpath --path --ignore --mixed "$arg" )188+ fi189+ # Roll the args list around exactly as many times as the number of190+ # args, so each arg winds up back in the position where it started, but191+ # possibly modified.192+ #193+ # NB: a `for` loop captures its iteration list before it begins, so194+ # changing the positional parameters here affects neither the number of195+ # iterations, nor the values presented in `arg`.196+ shift # remove old arg197+ set -- "$@" "$arg" # push replacement arg198+ done199+fi200+201+202+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.203+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'204+205+# Collect all arguments for the java command:206+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,207+# and any embedded shellness will be escaped.208+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be209+# treated as '${Hostname}' itself on the command line.210+211+set -- \212+ "-Dorg.gradle.appname=$APP_BASE_NAME" \213+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \214+ "$@"215+216+# Stop when "xargs" is not available.217+if ! command -v xargs >/dev/null 2>&1218+then219+ die "xargs is not available"220+fi221+222+# Use "xargs" to parse quoted args.223+#224+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.225+#226+# In Bash we could simply go:227+#228+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&229+# set -- "${ARGS[@]}" "$@"230+#231+# but POSIX shell has neither arrays nor command substitution, so instead we232+# post-process each arg (as a line of input to sed) to backslash-escape any233+# character that might be a shell metacharacter, then use eval to reverse234+# that process (while maintaining the separation between arguments), and wrap235+# the whole thing up as a single "set" statement.236+#237+# This will of course break if any of these variables contains a newline or238+# an unmatched quote.239+#240+241+eval "set -- $(242+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |243+ xargs -n1 |244+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |245+ tr '\n' ' '246+ )" '"$@"'247+248+exec "$JAVACMD" "$@"
A android/gradlew.bat +82 -0
@@ -0,0 +1,82 @@
1+@rem 2+@rem Copyright 2015 the original author or authors. 3+@rem 4+@rem Licensed under the Apache License, Version 2.0 (the "License"); 5+@rem you may not use this file except in compliance with the License. 6+@rem You may obtain a copy of the License at 7+@rem 8+@rem https://www.apache.org/licenses/LICENSE-2.0 9+@rem 10+@rem Unless required by applicable law or agreed to in writing, software 11+@rem distributed under the License is distributed on an "AS IS" BASIS, 12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13+@rem See the License for the specific language governing permissions and 14+@rem limitations under the License. 15+@rem 16+@rem SPDX-License-Identifier: Apache-2.0 17+@rem 18+ 19+@if "%DEBUG%"=="" @echo off 20+@rem ########################################################################## 21+@rem 22+@rem gradlew startup script for Windows 23+@rem 24+@rem ########################################################################## 25+ 26+@rem Set local scope for the variables, and ensure extensions are enabled 27+setlocal EnableExtensions 28+ 29+set DIRNAME=%~dp0 30+if "%DIRNAME%"=="" set DIRNAME=. 31+@rem This is normally unused 32+set APP_BASE_NAME=%~n0 33+set APP_HOME=%DIRNAME% 34+ 35+@rem Resolve any "." and ".." in APP_HOME to make it shorter. 36+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37+ 38+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40+ 41+@rem Find java.exe 42+if defined JAVA_HOME goto findJavaFromJavaHome 43+ 44+set JAVA_EXE=java.exe 45+%JAVA_EXE% -version >NUL 2>&1 46+if %ERRORLEVEL% equ 0 goto execute 47+ 48+echo. 1>&2 49+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50+echo. 1>&2 51+echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52+echo location of your Java installation. 1>&2 53+ 54+"%COMSPEC%" /c exit 1 55+ 56+:findJavaFromJavaHome 57+set JAVA_HOME=%JAVA_HOME:"=% 58+set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59+ 60+if exist "%JAVA_EXE%" goto execute 61+ 62+echo. 1>&2 63+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64+echo. 1>&2 65+echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66+echo location of your Java installation. 1>&2 67+ 68+"%COMSPEC%" /c exit 1 69+ 70+:execute 71+@rem Setup the command line 72+ 73+ 74+ 75+@rem Execute gradlew 76+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded 77+@rem which allows us to clear the local environment before executing the java command 78+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel 79+ 80+:exitWithErrorLevel 81+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts 82+"%COMSPEC%" /c exit %ERRORLEVEL%
A android/settings.gradle.kts +18 -0
A scripts/android.sh +54 -0
@@ -0,0 +1,54 @@
1+#!/bin/sh2+# Build the Airwaves Android TV app (debug) and install and start it on a TV.3+# scripts/android.sh 192.168.1.50:5555 [server]4+# The device is an adb serial: a TV's wireless debugging address (connected5+# first if adb isn't already; pair it once as android/README.md says) or any6+# serial from `adb devices`. With server (nas:8089, http://192.168.1.20:8089)7+# the app is pointed at it; without, it keeps its setting or asks at first run.8+# Builds the working tree. Needs a JDK 17 or later and the Android SDK9+# (ANDROID_HOME, or sdk.dir in android/local.properties); both are looked10+# for in the usual places when unset.11+set -eu12+usage='usage: android.sh <device> [server]'13+device=${1:?$usage}14+server=${2:-}15+root=$(cd "$(dirname "$0")/.." && pwd)16+app=dev.airwaves.tv17+apk=$root/android/app/build/outputs/apk/debug/app-debug.apk18+19+if [ -z "${ANDROID_HOME:-}" ] && [ ! -f "$root/android/local.properties" ]; then20+ for sdk in "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" \21+ /opt/homebrew/share/android-commandlinetools /usr/local/share/android-commandlinetools; do22+ if [ -n "$sdk" ] && [ -d "$sdk/platforms" ]; then23+ ANDROID_HOME=$sdk24+ export ANDROID_HOME25+ break26+ fi27+ done28+fi29+if [ -z "${JAVA_HOME:-}" ] && ! java -version >/dev/null 2>&1; then30+ for jdk in /opt/homebrew/opt/openjdk@21 /opt/homebrew/opt/openjdk@17 /opt/homebrew/opt/openjdk \31+ /usr/lib/jvm/java-21-openjdk* /usr/lib/jvm/java-17-openjdk*; do32+ if [ -x "$jdk/bin/java" ]; then33+ JAVA_HOME=$jdk34+ export JAVA_HOME35+ break36+ fi37+ done38+fi39+adb=adb40+command -v adb >/dev/null 2>&1 || adb=${ANDROID_HOME:?no adb: set ANDROID_HOME}/platform-tools/adb41+42+echo "building $(sed -n 's/.*versionName = "\(.*\)"/\1/p' "$root/android/app/build.gradle.kts") at $(git -C "$root" log -1 --format='%h %s')"43+(cd "$root/android" && ./gradlew --quiet assembleDebug)44+45+case $device in46+ *:*) "$adb" devices | awk -v d="$device" '$1 == d && $2 == "device" { up = 1 } END { exit !up }' ||47+ "$adb" connect "$device" ;;48+esac49+"$adb" -s "$device" install -r "$apk"50+if [ -n "$server" ]; then51+ "$adb" -s "$device" shell am start -n "$app/.MainActivity" --es server "'$server'"52+else53+ "$adb" -s "$device" shell am start -n "$app/.MainActivity"54+fi