chore: improvements to /murmurations d710f52f
Steve · 2026-05-03 22:41 1 file(s) · +35 −11
src/pages/murmurations.astro +35 −11
33 33
	>
34 34
		Back
35 35
	</a>
36 -
	<audio id="murmuration-audio" src="/murmuration.mp3" loop preload="none"></audio>
36 +
	<audio id="murmuration-audio" src="/murmuration.mp3" preload="none"></audio>
37 37
	<div
38 38
		id="poem-overlay"
39 39
		class="pointer-events-none fixed inset-0 z-10 hidden items-center justify-center p-8 italic text-xl"
48 48
		aria-label="Play audio"
49 49
		aria-pressed="false"
50 50
	>
51 -
		<span id="audio-icon">▶</span>
51 +
		<span id="audio-icon" class="flex h-8 w-8 items-center justify-center"></span>
52 52
	</button>
53 53
	<button
54 54
		id="info-btn"
115 115
			{ t: 101, line: "to think again of dangerous and noble things.\nI want to be light and frolicsome.\nI want to be improbable beautiful and afraid of nothing,\nas though I had wings." },
116 116
		];
117 117
118 +
		const POEM_END_T = 118;
119 +
		const PLAY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="h-4 w-4" viewBox="0 0 256 256"><path fill="currentColor" d="M231.36 116.19L87.28 28.06a14 14 0 0 0-14.18-.27A13.69 13.69 0 0 0 66 39.87v176.26a13.69 13.69 0 0 0 7.1 12.08a14 14 0 0 0 14.18-.27l144.08-88.13a13.82 13.82 0 0 0 0-23.62m-6.26 13.38L81 217.7a2 2 0 0 1-2.06 0a1.78 1.78 0 0 1-1-1.61V39.87a1.78 1.78 0 0 1 1-1.61A2.06 2.06 0 0 1 80 38a2 2 0 0 1 1 .31l144.1 88.12a1.82 1.82 0 0 1 0 3.14"/></svg>';
120 +
		const PAUSE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="h-4 w-4" viewBox="0 0 256 256"><path fill="currentColor" d="M200 34h-40a14 14 0 0 0-14 14v160a14 14 0 0 0 14 14h40a14 14 0 0 0 14-14V48a14 14 0 0 0-14-14m2 174a2 2 0 0 1-2 2h-40a2 2 0 0 1-2-2V48a2 2 0 0 1 2-2h40a2 2 0 0 1 2 2ZM96 34H56a14 14 0 0 0-14 14v160a14 14 0 0 0 14 14h40a14 14 0 0 0 14-14V48a14 14 0 0 0-14-14m2 174a2 2 0 0 1-2 2H56a2 2 0 0 1-2-2V48a2 2 0 0 1 2-2h40a2 2 0 0 1 2 2Z"/></svg>';
121 +
		if (audioIcon) audioIcon.innerHTML = PLAY_SVG;
122 +
118 123
		const poemText = poemOverlay?.firstElementChild as HTMLDivElement | null;
119 124
		let lastCueIdx = -1;
120 125
		let swapTimer: number | undefined;
123 128
			if (!poemOverlay || !poemText) return;
124 129
			if (idx === lastCueIdx) return;
125 130
			window.clearTimeout(swapTimer);
131 +
			const wasHidden = poemOverlay.classList.contains("hidden");
126 132
			poemText.classList.remove("opacity-100");
127 133
			poemText.classList.add("opacity-0");
128 -
			swapTimer = window.setTimeout(() => {
134 +
			const swap = () => {
129 135
				if (idx < 0) {
130 136
					poemOverlay.classList.add("hidden");
131 137
					poemOverlay.classList.remove("flex");
135 141
					poemOverlay.classList.remove("hidden");
136 142
					poemOverlay.classList.add("flex");
137 143
					requestAnimationFrame(() => {
138 -
						poemText.classList.remove("opacity-0");
139 -
						poemText.classList.add("opacity-100");
144 +
						requestAnimationFrame(() => {
145 +
							poemText.classList.remove("opacity-0");
146 +
							poemText.classList.add("opacity-100");
147 +
						});
140 148
					});
141 149
				}
142 -
			}, FADE_MS);
150 +
			};
151 +
			if (wasHidden) swap();
152 +
			else swapTimer = window.setTimeout(swap, FADE_MS);
143 153
			lastCueIdx = idx;
144 154
		};
145 155
146 156
		const updateCue = () => {
147 157
			if (!audioEl) return;
148 158
			const t = audioEl.currentTime;
159 +
			if (t >= POEM_END_T) {
160 +
				renderCue(-1);
161 +
				return;
162 +
			}
149 163
			let idx = -1;
150 164
			for (let i = 0; i < cues.length; i++) {
151 165
				if (cues[i].t <= t) idx = i;
154 168
			renderCue(idx);
155 169
		};
156 170
171 +
		const resetBtn = () => {
172 +
			if (!audioBtn || !audioIcon) return;
173 +
			audioIcon.innerHTML = PLAY_SVG;
174 +
			audioBtn.setAttribute("aria-pressed", "false");
175 +
			audioBtn.setAttribute("aria-label", "Play audio");
176 +
		};
177 +
157 178
		if (audioBtn && audioEl && audioIcon) {
158 179
			audioEl.addEventListener("timeupdate", updateCue);
159 180
			audioEl.addEventListener("seeked", updateCue);
160 181
			audioEl.addEventListener("pause", () => {
161 182
				if (audioEl.currentTime === 0) renderCue(-1);
162 183
			});
163 -
			audioEl.addEventListener("ended", () => renderCue(-1));
184 +
			audioEl.addEventListener("ended", () => {
185 +
				renderCue(-1);
186 +
				audioEl.currentTime = 0;
187 +
				resetBtn();
188 +
			});
164 189
165 190
			audioBtn.addEventListener("click", async () => {
166 191
				if (audioEl.paused) {
167 192
					try {
193 +
						if (audioEl.ended) audioEl.currentTime = 0;
168 194
						await audioEl.play();
169 -
						audioIcon.textContent = "⏸";
195 +
						audioIcon.innerHTML = PAUSE_SVG;
170 196
						audioBtn.setAttribute("aria-pressed", "true");
171 197
						audioBtn.setAttribute("aria-label", "Pause audio");
172 198
						updateCue();
175 201
					}
176 202
				} else {
177 203
					audioEl.pause();
178 -
					audioIcon.textContent = "▶";
179 -
					audioBtn.setAttribute("aria-pressed", "false");
180 -
					audioBtn.setAttribute("aria-label", "Play audio");
204 +
					resetBtn();
181 205
				}
182 206
			});
183 207
		}