Files
skills/orchestrator/templates/page-radio.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 c035373518 feat(orchestrator)!: S0 English everywhere, Sécurité, NATO hashes, ICAO digits
Requirement 001 phase 6, S0:

- Speech: the security class shows as "Sécurité" and is spoken as in
  maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more
  characters with a letter and a digit (commit, session, message ids) is
  spelled in the NATO alphabet; every other digit is spoken on its own
  in ICAO pronunciation (42305: four two tree zero fife). The spelling
  runs inside clip() before the length cut, so a phrase still fits
  SPEECH_LIMIT; the displayed text keeps its digits and hashes.
- English: every orchestrator label, tooltip, message, error, script
  output, document and test string is English (Regeln -> Firewall,
  Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...);
  the page declares lang="en" and formats dates as en-GB. Task filter
  ids are English. German stays only in the skill trigger phrases that
  quote the operator, and as identifiers: the view keys (view=) and the
  leitstand id.
- tests/test_english.py scans every tracked text file for umlauts and
  German words, with an allowlist of the quoted trigger phrases.

BREAKING CHANGE: the Stream search tokens art: and seit: are now kind:
and since:; an old art: or seit: token counts as a plain word.

Evidence: make test 657 OK, ruff and markdownlint clean
(/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log);
MEDUSA 86 files, 24 findings on reworded strings accepted after an
independent skeptic (21 path:line, 0 rejected, no behaviour change in
signing, approving, rules, denying, offices, events), rescan 0 open.

🎯 Fix: 93 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:26:51 +00:00

193 lines
6.6 KiB
JavaScript

const PLAY_REFUSED = {
NotAllowedError: ["blocked", "Click needed", "The browser blocks autoplay: a click on ▶ or anywhere on the page starts the station."],
NotSupportedError: ["error", "not playable", "Not playable: the browser does not understand the format or the address, or the station redirects to an address the page may not load sound from."],
};
const MEDIA_ERRORS = {1: "Loading aborted", 2: "Network error", 3: "Sound not decodable", 4: "Format or address not playable"};
const RESOLVE_STEP = "Radio → Edit stations: change the station's address, or remove it, save, add it again and save once more; then the server resolves the redirect again and releases its target.";
function radioVolume() {
if (radio.element) {
radio.element.volume = Math.min(Math.max(radio.level * settings.music.volume * (music.ducked ? DUCK : 1), 0), 1);
}
}
function fadeRadio(target, done) {
window.clearInterval(radio.timer);
if (radio.level === target) {
done();
return;
}
const from = radio.level;
const began = performance.now();
radio.timer = window.setInterval(() => {
const progress = Math.min((performance.now() - began) / FADE, 1);
radio.level = from + (target - from) * progress;
radioVolume();
if (progress >= 1) {
window.clearInterval(radio.timer);
done();
}
}, 100);
}
function radioStatus(kind, label, note) {
Object.assign(radio, {state: kind, short: label, note});
drawStations();
}
function radioTrouble(kind, label, note, cause) {
const told = radio.told;
const fresh = Ambience.retold(told, radio.url, kind, cause);
if (!fresh) {
radioStatus(...told.status);
return;
}
radioStatus(kind, label, note);
if (told) {
told.box.remove();
}
const stream = settings.streams[radio.index];
const box = toast(`Radio ${kind === "blocked" ? "blocked" : "silent"}: ${stream ? stream.name : radio.url}`, [note], false);
radio.told = {...fresh, status: [kind, label, note], box};
}
function redirectNote(stream) {
if (stream && stream.via === null) {
return "The server has not resolved this station's redirect yet; saving after ten minutes tries again.";
}
const released = stream ? stream.via : [];
const unloaded = released.filter((origin) => !pageMedia.has(origin));
if (unloaded.length) {
return `The station redirects to ${unloaded.join(", ")}; the loaded page may not load sound from there yet: reload the page.`;
}
return released.length
? `The station redirects to an address outside the released ${released.join(", ")}. ${RESOLVE_STEP}`
: `The station redirects to an address that is not released yet; the browser does not name the target of a redirect. ${RESOLVE_STEP}`;
}
function stopStream() {
radio.element.pause();
radio.element.removeAttribute("src");
radio.element.load();
radio.playing = false;
tuneMusic(FADE / 1000);
}
function dueStation() {
const sig = JSON.stringify(settings.streams);
if (sig !== radio.sig) {
radio.sig = sig;
radio.pick = null;
}
if (radio.pick === -1) {
return -1;
}
if (radio.pick !== null && settings.streams[radio.pick]) {
return radio.pick;
}
const due = Ambience.activeStream(settings.streams, Ambience.slotOf(new Date()));
return due ? settings.streams.indexOf(due) : -1;
}
function syncRadio() {
const index = dueStation();
const stream = index < 0 ? null : settings.streams[index];
const url = stream ? stream.url : "";
if (url === radio.url && !radio.waiting) {
drawStations();
return;
}
const missing = stream ? Ambience.stationOrigins(stream).filter((origin) => !pageMedia.has(origin)) : [];
radio.index = index;
if (missing.length) {
radioStatus("reload", "reloading", `New address ${missing.join(", ")}: the page reloads so the browser may load sound from there.`);
if (missing.every((origin) => state.media.includes(origin))) {
window.location.reload();
}
return;
}
radio.url = url;
radio.waiting = false;
radioStatus(url ? "loading" : "", url ? "connecting" : "", url ? "Connecting to the station …" : "");
fadeRadio(0, () => {
if (radio.url !== url) {
return;
}
if (!url) {
stopStream();
return;
}
radio.element.src = url;
radio.element.play().then(() => {
radio.playing = true;
radio.told = null;
radioStatus("playing", "playing", `playing since ${clock.format(new Date())}`);
tuneMusic(FADE / 1000);
fadeRadio(1, () => null);
}).catch((error) => {
if (radio.url !== url || error.name === "AbortError") {
return;
}
radio.waiting = true;
const [kind, label, note] = PLAY_REFUSED[error.name] || ["error", "Error", `${error.name}: ${error.message}`];
radioTrouble(kind, label, note, "refused");
});
});
}
function playStation(index) {
const stream = settings.streams[index];
radio.pick = index;
if (!(radio.playing && radio.url === stream.url)) {
radio.waiting = true;
}
syncRadio();
}
function stopStation() {
radio.pick = -1;
syncRadio();
}
function radioElement() {
const element = h("audio", {preload: "none"});
element.addEventListener("error", () => {
if (!element.getAttribute("src")) {
return;
}
const failure = element.error;
radio.playing = false;
radio.waiting = true;
tuneMusic(FADE / 1000);
const reason = failure ? MEDIA_ERRORS[failure.code] || `Error ${failure.code}` : "unknown error";
radioTrouble("error", "Error", `The station does not play: ${reason}${failure && failure.message ? ` (${failure.message})` : ""}.`, "media");
});
document.addEventListener("securitypolicyviolation", (event) => {
// Trip-wire: for a redirected load, blockedURI is the station's own URL, never the redirect target.
if (event.effectiveDirective === "media-src" && radio.url && !event.blockedURI.startsWith(window.location.origin)) {
radioTrouble("error", "blocked", redirectNote(settings.streams[radio.index]), "csp");
}
});
return element;
}
function duck(seconds) {
music.ducked += 1;
tuneMusic(0.15);
radioVolume();
window.setTimeout(() => {
music.ducked -= 1;
tuneMusic(1.5);
radioVolume();
}, (seconds + 0.6) * 1000);
}
function unlock() {
if (settings.music.on) {
context();
}
if (radio.waiting) {
syncRadio();
}
}