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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
This commit is contained in:
GGB
2026-09-16 22:45:09 +02:00
co-authored by Claude Sonnet 5
parent e3f9a07a67
commit 5a8f593b98
@@ -1,16 +1,144 @@
package de.ggb.kwabatv package de.ggb.kwabatv
import android.app.Activity import android.app.Activity
import android.net.Uri
import android.os.Bundle 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.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() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val webView = WebView(this)
webView = WebView(this)
setContentView(webView) setContentView(webView)
webView.settings.javaScriptEnabled = true webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = 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) 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
}
} }