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>
193 lines
6.6 KiB
JavaScript
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();
|
|
}
|
|
}
|