jefflix-website/lib/stores/offline.tsx

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>
)
}