Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`hooks/admin/useNavigationGuard.tsx`; and `services/downloadFilename.ts`
moved to `services/util/downloadFilename.ts`. All call sites updated;
no runtime behaviour change.
- Frontend audio playback now uses the platform `<audio>` element backed
by `MediaElementAudioSourceNode`. Each call is fetched on demand from
the existing `/api/calls/:id/audio` endpoint authenticated via the
session cookie. Drops the WebSocket-embedded base64 path, fixing
Mobile Edge AAC playback and dramatically reducing per-call memory
pressure on the client.

### Fixed

Expand Down
47 changes: 47 additions & 0 deletions frontend/src/app/audioListenerMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import type { RootState } from "@/app/store";
import { callReceived } from "@/app/slices/scanner/scannerSlice";
import { audioPlayer } from "@/services/audio/player";

/**
* Listener middleware that bridges incoming Redux call events to the
* audio player. Replaces the previous WS → callback → player pipeline:
* the WS client now only dispatches `callReceived(call)`, and this
* middleware enqueues into the player when scanner state allows.
*
* Filtering rules mirror the scanner UI:
* - LIVE off → drop (player also clears on LIVE off, see useAudioPlayer).
* - HOLD TG → only the held talkgroup plays.
* - HOLD SYSTEM → only that system plays.
* - AVOID → active avoid entries block their talkgroup.
* - SELECT → talkgroups explicitly disabled in tgSelection are dropped.
*/
export const audioListenerMiddleware = createListenerMiddleware();

audioListenerMiddleware.startListening({
actionCreator: callReceived,
effect: (action, listenerApi) => {
const state = listenerApi.getState() as RootState;
if (!state.scanner.isLive) return;

const call = action.payload;
const { heldTG, heldSystem, avoidList, tgSelection } = state.scanner;

if (heldTG !== null) {
if (call.talkgroup !== heldTG) return;
} else if (heldSystem !== null) {
if (call.system !== heldSystem) return;
}

const now = Date.now();
for (const entry of avoidList) {
if (entry.talkgroupId === call.talkgroup) {
if (entry.expiresAt === 0 || entry.expiresAt > now) return;
}
}

if (tgSelection[call.talkgroup] === false) return;

audioPlayer.enqueue(call);
},
});
5 changes: 4 additions & 1 deletion frontend/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api } from "@/app/api";
import { scannerSlice } from "@/app/slices/scanner/scannerSlice";
import { authSlice } from "@/app/slices/shared/authSlice";
import { callsSlice } from "@/app/slices/scanner/callsSlice";
import { audioListenerMiddleware } from "@/app/audioListenerMiddleware";

export const store = configureStore({
reducer: {
Expand All @@ -13,7 +14,9 @@ export const store = configureStore({
calls: callsSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
getDefaultMiddleware()
.prepend(audioListenerMiddleware.middleware)
.concat(api.middleware),
});

export type RootState = ReturnType<typeof store.getState>;
Expand Down
58 changes: 10 additions & 48 deletions frontend/src/components/scanner/BookmarksPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,60 +92,22 @@ export default function BookmarksPanel({

const bookmarkedCalls = bookmarkData?.calls ?? [];

const handlePlay = async (bc: BookmarkCall) => {
try {
const headers: HeadersInit = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const resp = await fetch(`/api/calls/${bc.id}/audio`, { headers });
if (!resp.ok) {
console.error("failed to load bookmark audio", bc.id, resp.status);
return;
}

const buf = await resp.arrayBuffer();
const mimeType =
resp.headers.get("Content-Type") || bc.audioType || "audio/mpeg";
const blob = new Blob([buf], { type: mimeType });
const audioUrl = URL.createObjectURL(blob);
const call = bookmarkCallToCall(bc);
audioPlayer.playNow(call, buf, audioUrl);
} catch (err) {
console.error("failed to play bookmark", bc.id, err);
}
const handlePlay = (bc: BookmarkCall) => {
const call = bookmarkCallToCall(bc);
audioPlayer.playNow(call);
};

const handleUnbookmark = (callId: number) => {
toggleBookmark(callId);
};

const handleDownload = async (bc: BookmarkCall) => {
try {
const headers: HeadersInit = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const resp = await fetch(`/api/calls/${bc.id}/audio`, { headers });
if (!resp.ok) {
console.error("failed to download bookmark audio", bc.id, resp.status);
return;
}

const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = sanitizeDownloadFilename(bc.audioName, `call-${bc.id}.mp3`);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("failed to download bookmark", bc.id, err);
}
const handleDownload = (bc: BookmarkCall) => {
const a = document.createElement("a");
a.href = `/api/calls/${bc.id}/audio`;
a.download = sanitizeDownloadFilename(bc.audioName, `call-${bc.id}.mp3`);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};

return (
Expand Down
123 changes: 39 additions & 84 deletions frontend/src/components/scanner/SearchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,98 +463,53 @@ export default function SearchPanel({ isOpen, onClose }: SearchPanelProps) {
return count;
}, [filters]);

const handleRowClick = useCallback(
async (call: CallSearchResult) => {
try {
const headers: HeadersInit = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const resp = await fetch(`/api/calls/${call.id}/audio`, { headers });
if (!resp.ok) {
console.error("failed to load call audio", call.id, resp.status);
return;
}

const buf = await resp.arrayBuffer();
const mimeType =
resp.headers.get("Content-Type") || call.audioType || "audio/mpeg";
const blob = new Blob([buf], { type: mimeType });
const audioUrl = URL.createObjectURL(blob);

const playCall: Call = {
id: call.id,
audioName: call.audioName || `call-${call.id}`,
audioType: call.audioType || blob.type || "audio/mpeg",
dateTime: call.dateTime,
systemId: call.systemId,
system: call.systemId,
talkgroupId: call.talkgroupId,
talkgroup: call.talkgroupId,
frequency: call.frequency,
duration: call.duration,
source: call.source,
site: call.site,
channel: call.channel,
decoder: call.decoder,
errorCount: call.errorCount,
spikeCount: call.spikeCount,
talkerAlias: call.talkerAlias,
systemLabel: call.systemLabel,
talkgroupLabel: call.talkgroupLabel,
talkgroupName: call.talkgroupName,
talkgroupTag: call.talkgroupTag,
talkgroupGroup: call.talkgroupGroup,
transcript: call.transcript,
};
const handleRowClick = useCallback(async (call: CallSearchResult) => {
const playCall: Call = {
id: call.id,
audioName: call.audioName || `call-${call.id}`,
audioType: call.audioType || "audio/mpeg",
dateTime: call.dateTime,
systemId: call.systemId,
system: call.systemId,
talkgroupId: call.talkgroupId,
talkgroup: call.talkgroupId,
frequency: call.frequency,
duration: call.duration,
source: call.source,
site: call.site,
channel: call.channel,
decoder: call.decoder,
errorCount: call.errorCount,
spikeCount: call.spikeCount,
talkerAlias: call.talkerAlias,
systemLabel: call.systemLabel,
talkgroupLabel: call.talkgroupLabel,
talkgroupName: call.talkgroupName,
talkgroupTag: call.talkgroupTag,
talkgroupGroup: call.talkgroupGroup,
transcript: call.transcript,
};

audioPlayer.playNow(playCall, buf, audioUrl);
} catch (err) {
console.error("failed to play call", call.id, err);
}
},
[token],
);
audioPlayer.playNow(playCall);
}, []);

const [showFilters, setShowFilters] = useState(false);

const handleToggleSection = useCallback((sectionId: string) => {
setOpenFilterSection((prev) => (prev === sectionId ? "" : sectionId));
}, []);

const handleDownload = useCallback(
async (call: CallSearchResult) => {
try {
const headers: HeadersInit = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const resp = await fetch(`/api/calls/${call.id}/audio`, { headers });
if (!resp.ok) {
console.error("failed to download call audio", call.id, resp.status);
return;
}

const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = sanitizeDownloadFilename(
call.audioName,
`call-${call.id}.mp3`,
);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("failed to download call", call.id, err);
}
},
[token],
);
const handleDownload = useCallback((call: CallSearchResult) => {
const a = document.createElement("a");
a.href = `/api/calls/${call.id}/audio`;
a.download = sanitizeDownloadFilename(
call.audioName,
`call-${call.id}.mp3`,
);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, []);

if (!isOpen) return null;

Expand Down
41 changes: 0 additions & 41 deletions frontend/src/hooks/scanner/useAudioPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useEffect, useCallback, useState, useRef } from "react";
import { useAppDispatch, useAppSelector } from "@/app/store";
import { store } from "@/app/store";
import { audioPlayer } from "@/services/audio/player";
import { wsClient } from "@/services/ws/client";
import {
setCurrentCall,
clearCurrentCall,
Expand Down Expand Up @@ -39,52 +38,12 @@ export function useAudioPlayer() {
setPendingCount(length);
});

wsClient.onAudioReceived((call, audioUrl, audioData) => {
// LIVE mode gates only streaming WS audio. Manual playback
// (Search/Bookmarks) uses audioPlayer.playNow and remains available.
if (!store.getState().scanner.isLive) {
try {
URL.revokeObjectURL(audioUrl);
} catch {
// ignore
}
return;
}
audioPlayer.play(call, audioData, audioUrl);
});

// If restored as paused (e.g. after refresh), tell audioPlayer so
// incoming calls queue instead of trying to auto-play.
if (store.getState().scanner.isPaused) {
audioPlayer.pause();
}

// Client-side filter — checks hold, avoid, and tgSelection each
// time a CAL arrives so changes take effect immediately.
wsClient.setCallFilter((call) => {
const { heldTG, heldSystem, avoidList, tgSelection } =
store.getState().scanner;

// Hold: if a talkgroup is held, only that TG plays
if (heldTG !== null) return call.talkgroup === heldTG;

// Hold: if a system is held, only calls from that system play
if (heldSystem !== null) {
if (call.system !== heldSystem) return false;
}

// Avoid: block avoided talkgroups
const now = Date.now();
for (const entry of avoidList) {
if (entry.talkgroupId === call.talkgroup) {
if (entry.expiresAt === 0 || entry.expiresAt > now) return false;
}
}

// tgSelection: undefined = enabled; only explicit false rejects
return tgSelection[call.talkgroup] !== false;
});

// Stop all audio when the hook unmounts (e.g. navigating away).
return () => {
audioPlayer.clearQueue();
Expand Down
Loading
Loading