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
# ── 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]]:
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
if not gcs_uri or not gcs_uri.startswith("gs://"):