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
7 changes: 5 additions & 2 deletions src/js/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* through the feature modules. No business logic lives here, only wiring.
*/

import { createRadioCore } from './radioCore';
import { createRadioCore, isLoadingLike } from './radioCore';
import {
radioSelect, player, loadingNoise, errorNoise, loadingMsg, errorMsg,
prevButton, playButton, pauseButton, stopButton, nextButton, logoButton,
Expand Down Expand Up @@ -179,8 +179,11 @@ player.addEventListener('pause', () => {
// During loading/retrying/recovering the main player pauses while the loading
// sound takes over. Actively re-assert 'playing' so macOS doesn't flash
// "Not Playing" in the gap. Only signal 'paused' in normal playback states.
// NOTE: deliberately narrower than isFeedbackAudible — 'error' is not
// re-asserted here (pre-existing drift from the other three state lists;
// whether that's intent or a bug gets decided in faza R3/R5, not silently).
if ('mediaSession' in navigator) {
if (s === 'loading' || s === 'retrying' || s === 'recovering') {
if (isLoadingLike(s) || s === 'recovering') {
navigator.mediaSession.playbackState = 'playing';
} else if (s === 'playing') {
navigator.mediaSession.playbackState = 'paused';
Expand Down
19 changes: 9 additions & 10 deletions src/js/mediaSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import type { RadioCore, RadioState } from './radioCore';
import { isLoadingLike, isErrorLike, isFeedbackAudible, playbackStateFor } from './radioCore';
import { LABELS } from './labels';
import { cloudinaryImageUrl } from './cloudinary';
import { radioSelect, posterImage, loadingMsg, loadingNoise, errorNoise } from './dom';
Expand Down Expand Up @@ -38,10 +39,9 @@ function registerMediaSessionHandlers() {
navigator.mediaSession.setActionHandler('nexttrack', () => core?.nextRadio());
navigator.mediaSession.setActionHandler('pause', () => {
if (!core) return;
const s = core.getState();
// During loading/error the sound effects are playing, not the stream.
// "Pause" should cancel everything (same as the on-screen stop button).
if (s === 'loading' || s === 'retrying' || s === 'error' || s === 'recovering') {
// While a feedback sound is what's audible (not the stream), "pause"
// should cancel everything (same as the on-screen stop button).
if (isFeedbackAudible(core.getState())) {
core.stopRadio();
} else {
core.pauseRadio();
Expand Down Expand Up @@ -90,8 +90,7 @@ errorNoise.addEventListener('timeupdate', clearPositionState);
// it picks up audio from the main player.
function reassertPlaybackState() {
if (!('mediaSession' in navigator) || !core) return;
const s = core.getState();
if (s === 'playing' || s === 'loading' || s === 'retrying' || s === 'error' || s === 'recovering') {
if (playbackStateFor(core.getState()) === 'playing') {
navigator.mediaSession.playbackState = 'playing';
clearPositionState();
}
Expand All @@ -102,8 +101,8 @@ errorNoise.addEventListener('pause', reassertPlaybackState);
export const updateMediaSession = (newState: RadioState) => {
const title = radioSelect.options[radioSelect.selectedIndex].text;
const isIdle = newState === 'idle';
const isLoading = newState === 'loading' || newState === 'retrying';
const hasError = newState === 'error' || newState === 'recovering';
const isLoading = isLoadingLike(newState);
const hasError = isErrorLike(newState);
const isLive = newState === 'playing';

const idleText = hasRestoredStation ? title : LABELS.appName;
Expand All @@ -124,11 +123,11 @@ export const updateMediaSession = (newState: RadioState) => {
}

// Keep session alive during loading/error (sounds are playing via <audio>)
navigator.mediaSession.playbackState = (isLive || isLoading || hasError) ? 'playing' : newState === 'paused' ? 'paused' : 'none';
navigator.mediaSession.playbackState = playbackStateFor(newState);

// Clear position state for active/paused states — tells the OS there's no
// seekable timeline, so it won't show a finite progress bar.
if (isLive || isLoading || hasError || newState === 'paused') {
if (playbackStateFor(newState) !== 'none') {
clearPositionState();
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/js/radioCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SimulatedClock } from 'xstate';
import {
createRadioCore,
isLoadingLike,
isErrorLike,
isFeedbackAudible,
playbackStateFor,
RECOVERY_DELAY_MS,
RECOVERY_DELAY_MAX_MS,
WATCHDOG_INTERVAL_MS,
WATCHDOG_STALL_TICKS,
SOUND_SUPERVISOR_INTERVAL_MS,
USER_PAUSE_INTENT_MS,
type RadioDeps,
type RadioState,
} from './radioCore';

// --- Helpers ---
Expand Down Expand Up @@ -1299,3 +1304,29 @@ describe('sound supervisor', () => {
expect(supervisorCount()).toBe(0);
});
});

// =============================================
// STATE CLASSIFICATION (single source of truth for the DOM layer)
// =============================================

describe('state classification predicates', () => {
it('classifies every state exactly as STATE_FX presents it', () => {
const table: Record<RadioState, {
loadingLike: boolean; errorLike: boolean; playback: 'playing' | 'paused' | 'none';
}> = {
idle: { loadingLike: false, errorLike: false, playback: 'none' },
loading: { loadingLike: true, errorLike: false, playback: 'playing' },
playing: { loadingLike: false, errorLike: false, playback: 'playing' },
paused: { loadingLike: false, errorLike: false, playback: 'paused' },
retrying: { loadingLike: true, errorLike: false, playback: 'playing' },
error: { loadingLike: false, errorLike: true, playback: 'playing' },
recovering: { loadingLike: false, errorLike: true, playback: 'playing' },
};
for (const [state, expected] of Object.entries(table) as Array<[RadioState, typeof table[RadioState]]>) {
expect(isLoadingLike(state), `isLoadingLike(${state})`).toBe(expected.loadingLike);
expect(isErrorLike(state), `isErrorLike(${state})`).toBe(expected.errorLike);
expect(isFeedbackAudible(state), `isFeedbackAudible(${state})`).toBe(expected.loadingLike || expected.errorLike);
expect(playbackStateFor(state), `playbackStateFor(${state})`).toBe(expected.playback);
}
});
});
6 changes: 6 additions & 0 deletions src/js/radioCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ type ActorOptions = NonNullable<Parameters<typeof createActor>[1]>;
// Shared domain types & timing constants live with the machine; re-exported
// here so the rest of the app keeps a single import surface.
export type { RadioState, FeedbackSound, RadioDeps } from './radioMachine';
export {
isLoadingLike,
isErrorLike,
isFeedbackAudible,
playbackStateFor,
} from './radioMachine';
export {
MAX_RETRIES,
LOADING_TIMEOUT_MS,
Expand Down
21 changes: 21 additions & 0 deletions src/js/radioMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ interface StateFx {
errorMsg: boolean;
}

// --- State classification: the single source of truth for the DOM layer ---
// Derived from what STATE_FX presents; DOM modules must not hand-maintain
// their own state lists (they drifted before — see plan.md, faza R2).

/** States presenting as "loading": loading tone + loading message. */
export const isLoadingLike = (s: RadioState): boolean =>
s === 'loading' || s === 'retrying';

/** States presenting as "error": error tone and/or error visuals. */
export const isErrorLike = (s: RadioState): boolean =>
s === 'error' || s === 'recovering';

/** States where a feedback sound, not the stream, is what's audible. */
export const isFeedbackAudible = (s: RadioState): boolean =>
isLoadingLike(s) || isErrorLike(s);

/** What the OS lock screen / Now Playing should report for a state —
* 'playing' whenever ANYTHING is audible (stream or feedback sound). */
export const playbackStateFor = (s: RadioState): 'playing' | 'paused' | 'none' =>
s === 'playing' || isFeedbackAudible(s) ? 'playing' : s === 'paused' ? 'paused' : 'none';

const STATE_FX: Record<RadioState, StateFx> = {
idle: { button: 'play', loading: 'stop', error: 'stop', loadingMsg: false, errorMsg: false },
loading: { button: 'stop', loading: 'play', error: 'stop', loadingMsg: true, errorMsg: false },
Expand Down