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
58 changes: 57 additions & 1 deletion plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ SW-urile NU raman "pe viata": no-cache + reg.update() + skipWaiting/claim +
stergerea cache-urilor radio-* vechi la activate. Forcarea invalidarii la
useri = bump la cele 3 constante de versiune (facut: app-v3/images-v3/sounds-v2).

## Episodul handoff/carry (PR #41 — REVERTAT integral, decizia lui Adrian)

S-a construit si VERIFICAT PE IPHONE un mecanism care facea sunetul de eroare
audibil din prima pe lock screen (handoff cu stop amanat + "carry": elementul
de loading isi schimba src-ul la tonul de eroare). Revertat pentru ca
contrazice filozofia proiectului: state machine = precizie, fara sunete
suprapuse, fara coordonare event-driven estimata in stratul DOM.

Cunostinte castigate (valabile, de refolosit):
- iOS in background REFUZA orice pornire proaspata de element audio, dar
PERMITE unui element care deja canta sa-si schimbe src si sa continue.
- Web Audio API a fost deja incercat si revertat istoric (69a58f2): iOS
pierde sesiunea fara un <audio> activ. Elementele separate loading/error
si re-inregistrarea handler-elor MediaSession (d798cc9) sunt deliberate.
- Evenimentul window 'offline' poate porni pipeline-ul instant (fara ~6s de
watchdog) — idee buna, de reintrodus curat candva.
- Widget-ul Now Playing pe macOS ramane gol la eroare offline (3 fix-uri
incercate si revertate) — limitare cunoscuta.

Directia agreata daca se reia: UN SINGUR element <audio> de feedback ("canal")
cu `tone: 'loading'|'error'|'none'` in STATE_FX — overlap imposibil prin
constructie, schimbarea de ton = swap de src pe elementul care deja canta
(exact continuarea permisa de iOS). De facut eventual in Faza 4 (XState),
cu re-validare completa pe device.

## Faza 2: TypeScript [gata]

Obiectiv: type safety pe contractul core <-> DOM, fara nicio schimbare de logica.
Expand Down Expand Up @@ -178,11 +203,26 @@ Fisiere: `tsconfig.json`, `package.json`, redenumirile din `src/js/`,
Verificari: `npm run typecheck` curat, `npm test` verde (aceleasi teste),
`npm run build` verde, e2e 23/23 neatins.

## Faza 3: modularizare script.ts
## Faza 3: modularizare script.ts [gata]

Obiectiv: spargem glue-ul DOM de 700+ linii in module cu responsabilitate unica.
Zero schimbare de comportament — doar mutare de cod si import/export.

Note de implementare (branch `faza3-modularizare`):
- In plus fata de planul initial: `src/js/dom.ts` (helper-ul el<T> + toate
referintele DOM partajate) — mediaSession/selector au nevoie de aceleasi
elemente ca main, iar importul dintr-un singur loc pastreaza comportamentul
(lookup la load) fara parametri plimbati peste tot.
- `updateMediaSession` NU mai apeleaza maybeReloadForPendingServiceWorkerUpdate
(dependenta mediaSession -> serviceWorker taiata): main compune cele doua in
deps.updateMediaSession, in aceeasi ordine ca inainte.
- Ciclul core <-> mediaSession rezolvat prin initMediaSession (inainte de
createRadioCore, pentru hasRestoredStation) + connectMediaSessionCore (dupa).
- Bundle-ul ramane un singur chunk `js/index.js` (importuri statice) — APP_SHELL
din sw.js neschimbat, fara bump de cache.
- Verificat: typecheck curat, 63/63 unit, build identic ca structura, 37/37 e2e
NEATINSE, smoke complet pe vite preview.

- `src/js/main.ts` — entry point: DOM refs, `createRadioCore(deps)`, event
listeners pe butoane/player/online, wiring intre module.
- `src/js/labels.ts` — `LABELS` (sursa unica pentru textele user-facing).
Expand Down Expand Up @@ -237,6 +277,22 @@ cu paritate 100% de comportament.
ramana testabil fara browser. `radioCore.ts` devine un adaptor subtire:
acelasi API public (`playRadio`, `stopRadio`, `togglePlayPause`, `onPlayerPause`
etc.) tradus in events -> `main.ts` si celelalte module nu se ating.
- Redesign `audioInstance` (soundEffects.ts) — punctul cel mai slab actual:
- `isPlaying` e INTENTIE, nu realitate (adevarul e in element: paused,
rejected play); ensure() re-impaca periodic cele doua = eventual
consistency. warmUp() se bazeaza pe curse cu playGeneration. Netestat
direct, desi are cea mai delicata logica async din stratul DOM.
- Modelul corect: instanta tine o singura stare de dorinta
(`desired: 'playing' | 'stopped'`), elementul e singura sursa de realitate,
si UN reconcile() le aliniaza — play/stop/ensure devin declansatoare.
Testabil unit cu un element fals injectat.
- Se leaga natural de ideea "canal de feedback": `tone: 'loading'|'error'|
'none'` in STATE_FX + un singur element <audio> real (NU Web Audio — vezi
episodul handoff si 69a58f2) — overlap imposibil prin constructie, iar
schimbarea de ton = swap de src pe elementul care deja canta (continuarea
permisa de iOS in background). Rezolva si lock screen-ul iOS, curat.
- Orice schimbare aici cere re-validare completa pe device (lock screen,
prev/next, offline) — zona cea mai empirica a aplicatiei.
- Capcane cunoscute de tratat explicit (comportament calit in productie):
- ordinea `setState('loading')` INAINTE de `playerPause()` (nativul 'pause' e
ignorat in loading/retrying — vezi comentariul din radioCore si logica din
Expand Down
2 changes: 1 addition & 1 deletion src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ <h1 class="uppercase text-sm text-Brown mt-6">Radio Player Romania
<p class="mt-2"><a href="./downloads/coji-radio-romania.apk" download>Descarcă aplicația Android (0.9)</a></p>
</footer>

<script type="module" src="./js/script.ts"></script>
<script type="module" src="./js/main.ts"></script>
</body>

</html>
27 changes: 27 additions & 0 deletions src/js/cloudinary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Cloudinary status/poster images + their offline pre-cache. */

export function cloudinaryImageUrl(text: string, live = false) {
const url_non_live = 'nndti4oybhdzggf8epvh';
const url_live = 'rhz6yy4btbqicjqhsy7a';
const encoded = encodeURIComponent(text);
return `https://res.cloudinary.com/adrianf/image/upload/c_scale,h_480,w_480/w_400,g_south_west,x_50,y_70,c_fit,l_text:arial_90:${encoded}/${live ? url_live : url_non_live}`;
}

// Pre-cache status images + station name images into Cache API for offline use
export function precacheStatusImages(texts: string[]): void {
if (!('caches' in window)) return;
caches.open('radio-images-v3').then(cache => {
texts.forEach(text => {
const url = cloudinaryImageUrl(text);
cache.match(url)
.then(hit => {
if (!hit) {
return fetch(url, { mode: 'no-cors' }).then(res => {
if (res.ok || res.type === 'opaque') return cache.put(url, res);
});
}
})
.catch(() => { /* offline or CORS — ignore, SW will cache on next online visit */ });
});
}).catch(() => { /* cache API unavailable */ });
}
28 changes: 28 additions & 0 deletions src/js/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Shared DOM references.
*
* The markup is ours (src/index.html), so a missing id is a build-time bug —
* fail loudly instead of null-checking every use site.
*/

export function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element: #${id}`);
return node as T;
}

export const radioSelect = el<HTMLSelectElement>('radioSelect');
export const player = el<HTMLAudioElement>('player');
export const loadingNoise = el<HTMLAudioElement>('loadingNoise');
export const errorNoise = el<HTMLAudioElement>('errorNoise');
export const loadingMsg = el<HTMLElement>('loadingMsg');
export const errorMsg = el<HTMLElement>('errorMsg');

export const prevButton = el<HTMLButtonElement>('prevButton');
export const playButton = el<HTMLButtonElement>('playButton');
export const pauseButton = el<HTMLButtonElement>('pauseButton');
export const stopButton = el<HTMLButtonElement>('stopButton');
export const nextButton = el<HTMLButtonElement>('nextButton');

export const logoButton = el<HTMLButtonElement>('logoButton');
export const posterImage = el<HTMLButtonElement>('posterImage');
6 changes: 6 additions & 0 deletions src/js/labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** All user-facing labels (single source of truth). */
export const LABELS = {
appName: 'Coji Radio Player',
loading: 'Se încarcă...',
error: 'Eroare',
};
217 changes: 217 additions & 0 deletions src/js/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* Radio Player — entry point: wires the pure core (radioCore) to the DOM
* through the feature modules. No business logic lives here, only wiring.
*/

import { createRadioCore } from './radioCore';
import {
radioSelect, player, loadingNoise, errorNoise, loadingMsg, errorMsg,
prevButton, playButton, pauseButton, stopButton, nextButton, logoButton,
} from './dom';
import { LABELS } from './labels';
import { precacheStatusImages } from './cloudinary';
import { getStoredStationIndex, saveLastIndex } from './storage';
import { audioInstance } from './soundEffects';
import { initMediaSession, connectMediaSessionCore, updateMediaSession } from './mediaSession';
import { initServiceWorker, maybeReloadForPendingServiceWorkerUpdate } from './serviceWorker';
import { initThemeColor } from './theme';
import { initStationSelector } from './stationSelector';

declare global {
interface Window {
electronAPI?: {
onMediaControl(callback: (command: string) => void): void;
updatePlaybackState(isPlaying: boolean): void;
};
}
}

document.addEventListener("touchstart", function () { }, true);

// Pre-cache status images + station name images for offline use
precacheStatusImages([
...Object.values(LABELS),
...Array.from(radioSelect.options).map(o => o.text),
]);

// Restore last station before anything reads selectedIndex
const restoredStationIndex = getStoredStationIndex(radioSelect.options.length);
const hasRestoredStation = restoredStationIndex !== null;
if (hasRestoredStation) {
radioSelect.selectedIndex = restoredStationIndex;
}
initMediaSession({ hasRestoredStation });

// --- Feedback sounds ---

const loadingNoiseInstance = audioInstance(loadingNoise);
const errorNoiseInstance = audioInstance(errorNoise);

// Preload audio blobs once per page. Re-called from user interactions as a
// retry if the eager page-load preload failed — fetch() doesn't need a user
// gesture, only playback does.
function preloadAudioBlobs() {
loadingNoiseInstance.preloadBlob();
errorNoiseInstance.preloadBlob();
}
preloadAudioBlobs();

// Every playback-starting interaction also warms the sound elements up
// (plays them for a split second) so iOS blesses them with the user gesture.
function warmUpFeedbackSounds() {
preloadAudioBlobs();
loadingNoiseInstance.warmUp();
errorNoiseInstance.warmUp();
}

// --- Playback control buttons ---

function isPlaybackControl(element: Element | null) {
return element === playButton || element === pauseButton || element === stopButton;
}

function focusInitialPlaybackControl() {
if (document.activeElement && document.activeElement !== document.body) return;
playButton.focus();
}

const showButton = (which: 'play' | 'pause' | 'stop') => {
const shouldPreserveFocus = isPlaybackControl(document.activeElement);
const nextControl = which === 'play' ? playButton : which === 'pause' ? pauseButton : stopButton;

playButton.classList.toggle('hidden', which !== 'play');
pauseButton.classList.toggle('hidden', which !== 'pause');
stopButton.classList.toggle('hidden', which !== 'stop');

if (shouldPreserveFocus) nextControl.focus();
};

// --- Core ---

const core = createRadioCore({
getStationUrl: (i) => radioSelect.options[i].value,
getStationCount: () => radioSelect.options.length,
getSelectedIndex: () => radioSelect.selectedIndex,
setSelectedIndex: (i) => { radioSelect.selectedIndex = i; },
playerPlay: () => player.play(),
playerPause: () => player.pause(),
playerSetSrc: (url) => { player.src = url; },
playerLoad: () => player.load(),
playerIsPaused: () => player.paused,
playerCurrentTime: () => player.currentTime,
loadingSound: loadingNoiseInstance,
errorSound: errorNoiseInstance,
showButton,
setLoadingMsg: (v) => loadingMsg.classList.toggle('invisible', !v),
setErrorMsg: (v) => errorMsg.classList.toggle('invisible', !v),
updateMediaSession: (s) => {
updateMediaSession(s);
maybeReloadForPendingServiceWorkerUpdate(s);
},
saveLastIndex,
setTimeout,
clearTimeout: (id) => clearTimeout(id ?? undefined),
setInterval,
clearInterval: (id) => clearInterval(id ?? undefined),
performanceNow: () => performance.now(),
isOnline: () => navigator.onLine,
});
connectMediaSessionCore(core);
focusInitialPlaybackControl();

// --- Event listeners ---

radioSelect.addEventListener('change', () => {
if (radioSelect.value) {
core.playRadio(radioSelect.selectedIndex);
} else {
core.stopRadio();
}
});

// Electron
const electronAPI = window.electronAPI;
if (electronAPI) {
electronAPI.onMediaControl((command) => {
if (command === "playpause") {
core.togglePlayPause();
} else if (command === "next") {
core.nextRadio();
} else if (command === "previous") {
core.prevRadio();
}
});

document.addEventListener("DOMContentLoaded", () => {
const updatePlaybackState = () =>
electronAPI.updatePlaybackState(core.getState() === 'playing');
player.addEventListener("play", updatePlaybackState);
player.addEventListener("pause", updatePlaybackState);
});
}

// Buttons
playButton.addEventListener('click', () => {
warmUpFeedbackSounds();
core.onPlayButtonClick();
});
pauseButton.addEventListener('click', () => core.pauseRadio());
stopButton.addEventListener('click', () => core.stopRadio());

// Native audio events → core (iOS needs the mediaSession.playbackState override)
player.addEventListener('play', () => {
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
core.onPlayerPlay();
});

player.addEventListener('pause', () => {
const s = core.getState();
// 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.
if ('mediaSession' in navigator) {
if (s === 'loading' || s === 'retrying' || s === 'recovering') {
navigator.mediaSession.playbackState = 'playing';
} else if (s === 'playing') {
navigator.mediaSession.playbackState = 'paused';
}
}
core.onPlayerPause();
});

// Stream failure during playback (lost WiFi, server died, etc.)
// Silent failures (no 'error' event, audio just stops — common on HLS and
// flaky wifi) are caught by the core's playback-progress watchdog instead of
// the unreliable 'stalled' event.
player.addEventListener('error', () => core.onPlayerError());

// Auto-recovery when network comes back
window.addEventListener('online', () => core.retryFromError());

// Prev / Next
prevButton.addEventListener('click', () => {
warmUpFeedbackSounds();
core.prevRadio();
});
nextButton.addEventListener('click', () => {
warmUpFeedbackSounds();
core.nextRadio();
});

// Logo reloads the page
logoButton.addEventListener('click', () => {
window.location.reload();
});

// Keep alive worker
const worker = new Worker("./js/keepAlive.js");
worker.onmessage = () => {};

initServiceWorker(core);
initThemeColor();
initStationSelector({
onSelect(index) {
warmUpFeedbackSounds();
core.playRadio(index);
},
});
Loading