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
+61
View File
@@ -121,6 +121,67 @@ async def download_audio(gcs_uri: str) -> Optional[bytes]:
return None return None
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
# into memory, so /media used to make the browser wait for all of it before
# the first sample played. These two read only what a request asks for: the
# object size (one metadata GET) and then an inclusive byte span, which the
# router streams out in chunks. One cached client, because the router issues
# several span reads per playback and a fresh Client() per read re-resolves
# credentials every time.
_gcs_client_cache = None
def _gcs_client():
global _gcs_client_cache
if _gcs_client_cache is None:
from google.cloud import storage
if settings.gcp_credentials_path:
_gcs_client_cache = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
_gcs_client_cache = storage.Client()
return _gcs_client_cache
async def audio_object_size(gcs_uri: str) -> Optional[int]:
"""Size in bytes of a stored audio object, or None if it is missing/unreadable."""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _size() -> Optional[int]:
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
blob.reload()
return blob.size
try:
size = await asyncio.to_thread(_size)
except Exception as e:
logger.warning(f"GCS metadata read failed for {gcs_uri}: {e}")
return None
return int(size) if size is not None else None
async def download_audio_range(gcs_uri: str, start: int, end: int) -> Optional[bytes]:
"""Bytes ``start``..``end`` INCLUSIVE of a stored object (GCS's own convention).
Reads only that span from GCS — never the whole object.
"""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _read() -> bytes:
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
return blob.download_as_bytes(start=start, end=end)
try:
return await asyncio.to_thread(_read)
except Exception as e:
logger.warning(f"GCS ranged read failed for {gcs_uri} [{start}-{end}]: {e}")
return None
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]: def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``.""" """``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
if not gcs_uri or not gcs_uri.startswith("gs://"): if not gcs_uri or not gcs_uri.startswith("gs://"):
+125 -22
View File
@@ -10,19 +10,100 @@ happens inline below, in the same spirit as routers/enrollment.py.
The bucket stays fully private and c2-core reads the object server-side with The bucket stays fully private and c2-core reads the object server-side with
Application Default Credentials, so no GCS signed URL — and therefore no Application Default Credentials, so no GCS signed URL — and therefore no
service-account private key on the VM — is involved anywhere in this path. service-account private key on the VM — is involved anywhere in this path.
HTTP Range (server-26#233): the body is streamed from GCS in fixed-size span
reads instead of downloaded whole first. A 600 s FLAC call is ~13 MB, and the
old full-body response (``Accept-Ranges: none``) made the browser wait for all
of it before playback started and made seeking re-download it. Now playback
starts after the first span and a seek is a ``206`` for just the bytes needed.
Only a single range is honoured; a multi-range request gets the full body,
which RFC 9110 §14.2 permits.
""" """
from fastapi import APIRouter, HTTPException, Query, Response import re
from typing import AsyncIterator, Optional, Tuple
from fastapi import APIRouter, Header, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for from app.internal.logger import logger
from app.internal.storage import (
audio_object_size,
content_type_for,
download_audio_range,
gcs_uri_for_call,
verify_audio_link,
)
router = APIRouter(prefix="/media", tags=["media"]) router = APIRouter(prefix="/media", tags=["media"])
# Bytes per GCS span read. ~25 s of FLAC at ~1.3 MB/min, so the first span —
# the only one the browser waits on — is one small GCS round-trip.
STREAM_CHUNK_BYTES = 512 * 1024
# Immutable content, but the URL expires — cache privately only.
_CACHE_CONTROL = "private, max-age=3600"
_RANGE_RE = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
class _Unsatisfiable(Exception):
pass
def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
"""Resolve a ``Range`` header against an object of ``size`` bytes.
Returns an inclusive ``(start, end)``, or None when the header is absent or
should be ignored (not ``bytes``, multi-range, malformed — RFC 9110 lets a
server answer those with the full 200). Raises _Unsatisfiable for a
well-formed range that selects nothing (-> 416).
"""
if not header:
return None
m = _RANGE_RE.match(header)
if not m:
return None # other unit, multi-range ("a-b,c-d") or garbage
first, last = m.group(1), m.group(2)
if first == "" and last == "":
return None
if first == "":
# Suffix range: the final N bytes.
n = int(last)
if n == 0 or size == 0:
raise _Unsatisfiable()
return max(0, size - n), size - 1
start = int(first)
if last != "" and int(last) < start:
return None # invalid spec -> ignore the header
if start >= size:
raise _Unsatisfiable()
end = size - 1 if last == "" else min(int(last), size - 1)
return start, end
async def _stream(gcs_uri: str, first_chunk: bytes, start: int, end: int) -> AsyncIterator[bytes]:
yield first_chunk
pos = start + len(first_chunk)
while pos <= end:
chunk_end = min(pos + STREAM_CHUNK_BYTES - 1, end)
data = await download_audio_range(gcs_uri, pos, chunk_end)
if not data:
# Headers are already sent, so the status can't change; ending the
# body short makes the client see a truncated response rather than
# silently wrong audio.
logger.warning(f"/media stream cut short at byte {pos} of {gcs_uri}")
return
yield data
pos += len(data)
@router.get("/calls/{call_id}/audio") @router.get("/calls/{call_id}/audio")
async def get_call_audio( async def get_call_audio(
call_id: str, call_id: str,
exp: int = Query(..., description="Link expiry, unix seconds."), exp: int = Query(..., description="Link expiry, unix seconds."),
sig: str = Query(..., description="HMAC over call_id and expiry."), sig: str = Query(..., description="HMAC over call_id and expiry."),
range_header: Optional[str] = Header(None, alias="Range"),
): ):
# Verify before touching Firestore so an invalid link costs nothing. # Verify before touching Firestore so an invalid link costs nothing.
if not verify_audio_link(call_id, exp, sig): if not verify_audio_link(call_id, exp, sig):
@@ -36,26 +117,48 @@ async def get_call_audio(
if not gcs_uri: if not gcs_uri:
raise HTTPException(404, "No audio for this call.") raise HTTPException(404, "No audio for this call.")
data = await download_audio(gcs_uri) size = await audio_object_size(gcs_uri)
if not data: if size is None:
raise HTTPException(404, "Audio object missing from storage.") raise HTTPException(404, "Audio object missing from storage.")
return Response( # Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser will
content=data, # not play a FLAC body labelled audio/mpeg. Derived from the stored
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser # object's extension so old .mp3 recordings keep working unchanged.
# will not play a FLAC body labelled audio/mpeg. Derived from the stored media_type = content_type_for(gcs_uri)
# object's extension so old .mp3 recordings keep working unchanged.
media_type=content_type_for(gcs_uri), try:
headers={ span = parse_range(range_header, size)
"Content-Length": str(len(data)), except _Unsatisfiable:
# Whole body at once, no Range support. This was comfortable at return Response(
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a status_code=416,
# long call is now tens of MB and the browser must download all of headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
# it before playback starts. Acceptable for typical few-second )
# transmissions, but this is the change that makes Range support
# actually matter — see DEFERRED.md. if size == 0:
"Accept-Ranges": "none", return Response(
# Immutable content, but the URL expires — cache privately only. content=b"",
"Cache-Control": "private, max-age=3600", media_type=media_type,
}, headers={"Accept-Ranges": "bytes", "Cache-Control": _CACHE_CONTROL},
)
start, end = span if span else (0, size - 1)
# Fetched before the response starts so a read failure is still a clean
# 404 rather than a stream that dies after a 200/206 has gone out.
first = await download_audio_range(gcs_uri, start, min(start + STREAM_CHUNK_BYTES - 1, end))
if not first:
raise HTTPException(404, "Audio object missing from storage.")
headers = {
"Content-Length": str(end - start + 1),
"Accept-Ranges": "bytes",
"Cache-Control": _CACHE_CONTROL,
}
if span:
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
return StreamingResponse(
_stream(gcs_uri, first, start, end),
status_code=206 if span else 200,
media_type=media_type,
headers=headers,
) )
+220
View File
@@ -0,0 +1,220 @@
"""
HTTP Range on /media/calls/{id}/audio (server-26#233).
The GCS reads are replaced by an in-memory object: ``audio_object_size`` and
``download_audio_range`` are patched where routers/media.py looks them up, and
every span read is recorded so the tests can assert the route never asks GCS
for more than the requested range (plus at most one STREAM_CHUNK_BYTES span).
"""
import time
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal import storage
from app.routers import media
app = FastAPI()
app.include_router(media.router)
client = TestClient(app)
CALL_ID = "call-abc"
GCS_URI = "gs://bucket/calls/call-abc.flac"
class FakeObject:
"""A stored object plus a log of every (start, end) span read from it."""
def __init__(self, data: bytes):
self.data = data
self.reads: list[tuple[int, int]] = []
self.size_calls = 0
async def size(self, gcs_uri):
self.size_calls += 1
return len(self.data)
async def read(self, gcs_uri, start, end):
self.reads.append((start, end))
return self.data[start:end + 1]
@pytest.fixture
def signed(monkeypatch):
monkeypatch.setattr(storage.settings, "service_key", "test-key", raising=False)
exp = int(time.time()) + 600
return {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)}
def _run(obj: FakeObject, params, headers=None, doc=None):
doc_get = AsyncMock(return_value=doc if doc is not None else {"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})
with patch.object(media.fstore, "doc_get", doc_get), \
patch.object(media, "audio_object_size", obj.size), \
patch.object(media, "download_audio_range", obj.read):
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=params, headers=headers or {})
return resp, doc_get
def test_bounded_range_returns_206_with_exact_bytes(signed):
obj = FakeObject(bytes(range(256)) * 40) # 10240 bytes
resp, _ = _run(obj, signed, {"Range": "bytes=100-199"})
assert resp.status_code == 206
assert resp.content == obj.data[100:200]
assert resp.headers["content-range"] == f"bytes 100-199/{len(obj.data)}"
assert resp.headers["content-length"] == "100"
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-type"] == "audio/flac"
assert resp.headers["cache-control"] == "private, max-age=3600"
# Only the requested span was read from GCS — not the whole object.
assert obj.reads == [(100, 199)]
def test_open_ended_range(signed):
obj = FakeObject(b"x" * 5000 + b"tail")
resp, _ = _run(obj, signed, {"Range": "bytes=5000-"})
assert resp.status_code == 206
assert resp.content == b"tail"
assert resp.headers["content-range"] == "bytes 5000-5003/5004"
def test_suffix_range(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=-3"})
assert resp.status_code == 206
assert resp.content == b"789"
assert resp.headers["content-range"] == "bytes 7-9/10"
def test_suffix_longer_than_object_returns_whole_object(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=-500"})
assert resp.status_code == 206
assert resp.content == obj.data
assert resp.headers["content-range"] == "bytes 0-9/10"
def test_end_past_object_is_clamped(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=8-999"})
assert resp.status_code == 206
assert resp.content == b"89"
assert resp.headers["content-range"] == "bytes 8-9/10"
@pytest.mark.parametrize("rng", ["bytes=10-", "bytes=50-60", "bytes=-0"])
def test_unsatisfiable_range_is_416(signed, rng):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": rng})
assert resp.status_code == 416
assert resp.headers["content-range"] == "bytes */10"
assert obj.reads == []
def test_no_range_is_200_full_body_and_advertises_ranges(signed):
obj = FakeObject(b"abcdef" * 100)
resp, _ = _run(obj, signed)
assert resp.status_code == 200
assert resp.content == obj.data
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-length"] == str(len(obj.data))
assert "content-range" not in resp.headers
@pytest.mark.parametrize("rng", ["bytes=0-1,4-5", "items=0-5", "bytes=5-2", "garbage"])
def test_ignored_range_headers_fall_back_to_200(signed, rng):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": rng})
assert resp.status_code == 200
assert resp.content == obj.data
def test_long_object_is_streamed_in_bounded_spans(signed):
# A ~13 MB "600 s FLAC": a seek near the end reads only from there on, in
# spans no larger than STREAM_CHUNK_BYTES, and the first span is all the
# route waits on before the response starts.
size = 13 * 1024 * 1024
obj = FakeObject(bytes(size))
start = size - 1_500_000
resp, _ = _run(obj, signed, {"Range": f"bytes={start}-"})
assert resp.status_code == 206
assert len(resp.content) == 1_500_000
assert obj.reads[0][0] == start
assert all(e - s + 1 <= media.STREAM_CHUNK_BYTES for s, e in obj.reads)
assert sum(e - s + 1 for s, e in obj.reads) == 1_500_000
def test_bad_signature_is_403_before_any_lookup(signed):
obj = FakeObject(b"0123456789")
resp, doc_get = _run(obj, {"exp": signed["exp"], "sig": "0" * 64}, {"Range": "bytes=0-3"})
assert resp.status_code == 403
doc_get.assert_not_called()
assert obj.size_calls == 0
assert obj.reads == []
def test_expired_link_is_403(signed, monkeypatch):
exp = int(time.time()) - 5
obj = FakeObject(b"0123456789")
resp, doc_get = _run(obj, {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)})
assert resp.status_code == 403
doc_get.assert_not_called()
def test_missing_object_is_404(signed):
obj = FakeObject(b"")
async def no_size(gcs_uri):
return None
with patch.object(media.fstore, "doc_get", AsyncMock(return_value={"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})), \
patch.object(media, "audio_object_size", no_size), \
patch.object(media, "download_audio_range", obj.read):
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=signed)
assert resp.status_code == 404
def test_legacy_mp3_keeps_its_content_type(signed):
obj = FakeObject(b"ID3" + b"\0" * 50)
resp, _ = _run(obj, signed, {"Range": "bytes=0-2"}, doc={"call_id": CALL_ID, "audio_gcs_uri": "gs://b/calls/x.mp3"})
assert resp.status_code == 206
assert resp.headers["content-type"] == "audio/mpeg"
assert resp.content == b"ID3"
# ── storage.py: the ranged reads never pull the whole object ────────────────
def _fake_client(monkeypatch, size=1234, payload=b"span"):
from unittest.mock import MagicMock
client = MagicMock()
blob = client.bucket.return_value.blob.return_value
blob.size = size
blob.download_as_bytes.return_value = payload
monkeypatch.setattr(storage, "_gcs_client_cache", client)
return client, blob
def test_storage_size_reloads_metadata_only(monkeypatch):
import asyncio
client, blob = _fake_client(monkeypatch, size=13_000_000)
assert asyncio.run(storage.audio_object_size(GCS_URI)) == 13_000_000
client.bucket.assert_called_with("bucket")
client.bucket.return_value.blob.assert_called_with("calls/call-abc.flac")
blob.reload.assert_called_once()
blob.download_as_bytes.assert_not_called()
def test_storage_range_read_passes_inclusive_bounds(monkeypatch):
import asyncio
_, blob = _fake_client(monkeypatch)
assert asyncio.run(storage.download_audio_range(GCS_URI, 100, 199)) == b"span"
blob.download_as_bytes.assert_called_once_with(start=100, end=199)
def test_storage_range_read_failure_returns_none(monkeypatch):
import asyncio
_, blob = _fake_client(monkeypatch)
blob.download_as_bytes.side_effect = RuntimeError("gcs down")
assert asyncio.run(storage.download_audio_range(GCS_URI, 0, 10)) is None
blob.reload.side_effect = RuntimeError("404")
assert asyncio.run(storage.audio_object_size(GCS_URI)) is None
+4 -94
View File
@@ -8,105 +8,15 @@
* memorable thing per §2.4: "where was he when he said that" is answered * memorable thing per §2.4: "where was he when he said that" is answered
* by looking, not cross-referencing timestamps. * by looking, not cross-referencing timestamps.
*/ */
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import type { CallRecord } from "@/lib/types"; 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"; import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); 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 }) { function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
if (!stopNumber) { if (!stopNumber) {
@@ -132,7 +42,7 @@ export function CallSpineEntry({
isAdmin?: boolean; isAdmin?: boolean;
}) { }) {
const [expanded, setExpanded] = useState(false); 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 transcript = call.transcript_corrected || call.transcript;
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)"; 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 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 { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall"; 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 ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl; 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 ─── // ── Incident path stop marker — numbered, shared index with the call spine ───
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon { // `playing` (server-26#233): the stop whose call is on the speaker gets a
const size = 22; // 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 fill = isFirst ? "var(--surface)" : color;
const textColor = isFirst ? color : "var(--page)"; 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({ return L.divIcon({
className: "", 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} ${halo}
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/> <circle cx="${c}" cy="${c}" 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> <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>`, </svg>`,
iconSize: [size, size], iconSize: [size, size],
iconAnchor: [size / 2, size / 2], iconAnchor: [c, c],
}); });
} }
@@ -1023,6 +1031,8 @@ function IncidentPathLayer({
incidents: IncidentRecord[]; incidents: IncidentRecord[];
callsByIncident: Map<string, CallRecord[]>; callsByIncident: Map<string, CallRecord[]>;
}) { }) {
// Only the playing id, so a timeupdate never re-renders the map.
const playingCallId = useAudio((s) => s.playingCallId);
return ( return (
<> <>
{incidents.map((inc) => { {incidents.map((inc) => {
@@ -1043,13 +1053,20 @@ function IncidentPathLayer({
<Marker <Marker
key={c.call_id} key={c.call_id}
position={[c.location_coords!.lat, c.location_coords!.lng]} 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}> <Popup minWidth={160}>
<div className="text-gray-900"> <div className="text-gray-900">
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p> <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> <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>} {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"> <a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident → View incident →
</a> </a>
@@ -1388,6 +1405,11 @@ export default function MapView({
const live = onAir?.has(inc.incident_id) ?? false; const live = onAir?.has(inc.incident_id) ?? false;
const cams = camCountByIncident?.get(inc.incident_id) ?? 0; const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
const callCount = inc.call_ids?.length ?? 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 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 = ( 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) { if (inc.location_coords || onSelectIncident) {
return ( return (
<button <div key={inc.incident_id} className="relative shrink-0">
key={inc.incident_id} <button
onClick={() => handleIncidentSelect(inc)} onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected} aria-pressed={selected}
className={baseClass} className={`${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }} style={{ borderColor: color + "55" }}
> >
{cardBody} {cardBody}
</button> </button>
{playLatest}
</div>
); );
} }
return ( return (
<a <div key={inc.incident_id} className="relative shrink-0">
key={inc.incident_id} <a
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass}`} className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }} style={{ borderColor: color + "55" }}
> >
{cardBody} {cardBody}
</a> </a>
{playLatest}
</div>
); );
})} })}
</div> </div>
+56 -4
View File
@@ -14,6 +14,8 @@ import { useAuth } from "@/components/AuthProvider";
import { CameraFeed } from "@/components/CameraFeed"; import { CameraFeed } from "@/components/CameraFeed";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; 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"; import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
function sevColor(s: string | null | undefined) { 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"; 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"; 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 }) { export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
@@ -183,6 +187,20 @@ export function IncidentPanel({
return m; return m;
}, [calls]); }, [calls]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [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 unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
const cleared = inc.units_cleared ?? []; const cleared = inc.units_cleared ?? [];
@@ -270,23 +288,57 @@ export function IncidentPanel({
</div> </div>
<div className={SEC}> <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 ? ( {spine.length ? (
<ol className="space-y-2"> <ol className="space-y-2">
{spine.slice(0, 30).map((c) => { {shown.map((c) => {
const stop = stopOf.get(c.call_id); const stop = stopOf.get(c.call_id);
const text = c.transcript_corrected || c.transcript; const text = c.transcript_corrected || c.transcript;
const isPlaying = c.call_id === playingCallId;
return ( 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]" <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> 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"> <div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span> <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>} {c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
</div> </div>
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p> <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> </div>
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
</li> </li>
); );
})} })}
+176
View File
@@ -0,0 +1,176 @@
"use client";
/**
* Call-audio playback store — server-26#233.
*
* ONE module-level <audio> element for the whole app, so exactly one call
* plays at a time no matter how many players are on screen (spine rows, map
* path-stop popups, rail cards, the incident page). Components read it through
* useSyncExternalStore, which is what lets the wall panel and the map share
* "now playing" without LiveView.tsx threading state between them.
*
* Nothing here runs until play()/playIncident() is called from a click, so
* nothing autoplays on page load. The playback URL is minted per play by
* GET /calls/{id} (a short-lived HMAC link, see c2-core storage.py) rather
* than read off the Firestore doc, which only holds the gs:// location.
* c2-core serves it with HTTP Range, so a long call starts after the first
* span and seeking is a 206 for just the bytes needed.
*/
import { useSyncExternalStore } from "react";
import { c2api } from "@/lib/c2api";
import type { CallRecord } from "@/lib/types";
export type AudioStatus = "idle" | "loading" | "playing" | "paused";
export interface AudioState {
/** Calls still to play after the current one, oldest first. */
queue: string[];
playingCallId: string | null;
status: AudioStatus;
/** The last call whose audio could not be fetched or decoded. */
errorCallId: string | null;
current: number;
duration: number;
}
const IDLE: AudioState = {
queue: [], playingCallId: null, status: "idle", errorCallId: null, current: 0, duration: 0,
};
let state: AudioState = IDLE;
const listeners = new Set<() => void>();
let el: HTMLAudioElement | null = null;
// Bumped on every play/stop so a getCall() that resolves after the user has
// already moved on is dropped instead of hijacking the player.
let token = 0;
function set(patch: Partial<AudioState>) {
state = { ...state, ...patch };
listeners.forEach((l) => l());
}
function audio(): HTMLAudioElement {
if (el) return el;
el = new Audio();
el.preload = "auto";
el.addEventListener("play", () => set({ status: "playing" }));
el.addEventListener("pause", () => { if (state.status === "playing") set({ status: "paused" }); });
el.addEventListener("timeupdate", () => set({ current: el!.currentTime }));
el.addEventListener("loadedmetadata", () => set({ duration: Number.isFinite(el!.duration) ? el!.duration : 0 }));
el.addEventListener("ended", advance);
el.addEventListener("error", () => {
if (!state.playingCallId || !el!.getAttribute("src")) return;
set({ errorCallId: state.playingCallId });
advance();
});
return el;
}
function advance() {
const [next, ...rest] = state.queue;
if (next) void start(next, rest);
else stop();
}
async function start(callId: string, queue: string[]) {
const mine = ++token;
const a = audio();
a.pause();
set({ queue, playingCallId: callId, status: "loading", current: 0, duration: 0,
errorCallId: state.errorCallId === callId ? null : state.errorCallId });
let url: string | null = null;
try {
url = (await c2api.getCall(callId)).audio_url ?? null;
} catch {
url = null;
}
if (mine !== token) return;
if (!url) {
set({ errorCallId: callId });
advance();
return;
}
a.src = url;
// The click that got us here is the play intent; a rejection (autoplay
// policy after a slow fetch) leaves it paused for one more click.
a.play().catch(() => { if (mine === token) set({ status: "paused" }); });
}
/** Play one call now, replacing whatever is playing or queued. */
export function play(callId: string) {
void start(callId, []);
}
/** Play an incident's calls back to back. Pass them oldest first. */
export function playIncident(callIdsOldestFirst: string[]) {
const [first, ...rest] = callIdsOldestFirst;
if (first) void start(first, rest);
}
export function stop() {
token++;
if (el) {
el.pause();
el.removeAttribute("src");
el.load();
}
set({ ...IDLE, errorCallId: state.errorCallId });
}
export function pause() {
el?.pause();
}
export function resume() {
el?.play().catch(() => {});
}
export function seek(t: number) {
if (el && Number.isFinite(t)) {
el.currentTime = t;
set({ current: t });
}
}
/** Play/pause toggle for one call's button. */
export function toggle(callId: string) {
if (state.playingCallId !== callId) play(callId);
else if (state.status === "playing") pause();
else if (state.status === "paused") resume();
}
function subscribe(l: () => void): () => void {
listeners.add(l);
return () => {
listeners.delete(l);
// Last player unmounted (navigated away from every page that shows one):
// stop rather than keep playing audio nothing on screen can pause.
// Deferred so a same-tick remount (StrictMode, route swap) doesn't stop it.
if (!listeners.size) setTimeout(() => { if (!listeners.size && state.playingCallId) stop(); }, 0);
};
}
/**
* Subscribe to a slice of the store. Return a primitive from `select` (an id,
* a status, a number) so a component re-renders only when that slice changes
* — the map must not re-render on every timeupdate.
*/
export function useAudio<T>(select: (s: AudioState) => T): T {
return useSyncExternalStore(subscribe, () => select(state), () => select(IDLE));
}
/** True when a call has a stored recording c2-core can serve. */
export function hasAudio(c: Pick<CallRecord, "audio_gcs_uri" | "audio_url">): boolean {
return !!(c.audio_gcs_uri || c.audio_url);
}
/**
* The ONE gate for call-audio playback in the UI. BUSINESS_MODEL.md §3 puts
* audio playback in Scanner and up, with the Public tier getting none; no tier
* concept exists in the frontend yet (and Gate A keeps tier names off public
* surfaces), so today this is true for any signed-in org member. When tiers
* land, switch the Public tier off HERE rather than at each player.
*/
export function canPlayAudio(auth: { user: unknown; orgId: string | null; isAdmin?: boolean }): boolean {
return !!auth.user && (!!auth.orgId || !!auth.isAdmin);
}