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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`ParseEncodingPreset` fallback) instead of `aac_lc_32k`. New installs
enabling audio conversion will now default to MP3 32 kbps as the UI
advertises.
- Audio playback now silently recovers from a 401 on `/api/calls/:id/audio`
by triggering a single token refresh and retrying the same call. Fixes
the case where a sibling device login (phone, tablet, second tab) pushes
a desktop's access JWT out of the per-user concurrent-token cap and
leaves \<audio\> playback failing until the next scheduled refresh.

## [1.1.2] — 2026-04-24

Expand Down
31 changes: 30 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,40 @@ import { Provider } from "react-redux";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { store } from "@/app/store";
import { useAppSelector } from "@/app/store";
import { selectAuthReady } from "@/app/slices/shared/authSlice";
import { selectAuthReady, setCredentials } from "@/app/slices/shared/authSlice";
import { useAuthInit } from "@/hooks/shared/useAuthInit";
import { useTokenRefresh } from "@/hooks/shared/useTokenRefresh";
import { audioPlayer } from "@/services/audio/player";
import type { RefreshResponse } from "@/types";
import "@/index.css";

// Wire a silent auth-recovery hook into the audio player so that when an
// `<audio>` fetch returns 401 (e.g. another device pushed our access JWT
// out of the per-user concurrent-token cap) we transparently refresh the
// session cookie and retry the same call once. Bypasses RTK Query's
// retry-on-401 plumbing because media-element fetches don't go through it.
audioPlayer.setAuthRecovery(async () => {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) return false;
const data = (await res.json()) as RefreshResponse;
store.dispatch(
setCredentials({
token: data.token,
role: data.user.role,
username: data.user.username,
passwordNeedChange: false,
}),
);
return true;
} catch {
return false;
}
});

const Scanner = lazy(() => import("@/pages/Scanner"));
const Login = lazy(() => import("@/pages/Login"));
const Setup = lazy(() => import("@/pages/Setup"));
Expand Down
58 changes: 53 additions & 5 deletions frontend/src/services/audio/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ interface QueueItem {
call: Call;
/** True for search/bookmark plays — discardable on a newer playNow. */
onDemand?: boolean;
/**
* Set after we've already attempted a silent auth refresh + retry for
* this item. Prevents an infinite refresh loop when the failure isn't
* actually about auth.
*/
recoveryTried?: boolean;
}

// Extend window for Safari's prefixed AudioContext.
Expand Down Expand Up @@ -43,6 +49,7 @@ class AudioPlayer {
private callStartCb: ((call: Call) => void) | null = null;
private callEndCb: (() => void) | null = null;
private queueChangeCb: ((length: number) => void) | null = null;
private authRecovery: (() => Promise<boolean>) | null = null;

constructor() {
this.bootstrapAudio();
Expand Down Expand Up @@ -228,6 +235,20 @@ class AudioPlayer {
this.queueChangeCb = cb;
}

/**
* Register a callback invoked when an audio fetch fails. The callback
* should attempt a silent auth refresh (e.g. POST /api/auth/refresh)
* and resolve to `true` if it succeeded — in which case the player
* retries the current call with the new session cookie. Required
* because <audio src=…> bypasses the RTK Query 401 retry path, so a
* server-side token revocation (e.g. a sibling device exhausting the
* concurrent-token cap) would otherwise leave playback broken until
* the next scheduled refresh fires.
*/
setAuthRecovery(fn: () => Promise<boolean>): void {
this.authRecovery = fn;
}

clearQueue(): void {
this.queue = [];
this.queueChangeCb?.(0);
Expand Down Expand Up @@ -347,14 +368,41 @@ class AudioPlayer {

private handleError = (): void => {
if (!this.currentItem) return;
console.warn(
"[audioPlayer] failed to play call",
this.currentItem.call.id,
);
const item = this.currentItem;

// First failure for this item: try a silent auth refresh in case the
// session cookie's JWT was revoked server-side (concurrent-token cap,
// explicit logout-elsewhere, etc.). On success, reload the same src
// — the new Set-Cookie will be picked up automatically.
if (!item.recoveryTried && this.authRecovery && this.audio) {
item.recoveryTried = true;
const recovery = this.authRecovery;
const audio = this.audio;
void recovery().then((ok) => {
if (!ok || this.currentItem !== item || this.audio !== audio) {
this.skipCurrent(item);
return;
}
try {
audio.load();
} catch {
// ignore
}
audio.play().catch(() => this.skipCurrent(item));
});
return;
}

this.skipCurrent(item);
};

private skipCurrent(item: QueueItem): void {
if (this.currentItem !== item) return;
console.warn("[audioPlayer] failed to play call", item.call.id);
this.currentItem = null;
this._playing = false;
this.playNext();
};
}

private playNext(): void {
const next = this.queue.shift();
Expand Down
Loading