Add music player

This commit is contained in:
SirBlob committed 2026-10-31 21:17:42 -04:00
1 parent c820672803
commit 40604b4a04
17 files changed
+1153

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
let { playing }: { playing: boolean } = $props();
const barCount = 4;
</script>
<span class="music-bars" class:playing aria-hidden="true">
{#each { length: barCount }, index (index)}
<span class="bar" style:animation-delay="{index * -0.3}s"></span>
{/each}
</span>
<style>
.music-bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
width: 14px;
height: 12px;
}
.bar {
flex: 1;
height: 25%;
background: currentColor;
transition: height 0.2s ease;
}
.playing .bar {
animation: bounce 1.1s ease-in-out infinite;
}
.playing .bar:nth-child(2) {
animation-duration: 0.9s;
}
.playing .bar:nth-child(3) {
animation-duration: 1.3s;
}
.playing .bar:nth-child(4) {
animation-duration: 1s;
}
@keyframes bounce {
0%,
100% {
height: 25%;
}
50% {
height: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.playing .bar {
animation: none;
height: 60%;
}
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
const skipSeconds = 10;
</script>
<div class="controls">
<button
class="control small"
class:active={musicPlayer.repeat}
title={musicPlayer.repeat ? 'Repeat on' : 'Repeat off'}
aria-pressed={musicPlayer.repeat}
onclick={() => musicPlayer.toggleRepeat()}
>
<Icon icon={musicPlayer.repeat ? 'mdi:repeat-once' : 'mdi:repeat'} width="18" />
</button>
<button class="control" title="Previous song" disabled={!musicPlayer.canGoBack} onclick={() => musicPlayer.playPrevious()}>
<Icon icon="mdi:skip-previous" width="22" />
</button>
<button class="control" title="Back {skipSeconds} seconds" onclick={() => musicPlayer.skipBy(-skipSeconds)}>
<Icon icon="mdi:rewind-10" width="20" />
</button>
<button
class="control play"
class:playing={!musicPlayer.paused}
title={musicPlayer.paused ? 'Play' : 'Pause'}
onclick={() => musicPlayer.togglePlay()}
>
<Icon icon={musicPlayer.paused ? 'mdi:play' : 'mdi:pause'} width="28" />
</button>
<button class="control" title="Forward {skipSeconds} seconds" onclick={() => musicPlayer.skipBy(skipSeconds)}>
<Icon icon="mdi:fast-forward-10" width="20" />
</button>
<button class="control" title="Random song" onclick={() => musicPlayer.playRandom()}>
<Icon icon="mdi:skip-next" width="22" />
</button>
</div>
<style>
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.5rem 1.1rem 0.8rem;
}
.control {
display: grid;
place-items: center;
padding: 0.35rem;
background: none;
border: none;
color: var(--foreground);
transition:
color 0.15s ease,
transform 0.15s ease;
}
.control:hover:not(:disabled) {
color: var(--accent);
transform: scale(1.1);
}
.control:active:not(:disabled) {
transform: scale(0.92);
}
.control:disabled {
color: var(--muted);
cursor: default;
opacity: 0.5;
}
.control.small {
color: var(--muted);
}
.control.small.active {
color: var(--accent);
}
.control.play {
position: relative;
width: 52px;
height: 52px;
margin: 0 0.3rem;
background: var(--accent);
color: var(--background);
}
.control.play:hover {
color: var(--background);
}
.control.play.playing::after {
position: absolute;
inset: 0;
border: 2px solid var(--accent);
content: '';
animation: pulse-ring 1.8s ease-out infinite;
pointer-events: none;
}
@keyframes pulse-ring {
from {
opacity: 0.7;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(1.35);
}
}
@media (prefers-reduced-motion: reduce) {
.control.play.playing::after {
animation: none;
}
}
@media (max-width: 640px) {
.controls {
gap: 0.1rem;
padding: 0.5rem 0.6rem 0.8rem;
}
}
</style>
@@ -0,0 +1,146 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { fly } from 'svelte/transition';
import { musicPlayer } from '#lib/music/player.svelte';
import type { Track } from '#lib/music/types';
import MusicBars from './MusicBars.svelte';
let { track }: { track: Track } = $props();
const isPlaying = $derived(!musicPlayer.paused);
const youtubeUrl = $derived(`https://www.youtube.com/watch?v=${track.id}`);
</script>
<header class="now-playing" style:--cover-image="url({track.cover})">
<div class="cover-backdrop"></div>
<a class="open-button" href={youtubeUrl} target="_blank" rel="noopener noreferrer" title="Open song in a new tab">
<Icon icon="mdi:open-in-new" width="14" />
<span>Open</span>
</a>
{#key track.id}
<div class="now-playing-content" in:fly={{ y: 8, duration: 250 }}>
<img class="cover" class:playing={isPlaying} src={track.cover} alt="" />
<div class="track-text">
<span class="label">
<MusicBars playing={isPlaying} />
{isPlaying ? 'Now playing' : 'Paused'}
</span>
<span class="title" title={track.title}>{track.title}</span>
<span class="artist">{track.artist}</span>
</div>
</div>
{/key}
</header>
<style>
.now-playing {
position: relative;
overflow: hidden;
padding: 1.1rem;
}
.cover-backdrop {
position: absolute;
inset: -20px;
background-image: var(--cover-image);
background-size: cover;
background-position: center;
filter: blur(24px) saturate(1.4);
opacity: 0.35;
}
.open-button {
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
background: color-mix(in srgb, var(--background) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
color: var(--foreground);
font-size: 0.75rem;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.open-button:hover {
border-color: var(--accent);
color: var(--accent);
}
.now-playing-content {
position: relative;
display: flex;
align-items: center;
gap: 1rem;
}
.cover {
flex-shrink: 0;
width: 96px;
height: 96px;
object-fit: cover;
background: var(--lighter-background);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.45);
transition: transform 0.4s ease;
}
.cover.playing {
animation: breathe 3s ease-in-out infinite;
}
.track-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding-right: 3.5rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--accent);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.title,
.artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
font-size: 1.05rem;
font-weight: 700;
}
.artist {
color: var(--muted);
font-size: 0.85rem;
}
@keyframes breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.cover.playing {
animation: none;
}
}
</style>
@@ -0,0 +1,83 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
import MusicNowPlaying from './MusicNowPlaying.svelte';
import MusicProgress from './MusicProgress.svelte';
import MusicControls from './MusicControls.svelte';
import MusicVolume from './MusicVolume.svelte';
import MusicTrackList from './MusicTrackList.svelte';
let { onclose }: { onclose: () => void } = $props();
const track = $derived(musicPlayer.unavailable ? undefined : musicPlayer.currentTrack);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close music player" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Music player">
<div class="popover-surface">
{#if track}
<MusicNowPlaying {track} />
<MusicProgress />
<MusicControls />
<MusicVolume />
<MusicTrackList />
{:else}
<div class="empty">
<Icon icon="mdi:music-note-off" width="28" />
<span>No music playing right now</span>
</div>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: none;
border: none;
cursor: default;
}
.popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
width: 420px;
padding: var(--border-size);
background: var(--border-active);
animation: window-pop-in 0.15s ease-out;
}
.popover-surface {
overflow: hidden;
background: var(--background);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
color: var(--muted);
}
@media (max-width: 640px) {
.popover {
position: fixed;
top: calc(var(--bar-height) + 6px);
right: 8px;
left: 8px;
width: auto;
}
}
</style>
@@ -0,0 +1,93 @@
<script lang="ts">
import { musicPlayer } from '#lib/music/player.svelte';
import { formatTrackTime } from '#lib/music/playlist';
let draggedPosition = $state<number | null>(null);
const shownPosition = $derived(draggedPosition ?? musicPlayer.position);
const progressPercent = $derived(musicPlayer.duration > 0 ? (shownPosition / musicPlayer.duration) * 100 : 0);
const remainingSeconds = $derived(Math.max(0, musicPlayer.duration - shownPosition));
function finishSeek() {
if (draggedPosition !== null) musicPlayer.seek(draggedPosition);
draggedPosition = null;
}
</script>
<div class="progress">
<div class="track" class:dragging={draggedPosition !== null}>
<div class="fill" style:width="{progressPercent}%"></div>
<div class="thumb" style:left="{progressPercent}%"></div>
<input
type="range"
min="0"
max={musicPlayer.duration}
step="1"
value={shownPosition}
aria-label="Seek"
oninput={(event) => (draggedPosition = Number(event.currentTarget.value))}
onchange={finishSeek}
/>
</div>
<div class="times">
<span>{formatTrackTime(shownPosition)}</span>
<span>-{formatTrackTime(remainingSeconds)}</span>
</div>
</div>
<style>
.progress {
padding: 0.9rem 1.1rem 0.2rem;
}
.track {
position: relative;
height: 6px;
background: var(--lighter-background);
}
.fill {
height: 100%;
background: var(--accent);
transition: width 0.25s linear;
}
.thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
background: var(--foreground);
transform: translate(-50%, -50%) scale(0);
transition:
transform 0.15s ease,
left 0.25s linear;
}
.track:hover .thumb,
.track.dragging .thumb {
transform: translate(-50%, -50%) scale(1);
}
.track.dragging .fill,
.track.dragging .thumb {
transition: none;
}
.track input {
position: absolute;
inset: -6px 0;
width: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.times {
display: flex;
justify-content: space-between;
margin-top: 0.4rem;
color: var(--muted);
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,142 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import { musicPlayer } from '#lib/music/player.svelte';
import { formatTrackTime } from '#lib/music/playlist';
import MusicBars from './MusicBars.svelte';
let expanded = $state(false);
</script>
<section class="track-list">
<button class="list-toggle" aria-expanded={expanded} onclick={() => (expanded = !expanded)}>
<Icon icon="mdi:playlist-music" width="18" />
<span>Playlist</span>
<span class="count">{musicPlayer.tracks.length} songs</span>
<span class="chevron" class:expanded>
<Icon icon="mdi:chevron-down" width="18" />
</span>
</button>
{#if expanded}
<ul class="tracks" transition:slide={{ duration: 200 }}>
{#each musicPlayer.tracks as track, index (track.id)}
{@const isCurrent = index === musicPlayer.currentIndex}
<li>
<button class="track-row" class:current={isCurrent} onclick={() => musicPlayer.playTrack(index)}>
<img class="thumbnail" src={track.cover} alt="" loading="lazy" />
<span class="track-text">
<span class="track-title">{track.title}</span>
<span class="track-artist">{track.artist}</span>
</span>
{#if isCurrent}
<MusicBars playing={!musicPlayer.paused} />
{:else}
<span class="track-duration">{formatTrackTime(track.durationSeconds)}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.track-list {
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.list-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 1.1rem;
background: none;
border: none;
color: var(--foreground);
font-size: 0.85rem;
}
.list-toggle:hover {
color: var(--accent);
}
.count {
margin-left: auto;
color: var(--muted);
font-size: 0.75rem;
}
.chevron {
display: grid;
place-items: center;
transition: transform 0.2s ease;
}
.chevron.expanded {
transform: rotate(180deg);
}
.tracks {
max-height: 220px;
margin: 0;
padding: 0 0.5rem 0.5rem;
overflow-y: auto;
list-style: none;
}
.track-row {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.4rem 0.6rem;
background: none;
border: 1px solid transparent;
color: var(--foreground);
text-align: left;
transition: background 0.15s ease;
}
.track-row:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
.track-row.current {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.thumbnail {
flex-shrink: 0;
width: 36px;
height: 36px;
object-fit: cover;
}
.track-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.track-title,
.track-artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.track-title {
font-size: 0.85rem;
}
.track-artist,
.track-duration {
color: var(--muted);
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
const shownVolume = $derived(musicPlayer.muted ? 0 : musicPlayer.volume);
const volumePercent = $derived(Math.round(shownVolume * 100));
const volumeIcon = $derived.by(() => {
if (shownVolume === 0) return 'mdi:volume-off';
if (shownVolume < 0.35) return 'mdi:volume-low';
if (shownVolume < 0.7) return 'mdi:volume-medium';
return 'mdi:volume-high';
});
</script>
<div class="volume">
<button class="mute-button" title={musicPlayer.muted ? 'Unmute' : 'Mute'} onclick={() => musicPlayer.toggleMute()}>
<Icon icon={volumeIcon} width="18" />
</button>
<div class="slider">
<div class="fill" style:width="{volumePercent}%"></div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={shownVolume}
aria-label="Volume"
oninput={(event) => musicPlayer.setVolume(Number(event.currentTarget.value))}
/>
</div>
<span class="percent">{volumePercent}%</span>
</div>
<style>
.volume {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.65rem 1.1rem;
background: var(--lighter-background);
}
.mute-button {
display: grid;
place-items: center;
padding: 0.2rem;
background: none;
border: none;
color: var(--foreground);
}
.mute-button:hover {
color: var(--accent);
}
.slider {
position: relative;
flex: 1;
height: 4px;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
}
.fill {
height: 100%;
background: var(--accent);
transition: width 0.15s ease;
}
.slider input {
position: absolute;
inset: -8px 0;
width: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.percent {
width: 2.6rem;
color: var(--muted);
font-size: 0.75rem;
text-align: right;
}
</style>
+19
View File
@@ -6,6 +6,7 @@
import ThemeMenu from './ThemeMenu.svelte';
import DiscordStatus from './DiscordStatus.svelte';
import Battery from './Battery.svelte';
import Music from './Music.svelte';
</script>
<header class="bar">
@@ -21,6 +22,7 @@
</button>
<div class="bar-section right">
<Music />
<DiscordStatus />
<Battery />
<ThemeMenu />
@@ -86,4 +88,21 @@
width: 36px;
}
}
@media (max-width: 420px) {
.bar {
grid-template-columns: auto 1fr auto;
gap: 2px;
padding: 0 2px;
}
.clock-button {
justify-self: center;
padding: 0 0.25rem;
}
.bar :global(.tray-button) {
width: 30px;
}
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
import MusicPopover from '../music/MusicPopover.svelte';
import MusicBars from '../music/MusicBars.svelte';
let open = $state(false);
const track = $derived(musicPlayer.unavailable ? undefined : musicPlayer.currentTrack);
const muteIcon = $derived(musicPlayer.muted ? 'mdi:volume-off' : 'mdi:volume-high');
onMount(() => {
musicPlayer.start();
});
</script>
{#if track}
{#key musicPlayer.streamKey}
<audio
src={musicPlayer.streamUrl}
autoplay
preload="auto"
bind:paused={musicPlayer.paused}
bind:muted={musicPlayer.muted}
bind:volume={musicPlayer.volume}
ontimeupdate={(event) => musicPlayer.handleTimeUpdate(event.currentTarget.currentTime)}
oncanplay={() => musicPlayer.handleTrackLoaded()}
onended={() => musicPlayer.handleEnded()}
onerror={(event) => musicPlayer.handleTrackError(event.currentTarget.error)}
></audio>
{/key}
{/if}
<div class="music-widget">
{#if track}
<button
class="mute-button"
class:active={!musicPlayer.muted}
title={musicPlayer.muted ? 'Unmute music' : 'Mute music'}
onclick={() => musicPlayer.toggleMute()}
>
<Icon icon={muteIcon} width="18" />
</button>
{/if}
<button
class="track-button"
title={track ? `${track.title} - ${track.artist}` : 'Music'}
aria-expanded={open}
onclick={() => (open = !open)}
>
{#if track}
<MusicBars playing={!musicPlayer.paused} />
<span class="track-title">{track.title}</span>
{:else}
<Icon icon="mdi:music-note-off" width="16" />
{/if}
</button>
{#if open}
<MusicPopover onclose={() => (open = false)} />
{/if}
</div>
<style>
.music-widget {
position: relative;
display: flex;
align-items: center;
margin-right: 4px;
}
.mute-button,
.track-button {
display: inline-flex;
align-items: center;
height: var(--bar-height);
background: none;
border: none;
color: var(--foreground);
}
.mute-button {
padding: 0 0.35rem;
color: var(--muted);
}
.mute-button.active {
color: var(--accent);
}
.track-button {
gap: 0.35rem;
padding: 0 0.4rem;
}
.mute-button:hover,
.track-button:hover {
color: var(--accent);
}
.track-title {
max-width: 14rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.track-title {
display: none;
}
.music-widget {
margin-right: 2px;
}
.mute-button,
.track-button {
padding: 0 0.3rem;
}
}
</style>