Files
gods-eye-view/src/layers/radio/playback.js
T
German Khokhlov a55450e2e7 Trim the Radio Next/Previous change to the HAL-722 scope
- Selections take the playback fallback as a `playFallback` option and
  set it after releasing a stale stream, instead of callers arming state
  that the release then had to save and restore.
- Move the tuner `station-unavailable` release and the Pause label while
  a stream connects out of this branch: both were wrong on main already
  and go in a separate change.
- Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs
  and fold overlapping Next/Previous tests (13 to 7).
- One-line comments and a two-line changelog entry.
2026-10-30 16:37:36 +04:00

398 lines
13 KiB
JavaScript

import { msg } from '../../i18n/index.js';
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
// Playback errors stay English in layer state; the Radio panel shows them
// through t().
const AUDIO_ERROR_STREAM_FAILED = msg(
'Broadcaster stream is unavailable or blocked by the browser.',
);
const AUDIO_ERROR_GESTURE_REQUIRED = msg(
'Playback requires a direct click or tap.',
);
const AUDIO_ERROR_START_FAILED = msg(
'Broadcaster stream could not be started.',
);
export function createPlayback({ state: layerState, services, parts, source }) {
function audioEventBelongsToActiveAttempt(audio) {
const attempt = layerState._activePlaybackAttempt;
return (
layerState._audio === audio &&
Boolean(attempt) &&
attempt.generation === layerState._playGeneration &&
attempt.stationId === layerState._audioStationId
);
}
function installAudio({ replace = false } = {}) {
if (layerState._audio && !replace) return;
if (typeof Audio === 'undefined') return;
const previousAudio = layerState._audio;
layerState._audio = null;
if (previousAudio) {
try {
previousAudio.pause();
} catch {
/* already stopped */
}
try {
previousAudio.removeAttribute('src');
} catch {
/* no source */
}
try {
previousAudio.load();
} catch {
/* detached media */
}
}
const audio = new Audio();
layerState._audio = audio;
audio.preload = 'none';
audio.volume = layerState._voiceDucked ? 0 : layerState._userVolume;
audio.addEventListener('playing', () => {
if (layerState._audio !== audio) return;
if (!audioEventBelongsToActiveAttempt(audio)) return;
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
return;
layerState._audioState = 'playing';
layerState._audioError = null;
if (layerState._tuningAwaitingStationId === layerState._audioStationId)
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState();
});
audio.addEventListener('pause', () => {
if (layerState._audio !== audio) return;
if (
layerState._audioState === 'stopped' ||
layerState._audioState === 'loading'
)
return;
layerState._audioState = 'paused';
parts.presentation.emitState();
});
audio.addEventListener('waiting', () => {
if (layerState._audio !== audio) return;
if (!audioEventBelongsToActiveAttempt(audio)) return;
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
return;
layerState._audioState = 'buffering';
parts.presentation.emitState();
});
audio.addEventListener('error', () => {
if (layerState._audio !== audio) return;
if (!audioEventBelongsToActiveAttempt(audio)) return;
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
return;
const failedId = layerState._audioStationId;
const attempt = layerState._activePlaybackAttempt;
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
layerState._audioState = 'error';
layerState._audioError = AUDIO_ERROR_STREAM_FAILED;
parts.presentation.emitState();
});
}
function tryRadioFallback(
failedId,
origin = layerState._playFallbackOrigin || 'programmatic',
attemptId = layerState._playFallbackAttemptId,
) {
const fallbackId = layerState._playFallbackId;
const fallbackStation = fallbackId
? layerState._stationById.get(fallbackId)
: null;
const fallbackFocusPolicy = layerState._playFallbackFocus;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
if (
!fallbackStation ||
fallbackId === failedId ||
layerState._selectedId !== failedId
) {
return false;
}
const fallbackFocusResult =
typeof fallbackFocusPolicy === 'function'
? fallbackFocusPolicy(fallbackStation)
: fallbackFocusPolicy;
const fallbackCameraNavigation =
fallbackFocusResult && typeof fallbackFocusResult === 'object'
? fallbackFocusResult
: null;
const fallbackFocus = fallbackCameraNavigation
? false
: Boolean(fallbackFocusResult);
parts.selection.selectRadioStation(fallbackId, {
autoplay: true,
focus: fallbackFocus,
origin: origin || 'programmatic',
attemptId,
cameraNavigation: fallbackCameraNavigation,
});
return true;
}
function recordDirectoryClick(id) {
Promise.resolve()
.then(() => source.recordClick(id))
.catch(() => {});
}
/** Play the selected broadcaster stream after an explicit user action. */
async function playSelectedRadio({
origin = 'programmatic',
attemptId = null,
} = {}) {
const station = parts.queries.selectedStation();
if (!parts.interaction.radioPresentationAllowed() || !station?.streamUrl)
return false;
if (layerState._tuningActive) parts.tuning.endRadioTuning();
// A media event carries no reliable attempt identity. Give every explicit
// play/resume/replacement its own element so queued events from the retired
// attempt remain bound to the discarded element and cannot mutate this one.
installAudio({ replace: true });
if (!layerState._audio) return false;
const generation = ++layerState._playGeneration;
const ownedAttemptId =
attemptId || `radio-play-${++layerState._playAttemptSequence}`;
layerState._activePlaybackAttempt = {
id: ownedAttemptId,
origin,
stationId: station.id,
streamUrl: station.streamUrl,
generation,
};
if (layerState._playFallbackId) {
layerState._playFallbackOrigin = origin;
layerState._playFallbackAttemptId = ownedAttemptId;
}
layerState._audioError = null;
layerState._audioState = 'loading';
if (
layerState._audioStationId !== station.id ||
layerState._audio.src !== station.streamUrl
) {
layerState._audio.pause();
layerState._audio.src = station.streamUrl;
layerState._audioStationId = station.id;
}
parts.presentation.emitState();
try {
const playback = layerState._audio.play();
if (playback?.then) await playback;
if (
generation !== layerState._playGeneration ||
layerState._audioStationId !== station.id ||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
)
return false;
layerState._audioState = 'playing';
if (layerState._tuningAwaitingStationId === station.id)
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
recordDirectoryClick(station.id);
parts.presentation.emitState();
if (origin === 'user')
parts.presentation.emitPlaybackControl('play', origin, ownedAttemptId);
return true;
} catch (error) {
if (
generation !== layerState._playGeneration ||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
)
return false;
if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false;
layerState._audioState = 'error';
layerState._audioError =
error?.name === 'NotAllowedError'
? AUDIO_ERROR_GESTURE_REQUIRED
: AUDIO_ERROR_START_FAILED;
parts.presentation.emitState();
return false;
}
}
/**
* Wait for confirmed Radio playback while voice owns a hard mute.
* A fallback station may replace the first stream during this wait, so the
* state subscription—not the first play() promise—is authoritative.
*/
function confirmRadioPlayback({
startPlayback,
subscribe,
getState,
timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS,
} = {}) {
if (
typeof startPlayback !== 'function' ||
typeof subscribe !== 'function' ||
typeof getState !== 'function'
) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
let started = false;
let unsubscribe = () => {};
let timer = null;
const finish = (ok) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
unsubscribe();
resolve(Boolean(ok));
};
const inspect = (state) => {
if (!started || !state) return;
if (
!state.voiceDucked &&
['loading', 'buffering', 'playing'].includes(state.audioState)
) {
finish(false);
return;
}
if (
state.audioState === 'playing' &&
state.playingStationId &&
state.voiceDucked
) {
finish(true);
} else if (state.audioState === 'error') {
finish(false);
}
};
unsubscribe = subscribe(inspect);
started = true;
timer = setTimeout(
() => finish(false),
Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS),
);
Promise.resolve()
.then(startPlayback)
.then((playStarted) => {
const state = getState();
inspect(state);
if (
!playStarted &&
!['loading', 'buffering', 'playing'].includes(state?.audioState)
)
finish(false);
})
.catch(() => finish(false));
});
}
/** Start and verify a prepared station without ever making it audible under voice. */
function playPreparedRadioForVoice(options = {}) {
if (!layerState._voiceDucked) return Promise.resolve(false);
return confirmRadioPlayback({
startPlayback: () =>
playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }),
subscribe: parts.presentation.subscribeToRadio,
getState: parts.presentation.getRadioUIState,
timeoutMs: options.timeoutMs,
});
}
/** Stop the shared stream and release its network resource. */
function stopRadioPlayback({
origin = 'programmatic',
attemptId = null,
} = {}) {
if (attemptId && layerState._activePlaybackAttempt?.id !== attemptId)
return false;
const stoppedAttemptId = layerState._activePlaybackAttempt?.id || null;
parts.tuning.endRadioTuning();
layerState._playGeneration += 1;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
layerState._activePlaybackAttempt = null;
if (layerState._audio) {
layerState._audio.pause();
layerState._audio.removeAttribute('src');
layerState._audio.load();
}
layerState._audioStationId = null;
layerState._audioState = 'stopped';
layerState._audioError = null;
parts.presentation.emitState();
if (origin === 'user' || origin === 'voice') {
parts.presentation.emitPlaybackControl('stop', origin, stoppedAttemptId);
}
return true;
}
/** Pause or resume the selected stream. Resuming is still click initiated. */
function toggleRadioPlayback({ origin = 'programmatic' } = {}) {
if (['loading', 'playing', 'buffering'].includes(layerState._audioState)) {
return Promise.resolve(pauseRadioPlayback({ origin }));
}
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
if (layerState._selectedId) {
// Drop a fallback left by a voice step whose playback never started.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
} else {
const ranked = parts.queries.rankedVisibleStations();
if (!ranked.length) return Promise.resolve(false);
parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false,
focus: false,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
}
return playSelectedRadio({ origin });
}
/** Pause Radio without toggling a stopped or already-paused stream back on. */
function pauseRadioPlayback({ origin = 'programmatic' } = {}) {
if (!['loading', 'playing', 'buffering'].includes(layerState._audioState))
return false;
const pausedAttemptId = layerState._activePlaybackAttempt?.id || null;
if (layerState._tuningAwaitingStationId && !layerState._tuningActive)
parts.tuning.endRadioTuning();
layerState._playGeneration += 1;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
layerState._activePlaybackAttempt = null;
layerState._audio?.pause();
layerState._audioState = 'paused';
parts.presentation.emitState();
if (origin === 'user' || origin === 'voice') {
parts.presentation.emitPlaybackControl('pause', origin, pausedAttemptId);
}
return true;
}
return {
audioEventBelongsToActiveAttempt,
installAudio,
tryRadioFallback,
recordDirectoryClick,
playSelectedRadio,
confirmRadioPlayback,
playPreparedRadioForVoice,
stopRadioPlayback,
toggleRadioPlayback,
pauseRadioPlayback,
};
}