Stop previous stream when a new one starts; ease animations

Starting a movie now closes the current decode engine and bumps a playback
generation so the stale video/audio threads stop themselves (they were
overlapping the movie's canvas and audio). PlaybackEngine.close is idempotent.
Animation records now honor their accelMode (linear/accel/decel easing).
This commit is contained in:
2026-09-16 12:20:47 -04:00
parent 0531671d26
commit ba21426b77
9 changed files with 103 additions and 20 deletions
+24 -5
View File
@@ -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 menu, and play the movie (H.264 video + ATRAC3+ audio) with mouse and
keyboard/media-key controls. keyboard/media-key controls.
> **Status:** early development. Currently implemented: ISO 9660/UMD reading, > **Status:** playable. Opens a UMD Video ISO, plays the real RCO menu (rendered
> `param.sfo` metadata parsing, and file listing. > from the disc), and plays the movie (H.264 video + ATRAC3+ audio).
## Build ## Build
Requires JDK 21 (Gradle auto-provisions it via the Foojay toolchain resolver). Requires JDK 21 (Gradle auto-provisions it).
```sh ```sh
./gradlew build ./gradlew build
``` ```
## Run ## Run the player
```sh ```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 <iso> UMD_VIDEO/RESOURCE/EN100000.RCO" # menu data
./gradlew cli:run --args="play <iso> <streamName>" # headless A/V sync test
``` ```
## License ## License
@@ -66,6 +66,7 @@ public final class PlaybackEngine implements AutoCloseable {
private volatile boolean eof = false; private volatile boolean eof = false;
private volatile IOException failure = null; private volatile IOException failure = null;
private volatile boolean closed = false;
private final Thread decodeThread; private final Thread decodeThread;
private PlaybackEngine(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException { private PlaybackEngine(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException {
@@ -254,6 +255,10 @@ public final class PlaybackEngine implements AutoCloseable {
@Override @Override
public void close() { public void close() {
if (closed) {
return;
}
closed = true;
decodeThread.interrupt(); decodeThread.interrupt();
try { try {
decodeThread.join(1000); decodeThread.join(1000);
@@ -26,7 +26,7 @@ public class FadeAnim extends BaseAnim {
return; return;
} }
float sa = target.alphaScale.getFloatValue(); 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.alphaScale.setFloatValue(sa + (ta - sa) * p);
target.onDisplayUpdated(); target.onDisplayUpdated();
})); }));
@@ -34,7 +34,7 @@ public class MoveToAnim extends BaseAnim {
float sx = target.animX; float sx = target.animX;
float sy = target.animY; float sy = target.animY;
float sz = target.animZ; 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.animX = sx + (tx - sx) * p;
target.animY = sy + (ty - sy) * p; target.animY = sy + (ty - sy) * p;
target.animZ = sz + (tz - sz) * p; target.animZ = sz + (tz - sz) * p;
@@ -38,7 +38,7 @@ public class RecolourAnim extends BaseAnim {
float sg = target.greenScale.getFloatValue(); float sg = target.greenScale.getFloatValue();
float sb = target.blueScale.getFloatValue(); float sb = target.blueScale.getFloatValue();
float sa = target.alphaScale.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.redScale.setFloatValue(sr + (tr - sr) * p);
target.greenScale.setFloatValue(sg + (tg - sg) * p); target.greenScale.setFloatValue(sg + (tg - sg) * p);
target.blueScale.setFloatValue(sb + (tb - sb) * p); target.blueScale.setFloatValue(sb + (tb - sb) * p);
@@ -34,7 +34,7 @@ public class ResizeAnim extends BaseAnim {
float sw = target.scaleWidth.getFloatValue(); float sw = target.scaleWidth.getFloatValue();
float sh = target.scaleHeight.getFloatValue(); float sh = target.scaleHeight.getFloatValue();
float sd = target.scaleDepth.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.scaleWidth.setFloatValue(sw + (tw - sw) * p);
target.scaleHeight.setFloatValue(sh + (th - sh) * p); target.scaleHeight.setFloatValue(sh + (th - sh) * p);
target.scaleDepth.setFloatValue(sd + (td - sd) * p); target.scaleDepth.setFloatValue(sd + (td - sd) * p);
@@ -34,7 +34,7 @@ public class RotateAnim extends BaseAnim {
float sx = target.rotX; float sx = target.rotX;
float sy = target.rotY; float sy = target.rotY;
float sz = target.rotZ; 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.rotX = sx + (tx - sx) * p;
target.rotY = sy + (ty - sy) * p; target.rotY = sy + (ty - sy) * p;
target.rotZ = sz + (tz - sz) * p; target.rotZ = sz + (tz - sz) * p;
@@ -3,10 +3,17 @@ package dev.quoije.umd.rco.anim;
/** /**
* A single interpolation from 0..1 over a duration in milliseconds. The * 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 { 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. */ /** Receives progress 0..1 on every animation tick. */
@FunctionalInterface @FunctionalInterface
public interface ProgressCallback { public interface ProgressCallback {
@@ -15,12 +22,18 @@ public final class Tween {
private final long startNanos; private final long startNanos;
private final long durationNanos; private final long durationNanos;
private final int accelMode;
private final ProgressCallback callback; private final ProgressCallback callback;
private boolean finished; private boolean finished;
public Tween(long durationMillis, ProgressCallback callback) { public Tween(long durationMillis, ProgressCallback callback) {
this(durationMillis, EASE_LINEAR, callback);
}
public Tween(long durationMillis, int accelMode, ProgressCallback callback) {
this.startNanos = System.nanoTime(); this.startNanos = System.nanoTime();
this.durationNanos = Math.max(1L, durationMillis) * 1_000_000L; this.durationNanos = Math.max(1L, durationMillis) * 1_000_000L;
this.accelMode = accelMode;
this.callback = callback; this.callback = callback;
} }
@@ -31,7 +44,15 @@ public final class Tween {
progress = 1f; progress = 1f;
finished = true; finished = true;
} }
callback.apply(progress); callback.apply(ease(progress, accelMode));
return finished; 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;
};
}
} }
@@ -72,6 +72,11 @@ public final class PlayerApp extends Application {
private volatile VsmxNativeObject controllerObject; private volatile VsmxNativeObject controllerObject;
private long lastMenuKeyNanos = 0; 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 @Override
public void start(Stage stage) { public void start(Stage stage) {
// Gradle's --args splits on whitespace; rejoin so paths with spaces work. // Gradle's --args splits on whitespace; rejoin so paths with spaces work.
@@ -200,6 +205,12 @@ public final class PlayerApp extends Application {
@Override @Override
public void stopMovie() { public void stopMovie() {
PlaybackEngine engine = currentEngine;
if (engine != null) {
playbackGeneration++;
engine.close();
currentEngine = null;
}
} }
@Override @Override
@@ -230,17 +241,29 @@ public final class PlayerApp extends Application {
private void startPlayback(String isoPath, Playlist.Stream stream, int playListNumber) { private void startPlayback(String isoPath, Playlist.Stream stream, int playListNumber) {
String streamName = stream.name(); 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(() -> { Thread player = new Thread(() -> {
try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath)); try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath));
PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) { PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) {
currentEngine = engine;
// Each stream has its own PTS timeline, so re-anchor the master // Each stream has its own PTS timeline, so re-anchor the master
// clock to this stream's first audio chunk. // clock to this stream's first audio chunk.
clockStartPts = -1; clockStartPts = -1;
clockStartNanos = -1; clockStartNanos = -1;
clockOffsetNanos = 0; clockOffsetNanos = 0;
Thread audio = startAudio(engine); Thread audio = startAudio(engine, generation);
awaitClock(); awaitClock();
long basePts = clockStartPts; long basePts = clockStartPts;
long baseNanos = clockStartNanos; long baseNanos = clockStartNanos;
@@ -248,6 +271,9 @@ public final class PlayerApp extends Application {
PlaybackEngine.VideoFrame frame; PlaybackEngine.VideoFrame frame;
int lastChapter = -2; int lastChapter = -2;
while ((frame = engine.nextVideoFrame()) != null) { while ((frame = engine.nextVideoFrame()) != null) {
if (playbackGeneration != generation) {
break;
}
if (skipRequested) { if (skipRequested) {
skipRequested = false; skipRequested = false;
break; break;
@@ -279,13 +305,17 @@ public final class PlayerApp extends Application {
sleepUntil(targetNanos); sleepUntil(targetNanos);
} }
// Wait for the audio thread to finish draining before the // Wait for the audio thread to finish before the engine (and
// engine (and its decoders) is closed by this try block. // its decoders) is closed by this try block.
audio.join(2000); audio.join(2000);
MoviePlayer mp = moviePlayer; // Only report end-of-stream if we're still the active playback;
if (mp != null) { // otherwise a stale stream would re-trigger the menu loop.
mp.onPlayListEnd(playListNumber); if (playbackGeneration == generation) {
MoviePlayer mp = moviePlayer;
if (mp != null) {
mp.onPlayListEnd(playListNumber);
}
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); 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. */ /** 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(() -> { Thread audio = new Thread(() -> {
SourceDataLine line = null; SourceDataLine line = null;
try { try {
line = openLine(); line = openLine();
PlaybackEngine.AudioChunk chunk; PlaybackEngine.AudioChunk chunk;
while ((chunk = engine.nextAudioChunk()) != null) { while ((chunk = engine.nextAudioChunk()) != null) {
if (playbackGeneration != generation) {
break;
}
if (clockStartPts < 0) { if (clockStartPts < 0) {
clockStartPts = chunk.pts(); clockStartPts = chunk.pts();
clockStartNanos = System.nanoTime(); clockStartNanos = System.nanoTime();
@@ -313,7 +346,12 @@ public final class PlayerApp extends Application {
sleepUntil(targetNanos); sleepUntil(targetNanos);
line.write(chunk.pcm(), 0, chunk.pcm().length); 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) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} finally { } finally {