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