Museum — Audio-Guide Player
A curatorial audio-guide player for a fictional gallery, anchored by an oversized serif stop number, a framed artwork swatch, and the work's title, artist, date and catalog line. A simulated transport drives play and pause, a gold scrubber with elapsed and total times, a fifteen-second skip, cycling playback speed and a read-transcript toggle. An up-next list lets visitors jump between stops, and a numeric keypad enters a stop number directly, all keyboard friendly and calm.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow: 0 1px 2px rgba(28, 27, 25, 0.05), 0 18px 44px -28px rgba(28, 27, 25, 0.4);
}
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
line-height: 1.55;
padding: clamp(16px, 4vw, 48px);
}
.frame {
max-width: 980px;
margin: 0 auto;
}
/* ===== masthead ===== */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 20px;
margin-bottom: 24px;
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.crest {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid var(--line-2);
border-radius: 50%;
color: var(--gold-d);
font-size: 18px;
}
.brand-name {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: 20px;
letter-spacing: 0.01em;
color: var(--charcoal);
}
.brand-sub {
margin: 2px 0 0;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.tour-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid var(--line-2);
border-radius: 999px;
background: var(--wall);
font-size: 12.5px;
font-weight: 500;
color: var(--ink-2);
}
.tour-pill .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px rgba(63, 125, 86, 0.16);
}
/* ===== layout ===== */
.layout {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 22px;
align-items: start;
}
/* ===== player ===== */
.player {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: clamp(18px, 3vw, 28px);
}
.player-top {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 20px;
align-items: center;
}
.stopno {
text-align: center;
}
.stopno-label {
display: block;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.stopno-num {
display: block;
font-family: var(--serif);
font-weight: 700;
font-size: clamp(46px, 9vw, 64px);
line-height: 1;
color: var(--charcoal);
}
.artwork {
width: 92px;
border-radius: var(--r-sm);
padding: 6px;
background: var(--gold-50);
border: 1px solid var(--line-2);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
.artwork svg {
display: block;
width: 100%;
height: auto;
border-radius: 2px;
}
.now-eyebrow {
margin: 0 0 4px;
font-size: 11.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.now-title {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: clamp(24px, 4.4vw, 32px);
line-height: 1.1;
color: var(--charcoal);
}
.now-meta {
margin: 8px 0 2px;
font-size: 14px;
color: var(--ink-2);
}
.now-cat {
margin: 0;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--muted);
}
/* ===== transport ===== */
.transport {
margin-top: 24px;
padding-top: 22px;
border-top: 1px solid var(--line);
}
.scrub {
display: flex;
align-items: center;
gap: 12px;
}
.time {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--muted);
min-width: 36px;
text-align: center;
}
.seek {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--gold) 0%,
var(--gold) var(--p, 0%),
var(--line) var(--p, 0%),
var(--line) 100%
);
cursor: pointer;
}
.seek::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--wall);
border: 2px solid var(--gold-d);
box-shadow: 0 1px 4px rgba(28, 27, 25, 0.25);
cursor: pointer;
}
.seek::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--wall);
border: 2px solid var(--gold-d);
cursor: pointer;
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 20px 0;
}
.ctl {
font-family: var(--sans);
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink);
cursor: pointer;
transition: transform 0.12s ease, background 0.18s ease, border-color 0.18s ease;
}
.ctl:focus-visible {
outline: 2px solid var(--gold-d);
outline-offset: 2px;
}
.ctl-skip {
display: inline-flex;
align-items: center;
gap: 1px;
padding: 0 14px;
height: 44px;
border-radius: var(--r-md);
font-size: 12px;
font-weight: 600;
}
.ctl-skip .ctl-glyph {
font-size: 16px;
}
.ctl-skip:hover {
background: var(--gold-50);
border-color: var(--gold);
}
.ctl-play {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--charcoal);
border-color: var(--charcoal);
color: var(--paper);
font-size: 22px;
display: grid;
place-items: center;
}
.ctl-play:hover {
background: var(--ink);
}
.ctl-play:active {
transform: scale(0.95);
}
.play-glyph {
margin-left: 3px;
}
.play-glyph.paused {
margin-left: 0;
}
.toggles {
display: flex;
gap: 10px;
justify-content: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid var(--line-2);
border-radius: 999px;
background: var(--wall);
font-family: var(--sans);
font-size: 12.5px;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
}
.chip:hover {
background: var(--gold-50);
border-color: var(--gold);
}
.chip:focus-visible {
outline: 2px solid var(--gold-d);
outline-offset: 2px;
}
.chip-k {
color: var(--muted);
letter-spacing: 0.04em;
}
.chip-v {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--charcoal);
}
.chip[aria-pressed="true"] {
background: var(--charcoal);
border-color: var(--charcoal);
}
.chip[aria-pressed="true"] .chip-k,
.chip[aria-pressed="true"] .chip-v {
color: var(--paper);
}
/* ===== transcript ===== */
.transcript {
margin-top: 20px;
padding: 16px 18px;
background: var(--gold-50);
border: 1px solid var(--line);
border-left: 3px solid var(--gold);
border-radius: var(--r-md);
}
.transcript-label {
margin: 0 0 6px;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.transcript p:last-child {
margin: 0;
font-family: var(--serif);
font-size: 17px;
line-height: 1.5;
color: var(--ink);
}
/* ===== rail panels ===== */
.rail {
display: grid;
gap: 22px;
}
.panel {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: 18px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-head h2 {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: 19px;
color: var(--charcoal);
}
.count,
.hint {
font-size: 11.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
/* ===== queue ===== */
.queue {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.queue-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
padding: 10px 10px;
border: 1px solid transparent;
border-radius: var(--r-md);
background: transparent;
font-family: var(--sans);
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease;
}
.queue-item:hover {
background: var(--gold-50);
border-color: var(--line);
}
.queue-item:focus-visible {
outline: 2px solid var(--gold-d);
outline-offset: 1px;
}
.queue-item.is-current {
background: var(--gold-50);
border-color: var(--gold);
}
.q-num {
font-family: var(--serif);
font-weight: 600;
font-size: 18px;
color: var(--gold-d);
width: 28px;
text-align: center;
}
.q-body {
min-width: 0;
}
.q-title {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.q-sub {
display: block;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.q-dur {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.q-badge {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--gold-d);
background: var(--wall);
border: 1px solid var(--gold);
border-radius: 999px;
padding: 2px 7px;
}
/* ===== keypad ===== */
.readout {
font-family: var(--serif);
font-weight: 700;
font-size: 34px;
letter-spacing: 0.12em;
text-align: center;
color: var(--charcoal);
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 10px;
margin-bottom: 14px;
min-height: 56px;
font-variant-numeric: tabular-nums;
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.key {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
color: var(--ink);
height: 52px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--wall);
cursor: pointer;
transition: background 0.14s ease, transform 0.1s ease;
}
.key:hover {
background: var(--gold-50);
}
.key:active {
transform: scale(0.96);
}
.key:focus-visible {
outline: 2px solid var(--gold-d);
outline-offset: 2px;
}
.key.key-fn {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
}
.key.key-go {
background: var(--charcoal);
border-color: var(--charcoal);
color: var(--paper);
}
.key.key-go:hover {
background: var(--ink);
}
/* ===== toast ===== */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--charcoal);
color: var(--paper);
font-size: 13px;
padding: 11px 18px;
border-radius: 999px;
box-shadow: 0 12px 30px -10px rgba(28, 27, 25, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 40;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ===== responsive ===== */
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
body {
padding: 14px;
}
.masthead {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.player-top {
grid-template-columns: auto 1fr;
grid-template-areas:
"stop now"
"art now";
gap: 14px 16px;
}
.stopno {
grid-area: stop;
text-align: left;
}
.artwork {
grid-area: art;
width: 72px;
}
.now {
grid-area: now;
}
.controls {
gap: 12px;
}
.toggles {
flex-wrap: wrap;
}
}(function () {
"use strict";
// ---- demo data: a guided tour through fictional artworks ----
var STOPS = [
{
n: 5,
gallery: "Gallery 4 · Northern Light",
title: "The Glassblower's Hands",
artist: "Henrik Vahl",
year: "1872",
medium: "Oil on panel",
cat: "AC-1872.041",
dur: 196,
grad: ["#8a6a9c", "#3c2c4d"],
transcript:
"Vahl spent a winter in the Murano workshops, and you can feel the heat coming off the panel — the molten gather glows the same amber as the candle behind it. Look only at the hands and the rest of the room dissolves into smoke.",
},
{
n: 6,
gallery: "Gallery 4 · Northern Light",
title: "Evening Train, Kettle Vale",
artist: "Rosa Linde",
year: "1901",
medium: "Pastel on grey paper",
cat: "AC-1901.118",
dur: 162,
grad: ["#5b7a8c", "#243743"],
transcript:
"A pastel done from memory, Linde insisted, after a single delayed journey home. The smear of the locomotive's smoke is dragged with a thumb — you can still see the print if you lean to the right.",
},
{
n: 7,
gallery: "Gallery 4 · Northern Light",
title: "Harbour at First Frost",
artist: "Adèle Marchetti",
year: "1887",
medium: "Oil on linen",
cat: "AC-1887.214",
dur: 228,
grad: ["#caa45e", "#7d5a28"],
transcript:
"Stand close and the harbour seems to breathe. Marchetti painted this in the failing light of a December afternoon, the linen barely primed so the weave still glints through the fog. Notice how the boats are scarcely there at all — three strokes, a smear of lead-white — and yet the whole cold morning hangs upon them.",
},
{
n: 8,
gallery: "Gallery 5 · The Long Room",
title: "Self-Portrait with Quince",
artist: "Teodora Reyes",
year: "1914",
medium: "Tempera on board",
cat: "AC-1914.077",
dur: 174,
grad: ["#a8584c", "#5a221c"],
transcript:
"Reyes painted herself nine times; this is the only version where she meets your eye. The quince — bitter, unripe — was a private joke about the academy that rejected her twice before they finally hung this very picture.",
},
{
n: 9,
gallery: "Gallery 5 · The Long Room",
title: "Study of Falling Water",
artist: "Marek Osei",
year: "1959",
medium: "Ink and gouache",
cat: "AC-1959.302",
dur: 141,
grad: ["#4f8f86", "#1f4a44"],
transcript:
"Osei made forty of these in a single week beside the Tana falls. The white is not paint at all — it is bare paper, the water described entirely by the dark left around it.",
},
{
n: 10,
gallery: "Gallery 5 · The Long Room",
title: "Two Chairs, Late August",
artist: "Liv Sandström",
year: "1978",
medium: "Acrylic on canvas",
cat: "AC-1978.155",
dur: 207,
grad: ["#c98a4a", "#6b3f1d"],
transcript:
"An empty room, two chairs, and a light Sandström called the most honest hour of the day. Nothing happens here, which is exactly the point — the guide before me called it the loneliest picture in the wing.",
},
];
var SPEEDS = [1.0, 1.25, 1.5, 0.75];
// ---- state ----
var state = {
index: 2, // start on "Harbour at First Frost" (stop 7)
playing: false,
elapsed: 0, // seconds
speedIdx: 0,
transcript: false,
keyBuffer: "",
};
var timer = null;
// ---- elements ----
var $ = function (id) {
return document.getElementById(id);
};
var els = {
stopNum: $("stopNum"),
artwork: $("artwork"),
nowGallery: $("nowGallery"),
nowTitle: $("nowTitle"),
nowMeta: $("nowMeta"),
nowCat: $("nowCat"),
elapsed: $("elapsed"),
total: $("total"),
seek: $("seek"),
play: $("play"),
playGlyph: $("playGlyph"),
skipBack: $("skipBack"),
skipFwd: $("skipFwd"),
speedBtn: $("speedBtn"),
speedVal: $("speedVal"),
transcriptBtn: $("transcriptBtn"),
transcriptVal: $("transcriptVal"),
transcript: $("transcript"),
transcriptBody: $("transcriptBody"),
queue: $("queue"),
queueCount: $("queueCount"),
keyReadout: $("keyReadout"),
keypad: $("keypad"),
toast: $("toast"),
};
// ---- helpers ----
function pad2(n) {
return n < 10 ? "0" + n : "" + n;
}
function fmt(sec) {
sec = Math.max(0, Math.round(sec));
var m = Math.floor(sec / 60);
var s = sec % 60;
return m + ":" + pad2(s);
}
var toastTimer = null;
function toast(msg) {
els.toast.textContent = msg;
els.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
els.toast.classList.remove("show");
}, 2200);
}
function current() {
return STOPS[state.index];
}
function setArtwork(stop) {
var g = stop.grad;
els.artwork.innerHTML =
'<svg viewBox="0 0 120 150" role="img">' +
'<defs><linearGradient id="ga" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0" stop-color="' +
g[0] +
'" /><stop offset="1" stop-color="' +
g[1] +
'" /></linearGradient></defs>' +
'<rect width="120" height="150" fill="url(#ga)" />' +
'<circle cx="78" cy="44" r="22" fill="#f3ecdd" opacity="0.5" />' +
'<path d="M0 120 L40 70 L70 110 L95 80 L120 118 L120 150 L0 150 Z" fill="#1c1b19" opacity="0.32" />' +
"</svg>";
}
// ---- render ----
function renderNow() {
var s = current();
els.stopNum.textContent = pad2(s.n);
els.nowGallery.textContent = s.gallery;
els.nowTitle.textContent = s.title;
els.nowMeta.textContent = s.artist + " · " + s.year + " · " + s.medium;
els.nowCat.textContent = "Cat. " + s.cat;
els.total.textContent = fmt(s.dur);
els.transcriptBody.textContent = s.transcript;
setArtwork(s);
renderProgress();
renderQueue();
}
function renderProgress() {
var s = current();
var pct = s.dur ? (state.elapsed / s.dur) * 100 : 0;
pct = Math.max(0, Math.min(100, pct));
els.elapsed.textContent = fmt(state.elapsed);
els.seek.value = pct;
els.seek.style.setProperty("--p", pct + "%");
}
function renderPlay() {
els.playGlyph.textContent = state.playing ? "❚❚" : "▶";
els.playGlyph.className = state.playing ? "play-glyph paused" : "play-glyph";
els.play.setAttribute("aria-label", state.playing ? "Pause" : "Play");
els.play.setAttribute("aria-pressed", state.playing ? "true" : "false");
}
function renderQueue() {
var upcoming = [];
for (var i = 0; i < STOPS.length; i++) {
if (i !== state.index) upcoming.push({ i: i, s: STOPS[i] });
}
// show the current + everything after it, in tour order
var order = [];
for (var j = state.index; j < STOPS.length; j++) order.push(j);
for (var k = 0; k < state.index; k++) order.push(k);
els.queue.innerHTML = "";
var shown = 0;
order.forEach(function (idx) {
var s = STOPS[idx];
var li = document.createElement("li");
var btn = document.createElement("button");
btn.type = "button";
btn.className = "queue-item" + (idx === state.index ? " is-current" : "");
btn.setAttribute("data-idx", idx);
btn.innerHTML =
'<span class="q-num">' +
pad2(s.n) +
"</span>" +
'<span class="q-body">' +
'<span class="q-title">' +
s.title +
"</span>" +
'<span class="q-sub">' +
s.artist +
" · " +
s.year +
"</span>" +
"</span>" +
(idx === state.index
? '<span class="q-badge">Now</span>'
: '<span class="q-dur">' + fmt(s.dur) + "</span>");
li.appendChild(btn);
els.queue.appendChild(li);
shown++;
});
els.queueCount.textContent = shown + " stops";
}
// ---- playback simulation ----
function tick() {
var s = current();
var step = 0.25 * SPEEDS[state.speedIdx];
state.elapsed += step;
if (state.elapsed >= s.dur) {
state.elapsed = s.dur;
renderProgress();
stop();
advance(true);
return;
}
renderProgress();
}
function start() {
if (state.playing) return;
state.playing = true;
renderPlay();
timer = setInterval(tick, 250);
}
function stop() {
state.playing = false;
renderPlay();
if (timer) {
clearInterval(timer);
timer = null;
}
}
function togglePlay() {
if (state.playing) {
stop();
} else {
start();
}
}
function goTo(idx, autoplay) {
if (idx < 0 || idx >= STOPS.length) return;
var wasPlaying = state.playing || autoplay;
stop();
state.index = idx;
state.elapsed = 0;
renderNow();
if (wasPlaying) start();
}
function advance(autoplay) {
var next = state.index + 1;
if (next >= STOPS.length) {
toast("End of tour reached");
return;
}
goTo(next, autoplay);
toast("Now playing · Stop " + current().n);
}
// ---- controls ----
els.play.addEventListener("click", togglePlay);
els.skipBack.addEventListener("click", function () {
state.elapsed = Math.max(0, state.elapsed - 15);
renderProgress();
});
els.skipFwd.addEventListener("click", function () {
var s = current();
state.elapsed = Math.min(s.dur, state.elapsed + 15);
renderProgress();
if (state.elapsed >= s.dur) {
stop();
advance(true);
}
});
els.seek.addEventListener("input", function () {
var s = current();
state.elapsed = (parseFloat(els.seek.value) / 100) * s.dur;
renderProgress();
});
els.speedBtn.addEventListener("click", function () {
state.speedIdx = (state.speedIdx + 1) % SPEEDS.length;
els.speedVal.textContent = SPEEDS[state.speedIdx].toFixed(2).replace(/0$/, "") + "×";
toast("Speed " + SPEEDS[state.speedIdx] + "×");
});
els.transcriptBtn.addEventListener("click", function () {
state.transcript = !state.transcript;
els.transcript.hidden = !state.transcript;
els.transcriptBtn.setAttribute("aria-pressed", state.transcript ? "true" : "false");
els.transcriptBtn.setAttribute("aria-expanded", state.transcript ? "true" : "false");
els.transcriptVal.textContent = state.transcript ? "Shown" : "Hidden";
});
// ---- queue clicks ----
els.queue.addEventListener("click", function (e) {
var btn = e.target.closest(".queue-item");
if (!btn) return;
var idx = parseInt(btn.getAttribute("data-idx"), 10);
if (idx === state.index) return;
goTo(idx, false);
toast("Jumped to Stop " + current().n);
});
// ---- keypad ----
var KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "go"];
function renderReadout() {
els.keyReadout.textContent = state.keyBuffer === "" ? "—" : state.keyBuffer;
}
function pressKey(val) {
if (val === "clear") {
state.keyBuffer = "";
} else if (val === "go") {
submitStop();
return;
} else {
if (state.keyBuffer.length >= 2) state.keyBuffer = "";
state.keyBuffer += val;
}
renderReadout();
}
function submitStop() {
var num = parseInt(state.keyBuffer, 10);
if (isNaN(num)) {
toast("Enter a stop number");
return;
}
var idx = -1;
for (var i = 0; i < STOPS.length; i++) {
if (STOPS[i].n === num) {
idx = i;
break;
}
}
if (idx === -1) {
toast("No stop " + num + " on this tour");
els.keyReadout.animate(
[{ color: "#b4493a" }, { color: "" }],
{ duration: 700 }
);
state.keyBuffer = "";
renderReadout();
return;
}
goTo(idx, true);
toast("Now playing · Stop " + num);
state.keyBuffer = "";
renderReadout();
}
(function buildKeypad() {
KEYS.forEach(function (k) {
var b = document.createElement("button");
b.type = "button";
if (k === "clear") {
b.className = "key key-fn";
b.textContent = "Clear";
b.setAttribute("aria-label", "Clear entry");
} else if (k === "go") {
b.className = "key key-fn key-go";
b.textContent = "Go";
b.setAttribute("aria-label", "Go to stop");
} else {
b.className = "key";
b.textContent = k;
b.setAttribute("aria-label", "Digit " + k);
}
b.setAttribute("data-key", k);
els.keypad.appendChild(b);
});
})();
els.keypad.addEventListener("click", function (e) {
var b = e.target.closest(".key");
if (!b) return;
pressKey(b.getAttribute("data-key"));
});
// physical keyboard support for the keypad
document.addEventListener("keydown", function (e) {
if (e.target.tagName === "INPUT") return;
if (/^[0-9]$/.test(e.key)) {
pressKey(e.key);
} else if (e.key === "Enter") {
if (state.keyBuffer !== "") submitStop();
} else if (e.key === "Backspace") {
state.keyBuffer = state.keyBuffer.slice(0, -1);
renderReadout();
} else if (e.key === " " && e.target.tagName !== "BUTTON") {
e.preventDefault();
togglePlay();
}
});
// ---- init ----
els.speedVal.textContent = SPEEDS[0].toFixed(1) + "×";
renderNow();
renderPlay();
renderReadout();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Museum — Audio-Guide Player</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="frame">
<header class="masthead">
<div class="brand">
<span class="crest" aria-hidden="true">◈</span>
<div>
<p class="brand-name">The Aldemere Collection</p>
<p class="brand-sub">Audio Guide · West Wing</p>
</div>
</div>
<div class="tour-pill" aria-label="Active tour">
<span class="dot" aria-hidden="true"></span>
Tour A · Masters of Light
</div>
</header>
<main class="layout">
<!-- ===== PLAYER ===== -->
<section class="player" aria-label="Audio guide player">
<div class="player-top">
<div class="stopno" aria-label="Current stop number">
<span class="stopno-label">Stop</span>
<span class="stopno-num" id="stopNum">07</span>
</div>
<div class="artwork" id="artwork" aria-hidden="true">
<svg viewBox="0 0 120 150" role="img">
<defs>
<linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#caa45e" />
<stop offset="1" stop-color="#7d5a28" />
</linearGradient>
</defs>
<rect width="120" height="150" fill="url(#g1)" />
<circle cx="78" cy="44" r="22" fill="#f3ecdd" opacity="0.55" />
<path d="M0 120 L40 70 L70 110 L95 80 L120 118 L120 150 L0 150 Z" fill="#1c1b19" opacity="0.35" />
</svg>
</div>
<div class="now">
<p class="now-eyebrow" id="nowGallery">Gallery 4 · Northern Light</p>
<h1 class="now-title" id="nowTitle">Harbour at First Frost</h1>
<p class="now-meta" id="nowMeta">Adèle Marchetti · 1887 · Oil on linen</p>
<p class="now-cat" id="nowCat">Cat. AC-1887.214</p>
</div>
</div>
<!-- transport -->
<div class="transport">
<div class="scrub">
<span class="time" id="elapsed">0:00</span>
<input
type="range"
id="seek"
class="seek"
min="0"
max="100"
value="0"
step="0.1"
aria-label="Seek through narration"
/>
<span class="time" id="total">3:48</span>
</div>
<div class="controls">
<button class="ctl ctl-skip" id="skipBack" type="button" aria-label="Skip back 15 seconds">
<span class="ctl-glyph">«</span><span class="ctl-sub">15</span>
</button>
<button class="ctl ctl-play" id="play" type="button" aria-label="Play" aria-pressed="false">
<span id="playGlyph" class="play-glyph">▶</span>
</button>
<button class="ctl ctl-skip" id="skipFwd" type="button" aria-label="Skip forward 15 seconds">
<span class="ctl-sub">15</span><span class="ctl-glyph">»</span>
</button>
</div>
<div class="toggles">
<button class="chip" id="speedBtn" type="button" aria-label="Playback speed">
<span class="chip-k">Speed</span><span class="chip-v" id="speedVal">1.0×</span>
</button>
<button class="chip" id="transcriptBtn" type="button" aria-pressed="false" aria-expanded="false" aria-controls="transcript">
<span class="chip-k">Transcript</span><span class="chip-v" id="transcriptVal">Hidden</span>
</button>
</div>
</div>
<div class="transcript" id="transcript" hidden>
<p class="transcript-label">Narrated transcript</p>
<p id="transcriptBody">
Stand close and the harbour seems to breathe. Marchetti painted this in the failing
light of a December afternoon, the linen barely primed so the weave still glints
through the fog. Notice how the boats are scarcely there at all — three strokes,
a smear of lead-white — and yet the whole cold morning hangs upon them.
</p>
</div>
</section>
<!-- ===== SIDE: UP NEXT + KEYPAD ===== -->
<aside class="rail">
<section class="panel" aria-label="Up next">
<header class="panel-head">
<h2>Up next</h2>
<span class="count" id="queueCount">4 stops</span>
</header>
<ol class="queue" id="queue"></ol>
</section>
<section class="panel keypad-panel" aria-label="Enter stop number">
<header class="panel-head">
<h2>Go to stop</h2>
<span class="hint">Enter a number</span>
</header>
<div class="readout" id="keyReadout" aria-live="polite">—</div>
<div class="keypad" id="keypad" role="group" aria-label="Numeric keypad"></div>
</section>
</aside>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Audio-Guide Player
The handheld companion a visitor carries through the West Wing of the fictional Aldemere Collection. A large serif stop number sits beside a thin-matted artwork swatch and the now-playing record — gallery, work title, attribution (artist, date, medium) and catalog number — so a guest always knows exactly where they stand on the tour. The whole player rests in calm wall-space on a refined gallery palette of paper, charcoal and gold.
The transport is fully simulated in vanilla JS: a circular play/pause control, a gold-filled scrubber that advances elapsed time toward the track total, a fifteen-second skip-back and skip-forward, a speed chip that cycles 1.0× / 1.25× / 1.5× / 0.75×, and a read-transcript toggle that reveals the narrated text in a gold-ruled note. Reaching the end of a track auto-advances to the next stop, and a small toast confirms each transition.
Two side panels carry the rest of the experience. An up-next list shows the current stop with a Now badge and the remaining stops with their durations, each clickable to jump straight there. A numeric keypad with a large readout lets a visitor punch in a stop number and press Go — invalid stops flash and clear. Everything is keyboard-usable: digit keys feed the keypad, Enter submits, Backspace deletes, and Space toggles playback, with visible focus throughout and a layout that collapses cleanly to roughly 360px.
Illustrative UI only — demo data; not a real museum system.