196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
'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>
|
|
)
|
|
}
|