bambu-rs 0.1.0

AI-agent-friendly Bambu Lab 3D printer CLI & library
Documentation
import { useEffect, useRef, useState } from "react";
import { listParks, type ParkIndex } from "../cameras";

// How often to re-poll the filmstrip index (picks up new frames while a run is live),
// and the playback rate when "play" is pressed.
const POLL_MS = 1500;
const PLAY_FPS = 6;

// A video-player-style review of one camera's park filmstrip: scrub past frames, step
// frame-by-frame, play through them, or follow the live tip as the run captures more.
// Frames are the indexed `/park/{n}` JPEGs; the index (`/parks`) is polled so it works
// both during a run and after it stops (until the next run replaces it).
export function ParkPlayer({ id }: { id: string }) {
  const [idx, setIdx] = useState<ParkIndex>({ running: false, count: 0, parks: [] });
  const [cur, setCur] = useState(0);
  const [playing, setPlaying] = useState(false);
  // Follow the live tip: stay pinned to the newest frame as new ones arrive. Set false
  // the moment the user scrubs/steps back, true again when they return to the end.
  const [atLive, setAtLive] = useState(true);
  // Bumped each poll; used only to bust the cache on the LIVE tip so a freshly-written
  // (or replaced) frame refreshes, while past frames keep a stable, cacheable src.
  const [tick, setTick] = useState(0);

  const count = idx.count;
  const last = Math.max(0, count - 1);

  // Mirror `cur` into a ref so the playback interval reads the latest without re-arming.
  const curRef = useRef(0);
  useEffect(() => {
    curRef.current = cur;
  }, [cur]);

  // Poll the filmstrip index (live during a run, frozen after it stops).
  useEffect(() => {
    let live = true;
    const poll = async () => {
      const s = await listParks(id);
      if (!live) return;
      setIdx(s);
      setTick((t) => t + 1);
    };
    void poll();
    const h = setInterval(() => void poll(), POLL_MS);
    return () => {
      live = false;
      clearInterval(h);
    };
  }, [id]);

  // Keep `cur` valid as the strip grows/shrinks: pinned to the tip while following,
  // otherwise clamped into range.
  useEffect(() => {
    if (count === 0) {
      setCur(0);
      return;
    }
    setCur((c) => (atLive ? last : Math.min(c, last)));
  }, [count, atLive, last]);

  // Playback: advance ~PLAY_FPS. At the end, tail the live tip if the run is still going
  // (sit at the newest, jump forward as frames arrive); else stop — a finished strip
  // doesn't loop. setState stays out of the updater (curRef drives it) to keep it clean.
  useEffect(() => {
    if (!playing || count === 0) return;
    const h = setInterval(() => {
      const c = curRef.current;
      if (c >= last) {
        if (idx.running) setAtLive(true);
        else setPlaying(false);
        return;
      }
      const nx = c + 1;
      setCur(nx);
      if (nx >= last) setAtLive(true);
    }, 1000 / PLAY_FPS);
    return () => clearInterval(h);
  }, [playing, count, last, idx.running]);

  const goto = (n: number) => {
    const v = Math.min(last, Math.max(0, n));
    setCur(v);
    setAtLive(v >= last);
  };
  const first = () => {
    setPlaying(false);
    goto(0);
  };
  const prev = () => {
    setPlaying(false);
    goto(cur - 1);
  };
  const next = () => goto(cur + 1);
  const latest = () => {
    setAtLive(true);
    setCur(last);
  };
  const togglePlay = () => {
    if (playing) {
      setPlaying(false);
      return;
    }
    // Pressing play while parked at the tip (latest) replays from the start — otherwise
    // there'd be nothing to play. From mid-strip it just resumes from here.
    if (cur >= last) {
      setAtLive(false);
      setCur(0);
    }
    setPlaying(true);
  };

  if (count === 0) {
    return (
      <div className="pp pp--empty" data-testid="park-player">
        <div className="cam__msg" data-testid="park-empty">
          {idx.running ? "waiting for the first park…" : "no park frames — start a park run"}
        </div>
      </div>
    );
  }

  const liveTip = atLive && idx.running;
  const meta = idx.parks[cur];
  // `cur` is the scrubber POSITION; the frame URL needs the entry's real index `n`, which
  // can be sparse (the index parser skips malformed lines), so don't assume n === position.
  const frameN = meta?.n ?? cur;
  // Frames are the indexed `/park/{n}` JPEGs (the index lives at `/park`).
  const src = `/api/camera/${id}/park/${frameN}${liveTip ? `?t=${tick}` : ""}`;

  return (
    <div className="pp" data-testid="park-player">
      <img
        className="pp__frame"
        src={src}
        alt={`park frame ${cur + 1} of ${count}`}
        data-testid="park-frame"
        data-n={frameN}
      />
      <div className="pp__bar" data-testid="park-transport">
        <button
          className="pp__btn"
          data-testid="park-first"
          title="first frame"
          aria-label="first frame"
          disabled={cur === 0}
          onClick={first}
        >
          ⏮
        </button>
        <button
          className="pp__btn"
          data-testid="park-prev"
          title="previous frame"
          aria-label="previous frame"
          disabled={cur === 0}
          onClick={prev}
        >
          ◀
        </button>
        <button
          className="pp__btn pp__btn--play"
          data-testid="park-play"
          title={playing ? "pause" : "play"}
          aria-label={playing ? "pause" : "play"}
          onClick={togglePlay}
        >
          {playing ? "⏸" : "▶"}
        </button>
        <button
          className="pp__btn"
          data-testid="park-next"
          title="next frame"
          aria-label="next frame"
          disabled={cur >= last}
          onClick={next}
        >
          ▶
        </button>
        <button
          className="pp__btn"
          data-testid="park-latest"
          title="latest frame"
          aria-label="latest frame"
          disabled={cur >= last && !idx.running}
          onClick={latest}
        >
          ⏭
        </button>
        <input
          className="pp__scrub"
          type="range"
          min={0}
          max={last}
          value={cur}
          data-testid="park-scrub"
          aria-label="scrub park frames"
          onChange={(e) => goto(Number(e.target.value))}
        />
        <span className="pp__count" data-testid="park-count">
          {cur + 1} / {count}
        </span>
        {liveTip && (
          <span className="pp__live" data-testid="park-live">
            ● live
          </span>
        )}
      </div>
      {meta?.t != null && (
        <div className="pp__meta dim" data-testid="park-meta">
          t={meta.t.toFixed(1)}s
          {meta.confidence != null ? ` · conf ${meta.confidence.toFixed(2)}` : ""}
        </div>
      )}
    </div>
  );
}