Release v0.1.1 2026-07-10

This commit is contained in:
Warren H
2026-07-10 23:47:37 -04:00
parent 537a311f52
commit 6726c600e4
40 changed files with 2987 additions and 192 deletions
@@ -0,0 +1,225 @@
package com.phenomrom.iwanttoheal;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import androidx.activity.OnBackPressedCallback;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
dispatchNativeControllerToken("Button1", false);
}
});
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@Override
public void onPause() {
clearHeldControllerState();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
String token = controllerToken(event.getKeyCode());
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
long now = SystemClock.uptimeMillis();
for (String token : currentTokens) {
boolean repeat = activeMotionTokens.contains(token);
long lastDispatch = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatch >= REPEAT_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
return true;
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(Set<String> tokens, float value, String negative, String positive) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negative);
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
}
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void clearHeldControllerState() {
activeMotionTokens.clear();
lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
null
));
}
private boolean shouldThrottleDpad(String token) {
int index = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[index] < REPEAT_THROTTLE_MS) return true;
lastDpadDispatchAt[index] = now;
return false;
}
private boolean isDpadToken(String token) {
return token.equals("Button12")
|| token.equals("Button13")
|| token.equals("Button14")
|| token.equals("Button15");
}
private String controllerToken(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0";
case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: return "Button2";
case KeyEvent.KEYCODE_BUTTON_Y: return "Button3";
case KeyEvent.KEYCODE_BUTTON_L1: return "Button4";
case KeyEvent.KEYCODE_BUTTON_R1: return "Button5";
case KeyEvent.KEYCODE_BUTTON_L2: return "Button6";
case KeyEvent.KEYCODE_BUTTON_R2: return "Button7";
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Button8";
case KeyEvent.KEYCODE_BUTTON_START: return "Button9";
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "Button10";
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "Button11";
case KeyEvent.KEYCODE_DPAD_UP: return "Button12";
case KeyEvent.KEYCODE_DPAD_DOWN: return "Button13";
case KeyEvent.KEYCODE_DPAD_LEFT: return "Button14";
case KeyEvent.KEYCODE_DPAD_RIGHT: return "Button15";
default: return null;
}
}
}
@@ -1,44 +1,11 @@
package com.phenomrom.iwanttoheal;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
public class MainActivity extends ControllerBridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(ThorDualScreenPlugin.class);
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
getBridge().getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
getBridge().getWebView().setFocusableInTouchMode(true);
getBridge().getWebView().requestFocus();
enterImmersiveMode();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
}
@@ -0,0 +1,307 @@
package com.phenomrom.iwanttoheal;
import android.app.Presentation;
import android.content.Context;
import android.graphics.Color;
import android.hardware.display.DisplayManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Display;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/** Keeps a dedicated WebView visible on each AYN Thor panel for the activity lifetime. */
@CapacitorPlugin(name = "ThorDualScreen")
public class ThorDualScreenPlugin extends Plugin implements DisplayManager.DisplayListener {
private final Handler handler = new Handler(Looper.getMainLooper());
private DisplayManager displayManager;
private ThorPresentation presentation;
private boolean forceEnabled;
@Override
public void load() {
displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
displayManager.registerDisplayListener(this, handler);
}
@PluginMethod
public void getDisplays(PluginCall call) {
Display current = currentDisplay();
JSArray displays = new JSArray();
for (Display display : displayManager.getDisplays()) displays.put(describeDisplay(display, current));
JSObject result = new JSObject();
result.put("currentDisplayId", current == null ? -1 : current.getDisplayId());
result.put("displays", displays);
call.resolve(result);
}
@PluginMethod
public void forceBothDisplays(PluginCall call) {
forceEnabled = true;
getActivity().runOnUiThread(() -> {
try {
DisplayPlan plan = createPlan();
if (plan.target == null) {
call.reject("No active secondary Android display is available.");
return;
}
boolean alreadyOpen = presentation != null
&& presentation.isShowing()
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId();
if (!alreadyOpen) openPlan(plan);
JSObject result = describeDisplay(plan.target, currentDisplay());
result.put("opened", true);
result.put("topOnActivity", plan.currentIsTop);
call.resolve(result);
} catch (Exception exception) {
closePresentation();
call.reject("Unable to force both Thor displays.", exception);
}
});
}
@Override
protected void handleOnResume() {
if (forceEnabled) scheduleEnsurePresentation(0L);
}
@Override
protected void handleOnDestroy() {
forceEnabled = false;
if (displayManager != null) displayManager.unregisterDisplayListener(this);
handler.removeCallbacksAndMessages(null);
closePresentation();
}
@Override
public void onDisplayAdded(int displayId) {
if (forceEnabled) scheduleEnsurePresentation(100L);
}
@Override
public void onDisplayRemoved(int displayId) {
if (presentation != null && presentation.getDisplay().getDisplayId() == displayId) {
closePresentation();
}
if (forceEnabled) scheduleEnsurePresentation(250L);
}
@Override
public void onDisplayChanged(int displayId) {
if (forceEnabled) scheduleEnsurePresentation(100L);
}
private void scheduleEnsurePresentation(long delayMs) {
handler.removeCallbacks(ensurePresentation);
handler.postDelayed(ensurePresentation, delayMs);
}
private final Runnable ensurePresentation = () -> {
if (!forceEnabled || getActivity() == null || getActivity().isFinishing()) return;
DisplayPlan plan = createPlan();
if (plan.target == null) return;
if (
presentation != null
&& presentation.isShowing()
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId()
) return;
openPlan(plan);
};
private void openPlan(DisplayPlan plan) {
String baseUrl = bridge.getLocalUrl();
String targetUrl = baseUrl + "/?display=" + (plan.targetIsTop ? "top" : "bottom") + "&role=presentation";
String currentUrl = baseUrl + "/?display=" + (plan.currentIsTop ? "top" : "bottom") + "&role=activity";
closePresentation();
presentation = new ThorPresentation(
getActivity(),
plan.target,
targetUrl
);
presentation.setOnDismissListener(dialog -> {
presentation = null;
notifyListeners("displayDisconnected", new JSObject());
if (forceEnabled) scheduleEnsurePresentation(300L);
});
presentation.show();
bridge.getWebView().loadUrl(currentUrl);
}
private DisplayPlan createPlan() {
Display current = currentDisplay();
int currentId = current == null ? -1 : current.getDisplayId();
Display[] displays = displayManager.getDisplays();
Display top = largestDisplay(displays);
boolean currentIsTop = top != null && top.getDisplayId() == currentId;
Display target = currentIsTop
? smallestOtherDisplay(displays, currentId)
: top != null && top.getDisplayId() != currentId ? top : largestOtherDisplay(displays, currentId);
boolean targetIsTop = target != null && top != null && target.getDisplayId() == top.getDisplayId();
return new DisplayPlan(target, targetIsTop, currentIsTop);
}
private Display currentDisplay() {
return getActivity().getWindow().getDecorView().getDisplay();
}
private Display largestDisplay(Display[] displays) {
Display selected = null;
long pixels = -1L;
for (Display display : displays) {
if (display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate > pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private Display largestOtherDisplay(Display[] displays, int currentId) {
Display selected = null;
long pixels = -1L;
for (Display display : displays) {
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate > pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private Display smallestOtherDisplay(Display[] displays, int currentId) {
Display selected = null;
long pixels = Long.MAX_VALUE;
for (Display display : displays) {
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate < pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private long pixelCount(Display display) {
Display.Mode mode = display.getMode();
return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight();
}
private JSObject describeDisplay(Display display, Display current) {
Display.Mode mode = display.getMode();
JSObject result = new JSObject();
result.put("id", display.getDisplayId());
result.put("name", display.getName());
result.put("width", mode.getPhysicalWidth());
result.put("height", mode.getPhysicalHeight());
result.put("refreshRate", mode.getRefreshRate());
result.put("isCurrent", current != null && current.getDisplayId() == display.getDisplayId());
return result;
}
private void closePresentation() {
if (presentation == null) return;
ThorPresentation closing = presentation;
presentation = null;
closing.setOnDismissListener(null);
closing.dismiss();
}
private final class ThorPresentation extends Presentation {
private final String initialUrl;
private WebView webView;
ThorPresentation(Context context, Display display, String initialUrl) {
super(context, display);
this.initialUrl = initialUrl;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setBackgroundDrawableResource(android.R.color.black);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK);
webView = new WebView(getContext());
configureWebView(webView);
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
setContentView(container);
webView.loadUrl(initialUrl);
}
@Override
protected void onStop() {
super.onStop();
if (webView != null) {
webView.stopLoading();
webView.destroy();
webView = null;
}
}
}
private void configureWebView(WebView webView) {
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
webView.setBackgroundColor(Color.BLACK);
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
webView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return bridge.getLocalServer().shouldInterceptRequest(request);
}
});
}
private static final class DisplayPlan {
private final Display target;
private final boolean targetIsTop;
private final boolean currentIsTop;
DisplayPlan(Display target, boolean targetIsTop, boolean currentIsTop) {
this.target = target;
this.targetIsTop = targetIsTop;
this.currentIsTop = currentIsTop;
}
}
}