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

+13
View File
@@ -0,0 +1,13 @@
{
"videos": [
"https://music.youtube.com/watch?v=NqCuW5bupyE&si=D0oy80gIACjXpHM1",
"https://music.youtube.com/watch?v=MXp3lZQgwes&si=fPOA9Rwkx6PLm9bv",
"https://music.youtube.com/watch?v=noiy7ajORtA&si=BeHiGpw-4eyKa76O",
"https://music.youtube.com/watch?v=aEPaTYwYlF8&si=UdCgBnMJTnORpXIX",
"https://music.youtube.com/watch?v=D1JpkgXxMR8&si=9u-Z4CAHbGv1Qopl",
"https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI",
"https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
"https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO",
"https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5"
]
}
+1
View File
@@ -27,6 +27,7 @@
"vite": "^8.3.5" "vite": "^8.3.5"
}, },
"dependencies": { "dependencies": {
"@distube/ytdl-core": "^5.2.0",
"@iconify/svelte": "^5.2.3", "@iconify/svelte": "^5.2.3",
"discord.js": "^14.28.0", "discord.js": "^14.28.0",
"svelte-qrcode": "^1.0.1" "svelte-qrcode": "^1.0.1"
+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 ThemeMenu from './ThemeMenu.svelte';
import DiscordStatus from './DiscordStatus.svelte'; import DiscordStatus from './DiscordStatus.svelte';
import Battery from './Battery.svelte'; import Battery from './Battery.svelte';
import Music from './Music.svelte';
</script> </script>
<header class="bar"> <header class="bar">
@@ -21,6 +22,7 @@
</button> </button>
<div class="bar-section right"> <div class="bar-section right">
<Music />
<DiscordStatus /> <DiscordStatus />
<Battery /> <Battery />
<ThemeMenu /> <ThemeMenu />
@@ -86,4 +88,21 @@
width: 36px; 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> </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>
+137
View File
@@ -0,0 +1,137 @@
import type { Track } from './types';
import { pickRandomIndex } from './playlist';
class MusicPlayer {
tracks = $state<Track[]>([]);
currentIndex = $state(-1);
startOffset = $state(0);
streamKey = $state(0);
elapsedSinceStart = $state(0);
paused = $state(true);
muted = $state(true);
volume = $state(0.6);
unavailable = $state(false);
history = $state<number[]>([]);
repeat = $state(false);
private failedTrackCount = 0;
private loading = false;
get currentTrack(): Track | undefined {
return this.tracks[this.currentIndex];
}
get streamUrl(): string {
const track = this.currentTrack;
if (!track) return '';
return this.startOffset > 0 ? `/api/music/${track.id}?start=${this.startOffset}` : `/api/music/${track.id}`;
}
get position(): number {
return this.startOffset + this.elapsedSinceStart;
}
get duration(): number {
return this.currentTrack?.durationSeconds ?? 0;
}
get canGoBack(): boolean {
return this.history.length > 0;
}
async start() {
if (this.loading || this.tracks.length > 0) return;
this.loading = true;
try {
const response = await fetch('/api/music');
if (response.ok) this.tracks = await response.json();
} catch {
this.tracks = [];
}
this.loading = false;
if (this.tracks.length > 0) this.changeTrack(pickRandomIndex(this.tracks.length, -1));
}
private changeTrack(index: number) {
this.currentIndex = index;
this.startOffset = 0;
this.elapsedSinceStart = 0;
this.streamKey += 1;
}
playTrack(index: number) {
if (index !== this.currentIndex) this.history.push(this.currentIndex);
this.changeTrack(index);
this.paused = false;
}
playRandom() {
this.playTrack(pickRandomIndex(this.tracks.length, this.currentIndex));
}
handleEnded() {
if (this.repeat) {
this.seek(0);
return;
}
this.playRandom();
}
toggleRepeat() {
this.repeat = !this.repeat;
}
skipBy(seconds: number) {
this.seek(this.position + seconds);
}
playPrevious() {
const previousIndex = this.history.pop();
if (previousIndex === undefined) return;
this.changeTrack(previousIndex);
this.paused = false;
}
togglePlay() {
this.paused = !this.paused;
}
toggleMute() {
this.muted = !this.muted;
if (!this.muted) this.paused = false;
}
setVolume(volume: number) {
this.volume = volume;
this.muted = volume === 0;
if (!this.muted) this.paused = false;
}
seek(seconds: number) {
this.startOffset = Math.max(0, Math.min(Math.floor(seconds), this.duration - 1));
this.elapsedSinceStart = 0;
this.streamKey += 1;
this.paused = false;
}
handleTimeUpdate(elapsedSeconds: number) {
this.elapsedSinceStart = elapsedSeconds;
}
handleTrackLoaded() {
this.failedTrackCount = 0;
}
handleTrackError(mediaError: MediaError | null) {
console.error(`Music stream failed for ${this.currentTrack?.id}:`, mediaError?.message || mediaError?.code);
this.failedTrackCount += 1;
if (this.failedTrackCount >= this.tracks.length) {
this.unavailable = true;
this.paused = true;
return;
}
this.playRandom();
}
}
export const musicPlayer = new MusicPlayer();
+12
View File
@@ -0,0 +1,12 @@
export function pickRandomIndex(trackCount: number, excludedIndex: number): number {
if (trackCount <= 1) return 0;
const randomIndex = Math.floor(Math.random() * (trackCount - 1));
return randomIndex >= excludedIndex ? randomIndex + 1 : randomIndex;
}
export function formatTrackTime(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '0:00';
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
+7
View File
@@ -0,0 +1,7 @@
export interface Track {
id: string;
title: string;
artist: string;
durationSeconds: number;
cover: string;
}
+78
View File
@@ -0,0 +1,78 @@
import { Quality, Ytdl, parseVideoId } from '@distube/ytdl-core';
import { spawn } from 'node:child_process';
import { Readable } from 'node:stream';
import { playlist } from '#lib/content';
import type { Track } from '#lib/music/types';
const downloadBufferBytes = 32 * 1024 * 1024;
const mp3Bitrate = '160k';
const youtube = new Ytdl();
const playlistVideoIds = playlist.videos.flatMap((video) => {
const videoId = parseVideoId(video);
return videoId ? [videoId] : [];
});
const allowedVideoIds = new Set(playlistVideoIds);
const trackCache = new Map<string, Track>();
export function isPlaylistVideo(videoId: string): boolean {
return allowedVideoIds.has(videoId);
}
async function getTrack(videoId: string): Promise<Track> {
const cachedTrack = trackCache.get(videoId);
if (cachedTrack) return cachedTrack;
const { videoDetails } = await youtube.getInfo(videoId);
const track: Track = {
id: videoId,
title: videoDetails.title,
artist: videoDetails.author.name,
durationSeconds: videoDetails.lengthSeconds,
cover: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
};
trackCache.set(videoId, track);
return track;
}
export async function getPlaylistTracks(): Promise<Track[]> {
const results = await Promise.allSettled(playlistVideoIds.map(getTrack));
return results.flatMap((result, index) => {
if (result.status === 'fulfilled') return [result.value];
console.error(`Music lookup failed for ${playlistVideoIds[index]}:`, result.reason);
return [];
});
}
export function streamTrackAsMp3(videoId: string, startSeconds: number): ReadableStream<Uint8Array> {
const downloadController = new AbortController();
const youtubeAudio = youtube.download(videoId, {
quality: Quality.HighestAudio,
filter: { hasAudio: true, hasVideo: false },
highWaterMark: downloadBufferBytes,
signal: downloadController.signal
});
const ffmpegArguments = ['-loglevel', 'error', '-ss', String(startSeconds), '-i', 'pipe:0', '-vn', '-f', 'mp3', '-b:a', mp3Bitrate, 'pipe:1'];
const ffmpeg = spawn('ffmpeg', ffmpegArguments, { stdio: ['pipe', 'pipe', 'inherit'] });
const stop = () => {
downloadController.abort();
youtubeAudio.destroy();
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
};
youtubeAudio.on('error', (error) => {
console.error(`Music download failed for ${videoId}:`, error);
stop();
});
ffmpeg.on('error', (error) => {
console.error('ffmpeg failed to start:', error);
stop();
});
ffmpeg.stdin.on('error', stop);
ffmpeg.stdout.on('close', stop);
youtubeAudio.pipe(ffmpeg.stdin);
return Readable.toWeb(ffmpeg.stdout) as ReadableStream<Uint8Array>;
}
+8
View File
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { getPlaylistTracks } from '#lib/server/music';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const tracks = await getPlaylistTracks();
return json(tracks, { headers: { 'Cache-Control': 'no-store' } });
};
+17
View File
@@ -0,0 +1,17 @@
import { error } from '@sveltejs/kit';
import { isPlaylistVideo, streamTrackAsMp3 } from '#lib/server/music';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ params, url }) => {
if (!isPlaylistVideo(params.id)) error(404, 'Song not found');
const requestedStart = Number(url.searchParams.get('start') ?? 0);
const startSeconds = Number.isFinite(requestedStart) && requestedStart > 0 ? Math.floor(requestedStart) : 0;
return new Response(streamTrackAsMp3(params.id, startSeconds), {
headers: {
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-store'
}
});
};