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:
parent
07e64e0627
commit
b9551d7597
|
|
@ -13,3 +13,12 @@ TOKEN_SECRET=your-random-secret-here
|
||||||
# Threadfin credentials for one-click channel activation
|
# Threadfin credentials for one-click channel activation
|
||||||
THREADFIN_USER=your-threadfin-username
|
THREADFIN_USER=your-threadfin-username
|
||||||
THREADFIN_PASS=your-threadfin-password
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { Geist, Geist_Mono, Fredoka, Permanent_Marker } from "next/font/google"
|
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"
|
import "./globals.css"
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] })
|
const _geist = Geist({ subsets: ["latin"] })
|
||||||
|
|
@ -70,7 +72,10 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
||||||
<body className={`font-sans antialiased`}>
|
<body className={`font-sans antialiased`}>
|
||||||
|
<MusicProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<MiniPlayer />
|
||||||
|
</MusicProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 “{debouncedQuery}” 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't find what you're looking for? Search Soulseek to find
|
||||||
|
and download music from the peer-to-peer network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
<a href="https://music.jefflix.lol">
|
<a href="/music">
|
||||||
<Music className="mr-2 h-5 w-5" />
|
<Music className="mr-2 h-5 w-5" />
|
||||||
Listen to Music
|
Listen to Music
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -280,7 +280,7 @@ export default function JefflixPage() {
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white">
|
<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" />
|
<Music className="mr-2 h-5 w-5" />
|
||||||
Listen to Music
|
Listen to Music
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 “{state.currentTrack?.title}” 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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} · {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -19,6 +19,11 @@ services:
|
||||||
- THREADFIN_URL=https://threadfin.jefflix.lol
|
- THREADFIN_URL=https://threadfin.jefflix.lol
|
||||||
- THREADFIN_USER=${THREADFIN_USER}
|
- THREADFIN_USER=${THREADFIN_USER}
|
||||||
- THREADFIN_PASS=${THREADFIN_PASS}
|
- 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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue