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:
+ *
+ * - null
+ * - String
+ * - Boolean
+ * - Number (finite)
+ *
+ *
+ * @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