diff --git a/app/api/music/musicbrainz/route.ts b/app/api/music/musicbrainz/route.ts new file mode 100644 index 0000000..79b9c26 --- /dev/null +++ b/app/api/music/musicbrainz/route.ts @@ -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 }) + } +} diff --git a/app/api/music/offline/route.ts b/app/api/music/offline/route.ts new file mode 100644 index 0000000..a14ff71 --- /dev/null +++ b/app/api/music/offline/route.ts @@ -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('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('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('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 }) + } +} diff --git a/app/api/music/playlists/route.ts b/app/api/music/playlists/route.ts index 7e1ff6f..3cc3844 100644 --- a/app/api/music/playlists/route.ts +++ b/app/api/music/playlists/route.ts @@ -18,13 +18,15 @@ interface PlaylistsResult { export async function GET() { try { const data = await navidromeGet('getPlaylists.view') - const playlists = (data.playlists?.playlist || []).map((p) => ({ - id: p.id, - name: p.name, - songCount: p.songCount, - duration: p.duration, - coverArt: p.coverArt, - })) + const playlists = (data.playlists?.playlist || []) + .filter((p) => p.name !== '__soulsync_offline__') + .map((p) => ({ + id: p.id, + name: p.name, + songCount: p.songCount, + duration: p.duration, + coverArt: p.coverArt, + })) return NextResponse.json({ playlists }) } catch (error) { console.error('Playlists error:', error) diff --git a/app/api/music/search/route.ts b/app/api/music/search/route.ts index 80ad979..3b13d10 100644 --- a/app/api/music/search/route.ts +++ b/app/api/music/search/route.ts @@ -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('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() + 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, diff --git a/app/api/music/slskd/auto-download/route.ts b/app/api/music/slskd/auto-download/route.ts new file mode 100644 index 0000000..399fa9d --- /dev/null +++ b/app/api/music/slskd/auto-download/route.ts @@ -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 }) + } +} diff --git a/app/api/music/slskd/results/[searchId]/route.ts b/app/api/music/slskd/results/[searchId]/route.ts index 81c0e3b..a37cc2e 100644 --- a/app/api/music/slskd/results/[searchId]/route.ts +++ b/app/api/music/slskd/results/[searchId]/route.ts @@ -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 }) diff --git a/app/layout.tsx b/app/layout.tsx index 60c3fab..d93b55f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( + - {children} - + + {children} + + diff --git a/app/music/page.tsx b/app/music/page.tsx index 1e42f6f..f4550ba 100644 --- a/app/music/page.tsx +++ b/app/music/page.tsx @@ -6,7 +6,8 @@ import { Badge } from '@/components/ui/badge' import { JefflixLogo } from '@/components/jefflix-logo' import { SongRow } from '@/components/music/search-results' import { useMusicPlayer, type Track } from '@/components/music/music-provider' -import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown } from 'lucide-react' +import { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results' +import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users } from 'lucide-react' import Link from 'next/link' interface Playlist { @@ -16,16 +17,18 @@ interface Playlist { coverArt: string } -interface SlskdResult { - username: string - freeSlots: number - speed: number - files: { - filename: string - size: number - bitRate: number - length: number - }[] +interface SlskdFile { + displayName: string + filename: string + size: number + bitRate: number + length: number + bestPeer: { + username: string + freeSlots: number + speed: number + } + peerCount: number } export default function MusicPage() { @@ -47,7 +50,7 @@ export default function MusicPage() { // Soulseek state const [slskMode, setSlskMode] = useState(false) const [slskSearchId, setSlskSearchId] = useState(null) - const [slskResults, setSlskResults] = useState([]) + const [slskResults, setSlskResults] = useState([]) const [slskSearching, setSlskSearching] = useState(false) const [downloading, setDownloading] = useState(null) const pollRef = useRef(null) @@ -134,7 +137,7 @@ export default function MusicPage() { try { const res = await fetch(`/api/music/slskd/results/${searchId}`) const d = await res.json() - setSlskResults(d.results || []) + setSlskResults(d.files || []) if (!d.isComplete) { pollRef.current = setTimeout(poll, 2000) } else { @@ -147,14 +150,17 @@ export default function MusicPage() { poll() } - const triggerDownload = async (username: string, files: SlskdResult['files']) => { - const key = `${username}:${files[0]?.filename}` + const triggerDownload = async (file: SlskdFile) => { + const key = `${file.bestPeer.username}:${file.filename}` setDownloading(key) try { await fetch('/api/music/slskd/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, files }), + body: JSON.stringify({ + username: file.bestPeer.username, + files: [{ filename: file.filename, size: file.size }], + }), }) } catch {} setDownloading(null) @@ -178,12 +184,20 @@ export default function MusicPage() { - - - +
+ + + + + + +
@@ -244,7 +258,7 @@ export default function MusicPage() { {!searching && songs.length > 0 && (
{songs.map((song, i) => ( - + ))}
)} @@ -269,6 +283,11 @@ export default function MusicPage() { Type at least 2 characters to search

)} + + {/* MusicBrainz Discovery */} + {debouncedQuery.length >= 2 && ( + + )} )} @@ -283,59 +302,51 @@ export default function MusicPage() { )} {slskResults.length > 0 && ( -
+
{slskSearching && ( -
+
Still searching...
)} - {slskResults.map((result) => ( -
-
-
- {result.username} - - {result.freeSlots > 0 ? `${result.freeSlots} free slots` : 'No free slots'} - -
-
+
+ {slskResults.map((file) => { + const sizeMB = (file.size / 1024 / 1024).toFixed(1) + const key = `${file.bestPeer.username}:${file.filename}` - {result.files.map((file) => { - const name = file.filename.split('\\').pop() || file.filename - const sizeMB = (file.size / 1024 / 1024).toFixed(1) - const key = `${result.username}:${file.filename}` - - return ( -
-
-
{name}
-
- {sizeMB} MB - {file.bitRate > 0 && ` · ${file.bitRate} kbps`} -
+ return ( +
+
+
{file.displayName}
+
+ {sizeMB} MB + {file.bitRate > 0 && ` · ${file.bitRate} kbps`} + {' · '}{file.bestPeer.username}
-
- ) - })} -
- ))} + {file.peerCount > 1 && ( + + + {file.peerCount} + + )} + +
+ ) + })} +
)} @@ -409,7 +420,7 @@ export default function MusicPage() { ) : (
{playlistSongs.map((song, i) => ( - + ))}
)} diff --git a/app/offline/page.tsx b/app/offline/page.tsx new file mode 100644 index 0000000..c8313c4 --- /dev/null +++ b/app/offline/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ + + + + + +
+
+ +
+
+ {/* Hero */} +
+
+ +
+

Offline Library

+

+ Songs downloaded for offline playback. Syncs across all your devices. +

+
+ + {/* Stats + Actions */} +
+
+ + {formatBytes(storageUsed)} used +
+
+ + {offlineTracks.length} songs +
+
+ + {offlineTracks.length > 0 && ( + <> + + + + )} +
+ + {/* Download queue */} + {queue.length > 0 && ( +
+

+ + Downloading ({queue.length} remaining) +

+
+ {queue.slice(0, 5).map((t) => ( +
+ {t.id === activeDownloadId && } + {t.title} — {t.artist} +
+ ))} + {queue.length > 5 && ( +
...and {queue.length - 5} more
+ )} +
+
+ )} + + {/* Songs list */} + {loading ? ( +
+ +
+ ) : offlineTracks.length === 0 ? ( +
+ +

+ No songs downloaded yet. Tap the download icon on any song to save it for offline. +

+ + + +
+ ) : ( +
+ {offlineTracks.map((song, i) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/components/music/download-button.tsx b/components/music/download-button.tsx new file mode 100644 index 0000000..1031d5a --- /dev/null +++ b/components/music/download-button.tsx @@ -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 ( + + ) + } + + if (status === 'downloading') { + return ( + + + + ) + } + + if (status === 'queued') { + return ( + + + + ) + } + + return ( + + ) +} diff --git a/components/music/full-screen-player.tsx b/components/music/full-screen-player.tsx index 8d3e981..4bf6f27 100644 --- a/components/music/full-screen-player.tsx +++ b/components/music/full-screen-player.tsx @@ -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(null) const [loadingLyrics, setLoadingLyrics] = useState(false) const [playlistOpen, setPlaylistOpen] = useState(false) @@ -146,8 +148,27 @@ export function FullScreenPlayer() { />
+ {/* Audio output selector */} + {outputDevices.length > 1 && ( +
+ + +
+ )} + {/* Actions */}
+ +
+ ) + })} +
+
+ ) +} diff --git a/components/music/search-results.tsx b/components/music/search-results.tsx index b05ed05..8c5a072 100644 --- a/components/music/search-results.tsx +++ b/components/music/search-results.tsx @@ -1,6 +1,8 @@ 'use client' import { useMusicPlayer, type Track } from './music-provider' +import { DownloadButton } from './download-button' +import { SwipeableRow } from './swipeable-row' import { Play, Pause, ListPlus } from 'lucide-react' function formatDuration(secs: number) { @@ -14,70 +16,77 @@ export function SongRow({ song, songs, index, + showDownload = false, }: { song: Track songs: Track[] index: number + showDownload?: boolean }) { const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer() const isActive = state.currentTrack?.id === song.id const isPlaying = isActive && state.isPlaying return ( -
- {/* Play button / track number */} - + {/* Play button / track number */} + - {/* Cover art */} -
- {song.coverArt ? ( - {song.album} - ) : ( -
- )} -
- - {/* Info */} -
-
- {song.title} + {/* Cover art */} +
+ {song.coverArt ? ( + {song.album} + ) : ( +
+ )}
-
- {song.artist} · {song.album} + + {/* Info */} +
+
+ {song.title} +
+
+ {song.artist} · {song.album} +
+ + {/* Duration */} + + {formatDuration(song.duration)} + + + {/* Download */} + {showDownload && } + + {/* Add to queue (desktop hover) */} +
- - {/* Duration */} - - {formatDuration(song.duration)} - - - {/* Add to queue */} - -
+ ) } diff --git a/components/music/swipeable-row.tsx b/components/music/swipeable-row.tsx new file mode 100644 index 0000000..d7ca572 --- /dev/null +++ b/components/music/swipeable-row.tsx @@ -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 ( +
+ {/* Green reveal strip behind */} +
THRESHOLD ? 'bg-green-600' : 'bg-green-600/80' + }`} + style={{ width: Math.max(offset, confirmed ? 200 : 0) }} + > + + + {confirmed ? 'Added!' : 'Add to Queue'} + +
+ + {/* Swipeable content */} +
+ {children} +
+
+ ) +} diff --git a/components/sw-register.tsx b/components/sw-register.tsx new file mode 100644 index 0000000..a847761 --- /dev/null +++ b/components/sw-register.tsx @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 4a6264c..85e6578 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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/"] diff --git a/lib/offline-db.ts b/lib/offline-db.ts new file mode 100644 index 0000000..21c9469 --- /dev/null +++ b/lib/offline-db.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) } + }) +} diff --git a/lib/slskd-dedup.ts b/lib/slskd-dedup.ts new file mode 100644 index 0000000..782e94c --- /dev/null +++ b/lib/slskd-dedup.ts @@ -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() + + 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) +} diff --git a/lib/stores/offline.tsx b/lib/stores/offline.tsx new file mode 100644 index 0000000..e14af96 --- /dev/null +++ b/lib/stores/offline.tsx @@ -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 + queue: Track[] + activeDownloadId: string | null + storageUsed: number + download: (track: Track) => void + remove: (trackId: string) => void + clearAll: () => void + getStatus: (trackId: string) => DownloadStatus + sync: () => Promise + offlineTracks: Track[] + loading: boolean +} + +const OfflineContext = createContext(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>(new Set()) + const [offlineTracks, setOfflineTracks] = useState([]) + const [queue, setQueue] = useState([]) + const [activeDownloadId, setActiveDownloadId] = useState(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([]) + + 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 ( + + {children} + + ) +} diff --git a/middleware.ts b/middleware.ts index 775ca0e..2ae0a35 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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() } diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..1de4e9d --- /dev/null +++ b/public/sw.js @@ -0,0 +1,108 @@ +/// + +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 + } +})