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 a0abcca..7995ce0 100644 --- a/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java +++ b/core/src/main/java/dev/quoije/umd/player/PlaybackEngine.java @@ -9,7 +9,9 @@ import dev.quoije.umd.mps.AudioFramer; import dev.quoije.umd.mps.MpsDemuxer; import java.io.IOException; -import java.util.ArrayDeque; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; /** * Decodes an MPS stream into timestamped video frames and PCM audio. @@ -47,9 +49,12 @@ public final class PlaybackEngine implements AutoCloseable { private final AnnexBSplitter videoSplitter = new AnnexBSplitter(); private final AudioFramer audioFramer = new AudioFramer(); + private static final int VIDEO_QUEUE_CAPACITY = 16; + private static final int AUDIO_QUEUE_CAPACITY = 64; + private Atrac3pDecoder audioDecoder; - private final ArrayDeque videoQueue = new ArrayDeque<>(); - private final ArrayDeque audioQueue = new ArrayDeque<>(); + private final BlockingQueue videoQueue = new LinkedBlockingQueue<>(VIDEO_QUEUE_CAPACITY); + private final BlockingQueue audioQueue = new LinkedBlockingQueue<>(AUDIO_QUEUE_CAPACITY); private long pendingVideoPts = -1; private long nextVideoPts = 0; @@ -59,71 +64,98 @@ public final class PlaybackEngine implements AutoCloseable { private boolean audioPtsSet = false; private long audioFramesEmitted = 0; - private boolean eof = false; + private volatile boolean eof = false; + private volatile IOException failure = null; + private final Thread decodeThread; private PlaybackEngine(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException { this.demuxer = MpsDemuxer.open(iso, streamName); this.audioSubstream = audioSubstream; + this.decodeThread = new Thread(this::decodeLoop, "umd-decode-" + streamName); + this.decodeThread.setDaemon(true); } - /** Opens the given playlist stream for playback, using the given audio substream (0 or 1). */ + /** Opens the given playlist stream and starts decoding in the background. */ public static PlaybackEngine open(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException { - return new PlaybackEngine(iso, streamName, audioSubstream); + PlaybackEngine engine = new PlaybackEngine(iso, streamName, audioSubstream); + engine.decodeThread.start(); + return engine; } - /** - * Decodes until the next video frame is available, or end of stream. - * Returns {@code null} at end of stream. - */ - public synchronized VideoFrame nextVideoFrame() throws IOException { - int budget = 1000; - while (videoQueue.isEmpty() && budget-- > 0) { - if (!routeOne()) { - break; + /** The single decode thread: routes every packet and fills both queues. */ + private void decodeLoop() { + try { + while (true) { + MpsDemuxer.Packet packet = demuxer.nextPacket(); + if (packet == null) { + finish(); + break; + } + if (packet.isVideo()) { + handleVideo(packet); + } else if (packet.isAudio() && packet.channel() == audioSubstream) { + handleAudio(packet); + } + } + } catch (InterruptedException e) { + // Closed; stop decoding. + } catch (IOException e) { + failure = e; + } finally { + eof = true; + } + } + + /** Waits for the next video frame, or returns {@code null} at end of stream. */ + public VideoFrame nextVideoFrame() throws IOException { + while (true) { + VideoFrame frame; + try { + frame = videoQueue.poll(50, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + if (frame != null) { + return frame; + } + if (eof && videoQueue.isEmpty()) { + if (failure != null) { + throw failure; + } + return null; } } - return videoQueue.poll(); } - /** - * Decodes until the next audio chunk is available, or end of stream. - * Returns {@code null} at end of stream. - */ - public synchronized AudioChunk nextAudioChunk() throws IOException { - int budget = 1000; - while (audioQueue.isEmpty() && budget-- > 0) { - if (!routeOne()) { - break; + /** Waits for the next audio chunk, or returns {@code null} at end of stream. */ + public AudioChunk nextAudioChunk() throws IOException { + while (true) { + AudioChunk chunk; + try { + chunk = audioQueue.poll(50, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + if (chunk != null) { + return chunk; + } + if (eof && audioQueue.isEmpty()) { + if (failure != null) { + throw failure; + } + return null; } } - return audioQueue.poll(); } - /** True once the stream has been fully decoded. */ - public synchronized boolean isEof() { + /** True once the stream has been fully decoded and drained. */ + public boolean isEof() { return eof && videoQueue.isEmpty() && audioQueue.isEmpty(); } - /** Reads and routes one packet. Returns false at end of stream. */ - private boolean routeOne() throws IOException { - if (eof) { - return false; - } - MpsDemuxer.Packet packet = demuxer.nextPacket(); - if (packet == null) { - finish(); - eof = true; - return false; - } - if (packet.isVideo()) { - handleVideo(packet); - } else if (packet.isAudio() && packet.channel() == audioSubstream) { - handleAudio(packet); - } - return true; - } - - private void handleVideo(MpsDemuxer.Packet packet) { + private void handleVideo(MpsDemuxer.Packet packet) throws InterruptedException { if (packet.pts() >= 0) { if (pendingVideoPts < 0) { nextVideoPts = packet.pts(); @@ -137,7 +169,7 @@ public final class PlaybackEngine implements AutoCloseable { videoDecoder.feedAccessUnit(accessUnit); VideoDecoder.Frame frame; while ((frame = videoDecoder.receive()) != null) { - videoQueue.add(new VideoFrame( + videoQueue.put(new VideoFrame( assignVideoPts(), frame.width(), frame.height(), frame.pixelFormat(), frame.yPlane(), frame.yStride(), frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride())); @@ -167,7 +199,7 @@ public final class PlaybackEngine implements AutoCloseable { return videoFrameDurationTicks; } - private void handleAudio(MpsDemuxer.Packet packet) { + private void handleAudio(MpsDemuxer.Packet packet) throws InterruptedException { if (packet.pts() >= 0) { if (!audioPtsSet) { audioFirstPts = packet.pts(); @@ -191,12 +223,12 @@ public final class PlaybackEngine implements AutoCloseable { audioDecoder.feedFrame(frame); byte[] pcm; while ((pcm = audioDecoder.receive()) != null) { - audioQueue.add(new AudioChunk(pts, pcm)); + audioQueue.put(new AudioChunk(pts, pcm)); } } } - private void finish() { + private void finish() throws InterruptedException { byte[] tail = videoSplitter.drain(); if (tail != null && tail.length > 0) { videoDecoder.feedAccessUnit(tail); @@ -204,7 +236,7 @@ public final class PlaybackEngine implements AutoCloseable { videoDecoder.flush(); VideoDecoder.Frame frame; while ((frame = videoDecoder.receive()) != null) { - videoQueue.add(new VideoFrame( + videoQueue.put(new VideoFrame( assignVideoPts(), frame.width(), frame.height(), frame.pixelFormat(), frame.yPlane(), frame.yStride(), frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride())); @@ -214,7 +246,7 @@ public final class PlaybackEngine implements AutoCloseable { audioDecoder.flush(); byte[] pcm; while ((pcm = audioDecoder.receive()) != null) { - audioQueue.add(new AudioChunk( + audioQueue.put(new AudioChunk( audioFirstPts + audioFramesEmitted * AUDIO_FRAME_TICKS, pcm)); } } @@ -222,6 +254,12 @@ public final class PlaybackEngine implements AutoCloseable { @Override public void close() { + decodeThread.interrupt(); + try { + decodeThread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } videoDecoder.close(); if (audioDecoder != null) { audioDecoder.close(); 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 fd57c1d..e3d1951 100644 --- a/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java +++ b/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java @@ -64,6 +64,7 @@ public final class PlayerApp extends Application { private final WritableImage[] videoImages = new WritableImage[2]; private final int[][] argbBuffers = new int[2][]; private int videoBufferIndex = 0; + private volatile boolean skipRequested = false; @Override public void start(Stage stage) { @@ -114,6 +115,10 @@ public final class PlayerApp extends Application { togglePause(); return; } + case S, TRACK_NEXT -> { + skipRequested = true; + return; + } default -> { } } @@ -202,6 +207,10 @@ public final class PlayerApp extends Application { PlaybackEngine.VideoFrame frame; while ((frame = engine.nextVideoFrame()) != null) { + if (skipRequested) { + skipRequested = false; + break; + } int index = videoBufferIndex++ & 1; int[] argb = argbBuffers[index]; int pixelCount = frame.width() * frame.height();