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>
112 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|