Added image/video control and property manipulation

This commit is contained in:
UnlegitDqrk
2026-02-28 16:56:30 +01:00
parent aa629b9237
commit a84c626416
21 changed files with 1708 additions and 60 deletions

View File

@@ -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.
*
* <p>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://}.</p>
*/
public final class FxAudioHost implements AudioHost {
private static final Set<String> 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);
}
}
}

View File

@@ -10,7 +10,9 @@ import java.util.concurrent.atomic.AtomicLong;
/**
* DomHost implementation backed by JavaFX WebView's W3C DOM (WebEngine#getDocument()).
*
* <p>No jsoup and no JavaScript. All operations are performed via W3C DOM APIs.</p>
* <p>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.</p>
*
* <p>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.</p>
@@ -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.
*
* <p>Supported:</p>
* <ul>
* <li>null</li>
* <li>String</li>
* <li>Boolean</li>
* <li>Number (finite)</li>
* </ul>
*
* @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();
}
}

View File

@@ -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 <img>} element.
*
* <p>No JavaFX overlay panes are used. Positioning is done by setting CSS via {@code style} attribute.</p>
*
* <p>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.</p>
*/
public final class FxImageHost implements ImageHost {
private static final Set<String> 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 <body> 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;
}
}

View File

@@ -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 <video>} element.
*
* <p>No JavaFX overlay panes are used. Positioning is done by setting CSS via {@code style} attribute.</p>
*
* <p>Playback control is done via {@link FxDomHost#setProperty(String, String, Object)} and
* {@link FxDomHost#call(String, String, Object...)}, i.e. HTMLMediaElement methods.</p>
*
* <p>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.</p>
*/
public final class FxVideoHost implements VideoHost {
private static final Set<String> VIDEO_EXTENSIONS = Set.of(
"mp4", "mov", "ogg", "ogv", "gif", "gifv", "avi", "m4v"
);
private static final String VIDEO_ID = "__oac_video";
private final WebEngine engine;
private final FxDomHost dom;
private volatile boolean loop;
private volatile double volume = 1.0;
private volatile double x;
private volatile double y;
private volatile double w = 640;
private volatile double h = 360;
private volatile Path lastTempFile;
/**
* Creates a new video host.
*
* @param engine web engine
* @param dom fx dom host
*/
public FxVideoHost(WebEngine engine, FxDomHost dom) {
this.engine = Objects.requireNonNull(engine, "engine");
this.dom = Objects.requireNonNull(dom, "dom");
}
@Override
public void playFile(File file) {
Objects.requireNonNull(file, "file");
if (!file.isFile()) {
throw new IllegalArgumentException("Video file not found: " + file.getAbsolutePath());
}
String ext = MediaExtensions.extensionOf(file.getName());
ensureAllowed(ext, "video", file.getName());
setSource(file.toURI().toString(), null);
resume();
}
@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 ext = MediaExtensions.extensionOf(uri.getPath());
ensureAllowed(ext, "video", 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);
resume();
return;
}
if ("web".equals(scheme)) {
Path tmp = null;
try {
tmp = downloadToTempFile(u, ext, "oac-video-");
rememberTemp(tmp);
setSource(tmp.toUri().toString(), tmp);
resume();
return;
} catch (IOException e) {
safeDelete(tmp);
throw new RuntimeException("Failed to load web video: " + e.getMessage(), e);
}
}
throw new IllegalArgumentException("Unsupported scheme: " + scheme);
}
@Override
public void pause() {
FxThreadBridge.runAndWait(() -> {
ensureVideoElement();
dom.call(VIDEO_ID, "pause");
});
}
@Override
public void resume() {
FxThreadBridge.runAndWait(() -> {
Element v = ensureVideoElement();
applyMediaProps();
v.setAttribute("style", mergeStyle(baseStyle(true), rectStyle(), visualStyle()));
dom.call(VIDEO_ID, "play");
});
}
@Override
public void stop() {
FxThreadBridge.runAndWait(() -> {
Element v = ensureVideoElement();
dom.call(VIDEO_ID, "pause");
dom.setProperty(VIDEO_ID, "currentTime", 0);
v.removeAttribute("src");
v.setAttribute("style", mergeStyle(baseStyle(false), rectStyle(), visualStyle()));
});
Path tmp = this.lastTempFile;
this.lastTempFile = null;
safeDelete(tmp);
}
@Override
public void hide() {
FxThreadBridge.runAndWait(() -> {
Element v = ensureVideoElement();
v.setAttribute("style", mergeStyle(baseStyle(false), rectStyle(), visualStyle()));
});
}
@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);
FxThreadBridge.runAndWait(() -> {
Element v = ensureVideoElement();
boolean visible = "block".equalsIgnoreCase(extractDisplay(v.getAttribute("style")));
v.setAttribute("style", mergeStyle(baseStyle(visible), rectStyle(), visualStyle()));
});
}
@Override
public void setVolume(double volume) {
this.volume = clamp01(volume);
FxThreadBridge.runAndWait(this::applyMediaProps);
}
@Override
public void setLoop(boolean loop) {
this.loop = loop;
FxThreadBridge.runAndWait(this::applyMediaProps);
}
@Override
public void seek(double seconds) {
double s = Math.max(0.0, seconds);
FxThreadBridge.runAndWait(() -> {
ensureVideoElement();
dom.setProperty(VIDEO_ID, "currentTime", s);
});
}
private void setSource(String src, Path tempFileKept) {
FxThreadBridge.runAndWait(() -> {
Element v = ensureVideoElement();
v.setAttribute("src", src);
applyMediaProps();
v.setAttribute("style", mergeStyle(baseStyle(true), rectStyle(), visualStyle()));
});
if (tempFileKept != null) {
rememberTemp(tempFileKept);
}
}
private void applyMediaProps() {
ensureVideoElement();
dom.setProperty(VIDEO_ID, "loop", loop);
dom.setProperty(VIDEO_ID, "volume", volume);
}
private Element ensureVideoElement() {
Document doc = dom.requireDocument();
Element v = doc.getElementById(VIDEO_ID);
if (v != null) return v;
Element body = (Element) doc.getElementsByTagName("body").item(0);
if (body == null) throw new IllegalStateException("No <body> element available");
v = doc.createElement("video");
v.setAttribute("id", VIDEO_ID);
// Default: no controls; script can enable via dom.setAttr(id,"controls","controls") if needed.
v.setAttribute("preload", "metadata");
body.appendChild(v);
return v;
}
private String baseStyle(boolean visible) {
return "position:fixed;"
+ "left:0;top:0;"
+ "z-index:2147483647;"
+ "background-color:black;"
+ "display:" + (visible ? "block" : "none") + ";";
}
private String rectStyle() {
return "left:" + x + "px;"
+ "top:" + y + "px;"
+ "width:" + w + "px;"
+ "height:" + h + "px;";
}
private String visualStyle() {
return "object-fit:contain;";
}
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 String extractDisplay(String style) {
if (style == null || style.isBlank()) return "";
String[] parts = style.split(";");
for (String part : parts) {
String p = part.trim();
if (p.isEmpty()) continue;
int idx = p.indexOf(':');
if (idx <= 0) continue;
String k = p.substring(0, idx).trim().toLowerCase(Locale.ROOT);
if ("display".equals(k)) return p.substring(idx + 1).trim().toLowerCase(Locale.ROOT);
}
return "";
}
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() || !VIDEO_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;
}
}

View File

@@ -0,0 +1,34 @@
package org.openautonomousconnection.luascript.fx;
import java.util.Locale;
/**
* Shared extension helper for media sources.
*/
public final class MediaExtensions {
/**
* Extracts a lower-case extension without dot, or empty string if none.
*
* @param pathOrName path or file name
* @return extension without dot, lower-case, or empty string
*/
public static String extensionOf(String pathOrName) {
if (pathOrName == null) return "";
String s = pathOrName.trim();
if (s.isEmpty()) return "";
int q = s.indexOf('?');
if (q >= 0) s = s.substring(0, q);
int h = s.indexOf('#');
if (h >= 0) s = s.substring(0, h);
int slash = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
String name = (slash >= 0) ? s.substring(slash + 1) : s;
int dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length() - 1) return "";
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}