RecordingStatusPanel.java

package org.hammer;

import java.awt.FlowLayout;
import java.awt.event.ActionListener;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Locale;
import java.util.Objects;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import org.hammer.audio.recording.runtime.RecordingState;
import org.hammer.audio.recording.runtime.RecordingStatus;
import org.hammer.audio.recording.runtime.RecordingStorageLevel;

/** Persistent non-modal recording health strip for the desktop workbench. */
public final class RecordingStatusPanel extends JPanel {

  private static final long serialVersionUID = 1L;

  private final JLabel state = new JLabel("● Idle");
  private final JLabel elapsed = new JLabel("00:00:00");
  private final JLabel destination = new JLabel("—");
  private final JLabel size = new JLabel("0 B");
  private final JLabel free = new JLabel("free: unknown");
  private final JLabel rate = new JLabel("0 B/s");
  private final JLabel remaining = new JLabel("remaining: unknown");
  private final JLabel completeness = new JLabel("complete");
  private final JButton stopButton = new JButton("Stop recording");

  /** Create an initially idle status strip. */
  public RecordingStatusPanel() {
    super(new FlowLayout(FlowLayout.LEFT, 10, 3));
    setBorder(BorderFactory.createTitledBorder("Experiment recording"));
    add(state);
    add(elapsed);
    add(destination);
    add(size);
    add(free);
    add(rate);
    add(remaining);
    add(completeness);
    add(stopButton);
    stopButton.setEnabled(false);
    setVisible(false);
  }

  /** Configure the action invoked by the persistent stop control. */
  public void setStopAction(Runnable action) {
    Objects.requireNonNull(action, "action");
    for (ActionListener listener : stopButton.getActionListeners()) {
      stopButton.removeActionListener(listener);
    }
    stopButton.addActionListener(event -> action.run());
  }

  /** Render one immutable recording status snapshot. Must be called on the Swing EDT. */
  public void updateStatus(RecordingStatus status) {
    Objects.requireNonNull(status, "status");
    setVisible(true);
    state.setText(stateLabel(status.state()));
    elapsed.setText(formatDuration(status.elapsed()));
    Path fileName = status.destination().getFileName();
    destination.setText(fileName == null ? status.destination().toString() : fileName.toString());
    destination.setToolTipText(status.destination().toString());
    size.setText(formatBytes(status.bytesWritten()));
    if (status.storage().usableBytes() >= 0L) {
      free.setText("free: " + formatBytes(status.storage().usableBytes()));
    } else {
      free.setText("free: unknown");
    }
    rate.setText(formatBytes((long) status.measuredBytesPerSecond()) + "/s");
    remaining.setText(formatRemaining(status.storage().estimatedSafeSecondsRemaining()));
    completeness.setText(completenessLabel(status));
    completeness.setToolTipText(detail(status));
    stopButton.setEnabled(!status.terminal() && status.state() != RecordingState.STOPPING);
    RecordingStorageLevel storageLevel = status.storage().level();
    state.setToolTipText(
        storageLevel == RecordingStorageLevel.NORMAL
            ? "Recording storage is healthy."
            : "Storage status: " + storageLevel + " — " + status.storage().errorMessage());
    revalidate();
    repaint();
  }

  private static String stateLabel(RecordingState value) {
    return switch (value) {
      case STARTING -> "● Starting";
      case RECORDING -> "● Recording";
      case STOPPING -> "● Stopping";
      case COMPLETED -> "✓ Completed";
      case INCOMPLETE -> "⚠ Incomplete";
      case FAILED -> "✕ Failed";
    };
  }

  private static String completenessLabel(RecordingStatus status) {
    if (status.errorMessage().isBlank()) {
      return String.format(
          Locale.ROOT,
          "blocks %d/%d · dropped %d · gaps %d · queue %d/%d",
          status.writtenBlocks(),
          status.receivedBlocks(),
          status.droppedBlocks(),
          status.continuityGapCount(),
          status.queueDepth(),
          status.queueCapacity());
    }
    return "error: " + status.errorMessage();
  }

  private static String detail(RecordingStatus status) {
    String reason =
        status.stopReason().isBlank() ? "No stop reason recorded." : status.stopReason();
    return reason
        + " Written frames: "
        + status.writtenFrames()
        + "/"
        + status.receivedFrames()
        + ".";
  }

  private static String formatDuration(Duration duration) {
    long seconds = Math.max(0L, duration.toSeconds());
    return String.format(
        Locale.ROOT, "%02d:%02d:%02d", seconds / 3600L, (seconds / 60L) % 60L, seconds % 60L);
  }

  private static String formatRemaining(long seconds) {
    if (seconds < 0L) {
      return "remaining: unknown";
    }
    return "remaining: " + formatDuration(Duration.ofSeconds(seconds));
  }

  private static String formatBytes(long bytes) {
    if (bytes < 1024L) {
      return bytes + " B";
    }
    double value = bytes;
    String[] units = {"KiB", "MiB", "GiB", "TiB"};
    int unit = -1;
    do {
      value /= 1024.0;
      unit++;
    } while (value >= 1024.0 && unit + 1 < units.length);
    return String.format(Locale.ROOT, "%.1f %s", value, units[unit]);
  }
}