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}