From b9551d759701ddb4d28d0c9136251d26b962e89f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 30 Mar 2026 20:35:13 -0700 Subject: [PATCH] feat: add integrated music player with Navidrome + Soulseek support Search and play music from the Navidrome library with a persistent mini player, full-screen drawer with lyrics/playlists, and Soulseek P2P fallback for discovering new tracks. All streaming proxied through server-side API routes to keep credentials hidden. Co-Authored-By: Claude Opus 4.6 --- .env.example | 9 + app/api/music/cover/[id]/route.ts | 25 ++ app/api/music/lyrics/route.ts | 30 ++ app/api/music/playlist/[id]/route.ts | 26 ++ app/api/music/playlist/create/route.ts | 20 ++ app/api/music/playlists/route.ts | 33 ++ app/api/music/search/route.ts | 57 ++++ app/api/music/slskd/download/route.ts | 28 ++ .../music/slskd/results/[searchId]/route.ts | 54 +++ app/api/music/slskd/search/route.ts | 26 ++ app/api/music/stream/[id]/route.ts | 26 ++ app/layout.tsx | 7 +- app/music/page.tsx | 322 ++++++++++++++++++ app/page.tsx | 4 +- components/music/full-screen-player.tsx | 181 ++++++++++ components/music/mini-player.tsx | 94 +++++ components/music/music-provider.tsx | 227 ++++++++++++ components/music/playlist-picker.tsx | 155 +++++++++ components/music/search-results.tsx | 83 +++++ components/ui/dialog.tsx | 100 ++++++ components/ui/scroll-area.tsx | 49 +++ components/ui/slider.tsx | 43 +++ docker-compose.yml | 5 + lib/navidrome.ts | 47 +++ lib/slskd.ts | 19 ++ 25 files changed, 1667 insertions(+), 3 deletions(-) create mode 100644 app/api/music/cover/[id]/route.ts create mode 100644 app/api/music/lyrics/route.ts create mode 100644 app/api/music/playlist/[id]/route.ts create mode 100644 app/api/music/playlist/create/route.ts create mode 100644 app/api/music/playlists/route.ts create mode 100644 app/api/music/search/route.ts create mode 100644 app/api/music/slskd/download/route.ts create mode 100644 app/api/music/slskd/results/[searchId]/route.ts create mode 100644 app/api/music/slskd/search/route.ts create mode 100644 app/api/music/stream/[id]/route.ts create mode 100644 app/music/page.tsx create mode 100644 components/music/full-screen-player.tsx create mode 100644 components/music/mini-player.tsx create mode 100644 components/music/music-provider.tsx create mode 100644 components/music/playlist-picker.tsx create mode 100644 components/music/search-results.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/slider.tsx create mode 100644 lib/navidrome.ts create mode 100644 lib/slskd.ts diff --git a/.env.example b/.env.example index 7aea2eb..2057a31 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,12 @@ TOKEN_SECRET=your-random-secret-here # Threadfin credentials for one-click channel activation THREADFIN_USER=your-threadfin-username THREADFIN_PASS=your-threadfin-password + +# Navidrome (Music / Subsonic API) +NAVIDROME_URL=https://music.jefflix.lol +NAVIDROME_USER=your-navidrome-username +NAVIDROME_PASS=your-navidrome-password + +# slskd (Soulseek P2P downloads) +SLSKD_URL=https://slskd.jefflix.lol +SLSKD_API_KEY=your-slskd-api-key diff --git a/app/api/music/cover/[id]/route.ts b/app/api/music/cover/[id]/route.ts new file mode 100644 index 0000000..bdfdfa5 --- /dev/null +++ b/app/api/music/cover/[id]/route.ts @@ -0,0 +1,25 @@ +import { navidromeFetch } from '@/lib/navidrome' + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const { searchParams } = new URL(request.url) + const size = searchParams.get('size') || '300' + + try { + const res = await navidromeFetch('getCoverArt.view', { id, size }) + const contentType = res.headers.get('content-type') || 'image/jpeg' + + return new Response(res.body, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=604800', + }, + }) + } catch (error) { + console.error('Cover art error:', error) + return new Response('Cover art failed', { status: 502 }) + } +} diff --git a/app/api/music/lyrics/route.ts b/app/api/music/lyrics/route.ts new file mode 100644 index 0000000..69ff892 --- /dev/null +++ b/app/api/music/lyrics/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { navidromeGet } from '@/lib/navidrome' + +interface LyricsResult { + lyrics?: { + artist?: string + title?: string + value?: string + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const artist = searchParams.get('artist') || '' + const title = searchParams.get('title') || '' + + if (!artist || !title) { + return NextResponse.json({ lyrics: null }) + } + + try { + const data = await navidromeGet('getLyrics.view', { artist, title }) + return NextResponse.json({ + lyrics: data.lyrics?.value || null, + }) + } catch (error) { + console.error('Lyrics error:', error) + return NextResponse.json({ lyrics: null }) + } +} diff --git a/app/api/music/playlist/[id]/route.ts b/app/api/music/playlist/[id]/route.ts new file mode 100644 index 0000000..0d6edda --- /dev/null +++ b/app/api/music/playlist/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import { navidromeGet } from '@/lib/navidrome' + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + try { + const { songId } = await request.json() + if (!songId) { + return NextResponse.json({ error: 'songId required' }, { status: 400 }) + } + + await navidromeGet('updatePlaylist.view', { + playlistId: id, + songIdToAdd: songId, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Add to playlist error:', error) + return NextResponse.json({ error: 'Failed to add to playlist' }, { status: 502 }) + } +} diff --git a/app/api/music/playlist/create/route.ts b/app/api/music/playlist/create/route.ts new file mode 100644 index 0000000..684881b --- /dev/null +++ b/app/api/music/playlist/create/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { navidromeGet } from '@/lib/navidrome' + +export async function POST(request: Request) { + try { + const { name, songId } = await request.json() + if (!name) { + return NextResponse.json({ error: 'name required' }, { status: 400 }) + } + + const params: Record = { name } + if (songId) params.songId = songId + + await navidromeGet('createPlaylist.view', params) + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Create playlist error:', error) + return NextResponse.json({ error: 'Failed to create playlist' }, { status: 502 }) + } +} diff --git a/app/api/music/playlists/route.ts b/app/api/music/playlists/route.ts new file mode 100644 index 0000000..7e1ff6f --- /dev/null +++ b/app/api/music/playlists/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' +import { navidromeGet } from '@/lib/navidrome' + +interface SubsonicPlaylist { + id: string + name: string + songCount: number + duration: number + coverArt: string +} + +interface PlaylistsResult { + playlists?: { + playlist?: SubsonicPlaylist[] + } +} + +export async function GET() { + try { + const data = await navidromeGet('getPlaylists.view') + const playlists = (data.playlists?.playlist || []).map((p) => ({ + id: p.id, + name: p.name, + songCount: p.songCount, + duration: p.duration, + coverArt: p.coverArt, + })) + return NextResponse.json({ playlists }) + } catch (error) { + console.error('Playlists error:', error) + return NextResponse.json({ error: 'Failed to load playlists' }, { status: 502 }) + } +} diff --git a/app/api/music/search/route.ts b/app/api/music/search/route.ts new file mode 100644 index 0000000..80ad979 --- /dev/null +++ b/app/api/music/search/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' +import { navidromeGet } from '@/lib/navidrome' + +interface SubsonicSong { + id: string + title: string + artist: string + album: string + albumId: string + duration: number + track: number + year: number + coverArt: string + suffix: string +} + +interface SearchResult { + searchResult3?: { + song?: SubsonicSong[] + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const q = searchParams.get('q') + + if (!q || q.length < 2) { + return NextResponse.json({ songs: [] }) + } + + try { + const data = await navidromeGet('search3.view', { + query: q, + songCount: '30', + albumCount: '0', + artistCount: '0', + }) + + const songs = (data.searchResult3?.song || []).map((s) => ({ + id: s.id, + title: s.title, + artist: s.artist, + album: s.album, + albumId: s.albumId, + duration: s.duration, + track: s.track, + year: s.year, + coverArt: s.coverArt, + suffix: s.suffix, + })) + + return NextResponse.json({ songs }) + } catch (error) { + console.error('Music search error:', error) + return NextResponse.json({ error: 'Search failed' }, { status: 502 }) + } +} diff --git a/app/api/music/slskd/download/route.ts b/app/api/music/slskd/download/route.ts new file mode 100644 index 0000000..2740702 --- /dev/null +++ b/app/api/music/slskd/download/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { slskdFetch } from '@/lib/slskd' + +export async function POST(request: Request) { + try { + const { username, files } = await request.json() + if (!username || !files?.length) { + return NextResponse.json({ error: 'username and files required' }, { status: 400 }) + } + + const res = await slskdFetch(`/transfers/downloads/${encodeURIComponent(username)}`, { + method: 'POST', + body: JSON.stringify(files.map((f: { filename: string; size: number }) => ({ + filename: f.filename, + size: f.size, + }))), + }) + + if (!res.ok) { + throw new Error(`slskd download returned ${res.status}`) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Soulseek download error:', error) + return NextResponse.json({ error: 'Download request failed' }, { status: 502 }) + } +} diff --git a/app/api/music/slskd/results/[searchId]/route.ts b/app/api/music/slskd/results/[searchId]/route.ts new file mode 100644 index 0000000..81c0e3b --- /dev/null +++ b/app/api/music/slskd/results/[searchId]/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { slskdFetch } from '@/lib/slskd' + +interface SlskdFile { + filename: string + size: number + bitRate: number + length: number +} + +interface SlskdSearchResponse { + username: string + files: SlskdFile[] + freeUploadSlots: number + speed: number +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ searchId: string }> } +) { + const { searchId } = await params + + try { + const res = await slskdFetch(`/searches/${searchId}`) + if (!res.ok) { + throw new Error(`slskd results returned ${res.status}`) + } + + const data = await res.json() + const isComplete = data.state === 'Completed' || data.state === 'TimedOut' + + // Flatten results: each response has username + files + const results = (data.responses || []) + .filter((r: SlskdSearchResponse) => r.files?.length > 0) + .slice(0, 20) + .map((r: SlskdSearchResponse) => ({ + username: r.username, + freeSlots: r.freeUploadSlots, + speed: r.speed, + files: r.files.slice(0, 5).map((f: SlskdFile) => ({ + filename: f.filename, + size: f.size, + bitRate: f.bitRate, + length: f.length, + })), + })) + + return NextResponse.json({ results, isComplete }) + } catch (error) { + console.error('Soulseek results error:', error) + return NextResponse.json({ error: 'Failed to get results' }, { status: 502 }) + } +} diff --git a/app/api/music/slskd/search/route.ts b/app/api/music/slskd/search/route.ts new file mode 100644 index 0000000..3a1c9e3 --- /dev/null +++ b/app/api/music/slskd/search/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import { slskdFetch } from '@/lib/slskd' + +export async function POST(request: Request) { + try { + const { query } = await request.json() + if (!query) { + return NextResponse.json({ error: 'query required' }, { status: 400 }) + } + + const res = await slskdFetch('/searches', { + method: 'POST', + body: JSON.stringify({ searchText: query }), + }) + + if (!res.ok) { + throw new Error(`slskd search returned ${res.status}`) + } + + const data = await res.json() + return NextResponse.json({ searchId: data.id }) + } catch (error) { + console.error('Soulseek search error:', error) + return NextResponse.json({ error: 'Soulseek search failed' }, { status: 502 }) + } +} diff --git a/app/api/music/stream/[id]/route.ts b/app/api/music/stream/[id]/route.ts new file mode 100644 index 0000000..92d7dbd --- /dev/null +++ b/app/api/music/stream/[id]/route.ts @@ -0,0 +1,26 @@ +import { navidromeFetch } from '@/lib/navidrome' + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + try { + const res = await navidromeFetch('stream.view', { id }) + const contentType = res.headers.get('content-type') || 'audio/mpeg' + const contentLength = res.headers.get('content-length') + + const headers: Record = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400', + 'Accept-Ranges': 'bytes', + } + if (contentLength) headers['Content-Length'] = contentLength + + return new Response(res.body, { headers }) + } catch (error) { + console.error('Stream error:', error) + return new Response('Stream failed', { status: 502 }) + } +} diff --git a/app/layout.tsx b/app/layout.tsx index a05e16b..7e183df 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ import type React from "react" import type { Metadata } from "next" import { Geist, Geist_Mono, Fredoka, Permanent_Marker } from "next/font/google" +import { MusicProvider } from "@/components/music/music-provider" +import { MiniPlayer } from "@/components/music/mini-player" import "./globals.css" const _geist = Geist({ subsets: ["latin"] }) @@ -70,7 +72,10 @@ export default function RootLayout({ return ( - {children} + + {children} + + ) diff --git a/app/music/page.tsx b/app/music/page.tsx new file mode 100644 index 0000000..fdc2ec4 --- /dev/null +++ b/app/music/page.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { JefflixLogo } from '@/components/jefflix-logo' +import { SongRow } from '@/components/music/search-results' +import { useMusicPlayer, type Track } from '@/components/music/music-provider' +import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle } from 'lucide-react' +import Link from 'next/link' + +interface SlskdResult { + username: string + freeSlots: number + speed: number + files: { + filename: string + size: number + bitRate: number + length: number + }[] +} + +export default function MusicPage() { + const { state } = useMusicPlayer() + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [songs, setSongs] = useState([]) + const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState('') + const debounceRef = useRef(null) + + // Soulseek state + const [slskMode, setSlskMode] = useState(false) + const [slskSearchId, setSlskSearchId] = useState(null) + const [slskResults, setSlskResults] = useState([]) + const [slskSearching, setSlskSearching] = useState(false) + const [downloading, setDownloading] = useState(null) + const pollRef = useRef(null) + + // Debounced Navidrome search + useEffect(() => { + if (slskMode) return + debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300) + return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } + }, [query, slskMode]) + + useEffect(() => { + if (!debouncedQuery || debouncedQuery.length < 2 || slskMode) { + setSongs([]) + return + } + + setSearching(true) + setSearchError('') + fetch(`/api/music/search?q=${encodeURIComponent(debouncedQuery)}`) + .then((r) => r.json()) + .then((d) => { + if (d.error) throw new Error(d.error) + setSongs(d.songs || []) + }) + .catch((e) => setSearchError(e.message)) + .finally(() => setSearching(false)) + }, [debouncedQuery, slskMode]) + + // Cleanup slskd polling on unmount + useEffect(() => { + return () => { if (pollRef.current) clearTimeout(pollRef.current) } + }, []) + + const searchSoulseek = async () => { + setSlskMode(true) + setSlskSearching(true) + setSlskResults([]) + + try { + const res = await fetch('/api/music/slskd/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: query || debouncedQuery }), + }) + const d = await res.json() + if (d.error) throw new Error(d.error) + setSlskSearchId(d.searchId) + pollSlskResults(d.searchId) + } catch { + setSlskSearching(false) + } + } + + const pollSlskResults = (searchId: string) => { + const poll = async () => { + try { + const res = await fetch(`/api/music/slskd/results/${searchId}`) + const d = await res.json() + setSlskResults(d.results || []) + if (!d.isComplete) { + pollRef.current = setTimeout(poll, 2000) + } else { + setSlskSearching(false) + } + } catch { + setSlskSearching(false) + } + } + poll() + } + + const triggerDownload = async (username: string, files: SlskdResult['files']) => { + const key = `${username}:${files[0]?.filename}` + setDownloading(key) + try { + await fetch('/api/music/slskd/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, files }), + }) + } catch {} + setDownloading(null) + } + + const exitSlsk = () => { + setSlskMode(false) + setSlskSearchId(null) + setSlskResults([]) + setSlskSearching(false) + if (pollRef.current) clearTimeout(pollRef.current) + } + + const hasPlayer = !!state.currentTrack + + return ( +
+ {/* Header */} +
+
+ + + + + + +
+
+ + {/* Main */} +
+
+ {/* Hero */} +
+
+ +
+

Music

+

+ Search the library, play songs, and manage playlists. +

+
+ + {/* Search */} +
+ + { setQuery(e.target.value); if (slskMode) exitSlsk() }} + className="w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-purple-500" + placeholder="Search songs, artists, albums..." + autoFocus + /> +
+ + {/* Soulseek mode toggle */} + {slskMode && ( +
+ Soulseek + Searching peer-to-peer network + +
+ )} + + {/* Navidrome Results */} + {!slskMode && ( + <> + {searching && ( +
+ +
+ )} + + {searchError && ( +
+ +

{searchError}

+
+ )} + + {!searching && songs.length > 0 && ( +
+ {songs.map((song, i) => ( + + ))} +
+ )} + + {!searching && debouncedQuery.length >= 2 && songs.length === 0 && !searchError && ( +
+

+ No results for “{debouncedQuery}” in the library +

+ +
+ )} + + {query.length > 0 && query.length < 2 && ( +

+ Type at least 2 characters to search +

+ )} + + )} + + {/* Soulseek Results */} + {slskMode && ( + <> + {slskSearching && slskResults.length === 0 && ( +
+ +

Searching peer-to-peer network...

+
+ )} + + {slskResults.length > 0 && ( +
+ {slskSearching && ( +
+ + Still searching... +
+ )} + + {slskResults.map((result) => ( +
+
+
+ {result.username} + + {result.freeSlots > 0 ? `${result.freeSlots} free slots` : 'No free slots'} + +
+
+ + {result.files.map((file) => { + const name = file.filename.split('\\').pop() || file.filename + const sizeMB = (file.size / 1024 / 1024).toFixed(1) + const key = `${result.username}:${file.filename}` + + return ( +
+
+
{name}
+
+ {sizeMB} MB + {file.bitRate > 0 && ` ยท ${file.bitRate} kbps`} +
+
+ +
+ ) + })} +
+ ))} +
+ )} + + {!slskSearching && slskResults.length === 0 && slskSearchId && ( +

+ No results found on Soulseek +

+ )} + + )} + + {/* Info */} +
+

How does this work?

+

+ This searches your Navidrome music library. Songs play directly in the browser through a + persistent audio player. Can't find what you're looking for? Search Soulseek to find + and download music from the peer-to-peer network. +

+
+
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 1bb68d3..716fcda 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -108,7 +108,7 @@ export default function JefflixPage() { className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white" variant="default" > - + Listen to Music @@ -280,7 +280,7 @@ export default function JefflixPage() { + + {/* Album art */} +
+ {track.coverArt ? ( + {track.album} + ) : ( +
+ +
+ )} +
+ + {/* Title / Artist */} +
+

{track.title}

+

{track.artist}

+

{track.album}

+
+ + {/* Progress */} +
+ seek(v)} + className="mb-2" + /> +
+ {formatTime(state.progress)} + {formatTime(state.duration)} +
+
+ + {/* Controls */} +
+ + + +
+ + {/* Volume */} +
+ + setVolume(v / 100)} + /> +
+ + {/* Actions */} +
+ + +
+ + {/* Lyrics */} + {loadingLyrics ? ( +

Loading lyrics...

+ ) : lyrics ? ( +
+

Lyrics

+
+                      {lyrics}
+                    
+
+ ) : null} + + )} + + + + + + + ) +} diff --git a/components/music/mini-player.tsx b/components/music/mini-player.tsx new file mode 100644 index 0000000..a4c1fd5 --- /dev/null +++ b/components/music/mini-player.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useMusicPlayer } from './music-provider' +import { FullScreenPlayer } from './full-screen-player' +import { Slider } from '@/components/ui/slider' +import { Play, Pause, SkipBack, SkipForward } from 'lucide-react' + +function formatTime(secs: number) { + if (!secs || !isFinite(secs)) return '0:00' + const m = Math.floor(secs / 60) + const s = Math.floor(secs % 60) + return `${m}:${s.toString().padStart(2, '0')}` +} + +export function MiniPlayer() { + const { state, togglePlay, seek, nextTrack, prevTrack, setFullScreen } = useMusicPlayer() + + if (!state.currentTrack) return null + + const track = state.currentTrack + + return ( + <> +
+ {/* Progress bar (thin, above the player) */} +
+ seek(v)} + className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden" + /> +
+ +
+ {/* Cover art - clickable to open fullscreen */} + + + {/* Track info - clickable to open fullscreen */} + + + {/* Time */} + + {formatTime(state.progress)} / {formatTime(state.duration)} + + + {/* Controls */} +
+ + + +
+
+
+ + + + ) +} diff --git a/components/music/music-provider.tsx b/components/music/music-provider.tsx new file mode 100644 index 0000000..8474a15 --- /dev/null +++ b/components/music/music-provider.tsx @@ -0,0 +1,227 @@ +'use client' + +import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback } from 'react' + +export interface Track { + id: string + title: string + artist: string + album: string + albumId: string + duration: number + coverArt: string +} + +interface PlayerState { + currentTrack: Track | null + queue: Track[] + queueIndex: number + isPlaying: boolean + progress: number + duration: number + volume: number + isFullScreen: boolean +} + +type PlayerAction = + | { type: 'PLAY_TRACK'; track: Track; queue?: Track[]; index?: number } + | { type: 'TOGGLE_PLAY' } + | { type: 'SET_PLAYING'; playing: boolean } + | { type: 'SET_PROGRESS'; progress: number } + | { type: 'SET_DURATION'; duration: number } + | { type: 'SET_VOLUME'; volume: number } + | { type: 'NEXT_TRACK' } + | { type: 'PREV_TRACK' } + | { type: 'SET_FULLSCREEN'; open: boolean } + | { type: 'ADD_TO_QUEUE'; track: Track } + +const initialState: PlayerState = { + currentTrack: null, + queue: [], + queueIndex: -1, + isPlaying: false, + progress: 0, + duration: 0, + volume: 0.8, + isFullScreen: false, +} + +function playerReducer(state: PlayerState, action: PlayerAction): PlayerState { + switch (action.type) { + case 'PLAY_TRACK': { + const queue = action.queue || [action.track] + const index = action.index ?? 0 + return { ...state, currentTrack: action.track, queue, queueIndex: index, isPlaying: true, progress: 0 } + } + case 'TOGGLE_PLAY': + return { ...state, isPlaying: !state.isPlaying } + case 'SET_PLAYING': + return { ...state, isPlaying: action.playing } + case 'SET_PROGRESS': + return { ...state, progress: action.progress } + case 'SET_DURATION': + return { ...state, duration: action.duration } + case 'SET_VOLUME': + return { ...state, volume: action.volume } + case 'NEXT_TRACK': { + const next = state.queueIndex + 1 + if (next >= state.queue.length) return { ...state, isPlaying: false } + return { ...state, currentTrack: state.queue[next], queueIndex: next, isPlaying: true, progress: 0 } + } + case 'PREV_TRACK': { + // If > 3s in, restart current track + if (state.progress > 3) return { ...state, progress: 0 } + const prev = state.queueIndex - 1 + if (prev < 0) return { ...state, progress: 0 } + return { ...state, currentTrack: state.queue[prev], queueIndex: prev, isPlaying: true, progress: 0 } + } + case 'SET_FULLSCREEN': + return { ...state, isFullScreen: action.open } + case 'ADD_TO_QUEUE': + return { ...state, queue: [...state.queue, action.track] } + default: + return state + } +} + +interface MusicContextValue { + state: PlayerState + playTrack: (track: Track, queue?: Track[], index?: number) => void + togglePlay: () => void + seek: (time: number) => void + setVolume: (volume: number) => void + nextTrack: () => void + prevTrack: () => void + setFullScreen: (open: boolean) => void + addToQueue: (track: Track) => void +} + +const MusicContext = createContext(null) + +export function useMusicPlayer() { + const ctx = useContext(MusicContext) + if (!ctx) throw new Error('useMusicPlayer must be used within MusicProvider') + return ctx +} + +export function MusicProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(playerReducer, initialState) + const audioRef = useRef(null) + + // Create audio element on mount (client only) + useEffect(() => { + const audio = new Audio() + audio.preload = 'auto' + audioRef.current = audio + + const onTimeUpdate = () => dispatch({ type: 'SET_PROGRESS', progress: audio.currentTime }) + const onDurationChange = () => dispatch({ type: 'SET_DURATION', duration: audio.duration || 0 }) + const onEnded = () => dispatch({ type: 'NEXT_TRACK' }) + const onPause = () => dispatch({ type: 'SET_PLAYING', playing: false }) + const onPlay = () => dispatch({ type: 'SET_PLAYING', playing: true }) + + audio.addEventListener('timeupdate', onTimeUpdate) + audio.addEventListener('durationchange', onDurationChange) + audio.addEventListener('ended', onEnded) + audio.addEventListener('pause', onPause) + audio.addEventListener('play', onPlay) + + return () => { + audio.removeEventListener('timeupdate', onTimeUpdate) + audio.removeEventListener('durationchange', onDurationChange) + audio.removeEventListener('ended', onEnded) + audio.removeEventListener('pause', onPause) + audio.removeEventListener('play', onPlay) + audio.pause() + audio.src = '' + } + }, []) + + // When currentTrack changes, update audio src + useEffect(() => { + const audio = audioRef.current + if (!audio || !state.currentTrack) return + audio.src = `/api/music/stream/${state.currentTrack.id}` + audio.play().catch(() => {}) + }, [state.currentTrack?.id]) // eslint-disable-line react-hooks/exhaustive-deps + + // Sync play/pause + useEffect(() => { + const audio = audioRef.current + if (!audio || !state.currentTrack) return + if (state.isPlaying) { + audio.play().catch(() => {}) + } else { + audio.pause() + } + }, [state.isPlaying, state.currentTrack]) + + // Sync volume + useEffect(() => { + if (audioRef.current) audioRef.current.volume = state.volume + }, [state.volume]) + + // MediaSession API + useEffect(() => { + if (!state.currentTrack || !('mediaSession' in navigator)) return + navigator.mediaSession.metadata = new MediaMetadata({ + title: state.currentTrack.title, + artist: state.currentTrack.artist, + album: state.currentTrack.album, + artwork: state.currentTrack.coverArt + ? [{ src: `/api/music/cover/${state.currentTrack.coverArt}?size=300`, sizes: '300x300', type: 'image/jpeg' }] + : [], + }) + navigator.mediaSession.setActionHandler('play', () => dispatch({ type: 'SET_PLAYING', playing: true })) + navigator.mediaSession.setActionHandler('pause', () => dispatch({ type: 'SET_PLAYING', playing: false })) + navigator.mediaSession.setActionHandler('previoustrack', () => dispatch({ type: 'PREV_TRACK' })) + navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' })) + }, [state.currentTrack]) + + // Keyboard shortcut: Space = play/pause (only when not in input) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.code === 'Space' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) { + e.preventDefault() + dispatch({ type: 'TOGGLE_PLAY' }) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + const playTrack = useCallback((track: Track, queue?: Track[], index?: number) => { + dispatch({ type: 'PLAY_TRACK', track, queue, index }) + }, []) + + const togglePlay = useCallback(() => dispatch({ type: 'TOGGLE_PLAY' }), []) + + const seek = useCallback((time: number) => { + if (audioRef.current) { + audioRef.current.currentTime = time + dispatch({ type: 'SET_PROGRESS', progress: time }) + } + }, []) + + const setVolume = useCallback((v: number) => dispatch({ type: 'SET_VOLUME', volume: v }), []) + const nextTrack = useCallback(() => dispatch({ type: 'NEXT_TRACK' }), []) + const prevTrack = useCallback(() => dispatch({ type: 'PREV_TRACK' }), []) + const setFullScreen = useCallback((open: boolean) => dispatch({ type: 'SET_FULLSCREEN', open }), []) + const addToQueue = useCallback((track: Track) => dispatch({ type: 'ADD_TO_QUEUE', track }), []) + + return ( + + {children} + + ) +} diff --git a/components/music/playlist-picker.tsx b/components/music/playlist-picker.tsx new file mode 100644 index 0000000..df50e56 --- /dev/null +++ b/components/music/playlist-picker.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Button } from '@/components/ui/button' +import { ListPlus, Plus, Loader2, CheckCircle } from 'lucide-react' +import { useMusicPlayer } from './music-provider' + +interface Playlist { + id: string + name: string + songCount: number +} + +export function PlaylistPicker({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const { state } = useMusicPlayer() + const [playlists, setPlaylists] = useState([]) + const [loading, setLoading] = useState(false) + const [adding, setAdding] = useState(null) + const [added, setAdded] = useState(null) + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState('') + + useEffect(() => { + if (!open) return + setLoading(true) + setAdded(null) + fetch('/api/music/playlists') + .then((r) => r.json()) + .then((d) => setPlaylists(d.playlists || [])) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [open]) + + const songId = state.currentTrack?.id + if (!songId) return null + + const addToPlaylist = async (playlistId: string) => { + setAdding(playlistId) + try { + await fetch(`/api/music/playlist/${playlistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ songId }), + }) + setAdded(playlistId) + } catch {} + setAdding(null) + } + + const createPlaylist = async () => { + if (!newName.trim()) return + setCreating(true) + try { + await fetch('/api/music/playlist/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName.trim(), songId }), + }) + setAdded('new') + setNewName('') + } catch {} + setCreating(false) + } + + return ( + + + + + + Add to Playlist + + + Add “{state.currentTrack?.title}” to an existing playlist or create a new one. + + + + {loading ? ( +
+ +
+ ) : ( + <> + +
+ {playlists.map((p) => ( + + ))} + {playlists.length === 0 && ( +

+ No playlists yet. Create one below. +

+ )} +
+
+ +
+ setNewName(e.target.value)} + placeholder="New playlist name..." + className="flex-1 px-3 py-2 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring" + onKeyDown={(e) => e.key === 'Enter' && createPlaylist()} + /> + +
+ + {added === 'new' && ( +

+ + Playlist created with song added +

+ )} + + )} +
+
+ ) +} diff --git a/components/music/search-results.tsx b/components/music/search-results.tsx new file mode 100644 index 0000000..b05ed05 --- /dev/null +++ b/components/music/search-results.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useMusicPlayer, type Track } from './music-provider' +import { Play, Pause, ListPlus } from 'lucide-react' + +function formatDuration(secs: number) { + if (!secs) return '' + const m = Math.floor(secs / 60) + const s = Math.floor(secs % 60) + return `${m}:${s.toString().padStart(2, '0')}` +} + +export function SongRow({ + song, + songs, + index, +}: { + song: Track + songs: Track[] + index: number +}) { + const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer() + const isActive = state.currentTrack?.id === song.id + const isPlaying = isActive && state.isPlaying + + return ( +
+ {/* Play button / track number */} + + + {/* Cover art */} +
+ {song.coverArt ? ( + {song.album} + ) : ( +
+ )} +
+ + {/* Info */} +
+
+ {song.title} +
+
+ {song.artist} · {song.album} +
+
+ + {/* Duration */} + + {formatDuration(song.duration)} + + + {/* Add to queue */} + +
+ ) +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..72b5139 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,100 @@ +'use client' + +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger +const DialogClose = DialogPrimitive.Close +const DialogPortal = DialogPrimitive.Portal + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
+} + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return
+} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 0000000..55e5039 --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -0,0 +1,49 @@ +'use client' + +import * as React from 'react' +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' +import { cn } from '@/lib/utils' + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 0000000..694f7ed --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,43 @@ +'use client' + +import * as React from 'react' +import * as SliderPrimitive from '@radix-ui/react-slider' +import { cn } from '@/lib/utils' + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = value ?? defaultValue ?? [min] + + return ( + + + + + {_values.map((_, i) => ( + + ))} + + ) +} + +export { Slider } diff --git a/docker-compose.yml b/docker-compose.yml index cd17ec4..b175f55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,11 @@ services: - THREADFIN_URL=https://threadfin.jefflix.lol - THREADFIN_USER=${THREADFIN_USER} - THREADFIN_PASS=${THREADFIN_PASS} + - NAVIDROME_URL=${NAVIDROME_URL:-https://music.jefflix.lol} + - NAVIDROME_USER=${NAVIDROME_USER} + - NAVIDROME_PASS=${NAVIDROME_PASS} + - SLSKD_URL=${SLSKD_URL:-https://slskd.jefflix.lol} + - SLSKD_API_KEY=${SLSKD_API_KEY} labels: - "traefik.enable=true" - "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)" diff --git a/lib/navidrome.ts b/lib/navidrome.ts new file mode 100644 index 0000000..3bd6db5 --- /dev/null +++ b/lib/navidrome.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto' + +const NAVIDROME_URL = process.env.NAVIDROME_URL || 'https://music.jefflix.lol' +const NAVIDROME_USER = process.env.NAVIDROME_USER || '' +const NAVIDROME_PASS = process.env.NAVIDROME_PASS || '' + +function subsonicAuth() { + const salt = crypto.randomBytes(6).toString('hex') + const token = crypto.createHash('md5').update(NAVIDROME_PASS + salt).digest('hex') + return { u: NAVIDROME_USER, t: token, s: salt, v: '1.16.1', c: 'jefflix', f: 'json' } +} + +function buildUrl(path: string, params: Record = {}) { + const auth = subsonicAuth() + const url = new URL(`/rest/${path}`, NAVIDROME_URL) + for (const [k, v] of Object.entries({ ...auth, ...params })) { + url.searchParams.set(k, v) + } + return url.toString() +} + +/** JSON response from Subsonic API (parsed) */ +export async function navidromeGet>( + path: string, + params: Record = {} +): Promise { + const url = buildUrl(path, params) + const res = await fetch(url, { cache: 'no-store' }) + if (!res.ok) throw new Error(`Navidrome ${path} returned ${res.status}`) + const json = await res.json() + const sub = json['subsonic-response'] + if (sub?.status !== 'ok') { + throw new Error(sub?.error?.message || `Navidrome error on ${path}`) + } + return sub as T +} + +/** Raw binary response (for streaming audio, cover art) */ +export async function navidromeFetch( + path: string, + params: Record = {} +): Promise { + const url = buildUrl(path, params) + const res = await fetch(url, { cache: 'no-store' }) + if (!res.ok) throw new Error(`Navidrome ${path} returned ${res.status}`) + return res +} diff --git a/lib/slskd.ts b/lib/slskd.ts new file mode 100644 index 0000000..3828c42 --- /dev/null +++ b/lib/slskd.ts @@ -0,0 +1,19 @@ +const SLSKD_URL = process.env.SLSKD_URL || 'https://slskd.jefflix.lol' +const SLSKD_API_KEY = process.env.SLSKD_API_KEY || '' + +export async function slskdFetch( + path: string, + options: RequestInit = {} +): Promise { + const url = `${SLSKD_URL}/api/v0${path}` + const res = await fetch(url, { + ...options, + headers: { + 'X-API-Key': SLSKD_API_KEY, + 'Content-Type': 'application/json', + ...options.headers, + }, + cache: 'no-store', + }) + return res +}