2026-05-26 11:52:32 -05:00
|
|
|
<script lang="ts">
|
|
|
|
|
type Props = {
|
|
|
|
|
label: string;
|
|
|
|
|
src: string;
|
|
|
|
|
};
|
|
|
|
|
let { label, src }: Props = $props();
|
|
|
|
|
|
|
|
|
|
let audio: HTMLAudioElement | undefined = $state();
|
|
|
|
|
let paused = $state(true);
|
|
|
|
|
let currentTime = $state(0);
|
|
|
|
|
let duration = $state(0);
|
|
|
|
|
let loading = $state(false);
|
|
|
|
|
let errored = $state(false);
|
|
|
|
|
|
|
|
|
|
function toggle() {
|
|
|
|
|
if (errored) {
|
|
|
|
|
retry();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!audio) return;
|
|
|
|
|
if (audio.paused) {
|
2026-05-27 06:39:44 -05:00
|
|
|
if (audio.readyState < 3) loading = true;
|
2026-05-26 11:52:32 -05:00
|
|
|
audio.play().catch(() => undefined);
|
|
|
|
|
} else {
|
|
|
|
|
audio.pause();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function retry() {
|
|
|
|
|
if (!audio) return;
|
|
|
|
|
errored = false;
|
|
|
|
|
loading = true;
|
|
|
|
|
audio.load();
|
|
|
|
|
audio.play().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onPlaying() {
|
|
|
|
|
loading = false;
|
|
|
|
|
// Single-player invariant: pause every other audio element on the page.
|
|
|
|
|
if (audio) {
|
|
|
|
|
for (const other of document.querySelectorAll('audio')) {
|
|
|
|
|
if (other !== audio && !other.paused) other.pause();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmt(s: number) {
|
|
|
|
|
if (!isFinite(s) || s < 0) return '0:00';
|
|
|
|
|
const total = Math.floor(s);
|
|
|
|
|
const h = Math.floor(total / 3600);
|
|
|
|
|
const m = Math.floor((total % 3600) / 60);
|
|
|
|
|
const sec = total % 60;
|
|
|
|
|
return h > 0
|
|
|
|
|
? `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
|
|
|
|
: `${m}:${sec.toString().padStart(2, '0')}`;
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="player" class:playing={!paused && !loading && !errored} class:loading class:errored>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="play"
|
|
|
|
|
onclick={toggle}
|
|
|
|
|
aria-label={errored
|
|
|
|
|
? `Tap to retry ${label}`
|
|
|
|
|
: loading
|
|
|
|
|
? `Loading ${label}`
|
|
|
|
|
: paused
|
|
|
|
|
? `Play ${label}`
|
|
|
|
|
: `Pause ${label}`}
|
|
|
|
|
aria-pressed={!paused}
|
|
|
|
|
>
|
|
|
|
|
{#if errored}
|
|
|
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
|
|
|
|
<path d="M12 2L1 21h22L12 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
|
|
|
|
<path d="M12 10v5M12 17.5v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
{:else if loading}
|
|
|
|
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" class="spin">
|
|
|
|
|
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-opacity="0.25" />
|
|
|
|
|
<path d="M21 12a9 9 0 00-9-9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
{:else if paused}
|
|
|
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
|
|
|
|
<path d="M8 5l11 7-11 7V5z" fill="currentColor" />
|
|
|
|
|
</svg>
|
|
|
|
|
{:else}
|
|
|
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
|
|
|
|
<path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />
|
|
|
|
|
</svg>
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div class="meta">
|
|
|
|
|
<span class="label">{label}</span>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min="0"
|
2026-05-27 06:39:44 -05:00
|
|
|
max={duration && isFinite(duration) ? duration : 1}
|
2026-05-26 11:52:32 -05:00
|
|
|
step="0.1"
|
|
|
|
|
bind:value={currentTime}
|
|
|
|
|
disabled={!duration || !isFinite(duration)}
|
|
|
|
|
aria-label="Seek {label}"
|
|
|
|
|
aria-valuetext="{fmt(currentTime)} of {fmt(duration)}"
|
|
|
|
|
/>
|
|
|
|
|
<span class="time tabular">{fmt(currentTime)} / {fmt(duration)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<audio
|
|
|
|
|
bind:this={audio}
|
|
|
|
|
bind:paused
|
|
|
|
|
bind:currentTime
|
|
|
|
|
bind:duration
|
2026-05-27 06:39:44 -05:00
|
|
|
onloadstart={() => { errored = false; }}
|
2026-05-26 11:52:32 -05:00
|
|
|
oncanplay={() => { loading = false; }}
|
|
|
|
|
onwaiting={() => { loading = true; }}
|
|
|
|
|
onplaying={onPlaying}
|
|
|
|
|
onerror={() => { loading = false; errored = true; }}
|
|
|
|
|
onended={() => { paused = true; }}
|
|
|
|
|
preload="metadata"
|
|
|
|
|
{src}
|
|
|
|
|
></audio>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.player {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: auto 1fr;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.85rem;
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
box-shadow: var(--shadow);
|
|
|
|
|
}
|
|
|
|
|
.play {
|
|
|
|
|
display: grid;
|
|
|
|
|
place-items: center;
|
|
|
|
|
width: 44px;
|
|
|
|
|
height: 44px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: var(--ink);
|
|
|
|
|
color: #fff;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
transition: background 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
.player.errored .play {
|
|
|
|
|
background: var(--title);
|
|
|
|
|
}
|
|
|
|
|
.play:hover {
|
|
|
|
|
background: var(--ink-deep);
|
|
|
|
|
}
|
|
|
|
|
.play:focus-visible {
|
|
|
|
|
outline: 2px solid var(--title);
|
|
|
|
|
outline-offset: 3px;
|
|
|
|
|
}
|
|
|
|
|
.meta {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: auto 1fr auto;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.label {
|
|
|
|
|
font-family: var(--font-serif);
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.time {
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
color: var(--ink-mute);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tabular {
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
}
|
|
|
|
|
input[type='range'] {
|
|
|
|
|
-webkit-appearance: none;
|
|
|
|
|
appearance: none;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 5px;
|
|
|
|
|
background: var(--rule);
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
outline: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
input[type='range']:focus-visible {
|
|
|
|
|
outline: 2px solid var(--title);
|
|
|
|
|
outline-offset: 3px;
|
|
|
|
|
}
|
|
|
|
|
input[type='range']::-webkit-slider-thumb {
|
|
|
|
|
-webkit-appearance: none;
|
|
|
|
|
appearance: none;
|
|
|
|
|
width: 18px;
|
|
|
|
|
height: 18px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: var(--highlight);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border: 0;
|
|
|
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
|
|
|
|
}
|
|
|
|
|
input[type='range']::-moz-range-thumb {
|
|
|
|
|
width: 18px;
|
|
|
|
|
height: 18px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: var(--highlight);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border: 0;
|
|
|
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
|
|
|
|
}
|
|
|
|
|
input[type='range']:disabled {
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
.spin {
|
|
|
|
|
animation: spin 0.9s linear infinite;
|
|
|
|
|
}
|
|
|
|
|
@keyframes spin {
|
|
|
|
|
to { transform: rotate(360deg); }
|
|
|
|
|
}
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
.spin {
|
|
|
|
|
animation: none;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 480px) {
|
|
|
|
|
.meta {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
.meta .label {
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
}
|
|
|
|
|
.meta .time {
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|