feat: Adding audio player + music to play
This commit is contained in:
+2
-2
@@ -104,8 +104,8 @@ body {
|
||||
|
||||
#side {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
.cal__menu { display: block; width: 100%; margin-top: 8px; }
|
||||
|
||||
.menu__folder {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #a37a24 rgba(110, 75, 25, 0.25);
|
||||
position: relative;
|
||||
width: min(580px, 94%);
|
||||
max-height: 100%;
|
||||
@@ -147,3 +149,18 @@
|
||||
.rules th { background: rgba(28, 42, 68, 0.1); font-weight: 700; }
|
||||
.rules blockquote { margin: 8px 0; padding: 4px 10px; border-left: 4px solid var(--ink-red); background: rgba(255, 255, 255, 0.3); font-style: italic; }
|
||||
.rules hr { border: 0; border-top: 1px dashed rgba(43, 36, 24, 0.5); margin: 14px 0; }
|
||||
|
||||
/* ---- The sound mixer: a label, a slider and the level on each line ---- */
|
||||
.mixer { display: grid; margin: 8px 0 12px; }
|
||||
.mixer__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) 3.2em;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px dotted rgba(43, 36, 24, 0.4);
|
||||
font-size: 13px;
|
||||
}
|
||||
.mixer__row--master { margin-bottom: 4px; font-weight: 700; border-bottom: 2px solid var(--ink); }
|
||||
.mixer__row input[type="range"] { width: 100%; min-height: 30px; margin: 0; accent-color: #a37a24; }
|
||||
.mixer__row output { font-family: var(--font-num); font-weight: 700; text-align: right; }
|
||||
|
||||
+11
-8
@@ -24,21 +24,24 @@
|
||||
#pad > * { grid-column: 1; }
|
||||
|
||||
/* Papers lie flat on a phone: a rotated sheet wastes the little width there is. */
|
||||
.paper { transform: none; }
|
||||
.paper, #walkman { transform: none; }
|
||||
#side, #ledger, #dashboard, #folder { overflow-x: hidden; }
|
||||
|
||||
/* One paper at a time */
|
||||
#dashboard, #folder, #ledger, #newspaper, #calendar { grid-row: 2; display: none; overflow-y: auto; }
|
||||
#side { display: contents; }
|
||||
#dashboard, #folder, #ledger { grid-row: 2; display: none; overflow-y: auto; }
|
||||
#side { grid-row: 2; display: none; grid-template-rows: none; align-content: start; gap: 12px; overflow-y: auto; }
|
||||
#calendar, #walkman, #newspaper { display: none; }
|
||||
#pad[data-view="tray"] #folder,
|
||||
#pad[data-view="brief"] #dashboard,
|
||||
#pad[data-view="news"] #newspaper,
|
||||
#pad[data-view="clock"] #calendar { display: block; }
|
||||
#calendar { align-self: start; }
|
||||
#pad[data-view="brief"] #dashboard { display: block; }
|
||||
#pad[data-view="clock"] #side,
|
||||
#pad[data-view="news"] #side { display: grid; }
|
||||
#pad[data-view="clock"] #calendar,
|
||||
#pad[data-view="clock"] #walkman,
|
||||
#pad[data-view="news"] #newspaper { display: block; }
|
||||
|
||||
/* The ledger is a view of its own while a review is open; the tabs still reach the other papers */
|
||||
#pad.is-ledger #folder { display: none; }
|
||||
#pad[data-view="ledger"] #ledger:not([hidden]) { display: block; }
|
||||
#pad.is-ledger #side { display: contents; }
|
||||
#ledger { grid-column: 1; }
|
||||
|
||||
/* Menu and end screens cover the shelf too */
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* A small silver cassette player on the desk, with an orange brand stripe and chunky keys */
|
||||
|
||||
#walkman {
|
||||
position: relative;
|
||||
transform: rotate(var(--rot, 0deg));
|
||||
filter: drop-shadow(0 8px 9px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.walkman__body {
|
||||
padding: 8px 10px 10px;
|
||||
background: linear-gradient(160deg, #d3d6d8 0%, #acb0b4 45%, #92969a 100%);
|
||||
border: 1px solid #5d6064;
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75), inset 0 -3px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.walkman__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-family: var(--font-num);
|
||||
color: #3a3d40;
|
||||
}
|
||||
.walkman__badge b { font-size: 12px; letter-spacing: 0.22em; color: #c4521f; }
|
||||
.walkman__badge span { flex: 1; font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.8; }
|
||||
|
||||
.walkman__led {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #5a1b14;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.6);
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.walkman.is-playing .walkman__led { background: #ff5a3c; box-shadow: 0 0 8px 2px rgba(255, 90, 60, 0.65); }
|
||||
|
||||
/* The cassette window: two reels around the label with the title */
|
||||
.walkman__window {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 38px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: linear-gradient(#26292c, #15171a);
|
||||
border: 2px solid #4b4e52;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.85), 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.walkman__reel { width: 38px; height: 38px; }
|
||||
.walkman__tape { fill: #3b2b21; stroke: #1b130e; stroke-width: 1.5; }
|
||||
.walkman__spokes line { stroke: #d9d3c1; stroke-width: 2.4; stroke-linecap: round; }
|
||||
.walkman__spokes { transform-box: fill-box; transform-origin: 50% 50%; }
|
||||
.walkman__hub { fill: #d9d3c1; stroke: #8a8472; stroke-width: 1; }
|
||||
.walkman.is-playing .walkman__reel--left .walkman__spokes { animation: wm-spin 3.2s linear infinite; }
|
||||
.walkman.is-playing .walkman__reel--right .walkman__spokes { animation: wm-spin 2.4s linear infinite; }
|
||||
|
||||
.walkman__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 7px;
|
||||
overflow: hidden;
|
||||
background: #efe4c4 var(--paper-grain);
|
||||
border-bottom: 3px solid #c4521f;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 0 8px rgba(120, 90, 40, 0.3);
|
||||
font-family: var(--font-type);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.walkman__title { display: block; white-space: nowrap; }
|
||||
.walkman.is-empty .walkman__title { opacity: 0.55; font-style: italic; }
|
||||
.walkman.is-playing .walkman__title.is-scroll { width: max-content; animation: wm-marquee 11s linear infinite; }
|
||||
|
||||
/* Vintage keys: raised piano keys that sink when pressed */
|
||||
.walkman__keys { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 9px; }
|
||||
.walkman__key {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ebe7d8;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(#63676b, #34373a);
|
||||
border: 1px solid #1d1f21;
|
||||
border-bottom-width: 4px;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.07s, border-bottom-width 0.07s, filter 0.15s;
|
||||
}
|
||||
.walkman__key svg { width: 20px; height: 14px; fill: currentColor; }
|
||||
.walkman__key--play { box-shadow: inset 0 3px 0 #4f8f5a, 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||
.walkman__key--stop { box-shadow: inset 0 3px 0 #c4521f, 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||
.walkman__key:hover:not(:disabled) { filter: brightness(1.18); }
|
||||
.walkman__key:active:not(:disabled),
|
||||
.walkman__key[aria-pressed="true"] {
|
||||
border-bottom-width: 1px;
|
||||
transform: translateY(3px);
|
||||
background: linear-gradient(#34373a, #4b4e52);
|
||||
}
|
||||
.walkman__key:focus-visible { outline: 2px solid #c4521f; outline-offset: 2px; }
|
||||
.walkman__key:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
@keyframes wm-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes wm-marquee {
|
||||
from { transform: translateX(40%); }
|
||||
to { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.walkman.is-playing .walkman__spokes,
|
||||
.walkman.is-playing .walkman__title.is-scroll { animation: none !important; }
|
||||
.walkman__title { overflow: hidden; text-overflow: ellipsis; }
|
||||
}
|
||||
@@ -162,6 +162,12 @@ The game **autosaves** in your browser, and the autosave is removed when a game
|
||||
| Y / N | Approve or reject the top proposal |
|
||||
| Esc | Open the menu (time stops while it is open) |
|
||||
|
||||
### Sound and music
|
||||
|
||||
Soft sound effects accompany the desk: stamps, paper, telegrams. In **Settings → Sound mixer** every sound has its own slider, so you can turn down only what bothers you. On the day before each new proposal a small clock ticks while the dial turns.
|
||||
|
||||
The cassette player under the calendar plays the `.mp3` files of the `music` folder in random order. Its keys are previous, play, pause, stop and next. With no file in the folder it reads "No tape".
|
||||
|
||||
## Advice
|
||||
|
||||
- Balance the books, but do not be shy: bold investment pays up to a point, then the debt calls.
|
||||
|
||||
@@ -162,6 +162,12 @@ Le jeu fait une **sauvegarde auto** dans votre navigateur, supprimée quand une
|
||||
| Y / N | Approuver ou rejeter la proposition du dessus |
|
||||
| Échap | Ouvrir le menu (le temps s'arrête tant qu'il est ouvert) |
|
||||
|
||||
### Sons et musique
|
||||
|
||||
De discrets effets sonores accompagnent le bureau : tampons, papier, télégrammes. Dans **Réglages → Table de mixage**, chaque son a son propre curseur : vous ne baissez que ce qui vous gêne. La veille de chaque nouveau dossier, une petite horloge fait tic-tac pendant que le cadran tourne.
|
||||
|
||||
Le lecteur de cassettes, sous le calendrier, joue dans un ordre aléatoire les fichiers `.mp3` du dossier `music`. Ses touches sont : précédent, lecture, pause, arrêt, suivant. Sans fichier dans le dossier, il affiche « Pas de cassette ».
|
||||
|
||||
## Conseils
|
||||
|
||||
- Équilibrez les comptes, mais sans timidité : l'investissement audacieux paie jusqu'à un certain point, puis la dette réclame son dû.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="css/decision.css">
|
||||
<link rel="stylesheet" href="css/endgame.css">
|
||||
<link rel="stylesheet" href="css/menu.css">
|
||||
<link rel="stylesheet" href="css/walkman.css">
|
||||
<link rel="stylesheet" href="css/anim.css">
|
||||
<link rel="stylesheet" href="css/mobile.css">
|
||||
</head>
|
||||
@@ -27,6 +28,7 @@
|
||||
<section id="menu" hidden aria-label="Menu"></section>
|
||||
<div id="side">
|
||||
<section id="calendar" class="paper paper--calendar"></section>
|
||||
<section id="walkman" class="walkman" aria-label="Cassette player"></section>
|
||||
<section id="newspaper" class="paper paper--news"></section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+29
-1
@@ -202,5 +202,33 @@
|
||||
"rules.error": "The rule book could not be opened.",
|
||||
"settings.sound": "Sounds",
|
||||
"settings.soundHint": "Soft effects: stamps, paper, telegrams.",
|
||||
"settings.volume": "Volume"
|
||||
"settings.volume": "Volume",
|
||||
"walkman.title": "Cassette player",
|
||||
"walkman.model": "Stereo cassette",
|
||||
"walkman.prev": "Previous tape",
|
||||
"walkman.play": "Play",
|
||||
"walkman.pause": "Pause",
|
||||
"walkman.stop": "Stop",
|
||||
"walkman.next": "Next tape",
|
||||
"walkman.empty": "No tape",
|
||||
"walkman.stopped": "Stopped",
|
||||
"walkman.tap": "Press play",
|
||||
"walkman.hint": "Put .mp3 files in the music folder to load a tape.",
|
||||
"settings.mixer": "Sound mixer",
|
||||
"mixer.title": "Sound mixer",
|
||||
"mixer.hint": "Slide each sound down to your taste. You hear the result at once.",
|
||||
"mixer.reset": "Reset levels",
|
||||
"mixer.master": "Overall volume",
|
||||
"mixer.music": "Music",
|
||||
"mixer.stamp": "Stamps",
|
||||
"mixer.paper": "Paper",
|
||||
"mixer.page": "Page turns",
|
||||
"mixer.sign": "Signing the ledger",
|
||||
"mixer.telegram": "Telegrams",
|
||||
"mixer.alert": "Danger warnings",
|
||||
"mixer.chime": "Full-tray chime",
|
||||
"mixer.tick": "Daily tick",
|
||||
"mixer.countdown": "Clock before a new proposal",
|
||||
"mixer.click": "Buttons",
|
||||
"mixer.ending": "End of term"
|
||||
}
|
||||
|
||||
+29
-1
@@ -202,5 +202,33 @@
|
||||
"rules.error": "Le règlement n'a pas pu être ouvert.",
|
||||
"settings.sound": "Sons",
|
||||
"settings.soundHint": "Effets discrets : tampons, papier, télégrammes.",
|
||||
"settings.volume": "Volume"
|
||||
"settings.volume": "Volume",
|
||||
"walkman.title": "Lecteur de cassettes",
|
||||
"walkman.model": "Cassette stéréo",
|
||||
"walkman.prev": "Piste précédente",
|
||||
"walkman.play": "Lecture",
|
||||
"walkman.pause": "Pause",
|
||||
"walkman.stop": "Arrêt",
|
||||
"walkman.next": "Piste suivante",
|
||||
"walkman.empty": "Pas de cassette",
|
||||
"walkman.stopped": "Arrêté",
|
||||
"walkman.tap": "Appuyez sur lecture",
|
||||
"walkman.hint": "Déposez des fichiers .mp3 dans le dossier music pour charger une cassette.",
|
||||
"settings.mixer": "Table de mixage",
|
||||
"mixer.title": "Table de mixage",
|
||||
"mixer.hint": "Baissez chaque son selon votre goût. Le résultat s'entend aussitôt.",
|
||||
"mixer.reset": "Rétablir les niveaux",
|
||||
"mixer.master": "Volume général",
|
||||
"mixer.music": "Musique",
|
||||
"mixer.stamp": "Tampons",
|
||||
"mixer.paper": "Papier",
|
||||
"mixer.page": "Pages tournées",
|
||||
"mixer.sign": "Signature du registre",
|
||||
"mixer.telegram": "Télégrammes",
|
||||
"mixer.alert": "Alertes de danger",
|
||||
"mixer.chime": "Carillon de corbeille pleine",
|
||||
"mixer.tick": "Tic quotidien",
|
||||
"mixer.countdown": "Horloge avant un nouveau dossier",
|
||||
"mixer.click": "Boutons",
|
||||
"mixer.ending": "Fin de mandat"
|
||||
}
|
||||
|
||||
+52
-5
@@ -26,6 +26,9 @@ import { createEndgame } from "./ui/endgame.js";
|
||||
import { createMenu } from "./ui/menu.js";
|
||||
import { createShelf } from "./ui/shelf.js";
|
||||
import { createAudio } from "./ui/audio.js";
|
||||
import { createMusic } from "./ui/music.js";
|
||||
import { createWalkman } from "./ui/walkman.js";
|
||||
import { DEFAULT_MIX } from "./save/storage.js";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const getJson = (url) => fetch(url).then((r) => r.json());
|
||||
@@ -54,7 +57,23 @@ let settings = loadSettings(storage);
|
||||
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
||||
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
||||
|
||||
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume });
|
||||
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume, mix: () => settings.mix });
|
||||
|
||||
// Background music from the music folder, driven by the cassette player on the desk.
|
||||
const music = createMusic({
|
||||
getVolume: () => settings.volume * settings.mix.music,
|
||||
onUserChoice: (on) => { if (settings.musicOn !== on) applySettings({ musicOn: on }); },
|
||||
});
|
||||
let gestured = false;
|
||||
const startMusic = () => gestured && music.unlockedByGesture(settings.musicOn);
|
||||
for (const ev of ["pointerdown", "keydown"]) {
|
||||
document.addEventListener(ev, (e) => {
|
||||
if (e.target.closest?.("#walkman")) return; // pressing a walkman key is its own choice
|
||||
gestured = true;
|
||||
startMusic();
|
||||
}, { passive: true });
|
||||
}
|
||||
music.load().then(startMusic);
|
||||
|
||||
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
||||
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
||||
@@ -79,9 +98,11 @@ const el = {
|
||||
news: document.getElementById("newspaper"),
|
||||
menu: document.getElementById("menu"),
|
||||
shelf: document.getElementById("shelf"),
|
||||
walkman: document.getElementById("walkman"),
|
||||
};
|
||||
const pad = document.getElementById("pad");
|
||||
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
||||
tilt(el.walkman, "walkman", 0.5);
|
||||
|
||||
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
|
||||
function prepareWeek() {
|
||||
@@ -136,6 +157,7 @@ function build() {
|
||||
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
|
||||
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
||||
shelf: createShelf(el.shelf, balance, { getView: () => view, onView: setView, onSpeed, onMenu: openMenu }),
|
||||
walkman: createWalkman(el.walkman, { music, onPress: () => audio.play("click") }),
|
||||
news: createNewspaper(el.news, newsTpl),
|
||||
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
||||
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
||||
@@ -218,7 +240,7 @@ function quitToMenu() {
|
||||
panels?.decision.destroy();
|
||||
state = null;
|
||||
panels = null;
|
||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf"]) el[id].innerHTML = "";
|
||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf", "walkman"]) el[id].innerHTML = "";
|
||||
document.title = t("app.title");
|
||||
el.ledger.hidden = true;
|
||||
el.alerts.hidden = true;
|
||||
@@ -231,9 +253,12 @@ function quitToMenu() {
|
||||
|
||||
function applySettings(patch) {
|
||||
const langChanged = patch.lang && patch.lang !== getLang();
|
||||
settings = { ...settings, ...patch };
|
||||
const { resetMix, ...rest } = patch;
|
||||
settings = { ...settings, ...rest, mix: resetMix ? { ...DEFAULT_MIX } : { ...settings.mix, ...rest.mix } };
|
||||
if (resetMix) settings.volume = 0.6;
|
||||
saveSettings(storage, settings);
|
||||
if (patch.sound === true || patch.volume !== undefined) audio.play("click");
|
||||
if (patch.sound === true) audio.play("click");
|
||||
if (patch.volume !== undefined || patch.mix?.music !== undefined || resetMix) music.applyVolume();
|
||||
if (!langChanged) return;
|
||||
setLang(patch.lang);
|
||||
document.title = t("app.title");
|
||||
@@ -241,6 +266,12 @@ function applySettings(patch) {
|
||||
menu.refresh();
|
||||
}
|
||||
|
||||
// The sound heard when a mixer slider is released.
|
||||
const PREVIEW = {
|
||||
master: "chime", stamp: "stampYes", paper: "paper", page: "page", sign: "sign", telegram: "telegram", alert: "alert",
|
||||
chime: "chime", tick: "tick", countdown: "clockTick", click: "click", ending: "fanfare",
|
||||
};
|
||||
|
||||
const menu = createMenu(el.menu, {
|
||||
content,
|
||||
storage,
|
||||
@@ -252,6 +283,7 @@ const menu = createMenu(el.menu, {
|
||||
onResume: closeMenu,
|
||||
onQuit: quitToMenu,
|
||||
onSettings: applySettings,
|
||||
onPreview: (key) => audio.play(PREVIEW[key] ?? "click"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -345,7 +377,7 @@ function step(now) {
|
||||
if (!r.advanced) break;
|
||||
advanced = true;
|
||||
if (r.events.weeklyCard) prepareWeek();
|
||||
if (!document.hidden && state.speed <= 2) audio.play(state.day % 2 ? "tick" : "tock");
|
||||
if (!document.hidden && state.speed <= 2 && !isDayBeforeIssue()) audio.play(state.day % 2 ? "tick" : "tock");
|
||||
if (isWaiting(state) || state.over) break;
|
||||
}
|
||||
if (advanced) render();
|
||||
@@ -355,12 +387,27 @@ function step(now) {
|
||||
const devMsPerDay = devRun && params.has("msPerDay") ? Number(params.get("msPerDay")) : 0; // dev: faster days for tests
|
||||
const msPerDay = () => devMsPerDay || (settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay);
|
||||
|
||||
// The day before a new proposal arrives (the Sunday before each Monday), a small clock ticks while that day runs.
|
||||
const isDayBeforeIssue = () => (state.day + 1) % balance.time.daysPerWeek === 0;
|
||||
let lastCountdown = "";
|
||||
|
||||
function countdownTicks(p) {
|
||||
if (!state || menuOpen || state.speed <= 0 || state.over || isWaiting(state) || document.hidden || !isDayBeforeIssue()) return;
|
||||
const perDay = state.speed >= 4 ? 10 : 20; // the ticks follow the dial: faster at higher speeds
|
||||
const n = Math.floor(p * perDay);
|
||||
const key = `${state.day}:${n}`;
|
||||
if (key === lastCountdown) return;
|
||||
lastCountdown = key;
|
||||
audio.play(n % 2 ? "clockTock" : "clockTick");
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
step(now);
|
||||
if (panels) {
|
||||
const p = Math.min(1, clock.acc / msPerDay());
|
||||
panels.cal.clockHand(p);
|
||||
panels.shelf.clockHand(p);
|
||||
countdownTicks(p);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
+11
-3
@@ -88,7 +88,13 @@ export function readSave(store, key) {
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6 };
|
||||
// The mixer: one level (0 to 1) per sound group and one for the music. Keep in step with MIX_GROUPS in ui/audio.js.
|
||||
export const MIX_KEYS = ["stamp", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "countdown", "click", "ending", "music"];
|
||||
export const DEFAULT_MIX = Object.fromEntries(MIX_KEYS.map((k) => [k, k === "music" ? 0.5 : 1]));
|
||||
|
||||
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6, mix: DEFAULT_MIX, musicOn: true };
|
||||
|
||||
const level = (v, fallback) => (typeof v === "number" && v >= 0 && v <= 1 ? v : fallback);
|
||||
|
||||
export function loadSettings(store) {
|
||||
try {
|
||||
@@ -97,10 +103,12 @@ export function loadSettings(store) {
|
||||
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
||||
slow: s.slow === true,
|
||||
sound: s.sound !== false,
|
||||
volume: typeof s.volume === "number" && s.volume >= 0 && s.volume <= 1 ? s.volume : DEFAULT_SETTINGS.volume,
|
||||
volume: level(s.volume, DEFAULT_SETTINGS.volume),
|
||||
mix: Object.fromEntries(MIX_KEYS.map((k) => [k, level(s.mix?.[k], DEFAULT_MIX[k])])),
|
||||
musicOn: s.musicOn !== false,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
return { ...DEFAULT_SETTINGS, mix: { ...DEFAULT_MIX } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+99
-44
@@ -1,24 +1,63 @@
|
||||
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
|
||||
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
|
||||
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft: slow
|
||||
// attacks, long fades, no deep bass, and a short room reverb under it all so sounds melt away
|
||||
// instead of stopping. Each sound belongs to a mixer group the player can turn down on its own.
|
||||
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
|
||||
|
||||
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
// Mixer groups, in the order the mixer shows them.
|
||||
export const MIX_GROUPS = ["stamp", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "countdown", "click", "ending"];
|
||||
|
||||
const GROUP_OF = {
|
||||
stampYes: "stamp", stampNo: "stamp", paper: "paper", page: "page", sign: "sign", telegram: "telegram",
|
||||
alert: "alert", chime: "chime", tick: "tick", tock: "tick", clockTick: "countdown", clockTock: "countdown",
|
||||
click: "click", fall: "ending", fanfare: "ending",
|
||||
};
|
||||
|
||||
export function createAudio({ enabled = () => true, volume = () => 0.6, mix = () => ({}) } = {}) {
|
||||
let ctx = null;
|
||||
let master = null;
|
||||
let noiseBuf = null;
|
||||
let out = null; // where the sound being built sends its notes (a gain node per play)
|
||||
|
||||
function context() {
|
||||
if (!ctx) {
|
||||
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
try {
|
||||
ctx = new AC();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
master = ctx.createGain();
|
||||
master.connect(ctx.destination);
|
||||
if (ctx) return ctx;
|
||||
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
try {
|
||||
ctx = new AC();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// master -> no deep bass -> no harsh treble -> gentle limiter -> speakers, plus a soft room send
|
||||
master = ctx.createGain();
|
||||
const lowCut = ctx.createBiquadFilter();
|
||||
lowCut.type = "highpass";
|
||||
lowCut.frequency.value = 140;
|
||||
const highCut = ctx.createBiquadFilter();
|
||||
highCut.type = "lowpass";
|
||||
highCut.frequency.value = 4800;
|
||||
highCut.Q.value = 0.4;
|
||||
const limiter = ctx.createDynamicsCompressor();
|
||||
limiter.threshold.value = -22;
|
||||
limiter.knee.value = 24;
|
||||
limiter.ratio.value = 6;
|
||||
limiter.attack.value = 0.01;
|
||||
limiter.release.value = 0.35;
|
||||
master.connect(lowCut).connect(highCut).connect(limiter).connect(ctx.destination);
|
||||
|
||||
const room = ctx.createConvolver();
|
||||
const len = Math.floor(ctx.sampleRate * 1.5);
|
||||
const ir = ctx.createBuffer(2, len, ctx.sampleRate);
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const d = ir.getChannelData(ch);
|
||||
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6);
|
||||
}
|
||||
room.buffer = ir;
|
||||
const wet = ctx.createGain();
|
||||
wet.gain.value = 0.28;
|
||||
const roomTone = ctx.createBiquadFilter();
|
||||
roomTone.type = "lowpass";
|
||||
roomTone.frequency.value = 2600;
|
||||
highCut.connect(wet).connect(room).connect(roomTone).connect(limiter);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -28,8 +67,8 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
}
|
||||
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
|
||||
|
||||
// One note: a sine (or other) wave with an attack and an exponential fade.
|
||||
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
|
||||
// One note: a wave with an attack and an exponential fade.
|
||||
function tone(c, at, { freq, to, type = "sine", dur = 0.3, gain = 0.1, attack = 0.02 }) {
|
||||
const o = c.createOscillator();
|
||||
const g = c.createGain();
|
||||
o.type = type;
|
||||
@@ -38,13 +77,13 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
g.gain.setValueAtTime(0.0001, at);
|
||||
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
o.connect(g).connect(master);
|
||||
o.connect(g).connect(out);
|
||||
o.start(at);
|
||||
o.stop(at + dur + 0.05);
|
||||
}
|
||||
|
||||
// Filtered noise: paper, pages, thumps and clicks.
|
||||
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
|
||||
// Filtered noise: paper, pages, soft impacts and ticks.
|
||||
function noise(c, at, { dur = 0.2, gain = 0.06, type = "bandpass", freq = 2000, to, q = 0.6, attack = 0.03 }) {
|
||||
noiseBuf ??= (() => {
|
||||
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||
const d = b.getChannelData(0);
|
||||
@@ -63,55 +102,66 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
g.gain.setValueAtTime(0.0001, at);
|
||||
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
src.connect(f).connect(g).connect(master);
|
||||
src.connect(f).connect(g).connect(out);
|
||||
src.start(at);
|
||||
src.stop(at + dur + 0.05);
|
||||
}
|
||||
|
||||
const SOUNDS = {
|
||||
// A padded "thup" rather than a thud: mid-range, slow to start, with a soft paper tail.
|
||||
stampYes: (c, t) => {
|
||||
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
|
||||
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
|
||||
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
|
||||
tone(c, t, { freq: 230, to: 130, dur: 0.32, gain: 0.2, attack: 0.016 });
|
||||
noise(c, t, { type: "lowpass", freq: 1100, dur: 0.18, gain: 0.09, attack: 0.014, q: 0.4 });
|
||||
noise(c, t + 0.02, { freq: 2000, to: 1400, dur: 0.32, gain: 0.025, attack: 0.03 });
|
||||
},
|
||||
stampNo: (c, t) => {
|
||||
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
|
||||
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
|
||||
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
|
||||
tone(c, t, { freq: 190, to: 108, dur: 0.34, gain: 0.2, attack: 0.016 });
|
||||
noise(c, t, { type: "lowpass", freq: 900, dur: 0.2, gain: 0.09, attack: 0.014, q: 0.4 });
|
||||
noise(c, t + 0.02, { freq: 1700, to: 1200, dur: 0.32, gain: 0.025, attack: 0.03 });
|
||||
},
|
||||
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
|
||||
paper: (c, t) => noise(c, t, { freq: 2400, to: 1100, dur: 0.36, gain: 0.075, attack: 0.09 }),
|
||||
page: (c, t) => {
|
||||
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
|
||||
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
|
||||
noise(c, t, { freq: 450, to: 2800, dur: 0.52, gain: 0.085, attack: 0.13 });
|
||||
noise(c, t + 0.4, { freq: 2200, dur: 0.18, gain: 0.035, attack: 0.03 });
|
||||
},
|
||||
sign: (c, t) => {
|
||||
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
|
||||
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
|
||||
noise(c, t, { freq: 3200, to: 2400, dur: 0.42, gain: 0.04, q: 2, attack: 0.11 });
|
||||
tone(c, t + 0.44, { freq: 200, to: 125, dur: 0.28, gain: 0.15, attack: 0.02 });
|
||||
noise(c, t + 0.44, { type: "lowpass", freq: 1000, dur: 0.14, gain: 0.06, attack: 0.012, q: 0.4 });
|
||||
},
|
||||
telegram: (c, t) => {
|
||||
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
|
||||
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
|
||||
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
|
||||
for (let i = 0; i < 4; i++) noise(c, t + i * 0.09, { type: "highpass", freq: 3200, dur: 0.045, gain: 0.05, attack: 0.007 });
|
||||
tone(c, t + 0.44, { freq: 1320, dur: 1.6, gain: 0.07, attack: 0.04 });
|
||||
tone(c, t + 0.44, { freq: 1980, dur: 1.1, gain: 0.022, attack: 0.04 });
|
||||
},
|
||||
alert: (c, t) => {
|
||||
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
|
||||
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
|
||||
tone(c, t, { freq: 233, dur: 0.95, gain: 0.12, type: "triangle", attack: 0.16 });
|
||||
tone(c, t + 0.32, { freq: 175, dur: 1.25, gain: 0.12, type: "triangle", attack: 0.16 });
|
||||
},
|
||||
chime: (c, t) => {
|
||||
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
|
||||
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
|
||||
tone(c, t, { freq: 659, dur: 1.4, gain: 0.08, attack: 0.06 });
|
||||
tone(c, t + 0.22, { freq: 880, dur: 1.8, gain: 0.065, attack: 0.06 });
|
||||
},
|
||||
tick: (c, t) => tone(c, t, { freq: 1000, to: 850, dur: 0.07, gain: 0.02, attack: 0.007 }),
|
||||
tock: (c, t) => tone(c, t, { freq: 760, to: 650, dur: 0.07, gain: 0.02, attack: 0.007 }),
|
||||
// The countdown on the day before a new proposal: a small wooden clock.
|
||||
clockTick: (c, t) => {
|
||||
tone(c, t, { freq: 1500, to: 1150, dur: 0.1, gain: 0.05, attack: 0.005 });
|
||||
noise(c, t, { freq: 2800, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 });
|
||||
},
|
||||
clockTock: (c, t) => {
|
||||
tone(c, t, { freq: 1150, to: 880, dur: 0.1, gain: 0.05, attack: 0.005 });
|
||||
noise(c, t, { freq: 2200, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 });
|
||||
},
|
||||
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
||||
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
||||
click: (c, t) => {
|
||||
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
|
||||
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
|
||||
tone(c, t, { freq: 800, to: 600, dur: 0.09, gain: 0.045, attack: 0.007 });
|
||||
noise(c, t, { type: "highpass", freq: 3000, dur: 0.035, gain: 0.02, attack: 0.005 });
|
||||
},
|
||||
fall: (c, t) => {
|
||||
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
|
||||
[262, 220, 196, 165].forEach((f, i) => tone(c, t + i * 0.6, { freq: f, dur: 1.9, gain: 0.09, attack: 0.16 }));
|
||||
},
|
||||
fanfare: (c, t) => {
|
||||
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
|
||||
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.2, { freq: f, dur: 1.9, gain: 0.07, attack: 0.07 }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -124,7 +174,12 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
c.resume?.().catch?.(() => {});
|
||||
return; // still locked: nothing to hear yet
|
||||
}
|
||||
const level = Math.max(0, Math.min(1, mix()[GROUP_OF[name]] ?? 1));
|
||||
if (level < 0.005) return;
|
||||
master.gain.value = Math.max(0, Math.min(1, volume()));
|
||||
out = c.createGain();
|
||||
out.gain.value = level;
|
||||
out.connect(master);
|
||||
try {
|
||||
make(c, c.currentTime + 0.005);
|
||||
} catch {
|
||||
@@ -132,5 +187,5 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return { play, unlock, names: Object.keys(SOUNDS) };
|
||||
return { play, unlock, names: Object.keys(SOUNDS), groupOf: (name) => GROUP_OF[name] };
|
||||
}
|
||||
|
||||
+33
-5
@@ -8,6 +8,7 @@ import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
||||
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
import { renderMarkdown } from "./markdown.js";
|
||||
import { MIX_GROUPS } from "./audio.js";
|
||||
|
||||
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
||||
|
||||
@@ -221,9 +222,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
|
||||
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
|
||||
</label>
|
||||
<label class="menu__field">${t("settings.volume")}
|
||||
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
|
||||
</label>
|
||||
<div class="menu__list">${button("mixer", t("settings.mixer"))}</div>
|
||||
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
||||
${back()}`;
|
||||
}
|
||||
@@ -253,7 +252,26 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
|
||||
}
|
||||
|
||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
|
||||
// One slider per sound group, plus the overall volume and the music. Levels apply as you drag.
|
||||
function mixer() {
|
||||
const s = getSettings();
|
||||
const rows = [["master", s.volume], ["music", s.mix.music], ...MIX_GROUPS.map((g) => [g, s.mix[g]])];
|
||||
return `
|
||||
<h3>${t("mixer.title")}</h3>
|
||||
<p class="menu__sub">${t("mixer.hint")}</p>
|
||||
<div class="mixer">
|
||||
${rows.map(([key, v]) => `
|
||||
<label class="mixer__row ${key === "master" ? "mixer__row--master" : ""}">
|
||||
<span>${t("mixer." + key)}</span>
|
||||
<input type="range" min="0" max="1" step="0.05" value="${v}" data-mix="${key}" aria-label="${t("mixer." + key)}">
|
||||
<output>${Math.round(v * 100)}%</output>
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
<div class="menu__list">${button("mixerReset", t("mixer.reset"))}</div>
|
||||
<footer class="menu__foot">${button("settings", t("menu.back"))}</footer>`;
|
||||
}
|
||||
|
||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules, mixer };
|
||||
|
||||
function render() {
|
||||
root.innerHTML = `
|
||||
@@ -324,6 +342,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
import: () => go("import"),
|
||||
settings: () => go("settings"),
|
||||
rules: () => go("rules"),
|
||||
mixer: () => go("mixer"),
|
||||
mixerReset: () => { handlers.onSettings({ resetMix: true }); render(); },
|
||||
credits: () => go("credits"),
|
||||
back: () => go("home"),
|
||||
resume: () => handlers.onResume(),
|
||||
@@ -362,10 +382,18 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
ACTIONS[action](arg);
|
||||
});
|
||||
|
||||
root.addEventListener("input", (e) => {
|
||||
const key = e.target.dataset?.mix;
|
||||
if (!key) return;
|
||||
const v = Number(e.target.value);
|
||||
e.target.parentElement.querySelector("output").textContent = `${Math.round(v * 100)}%`;
|
||||
handlers.onSettings(key === "master" ? { volume: v } : { mix: { [key]: v } });
|
||||
});
|
||||
|
||||
root.addEventListener("change", async (e) => {
|
||||
if (e.target.dataset?.mix) handlers.onPreview?.(e.target.dataset.mix);
|
||||
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
||||
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
|
||||
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
|
||||
if (e.target.id === "import-file" && e.target.files[0]) {
|
||||
$(root, "#import-text").value = await e.target.files[0].text();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Background music: plays the .mp3 files of the music folder (listed by the server at music/index.json)
|
||||
// in random order, like a shuffled tape. No DOM here: the walkman panel drives it and listens to it.
|
||||
//
|
||||
// States: "stopped" (nothing loaded), "paused" (track loaded, silent), "playing".
|
||||
// Browsers refuse to start audio before the first click or key press; start() then waits for it.
|
||||
|
||||
export const prettyTitle = (file) => {
|
||||
const base = file.replace(/\.mp3$/i, "");
|
||||
return base.replace(/^\d+[\s._-]*/, "").replace(/_+/g, " ").replace(/\s+/g, " ").trim() || base;
|
||||
};
|
||||
|
||||
function shuffled(list) {
|
||||
const a = [...list];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function createMusic({ getVolume = () => 0.3, onUserChoice = () => {} } = {}) {
|
||||
const el = new Audio();
|
||||
el.preload = "none";
|
||||
|
||||
let tracks = [];
|
||||
let bag = []; // tracks still to come in this shuffle
|
||||
let history = []; // tracks already played, for "previous"
|
||||
let current = null;
|
||||
let state = "stopped";
|
||||
let waitingForGesture = false;
|
||||
let failures = 0;
|
||||
let listener = () => {};
|
||||
|
||||
const notify = () => listener(info());
|
||||
const info = () => ({ available: tracks.length > 0, state, title: current ? prettyTitle(current) : "", waitingForGesture });
|
||||
|
||||
// The next track of the shuffle, never the one that just played (unless it is the only one).
|
||||
function pick() {
|
||||
if (!bag.length) {
|
||||
bag = shuffled(tracks);
|
||||
if (bag.length > 1 && bag[0] === current) bag.push(bag.shift());
|
||||
}
|
||||
return bag.shift();
|
||||
}
|
||||
|
||||
function setTrack(name) {
|
||||
current = name;
|
||||
el.src = `music/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
function applyVolume() {
|
||||
el.volume = Math.max(0, Math.min(1, getVolume()));
|
||||
}
|
||||
|
||||
async function begin() {
|
||||
applyVolume();
|
||||
try {
|
||||
await el.play();
|
||||
state = "playing";
|
||||
waitingForGesture = false;
|
||||
} catch (err) {
|
||||
// Blocked until the player touches the page, or the file could not be read.
|
||||
if (err?.name === "NotAllowedError") waitingForGesture = true;
|
||||
state = current ? "paused" : "stopped";
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
el.addEventListener("playing", () => { failures = 0; });
|
||||
el.addEventListener("ended", () => next());
|
||||
el.addEventListener("error", () => {
|
||||
// A broken or unreadable file: try another tape, and give up when none plays.
|
||||
failures++;
|
||||
if (failures > tracks.length) {
|
||||
state = "stopped";
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (current) history.pop();
|
||||
skip();
|
||||
});
|
||||
|
||||
function skip() {
|
||||
if (!tracks.length) return;
|
||||
setTrack(pick());
|
||||
void begin();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch("music/index.json", { cache: "no-store" });
|
||||
const data = r.ok ? await r.json() : {};
|
||||
tracks = Array.isArray(data.tracks) ? data.tracks.filter((n) => typeof n === "string") : [];
|
||||
} catch {
|
||||
tracks = [];
|
||||
}
|
||||
notify();
|
||||
return tracks.length;
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!tracks.length) return;
|
||||
if (state === "paused" && current) {
|
||||
void begin();
|
||||
} else {
|
||||
if (current) history.push(current);
|
||||
skip();
|
||||
}
|
||||
onUserChoice(true);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (state !== "playing") return;
|
||||
el.pause();
|
||||
state = "paused";
|
||||
waitingForGesture = false;
|
||||
onUserChoice(false);
|
||||
notify();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
state = "stopped";
|
||||
current = null;
|
||||
waitingForGesture = false;
|
||||
onUserChoice(false);
|
||||
notify();
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (!tracks.length) return;
|
||||
if (current) history.push(current);
|
||||
skip();
|
||||
onUserChoice(true);
|
||||
}
|
||||
|
||||
// Back to the start of the track, or to the one before when it has barely begun.
|
||||
function prev() {
|
||||
if (!tracks.length) return;
|
||||
if (state === "stopped" || el.currentTime > 3 || !history.length) {
|
||||
if (current) el.currentTime = 0;
|
||||
else skip();
|
||||
} else {
|
||||
setTrack(history.pop());
|
||||
void begin();
|
||||
}
|
||||
onUserChoice(true);
|
||||
}
|
||||
|
||||
// Called on the first click or key press: starts the music if the player wants it.
|
||||
function unlockedByGesture(wanted) {
|
||||
if (wanted && state === "stopped" && tracks.length) skip();
|
||||
else if (waitingForGesture && current) void begin();
|
||||
}
|
||||
|
||||
return {
|
||||
load, play, pause, stop, next, prev, applyVolume, unlockedByGesture,
|
||||
info,
|
||||
setListener: (fn) => { listener = fn; fn(info()); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// A small cassette player on the desk: a cassette window with two reels that turn while the music plays,
|
||||
// the track title on the cassette label, and five vintage keys (previous, play, pause, stop, next).
|
||||
|
||||
import { t } from "../i18n/i18n.js";
|
||||
import { $ } from "./desk.js";
|
||||
|
||||
const KEYS = [
|
||||
["prev", `<path d="M12 1v14L3 8zM22 1v14l-9-7z"/>`],
|
||||
["play", `<path d="M7 1v14l11-7z"/>`],
|
||||
["pause", `<path d="M6 1h4v14H6zM14 1h4v14h-4z"/>`],
|
||||
["stop", `<path d="M6 2h12v12H6z"/>`],
|
||||
["next", `<path d="M2 1v14l9-7zM12 1v14l9-7z"/>`],
|
||||
];
|
||||
|
||||
const reel = (side) => `
|
||||
<svg class="walkman__reel walkman__reel--${side}" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
|
||||
<circle class="walkman__tape" cx="20" cy="20" r="17"/>
|
||||
<g class="walkman__spokes">${[0, 60, 120].map((a) => `<line x1="20" y1="9" x2="20" y2="31" transform="rotate(${a} 20 20)"/>`).join("")}</g>
|
||||
<circle class="walkman__hub" cx="20" cy="20" r="6"/>
|
||||
</svg>`;
|
||||
|
||||
export function createWalkman(root, { music, onPress = () => {} }) {
|
||||
root.innerHTML = `
|
||||
<div class="walkman__body">
|
||||
<div class="walkman__badge"><b>VALDOR</b><span>${t("walkman.model")}</span><i class="walkman__led" aria-hidden="true"></i></div>
|
||||
<div class="walkman__window">
|
||||
${reel("left")}
|
||||
<div class="walkman__label"><span class="walkman__title" role="status"></span></div>
|
||||
${reel("right")}
|
||||
</div>
|
||||
<div class="walkman__keys" role="group" aria-label="${t("walkman.title")}">
|
||||
${KEYS.map(([act, icon]) => `
|
||||
<button class="walkman__key walkman__key--${act}" data-act="${act}" aria-label="${t("walkman." + act)}" title="${t("walkman." + act)}">
|
||||
<svg viewBox="0 0 24 16" aria-hidden="true" focusable="false">${icon}</svg>
|
||||
</button>`).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const title = $(root, ".walkman__title");
|
||||
const label = $(root, ".walkman__label");
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button[data-act]");
|
||||
if (!b || b.disabled) return;
|
||||
onPress();
|
||||
music[b.dataset.act]();
|
||||
});
|
||||
|
||||
function update() {
|
||||
const m = music.info();
|
||||
const playing = m.state === "playing";
|
||||
root.classList.toggle("is-playing", playing);
|
||||
root.classList.toggle("is-empty", !m.available);
|
||||
root.title = m.available ? "" : t("walkman.hint");
|
||||
|
||||
let text = t("walkman.empty");
|
||||
if (m.available) text = m.waitingForGesture ? t("walkman.tap") : m.state === "stopped" ? t("walkman.stopped") : m.title;
|
||||
if (title.textContent !== text) {
|
||||
title.textContent = text;
|
||||
title.classList.remove("is-scroll");
|
||||
// a long title scrolls across the label while the tape runs
|
||||
requestAnimationFrame(() => title.classList.toggle("is-scroll", title.scrollWidth > label.clientWidth + 2));
|
||||
}
|
||||
|
||||
for (const b of root.querySelectorAll("button[data-act]")) {
|
||||
b.disabled = !m.available;
|
||||
if (b.dataset.act === "play") b.setAttribute("aria-pressed", String(playing));
|
||||
if (b.dataset.act === "pause") b.setAttribute("aria-pressed", String(m.state === "paused"));
|
||||
if (b.dataset.act === "stop") b.setAttribute("aria-pressed", String(m.state === "stopped"));
|
||||
}
|
||||
}
|
||||
|
||||
music.setListener(update);
|
||||
return { update };
|
||||
}
|
||||
Reference in New Issue
Block a user