added music player component

This commit is contained in:
2026-04-18 22:44:50 +02:00
parent 46ba3965e9
commit 46cd759a5e
3 changed files with 256 additions and 2 deletions

229
src/lib/media/player.svelte Normal file
View File

@@ -0,0 +1,229 @@
<script lang="ts">
export interface Track {
title: string;
link: string;
}
let {
tracks,
cover, // album cover (optional, currently unused)
}: {
tracks: Track[];
cover?: string;
} = $props();
let selectedTrackId: number = $state(0);
let time: number = $state(0);
let duration: number = $state(0);
let paused: boolean = $state(true);
function playTrack(id: number) {
paused = true;
selectedTrackId = id;
time = 0;
paused = false;
}
function format(time: number) {
if (isNaN(time)) return '...';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`;
}
function playNextTrack() {
if (selectedTrackId < (tracks.length - 1)) {
playTrack(selectedTrackId + 1)
} else {
selectedTrackId = 0;
time = 0;
paused = true;
}
}
</script>
<div class="media-player">
<div class="now-playing">
<audio
src={tracks[selectedTrackId].link}
bind:currentTime={time}
bind:duration
bind:paused
onended={() => {
playNextTrack();
}}
></audio>
<button class="now-playing-button icon"
aria-label={paused ? 'play' : 'pause'}
onclick={() => paused = !paused}
>
{#if paused}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{/if}
</button>
<div>
<p class="now-playing-hint">now playing:</p>
<p class="now-playing-title">{tracks[selectedTrackId].title}</p>
</div>
<p class="now-playing-duration">
{format(time)}&nbsp;//&nbsp;{duration ? format(duration) : '--:--'}
</p>
</div>
<div class="progress-container">
<div class="slider"
onpointerdown={(e: MouseEvent) => {
const div = e.currentTarget as Element;
function seek(e: MouseEvent) {
const { left, width } = div.getBoundingClientRect();
let p = (e.clientX - left) / width;
if (p < 0) p = 0;
if (p > 1) p = 1;
time = p * duration;
}
seek(e);
window.addEventListener('pointermove', seek);
window.addEventListener('pointerup', () => {
window.removeEventListener('pointermove', seek);
}, {
once: true
});
}}
>
<div class="progress-bar" style="--progress: {time / duration}%"></div>
</div>
</div>
<div class="track-list">
{#each tracks as t, index}
<div class="track-container">
<button class="icon" aria-label="play" onclick={() => {
playTrack(index);
}}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
</svg>
</button>
<p class="track-title">{t.title}</p>
<div class="spacer"></div>
<a class="icon" href={t.link} aria-label="download">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</a>
</div>
{/each}
</div>
</div>
<style>
.icon {
width: 20px;
height: 20px;
color: var(--color-text);
transition: color var(--duration-animation) var(--anim-curve);
}
.icon:hover {
cursor: pointer;
color: var(--color-highlight-alt);
}
.media-player {
width: 100%;
border-radius: var(--border-radius);
background-color: var(--color-background-highlight-alt);
padding: 8px 12px;
}
.media-player p, .media-player a {
margin: 0;
}
.now-playing {
display: grid;
grid-template-columns: min-content auto min-content;
gap: 4px;
}
.now-playing-button {
align-self: center;
width: 32px;
height: 32px;
}
.now-playing-hint, .now-playing-duration {
font-size: 0.8rem;
line-height: 1.0rem;
}
.now-playing-title {
font-weight: 700;
}
.now-playing-duration {
display: flex;
flex-direction: row;
align-items: end;
font-family: var(--font-mono);
font-weight: 500;
}
.progress-container {
display: flex;
align-items: center;
margin: 4px 0;
}
.slider {
flex: 1;
height: 6px;
background: var(--color-background-highlight-alt);
border-radius: 6px;
overflow: hidden;
}
.progress-bar {
width: calc(100 * var(--progress));
height: 100%;
background: var(--color-highlight-alt);
transition: width var(--duration-animation) var(--anim-curve);
}
.track-title {
margin: 0 4px !important;
line-height: 1.0rem;
}
.track-container {
display: flex;
flex-direction: row;
align-items: center;
margin: 8px 0;
}
.spacer {
margin: 0 auto;
}
</style>