diff --git a/core/src/main/java/dev/quoije/umd/rco/Display.java b/core/src/main/java/dev/quoije/umd/rco/Display.java index a7998a4..a75b931 100644 --- a/core/src/main/java/dev/quoije/umd/rco/Display.java +++ b/core/src/main/java/dev/quoije/umd/rco/Display.java @@ -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(); diff --git a/core/src/main/java/dev/quoije/umd/rco/object/ButtonObject.java b/core/src/main/java/dev/quoije/umd/rco/object/ButtonObject.java index f8763d8..4e1fb65 100644 --- a/core/src/main/java/dev/quoije/umd/rco/object/ButtonObject.java +++ b/core/src/main/java/dev/quoije/umd/rco/object/ButtonObject.java @@ -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 diff --git a/core/src/main/java/dev/quoije/umd/rco/object/IconObject.java b/core/src/main/java/dev/quoije/umd/rco/object/IconObject.java index c493d15..deb0698 100644 --- a/core/src/main/java/dev/quoije/umd/rco/object/IconObject.java +++ b/core/src/main/java/dev/quoije/umd/rco/object/IconObject.java @@ -13,6 +13,6 @@ public class IconObject extends BasePositionObject { @Override public Object getImage() { - return unknownImage16.getImage() == null ? null : unknownImage16.getImage().getImage(); + return unknownImage16.getImage(); } } diff --git a/core/src/main/java/dev/quoije/umd/rco/object/PageObject.java b/core/src/main/java/dev/quoije/umd/rco/object/PageObject.java index 4795197..0f6959e 100644 --- a/core/src/main/java/dev/quoije/umd/rco/object/PageObject.java +++ b/core/src/main/java/dev/quoije/umd/rco/object/PageObject.java @@ -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); } } diff --git a/core/src/main/java/dev/quoije/umd/rco/object/PlaneObject.java b/core/src/main/java/dev/quoije/umd/rco/object/PlaneObject.java index 78e8044..02c511b 100644 --- a/core/src/main/java/dev/quoije/umd/rco/object/PlaneObject.java +++ b/core/src/main/java/dev/quoije/umd/rco/object/PlaneObject.java @@ -13,6 +13,6 @@ public class PlaneObject extends BasePositionObject { @Override public Object getImage() { - return image.getImage() == null ? null : image.getImage().getImage(); + return image.getImage(); } } diff --git a/core/src/main/java/dev/quoije/umd/rco/object/UButtonObject.java b/core/src/main/java/dev/quoije/umd/rco/object/UButtonObject.java index 38016f5..4d84651 100644 --- a/core/src/main/java/dev/quoije/umd/rco/object/UButtonObject.java +++ b/core/src/main/java/dev/quoije/umd/rco/object/UButtonObject.java @@ -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 diff --git a/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java b/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java index ab1d3bd..261f71e 100644 --- a/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java +++ b/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java @@ -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 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) { diff --git a/ui/src/main/java/dev/quoije/umd/ui/RcoRenderer.java b/ui/src/main/java/dev/quoije/umd/ui/RcoRenderer.java new file mode 100644 index 0000000..5f681b3 --- /dev/null +++ b/ui/src/main/java/dev/quoije/umd/ui/RcoRenderer.java @@ -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 displays = new ArrayList<>(); + private final Map 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 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; + } + } +}