diff --git a/content/music.json b/content/music.json new file mode 100644 index 0000000..d601ded --- /dev/null +++ b/content/music.json @@ -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" + ] +} diff --git a/package.json b/package.json index 057b5fe..af44e7b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "vite": "^8.3.5" }, "dependencies": { + "@distube/ytdl-core": "^5.2.0", "@iconify/svelte": "^5.2.3", "discord.js": "^14.28.0", "svelte-qrcode": "^1.0.1" diff --git a/src/lib/components/music/MusicBars.svelte b/src/lib/components/music/MusicBars.svelte new file mode 100644 index 0000000..fc9929c --- /dev/null +++ b/src/lib/components/music/MusicBars.svelte @@ -0,0 +1,61 @@ + + + + + diff --git a/src/lib/components/music/MusicControls.svelte b/src/lib/components/music/MusicControls.svelte new file mode 100644 index 0000000..d6962ec --- /dev/null +++ b/src/lib/components/music/MusicControls.svelte @@ -0,0 +1,129 @@ + + +
+ + + + + + +
+ + diff --git a/src/lib/components/music/MusicNowPlaying.svelte b/src/lib/components/music/MusicNowPlaying.svelte new file mode 100644 index 0000000..fc7f845 --- /dev/null +++ b/src/lib/components/music/MusicNowPlaying.svelte @@ -0,0 +1,146 @@ + + +
+
+ + + Open + + {#key track.id} +
+ +
+ + + {isPlaying ? 'Now playing' : 'Paused'} + + {track.title} + {track.artist} +
+
+ {/key} +
+ + diff --git a/src/lib/components/music/MusicPopover.svelte b/src/lib/components/music/MusicPopover.svelte new file mode 100644 index 0000000..d19bd2e --- /dev/null +++ b/src/lib/components/music/MusicPopover.svelte @@ -0,0 +1,83 @@ + + + + + + + + diff --git a/src/lib/components/music/MusicProgress.svelte b/src/lib/components/music/MusicProgress.svelte new file mode 100644 index 0000000..2efab06 --- /dev/null +++ b/src/lib/components/music/MusicProgress.svelte @@ -0,0 +1,93 @@ + + +
+
+
+
+ (draggedPosition = Number(event.currentTarget.value))} + onchange={finishSeek} + /> +
+
+ {formatTrackTime(shownPosition)} + -{formatTrackTime(remainingSeconds)} +
+
+ + diff --git a/src/lib/components/music/MusicTrackList.svelte b/src/lib/components/music/MusicTrackList.svelte new file mode 100644 index 0000000..7d4bac2 --- /dev/null +++ b/src/lib/components/music/MusicTrackList.svelte @@ -0,0 +1,142 @@ + + +
+ + + {#if expanded} + + {/if} +
+ + diff --git a/src/lib/components/music/MusicVolume.svelte b/src/lib/components/music/MusicVolume.svelte new file mode 100644 index 0000000..b13c867 --- /dev/null +++ b/src/lib/components/music/MusicVolume.svelte @@ -0,0 +1,84 @@ + + +
+ +
+
+ musicPlayer.setVolume(Number(event.currentTarget.value))} + /> +
+ {volumePercent}% +
+ + diff --git a/src/lib/components/shell/Bar.svelte b/src/lib/components/shell/Bar.svelte index c53951f..6e8cdfd 100644 --- a/src/lib/components/shell/Bar.svelte +++ b/src/lib/components/shell/Bar.svelte @@ -6,6 +6,7 @@ import ThemeMenu from './ThemeMenu.svelte'; import DiscordStatus from './DiscordStatus.svelte'; import Battery from './Battery.svelte'; + import Music from './Music.svelte';
@@ -21,6 +22,7 @@
+ @@ -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; + } + } diff --git a/src/lib/components/shell/Music.svelte b/src/lib/components/shell/Music.svelte new file mode 100644 index 0000000..406ba06 --- /dev/null +++ b/src/lib/components/shell/Music.svelte @@ -0,0 +1,123 @@ + + +{#if track} + {#key musicPlayer.streamKey} + + {/key} +{/if} + +
+ {#if track} + + {/if} + + + {#if open} + (open = false)} /> + {/if} +
+ + diff --git a/src/lib/music/player.svelte.ts b/src/lib/music/player.svelte.ts new file mode 100644 index 0000000..737ee1a --- /dev/null +++ b/src/lib/music/player.svelte.ts @@ -0,0 +1,137 @@ +import type { Track } from './types'; +import { pickRandomIndex } from './playlist'; + +class MusicPlayer { + tracks = $state([]); + 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([]); + 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(); diff --git a/src/lib/music/playlist.ts b/src/lib/music/playlist.ts new file mode 100644 index 0000000..dfd7ace --- /dev/null +++ b/src/lib/music/playlist.ts @@ -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')}`; +} diff --git a/src/lib/music/types.ts b/src/lib/music/types.ts new file mode 100644 index 0000000..758f688 --- /dev/null +++ b/src/lib/music/types.ts @@ -0,0 +1,7 @@ +export interface Track { + id: string; + title: string; + artist: string; + durationSeconds: number; + cover: string; +} diff --git a/src/lib/server/music.ts b/src/lib/server/music.ts new file mode 100644 index 0000000..c709e44 --- /dev/null +++ b/src/lib/server/music.ts @@ -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(); + +export function isPlaylistVideo(videoId: string): boolean { + return allowedVideoIds.has(videoId); +} + +async function getTrack(videoId: string): Promise { + 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 { + 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 { + 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; +} diff --git a/src/routes/api/music/+server.ts b/src/routes/api/music/+server.ts new file mode 100644 index 0000000..59c001a --- /dev/null +++ b/src/routes/api/music/+server.ts @@ -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' } }); +}; diff --git a/src/routes/api/music/[id]/+server.ts b/src/routes/api/music/[id]/+server.ts new file mode 100644 index 0000000..ea39bab --- /dev/null +++ b/src/routes/api/music/[id]/+server.ts @@ -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' + } + }); +};