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>
90 lines
3.7 KiB
TypeScript
90 lines
3.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|