PlaybackModel.java
package org.hammer.audio.experimental.acoustic.workbench;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.experimental.acoustic.tracking.TrackingSnapshot;
/**
* Playback navigation model for a completed workbench run.
*
* <p>Holds a {@link WorkbenchRunResult} and tracks the currently selected frame. Listener
* notification is synchronous and runs on whatever thread calls the navigation methods. When used
* from a Swing UI all navigation calls should be made on the Event Dispatch Thread.
*
* <p>This class contains no Swing dependency and is therefore headless-testable.
*/
public final class PlaybackModel {
private final WorkbenchRunResult runResult;
private int currentFrameIndex;
private final List<FrameChangeListener> listeners = new ArrayList<>();
/** Notified when the current frame index changes. */
@FunctionalInterface
public interface FrameChangeListener {
/**
* Called after the current frame index has changed.
*
* @param frameIndex the new 0-based frame index
*/
void onFrameChanged(int frameIndex);
}
/**
* Create a playback model for the given result. The initial frame is 0 (or irrelevant for an
* empty result).
*
* @param result the completed run result; must not be {@code null}
*/
public PlaybackModel(WorkbenchRunResult result) {
this.runResult = Objects.requireNonNull(result, "result");
this.currentFrameIndex = 0;
}
/** The underlying run result. */
public WorkbenchRunResult result() {
return runResult;
}
/** Total number of frames (snapshots) in the run. */
public int frameCount() {
return runResult.snapshots().size();
}
/** {@code true} if the run produced no snapshots. */
public boolean isEmpty() {
return runResult.snapshots().isEmpty();
}
/** Current 0-based frame index. Always {@code 0} for an empty run. */
public int currentFrame() {
return currentFrameIndex;
}
/**
* Snapshot for the current frame, or {@code null} if the run is empty.
*
* @return current {@link TrackingSnapshot}, or {@code null}
*/
public TrackingSnapshot currentSnapshot() {
if (isEmpty()) {
return null;
}
return runResult.snapshots().get(currentFrameIndex);
}
/**
* Seek to the given frame index. The value is silently clamped to {@code [0, frameCount()-1]}.
* Listeners are notified only when the index actually changes. No-op for an empty run.
*
* @param frameIndex desired 0-based frame index
*/
public void seekTo(int frameIndex) {
if (isEmpty()) {
return;
}
int clamped = Math.max(0, Math.min(frameIndex, frameCount() - 1));
if (clamped != currentFrameIndex) {
currentFrameIndex = clamped;
notifyListeners();
}
}
/**
* Advance to the next frame. No-op when already at the last frame or the run is empty.
*
* @return {@code true} if the frame index changed
*/
public boolean stepForward() {
if (isEmpty() || currentFrameIndex >= frameCount() - 1) {
return false;
}
currentFrameIndex++;
notifyListeners();
return true;
}
/**
* Go back to the previous frame. No-op when already at frame 0 or the run is empty.
*
* @return {@code true} if the frame index changed
*/
public boolean stepBack() {
if (isEmpty() || currentFrameIndex <= 0) {
return false;
}
currentFrameIndex--;
notifyListeners();
return true;
}
/** Seek to the first frame (index 0). No-op for an empty run. */
public void first() {
seekTo(0);
}
/** Seek to the last frame. No-op for an empty run. */
public void last() {
if (!isEmpty()) {
seekTo(frameCount() - 1);
}
}
/** {@code true} if the current frame is the first frame (or the run is empty). */
public boolean isAtFirst() {
return isEmpty() || currentFrameIndex == 0;
}
/** {@code true} if the current frame is the last frame (or the run is empty). */
public boolean isAtLast() {
return isEmpty() || currentFrameIndex == frameCount() - 1;
}
/**
* Register a listener for frame changes.
*
* @param listener must not be {@code null}
*/
public void addListener(FrameChangeListener listener) {
listeners.add(Objects.requireNonNull(listener, "listener"));
}
/**
* Unregister a previously registered listener.
*
* @param listener listener to remove; ignored if not registered
*/
public void removeListener(FrameChangeListener listener) {
listeners.remove(listener);
}
private void notifyListeners() {
for (FrameChangeListener listener : List.copyOf(listeners)) {
listener.onFrameChanged(currentFrameIndex);
}
}
}