Add JavaFX menu renderer and player window

- RcoRenderer implements Display: draws the live RCO object tree onto a
  Canvas, decodes PNG textures, handles focus and resource changes
  (thread-safe via Platform.runLater).
- PageObject now implements root/children semantics and open/close/display
  so opening a page adds its objects to the renderer.
- Object getImage() returns the ImageObject so the UI can decode PNGs.
- PlayerApp wires menu + video: loads the RCO, runs the VSMX script,
  executes onAutoPlay, plays video via PlaybackEngine with YUV->ARGB
  blitting and PTS pacing, and routes arrow/Enter keys to the controller.
- Runs end-to-end on Batman Begins (window, menu load, playback).
This commit is contained in:
2026-09-15 23:14:18 -04:00
parent 6929d0d2d3
commit a4f0d5992b
8 changed files with 387 additions and 19 deletions
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.quoije.umd.rco;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxBaseObject;
/** The rendering surface backing an RCO menu. Implemented by the UI layer. */
public interface Display {
void setFocus(IDisplay display);
void add(IDisplay display);
void add(VsmxBaseObject object);
void remove(IDisplay display);
void remove(VsmxBaseObject object);
void repaint();
@@ -26,7 +26,7 @@ public class ButtonObject extends BasePositionObject {
@Override
public Object getImage() {
return image.getImage() == null ? null : image.getImage().getImage();
return image.getImage();
}
@Override
@@ -13,6 +13,6 @@ public class IconObject extends BasePositionObject {
@Override
public Object getImage() {
return unknownImage16.getImage() == null ? null : unknownImage16.getImage().getImage();
return unknownImage16.getImage();
}
}
@@ -2,9 +2,14 @@
package dev.quoije.umd.rco.object;
import dev.quoije.umd.rco.ObjectField;
import dev.quoije.umd.rco.RcoFile;
import dev.quoije.umd.rco.type.EventType;
import dev.quoije.umd.rco.type.IntType;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxBaseObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxInterpreter;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxNativeObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxObject;
import dev.quoije.umd.rco.vsmx.objects.Resource;
/** A menu page. */
public class PageObject extends BaseObject {
@@ -15,8 +20,66 @@ public class PageObject extends BaseObject {
@ObjectField(order = 4) public EventType onContextMenu;
@ObjectField(order = 5) public EventType onActivate;
@Override
public VsmxBaseObject createVsmxObject(VsmxInterpreter interpreter, VsmxBaseObject parent, RcoFile.Entry entry) {
VsmxBaseObject object = super.createVsmxObject(interpreter, parent, entry);
VsmxObject root = new VsmxObject(interpreter, null);
object.setPropertyValue(Resource.rootName, root);
return root;
}
private void display(VsmxBaseObject object) {
if (!object.hasPropertyValue(Resource.childrenName)) {
return;
}
VsmxBaseObject children = object.getPropertyValue(Resource.childrenName);
for (String childName : children.getPropertyNames()) {
VsmxBaseObject child = children.getPropertyValue(childName);
display.add(child);
display(child);
}
}
private void hide(VsmxBaseObject object) {
if (!object.hasPropertyValue(Resource.childrenName)) {
return;
}
VsmxBaseObject children = object.getPropertyValue(Resource.childrenName);
for (String childName : children.getPropertyNames()) {
VsmxBaseObject child = children.getPropertyValue(childName);
display.remove(child);
hide(child);
}
}
private BasePositionObject getFirstButton(VsmxBaseObject object) {
if (!object.hasPropertyValue(Resource.childrenName)) {
return null;
}
VsmxBaseObject children = object.getPropertyValue(Resource.childrenName);
for (String childName : children.getPropertyNames()) {
VsmxBaseObject child = children.getPropertyValue(childName);
if (child instanceof VsmxNativeObject nativeObject && nativeObject.getObject() instanceof UButtonObject button) {
return button;
}
BasePositionObject button = getFirstButton(child);
if (button != null) {
return button;
}
}
return null;
}
public VsmxBaseObject open(VsmxBaseObject object) {
trigger(onInit);
if (display != null && object.hasPropertyValue(Resource.rootName)) {
display(object.getPropertyValue(Resource.rootName));
BasePositionObject button = getFirstButton(object.getPropertyValue(Resource.rootName));
if (button != null) {
button.setFocus();
}
}
return object;
}
@@ -26,6 +89,9 @@ public class PageObject extends BaseObject {
}
public void close(VsmxBaseObject object) {
if (display != null && object.hasPropertyValue(Resource.rootName)) {
hide(object.getPropertyValue(Resource.rootName));
}
trigger(onCancel);
}
}
@@ -13,6 +13,6 @@ public class PlaneObject extends BasePositionObject {
@Override
public Object getImage() {
return image.getImage() == null ? null : image.getImage().getImage();
return image.getImage();
}
}
@@ -21,7 +21,7 @@ public class UButtonObject extends BasePositionObject {
@Override
public Object getImage() {
return image.getImage() == null ? null : image.getImage().getImage();
return image.getImage();
}
@Override
+171 -13
View File
@@ -1,36 +1,194 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.quoije.umd.ui;
import dev.quoije.umd.iso.UmdIsoFile;
import dev.quoije.umd.iso.UmdIsoReader;
import dev.quoije.umd.media.YuvConverter;
import dev.quoije.umd.player.PlaybackEngine;
import dev.quoije.umd.playlist.Playlist;
import dev.quoije.umd.rco.RcoFile;
import dev.quoije.umd.rco.vsmx.Vsmx;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxInterpreter;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxNativeObject;
import dev.quoije.umd.rco.vsmx.objects.Controller;
import dev.quoije.umd.rco.vsmx.objects.GlobalVariables;
import dev.quoije.umd.rco.vsmx.objects.Math;
import dev.quoije.umd.rco.vsmx.objects.MenuHost;
import dev.quoije.umd.rco.vsmx.objects.MoviePlayer;
import dev.quoije.umd.rco.vsmx.objects.Resource;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.canvas.Canvas;
import javafx.scene.image.PixelFormat;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import java.util.List;
import java.io.IOException;
import java.nio.file.Path;
/** JavaFX application entry point for the UMD player. */
public final class PlayerApp extends Application {
private static final int WIDTH = 854;
private static final int HEIGHT = 480;
private Canvas menuCanvas;
private Canvas videoCanvas;
private RcoRenderer renderer;
private volatile Controller controller;
@Override
public void start(Stage stage) {
List<String> params = getParameters().getRaw();
String imagePath = params.isEmpty() ? null : params.get(0);
String isoPath = getParameters().getRaw().isEmpty() ? null : getParameters().getRaw().get(0);
StackPane root = new StackPane(new Label("useless-umd-player\n" + (imagePath == null ? "(no iso)" : imagePath)));
menuCanvas = new Canvas(WIDTH, HEIGHT);
videoCanvas = new Canvas(WIDTH, HEIGHT);
StackPane root = new StackPane(videoCanvas, menuCanvas);
Scene scene = new Scene(root, WIDTH, HEIGHT);
stage.setTitle("useless-umd-player");
stage.setScene(new Scene(root, 854, 480));
stage.setScene(scene);
stage.show();
// Temporary smoke test: auto-close after 2 seconds.
new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
scene.setOnKeyPressed(event -> handleKey(event.getCode()));
if (isoPath != null) {
String path = isoPath;
Thread loader = new Thread(() -> loadMenu(path), "menu-loader");
loader.setDaemon(true);
loader.start();
}
}
private void handleKey(KeyCode code) {
Controller c = controller;
if (c == null) {
return;
}
switch (code) {
case UP -> c.onUp();
case DOWN -> c.onDown();
case LEFT -> c.onLeft();
case RIGHT -> c.onRight();
case ENTER -> c.onPush();
default -> { }
}
}
private void loadMenu(String isoPath) {
try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath))) {
Playlist playlist = Playlist.parse(iso);
byte[] rcoData = readFile(iso, "UMD_VIDEO/RESOURCE/EN100000.RCO");
if (rcoData == null) {
System.err.println("EN100000.RCO not found");
return;
}
Platform.exit();
}, "smoke-close").start();
RcoFile rco = new RcoFile(rcoData);
if (!rco.isValid()) {
System.err.println("RCO invalid");
return;
}
Vsmx vsmx = new Vsmx(rco.vsmxData(), "menu");
VsmxInterpreter interpreter = new VsmxInterpreter();
interpreter.setVsmx(vsmx);
renderer = new RcoRenderer(menuCanvas);
MenuHost menuHost = new MenuHost() {
@Override
public void playMovie(int playListNumber, int chapterNumber, int videoNumber, int audioNumber,
int audioFlag, int subtitleNumber, int subtitleFlag) {
Playlist.Stream stream = playlist.streamForPlayListNumber(playListNumber);
if (stream != null) {
startPlayback(isoPath, stream.name());
}
}
@Override
public void stopMovie() {
}
@Override
public void changeResource(String resourceName) {
}
};
VsmxNativeObject globals = GlobalVariables.create(interpreter, WIDTH, HEIGHT);
VsmxNativeObject controllerObject = Controller.create(interpreter, menuHost, "EN100000");
VsmxNativeObject movieplayer = MoviePlayer.create(interpreter, menuHost, controllerObject);
VsmxNativeObject resource = Resource.create(interpreter, renderer, controllerObject, rco);
VsmxNativeObject math = Math.create(interpreter);
globals.setPropertyValue(Controller.objectName, controllerObject);
globals.setPropertyValue(MoviePlayer.objectName, movieplayer);
globals.setPropertyValue(Resource.objectName, resource);
globals.setPropertyValue(Math.objectName, math);
interpreter.run(globals);
controller = (Controller) controllerObject.getObject();
controller.callCallback(interpreter, "onAutoPlay", null);
} catch (Exception e) {
e.printStackTrace();
}
}
private void startPlayback(String isoPath, String streamName) {
Thread player = new Thread(() -> {
try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath));
PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) {
long basePts = -1;
long baseNanos = -1;
PlaybackEngine.VideoFrame frame;
while ((frame = engine.nextVideoFrame()) != null) {
int[] argb = YuvConverter.yuvj420ToArgb(frame.width(), frame.height(),
frame.yPlane(), frame.yStride(),
frame.uPlane(), frame.uStride(),
frame.vPlane(), frame.vStride());
blitVideo(argb, frame.width(), frame.height());
if (basePts < 0) {
basePts = frame.pts();
baseNanos = System.nanoTime();
}
long targetNanos = baseNanos + (frame.pts() - basePts) * 1_000_000_000L / PlaybackEngine.MPEG_TIMESTAMP;
long sleepNanos = targetNanos - System.nanoTime();
if (sleepNanos > 0) {
long millis = sleepNanos / 1_000_000;
int nanos = (int) (sleepNanos % 1_000_000);
Thread.sleep(millis, nanos);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}, "video-player");
player.setDaemon(true);
player.start();
}
private void blitVideo(int[] argb, int width, int height) {
Platform.runLater(() -> {
videoCanvas.getGraphicsContext2D()
.getPixelWriter()
.setPixels(0, 0, width, height, PixelFormat.getIntArgbInstance(), argb, 0, width);
});
}
private static byte[] readFile(UmdIsoReader iso, String path) throws IOException {
if (!iso.hasFile(path)) {
return null;
}
try (UmdIsoFile file = iso.getFile(path)) {
byte[] buffer = new byte[(int) file.length()];
file.readFully(buffer);
return buffer;
}
}
public static void main(String[] args) {
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.quoije.umd.ui;
import dev.quoije.umd.rco.Display;
import dev.quoije.umd.rco.IDisplay;
import dev.quoije.umd.rco.object.ImageObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxBaseObject;
import dev.quoije.umd.rco.vsmx.interpreter.VsmxNativeObject;
import javafx.application.Platform;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* JavaFX renderer for an RCO menu: draws the live object tree onto a Canvas.
*/
public final class RcoRenderer implements Display {
private final Canvas canvas;
private final List<IDisplay> displays = new ArrayList<>();
private final Map<ImageObject, Image> imageCache = new HashMap<>();
private IDisplay focus;
public RcoRenderer(Canvas canvas) {
this.canvas = canvas;
}
@Override
public void setFocus(IDisplay display) {
this.focus = display;
repaint();
}
@Override
public void add(VsmxBaseObject object) {
IDisplay display = unwrap(object);
if (display != null) {
synchronized (displays) {
if (!displays.contains(display)) {
displays.add(display);
}
}
}
repaint();
}
@Override
public void remove(VsmxBaseObject object) {
IDisplay display = unwrap(object);
if (display != null) {
synchronized (displays) {
displays.remove(display);
}
}
repaint();
}
@Override
public void repaint() {
Platform.runLater(this::render);
}
@Override
public void changeResource() {
synchronized (displays) {
displays.clear();
}
imageCache.clear();
focus = null;
Platform.runLater(this::render);
}
private static IDisplay unwrap(VsmxBaseObject object) {
if (object instanceof VsmxNativeObject nativeObject && nativeObject.getObject() instanceof IDisplay display) {
return display;
}
return null;
}
private void render() {
List<IDisplay> snapshot;
synchronized (displays) {
snapshot = new ArrayList<>(displays);
}
GraphicsContext g = canvas.getGraphicsContext2D();
double w = canvas.getWidth();
double h = canvas.getHeight();
g.clearRect(0, 0, w, h);
for (IDisplay display : snapshot) {
draw(g, display);
}
}
private void draw(GraphicsContext g, IDisplay display) {
Image image = decode(display.getImage());
if (image == null) {
return;
}
double alpha = display.getAlpha();
g.setGlobalAlpha(alpha);
int drawWidth = display.getWidth();
int drawHeight = display.getHeight();
double w = drawWidth > 0 ? drawWidth : image.getWidth();
double h = drawHeight > 0 ? drawHeight : image.getHeight();
g.drawImage(image, display.getX(), display.getY(), w, h);
g.setGlobalAlpha(1.0);
}
private Image decode(Object object) {
if (object instanceof Image image) {
return image;
}
if (object instanceof ImageObject imageObject) {
return imageCache.computeIfAbsent(imageObject, this::decodePng);
}
return null;
}
private Image decodePng(ImageObject imageObject) {
byte[] data = imageObject.getData();
if (data == null || data.length == 0) {
return null;
}
try (ByteArrayInputStream in = new ByteArrayInputStream(data)) {
return new Image(in);
} catch (IOException e) {
return null;
}
}
}