mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- 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.
398 lines
13 KiB
JavaScript
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,
|
|
};
|
|
}
|