feat: search dedup, MusicBrainz discovery, swipe-to-queue, audio output selector

- Deduplicate Navidrome search results by title+artist (keep highest bitRate)
- Deduplicate Soulseek results by filename across peers (show peer count badge)
- Add MusicBrainz fuzzy search with one-tap Soulseek auto-download
- Add swipe-right-to-queue gesture on mobile song rows
- Add audio output device selector (phone/Bluetooth) in full-screen player
- Include offline mode, download button, and service worker from prior work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 19:06:28 -07:00
parent 0f76ce3cd7
commit c24962f829
22 changed files with 1587 additions and 170 deletions

View File

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
interface MBRecording {
id: string
title: string
score: number
length?: number
'artist-credit'?: { name: string }[]
releases?: { title: string; date?: string }[]
}
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({ results: [] })
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const res = await fetch(
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(q)}&fmt=json&limit=20`,
{
headers: {
'User-Agent': 'SoulSync/1.0 (jeffemmett@gmail.com)',
Accept: 'application/json',
},
signal: controller.signal,
cache: 'no-store',
}
)
clearTimeout(timeout)
if (!res.ok) {
throw new Error(`MusicBrainz returned ${res.status}`)
}
const data = await res.json()
const results = (data.recordings || []).map((r: MBRecording) => ({
mbid: r.id,
title: r.title,
artist: r['artist-credit']?.[0]?.name || 'Unknown',
album: r.releases?.[0]?.title || '',
year: r.releases?.[0]?.date?.slice(0, 4) || '',
duration: r.length ? Math.round(r.length / 1000) : 0,
score: r.score,
}))
return NextResponse.json({ results })
} catch (error) {
console.error('MusicBrainz search error:', error)
return NextResponse.json({ error: 'MusicBrainz search failed' }, { status: 502 })
}
}

View File

@ -0,0 +1,121 @@
import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome'
const OFFLINE_PLAYLIST_NAME = '__soulsync_offline__'
interface SubsonicPlaylist {
id: string
name: string
songCount: number
coverArt: string
}
interface SubsonicSong {
id: string
title: string
artist: string
album: string
albumId: string
duration: number
coverArt: string
}
interface PlaylistsResult {
playlists?: { playlist?: SubsonicPlaylist[] }
}
interface PlaylistResult {
playlist?: {
id: string
name: string
songCount: number
entry?: SubsonicSong[]
}
}
/** Find or create the offline sync playlist, returning its id + songs */
async function getOrCreateOfflinePlaylist() {
// Find existing
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
const existing = (data.playlists?.playlist || []).find(
(p) => p.name === OFFLINE_PLAYLIST_NAME
)
if (existing) {
// Fetch full playlist with entries
const full = await navidromeGet<PlaylistResult>('getPlaylist.view', { id: existing.id })
const songs = (full.playlist?.entry || []).map((s) => ({
id: s.id,
title: s.title,
artist: s.artist,
album: s.album,
albumId: s.albumId,
duration: s.duration,
coverArt: s.coverArt,
}))
return { id: existing.id, songs }
}
// Create it
await navidromeGet('createPlaylist.view', { name: OFFLINE_PLAYLIST_NAME })
// Re-fetch to get its id
const data2 = await navidromeGet<PlaylistsResult>('getPlaylists.view')
const created = (data2.playlists?.playlist || []).find(
(p) => p.name === OFFLINE_PLAYLIST_NAME
)
return { id: created?.id || '', songs: [] }
}
/** GET: return offline playlist id + songs */
export async function GET() {
try {
const result = await getOrCreateOfflinePlaylist()
return NextResponse.json(result)
} catch (error) {
console.error('Offline playlist error:', error)
return NextResponse.json({ error: 'Failed to get offline playlist' }, { status: 502 })
}
}
/** POST: add a song to the offline playlist */
export async function POST(request: Request) {
try {
const { songId } = await request.json()
if (!songId) {
return NextResponse.json({ error: 'songId required' }, { status: 400 })
}
const { id: playlistId } = await getOrCreateOfflinePlaylist()
await navidromeGet('updatePlaylist.view', {
playlistId,
songIdToAdd: songId,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Add to offline playlist error:', error)
return NextResponse.json({ error: 'Failed to add song' }, { status: 502 })
}
}
/** DELETE: remove a song from the offline playlist by songId */
export async function DELETE(request: Request) {
try {
const { songId } = await request.json()
if (!songId) {
return NextResponse.json({ error: 'songId required' }, { status: 400 })
}
const { id: playlistId, songs } = await getOrCreateOfflinePlaylist()
// Subsonic removeFromPlaylist uses songIndexToRemove (0-based index)
const index = songs.findIndex((s) => s.id === songId)
if (index === -1) {
return NextResponse.json({ error: 'Song not in offline playlist' }, { status: 404 })
}
await navidromeGet('updatePlaylist.view', {
playlistId,
songIndexToRemove: String(index),
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Remove from offline playlist error:', error)
return NextResponse.json({ error: 'Failed to remove song' }, { status: 502 })
}
}

View File

@ -18,13 +18,15 @@ interface PlaylistsResult {
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,
}))
const playlists = (data.playlists?.playlist || [])
.filter((p) => p.name !== '__soulsync_offline__')
.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)

View File

@ -12,6 +12,7 @@ interface SubsonicSong {
year: number
coverArt: string
suffix: string
bitRate: number
}
interface SearchResult {
@ -31,12 +32,26 @@ export async function GET(request: Request) {
try {
const data = await navidromeGet<SearchResult>('search3.view', {
query: q,
songCount: '30',
songCount: '50',
albumCount: '0',
artistCount: '0',
})
const songs = (data.searchResult3?.song || []).map((s) => ({
const rawSongs = data.searchResult3?.song || []
// Dedup by title+artist, keeping highest bitRate (then most recent year)
const seen = new Map<string, SubsonicSong>()
for (const s of rawSongs) {
const key = `${s.title.toLowerCase().trim()}|||${s.artist.toLowerCase().trim()}`
const existing = seen.get(key)
if (!existing ||
(s.bitRate || 0) > (existing.bitRate || 0) ||
((s.bitRate || 0) === (existing.bitRate || 0) && (s.year || 0) > (existing.year || 0))) {
seen.set(key, s)
}
}
const songs = Array.from(seen.values()).map((s) => ({
id: s.id,
title: s.title,
artist: s.artist,

View File

@ -0,0 +1,79 @@
import { NextResponse } from 'next/server'
import { slskdFetch } from '@/lib/slskd'
import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function POST(request: Request) {
try {
const { artist, title } = await request.json()
if (!artist || !title) {
return NextResponse.json({ error: 'artist and title required' }, { status: 400 })
}
const query = `${artist} ${title}`
// Start slskd search
const searchRes = await slskdFetch('/searches', {
method: 'POST',
body: JSON.stringify({ searchText: query }),
})
if (!searchRes.ok) {
throw new Error(`slskd search returned ${searchRes.status}`)
}
const { id: searchId } = await searchRes.json()
// Poll up to 15s (5 polls x 3s)
let bestFile = null
for (let i = 0; i < 5; i++) {
await sleep(3000)
const res = await slskdFetch(`/searches/${searchId}`)
if (!res.ok) continue
const data = await res.json()
const responses: SlskdRawResponse[] = (data.responses || [])
.filter((r: SlskdRawResponse) => r.files?.length > 0)
const files = extractBestFiles(responses, 1)
if (files.length > 0) {
bestFile = files[0]
if (data.state === 'Completed' || data.state === 'TimedOut') break
}
}
if (!bestFile) {
return NextResponse.json({ success: false, searchId, error: 'No results found' })
}
// Trigger download
const dlRes = await slskdFetch(
`/transfers/downloads/${encodeURIComponent(bestFile.bestPeer.username)}`,
{
method: 'POST',
body: JSON.stringify([{
filename: bestFile.filename,
size: bestFile.size,
}]),
}
)
if (!dlRes.ok) {
throw new Error(`slskd download returned ${dlRes.status}`)
}
return NextResponse.json({
success: true,
searchId,
filename: bestFile.displayName,
peer: bestFile.bestPeer.username,
})
} catch (error) {
console.error('Auto-download error:', error)
return NextResponse.json({ error: 'Auto-download failed' }, { status: 502 })
}
}

View File

@ -1,19 +1,6 @@
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
}
import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
export async function GET(
_request: Request,
@ -30,23 +17,12 @@ export async function GET(
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,
})),
}))
const responses: SlskdRawResponse[] = (data.responses || [])
.filter((r: SlskdRawResponse) => r.files?.length > 0)
return NextResponse.json({ results, isComplete })
const files = extractBestFiles(responses)
return NextResponse.json({ files, isComplete })
} catch (error) {
console.error('Soulseek results error:', error)
return NextResponse.json({ error: 'Failed to get results' }, { status: 502 })

View File

@ -4,6 +4,8 @@ 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 { UpdateBanner } from "@/components/update-banner"
import { OfflineProvider } from "@/lib/stores/offline"
import { ServiceWorkerRegister } from "@/components/sw-register"
import "./globals.css"
const _geist = Geist({ subsets: ["latin"] })
@ -73,10 +75,13 @@ export default function RootLayout({
return (
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
<body className={`font-sans antialiased`}>
<ServiceWorkerRegister />
<UpdateBanner />
<MusicProvider>
{children}
<MiniPlayer />
<OfflineProvider>
{children}
<MiniPlayer />
</OfflineProvider>
</MusicProvider>
</body>
</html>

View File

@ -6,7 +6,8 @@ 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, ListMusic, ChevronRight, ChevronDown } from 'lucide-react'
import { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results'
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users } from 'lucide-react'
import Link from 'next/link'
interface Playlist {
@ -16,16 +17,18 @@ interface Playlist {
coverArt: string
}
interface SlskdResult {
username: string
freeSlots: number
speed: number
files: {
filename: string
size: number
bitRate: number
length: number
}[]
interface SlskdFile {
displayName: string
filename: string
size: number
bitRate: number
length: number
bestPeer: {
username: string
freeSlots: number
speed: number
}
peerCount: number
}
export default function MusicPage() {
@ -47,7 +50,7 @@ export default function MusicPage() {
// Soulseek state
const [slskMode, setSlskMode] = useState(false)
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
const [slskResults, setSlskResults] = useState<SlskdResult[]>([])
const [slskResults, setSlskResults] = useState<SlskdFile[]>([])
const [slskSearching, setSlskSearching] = useState(false)
const [downloading, setDownloading] = useState<string | null>(null)
const pollRef = useRef<NodeJS.Timeout>(null)
@ -134,7 +137,7 @@ export default function MusicPage() {
try {
const res = await fetch(`/api/music/slskd/results/${searchId}`)
const d = await res.json()
setSlskResults(d.results || [])
setSlskResults(d.files || [])
if (!d.isComplete) {
pollRef.current = setTimeout(poll, 2000)
} else {
@ -147,14 +150,17 @@ export default function MusicPage() {
poll()
}
const triggerDownload = async (username: string, files: SlskdResult['files']) => {
const key = `${username}:${files[0]?.filename}`
const triggerDownload = async (file: SlskdFile) => {
const key = `${file.bestPeer.username}:${file.filename}`
setDownloading(key)
try {
await fetch('/api/music/slskd/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, files }),
body: JSON.stringify({
username: file.bestPeer.username,
files: [{ filename: file.filename, size: file.size }],
}),
})
} catch {}
setDownloading(null)
@ -178,12 +184,20 @@ export default function MusicPage() {
<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 className="flex items-center gap-2">
<Link href="/offline">
<Button variant="ghost" size="sm">
<WifiOff className="h-4 w-4 mr-1.5" />
Offline
</Button>
</Link>
<Link href="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Home
</Button>
</Link>
</div>
</div>
</div>
@ -244,7 +258,7 @@ export default function MusicPage() {
{!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} />
<SongRow key={song.id} song={song} songs={songs} index={i} showDownload />
))}
</div>
)}
@ -269,6 +283,11 @@ export default function MusicPage() {
Type at least 2 characters to search
</p>
)}
{/* MusicBrainz Discovery */}
{debouncedQuery.length >= 2 && (
<MusicBrainzResultsSection query={debouncedQuery} />
)}
</>
)}
@ -283,59 +302,51 @@ export default function MusicPage() {
)}
{slskResults.length > 0 && (
<div className="space-y-3">
<div className="space-y-1">
{slskSearching && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<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>
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{slskResults.map((file) => {
const sizeMB = (file.size / 1024 / 1024).toFixed(1)
const key = `${file.bestPeer.username}:${file.filename}`
{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>
return (
<div key={key} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{file.displayName}</div>
<div className="text-xs text-muted-foreground">
{sizeMB} MB
{file.bitRate > 0 && ` · ${file.bitRate} kbps`}
{' · '}{file.bestPeer.username}
</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>
))}
{file.peerCount > 1 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 flex-shrink-0">
<Users className="h-3 w-3 mr-0.5" />
{file.peerCount}
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => triggerDownload(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>
)}
@ -409,7 +420,7 @@ export default function MusicPage() {
) : (
<div className="divide-y divide-border">
{playlistSongs.map((song, i) => (
<SongRow key={song.id} song={song} songs={playlistSongs} index={i} />
<SongRow key={song.id} song={song} songs={playlistSongs} index={i} showDownload />
))}
</div>
)}

171
app/offline/page.tsx Normal file
View File

@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { JefflixLogo } from '@/components/jefflix-logo'
import { SongRow } from '@/components/music/search-results'
import { useMusicPlayer } from '@/components/music/music-provider'
import { useOffline } from '@/lib/stores/offline'
import {
ArrowLeft,
Download,
HardDrive,
Loader2,
Play,
RefreshCw,
Trash2,
WifiOff,
} from 'lucide-react'
import Link from 'next/link'
function formatBytes(bytes: number) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
export default function OfflinePage() {
const { state, playTrack } = useMusicPlayer()
const {
offlineTracks,
queue,
activeDownloadId,
storageUsed,
clearAll,
sync,
loading,
} = useOffline()
const [syncing, setSyncing] = useState(false)
const [clearing, setClearing] = useState(false)
const hasPlayer = !!state.currentTrack
const handleSync = async () => {
setSyncing(true)
await sync()
setSyncing(false)
}
const handleClearAll = async () => {
if (!confirm('Remove all downloaded songs? They can be re-downloaded later.')) return
setClearing(true)
await clearAll()
setClearing(false)
}
const playAllOffline = () => {
if (offlineTracks.length > 0) {
playTrack(offlineTracks[0], offlineTracks, 0)
}
}
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="/music">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Music
</Button>
</Link>
</div>
</div>
<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-blue-100 dark:bg-blue-900/30 rounded-full">
<WifiOff className="h-10 w-10 text-blue-600 dark:text-blue-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Offline Library</h1>
<p className="text-muted-foreground">
Songs downloaded for offline playback. Syncs across all your devices.
</p>
</div>
{/* Stats + Actions */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-lg">
<HardDrive className="h-4 w-4" />
{formatBytes(storageUsed)} used
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-lg">
<Download className="h-4 w-4" />
{offlineTracks.length} songs
</div>
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing}>
{syncing ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
Sync
</Button>
{offlineTracks.length > 0 && (
<>
<Button size="sm" onClick={playAllOffline}>
<Play className="h-4 w-4 mr-1.5" />
Play All
</Button>
<Button variant="destructive" size="sm" onClick={handleClearAll} disabled={clearing}>
{clearing ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <Trash2 className="h-4 w-4 mr-1.5" />}
Clear All
</Button>
</>
)}
</div>
{/* Download queue */}
{queue.length > 0 && (
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
Downloading ({queue.length} remaining)
</h3>
<div className="space-y-1 text-sm text-muted-foreground">
{queue.slice(0, 5).map((t) => (
<div key={t.id} className="flex items-center gap-2">
{t.id === activeDownloadId && <Loader2 className="h-3 w-3 animate-spin" />}
<span className="truncate">{t.title} {t.artist}</span>
</div>
))}
{queue.length > 5 && (
<div className="text-xs">...and {queue.length - 5} more</div>
)}
</div>
</div>
)}
{/* Songs list */}
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
</div>
) : offlineTracks.length === 0 ? (
<div className="text-center py-12 space-y-4">
<WifiOff className="h-12 w-12 mx-auto text-muted-foreground/30" />
<p className="text-muted-foreground">
No songs downloaded yet. Tap the download icon on any song to save it for offline.
</p>
<Link href="/music">
<Button variant="outline">
Browse Music
</Button>
</Link>
</div>
) : (
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{offlineTracks.map((song, i) => (
<SongRow key={song.id} song={song} songs={offlineTracks} index={i} showDownload />
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,58 @@
'use client'
import type { Track } from './music-provider'
import { useOffline } from '@/lib/stores/offline'
import { Download, Loader2, CheckCircle, Clock } from 'lucide-react'
interface DownloadButtonProps {
track: Track
className?: string
size?: 'sm' | 'md'
}
export function DownloadButton({ track, className = '', size = 'sm' }: DownloadButtonProps) {
const { offlineIds, download, remove, getStatus } = useOffline()
const isOffline = offlineIds.has(track.id)
const status = getStatus(track.id)
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
const padding = size === 'sm' ? 'p-1.5' : 'p-2'
if (isOffline) {
return (
<button
onClick={(e) => { e.stopPropagation(); remove(track.id) }}
className={`${padding} rounded-full hover:bg-muted/50 transition-colors text-green-500 ${className}`}
title="Downloaded — tap to remove"
>
<CheckCircle className={iconSize} />
</button>
)
}
if (status === 'downloading') {
return (
<span className={`${padding} text-muted-foreground ${className}`} title="Downloading...">
<Loader2 className={`${iconSize} animate-spin`} />
</span>
)
}
if (status === 'queued') {
return (
<span className={`${padding} text-muted-foreground ${className}`} title="Queued for download">
<Clock className={iconSize} />
</span>
)
}
return (
<button
onClick={(e) => { e.stopPropagation(); download(track) }}
className={`${padding} rounded-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground ${className}`}
title="Download for offline"
>
<Download className={iconSize} />
</button>
)
}

View File

@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'
import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
@ -16,6 +17,7 @@ import {
Volume2,
VolumeX,
ChevronDown,
Speaker,
} from 'lucide-react'
function formatTime(secs: number) {
@ -26,7 +28,7 @@ function formatTime(secs: number) {
}
export function FullScreenPlayer() {
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen } = useMusicPlayer()
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const [lyrics, setLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
@ -146,8 +148,27 @@ export function FullScreenPlayer() {
/>
</div>
{/* Audio output selector */}
{outputDevices.length > 1 && (
<div className="flex items-center gap-2 w-full max-w-sm mb-6">
<Speaker className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<select
value={currentOutputId || 'default'}
onChange={(e) => setOutputDevice(e.target.value)}
className="flex-1 text-sm bg-muted/50 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{outputDevices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
</div>
)}
{/* Actions */}
<div className="flex gap-3 mb-8">
<DownloadButton track={track} size="md" />
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
<ListPlus className="h-4 w-4 mr-1.5" />
Add to Playlist

View File

@ -1,6 +1,7 @@
'use client'
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback } from 'react'
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback, useState } from 'react'
import { getTrackBlob } from '@/lib/offline-db'
export interface Track {
id: string
@ -84,6 +85,11 @@ function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
}
}
export interface AudioOutputDevice {
deviceId: string
label: string
}
interface MusicContextValue {
state: PlayerState
playTrack: (track: Track, queue?: Track[], index?: number) => void
@ -94,6 +100,9 @@ interface MusicContextValue {
prevTrack: () => void
setFullScreen: (open: boolean) => void
addToQueue: (track: Track) => void
outputDevices: AudioOutputDevice[]
currentOutputId: string
setOutputDevice: (deviceId: string) => void
}
const MusicContext = createContext<MusicContextValue | null>(null)
@ -107,6 +116,8 @@ export function useMusicPlayer() {
export function MusicProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(playerReducer, initialState)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Guard against pause events fired when changing audio.src during track transitions
const transitioningRef = useRef(false)
// Create audio element on mount (client only)
useEffect(() => {
@ -116,8 +127,15 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
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 onEnded = () => {
transitioningRef.current = true
dispatch({ type: 'NEXT_TRACK' })
}
const onPause = () => {
// Ignore pause events during track transitions (browser fires pause when src changes)
if (transitioningRef.current) return
dispatch({ type: 'SET_PLAYING', playing: false })
}
const onPlay = () => dispatch({ type: 'SET_PLAYING', playing: true })
audio.addEventListener('timeupdate', onTimeUpdate)
@ -137,24 +155,65 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
}
}, [])
// When currentTrack changes, update audio src
// Retry play with exponential backoff (handles mobile autoplay restrictions)
const playWithRetry = useCallback((audio: HTMLAudioElement, attempts = 3) => {
audio.play().catch((err) => {
if (attempts > 1) {
setTimeout(() => playWithRetry(audio, attempts - 1), 500)
} else {
console.warn('Autoplay blocked after retries:', err.message)
}
})
}, [])
// Track blob URL for cleanup
const blobUrlRef = useRef<string | null>(null)
// When currentTrack changes, update audio src (offline-first)
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
const trackId = state.currentTrack.id
transitioningRef.current = true
// Revoke previous blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
// Try offline first, fall back to streaming
getTrackBlob(trackId).then((blob) => {
// Guard: track may have changed while we awaited
if (state.currentTrack?.id !== trackId) return
if (blob) {
const url = URL.createObjectURL(blob)
blobUrlRef.current = url
audio.src = url
} else {
audio.src = `/api/music/stream/${trackId}`
}
playWithRetry(audio)
requestAnimationFrame(() => { transitioningRef.current = false })
}).catch(() => {
// IndexedDB unavailable, fall back to streaming
if (state.currentTrack?.id !== trackId) return
audio.src = `/api/music/stream/${trackId}`
playWithRetry(audio)
requestAnimationFrame(() => { transitioningRef.current = false })
})
}, [state.currentTrack?.id, playWithRetry]) // 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(() => {})
playWithRetry(audio)
} else {
audio.pause()
}
}, [state.isPlaying, state.currentTrack])
}, [state.isPlaying, state.currentTrack, playWithRetry])
// Sync volume
useEffect(() => {
@ -190,6 +249,40 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
return () => window.removeEventListener('keydown', handler)
}, [])
// Audio output device selection
const [outputDevices, setOutputDevices] = useState<AudioOutputDevice[]>([])
const [currentOutputId, setCurrentOutputId] = useState('')
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) return
const enumerate = () => {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const outputs = devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({
deviceId: d.deviceId,
label: d.label || (d.deviceId === 'default' ? 'Default' : `Device ${d.deviceId.slice(0, 6)}`),
}))
setOutputDevices(outputs)
}).catch(() => {})
}
enumerate()
navigator.mediaDevices.addEventListener('devicechange', enumerate)
return () => navigator.mediaDevices.removeEventListener('devicechange', enumerate)
}, [])
const setOutputDevice = useCallback((deviceId: string) => {
const audio = audioRef.current as HTMLAudioElement & { setSinkId?: (id: string) => Promise<void> }
if (!audio?.setSinkId) return
audio.setSinkId(deviceId).then(() => {
setCurrentOutputId(deviceId)
}).catch((err) => {
console.warn('Failed to set audio output:', err.message)
})
}, [])
const playTrack = useCallback((track: Track, queue?: Track[], index?: number) => {
dispatch({ type: 'PLAY_TRACK', track, queue, index })
}, [])
@ -220,6 +313,9 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
prevTrack,
setFullScreen,
addToQueue,
outputDevices,
currentOutputId,
setOutputDevice,
}}>
{children}
</MusicContext.Provider>

View File

@ -0,0 +1,170 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Download, Loader2, Check, X, Globe } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface MBResult {
mbid: string
title: string
artist: string
album: string
year: string
duration: number
score: number
}
type DownloadState = 'idle' | 'downloading' | 'success' | 'error'
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 MusicBrainzResultsSection({ query }: { query: string }) {
const [results, setResults] = useState<MBResult[]>([])
const [loading, setLoading] = useState(false)
const [downloadStates, setDownloadStates] = useState<Record<string, DownloadState>>({})
const debounceRef = useRef<NodeJS.Timeout>(null)
useEffect(() => {
if (query.length < 2) {
setResults([])
return
}
setLoading(true)
// 500ms debounce (800ms total after Navidrome's 300ms input debounce)
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/music/musicbrainz?q=${encodeURIComponent(query)}`)
const d = await res.json()
setResults(d.results || [])
} catch {
setResults([])
}
setLoading(false)
}, 500)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query])
const handleDownload = async (result: MBResult) => {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'downloading' }))
try {
const res = await fetch('/api/music/slskd/auto-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist: result.artist, title: result.title }),
})
const d = await res.json()
if (d.success) {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'success' }))
} else {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
setTimeout(() => {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
}, 3000)
}
} catch {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
setTimeout(() => {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
}, 3000)
}
}
if (loading && results.length === 0) {
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
<Globe className="h-4 w-4" />
Discover New Music
</h3>
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</div>
)
}
if (results.length === 0) return null
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
<Globe className="h-4 w-4" />
Discover New Music
</h3>
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{results.map((r) => {
const state = downloadStates[r.mbid] || 'idle'
return (
<div key={r.mbid} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{r.title}</div>
<div className="text-xs text-muted-foreground truncate">
{r.artist}
{r.album && <> &middot; {r.album}</>}
{r.year && <> &middot; {r.year}</>}
</div>
</div>
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatDuration(r.duration)}
</span>
{r.score > 80 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 hidden sm:inline-flex">
{r.score}%
</Badge>
)}
<Button
size="sm"
onClick={() => handleDownload(r)}
disabled={state === 'downloading' || state === 'success'}
className={
state === 'success'
? 'bg-green-600 hover:bg-green-600 text-white'
: state === 'error'
? 'bg-red-600 hover:bg-red-600 text-white'
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
}
>
{state === 'downloading' && (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
<span className="hidden sm:inline">Searching...</span>
</>
)}
{state === 'success' && (
<>
<Check className="h-3.5 w-3.5 mr-1" />
<span className="hidden sm:inline">Queued</span>
</>
)}
{state === 'error' && (
<>
<X className="h-3.5 w-3.5 mr-1" />
<span className="hidden sm:inline">Not found</span>
</>
)}
{state === 'idle' && (
<>
<Download className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Soulseek</span>
</>
)}
</Button>
</div>
)
})}
</div>
</div>
)
}

View File

@ -1,6 +1,8 @@
'use client'
import { useMusicPlayer, type Track } from './music-provider'
import { DownloadButton } from './download-button'
import { SwipeableRow } from './swipeable-row'
import { Play, Pause, ListPlus } from 'lucide-react'
function formatDuration(secs: number) {
@ -14,70 +16,77 @@ export function SongRow({
song,
songs,
index,
showDownload = false,
}: {
song: Track
songs: Track[]
index: number
showDownload?: boolean
}) {
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"
<SwipeableRow onSwipeRight={() => addToQueue(song)}>
<div
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
isActive ? 'bg-primary/5' : ''
}`}
>
{isPlaying ? (
<Pause className="h-4 w-4 text-primary" />
) : (
<Play className="h-4 w-4 text-primary ml-0.5" />
)}
</button>
{/* 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}
{/* 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>
<div className="text-xs text-muted-foreground truncate">
{song.artist} &middot; {song.album}
{/* 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>
{/* Download */}
{showDownload && <DownloadButton track={song} />}
{/* Add to queue (desktop hover) */}
<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>
{/* 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>
</SwipeableRow>
)
}

View File

@ -0,0 +1,72 @@
'use client'
import { useRef, useState, useCallback, type ReactNode } from 'react'
import { ListPlus } from 'lucide-react'
const THRESHOLD = 60
export function SwipeableRow({
onSwipeRight,
children,
}: {
onSwipeRight: () => void
children: ReactNode
}) {
const touchStartX = useRef(0)
const [offset, setOffset] = useState(0)
const [swiping, setSwiping] = useState(false)
const [confirmed, setConfirmed] = useState(false)
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
setSwiping(true)
}, [])
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!swiping) return
const delta = e.touches[0].clientX - touchStartX.current
// Only allow right swipe, cap at 120px
setOffset(Math.max(0, Math.min(delta, 120)))
}, [swiping])
const handleTouchEnd = useCallback(() => {
if (offset > THRESHOLD) {
onSwipeRight()
setConfirmed(true)
setTimeout(() => setConfirmed(false), 600)
}
setOffset(0)
setSwiping(false)
}, [offset, onSwipeRight])
return (
<div className="relative overflow-hidden touch-pan-y">
{/* Green reveal strip behind */}
<div
className={`absolute inset-y-0 left-0 flex items-center gap-2 pl-4 transition-colors ${
confirmed ? 'bg-green-500' : offset > THRESHOLD ? 'bg-green-600' : 'bg-green-600/80'
}`}
style={{ width: Math.max(offset, confirmed ? 200 : 0) }}
>
<ListPlus className="h-4 w-4 text-white flex-shrink-0" />
<span className="text-sm text-white font-medium whitespace-nowrap">
{confirmed ? 'Added!' : 'Add to Queue'}
</span>
</div>
{/* Swipeable content */}
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="relative bg-background"
style={{
transform: `translateX(${offset}px)`,
transition: swiping ? 'none' : 'transform 0.2s ease-out',
}}
>
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,14 @@
'use client'
import { useEffect } from 'react'
export function ServiceWorkerRegister() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('SW registration failed:', err)
})
}
}, [])
return null
}

View File

@ -27,7 +27,7 @@ services:
- THREADFIN_URL=https://threadfin.jefflix.lol
labels:
- "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`) || Host(`music.jefflix.lol`)"
- "traefik.http.services.jefflix-website.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]

141
lib/offline-db.ts Normal file
View File

@ -0,0 +1,141 @@
/**
* IndexedDB wrapper for offline audio storage.
* Two object stores:
* - 'audio-blobs': trackId Blob (the audio file)
* - 'track-meta': trackId Track metadata (for listing without loading blobs)
*/
import type { Track } from '@/components/music/music-provider'
const DB_NAME = 'soulsync-offline'
const DB_VERSION = 1
const AUDIO_STORE = 'audio-blobs'
const META_STORE = 'track-meta'
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
db.createObjectStore(AUDIO_STORE)
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE)
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function tx(
db: IDBDatabase,
stores: string | string[],
mode: IDBTransactionMode = 'readonly'
): IDBTransaction {
return db.transaction(stores, mode)
}
/** Save an audio blob and track metadata */
export async function saveTrack(trackId: string, blob: Blob, meta: Track): Promise<void> {
const db = await openDB()
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
t.objectStore(AUDIO_STORE).put(blob, trackId)
t.objectStore(META_STORE).put(meta, trackId)
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}
/** Get the audio blob for a track */
export async function getTrackBlob(trackId: string): Promise<Blob | undefined> {
const db = await openDB()
const t = tx(db, AUDIO_STORE)
const req = t.objectStore(AUDIO_STORE).get(trackId)
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Check if a track is stored offline */
export async function hasTrack(trackId: string): Promise<boolean> {
const db = await openDB()
const t = tx(db, META_STORE)
const req = t.objectStore(META_STORE).count(trackId)
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result > 0) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Remove a track from offline storage */
export async function removeTrack(trackId: string): Promise<void> {
const db = await openDB()
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
t.objectStore(AUDIO_STORE).delete(trackId)
t.objectStore(META_STORE).delete(trackId)
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}
/** List all offline tracks (metadata only) */
export async function listOfflineTracks(): Promise<Track[]> {
const db = await openDB()
const t = tx(db, META_STORE)
const req = t.objectStore(META_STORE).getAll()
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Get all offline track IDs (for fast Set building) */
export async function listOfflineIds(): Promise<string[]> {
const db = await openDB()
const t = tx(db, META_STORE)
const req = t.objectStore(META_STORE).getAllKeys()
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result as string[]) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Get total storage used (sum of all blob sizes in bytes) */
export async function getTotalSize(): Promise<number> {
const db = await openDB()
const t = tx(db, AUDIO_STORE)
const store = t.objectStore(AUDIO_STORE)
const req = store.openCursor()
let total = 0
return new Promise((resolve, reject) => {
req.onsuccess = () => {
const cursor = req.result
if (cursor) {
const blob = cursor.value as Blob
total += blob.size
cursor.continue()
} else {
db.close()
resolve(total)
}
}
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Remove all offline data */
export async function clearAll(): Promise<void> {
const db = await openDB()
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
t.objectStore(AUDIO_STORE).clear()
t.objectStore(META_STORE).clear()
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}

90
lib/slskd-dedup.ts Normal file
View File

@ -0,0 +1,90 @@
export interface SlskdRawFile {
filename: string
size: number
bitRate: number
length: number
}
export interface SlskdRawResponse {
username: string
files: SlskdRawFile[]
freeUploadSlots: number
speed: number
}
export interface DedupedFile {
displayName: string
filename: string
size: number
bitRate: number
length: number
bestPeer: {
username: string
freeSlots: number
speed: number
}
peerCount: number
}
function normalizeName(filename: string): string {
// Strip path separators (Windows backslash or Unix forward slash)
const basename = filename.replace(/^.*[\\\/]/, '')
// Strip extension
const noExt = basename.replace(/\.[^.]+$/, '')
return noExt.toLowerCase().trim()
}
function prettyName(filename: string): string {
return filename.replace(/^.*[\\\/]/, '').replace(/\.[^.]+$/, '')
}
export function extractBestFiles(responses: SlskdRawResponse[], limit = 30): DedupedFile[] {
const groups = new Map<string, { file: SlskdRawFile; peer: SlskdRawResponse; displayName: string }[]>()
for (const peer of responses) {
if (!peer.files?.length) continue
for (const file of peer.files) {
const key = normalizeName(file.filename)
if (!key) continue
const entry = { file, peer, displayName: prettyName(file.filename) }
const existing = groups.get(key)
if (existing) {
existing.push(entry)
} else {
groups.set(key, [entry])
}
}
}
const deduped: DedupedFile[] = []
for (const [, entries] of groups) {
// Pick best peer: prefer freeUploadSlots > 0, then highest speed
entries.sort((a, b) => {
const aFree = a.peer.freeUploadSlots > 0 ? 1 : 0
const bFree = b.peer.freeUploadSlots > 0 ? 1 : 0
if (aFree !== bFree) return bFree - aFree
return b.peer.speed - a.peer.speed
})
const best = entries[0]
deduped.push({
displayName: best.displayName,
filename: best.file.filename,
size: best.file.size,
bitRate: best.file.bitRate,
length: best.file.length,
bestPeer: {
username: best.peer.username,
freeSlots: best.peer.freeUploadSlots,
speed: best.peer.speed,
},
peerCount: entries.length,
})
}
// Sort by highest bitRate first
deduped.sort((a, b) => (b.bitRate || 0) - (a.bitRate || 0))
return deduped.slice(0, limit)
}

195
lib/stores/offline.tsx Normal file
View File

@ -0,0 +1,195 @@
'use client'
import React, { createContext, useContext, useCallback, useEffect, useRef, useState } from 'react'
import type { Track } from '@/components/music/music-provider'
import {
saveTrack,
removeTrack as removeFromDB,
listOfflineIds,
getTotalSize,
clearAll as clearDB,
listOfflineTracks,
} from '@/lib/offline-db'
type DownloadStatus = 'idle' | 'queued' | 'downloading'
interface OfflineContextValue {
offlineIds: Set<string>
queue: Track[]
activeDownloadId: string | null
storageUsed: number
download: (track: Track) => void
remove: (trackId: string) => void
clearAll: () => void
getStatus: (trackId: string) => DownloadStatus
sync: () => Promise<void>
offlineTracks: Track[]
loading: boolean
}
const OfflineContext = createContext<OfflineContextValue | null>(null)
export function useOffline() {
const ctx = useContext(OfflineContext)
if (!ctx) throw new Error('useOffline must be used within OfflineProvider')
return ctx
}
export function OfflineProvider({ children }: { children: React.ReactNode }) {
const [offlineIds, setOfflineIds] = useState<Set<string>>(new Set())
const [offlineTracks, setOfflineTracks] = useState<Track[]>([])
const [queue, setQueue] = useState<Track[]>([])
const [activeDownloadId, setActiveDownloadId] = useState<string | null>(null)
const [storageUsed, setStorageUsed] = useState(0)
const [loading, setLoading] = useState(true)
const processingRef = useRef(false)
// Mirror queue in a ref for access in async loops
const queueRef = useRef<Track[]>([])
const refreshLocal = useCallback(async () => {
const [ids, tracks, size] = await Promise.all([
listOfflineIds(),
listOfflineTracks(),
getTotalSize(),
])
setOfflineIds(new Set(ids))
setOfflineTracks(tracks)
setStorageUsed(size)
}, [])
const updateQueue = useCallback((updater: (prev: Track[]) => Track[]) => {
setQueue((prev) => {
const next = updater(prev)
queueRef.current = next
return next
})
}, [])
const processQueue = useCallback(async () => {
if (processingRef.current) return
processingRef.current = true
while (queueRef.current.length > 0) {
const nextTrack = queueRef.current[0]
setActiveDownloadId(nextTrack.id)
try {
const res = await fetch(`/api/music/stream/${nextTrack.id}`)
if (!res.ok) throw new Error(`Stream failed: ${res.status}`)
const blob = await res.blob()
await saveTrack(nextTrack.id, blob, nextTrack)
// Sync to server playlist (non-critical)
await fetch('/api/music/offline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: nextTrack.id }),
}).catch(() => {})
updateQueue((prev) => prev.filter((t) => t.id !== nextTrack.id))
await refreshLocal()
} catch (err) {
console.error(`Failed to download ${nextTrack.id}:`, err)
updateQueue((prev) => prev.filter((t) => t.id !== nextTrack.id))
}
}
setActiveDownloadId(null)
processingRef.current = false
}, [refreshLocal, updateQueue])
const sync = useCallback(async () => {
try {
setLoading(true)
const res = await fetch('/api/music/offline')
if (!res.ok) return
const { songs } = await res.json() as { songs: Track[] }
const localIds = await listOfflineIds()
const localSet = new Set(localIds)
const serverIds = new Set(songs.map((s: Track) => s.id))
// Download songs on server but not local
const toDownload = songs.filter((s: Track) => !localSet.has(s.id))
if (toDownload.length > 0) {
updateQueue((prev) => {
const existingIds = new Set(prev.map((t) => t.id))
return [...prev, ...toDownload.filter((t: Track) => !existingIds.has(t.id))]
})
}
// Remove local songs deleted from server
for (const localId of localIds) {
if (!serverIds.has(localId)) {
await removeFromDB(localId)
}
}
await refreshLocal()
} catch (err) {
console.error('Offline sync error:', err)
} finally {
setLoading(false)
}
}, [refreshLocal, updateQueue])
// Initial load + sync
useEffect(() => {
refreshLocal().then(() => sync())
}, [refreshLocal, sync])
// Process queue when items are added
useEffect(() => {
if (queue.length > 0 && !processingRef.current) {
processQueue()
}
}, [queue, processQueue])
const download = useCallback((track: Track) => {
updateQueue((prev) => {
if (prev.some((t) => t.id === track.id)) return prev
return [...prev, track]
})
}, [updateQueue])
const remove = useCallback(async (trackId: string) => {
await removeFromDB(trackId)
await fetch('/api/music/offline', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: trackId }),
}).catch(() => {})
await refreshLocal()
}, [refreshLocal])
const clearAllOffline = useCallback(async () => {
await clearDB()
await refreshLocal()
}, [refreshLocal])
const getStatus = useCallback((trackId: string): DownloadStatus => {
if (offlineIds.has(trackId)) return 'idle'
if (activeDownloadId === trackId) return 'downloading'
if (queue.some((t) => t.id === trackId)) return 'queued'
return 'idle'
}, [offlineIds, activeDownloadId, queue])
return (
<OfflineContext.Provider value={{
offlineIds,
queue,
activeDownloadId,
storageUsed,
download,
remove,
clearAll: clearAllOffline,
getStatus,
sync,
offlineTracks,
loading,
}}>
{children}
</OfflineContext.Provider>
)
}

View File

@ -8,6 +8,12 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL("/gate", request.url))
}
// Auto-redirect music.jefflix.lol root to /music
const host = request.headers.get("host") || ""
if (host.startsWith("music.") && request.nextUrl.pathname === "/") {
return NextResponse.redirect(new URL("/music", request.url))
}
return NextResponse.next()
}

108
public/sw.js Normal file
View File

@ -0,0 +1,108 @@
/// <reference lib="webworker" />
const CACHE_NAME = 'soulsync-shell-v1'
const DB_NAME = 'soulsync-offline'
const AUDIO_STORE = 'audio-blobs'
// App shell files to cache for offline UI access
const SHELL_FILES = [
'/',
'/music',
'/offline',
]
// Install: pre-cache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) =>
cache.addAll(SHELL_FILES).catch(() => {
// Non-critical if some pages fail to cache
})
)
)
self.skipWaiting()
})
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
)
self.clients.claim()
})
/**
* Open IndexedDB from the service worker to serve cached audio
*/
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
db.createObjectStore(AUDIO_STORE)
}
if (!db.objectStoreNames.contains('track-meta')) {
db.createObjectStore('track-meta')
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function getFromDB(trackId) {
return openDB().then(
(db) =>
new Promise((resolve, reject) => {
const tx = db.transaction(AUDIO_STORE, 'readonly')
const req = tx.objectStore(AUDIO_STORE).get(trackId)
req.onsuccess = () => {
db.close()
resolve(req.result)
}
req.onerror = () => {
db.close()
reject(req.error)
}
})
)
}
// Fetch: intercept /api/music/stream/ requests to serve from IndexedDB
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Intercept stream requests
const streamMatch = url.pathname.match(/^\/api\/music\/stream\/(.+)$/)
if (streamMatch) {
const trackId = streamMatch[1]
event.respondWith(
getFromDB(trackId).then((blob) => {
if (blob) {
return new Response(blob, {
headers: {
'Content-Type': blob.type || 'audio/mpeg',
'Content-Length': String(blob.size),
},
})
}
// Not cached, fetch from network
return fetch(event.request)
}).catch(() => fetch(event.request))
)
return
}
// For navigation requests: try network first, fall back to cache
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(event.request).then((cached) => cached || caches.match('/'))
)
)
return
}
})