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
+4 -94
View File
@@ -8,105 +8,15 @@
* memorable thing per §2.4: "where was he when he said that" is answered
* by looking, not cross-referencing timestamps.
*/
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api";
import { InlinePlayer } from "@/components/InlinePlayer";
import { hasAudio as callHasAudio } from "@/lib/audioQueue";
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
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")}`;
}
function InlinePlayer({ callId }: { callId: string }) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
async function ensureUrl() {
if (url || loading) return;
setLoading(true);
try {
const full = await c2api.getCall(callId);
setUrl(full.audio_url ?? null);
if (!full.audio_url) setError(true);
} catch {
setError(true);
} finally {
setLoading(false);
}
}
async function toggle() {
if (!url) {
await ensureUrl();
return;
}
const el = audioRef.current;
if (!el) return;
if (playing) el.pause();
else el.play();
}
// Once the URL lands, autoplay (the click that fetched it was the play intent).
useEffect(() => {
if (url && audioRef.current) audioRef.current.play().catch(() => {});
}, [url]);
if (error) {
return <span className="text-xs text-sev-major">Audio unavailable</span>;
}
return (
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
<button
onClick={toggle}
disabled={loading}
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
title={playing ? "Pause" : "Play"}
>
{loading ? "…" : playing ? "❚❚" : "▶"}
</button>
<input
type="range"
min={0}
max={duration || 0}
value={current}
onChange={(e) => {
const t = Number(e.target.value);
if (audioRef.current) audioRef.current.currentTime = t;
setCurrent(t);
}}
className="flex-1 h-1 accent-accent"
disabled={!url}
/>
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
{fmtClock(current)}
</span>
{url && (
<audio
ref={audioRef}
src={url}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
className="hidden"
/>
)}
</div>
);
}
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
if (!stopNumber) {
@@ -132,7 +42,7 @@ export function CallSpineEntry({
isAdmin?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
const hasAudio = callHasAudio(call);
const transcript = call.transcript_corrected || call.transcript;
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
+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>
);
}
+56 -25
View File
@@ -29,6 +29,8 @@ import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Ev
import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
import { PlayButton } from "@/components/InlinePlayer";
import { hasAudio, useAudio } from "@/lib/audioQueue";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -788,20 +790,26 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
}
// ── Incident path stop marker — numbered, shared index with the call spine ───
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon {
const size = 22;
// `playing` (server-26#233): the stop whose call is on the speaker gets a
// thick accent ring outside the severity halo — "where was he when he said
// that", answered while it is being said.
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string, playing = false): L.DivIcon {
const size = playing ? 30 : 22;
const c = size / 2;
const fill = isFirst ? "var(--surface)" : color;
const textColor = isFirst ? color : "var(--page)";
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
const halo = isLast ? `<circle cx="${c}" cy="${c}" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
const ring = playing ? `<circle cx="${c}" cy="${c}" r="13.5" fill="none" stroke="var(--accent)" stroke-width="3"/>` : "";
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22">
html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${ring}
${halo}
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
<circle cx="${c}" cy="${c}" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
<text x="${c}" y="${c + 3.5}" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
</svg>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
iconAnchor: [c, c],
});
}
@@ -1023,6 +1031,8 @@ function IncidentPathLayer({
incidents: IncidentRecord[];
callsByIncident: Map<string, CallRecord[]>;
}) {
// Only the playing id, so a timeupdate never re-renders the map.
const playingCallId = useAudio((s) => s.playingCallId);
return (
<>
{incidents.map((inc) => {
@@ -1043,13 +1053,20 @@ function IncidentPathLayer({
<Marker
key={c.call_id}
position={[c.location_coords!.lat, c.location_coords!.lng]}
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)}
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color, c.call_id === playingCallId)}
zIndexOffset={c.call_id === playingCallId ? 1000 : 0}
>
<Popup minWidth={160}>
<div className="text-gray-900">
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
{hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={`Play stop ${i + 1}`} />
<span className="text-xs text-gray-600">Play this call</span>
</div>
)}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident →
</a>
@@ -1388,6 +1405,11 @@ export default function MapView({
const live = onAir?.has(inc.incident_id) ?? false;
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
const callCount = inc.call_ids?.length ?? 0;
// "Play latest" (server-26#233): newest call of this incident in
// the loaded calls that has a recording.
const latest = (callsByIncident.get(inc.incident_id) ?? [])
.filter(hasAudio)
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
const cardBody = (
<>
@@ -1425,28 +1447,37 @@ export default function MapView({
)}
</>
);
// The play control is a sibling of the card, not inside it — a
// <button> can't nest in the card's <button>/<a>.
const playLatest = latest && (
<PlayButton callId={latest.call_id} label="Play latest call" className="absolute bottom-2 right-2 shadow" />
);
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected}
className={baseClass}
style={{ borderColor: color + "55" }}
>
{cardBody}
</button>
<div key={inc.incident_id} className="relative shrink-0">
<button
onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected}
className={`${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</button>
{playLatest}
</div>
);
}
return (
<a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</a>
<div key={inc.incident_id} className="relative shrink-0">
<a
href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</a>
{playLatest}
</div>
);
})}
</div>
+56 -4
View File
@@ -14,6 +14,8 @@ import { useAuth } from "@/components/AuthProvider";
import { CameraFeed } from "@/components/CameraFeed";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { InlinePlayer } from "@/components/InlinePlayer";
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
function sevColor(s: string | null | undefined) {
@@ -31,6 +33,8 @@ function hhmm(iso: string): string {
}
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
// Spine rows rendered before "show all loaded" (server-26#239).
const SPINE_CAP = 30;
const SEC = "px-4 py-3 border-b border-line";
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
@@ -183,6 +187,20 @@ export function IncidentPanel({
return m;
}, [calls]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
const auth = useAuth();
const audioOk = canPlayAudio(auth);
const playingCallId = useAudio((s) => s.playingCallId);
const queueLen = useAudio((s) => s.queue.length);
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
// Truncation is said out loud, not silent (server-26#239): the spine shows
// what this page has loaded, which can be less than the incident holds.
const [showAll, setShowAll] = useState(false);
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
const cleared = inc.units_cleared ?? [];
@@ -270,23 +288,57 @@ export function IncidentPanel({
</div>
<div className={SEC}>
<h3 className={H3}>Call spine · newest first</h3>
<div className="flex items-center gap-2 mb-1.5">
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
{audioOk && playable.length > 0 && (
playingHere ? (
<button type="button" onClick={stopAudio}
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
</button>
) : (
<button type="button" onClick={() => playIncident(playable)}
title="Play this incident's loaded calls back to back, oldest first"
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
▶ Play incident · {playable.length}
</button>
)
)}
</div>
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
<p className="text-[10px] text-ink-muted mb-1.5">
{showAll || spine.length <= SPINE_CAP
? `Showing all ${spine.length} loaded of ${totalCalls}`
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
{spine.length > SPINE_CAP && (
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
{showAll ? "show latest 30" : "show all loaded"}
</button>
)}
</p>
) : null}
{spine.length ? (
<ol className="space-y-2">
{spine.slice(0, 30).map((c) => {
{shown.map((c) => {
const stop = stopOf.get(c.call_id);
const text = c.transcript_corrected || c.transcript;
const isPlaying = c.call_id === playingCallId;
return (
<li key={c.call_id} className="flex gap-2 text-xs">
<li key={c.call_id}
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
aria-current={isPlaying ? "true" : undefined}>
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
</div>
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
</div>
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
</li>
);
})}