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
@@ -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://"):
|
||||||
|
|||||||
@@ -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
|
|
||||||
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
|
|
||||||
# object's extension so old .mp3 recordings keep working unchanged.
|
# object's extension so old .mp3 recordings keep working unchanged.
|
||||||
media_type=content_type_for(gcs_uri),
|
media_type = content_type_for(gcs_uri)
|
||||||
|
|
||||||
|
try:
|
||||||
|
span = parse_range(range_header, size)
|
||||||
|
except _Unsatisfiable:
|
||||||
|
return Response(
|
||||||
|
status_code=416,
|
||||||
|
headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if size == 0:
|
||||||
|
return Response(
|
||||||
|
content=b"",
|
||||||
|
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 = {
|
headers = {
|
||||||
"Content-Length": str(len(data)),
|
"Content-Length": str(end - start + 1),
|
||||||
# Whole body at once, no Range support. This was comfortable at
|
"Accept-Ranges": "bytes",
|
||||||
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a
|
"Cache-Control": _CACHE_CONTROL,
|
||||||
# long call is now tens of MB and the browser must download all of
|
}
|
||||||
# it before playback starts. Acceptable for typical few-second
|
if span:
|
||||||
# transmissions, but this is the change that makes Range support
|
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
|
||||||
# actually matter — see DEFERRED.md.
|
|
||||||
"Accept-Ranges": "none",
|
return StreamingResponse(
|
||||||
# Immutable content, but the URL expires — cache privately only.
|
_stream(gcs_uri, first, start, end),
|
||||||
"Cache-Control": "private, max-age=3600",
|
status_code=206 if span else 200,
|
||||||
},
|
media_type=media_type,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div key={inc.incident_id} className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
key={inc.incident_id}
|
|
||||||
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 (
|
||||||
|
<div key={inc.incident_id} className="relative shrink-0">
|
||||||
<a
|
<a
|
||||||
key={inc.incident_id}
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user