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:
parent
0f76ce3cd7
commit
c24962f829
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ interface PlaylistsResult {
|
|||
export async function GET() {
|
||||
try {
|
||||
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
|
||||
const playlists = (data.playlists?.playlist || []).map((p) => ({
|
||||
const playlists = (data.playlists?.playlist || [])
|
||||
.filter((p) => p.name !== '__soulsync_offline__')
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
songCount: p.songCount,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<OfflineProvider>
|
||||
{children}
|
||||
<MiniPlayer />
|
||||
</OfflineProvider>
|
||||
</MusicProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
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,6 +184,13 @@ export default function MusicPage() {
|
|||
<Link href="/" className="inline-block">
|
||||
<JefflixLogo size="small" />
|
||||
</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" />
|
||||
|
|
@ -186,6 +199,7 @@ export default function MusicPage() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="container mx-auto px-4 py-12 md:py-16">
|
||||
|
|
@ -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,46 +302,39 @@ 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>
|
||||
|
||||
{result.files.map((file) => {
|
||||
const name = file.filename.split('\\').pop() || file.filename
|
||||
<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 = `${result.username}:${file.filename}`
|
||||
const key = `${file.bestPeer.username}:${file.filename}`
|
||||
|
||||
return (
|
||||
<div key={file.filename} className="flex items-center gap-3 pl-2">
|
||||
<div key={key} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{name}</div>
|
||||
<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>
|
||||
</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(result.username, [file])}
|
||||
onClick={() => triggerDownload(file)}
|
||||
disabled={downloading === key}
|
||||
>
|
||||
{downloading === key ? (
|
||||
|
|
@ -335,7 +347,6 @@ export default function MusicPage() {
|
|||
)
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && <> · {r.album}</>}
|
||||
{r.year && <> · {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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,16 +16,19 @@ 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 (
|
||||
<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' : ''
|
||||
|
|
@ -70,7 +75,10 @@ export function SongRow({
|
|||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
|
||||
{/* Add to queue */}
|
||||
{/* 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"
|
||||
|
|
@ -79,5 +87,6 @@ export function SongRow({
|
|||
<ListPlus className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</SwipeableRow>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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/"]
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue