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:
co-authored by
Claude Opus 5.5
parent
a75149b2eb
commit
c836986b89
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user