From 5a8f593b98cc1fb81c7c3b91df53905ecc60cd1b Mon Sep 17 00:00:00 2001 From: "ggb.kevin" Date: Wed, 16 Sep 2026 22:45:09 +0200 Subject: [PATCH] Add remote-control key mapping, fullscreen tracking, back-button navigation dispatchKeyEvent() intercepts DPAD/media keys and re-dispatches real KeyEvents into the WebView per the design spec's mapping table: - DPAD center / Play-Pause -> Space - DPAD Left/Right tap -> Arrow (5s seek); held -> Shift+Arrow (30s seek), throttled to ~1 event/200ms since Android's own key-repeat rate would otherwise flood the room's sync socket - DPAD Up/Down + media Prev/Next -> P/N (title change, not ArrowUp/Down - those are explicit no-ops in the website's TV mode since volume is TV/AVR-controlled) - Back: room list -> no-op; in a room + fullscreen -> Escape (browser exits fullscreen natively); in a room + not fullscreen -> navigate to /tv (native WebView navigation, not a website reaction) - Menu (KEYCODE_MENU) deliberately NOT intercepted - passed through unchanged, matching the already-confirmed-working raw-browser test Fullscreen state comes from WebChromeClient.onShowCustomView/ onHideCustomView (needed for HTML5 video fullscreen support anyway). Current-page tracking comes from WebViewClient.onPageFinished. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K --- .../main/java/de/ggb/kwabatv/MainActivity.kt | 130 +++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/ggb/kwabatv/MainActivity.kt b/app/src/main/java/de/ggb/kwabatv/MainActivity.kt index 05732b1..679afa9 100644 --- a/app/src/main/java/de/ggb/kwabatv/MainActivity.kt +++ b/app/src/main/java/de/ggb/kwabatv/MainActivity.kt @@ -1,16 +1,144 @@ package de.ggb.kwabatv import android.app.Activity +import android.net.Uri import android.os.Bundle +import android.os.SystemClock +import android.view.KeyEvent +import android.view.View +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient import android.webkit.WebView +import android.webkit.WebViewClient +/** + * Reine WebView-Huelle. Fernbedienungstasten werden in dispatchKeyEvent() + * abgefangen und als echte (nicht per JS simulierte) KeyEvents an das + * WebView weitergereicht - Chromium verlangt fuer die Fullscreen-API und + * den einmaligen Autoplay-Ton-Klick eine vertrauenswuerdige Nutzereingabe, + * die ein simuliertes KeyboardEvent nicht liefert. + * + * KEYCODE_MENU wird bewusst NICHT abgefangen: das Oeffnen des TV-Menue- + * Popups wurde bereits gegen die echte Website in einem normalen Browser + * auf echter Fire-TV-Hardware bestaetigt, ohne dass diese App beteiligt + * war - unveraendertes Durchreichen ist die naechste Annaeherung an genau + * dieses schon erprobte Verhalten. + */ class MainActivity : Activity() { + + private lateinit var webView: WebView + private var fullscreenView: View? = null + private var currentPath: String = "/tv" + private var lastThrottledSeekAt = 0L + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val webView = WebView(this) + + webView = WebView(this) setContentView(webView) + webView.settings.javaScriptEnabled = true webView.settings.domStorageEnabled = true + webView.settings.mediaPlaybackRequiresUserGesture = false + + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + currentPath = Uri.parse(url).path ?: "/tv" + } + } + + webView.webChromeClient = object : WebChromeClient() { + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + fullscreenView = view + } + + override fun onHideCustomView() { + fullscreenView = null + } + + override fun onPermissionRequest(request: PermissionRequest) { + // Kein Mikrofon/Kamera im TV-Modus - Fire-TV-Sticks haben + // ohnehin meist keine Kamera, siehe Fixed Product Decisions. + request.deny() + } + } + webView.loadUrl(BuildConfig.WEBSITE_URL) } + + override fun onDestroy() { + webView.destroy() + super.onDestroy() + } + + private fun isFullscreen() = fullscreenView != null + private fun isRoomList() = currentPath == "/tv" + + private fun sendKey(keyCode: Int, shift: Boolean = false) { + val metaState = if (shift) KeyEvent.META_SHIFT_ON else 0 + val now = SystemClock.uptimeMillis() + webView.dispatchKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0, metaState)) + webView.dispatchKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0, metaState)) + } + + /** Tippen = 5s-Schritt (kein Shift), gehalten = 30s-Schritt (mit Shift), + * gedrosselt auf ~1 Ereignis pro 200ms - Androids eigene Tastenwieder- + * holung feuert schneller als das und wuerde sonst den Sync-Socket des + * Raums mit Sprungbefehlen ueberfluten, die an alle Zuschauer gehen. */ + private fun handleSeek(keyCode: Int, isRepeat: Boolean) { + if (!isRepeat) { sendKey(keyCode); return } + val now = SystemClock.uptimeMillis() + if (now - lastThrottledSeekAt < SEEK_THROTTLE_MS) return + lastThrottledSeekAt = now + sendKey(keyCode, shift = true) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN) { + return when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_NEXT, + KeyEvent.KEYCODE_BACK -> true // ACTION_UP derselben Tasten verschlucken, wir haben schon auf ACTION_DOWN reagiert + else -> super.dispatchKeyEvent(event) + } + } + return when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + sendKey(KeyEvent.KEYCODE_SPACE) + true + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + handleSeek(KeyEvent.KEYCODE_DPAD_LEFT, event.repeatCount > 0) + true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + handleSeek(KeyEvent.KEYCODE_DPAD_RIGHT, event.repeatCount > 0) + true + } + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + sendKey(KeyEvent.KEYCODE_P) + true + } + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT -> { + sendKey(KeyEvent.KEYCODE_N) + true + } + KeyEvent.KEYCODE_BACK -> { + when { + isRoomList() -> { /* verschluckt, keine weitere Aktion */ } + isFullscreen() -> sendKey(KeyEvent.KEYCODE_ESCAPE) + else -> webView.loadUrl(BuildConfig.WEBSITE_URL) + } + true + } + else -> super.dispatchKeyEvent(event) + } + } + + companion object { + private const val SEEK_THROTTLE_MS = 200L + } }