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
+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);
}