diff --git a/README.md b/README.md index 71416ae..6d8014f 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,40 @@ a standalone player for PSP UMD Video ISO images: open a disc, render its real menu, and play the movie (H.264 video + ATRAC3+ audio) with mouse and keyboard/media-key controls. -> **Status:** early development. Currently implemented: ISO 9660/UMD reading, -> `param.sfo` metadata parsing, and file listing. +> **Status:** playable. Opens a UMD Video ISO, plays the real RCO menu (rendered +> from the disc), and plays the movie (H.264 video + ATRAC3+ audio). ## Build -Requires JDK 21 (Gradle auto-provisions it via the Foojay toolchain resolver). +Requires JDK 21 (Gradle auto-provisions it). ```sh ./gradlew build ``` -## Run +## Run the player ```sh -./gradlew cli:run --args="dump /path/to/disc.iso" +./gradlew ui:run --args="/path/to/disc.iso" +``` + +Controls: + +- **↑ ↓ ← →** — navigate the menu +- **Enter** — select +- **M** — back to the menu +- **S** — skip the current intro/track +- **Space / P** — pause +- **F11** — fullscreen + +## CLI + +The `cli` module has inspection/debug commands: + +```sh +./gradlew cli:run --args="dump /path/to/disc.iso" # metadata + playlist +./gradlew cli:run --args="rco UMD_VIDEO/RESOURCE/EN100000.RCO" # menu data +./gradlew cli:run --args="play " # headless A/V sync test ``` ## License diff --git a/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java b/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java index 7995ce0..1aca756 100644 --- a/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java +++ b/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java @@ -66,6 +66,7 @@ public final class PlaybackEngine implements AutoCloseable { private volatile boolean eof = false; private volatile IOException failure = null; + private volatile boolean closed = false; private final Thread decodeThread; private PlaybackEngine(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException { @@ -254,6 +255,10 @@ public final class PlaybackEngine implements AutoCloseable { @Override public void close() { + if (closed) { + return; + } + closed = true; decodeThread.interrupt(); try { decodeThread.join(1000); diff --git a/core/src/main/java/dev/quoije/umd/rco/anim/FadeAnim.java b/core/src/main/java/dev/quoije/umd/rco/anim/FadeAnim.java index 96fb4f7..8be7585 100644 --- a/core/src/main/java/dev/quoije/umd/rco/anim/FadeAnim.java +++ b/core/src/main/java/dev/quoije/umd/rco/anim/FadeAnim.java @@ -26,7 +26,7 @@ public class FadeAnim extends BaseAnim { return; } float sa = target.alphaScale.getFloatValue(); - Animator.getInstance().add(new Tween(millis, p -> { + Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> { target.alphaScale.setFloatValue(sa + (ta - sa) * p); target.onDisplayUpdated(); })); diff --git a/core/src/main/java/dev/quoije/umd/rco/anim/MoveToAnim.java b/core/src/main/java/dev/quoije/umd/rco/anim/MoveToAnim.java index 1ba7063..5196b5b 100644 --- a/core/src/main/java/dev/quoije/umd/rco/anim/MoveToAnim.java +++ b/core/src/main/java/dev/quoije/umd/rco/anim/MoveToAnim.java @@ -34,7 +34,7 @@ public class MoveToAnim extends BaseAnim { float sx = target.animX; float sy = target.animY; float sz = target.animZ; - Animator.getInstance().add(new Tween(millis, p -> { + Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> { target.animX = sx + (tx - sx) * p; target.animY = sy + (ty - sy) * p; target.animZ = sz + (tz - sz) * p; diff --git a/core/src/main/java/dev/quoije/umd/rco/anim/RecolourAnim.java b/core/src/main/java/dev/quoije/umd/rco/anim/RecolourAnim.java index 60dbed2..5dfe482 100644 --- a/core/src/main/java/dev/quoije/umd/rco/anim/RecolourAnim.java +++ b/core/src/main/java/dev/quoije/umd/rco/anim/RecolourAnim.java @@ -38,7 +38,7 @@ public class RecolourAnim extends BaseAnim { float sg = target.greenScale.getFloatValue(); float sb = target.blueScale.getFloatValue(); float sa = target.alphaScale.getFloatValue(); - Animator.getInstance().add(new Tween(millis, p -> { + Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> { target.redScale.setFloatValue(sr + (tr - sr) * p); target.greenScale.setFloatValue(sg + (tg - sg) * p); target.blueScale.setFloatValue(sb + (tb - sb) * p); diff --git a/core/src/main/java/dev/quoije/umd/rco/anim/ResizeAnim.java b/core/src/main/java/dev/quoije/umd/rco/anim/ResizeAnim.java index b8fa086..3824e67 100644 --- a/core/src/main/java/dev/quoije/umd/rco/anim/ResizeAnim.java +++ b/core/src/main/java/dev/quoije/umd/rco/anim/ResizeAnim.java @@ -34,7 +34,7 @@ public class ResizeAnim extends BaseAnim { float sw = target.scaleWidth.getFloatValue(); float sh = target.scaleHeight.getFloatValue(); float sd = target.scaleDepth.getFloatValue(); - Animator.getInstance().add(new Tween(millis, p -> { + Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> { target.scaleWidth.setFloatValue(sw + (tw - sw) * p); target.scaleHeight.setFloatValue(sh + (th - sh) * p); target.scaleDepth.setFloatValue(sd + (td - sd) * p); diff --git a/core/src/main/java/dev/quoije/umd/rco/anim/RotateAnim.java b/core/src/main/java/dev/quoije/umd/rco/anim/RotateAnim.java index 77c2f8e..bf07b17 100644 --- a/core/src/main/java/dev/quoije/umd/rco/anim/RotateAnim.java +++ b/core/src/main/java/dev/quoije/umd/rco/anim/RotateAnim.java @@ -34,7 +34,7 @@ public class RotateAnim extends BaseAnim { float sx = target.rotX; float sy = target.rotY; float sz = target.rotZ; - Animator.getInstance().add(new Tween(millis, p -> { + Animator.getInstance().add(new Tween(millis, accelMode.getIntValue(), p -> { target.rotX = sx + (tx - sx) * p; target.rotY = sy + (ty - sy) * p; target.rotZ = sz + (tz - sz) * p; diff --git a/core/src/main/java/dev/quoije/umd/rco/anim/Tween.java b/core/src/main/java/dev/quoije/umd/rco/anim/Tween.java index 39431d6..0ccba78 100644 --- a/core/src/main/java/dev/quoije/umd/rco/anim/Tween.java +++ b/core/src/main/java/dev/quoije/umd/rco/anim/Tween.java @@ -3,10 +3,17 @@ package dev.quoije.umd.rco.anim; /** * A single interpolation from 0..1 over a duration in milliseconds. The - * callback receives linear progress each frame until completion. + * callback receives eased progress each frame until completion. */ public final class Tween { + /** Linear progress (no easing). */ + public static final int EASE_LINEAR = 0; + /** Accelerate: slow start, fast finish. */ + public static final int EASE_IN = 1; + /** Decelerate: fast start, slow finish. */ + public static final int EASE_OUT = 2; + /** Receives progress 0..1 on every animation tick. */ @FunctionalInterface public interface ProgressCallback { @@ -15,12 +22,18 @@ public final class Tween { private final long startNanos; private final long durationNanos; + private final int accelMode; private final ProgressCallback callback; private boolean finished; public Tween(long durationMillis, ProgressCallback callback) { + this(durationMillis, EASE_LINEAR, callback); + } + + public Tween(long durationMillis, int accelMode, ProgressCallback callback) { this.startNanos = System.nanoTime(); this.durationNanos = Math.max(1L, durationMillis) * 1_000_000L; + this.accelMode = accelMode; this.callback = callback; } @@ -31,7 +44,15 @@ public final class Tween { progress = 1f; finished = true; } - callback.apply(progress); + callback.apply(ease(progress, accelMode)); return finished; } + + private static float ease(float p, int accelMode) { + return switch (accelMode) { + case EASE_IN -> p * p; + case EASE_OUT -> 1f - (1f - p) * (1f - p); + default -> p; + }; + } } 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 654e3b5..3a25922 100644 --- a/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java +++ b/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java @@ -72,6 +72,11 @@ public final class PlayerApp extends Application { private volatile VsmxNativeObject controllerObject; private long lastMenuKeyNanos = 0; + /** The active decode engine, closed when a new stream starts. */ + private volatile PlaybackEngine currentEngine; + /** Bumped each time playback starts so stale streams stop themselves. */ + private volatile long playbackGeneration = 0; + @Override public void start(Stage stage) { // Gradle's --args splits on whitespace; rejoin so paths with spaces work. @@ -200,6 +205,12 @@ public final class PlayerApp extends Application { @Override public void stopMovie() { + PlaybackEngine engine = currentEngine; + if (engine != null) { + playbackGeneration++; + engine.close(); + currentEngine = null; + } } @Override @@ -230,17 +241,29 @@ public final class PlayerApp extends Application { private void startPlayback(String isoPath, Playlist.Stream stream, int playListNumber) { String streamName = stream.name(); + long generation = ++playbackGeneration; + + // Stop the previous stream so its video and audio threads don't keep + // playing underneath the new one. + PlaybackEngine previous = currentEngine; + if (previous != null) { + previous.close(); + } + currentEngine = null; + Thread player = new Thread(() -> { try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath)); PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) { + currentEngine = engine; + // Each stream has its own PTS timeline, so re-anchor the master // clock to this stream's first audio chunk. clockStartPts = -1; clockStartNanos = -1; clockOffsetNanos = 0; - Thread audio = startAudio(engine); + Thread audio = startAudio(engine, generation); awaitClock(); long basePts = clockStartPts; long baseNanos = clockStartNanos; @@ -248,6 +271,9 @@ public final class PlayerApp extends Application { PlaybackEngine.VideoFrame frame; int lastChapter = -2; while ((frame = engine.nextVideoFrame()) != null) { + if (playbackGeneration != generation) { + break; + } if (skipRequested) { skipRequested = false; break; @@ -279,13 +305,17 @@ public final class PlayerApp extends Application { sleepUntil(targetNanos); } - // Wait for the audio thread to finish draining before the - // engine (and its decoders) is closed by this try block. + // Wait for the audio thread to finish before the engine (and + // its decoders) is closed by this try block. audio.join(2000); - MoviePlayer mp = moviePlayer; - if (mp != null) { - mp.onPlayListEnd(playListNumber); + // Only report end-of-stream if we're still the active playback; + // otherwise a stale stream would re-trigger the menu loop. + if (playbackGeneration == generation) { + MoviePlayer mp = moviePlayer; + if (mp != null) { + mp.onPlayListEnd(playListNumber); + } } } catch (Exception e) { e.printStackTrace(); @@ -296,13 +326,16 @@ public final class PlayerApp extends Application { } /** Plays decoded PCM through the system audio line, acting as the master A/V clock. */ - private Thread startAudio(PlaybackEngine engine) { + private Thread startAudio(PlaybackEngine engine, long generation) { Thread audio = new Thread(() -> { SourceDataLine line = null; try { line = openLine(); PlaybackEngine.AudioChunk chunk; while ((chunk = engine.nextAudioChunk()) != null) { + if (playbackGeneration != generation) { + break; + } if (clockStartPts < 0) { clockStartPts = chunk.pts(); clockStartNanos = System.nanoTime(); @@ -313,7 +346,12 @@ public final class PlayerApp extends Application { sleepUntil(targetNanos); line.write(chunk.pcm(), 0, chunk.pcm().length); } - line.drain(); + if (playbackGeneration != generation) { + // Superseded: cut off any buffered audio immediately. + line.flush(); + } else { + line.drain(); + } } catch (Exception e) { e.printStackTrace(); } finally {