Click-to-listen on the live wall + HTTP Range on /media (server-26#233)

Backend: /media/calls/{id}/audio now honours a single HTTP Range
(bytes=a-b, a-, -n) with 206 + Content-Range, 416 for unsatisfiable,
and a 200 full body (Accept-Ranges: bytes) otherwise. The body is
streamed from GCS in 512 KiB span reads (storage.audio_object_size +
download_audio_range) instead of downloading the whole object first,
so a 600 s FLAC starts after one span and seeks fetch only what they
need. HMAC check and Firestore lookup unchanged and still first.

Frontend: InlinePlayer moved to components/InlinePlayer.tsx and now
drives lib/audioQueue.ts, a useSyncExternalStore store over ONE audio
element (one call plays at a time). Wall spine rows get play buttons
and a "Play incident" queue (oldest first) with the playing row
highlighted; map path-stop popups get a play button and the playing
stop gets an accent ring; rail cards get "play latest". Playback is
gated by canPlayAudio() (BUSINESS_MODEL section 3 / Gate A hook).
Nothing plays until clicked. Spine truncation is now stated
("showing latest 30 of M", show-all toggle, count outside the loaded
window) for server-26#239.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:33:25 -04:00
co-authored by Claude Opus 5.5
parent a75149b2eb
commit c836986b89
8 changed files with 787 additions and 145 deletions
+89
View File
@@ -0,0 +1,89 @@
"use client";
/**
* Click-to-listen control for one call — server-26#233. Moved out of
* CallSpineEntry.tsx so the live wall's spine, the map's path-stop popups and
* rail cards share it. It no longer owns an <audio> element: playback goes
* through lib/audioQueue.ts's single element, so starting a call anywhere
* stops whatever else was playing, and the map can ring the stop that's
* speaking.
*
* Every hook runs before any early return (the server-26#109 item 2 bug was a
* return between hooks). Nothing plays until the button is clicked.
*/
import { useAuth } from "@/components/AuthProvider";
import { canPlayAudio, seek, toggle, useAudio } from "@/lib/audioQueue";
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${r.toString().padStart(2, "0")}`;
}
/** Just the round play/pause button — for map popups, rail cards, spine rows. */
export function PlayButton({ callId, label, className = "" }: { callId: string; label?: string; className?: string }) {
const auth = useAuth();
const active = useAudio((s) => s.playingCallId === callId);
const status = useAudio((s) => (s.playingCallId === callId ? s.status : "idle"));
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
if (!canPlayAudio(auth)) return null;
const playing = active && status === "playing";
const loading = active && status === "loading";
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(callId); }}
disabled={loading}
title={failed ? "Audio unavailable — click to retry" : playing ? "Pause" : label ?? "Play this call"}
aria-label={playing ? "Pause call audio" : label ?? "Play call audio"}
aria-pressed={playing}
className={`w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[10px] disabled:opacity-50 ${failed ? "bg-raised text-sev-major border border-sev-major" : "bg-accent text-white"} ${className}`}
>
{loading ? "…" : playing ? "❚❚" : failed ? "!" : "▶"}
</button>
);
}
/**
* Button + seek bar + clock. `compact` shows only the button until this call
* is the one playing, then expands — for dense lists like the wall's spine.
*/
export function InlinePlayer({ callId, compact = false }: { callId: string; compact?: boolean }) {
const auth = useAuth();
const active = useAudio((s) => s.playingCallId === callId);
const current = useAudio((s) => (s.playingCallId === callId ? s.current : 0));
const duration = useAudio((s) => (s.playingCallId === callId ? s.duration : 0));
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
if (!canPlayAudio(auth)) return null;
if (failed && !compact) {
return (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<PlayButton callId={callId} />
<span className="text-xs text-sev-major">Audio unavailable</span>
</div>
);
}
if (compact && !active) return <PlayButton callId={callId} />;
return (
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
<PlayButton callId={callId} />
<input
type="range"
min={0}
max={duration || 0}
step="any"
value={Math.min(current, duration || 0)}
onChange={(e) => seek(Number(e.target.value))}
className="flex-1 h-1 accent-accent"
disabled={!active || !duration}
aria-label="Seek"
/>
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
{fmtClock(current)}{duration ? ` / ${fmtClock(duration)}` : ""}
</span>
</div>
);
}