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,13 +18,15 @@ interface PlaylistsResult {
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
|
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
|
||||||
const playlists = (data.playlists?.playlist || []).map((p) => ({
|
const playlists = (data.playlists?.playlist || [])
|
||||||
id: p.id,
|
.filter((p) => p.name !== '__soulsync_offline__')
|
||||||
name: p.name,
|
.map((p) => ({
|
||||||
songCount: p.songCount,
|
id: p.id,
|
||||||
duration: p.duration,
|
name: p.name,
|
||||||
coverArt: p.coverArt,
|
songCount: p.songCount,
|
||||||
}))
|
duration: p.duration,
|
||||||
|
coverArt: p.coverArt,
|
||||||
|
}))
|
||||||
return NextResponse.json({ playlists })
|
return NextResponse.json({ playlists })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Playlists error:', error)
|
console.error('Playlists error:', error)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface SubsonicSong {
|
||||||
year: number
|
year: number
|
||||||
coverArt: string
|
coverArt: string
|
||||||
suffix: string
|
suffix: string
|
||||||
|
bitRate: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
|
|
@ -31,12 +32,26 @@ export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const data = await navidromeGet<SearchResult>('search3.view', {
|
const data = await navidromeGet<SearchResult>('search3.view', {
|
||||||
query: q,
|
query: q,
|
||||||
songCount: '30',
|
songCount: '50',
|
||||||
albumCount: '0',
|
albumCount: '0',
|
||||||
artistCount: '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,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
artist: s.artist,
|
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 { NextResponse } from 'next/server'
|
||||||
import { slskdFetch } from '@/lib/slskd'
|
import { slskdFetch } from '@/lib/slskd'
|
||||||
|
import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
|
||||||
interface SlskdFile {
|
|
||||||
filename: string
|
|
||||||
size: number
|
|
||||||
bitRate: number
|
|
||||||
length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SlskdSearchResponse {
|
|
||||||
username: string
|
|
||||||
files: SlskdFile[]
|
|
||||||
freeUploadSlots: number
|
|
||||||
speed: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
|
|
@ -30,23 +17,12 @@ export async function GET(
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const isComplete = data.state === 'Completed' || data.state === 'TimedOut'
|
const isComplete = data.state === 'Completed' || data.state === 'TimedOut'
|
||||||
|
|
||||||
// Flatten results: each response has username + files
|
const responses: SlskdRawResponse[] = (data.responses || [])
|
||||||
const results = (data.responses || [])
|
.filter((r: SlskdRawResponse) => r.files?.length > 0)
|
||||||
.filter((r: SlskdSearchResponse) => r.files?.length > 0)
|
|
||||||
.slice(0, 20)
|
|
||||||
.map((r: SlskdSearchResponse) => ({
|
|
||||||
username: r.username,
|
|
||||||
freeSlots: r.freeUploadSlots,
|
|
||||||
speed: r.speed,
|
|
||||||
files: r.files.slice(0, 5).map((f: SlskdFile) => ({
|
|
||||||
filename: f.filename,
|
|
||||||
size: f.size,
|
|
||||||
bitRate: f.bitRate,
|
|
||||||
length: f.length,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json({ results, isComplete })
|
const files = extractBestFiles(responses)
|
||||||
|
|
||||||
|
return NextResponse.json({ files, isComplete })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Soulseek results error:', error)
|
console.error('Soulseek results error:', error)
|
||||||
return NextResponse.json({ error: 'Failed to get results' }, { status: 502 })
|
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 { MusicProvider } from "@/components/music/music-provider"
|
||||||
import { MiniPlayer } from "@/components/music/mini-player"
|
import { MiniPlayer } from "@/components/music/mini-player"
|
||||||
import { UpdateBanner } from "@/components/update-banner"
|
import { UpdateBanner } from "@/components/update-banner"
|
||||||
|
import { OfflineProvider } from "@/lib/stores/offline"
|
||||||
|
import { ServiceWorkerRegister } from "@/components/sw-register"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] })
|
const _geist = Geist({ subsets: ["latin"] })
|
||||||
|
|
@ -73,10 +75,13 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
||||||
<body className={`font-sans antialiased`}>
|
<body className={`font-sans antialiased`}>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<MusicProvider>
|
<MusicProvider>
|
||||||
{children}
|
<OfflineProvider>
|
||||||
<MiniPlayer />
|
{children}
|
||||||
|
<MiniPlayer />
|
||||||
|
</OfflineProvider>
|
||||||
</MusicProvider>
|
</MusicProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { JefflixLogo } from '@/components/jefflix-logo'
|
import { JefflixLogo } from '@/components/jefflix-logo'
|
||||||
import { SongRow } from '@/components/music/search-results'
|
import { SongRow } from '@/components/music/search-results'
|
||||||
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
|
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'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
|
|
@ -16,16 +17,18 @@ interface Playlist {
|
||||||
coverArt: string
|
coverArt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlskdResult {
|
interface SlskdFile {
|
||||||
username: string
|
displayName: string
|
||||||
freeSlots: number
|
filename: string
|
||||||
speed: number
|
size: number
|
||||||
files: {
|
bitRate: number
|
||||||
filename: string
|
length: number
|
||||||
size: number
|
bestPeer: {
|
||||||
bitRate: number
|
username: string
|
||||||
length: number
|
freeSlots: number
|
||||||
}[]
|
speed: number
|
||||||
|
}
|
||||||
|
peerCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MusicPage() {
|
export default function MusicPage() {
|
||||||
|
|
@ -47,7 +50,7 @@ export default function MusicPage() {
|
||||||
// Soulseek state
|
// Soulseek state
|
||||||
const [slskMode, setSlskMode] = useState(false)
|
const [slskMode, setSlskMode] = useState(false)
|
||||||
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
|
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
|
||||||
const [slskResults, setSlskResults] = useState<SlskdResult[]>([])
|
const [slskResults, setSlskResults] = useState<SlskdFile[]>([])
|
||||||
const [slskSearching, setSlskSearching] = useState(false)
|
const [slskSearching, setSlskSearching] = useState(false)
|
||||||
const [downloading, setDownloading] = useState<string | null>(null)
|
const [downloading, setDownloading] = useState<string | null>(null)
|
||||||
const pollRef = useRef<NodeJS.Timeout>(null)
|
const pollRef = useRef<NodeJS.Timeout>(null)
|
||||||
|
|
@ -134,7 +137,7 @@ export default function MusicPage() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/music/slskd/results/${searchId}`)
|
const res = await fetch(`/api/music/slskd/results/${searchId}`)
|
||||||
const d = await res.json()
|
const d = await res.json()
|
||||||
setSlskResults(d.results || [])
|
setSlskResults(d.files || [])
|
||||||
if (!d.isComplete) {
|
if (!d.isComplete) {
|
||||||
pollRef.current = setTimeout(poll, 2000)
|
pollRef.current = setTimeout(poll, 2000)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -147,14 +150,17 @@ export default function MusicPage() {
|
||||||
poll()
|
poll()
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerDownload = async (username: string, files: SlskdResult['files']) => {
|
const triggerDownload = async (file: SlskdFile) => {
|
||||||
const key = `${username}:${files[0]?.filename}`
|
const key = `${file.bestPeer.username}:${file.filename}`
|
||||||
setDownloading(key)
|
setDownloading(key)
|
||||||
try {
|
try {
|
||||||
await fetch('/api/music/slskd/download', {
|
await fetch('/api/music/slskd/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 {}
|
} catch {}
|
||||||
setDownloading(null)
|
setDownloading(null)
|
||||||
|
|
@ -178,12 +184,20 @@ export default function MusicPage() {
|
||||||
<Link href="/" className="inline-block">
|
<Link href="/" className="inline-block">
|
||||||
<JefflixLogo size="small" />
|
<JefflixLogo size="small" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm">
|
<Link href="/offline">
|
||||||
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
<Button variant="ghost" size="sm">
|
||||||
Home
|
<WifiOff className="h-4 w-4 mr-1.5" />
|
||||||
</Button>
|
Offline
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
||||||
|
Home
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -244,7 +258,7 @@ export default function MusicPage() {
|
||||||
{!searching && songs.length > 0 && (
|
{!searching && songs.length > 0 && (
|
||||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||||
{songs.map((song, i) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -269,6 +283,11 @@ export default function MusicPage() {
|
||||||
Type at least 2 characters to search
|
Type at least 2 characters to search
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* MusicBrainz Discovery */}
|
||||||
|
{debouncedQuery.length >= 2 && (
|
||||||
|
<MusicBrainzResultsSection query={debouncedQuery} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -283,59 +302,51 @@ export default function MusicPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{slskResults.length > 0 && (
|
{slskResults.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-1">
|
||||||
{slskSearching && (
|
{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" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
Still searching...
|
Still searching...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{slskResults.map((result) => (
|
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||||
<div
|
{slskResults.map((file) => {
|
||||||
key={`${result.username}-${result.files[0]?.filename}`}
|
const sizeMB = (file.size / 1024 / 1024).toFixed(1)
|
||||||
className="border border-border rounded-lg p-4 space-y-2"
|
const key = `${file.bestPeer.username}:${file.filename}`
|
||||||
>
|
|
||||||
<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) => {
|
return (
|
||||||
const name = file.filename.split('\\').pop() || file.filename
|
<div key={key} className="flex items-center gap-3 px-4 py-3">
|
||||||
const sizeMB = (file.size / 1024 / 1024).toFixed(1)
|
<div className="flex-1 min-w-0">
|
||||||
const key = `${result.username}:${file.filename}`
|
<div className="text-sm font-medium truncate">{file.displayName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
return (
|
{sizeMB} MB
|
||||||
<div key={file.filename} className="flex items-center gap-3 pl-2">
|
{file.bitRate > 0 && ` · ${file.bitRate} kbps`}
|
||||||
<div className="flex-1 min-w-0">
|
{' · '}{file.bestPeer.username}
|
||||||
<div className="text-sm truncate">{name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{sizeMB} MB
|
|
||||||
{file.bitRate > 0 && ` · ${file.bitRate} kbps`}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => triggerDownload(result.username, [file])}
|
|
||||||
disabled={downloading === key}
|
|
||||||
>
|
|
||||||
{downloading === key ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
{file.peerCount > 1 && (
|
||||||
})}
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 flex-shrink-0">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -409,7 +420,7 @@ export default function MusicPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{playlistSongs.map((song, i) => (
|
{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>
|
</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 { useState, useEffect } from 'react'
|
||||||
import { Drawer } from 'vaul'
|
import { Drawer } from 'vaul'
|
||||||
import { useMusicPlayer } from './music-provider'
|
import { useMusicPlayer } from './music-provider'
|
||||||
|
import { DownloadButton } from './download-button'
|
||||||
import { PlaylistPicker } from './playlist-picker'
|
import { PlaylistPicker } from './playlist-picker'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Speaker,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
function formatTime(secs: number) {
|
function formatTime(secs: number) {
|
||||||
|
|
@ -26,7 +28,7 @@ function formatTime(secs: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FullScreenPlayer() {
|
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 [lyrics, setLyrics] = useState<string | null>(null)
|
||||||
const [loadingLyrics, setLoadingLyrics] = useState(false)
|
const [loadingLyrics, setLoadingLyrics] = useState(false)
|
||||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||||
|
|
@ -146,8 +148,27 @@ export function FullScreenPlayer() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3 mb-8">
|
<div className="flex gap-3 mb-8">
|
||||||
|
<DownloadButton track={track} size="md" />
|
||||||
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
|
||||||
<ListPlus className="h-4 w-4 mr-1.5" />
|
<ListPlus className="h-4 w-4 mr-1.5" />
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client'
|
'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 {
|
export interface Track {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -84,6 +85,11 @@ function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AudioOutputDevice {
|
||||||
|
deviceId: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
interface MusicContextValue {
|
interface MusicContextValue {
|
||||||
state: PlayerState
|
state: PlayerState
|
||||||
playTrack: (track: Track, queue?: Track[], index?: number) => void
|
playTrack: (track: Track, queue?: Track[], index?: number) => void
|
||||||
|
|
@ -94,6 +100,9 @@ interface MusicContextValue {
|
||||||
prevTrack: () => void
|
prevTrack: () => void
|
||||||
setFullScreen: (open: boolean) => void
|
setFullScreen: (open: boolean) => void
|
||||||
addToQueue: (track: Track) => void
|
addToQueue: (track: Track) => void
|
||||||
|
outputDevices: AudioOutputDevice[]
|
||||||
|
currentOutputId: string
|
||||||
|
setOutputDevice: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MusicContext = createContext<MusicContextValue | null>(null)
|
const MusicContext = createContext<MusicContextValue | null>(null)
|
||||||
|
|
@ -107,6 +116,8 @@ export function useMusicPlayer() {
|
||||||
export function MusicProvider({ children }: { children: React.ReactNode }) {
|
export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [state, dispatch] = useReducer(playerReducer, initialState)
|
const [state, dispatch] = useReducer(playerReducer, initialState)
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
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)
|
// Create audio element on mount (client only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -116,8 +127,15 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const onTimeUpdate = () => dispatch({ type: 'SET_PROGRESS', progress: audio.currentTime })
|
const onTimeUpdate = () => dispatch({ type: 'SET_PROGRESS', progress: audio.currentTime })
|
||||||
const onDurationChange = () => dispatch({ type: 'SET_DURATION', duration: audio.duration || 0 })
|
const onDurationChange = () => dispatch({ type: 'SET_DURATION', duration: audio.duration || 0 })
|
||||||
const onEnded = () => dispatch({ type: 'NEXT_TRACK' })
|
const onEnded = () => {
|
||||||
const onPause = () => dispatch({ type: 'SET_PLAYING', playing: false })
|
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 })
|
const onPlay = () => dispatch({ type: 'SET_PLAYING', playing: true })
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
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(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio || !state.currentTrack) return
|
if (!audio || !state.currentTrack) return
|
||||||
audio.src = `/api/music/stream/${state.currentTrack.id}`
|
const trackId = state.currentTrack.id
|
||||||
audio.play().catch(() => {})
|
transitioningRef.current = true
|
||||||
}, [state.currentTrack?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
// 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
|
// Sync play/pause
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio || !state.currentTrack) return
|
if (!audio || !state.currentTrack) return
|
||||||
if (state.isPlaying) {
|
if (state.isPlaying) {
|
||||||
audio.play().catch(() => {})
|
playWithRetry(audio)
|
||||||
} else {
|
} else {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
}
|
}
|
||||||
}, [state.isPlaying, state.currentTrack])
|
}, [state.isPlaying, state.currentTrack, playWithRetry])
|
||||||
|
|
||||||
// Sync volume
|
// Sync volume
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -190,6 +249,40 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||||
return () => window.removeEventListener('keydown', handler)
|
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) => {
|
const playTrack = useCallback((track: Track, queue?: Track[], index?: number) => {
|
||||||
dispatch({ type: 'PLAY_TRACK', track, queue, index })
|
dispatch({ type: 'PLAY_TRACK', track, queue, index })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -220,6 +313,9 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||||
prevTrack,
|
prevTrack,
|
||||||
setFullScreen,
|
setFullScreen,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
|
outputDevices,
|
||||||
|
currentOutputId,
|
||||||
|
setOutputDevice,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</MusicContext.Provider>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useMusicPlayer, type Track } from './music-provider'
|
import { useMusicPlayer, type Track } from './music-provider'
|
||||||
|
import { DownloadButton } from './download-button'
|
||||||
|
import { SwipeableRow } from './swipeable-row'
|
||||||
import { Play, Pause, ListPlus } from 'lucide-react'
|
import { Play, Pause, ListPlus } from 'lucide-react'
|
||||||
|
|
||||||
function formatDuration(secs: number) {
|
function formatDuration(secs: number) {
|
||||||
|
|
@ -14,70 +16,77 @@ export function SongRow({
|
||||||
song,
|
song,
|
||||||
songs,
|
songs,
|
||||||
index,
|
index,
|
||||||
|
showDownload = false,
|
||||||
}: {
|
}: {
|
||||||
song: Track
|
song: Track
|
||||||
songs: Track[]
|
songs: Track[]
|
||||||
index: number
|
index: number
|
||||||
|
showDownload?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer()
|
const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer()
|
||||||
const isActive = state.currentTrack?.id === song.id
|
const isActive = state.currentTrack?.id === song.id
|
||||||
const isPlaying = isActive && state.isPlaying
|
const isPlaying = isActive && state.isPlaying
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<SwipeableRow onSwipeRight={() => addToQueue(song)}>
|
||||||
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
|
<div
|
||||||
isActive ? 'bg-primary/5' : ''
|
className={`flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
|
||||||
}`}
|
isActive ? 'bg-primary/5' : ''
|
||||||
>
|
}`}
|
||||||
{/* Play button / track number */}
|
|
||||||
<button
|
|
||||||
onClick={() => isActive ? togglePlay() : playTrack(song, songs, index)}
|
|
||||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors"
|
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{/* Play button / track number */}
|
||||||
<Pause className="h-4 w-4 text-primary" />
|
<button
|
||||||
) : (
|
onClick={() => isActive ? togglePlay() : playTrack(song, songs, index)}
|
||||||
<Play className="h-4 w-4 text-primary ml-0.5" />
|
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors"
|
||||||
)}
|
>
|
||||||
</button>
|
{isPlaying ? (
|
||||||
|
<Pause className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4 text-primary ml-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Cover art */}
|
{/* Cover art */}
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
|
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
|
||||||
{song.coverArt ? (
|
{song.coverArt ? (
|
||||||
<img
|
<img
|
||||||
src={`/api/music/cover/${song.coverArt}?size=80`}
|
src={`/api/music/cover/${song.coverArt}?size=80`}
|
||||||
alt={song.album}
|
alt={song.album}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-muted" />
|
<div className="w-full h-full bg-muted" />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className={`text-sm font-medium truncate ${isActive ? 'text-primary' : ''}`}>
|
|
||||||
{song.title}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
|
||||||
{song.artist} · {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} · {song.album}
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
</SwipeableRow>
|
||||||
{/* Duration */}
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
|
||||||
{formatDuration(song.duration)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Add to queue */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); addToQueue(song) }}
|
|
||||||
className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-muted/50 rounded transition-all"
|
|
||||||
title="Add to queue"
|
|
||||||
>
|
|
||||||
<ListPlus className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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
|
- THREADFIN_URL=https://threadfin.jefflix.lol
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`) || Host(`music.jefflix.lol`)"
|
||||||
- "traefik.http.services.jefflix-website.loadbalancer.server.port=3000"
|
- "traefik.http.services.jefflix-website.loadbalancer.server.port=3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
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))
|
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()
|
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