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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-30 20:35:13 -07:00
parent 07e64e0627
commit b9551d7597
25 changed files with 1667 additions and 3 deletions

View File

@ -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

View File

@ -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 })
}
}

View File

@ -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<LyricsResult>('getLyrics.view', { artist, title })
return NextResponse.json({
lyrics: data.lyrics?.value || null,
})
} catch (error) {
console.error('Lyrics error:', error)
return NextResponse.json({ lyrics: null })
}
}

View File

@ -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 })
}
}

View File

@ -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<string, string> = { 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 })
}
}

View File

@ -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<PlaylistsResult>('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 })
}
}

View File

@ -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<SearchResult>('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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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<string, string> = {
'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 })
}
}

View File

@ -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 (
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
<body className={`font-sans antialiased`}>
<MusicProvider>
{children}
<MiniPlayer />
</MusicProvider>
</body>
</html>
)

322
app/music/page.tsx Normal file
View File

@ -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<Track[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null)
// Soulseek state
const [slskMode, setSlskMode] = useState(false)
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
const [slskResults, setSlskResults] = useState<SlskdResult[]>([])
const [slskSearching, setSlskSearching] = useState(false)
const [downloading, setDownloading] = useState<string | null>(null)
const pollRef = useRef<NodeJS.Timeout>(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 (
<div className={`min-h-screen bg-background ${hasPlayer ? 'pb-20' : ''}`}>
{/* Header */}
<div className="border-b border-border">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="inline-block">
<JefflixLogo size="small" />
</Link>
<Link href="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Home
</Button>
</Link>
</div>
</div>
{/* Main */}
<div className="container mx-auto px-4 py-12 md:py-16">
<div className="max-w-2xl mx-auto">
{/* Hero */}
<div className="text-center space-y-4 mb-8">
<div className="inline-block p-4 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<Music className="h-10 w-10 text-purple-600 dark:text-purple-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Music</h1>
<p className="text-muted-foreground">
Search the library, play songs, and manage playlists.
</p>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => { 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
/>
</div>
{/* Soulseek mode toggle */}
{slskMode && (
<div className="flex items-center gap-2 mb-4">
<Badge className="bg-yellow-600 text-white">Soulseek</Badge>
<span className="text-sm text-muted-foreground">Searching peer-to-peer network</span>
<button onClick={exitSlsk} className="text-sm text-primary hover:underline ml-auto">
Back to Library
</button>
</div>
)}
{/* Navidrome Results */}
{!slskMode && (
<>
{searching && (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-purple-600" />
</div>
)}
{searchError && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{searchError}</p>
</div>
)}
{!searching && songs.length > 0 && (
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{songs.map((song, i) => (
<SongRow key={song.id} song={song} songs={songs} index={i} />
))}
</div>
)}
{!searching && debouncedQuery.length >= 2 && songs.length === 0 && !searchError && (
<div className="text-center py-8 space-y-4">
<p className="text-muted-foreground">
No results for &ldquo;{debouncedQuery}&rdquo; in the library
</p>
<Button
onClick={searchSoulseek}
className="bg-yellow-600 hover:bg-yellow-700 text-white"
>
<Download className="h-4 w-4 mr-1.5" />
Search Soulseek
</Button>
</div>
)}
{query.length > 0 && query.length < 2 && (
<p className="text-sm text-muted-foreground text-center">
Type at least 2 characters to search
</p>
)}
</>
)}
{/* Soulseek Results */}
{slskMode && (
<>
{slskSearching && slskResults.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8">
<Loader2 className="h-6 w-6 animate-spin text-yellow-600" />
<p className="text-sm text-muted-foreground">Searching peer-to-peer network...</p>
</div>
)}
{slskResults.length > 0 && (
<div className="space-y-3">
{slskSearching && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Still searching...
</div>
)}
{slskResults.map((result) => (
<div
key={`${result.username}-${result.files[0]?.filename}`}
className="border border-border rounded-lg p-4 space-y-2"
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{result.username}</span>
<span className="text-xs text-muted-foreground ml-2">
{result.freeSlots > 0 ? `${result.freeSlots} free slots` : 'No free slots'}
</span>
</div>
</div>
{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 (
<div key={file.filename} className="flex items-center gap-3 pl-2">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{name}</div>
<div className="text-xs text-muted-foreground">
{sizeMB} MB
{file.bitRate > 0 && ` · ${file.bitRate} kbps`}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => triggerDownload(result.username, [file])}
disabled={downloading === key}
>
{downloading === key ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</Button>
</div>
)
})}
</div>
))}
</div>
)}
{!slskSearching && slskResults.length === 0 && slskSearchId && (
<p className="text-center text-muted-foreground py-8">
No results found on Soulseek
</p>
)}
</>
)}
{/* Info */}
<div className="mt-12 p-6 bg-muted/50 rounded-lg space-y-3">
<h3 className="font-bold mb-2">How does this work?</h3>
<p className="text-sm text-muted-foreground">
This searches your Navidrome music library. Songs play directly in the browser through a
persistent audio player. Can&apos;t find what you&apos;re looking for? Search Soulseek to find
and download music from the peer-to-peer network.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -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"
>
<a href="https://music.jefflix.lol">
<a href="/music">
<Music className="mr-2 h-5 w-5" />
Listen to Music
</a>
@ -280,7 +280,7 @@ export default function JefflixPage() {
</a>
</Button>
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white">
<a href="https://music.jefflix.lol">
<a href="/music">
<Music className="mr-2 h-5 w-5" />
Listen to Music
</a>

View File

@ -0,0 +1,181 @@
'use client'
import { useState, useEffect } from 'react'
import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
import { PlaylistPicker } from './playlist-picker'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import {
Play,
Pause,
SkipBack,
SkipForward,
ListPlus,
Share2,
Volume2,
VolumeX,
ChevronDown,
} 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 FullScreenPlayer() {
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen } = useMusicPlayer()
const [lyrics, setLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
const track = state.currentTrack
// Fetch lyrics when track changes
useEffect(() => {
if (!track) return
setLyrics(null)
setLoadingLyrics(true)
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
.then((r) => r.json())
.then((d) => setLyrics(d.lyrics))
.catch(() => setLyrics(null))
.finally(() => setLoadingLyrics(false))
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const handleShare = async () => {
if (!track) return
const text = `${track.title} - ${track.artist}`
if (navigator.share) {
try {
await navigator.share({ title: text, text })
} catch {}
} else {
await navigator.clipboard.writeText(text)
}
}
return (
<>
<Drawer.Root open={state.isFullScreen} onOpenChange={setFullScreen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/60 z-50" />
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
<Drawer.Title className="sr-only">Music Player</Drawer.Title>
<Drawer.Description className="sr-only">
Full-screen music player with controls, lyrics, and playlist management
</Drawer.Description>
{track && (
<div className="flex flex-col items-center px-6 pb-8 pt-4 overflow-y-auto">
{/* Close button */}
<button
onClick={() => setFullScreen(false)}
className="self-start mb-4 p-1 rounded-full hover:bg-muted/50 transition-colors"
>
<ChevronDown className="h-6 w-6 text-muted-foreground" />
</button>
{/* Album art */}
<div className="w-64 h-64 sm:w-72 sm:h-72 rounded-xl overflow-hidden shadow-2xl mb-8 bg-muted flex-shrink-0">
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=600`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Volume2 className="h-16 w-16" />
</div>
)}
</div>
{/* Title / Artist */}
<div className="text-center mb-6 max-w-sm">
<h2 className="text-xl font-bold truncate">{track.title}</h2>
<p className="text-muted-foreground truncate">{track.artist}</p>
<p className="text-sm text-muted-foreground/70 truncate">{track.album}</p>
</div>
{/* Progress */}
<div className="w-full max-w-sm mb-4">
<Slider
value={[state.progress]}
max={state.duration || 1}
step={1}
onValueChange={([v]) => seek(v)}
className="mb-2"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatTime(state.progress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-6 mb-6">
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipBack className="h-6 w-6" />
</button>
<button
onClick={togglePlay}
className="p-4 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
>
{state.isPlaying ? <Pause className="h-7 w-7" /> : <Play className="h-7 w-7 ml-0.5" />}
</button>
<button onClick={nextTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipForward className="h-6 w-6" />
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-3 w-full max-w-[200px] mb-6">
<button onClick={() => setVolume(state.volume === 0 ? 0.8 : 0)} className="text-muted-foreground">
{state.volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<Slider
value={[state.volume * 100]}
max={100}
step={1}
onValueChange={([v]) => setVolume(v / 100)}
/>
</div>
{/* Actions */}
<div className="flex gap-3 mb-8">
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
<ListPlus className="h-4 w-4 mr-1.5" />
Add to Playlist
</Button>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="h-4 w-4 mr-1.5" />
Share
</Button>
</div>
{/* Lyrics */}
{loadingLyrics ? (
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
) : lyrics ? (
<div className="w-full max-w-sm">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
<pre className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground font-sans">
{lyrics}
</pre>
</div>
) : null}
</div>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
<PlaylistPicker open={playlistOpen} onOpenChange={setPlaylistOpen} />
</>
)
}

View File

@ -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 (
<>
<div className="fixed bottom-0 inset-x-0 z-50 bg-card border-t border-border shadow-lg">
{/* Progress bar (thin, above the player) */}
<div className="px-2">
<Slider
value={[state.progress]}
max={state.duration || 1}
step={1}
onValueChange={([v]) => seek(v)}
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
/>
</div>
<div className="flex items-center gap-3 px-4 py-2 h-14">
{/* Cover art - clickable to open fullscreen */}
<button
onClick={() => setFullScreen(true)}
className="flex-shrink-0 w-10 h-10 rounded-md overflow-hidden bg-muted"
>
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=80`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
</button>
{/* Track info - clickable to open fullscreen */}
<button
onClick={() => setFullScreen(true)}
className="flex-1 min-w-0 text-left"
>
<div className="text-sm font-medium truncate">{track.title}</div>
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
</button>
{/* Time */}
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatTime(state.progress)} / {formatTime(state.duration)}
</span>
{/* Controls */}
<div className="flex items-center gap-1">
<button
onClick={prevTrack}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
>
<SkipBack className="h-4 w-4" />
</button>
<button
onClick={togglePlay}
className="p-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
>
{state.isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</button>
<button
onClick={nextTrack}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
</div>
<FullScreenPlayer />
</>
)
}

View File

@ -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<MusicContextValue | null>(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<HTMLAudioElement | null>(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 (
<MusicContext.Provider value={{
state,
playTrack,
togglePlay,
seek,
setVolume,
nextTrack,
prevTrack,
setFullScreen,
addToQueue,
}}>
{children}
</MusicContext.Provider>
)
}

View File

@ -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<Playlist[]>([])
const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState<string | null>(null)
const [added, setAdded] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ListPlus className="h-5 w-5" />
Add to Playlist
</DialogTitle>
<DialogDescription>
Add &ldquo;{state.currentTrack?.title}&rdquo; to an existing playlist or create a new one.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ScrollArea className="max-h-[300px]">
<div className="space-y-1">
{playlists.map((p) => (
<button
key={p.id}
onClick={() => addToPlaylist(p.id)}
disabled={adding !== null}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors text-left"
>
<div>
<div className="font-medium text-sm">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
</div>
{adding === p.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : added === p.id ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : null}
</button>
))}
{playlists.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No playlists yet. Create one below.
</p>
)}
</div>
</ScrollArea>
<div className="flex gap-2 pt-2 border-t border-border">
<input
type="text"
value={newName}
onChange={(e) => 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()}
/>
<Button
size="sm"
onClick={createPlaylist}
disabled={!newName.trim() || creating}
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
{added === 'new' && (
<p className="text-sm text-green-500 flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5" />
Playlist created with song added
</p>
)}
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -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 (
<div
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
isActive ? 'bg-primary/5' : ''
}`}
>
{/* Play button / track number */}
<button
onClick={() => isActive ? togglePlay() : playTrack(song, songs, index)}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors"
>
{isPlaying ? (
<Pause className="h-4 w-4 text-primary" />
) : (
<Play className="h-4 w-4 text-primary ml-0.5" />
)}
</button>
{/* Cover art */}
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
{song.coverArt ? (
<img
src={`/api/music/cover/${song.coverArt}?size=80`}
alt={song.album}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isActive ? 'text-primary' : ''}`}>
{song.title}
</div>
<div className="text-xs text-muted-foreground truncate">
{song.artist} &middot; {song.album}
</div>
</div>
{/* Duration */}
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatDuration(song.duration)}
</span>
{/* Add to queue */}
<button
onClick={(e) => { e.stopPropagation(); addToQueue(song) }}
className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-muted/50 rounded transition-all"
title="Add to queue"
>
<ListPlus className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)
}

100
components/ui/dialog.tsx Normal file
View File

@ -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<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-bar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

43
components/ui/slider.tsx Normal file
View File

@ -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<typeof SliderPrimitive.Root>) {
const _values = value ?? defaultValue ?? [min]
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
{_values.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
className="border-primary/50 bg-background block size-4 rounded-full border shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -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`)"

47
lib/navidrome.ts Normal file
View File

@ -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<string, string> = {}) {
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<T = Record<string, unknown>>(
path: string,
params: Record<string, string> = {}
): Promise<T> {
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<string, string> = {}
): Promise<Response> {
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
}

19
lib/slskd.ts Normal file
View File

@ -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<Response> {
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
}