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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user