Files
server-26/drb-frontend/components/CallSpineEntry.tsx
Logan CusanoandClaude Opus 5.5 c836986b89 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>
2026-10-05 02:33:25 -04:00

112 lines
4.0 KiB
TypeScript

"use client";
/**
* One entry in an incident's call spine — UI_REDESIGN.md §5.2. Replaces
* CallRow for this context (CallRow stays in use for the Archive table
* until chunk 12). `stopNumber` is the same index the map's path stops use
* (see IncidentPathLayer in MapView.tsx) — that shared index is the one
* memorable thing per §2.4: "where was he when he said that" is answered
* by looking, not cross-referencing timestamps.
*/
import { useState } from "react";
import type { CallRecord } from "@/lib/types";
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 StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
if (!stopNumber) {
return <span className="w-6 h-6 shrink-0 rounded-full border border-line-strong" />;
}
return (
<span
className="w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[11px] font-semibold text-page"
style={{ background: color }}
>
{stopNumber}
</span>
);
}
export function CallSpineEntry({
call,
stopNumber,
isAdmin,
}: {
call: CallRecord;
stopNumber?: number;
isAdmin?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
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;
const units = call.units ?? [];
const clearedInCall = call.cleared_units ?? [];
if (!substantive) {
// Thin/routine calls collapse to a single line.
return (
<div className="flex items-center gap-3 py-1.5 text-xs text-ink-muted">
<span className="w-6 shrink-0" />
<span className="font-mono tabular-nums shrink-0">{fmtTime(call.started_at)}</span>
<span className="truncate">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"} · status only</span>
</div>
);
}
return (
<div className="flex gap-3 py-3">
<div className="flex flex-col items-center shrink-0">
<StopMarker stopNumber={stopNumber} color={color} />
<span className="flex-1 w-px bg-line mt-1" />
</div>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap text-xs text-ink-muted">
<span className="font-mono tabular-nums">{fmtTime(call.started_at)}</span>
<span>·</span>
<span className="font-mono">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"}</span>
<span className="font-mono">{call.node_id}</span>
</div>
{hasAudio && (
<div className="mt-1.5">
<InlinePlayer callId={call.call_id} />
</div>
)}
{transcript && (
<p
className={`text-sm text-ink leading-relaxed mt-1.5 ${!expanded ? "line-clamp-2" : ""}`}
onClick={() => setExpanded((v) => !v)}
>
{transcript}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{call.location && (
<span className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2">{call.location}</span>
)}
{units.map((u) => (
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2 font-mono">{u}</span>
))}
{clearedInCall.map((u) => (
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-transparent border border-line text-ink-muted font-mono line-through">{u}</span>
))}
</div>
{isAdmin && call.corr_path && (
<p className="text-[10px] font-mono text-ink-muted mt-1">corr: {call.corr_path}</p>
)}
</div>
</div>
);
}