Add music player
This commit is contained in:
1 parent
c820672803
commit
40604b4a04
17 files changed
+1153
No files matched your search
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
cover: string;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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' } });
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in new issue
Block a user