jevstrudel.git / website / src / jev / SeekBar.jsx
SeekBar.jsx71 lines · 3.4 KB · raw
1// The seek bar, at the top of the pane under the editor (seek.mjs): the
2// song's sections as one track. The part played is filled, the part Jev has
3// decided is solid, and the rest is dashed: clicking there buffers while Jev
4// is asked. Shown while one of the page's own songs with a jev() plays.
5import { useSyncExternalStore } from 'react';
6import { useBooth, usePlayhead } from './JevBooth.jsx';
7import { partyStore } from './party.mjs';
8import { sandbox } from './sandbox.mjs';
9import { decidedThrough, lastSection, seekStore, seekToSection } from './seek.mjs';
10import { themeColor as ACCENT } from './site.mjs';
11
12export function SeekBar({ context }) {
13  const booth = useBooth();
14  const cycle = usePlayhead(context.editorRef, context.started);
15  const { buffering } = useSyncExternalStore(seekStore.subscribe, seekStore.get, seekStore.get);
16  const party = useSyncExternalStore(partyStore.subscribe, partyStore.get, partyStore.get);
17  const sb = useSyncExternalStore(sandbox.subscribe, sandbox.get, sandbox.get);
18  const inParty = party.status !== 'off' && party.status !== 'ended';
19  if (!context.started || !booth?.jevs || !Number.isFinite(booth.segments) || inParty || sb.status === 'playing') return null;
20
21  const total = lastSection(booth) + 1;
22  const decided = decidedThrough(booth) + 1;
23  const played = Math.min(1, Math.max(0, (cycle ?? 0) / (total * booth.every)));
24  const name = (n) => (booth.form && booth.decisions[n]?.values?.[booth.form.name]?.choice) || `section ${n + 1}`;
25  const seek = (e) => {
26    const box = e.currentTarget.getBoundingClientRect();
27    seekToSection(context.editorRef.current, ((e.clientX - box.left) / box.width) * total);
28  };
29
30  return (
31    <div className="flex items-center gap-2 px-2 pt-1 font-mono text-xs" data-testid="jev-seek">
32      <div
33        role="slider"
34        aria-label="seek by section"
35        aria-valuemin={1}
36        aria-valuemax={total}
37        aria-valuenow={Math.min(total, Math.floor(played * total) + 1)}
38        tabIndex={0}
39        onClick={seek}
40        onKeyDown={(e) => {
41          const at = Math.floor(played * total);
42          if (e.key === 'ArrowLeft') seekToSection(context.editorRef.current, at - 1);
43          if (e.key === 'ArrowRight') seekToSection(context.editorRef.current, at + 1);
44        }}
45        className="relative grow h-4 cursor-pointer flex"
46        title="seek: click a section to jump to its start. Past what Jev has decided, the song waits while Jev is asked"
47      >
48        {Array.from({ length: total }, (_, n) => (
49          <div
50            key={n}
51            data-section={n}
52            title={n < decided ? name(n) : `section ${n + 1}: not decided yet`}
53            className="grow h-full border-r border-background last:border-r-0"
54            style={{
55              background: n < decided ? `${ACCENT}44` : 'transparent',
56              borderTop: n < decided ? `1px solid ${ACCENT}` : `1px dashed ${ACCENT}88`,
57              borderBottom: n < decided ? `1px solid ${ACCENT}` : `1px dashed ${ACCENT}88`,
58              outline: buffering === n ? `1px solid ${ACCENT}` : undefined,
59            }}
60          />
61        ))}
62        <div className="absolute inset-y-0 left-0 pointer-events-none" style={{ width: `${played * 100}%`, background: `${ACCENT}aa` }} />
63      </div>
64      {buffering !== null && (
65        <span className="shrink-0 animate-pulse" data-testid="jev-seek-buffering">
66          ⏳ asking Jev up to section {buffering + 1}…
67        </span>
68      )}
69    </div>
70  );
71}