docent/web/src/routes/artist/[id]/SidePlayer.svelte

242 lines
5.5 KiB
Svelte
Raw Normal View History

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) {
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"
max={duration && isFinite(duration) ? duration : 0}
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
onloadstart={() => { loading = true; errored = false; }}
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>