diff --git a/src/main/java/org/openautonomousconnection/luascript/events/JavaToLua.java b/src/main/java/org/openautonomousconnection/luascript/events/JavaToLua.java index a7fafb2..e3338de 100644 --- a/src/main/java/org/openautonomousconnection/luascript/events/JavaToLua.java +++ b/src/main/java/org/openautonomousconnection/luascript/events/JavaToLua.java @@ -3,6 +3,8 @@ package org.openautonomousconnection.luascript.events; import org.luaj.vm2.LuaTable; import org.luaj.vm2.LuaValue; +import java.lang.reflect.Array; +import java.util.Collection; import java.util.List; import java.util.Map; @@ -17,12 +19,18 @@ public final class JavaToLua { public static LuaValue coerce(Object v) { if (v == null) return LuaValue.NIL; if (v instanceof LuaValue lv) return lv; + if (v instanceof String s) return LuaValue.valueOf(s); if (v instanceof Boolean b) return LuaValue.valueOf(b); - if (v instanceof Integer i) return LuaValue.valueOf(i); - if (v instanceof Long l) return LuaValue.valueOf(l); - if (v instanceof Float f) return LuaValue.valueOf(f); - if (v instanceof Double d) return LuaValue.valueOf(d); + + if (v instanceof Byte n) return LuaValue.valueOf(n.intValue()); + if (v instanceof Short n) return LuaValue.valueOf(n.intValue()); + if (v instanceof Integer n) return LuaValue.valueOf(n); + if (v instanceof Long n) return LuaValue.valueOf(n); + if (v instanceof Float n) return LuaValue.valueOf(n.doubleValue()); + if (v instanceof Double n) return LuaValue.valueOf(n); + + if (v instanceof Number n) return LuaValue.valueOf(n.doubleValue()); if (v instanceof Map m) { LuaTable t = new LuaTable(); @@ -43,6 +51,24 @@ public final class JavaToLua { return t; } + if (v instanceof Collection col) { + LuaTable t = new LuaTable(); + int i = 1; + for (Object o : col) { + t.set(i++, coerce(o)); + } + return t; + } + + if (v.getClass().isArray()) { + LuaTable t = new LuaTable(); + int len = Array.getLength(v); + for (int i = 0; i < len; i++) { + t.set(i + 1, coerce(Array.get(v, i))); + } + return t; + } + return LuaValue.valueOf(String.valueOf(v)); } -} +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/luascript/fx/FxAudioHost.java b/src/main/java/org/openautonomousconnection/luascript/fx/FxAudioHost.java new file mode 100644 index 0000000..7c2a7ae --- /dev/null +++ b/src/main/java/org/openautonomousconnection/luascript/fx/FxAudioHost.java @@ -0,0 +1,212 @@ +package org.openautonomousconnection.luascript.fx; + +import javafx.application.Platform; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import org.openautonomousconnection.luascript.hosts.AudioHost; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * JavaFX MediaPlayer based AudioHost. + * + *

Note: JavaFX Media does not reliably use {@link java.net.URLStreamHandler} for custom schemes. + * Therefore, for {@code web://} this host resolves via {@link URLConnection} (your installed handler), + * spools to a temporary file and plays that file via {@code file://}.

+ */ +public final class FxAudioHost implements AudioHost { + + private static final Set AUDIO_EXTENSIONS = Set.of( + "mp3", "wav", "ogg", "m4a", "opus", "web" + ); + + private volatile MediaPlayer player; + private volatile boolean loop; + private volatile double volume = 1.0; + + private volatile Path lastTempFile; + + @Override + public void playFile(File file) { + Objects.requireNonNull(file, "file"); + + if (!file.isFile()) { + throw new IllegalArgumentException("Audio file not found: " + file.getAbsolutePath()); + } + + String ext = MediaExtensions.extensionOf(file.getName()); + ensureAllowedAudioExtension(ext, file.getName()); + + playUri(file.toURI()); + } + + @Override + public void playUrl(String url) { + Objects.requireNonNull(url, "url"); + String u = url.trim(); + if (u.isEmpty()) throw new IllegalArgumentException("URL is empty"); + + URI uri = URI.create(u); + String scheme = (uri.getScheme() == null) ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + + String ext = MediaExtensions.extensionOf(uri.getPath()); + ensureAllowedAudioExtension(ext, uri.getPath()); + + if ("http".equals(scheme) || "https".equals(scheme) || "file".equals(scheme)) { + playUri(uri); + return; + } + + if ("web".equals(scheme)) { + Path tmp = null; + try { + tmp = downloadToTempFile(u, ext); + rememberTemp(tmp); + playUri(tmp.toUri()); + return; + } catch (IOException e) { + safeDelete(tmp); + throw new RuntimeException("Failed to load web audio: " + e.getMessage(), e); + } + } + + throw new IllegalArgumentException("Unsupported scheme: " + scheme); + } + + @Override + public void pause() { + Platform.runLater(() -> { + MediaPlayer p = player; + if (p != null) p.pause(); + }); + } + + @Override + public void stop() { + Platform.runLater(this::stopInternal); + } + + @Override + public void setVolume(double volume) { + double v = clamp01(volume); + this.volume = v; + Platform.runLater(() -> { + MediaPlayer p = player; + if (p != null) p.setVolume(v); + }); + } + + @Override + public void setLoop(boolean loop) { + this.loop = loop; + Platform.runLater(() -> { + MediaPlayer p = player; + if (p != null) p.setCycleCount(loop ? MediaPlayer.INDEFINITE : 1); + }); + } + + private void playUri(URI uri) { + Platform.runLater(() -> { + stopInternal(); + + Media media = new Media(uri.toString()); + MediaPlayer p = new MediaPlayer(media); + + p.setCycleCount(loop ? MediaPlayer.INDEFINITE : 1); + p.setVolume(volume); + + p.setOnEndOfMedia(() -> { + if (!loop) { + try { + p.dispose(); + } catch (Exception ignored) { + // ignore + } + } + }); + + this.player = p; + p.play(); + }); + } + + private void stopInternal() { + MediaPlayer p = this.player; + this.player = null; + + if (p != null) { + try { + p.stop(); + } catch (Exception ignored) { + // ignore + } + try { + p.dispose(); + } catch (Exception ignored) { + // ignore + } + } + + Path tmp = this.lastTempFile; + this.lastTempFile = null; + safeDelete(tmp); + } + + private static Path downloadToTempFile(String url, String ext) throws IOException { + URL u = new URL(url); + URLConnection con = u.openConnection(); + con.setUseCaches(false); + + String safeExt = (ext == null || ext.isBlank()) ? "bin" : ext.toLowerCase(Locale.ROOT); + Path tmp = Files.createTempFile("oac-audio-", "." + safeExt); + + try (InputStream in = con.getInputStream()) { + Files.copy(in, tmp, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + safeDelete(tmp); + throw e; + } + + Files.write(tmp, new byte[0], StandardOpenOption.APPEND); + return tmp; + } + + private void rememberTemp(Path tmp) { + Path prev = this.lastTempFile; + this.lastTempFile = tmp; + safeDelete(prev); + } + + private static void safeDelete(Path p) { + if (p == null) return; + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best-effort + } + } + + private static double clamp01(double v) { + if (v < 0.0) return 0.0; + if (v > 1.0) return 1.0; + return v; + } + + private static void ensureAllowedAudioExtension(String ext, String source) { + String e = (ext == null) ? "" : ext.toLowerCase(Locale.ROOT); + if (e.isEmpty() || !AUDIO_EXTENSIONS.contains(e)) { + throw new IllegalArgumentException("Unsupported audio format '" + e + "' for: " + source); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/luascript/fx/FxDomHost.java b/src/main/java/org/openautonomousconnection/luascript/fx/FxDomHost.java index 628d9b0..c1a74a8 100644 --- a/src/main/java/org/openautonomousconnection/luascript/fx/FxDomHost.java +++ b/src/main/java/org/openautonomousconnection/luascript/fx/FxDomHost.java @@ -10,7 +10,9 @@ import java.util.concurrent.atomic.AtomicLong; /** * DomHost implementation backed by JavaFX WebView's W3C DOM (WebEngine#getDocument()). * - *

No jsoup and no JavaScript. All operations are performed via W3C DOM APIs.

+ *

Uses W3C DOM for structure/attributes and a small JavaScript bridge for DOM properties and method calls + * (required for HTMLMediaElement like video/audio). Lua remains the scripting language; JavaScript is only + * used internally to access DOM properties/methods that are not available via W3C DOM APIs.

* *

Element identity is the {@code id} attribute. This host auto-assigns stable ids to elements that * do not have one, ensuring addressability for Lua bindings and event routing.

@@ -77,6 +79,24 @@ public final class FxDomHost implements DomHost { return v; } + private static String requireId(String id) { + if (id == null) throw new IllegalArgumentException("elementId is null"); + String v = id.trim(); + if (v.isEmpty()) throw new IllegalArgumentException("elementId is blank"); + return v; + } + + private static String requireJsIdentifier(String s, String label) { + if (s == null) throw new IllegalArgumentException(label + " is null"); + String v = s.trim(); + if (v.isEmpty()) throw new IllegalArgumentException(label + " is blank"); + // Prevent JS injection: only simple identifier. + if (!v.matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) { + throw new IllegalArgumentException(label + " must be a JS identifier: " + v); + } + return v; + } + /** * Ensures every element has a stable id. */ @@ -314,6 +334,77 @@ public final class FxDomHost implements DomHost { }); } + @Override + public void setProperty(String elementId, String property, Object value) { + final String id = requireId(elementId); + final String prop = requireJsIdentifier(property, "property"); + final String jsValue = toJsLiteral(value); + + FxThreadBridge.runAndWait(() -> { + requireDocument(); + ensureJsEnabled(); + + String script = "" + + "(function(){" + + " var el = document.getElementById(" + toJsLiteral(id) + ");" + + " if(!el) throw new Error('Unknown element id: ' + " + toJsLiteral(id) + ");" + + " el[" + toJsLiteral(prop) + "] = " + jsValue + ";" + + " return null;" + + "})();"; + + engine.executeScript(script); + engine.setJavaScriptEnabled(false); + }); + } + + @Override + public Object getProperty(String elementId, String property) { + final String id = requireId(elementId); + final String prop = requireJsIdentifier(property, "property"); + + return FxThreadBridge.callAndWait(() -> { + requireDocument(); + ensureJsEnabled(); + + String script = "" + + "(function(){" + + " var el = document.getElementById(" + toJsLiteral(id) + ");" + + " if(!el) throw new Error('Unknown element id: ' + " + toJsLiteral(id) + ");" + + " return el[" + toJsLiteral(prop) + "];" + + "})();"; + + Object ret = engine.executeScript(script); + engine.setJavaScriptEnabled(false); + return ret; + }); + } + + @Override + public Object call(String elementId, String method, Object... args) { + final String id = requireId(elementId); + final String m = requireJsIdentifier(method, "method"); + final Object[] safeArgs = (args == null) ? new Object[0] : args.clone(); + final String argvLiteral = toJsArrayLiteral(safeArgs); + + return FxThreadBridge.callAndWait(() -> { + requireDocument(); + ensureJsEnabled(); + + String script = "" + + "(function(){" + + " var el = document.getElementById(" + toJsLiteral(id) + ");" + + " if(!el) throw new Error('Unknown element id: ' + " + toJsLiteral(id) + ");" + + " var fn = el[" + toJsLiteral(m) + "];" + + " if(typeof fn !== 'function') throw new Error('Not a function: ' + " + toJsLiteral(m) + ");" + + " return fn.apply(el, " + argvLiteral + ");" + + "})();"; + + Object ret = engine.executeScript(script); + engine.setJavaScriptEnabled(false); + return ret; + }); + } + /** * Exposes the current document (FX thread access required by callers). * @@ -339,6 +430,13 @@ public final class FxDomHost implements DomHost { return el; } + private void ensureJsEnabled() { + // Required for HTMLMediaElement and DOM property access beyond W3C DOM. + if (!engine.isJavaScriptEnabled()) { + engine.setJavaScriptEnabled(true); + } + } + private String generateUniqueId(Document doc) { while (true) { String id = "__auto_" + autoIdSeq.getAndIncrement(); @@ -353,4 +451,104 @@ public final class FxDomHost implements DomHost { } return generateUniqueId(doc); } -} + + /** + * Converts a Java value into a safe JavaScript literal. + * + *

Supported:

+ * + * + * @param v value + * @return JS literal + */ + private static String toJsLiteral(Object v) { + if (v == null) return "null"; + + if (v instanceof String s) { + return "'" + escapeJsSingleQuotedString(s) + "'"; + } + + if (v instanceof Boolean b) { + return b ? "true" : "false"; + } + + if (v instanceof Byte || v instanceof Short || v instanceof Integer || v instanceof Long) { + return String.valueOf(((Number) v).longValue()); + } + + if (v instanceof Float || v instanceof Double) { + double d = ((Number) v).doubleValue(); + if (!Double.isFinite(d)) { + throw new IllegalArgumentException("Non-finite number is not supported for JS literal: " + d); + } + // Use plain Java formatting; JS accepts it. + return Double.toString(d); + } + + if (v instanceof Number n) { + double d = n.doubleValue(); + if (!Double.isFinite(d)) { + throw new IllegalArgumentException("Non-finite number is not supported for JS literal: " + d); + } + return Double.toString(d); + } + + throw new IllegalArgumentException("Unsupported value type for JS literal: " + v.getClass().getName()); + } + + /** + * Builds a JavaScript array literal from Java arguments. + * + * @param args args (nullable) + * @return JS array literal + */ + private static String toJsArrayLiteral(Object[] args) { + if (args == null || args.length == 0) return "[]"; + StringBuilder sb = new StringBuilder(); + sb.append('['); + for (int i = 0; i < args.length; i++) { + if (i > 0) sb.append(','); + sb.append(toJsLiteral(args[i])); + } + sb.append(']'); + return sb.toString(); + } + + /** + * Escapes a string for inclusion inside a single-quoted JavaScript string literal. + * + * @param s raw string + * @return escaped string + */ + private static String escapeJsSingleQuotedString(String s) { + if (s == null || s.isEmpty()) return ""; + StringBuilder out = new StringBuilder(s.length() + 16); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\'' -> out.append("\\'"); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\u0000' -> out.append("\\0"); + case '\u2028' -> out.append("\\u2028"); + case '\u2029' -> out.append("\\u2029"); + default -> { + // Keep printable chars; escape other control chars. + if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + return out.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/luascript/fx/FxImageHost.java b/src/main/java/org/openautonomousconnection/luascript/fx/FxImageHost.java new file mode 100644 index 0000000..155999f --- /dev/null +++ b/src/main/java/org/openautonomousconnection/luascript/fx/FxImageHost.java @@ -0,0 +1,270 @@ +package org.openautonomousconnection.luascript.fx; + +import javafx.scene.web.WebEngine; +import org.openautonomousconnection.luascript.hosts.ImageHost; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * ImageHost implementation that renders images inside the WebView DOM using an {@code } element. + * + *

No JavaFX overlay panes are used. Positioning is done by setting CSS via {@code style} attribute.

+ * + *

For {@code web://} URLs this host downloads the resource via {@link URLConnection} (your installed handler), + * stores it as a temporary file, then sets {@code src=file://...} because WebView won't reliably load custom schemes.

+ */ +public final class FxImageHost implements ImageHost { + + private static final Set IMAGE_EXTENSIONS = Set.of( + "png", "jpg", "jpeg", "ico", "bmp", "avif", "heif", "heic", "webp" + ); + + private static final String IMG_ID = "__oac_image"; + + private final WebEngine engine; + private final FxDomHost dom; + + private volatile double x; + private volatile double y; + private volatile double w = 320; + private volatile double h = 240; + + private volatile boolean preserveRatio = true; + private volatile boolean smooth = true; + private volatile double opacity = 1.0; + + private volatile Path lastTempFile; + + /** + * Creates a new image host. + * + * @param engine web engine + * @param dom fx dom host + */ + public FxImageHost(WebEngine engine, FxDomHost dom) { + this.engine = Objects.requireNonNull(engine, "engine"); + this.dom = Objects.requireNonNull(dom, "dom"); + // Ensure element exists once document is present; if not yet loaded, calls will create on demand. + } + + @Override + public void showFile(File file) { + Objects.requireNonNull(file, "file"); + if (!file.isFile()) { + throw new IllegalArgumentException("Image file not found: " + file.getAbsolutePath()); + } + + String ext = MediaExtensions.extensionOf(file.getName()); + ensureAllowed(ext, "image", file.getName()); + + URI uri = file.toURI(); + setSource(uri.toString(), null); + } + + @Override + public void showUrl(String url) { + Objects.requireNonNull(url, "url"); + String u = url.trim(); + if (u.isEmpty()) throw new IllegalArgumentException("URL is empty"); + + URI uri = URI.create(u); + String ext = MediaExtensions.extensionOf(uri.getPath()); + ensureAllowed(ext, "image", uri.getPath()); + + String scheme = (uri.getScheme() == null) ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + + if ("http".equals(scheme) || "https".equals(scheme) || "file".equals(scheme)) { + setSource(uri.toString(), null); + return; + } + + if ("web".equals(scheme)) { + Path tmp = null; + try { + tmp = downloadToTempFile(u, ext, "oac-img-"); + rememberTemp(tmp); + setSource(tmp.toUri().toString(), tmp); + return; + } catch (IOException e) { + safeDelete(tmp); + throw new RuntimeException("Failed to load web image: " + e.getMessage(), e); + } + } + + throw new IllegalArgumentException("Unsupported scheme: " + scheme); + } + + @Override + public void hide() { + FxThreadBridge.runAndWait(() -> { + Element img = ensureImgElement(); + img.setAttribute("style", mergeStyle(baseStyle(false), rectStyle(), visualStyle())); + img.removeAttribute("src"); + }); + + Path tmp = this.lastTempFile; + this.lastTempFile = null; + safeDelete(tmp); + } + + @Override + public void setRect(double x, double y, double w, double h) { + this.x = x; + this.y = y; + this.w = Math.max(0.0, w); + this.h = Math.max(0.0, h); + applyStyle(true); + } + + @Override + public void setPreserveRatio(boolean preserve) { + this.preserveRatio = preserve; + applyStyle(true); + } + + @Override + public void setSmooth(boolean smooth) { + this.smooth = smooth; + applyStyle(true); + } + + @Override + public void setOpacity(double opacity) { + this.opacity = clamp01(opacity); + applyStyle(true); + } + + private void setSource(String src, Path tempFileKept) { + FxThreadBridge.runAndWait(() -> { + Element img = ensureImgElement(); + img.setAttribute("src", src); + img.setAttribute("style", mergeStyle(baseStyle(true), rectStyle(), visualStyle())); + }); + + // Keep temp file reference (so previous gets cleaned up) + if (tempFileKept != null) { + rememberTemp(tempFileKept); + } + } + + private void applyStyle(boolean visible) { + FxThreadBridge.runAndWait(() -> { + Element img = ensureImgElement(); + String src = img.getAttribute("src"); + boolean show = visible && src != null && !src.isBlank(); + img.setAttribute("style", mergeStyle(baseStyle(show), rectStyle(), visualStyle())); + }); + } + + private Element ensureImgElement() { + Document doc = dom.requireDocument(); + Element img = doc.getElementById(IMG_ID); + if (img != null) return img; + + Element body = (Element) doc.getElementsByTagName("body").item(0); + if (body == null) throw new IllegalStateException("No element available"); + + img = doc.createElement("img"); + img.setAttribute("id", IMG_ID); + img.setAttribute("draggable", "false"); + img.setAttribute("alt", ""); + + body.appendChild(img); + return img; + } + + private String baseStyle(boolean visible) { + return "position:fixed;" + + "left:0;top:0;" + + "z-index:2147483647;" + + "display:" + (visible ? "block" : "none") + ";"; + } + + private String rectStyle() { + return "left:" + x + "px;" + + "top:" + y + "px;" + + "width:" + w + "px;" + + "height:" + h + "px;"; + } + + private String visualStyle() { + String fit; + if (preserveRatio) { + fit = "object-fit:contain;"; + } else { + fit = "object-fit:fill;"; + } + + String smoothing = smooth ? "image-rendering:auto;" : "image-rendering:pixelated;"; + return fit + smoothing + "opacity:" + opacity + ";"; + } + + private static String mergeStyle(String... parts) { + StringBuilder sb = new StringBuilder(256); + for (String p : parts) { + if (p == null || p.isBlank()) continue; + String s = p.trim(); + if (!s.endsWith(";")) s += ";"; + sb.append(s); + } + return sb.toString(); + } + + private static Path downloadToTempFile(String url, String ext, String prefix) throws IOException { + URL u = new URL(url); + URLConnection con = u.openConnection(); + con.setUseCaches(false); + + String safeExt = (ext == null || ext.isBlank()) ? "bin" : ext.toLowerCase(Locale.ROOT); + Path tmp = Files.createTempFile(prefix, "." + safeExt); + + try (InputStream in = con.getInputStream()) { + Files.copy(in, tmp, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + safeDelete(tmp); + throw e; + } + + return tmp; + } + + private void rememberTemp(Path tmp) { + Path prev = this.lastTempFile; + this.lastTempFile = tmp; + safeDelete(prev); + } + + private static void safeDelete(Path p) { + if (p == null) return; + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best-effort + } + } + + private static void ensureAllowed(String ext, String kind, String source) { + String e = (ext == null) ? "" : ext.toLowerCase(Locale.ROOT); + if (e.isEmpty() || !IMAGE_EXTENSIONS.contains(e)) { + throw new IllegalArgumentException("Unsupported " + kind + " format '" + e + "' for: " + source); + } + } + + private static double clamp01(double v) { + if (v < 0.0) return 0.0; + if (v > 1.0) return 1.0; + return v; + } +} \ No newline at end of file diff --git a/src/main/java/org/openautonomousconnection/luascript/fx/FxVideoHost.java b/src/main/java/org/openautonomousconnection/luascript/fx/FxVideoHost.java new file mode 100644 index 0000000..f31915b --- /dev/null +++ b/src/main/java/org/openautonomousconnection/luascript/fx/FxVideoHost.java @@ -0,0 +1,313 @@ +package org.openautonomousconnection.luascript.fx; + +import javafx.scene.web.WebEngine; +import org.openautonomousconnection.luascript.hosts.VideoHost; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * VideoHost implementation that renders video inside the WebView DOM using a {@code