From 1d013bb9f62bb8140309e7f6b1bb70f888a9fc70 Mon Sep 17 00:00:00 2001 From: angel Date: Sun, 25 Jan 2026 00:35:58 +0000 Subject: [PATCH] refactor: remove all offline download and caching functionality --- app/album/[id]/page.tsx | 75 --- app/components/EnhancedOfflineManager.tsx | 627 ------------------ app/components/OfflineIndicator.tsx | 226 ------- app/components/OfflineLibrarySync.tsx | 0 app/components/OfflineManagement.tsx | 395 ------------ app/components/OfflineNavidromeContext.tsx | 367 ----------- app/components/OfflineNavidromeProvider.tsx | 281 -------- app/components/OfflineStatusIndicator.tsx | 65 -- app/components/RootLayoutClient.tsx | 6 +- app/components/SongRecommendations.tsx | 57 +- app/components/WhatsNewPopup.tsx | 6 +- app/components/album-artwork.tsx | 15 +- app/page.tsx | 100 ++- app/settings/page.tsx | 6 - components/ui/resizable.tsx | 16 +- hooks/use-offline-audio-player.ts | 281 -------- hooks/use-offline-downloads.ts | 682 -------------------- hooks/use-offline-library-sync.ts | 517 --------------- hooks/use-offline-library.ts | 538 --------------- hooks/use-progressive-album-loading.ts | 162 +---- 20 files changed, 91 insertions(+), 4331 deletions(-) delete mode 100644 app/components/EnhancedOfflineManager.tsx delete mode 100644 app/components/OfflineIndicator.tsx delete mode 100644 app/components/OfflineLibrarySync.tsx delete mode 100644 app/components/OfflineManagement.tsx delete mode 100644 app/components/OfflineNavidromeContext.tsx delete mode 100644 app/components/OfflineNavidromeProvider.tsx delete mode 100644 app/components/OfflineStatusIndicator.tsx delete mode 100644 hooks/use-offline-audio-player.ts delete mode 100644 hooks/use-offline-downloads.ts delete mode 100644 hooks/use-offline-library-sync.ts delete mode 100644 hooks/use-offline-library.ts diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index 3bb03ec..35991e2 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -13,9 +13,6 @@ import { Separator } from '@/components/ui/separator'; import { getNavidromeAPI } from '@/lib/navidrome'; import { useFavoriteAlbums } from '@/hooks/use-favorite-albums'; import { useIsMobile } from '@/hooks/use-mobile'; -import { OfflineIndicator, DownloadButton } from '@/app/components/OfflineIndicator'; -import { useOfflineDownloads } from '@/hooks/use-offline-downloads'; -import { useToast } from '@/hooks/use-toast'; export default function AlbumPage() { const { id } = useParams(); @@ -29,8 +26,6 @@ export default function AlbumPage() { const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums(); const isMobile = useIsMobile(); const api = getNavidromeAPI(); - const { downloadAlbum, isSupported: isOfflineSupported } = useOfflineDownloads(); - const { toast } = useToast(); useEffect(() => { const fetchAlbum = async () => { @@ -126,31 +121,6 @@ export default function AlbumPage() { return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; - const handleDownloadAlbum = async () => { - if (!album || !tracklist.length) return; - - try { - toast({ - title: "Download Started", - description: `Starting download of "${album.name}" by ${album.artist}`, - }); - - await downloadAlbum(album, tracklist); - - toast({ - title: "Download Complete", - description: `"${album.name}" has been downloaded for offline listening`, - }); - } catch (error) { - console.error('Failed to download album:', error); - toast({ - title: "Download Failed", - description: `Failed to download "${album.name}". Please try again.`, - variant: "destructive" - }); - } - }; - // Dynamic cover art URLs based on image size const getMobileCoverArtUrl = () => { return album.coverArt && api @@ -192,15 +162,6 @@ export default function AlbumPage() {

{album.genre} • {album.year}

{album.songCount} songs, {formatDuration(album.duration)}

- - {/* Offline indicator for mobile */} - {/* Right side - Controls */} @@ -212,18 +173,6 @@ export default function AlbumPage() { > - - {/* Download button for mobile */} - {isOfflineSupported && ( - - )} @@ -253,30 +202,12 @@ export default function AlbumPage() { - - {/* Download button for desktop */} - {isOfflineSupported && ( - - )} {/* Album info */}

{album.genre} • {album.year}

{album.songCount} songs, {formatDuration(album.duration)}

- - {/* Offline indicator for desktop */} -
@@ -312,12 +243,6 @@ export default function AlbumPage() { }`}> {song.title}

- {/* Song offline indicator */} -
diff --git a/app/components/EnhancedOfflineManager.tsx b/app/components/EnhancedOfflineManager.tsx deleted file mode 100644 index 0c930b7..0000000 --- a/app/components/EnhancedOfflineManager.tsx +++ /dev/null @@ -1,627 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Progress } from '@/components/ui/progress'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useToast } from '@/hooks/use-toast'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; -import { useNavidrome } from '@/app/components/NavidromeContext'; -import { - Download, - Trash2, - RefreshCw, - Wifi, - WifiOff, - Database, - Clock, - AlertCircle, - CheckCircle, - Music, - User, - List, - HardDrive, - Disc, - Search, - Filter, - SlidersHorizontal -} from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import Image from 'next/image'; -import { Album, Playlist } from '@/lib/navidrome'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { OfflineManagement } from './OfflineManagement'; -import { Skeleton } from '@/components/ui/skeleton'; - -// Helper functions -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -function formatDate(date: Date | null): string { - if (!date) return 'Never'; - return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString(); -} - -// Album card for selection -function AlbumSelectionCard({ - album, - isSelected, - onToggleSelection, - isDownloading, - downloadProgress, - estimatedSize -}: { - album: Album; - isSelected: boolean; - onToggleSelection: () => void; - isDownloading: boolean; - downloadProgress?: number; - estimatedSize: string; -}) { - const { api } = useNavidrome(); - - return ( - -
-
- {album.name} -
-
-

{album.name}

-

{album.artist}

-
- {album.songCount} songs • {estimatedSize} - -
-
-
- - {isDownloading && downloadProgress !== undefined && ( - - )} -
- ); -} - -// Playlist selection card -function PlaylistSelectionCard({ - playlist, - isSelected, - onToggleSelection, - isDownloading, - downloadProgress, - estimatedSize -}: { - playlist: Playlist; - isSelected: boolean; - onToggleSelection: () => void; - isDownloading: boolean; - downloadProgress?: number; - estimatedSize: string; -}) { - const { api } = useNavidrome(); - - return ( - -
-
-
- -
-
-
-

{playlist.name}

-

by {playlist.owner}

-
- {playlist.songCount} songs • {estimatedSize} - -
-
-
- - {isDownloading && downloadProgress !== undefined && ( - - )} -
- ); -} - -export default function EnhancedOfflineManager() { - const { toast } = useToast(); - const [activeTab, setActiveTab] = useState('overview'); - const [albums, setAlbums] = useState([]); - const [playlists, setPlaylists] = useState([]); - const [loading, setLoading] = useState({ - albums: false, - playlists: false - }); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedAlbums, setSelectedAlbums] = useState>(new Set()); - const [selectedPlaylists, setSelectedPlaylists] = useState>(new Set()); - const [downloadingItems, setDownloadingItems] = useState>(new Map()); - - // Filter state - const [sortBy, setSortBy] = useState('recent'); - const [filtersVisible, setFiltersVisible] = useState(false); - - const offline = useOfflineLibrary(); - const { api } = useNavidrome(); - - // Load albums and playlists - // ...existing code... - - // ...existing code... - // Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists - - // Load albums data - const loadAlbums = async () => { - setLoading(prev => ({ ...prev, albums: true })); - try { - const albumData = await offline.getAlbums(); - setAlbums(albumData); - - // Load previously selected albums from localStorage - const savedSelections = localStorage.getItem('navidrome-offline-albums'); - if (savedSelections) { - setSelectedAlbums(new Set(JSON.parse(savedSelections))); - } - - } catch (error) { - console.error('Failed to load albums:', error); - toast({ - title: 'Error', - description: 'Failed to load albums. Please try again.', - variant: 'destructive' - }); - } finally { - setLoading(prev => ({ ...prev, albums: false })); - } - }; - - // Load playlists data - const loadPlaylists = async () => { - setLoading(prev => ({ ...prev, playlists: true })); - try { - const playlistData = await offline.getPlaylists(); - setPlaylists(playlistData); - - // Load previously selected playlists from localStorage - const savedSelections = localStorage.getItem('navidrome-offline-playlists'); - if (savedSelections) { - setSelectedPlaylists(new Set(JSON.parse(savedSelections))); - } - - } catch (error) { - console.error('Failed to load playlists:', error); - toast({ - title: 'Error', - description: 'Failed to load playlists. Please try again.', - variant: 'destructive' - }); - } finally { - setLoading(prev => ({ ...prev, playlists: false })); - } - }; - - // Toggle album selection - const toggleAlbumSelection = (albumId: string) => { - setSelectedAlbums(prev => { - const newSelection = new Set(prev); - if (newSelection.has(albumId)) { - newSelection.delete(albumId); - } else { - newSelection.add(albumId); - } - - // Save to localStorage - localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection])); - - return newSelection; - }); - }; - - // Toggle playlist selection - const togglePlaylistSelection = (playlistId: string) => { - setSelectedPlaylists(prev => { - const newSelection = new Set(prev); - if (newSelection.has(playlistId)) { - newSelection.delete(playlistId); - } else { - newSelection.add(playlistId); - } - - // Save to localStorage - localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection])); - - return newSelection; - }); - }; - - // Download selected items - const downloadSelected = async () => { - // Mock implementation - in a real implementation, you'd integrate with the download system - const selectedIds = [...selectedAlbums, ...selectedPlaylists]; - if (selectedIds.length === 0) { - toast({ - title: 'No items selected', - description: 'Please select albums or playlists to download.', - }); - return; - } - - toast({ - title: 'Download Started', - description: `Downloading ${selectedIds.length} items for offline use.`, - }); - - // Mock download progress - const downloadMap = new Map(); - selectedIds.forEach(id => downloadMap.set(id, 0)); - setDownloadingItems(downloadMap); - - // Simulate download progress - const interval = setInterval(() => { - setDownloadingItems(prev => { - const updated = new Map(prev); - let allComplete = true; - - for (const [id, progress] of prev.entries()) { - if (progress < 100) { - updated.set(id, Math.min(progress + Math.random() * 10, 100)); - allComplete = false; - } - } - - if (allComplete) { - clearInterval(interval); - toast({ - title: 'Download Complete', - description: `${selectedIds.length} items are now available offline.`, - }); - - setTimeout(() => { - setDownloadingItems(new Map()); - }, 1000); - } - - return updated; - }); - }, 500); - }; - - // Filter and sort albums - const filteredAlbums = albums - .filter(album => { - if (!searchQuery) return true; - return album.name.toLowerCase().includes(searchQuery.toLowerCase()) || - album.artist.toLowerCase().includes(searchQuery.toLowerCase()); - }) - .sort((a, b) => { - switch (sortBy) { - case 'recent': - return new Date(b.created || '').getTime() - new Date(a.created || '').getTime(); - case 'name': - return a.name.localeCompare(b.name); - case 'artist': - return a.artist.localeCompare(b.artist); - default: - return 0; - } - }); - - // Filter and sort playlists - const filteredPlaylists = playlists - .filter(playlist => { - if (!searchQuery) return true; - return playlist.name.toLowerCase().includes(searchQuery.toLowerCase()); - }) - .sort((a, b) => { - switch (sortBy) { - case 'recent': - return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime(); - case 'name': - return a.name.localeCompare(b.name); - default: - return 0; - } - }); - - // Estimate album size (mock implementation) - const estimateSize = (songCount: number) => { - const averageSongSizeMB = 8; - const totalSizeMB = songCount * averageSongSizeMB; - if (totalSizeMB > 1000) { - return `${(totalSizeMB / 1000).toFixed(1)} GB`; - } - return `${totalSizeMB.toFixed(0)} MB`; - }; - - return ( - - - Overview - Albums - Playlists - - - - - - - - - - - - Select Albums - - - Choose albums to make available offline - - - -
-
- - setSearchQuery(e.target.value)} - className="pl-8" - /> -
- -
- - {filtersVisible && ( -
-
-
Sort By
-
- - - -
-
-
- )} - -
-
- {selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected -
- -
- - - {loading.albums ? ( - // Loading skeletons - Array.from({ length: 5 }).map((_, i) => ( - -
- -
- - - -
-
-
- )) - ) : filteredAlbums.length > 0 ? ( - filteredAlbums.map(album => ( - toggleAlbumSelection(album.id)} - isDownloading={downloadingItems.has(album.id)} - downloadProgress={downloadingItems.get(album.id)} - estimatedSize={estimateSize(album.songCount)} - /> - )) - ) : ( -
- -

- {searchQuery ? 'No albums found matching your search' : 'No albums available'} -

-
- )} -
-
- - - -
-
- - - - - - - Select Playlists - - - Choose playlists to make available offline - - - -
-
- - setSearchQuery(e.target.value)} - className="pl-8" - /> -
- -
- - {filtersVisible && ( -
-
-
Sort By
-
- - -
-
-
- )} - -
-
- {selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected -
- -
- - - {loading.playlists ? ( - // Loading skeletons - Array.from({ length: 5 }).map((_, i) => ( - -
- -
- - - -
-
-
- )) - ) : filteredPlaylists.length > 0 ? ( - filteredPlaylists.map(playlist => ( - togglePlaylistSelection(playlist.id)} - isDownloading={downloadingItems.has(playlist.id)} - downloadProgress={downloadingItems.get(playlist.id)} - estimatedSize={estimateSize(playlist.songCount)} - /> - )) - ) : ( -
- -

- {searchQuery ? 'No playlists found matching your search' : 'No playlists available'} -

-
- )} -
-
- - - -
-
-
- ); -} diff --git a/app/components/OfflineIndicator.tsx b/app/components/OfflineIndicator.tsx deleted file mode 100644 index 977f3a1..0000000 --- a/app/components/OfflineIndicator.tsx +++ /dev/null @@ -1,226 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { Download, Check, X, Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { useOfflineDownloads } from '@/hooks/use-offline-downloads'; - -interface OfflineIndicatorProps { - id: string; - type: 'album' | 'song'; - className?: string; - showLabel?: boolean; - size?: 'sm' | 'md' | 'lg'; -} - -export function OfflineIndicator({ - id, - type, - className, - showLabel = false, - size = 'md' -}: OfflineIndicatorProps) { - const [isOffline, setIsOffline] = useState(false); - const [isChecking, setIsChecking] = useState(true); - const { checkOfflineStatus, isInitialized } = useOfflineDownloads(); - - useEffect(() => { - let mounted = true; - - const checkStatus = async () => { - if (!isInitialized) return; - - setIsChecking(true); - try { - const status = await checkOfflineStatus(id, type); - if (mounted) { - setIsOffline(status); - } - } catch (error) { - console.error('Failed to check offline status:', error); - if (mounted) { - setIsOffline(false); - } - } finally { - if (mounted) { - setIsChecking(false); - } - } - }; - - checkStatus(); - - return () => { - mounted = false; - }; - }, [id, type, isInitialized, checkOfflineStatus]); - - const iconSize = { - sm: 'h-3 w-3', - md: 'h-4 w-4', - lg: 'h-5 w-5' - }[size]; - - const textSize = { - sm: 'text-xs', - md: 'text-sm', - lg: 'text-base' - }[size]; - - if (isChecking) { - return ( -
- - {showLabel && Checking...} -
- ); - } - - if (!isOffline) { - return null; // Don't show anything if not downloaded - } - - return ( -
- - {showLabel && ( - - {type === 'album' ? 'Album Downloaded' : 'Downloaded'} - - )} -
- ); -} - -interface DownloadButtonProps { - id: string; - type: 'album' | 'song'; - onDownload?: () => void; - className?: string; - size?: 'sm' | 'md' | 'lg'; - variant?: 'default' | 'outline' | 'ghost'; - children?: React.ReactNode; -} - -export function DownloadButton({ - id, - type, - onDownload, - className, - size = 'md', - variant = 'outline', - children -}: DownloadButtonProps) { - const [isOffline, setIsOffline] = useState(false); - const [isChecking, setIsChecking] = useState(true); - const { - checkOfflineStatus, - deleteOfflineContent, - isInitialized, - downloadProgress - } = useOfflineDownloads(); - - const isDownloading = downloadProgress.status === 'downloading' || downloadProgress.status === 'starting'; - - useEffect(() => { - let mounted = true; - - const checkStatus = async () => { - if (!isInitialized) return; - - setIsChecking(true); - try { - const status = await checkOfflineStatus(id, type); - if (mounted) { - setIsOffline(status); - } - } catch (error) { - console.error('Failed to check offline status:', error); - if (mounted) { - setIsOffline(false); - } - } finally { - if (mounted) { - setIsChecking(false); - } - } - }; - - checkStatus(); - - return () => { - mounted = false; - }; - }, [id, type, isInitialized, checkOfflineStatus]); - - const handleClick = async () => { - if (isOffline) { - // Remove from offline storage - try { - await deleteOfflineContent(id, type); - setIsOffline(false); - } catch (error) { - console.error('Failed to delete offline content:', error); - } - } else { - // Start download - if (onDownload) { - onDownload(); - } - } - }; - - const buttonSize = { - sm: 'sm', - md: 'default', - lg: 'lg' - }[size] as 'sm' | 'default' | 'lg'; - - const iconSize = { - sm: 'h-3 w-3', - md: 'h-4 w-4', - lg: 'h-5 w-5' - }[size]; - - if (isChecking) { - return ( - - ); - } - - return ( - - ); -} diff --git a/app/components/OfflineLibrarySync.tsx b/app/components/OfflineLibrarySync.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/app/components/OfflineManagement.tsx b/app/components/OfflineManagement.tsx deleted file mode 100644 index e296486..0000000 --- a/app/components/OfflineManagement.tsx +++ /dev/null @@ -1,395 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Progress } from '@/components/ui/progress'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { useToast } from '@/hooks/use-toast'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; -import { - Download, - Trash2, - RefreshCw, - Wifi, - WifiOff, - Database, - Clock, - AlertCircle, - CheckCircle, - Music, - User, - List, - HardDrive -} from 'lucide-react'; - -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -function formatDate(date: Date | null): string { - if (!date) return 'Never'; - return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString(); -} - -export function OfflineManagement() { - const { toast } = useToast(); - const [isClearing, setIsClearing] = useState(false); - - const { - isInitialized, - isOnline, - isSyncing, - lastSync, - stats, - syncProgress, - syncLibraryFromServer, - syncPendingOperations, - clearOfflineData, - refreshStats - } = useOfflineLibrary(); - - // Refresh stats periodically - useEffect(() => { - const interval = setInterval(() => { - if (isInitialized && !isSyncing) { - refreshStats(); - } - }, 10000); // Every 10 seconds - - return () => clearInterval(interval); - }, [isInitialized, isSyncing, refreshStats]); - - const handleFullSync = async () => { - try { - await syncLibraryFromServer(); - toast({ - title: "Sync Complete", - description: "Your music library has been synced for offline use.", - }); - } catch (error) { - console.error('Full sync failed:', error); - toast({ - title: "Sync Failed", - description: "Failed to sync library. Check your connection and try again.", - variant: "destructive" - }); - } - }; - - const handlePendingSync = async () => { - try { - await syncPendingOperations(); - toast({ - title: "Pending Operations Synced", - description: "All pending changes have been synced to the server.", - }); - } catch (error) { - console.error('Pending sync failed:', error); - toast({ - title: "Sync Failed", - description: "Failed to sync pending operations. Will retry automatically when online.", - variant: "destructive" - }); - } - }; - - const handleClearData = async () => { - if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) { - return; - } - - setIsClearing(true); - try { - await clearOfflineData(); - toast({ - title: "Offline Data Cleared", - description: "All offline music data has been removed.", - }); - } catch (error) { - console.error('Clear data failed:', error); - toast({ - title: "Clear Failed", - description: "Failed to clear offline data. Please try again.", - variant: "destructive" - }); - } finally { - setIsClearing(false); - } - }; - - if (!isInitialized) { - return ( - - - - - Offline Library - - - Setting up offline library... - - - -
-
- -

Initializing offline storage...

-
-
-
-
- ); - } - - return ( -
- {/* Connection Status */} - - - - {isOnline ? ( - - ) : ( - - )} - Connection Status - - - -
-
- - {isOnline ? "Online" : "Offline"} - - - {isOnline ? "Connected to Navidrome server" : "Working offline"} - -
- - {stats.pendingOperations > 0 && ( -
- - - {stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''} - -
- )} -
-
-
- - {/* Sync Status */} - - - - - Library Sync - - - Keep your offline library up to date - - - - {isSyncing && syncProgress && ( -
-
- {syncProgress.stage} - {syncProgress.current}% -
- -
- )} - -
-
-

Last Sync

-

- - {formatDate(lastSync)} -

-
- -
- {stats.pendingOperations > 0 && isOnline && ( - - )} - - -
-
-
-
- - {/* Library Statistics */} - - - - - Offline Library Stats - - - Your offline music collection - - - -
-
-
- -
-
-

{stats.albums.toLocaleString()}

-

Albums

-
-
- -
-
- -
-
-

{stats.artists.toLocaleString()}

-

Artists

-
-
- -
-
- -
-
-

{stats.songs.toLocaleString()}

-

Songs

-
-
- -
-
- -
-
-

{stats.playlists.toLocaleString()}

-

Playlists

-
-
-
- - - -
-
- - Storage Used -
- - {formatBytes(stats.storageSize)} - -
-
-
- - {/* Offline Features */} - - - Offline Features - - What works when you're offline - - - -
-
- -
-

Browse & Search

-

- Browse your synced albums, artists, and search offline -

-
-
- -
- -
-

Favorites & Playlists

-

- Star songs/albums and create playlists (syncs when online) -

-
-
- -
- -
-

Play Downloaded Music

-

- Play songs you've downloaded for offline listening -

-
-
- -
- -
-

Auto-Sync

-

- Changes sync automatically when you reconnect -

-
-
-
-
-
- - {/* Danger Zone */} - - - Danger Zone - - Permanently delete all offline data - - - -
-
-

Clear All Offline Data

-

- This will remove all synced library data and downloaded audio -

-
- - -
-
-
-
- ); -} diff --git a/app/components/OfflineNavidromeContext.tsx b/app/components/OfflineNavidromeContext.tsx deleted file mode 100644 index 5fc9d54..0000000 --- a/app/components/OfflineNavidromeContext.tsx +++ /dev/null @@ -1,367 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; -import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome'; -import { useNavidrome } from '@/app/components/NavidromeContext'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; - -interface OfflineNavidromeContextType { - // Data (offline-first) - albums: Album[]; - artists: Artist[]; - playlists: Playlist[]; - - // Loading states - isLoading: boolean; - albumsLoading: boolean; - artistsLoading: boolean; - playlistsLoading: boolean; - - // Connection state - isOnline: boolean; - isOfflineReady: boolean; - - // Error states - error: string | null; - - // Offline sync status - isSyncing: boolean; - lastSync: Date | null; - pendingOperations: number; - - // Methods (offline-aware) - searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>; - getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>; - getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>; - getPlaylists: () => Promise; - refreshData: () => Promise; - - // Offline-capable operations - starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; - unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; - createPlaylist: (name: string, songIds?: string[]) => Promise; - scrobble: (songId: string) => Promise; - - // Sync management - syncLibrary: () => Promise; - syncPendingOperations: () => Promise; - clearOfflineData: () => Promise; -} - -const OfflineNavidromeContext = createContext(undefined); - -interface OfflineNavidromeProviderProps { - children: ReactNode; -} - -export const OfflineNavidromeProvider: React.FC = ({ children }) => { - const [albums, setAlbums] = useState([]); - const [artists, setArtists] = useState([]); - const [playlists, setPlaylists] = useState([]); - - const [albumsLoading, setAlbumsLoading] = useState(false); - const [artistsLoading, setArtistsLoading] = useState(false); - const [playlistsLoading, setPlaylistsLoading] = useState(false); - - const [error, setError] = useState(null); - - // Use the original Navidrome context for online operations - const originalNavidrome = useNavidrome(); - - // Use offline library for offline operations - const { - isInitialized: isOfflineReady, - isOnline, - isSyncing, - lastSync, - stats, - syncLibraryFromServer, - syncPendingOperations: syncPendingOps, - getAlbums: getAlbumsOffline, - getArtists: getArtistsOffline, - getAlbum: getAlbumOffline, - getPlaylists: getPlaylistsOffline, - searchOffline, - starOffline, - unstarOffline, - createPlaylistOffline, - scrobbleOffline, - clearOfflineData: clearOfflineDataInternal, - refreshStats - } = useOfflineLibrary(); - - const isLoading = albumsLoading || artistsLoading || playlistsLoading; - const pendingOperations = stats.pendingOperations; - - // Load initial data (offline-first approach) - const loadAlbums = useCallback(async () => { - setAlbumsLoading(true); - setError(null); - - try { - const albumData = await getAlbumsOffline(); - setAlbums(albumData); - } catch (err) { - console.error('Failed to load albums:', err); - setError('Failed to load albums'); - } finally { - setAlbumsLoading(false); - } - }, [getAlbumsOffline]); - - const loadArtists = useCallback(async () => { - setArtistsLoading(true); - setError(null); - - try { - const artistData = await getArtistsOffline(); - setArtists(artistData); - } catch (err) { - console.error('Failed to load artists:', err); - setError('Failed to load artists'); - } finally { - setArtistsLoading(false); - } - }, [getArtistsOffline]); - - const loadPlaylists = useCallback(async () => { - setPlaylistsLoading(true); - setError(null); - - try { - const playlistData = await getPlaylistsOffline(); - setPlaylists(playlistData); - } catch (err) { - console.error('Failed to load playlists:', err); - setError('Failed to load playlists'); - } finally { - setPlaylistsLoading(false); - } - }, [getPlaylistsOffline]); - - const refreshData = useCallback(async () => { - await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]); - await refreshStats(); - }, [loadAlbums, loadArtists, loadPlaylists, refreshStats]); - - // Initialize data when offline library is ready - useEffect(() => { - if (isOfflineReady) { - refreshData(); - } - }, [isOfflineReady, refreshData]); - - // Auto-sync when coming back online - useEffect(() => { - if (isOnline && isOfflineReady && pendingOperations > 0) { - console.log('Back online with pending operations, starting sync...'); - syncPendingOps(); - } - }, [isOnline, isOfflineReady, pendingOperations, syncPendingOps]); - - // Offline-first methods - const searchMusic = useCallback(async (query: string) => { - setError(null); - try { - return await searchOffline(query); - } catch (err) { - console.error('Search failed:', err); - setError('Search failed'); - return { artists: [], albums: [], songs: [] }; - } - }, [searchOffline]); - - const getAlbum = useCallback(async (albumId: string) => { - setError(null); - try { - return await getAlbumOffline(albumId); - } catch (err) { - console.error('Failed to get album:', err); - setError('Failed to get album'); - return null; - } - }, [getAlbumOffline]); - - const getArtist = useCallback(async (artistId: string): Promise<{ artist: Artist; albums: Album[] } | null> => { - setError(null); - try { - // For now, use the original implementation if online, or search offline - if (isOnline && originalNavidrome.api) { - return await originalNavidrome.getArtist(artistId); - } else { - // Try to find artist in offline data - const allArtists = await getArtistsOffline(); - const artist = allArtists.find(a => a.id === artistId); - if (!artist) return null; - - const allAlbums = await getAlbumsOffline(); - const artistAlbums = allAlbums.filter(a => a.artistId === artistId); - - return { artist, albums: artistAlbums }; - } - } catch (err) { - console.error('Failed to get artist:', err); - setError('Failed to get artist'); - return null; - } - }, [isOnline, originalNavidrome, getArtistsOffline, getAlbumsOffline]); - - const getPlaylistsWrapper = useCallback(async (): Promise => { - try { - return await getPlaylistsOffline(); - } catch (err) { - console.error('Failed to get playlists:', err); - return []; - } - }, [getPlaylistsOffline]); - - // Offline-capable operations - const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { - setError(null); - try { - await starOffline(id, type); - // Refresh relevant data - if (type === 'album') { - await loadAlbums(); - } else if (type === 'artist') { - await loadArtists(); - } - } catch (err) { - console.error('Failed to star item:', err); - setError('Failed to star item'); - throw err; - } - }, [starOffline, loadAlbums, loadArtists]); - - const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { - setError(null); - try { - await unstarOffline(id, type); - // Refresh relevant data - if (type === 'album') { - await loadAlbums(); - } else if (type === 'artist') { - await loadArtists(); - } - } catch (err) { - console.error('Failed to unstar item:', err); - setError('Failed to unstar item'); - throw err; - } - }, [unstarOffline, loadAlbums, loadArtists]); - - const createPlaylist = useCallback(async (name: string, songIds?: string[]): Promise => { - setError(null); - try { - const playlist = await createPlaylistOffline(name, songIds); - await loadPlaylists(); // Refresh playlists - return playlist; - } catch (err) { - console.error('Failed to create playlist:', err); - setError('Failed to create playlist'); - throw err; - } - }, [createPlaylistOffline, loadPlaylists]); - - const scrobble = useCallback(async (songId: string) => { - try { - await scrobbleOffline(songId); - } catch (err) { - console.error('Failed to scrobble:', err); - // Don't set error state for scrobbling failures as they're not critical - } - }, [scrobbleOffline]); - - // Sync management - const syncLibrary = useCallback(async () => { - setError(null); - try { - await syncLibraryFromServer(); - await refreshData(); // Refresh local state after sync - } catch (err) { - console.error('Library sync failed:', err); - setError('Library sync failed'); - throw err; - } - }, [syncLibraryFromServer, refreshData]); - - const syncPendingOperations = useCallback(async () => { - try { - await syncPendingOps(); - await refreshStats(); - } catch (err) { - console.error('Failed to sync pending operations:', err); - // Don't throw or set error for pending operations sync - } - }, [syncPendingOps, refreshStats]); - - const clearOfflineData = useCallback(async () => { - try { - await clearOfflineDataInternal(); - setAlbums([]); - setArtists([]); - setPlaylists([]); - } catch (err) { - console.error('Failed to clear offline data:', err); - setError('Failed to clear offline data'); - throw err; - } - }, [clearOfflineDataInternal]); - - const value: OfflineNavidromeContextType = { - // Data - albums, - artists, - playlists, - - // Loading states - isLoading, - albumsLoading, - artistsLoading, - playlistsLoading, - - // Connection state - isOnline, - isOfflineReady, - - // Error state - error, - - // Offline sync status - isSyncing, - lastSync, - pendingOperations, - - // Methods - searchMusic, - getAlbum, - getArtist, - getPlaylists: getPlaylistsWrapper, - refreshData, - - // Offline-capable operations - starItem, - unstarItem, - createPlaylist, - scrobble, - - // Sync management - syncLibrary, - syncPendingOperations, - clearOfflineData - }; - - return ( - - {children} - - ); -}; - -export const useOfflineNavidrome = (): OfflineNavidromeContextType => { - const context = useContext(OfflineNavidromeContext); - if (context === undefined) { - throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider'); - } - return context; -}; diff --git a/app/components/OfflineNavidromeProvider.tsx b/app/components/OfflineNavidromeProvider.tsx deleted file mode 100644 index b1abd49..0000000 --- a/app/components/OfflineNavidromeProvider.tsx +++ /dev/null @@ -1,281 +0,0 @@ -'use client'; - -import React, { createContext, useContext, ReactNode } from 'react'; -import { Album, Artist, Song, Playlist } from '@/lib/navidrome'; -import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; - -interface OfflineNavidromeContextType { - // All the original NavidromeContext methods but with offline-first behavior - getAlbums: (starred?: boolean) => Promise; - getArtists: (starred?: boolean) => Promise; - getSongs: (albumId?: string, artistId?: string) => Promise; - getPlaylists: () => Promise; - - // Offline-aware operations - starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; - unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; - createPlaylist: (name: string, songIds?: string[]) => Promise; - updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise; - deletePlaylist: (id: string) => Promise; - scrobble: (songId: string) => Promise; - - // Offline state - isOfflineMode: boolean; - hasPendingOperations: boolean; - lastSync: Date | null; -} - -const OfflineNavidromeContext = createContext(undefined); - -interface OfflineNavidromeProviderInnerProps { - children: ReactNode; -} - -// Inner component that has access to both contexts -const OfflineNavidromeProviderInner: React.FC = ({ children }) => { - const navidromeContext = useNavidrome(); - const offlineLibrary = useOfflineLibrary(); - - // Offline-first data retrieval methods - const getAlbums = async (starred?: boolean): Promise => { - if (!offlineLibrary.isOnline || !navidromeContext.api) { - // Offline mode - get from IndexedDB - return await offlineLibrary.getAlbums(starred); - } - - try { - // Online mode - try server first, fallback to offline - const albums = starred - ? await navidromeContext.api.getAlbums('starred', 1000) - : await navidromeContext.api.getAlbums('alphabeticalByName', 1000); - return albums; - } catch (error) { - console.warn('Server request failed, falling back to offline data:', error); - return await offlineLibrary.getAlbums(starred); - } - }; - - const getArtists = async (starred?: boolean): Promise => { - if (!offlineLibrary.isOnline || !navidromeContext.api) { - return await offlineLibrary.getArtists(starred); - } - - try { - const artists = await navidromeContext.api.getArtists(); - if (starred) { - // Filter starred artists from the full list - const starredData = await navidromeContext.api.getStarred2(); - const starredArtistIds = new Set(starredData.starred2.artist?.map(a => a.id) || []); - return artists.filter(artist => starredArtistIds.has(artist.id)); - } - return artists; - } catch (error) { - console.warn('Server request failed, falling back to offline data:', error); - return await offlineLibrary.getArtists(starred); - } - }; - - const getSongs = async (albumId?: string, artistId?: string): Promise => { - if (!offlineLibrary.isOnline || !navidromeContext.api) { - return await offlineLibrary.getSongs(albumId, artistId); - } - - try { - if (albumId) { - const { songs } = await navidromeContext.api.getAlbum(albumId); - return songs; - } else if (artistId) { - const { albums } = await navidromeContext.api.getArtist(artistId); - const allSongs: Song[] = []; - for (const album of albums) { - const { songs } = await navidromeContext.api.getAlbum(album.id); - allSongs.push(...songs); - } - return allSongs; - } else { - return await navidromeContext.getAllSongs(); - } - } catch (error) { - console.warn('Server request failed, falling back to offline data:', error); - return await offlineLibrary.getSongs(albumId, artistId); - } - }; - - const getPlaylists = async (): Promise => { - if (!offlineLibrary.isOnline || !navidromeContext.api) { - return await offlineLibrary.getPlaylists(); - } - - try { - return await navidromeContext.api.getPlaylists(); - } catch (error) { - console.warn('Server request failed, falling back to offline data:', error); - return await offlineLibrary.getPlaylists(); - } - }; - - // Offline-aware operations (queue for sync when offline) - const starItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise => { - if (offlineLibrary.isOnline && navidromeContext.api) { - try { - await navidromeContext.starItem(id, type); - // Update offline data immediately - await offlineLibrary.starOffline(id, type); - return; - } catch (error) { - console.warn('Server star failed, queuing for sync:', error); - } - } - - // Queue for sync when back online - await offlineLibrary.starOffline(id, type); - await offlineLibrary.queueSyncOperation({ - type: 'star', - entityType: type, - entityId: id, - data: {} - }); - }; - - const unstarItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise => { - if (offlineLibrary.isOnline && navidromeContext.api) { - try { - await navidromeContext.unstarItem(id, type); - await offlineLibrary.unstarOffline(id, type); - return; - } catch (error) { - console.warn('Server unstar failed, queuing for sync:', error); - } - } - - await offlineLibrary.unstarOffline(id, type); - await offlineLibrary.queueSyncOperation({ - type: 'unstar', - entityType: type, - entityId: id, - data: {} - }); - }; - - const createPlaylist = async (name: string, songIds?: string[]): Promise => { - if (offlineLibrary.isOnline && navidromeContext.api) { - try { - const playlist = await navidromeContext.createPlaylist(name, songIds); - await offlineLibrary.createPlaylistOffline(name, songIds || []); - return; - } catch (error) { - console.warn('Server playlist creation failed, queuing for sync:', error); - } - } - - // Create offline - await offlineLibrary.createPlaylistOffline(name, songIds || []); - await offlineLibrary.queueSyncOperation({ - type: 'create_playlist', - entityType: 'playlist', - entityId: 'temp-' + Date.now(), - data: { name, songIds: songIds || [] } - }); - }; - - const updatePlaylist = async (id: string, name?: string, comment?: string, songIds?: string[]): Promise => { - if (offlineLibrary.isOnline && navidromeContext.api) { - try { - await navidromeContext.updatePlaylist(id, name, comment, songIds); - await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds); - return; - } catch (error) { - console.warn('Server playlist update failed, queuing for sync:', error); - } - } - - await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds); - await offlineLibrary.queueSyncOperation({ - type: 'update_playlist', - entityType: 'playlist', - entityId: id, - data: { name, comment, songIds } - }); - }; - - const deletePlaylist = async (id: string): Promise => { - if (offlineLibrary.isOnline && navidromeContext.api) { - try { - await navidromeContext.deletePlaylist(id); - await offlineLibrary.deletePlaylistOffline(id); - return; - } catch (error) { - console.warn('Server playlist deletion failed, queuing for sync:', error); - } - } - - await offlineLibrary.deletePlaylistOffline(id); - await offlineLibrary.queueSyncOperation({ - type: 'delete_playlist', - entityType: 'playlist', - entityId: id, - data: {} - }); - }; - - const scrobble = async (songId: string): Promise => { - if (offlineLibrary.isOnline && navidromeContext.api) { - try { - await navidromeContext.scrobble(songId); - return; - } catch (error) { - console.warn('Server scrobble failed, queuing for sync:', error); - } - } - - await offlineLibrary.queueSyncOperation({ - type: 'scrobble', - entityType: 'song', - entityId: songId, - data: { timestamp: Date.now() } - }); - }; - - const contextValue: OfflineNavidromeContextType = { - getAlbums, - getArtists, - getSongs, - getPlaylists, - starItem, - unstarItem, - createPlaylist, - updatePlaylist, - deletePlaylist, - scrobble, - isOfflineMode: !offlineLibrary.isOnline, - hasPendingOperations: offlineLibrary.stats.pendingOperations > 0, - lastSync: offlineLibrary.lastSync - }; - - return ( - - {children} - - ); -}; - -// Main provider component -export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - return ( - - - {children} - - - ); -}; - -// Hook to use the offline-aware Navidrome context -export const useOfflineNavidrome = (): OfflineNavidromeContextType => { - const context = useContext(OfflineNavidromeContext); - if (!context) { - throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider'); - } - return context; -}; diff --git a/app/components/OfflineStatusIndicator.tsx b/app/components/OfflineStatusIndicator.tsx deleted file mode 100644 index f739db8..0000000 --- a/app/components/OfflineStatusIndicator.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import React from 'react'; -import { Badge } from '@/components/ui/badge'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; -import { Wifi, WifiOff, Download, Clock } from 'lucide-react'; - -export function OfflineStatusIndicator() { - const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary(); - - if (!isOnline) { - return ( - - - Offline Mode - - ); - } - - if (isSyncing) { - return ( - - - Syncing... - - ); - } - - if (stats.pendingOperations > 0) { - return ( - - - {stats.pendingOperations} pending - - ); - } - - return ( - - - Online - - ); -} - -export function OfflineLibraryStats() { - const { stats, lastSync } = useOfflineLibrary(); - - if (!stats.albums && !stats.songs && !stats.artists) { - return null; - } - - return ( -
-
- 📀 {stats.albums} albums • 🎵 {stats.songs} songs • 👤 {stats.artists} artists -
- {lastSync && ( -
- Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()} -
- )} -
- ); -} diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index 1383d89..de8ed33 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import { AudioPlayerProvider } from "../components/AudioPlayerContext"; -import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider"; +import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext"; import { NavidromeConfigProvider } from "../components/NavidromeConfigContext"; import { ThemeProvider } from "../components/ThemeProvider"; import { WhatsNewPopup } from "../components/WhatsNewPopup"; @@ -105,7 +105,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo - + @@ -116,7 +116,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo - + ); diff --git a/app/components/SongRecommendations.tsx b/app/components/SongRecommendations.tsx index 9d85bca..067fafb 100644 --- a/app/components/SongRecommendations.tsx +++ b/app/components/SongRecommendations.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Song, Album, getNavidromeAPI } from '@/lib/navidrome'; -import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider'; +import { useNavidrome } from '@/app/components/NavidromeContext'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useIsMobile } from '@/hooks/use-mobile'; import { Button } from '@/components/ui/button'; @@ -17,7 +17,7 @@ interface SongRecommendationsProps { } export function SongRecommendations({ userName }: SongRecommendationsProps) { - const offline = useOfflineNavidrome(); + const { api } = useNavidrome(); const { playTrack, shuffle, toggleShuffle } = useAudioPlayer(); const isMobile = useIsMobile(); const [recommendedSongs, setRecommendedSongs] = useState([]); @@ -45,10 +45,9 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { setLoading(true); try { const api = getNavidromeAPI(); - const isOnline = !offline.isOfflineMode && !!api; - if (isOnline && api) { - // Online: use server-side recommendations + if (api) { + // Use server-side recommendations const randomAlbums = await api.getAlbums('random', 10); if (isMobile) { setRecommendedAlbums(randomAlbums.slice(0, 6)); @@ -69,29 +68,6 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; }); setSongStates(states); } - } else { - // Offline: use cached library - const albums = await offline.getAlbums(false); - const shuffledAlbums = [...(albums || [])].sort(() => Math.random() - 0.5); - if (isMobile) { - setRecommendedAlbums(shuffledAlbums.slice(0, 6)); - } else { - const pick = shuffledAlbums.slice(0, 3); - const allSongs: Song[] = []; - for (const a of pick) { - try { - const songs = await offline.getSongs(a.id); - allSongs.push(...songs); - } catch (e) { - // ignore per-album errors - } - } - const recommendations = allSongs.sort(() => Math.random() - 0.5).slice(0, 6); - setRecommendedSongs(recommendations); - const states: Record = {}; - recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; }); - setSongStates(states); - } } } catch (error) { console.error('Failed to load recommendations:', error); @@ -103,13 +79,15 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { }; loadRecommendations(); - }, [offline, isMobile]); + }, [isMobile]); const handlePlaySong = async (song: Song) => { try { const api = getNavidromeAPI(); - const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`; - const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : undefined; + if (!api) return; + + const url = api.getStreamUrl(song.id); + const coverArt = song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined; const track = { id: song.id, name: song.title, @@ -131,16 +109,13 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { const handlePlayAlbum = async (album: Album) => { try { const api = getNavidromeAPI(); - let albumSongs: Song[] = []; - if (api) { - albumSongs = await api.getAlbumSongs(album.id); - } else { - albumSongs = await offline.getSongs(album.id); - } + if (!api) return; + + const albumSongs = await api.getAlbumSongs(album.id); if (albumSongs.length > 0) { const first = albumSongs[0]; - const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`; - const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 300) : undefined; + const url = api.getStreamUrl(first.id); + const coverArt = first.coverArt ? api.getCoverArtUrl(first.coverArt, 300) : undefined; const track = { id: first.id, name: first.title, @@ -246,7 +221,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { className="group cursor-pointer block" >
- {album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? ( + {album.coverArt && getNavidromeAPI() ? ( {album.name}
- {song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? ( + {song.coverArt && getNavidromeAPI() ? ( <> , @@ -49,7 +47,6 @@ export function AlbumArtwork({ ...props }: AlbumArtworkProps) { const { api, isConnected } = useNavidrome(); - const offline = useOfflineNavidrome(); const router = useRouter(); const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer(); const { playlists, starItem, unstarItem } = useNavidrome(); @@ -153,7 +150,7 @@ export function AlbumArtwork({ handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
- {album.coverArt && api && !offline.isOfflineMode ? ( + {album.coverArt && api ? ( {album.name} handlePlayAlbum(album)}/>
- - {/* Offline indicator in top-right corner */} -
- -

diff --git a/app/page.tsx b/app/page.tsx index 68935eb..7647683 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,7 +4,7 @@ import { ScrollArea, ScrollBar } from '../components/ui/scroll-area'; import { Separator } from '../components/ui/separator'; import { Tabs, TabsContent } from '../components/ui/tabs'; import { AlbumArtwork } from './components/album-artwork'; -import { useOfflineNavidrome } from './components/OfflineNavidromeProvider'; +import { useNavidrome } from './components/NavidromeContext'; import { useEffect, useState, Suspense } from 'react'; import { Album, Song, getNavidromeAPI } from '@/lib/navidrome'; import { useNavidromeConfig } from './components/NavidromeConfigContext'; @@ -14,14 +14,12 @@ import { SongRecommendations } from './components/SongRecommendations'; import { Skeleton } from '@/components/ui/skeleton'; import { useIsMobile } from '@/hooks/use-mobile'; import { UserProfile } from './components/UserProfile'; -import { OfflineStatusIndicator } from './components/OfflineStatusIndicator'; import CompactListeningStreak from './components/CompactListeningStreak'; type TimeOfDay = 'morning' | 'afternoon' | 'evening'; function MusicPageContent() { - // Offline-first provider (falls back to offline data when not connected) - const offline = useOfflineNavidrome(); + const { api } = useNavidrome(); const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer(); const searchParams = useSearchParams(); const [allAlbums, setAllAlbums] = useState([]); @@ -33,13 +31,14 @@ function MusicPageContent() { const [shortcutProcessed, setShortcutProcessed] = useState(false); const isMobile = useIsMobile(); - // Load albums (offline-first) + // Load albums useEffect(() => { let mounted = true; const load = async () => { + if (!api) return; setAlbumsLoading(true); try { - const list = await offline.getAlbums(false); + const list = await api.getAlbums('newest', 500); if (!mounted) return; setAllAlbums(list || []); // Split albums into two sections @@ -48,7 +47,7 @@ function MusicPageContent() { setRecentAlbums(recent); setNewestAlbums(newest); } catch (e) { - console.error('Failed to load albums (offline-first):', e); + console.error('Failed to load albums:', e); if (mounted) { setAllAlbums([]); setRecentAlbums([]); @@ -60,17 +59,18 @@ function MusicPageContent() { }; load(); return () => { mounted = false; }; - }, [offline]); + }, [api]); useEffect(() => { let mounted = true; const loadFavoriteAlbums = async () => { + if (!api) return; setFavoritesLoading(true); try { - const starred = await offline.getAlbums(true); - if (mounted) setFavoriteAlbums((starred || []).slice(0, 20)); + const starred = await api.getAlbums('starred', 20); + if (mounted) setFavoriteAlbums(starred || []); } catch (error) { - console.error('Failed to load favorite albums (offline-first):', error); + console.error('Failed to load favorite albums:', error); if (mounted) setFavoriteAlbums([]); } finally { if (mounted) setFavoritesLoading(false); @@ -78,7 +78,7 @@ function MusicPageContent() { }; loadFavoriteAlbums(); return () => { mounted = false; }; - }, [offline]); + }, [api]); // Handle PWA shortcuts useEffect(() => { @@ -115,29 +115,31 @@ function MusicPageContent() { await playAlbum(shuffledAlbums[0].id); // Add remaining albums to queue - for (let i = 1; i < shuffledAlbums.length; i++) { - try { - const songs = await offline.getSongs(shuffledAlbums[i].id); - const api = getNavidromeAPI(); - songs.forEach((song: Song) => { - addToQueue({ - id: song.id, - name: song.title, - url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`, - artist: song.artist || 'Unknown Artist', - artistId: song.artistId || '', - album: song.album || 'Unknown Album', - albumId: song.parent, + const navidromeApi = getNavidromeAPI(); + if (navidromeApi) { + for (let i = 1; i < shuffledAlbums.length; i++) { + try { + const songs = await navidromeApi.getAlbumSongs(shuffledAlbums[i].id); + songs.forEach((song: Song) => { + addToQueue({ + id: song.id, + name: song.title, + url: navidromeApi.getStreamUrl(song.id), + artist: song.artist || 'Unknown Artist', + artistId: song.artistId || '', + album: song.album || 'Unknown Album', + albumId: song.parent, duration: song.duration || 0, coverArt: song.coverArt, starred: !!song.starred }); }); } catch (error) { - console.error('Failed to load album tracks (offline-first):', error); + console.error('Failed to load album tracks:', error); } } } + } break; case 'shuffle-favorites': @@ -154,29 +156,31 @@ function MusicPageContent() { await playAlbum(shuffledFavorites[0].id); // Add remaining albums to queue - for (let i = 1; i < shuffledFavorites.length; i++) { - try { - const songs = await offline.getSongs(shuffledFavorites[i].id); - const api = getNavidromeAPI(); - songs.forEach((song: Song) => { - addToQueue({ - id: song.id, - name: song.title, - url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`, - artist: song.artist || 'Unknown Artist', - artistId: song.artistId || '', - album: song.album || 'Unknown Album', - albumId: song.parent, + const navidromeApiFav = getNavidromeAPI(); + if (navidromeApiFav) { + for (let i = 1; i < shuffledFavorites.length; i++) { + try { + const songs = await navidromeApiFav.getAlbumSongs(shuffledFavorites[i].id); + songs.forEach((song: Song) => { + addToQueue({ + id: song.id, + name: song.title, + url: navidromeApiFav.getStreamUrl(song.id), + artist: song.artist || 'Unknown Artist', + artistId: song.artistId || '', + album: song.album || 'Unknown Album', + albumId: song.parent, duration: song.duration || 0, coverArt: song.coverArt, starred: !!song.starred }); }); } catch (error) { - console.error('Failed to load album tracks (offline-first):', error); + console.error('Failed to load album tracks:', error); } } } + } break; } setShortcutProcessed(true); @@ -188,7 +192,7 @@ function MusicPageContent() { // Delay to ensure data is loaded const timeout = setTimeout(handleShortcuts, 1000); return () => clearTimeout(timeout); - }, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]); + }, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]); // Try to get user name from navidrome context, fallback to 'user' let userName = ''; @@ -202,19 +206,7 @@ function MusicPageContent() { return (
{/* Connection status (offline indicator) */} - {!offline.isOfflineMode ? null : ( -
- -
- )} - {/* Offline empty state when nothing is cached */} - {offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && ( -
-

- You are offline and no albums are cached yet. Download albums for offline use from an album page, or open Settings → Offline Library to sync your library. -

-
- )} + {/* Song Recommendations Section */}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 5cdbede..d7d45f6 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -14,7 +14,6 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm'; import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts'; import { SidebarCustomization } from '@/app/components/SidebarCustomization'; import { SettingsManagement } from '@/app/components/SettingsManagement'; -import EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager'; import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings'; import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa'; import { Settings, ExternalLink, Tag } from 'lucide-react'; @@ -778,11 +777,6 @@ const SettingsPage = () => {
- {/* Offline Library Management */} -
- -
- {/* Auto-Tagging Settings */}
diff --git a/components/ui/resizable.tsx b/components/ui/resizable.tsx index 12bbd0b..7ceed5c 100644 --- a/components/ui/resizable.tsx +++ b/components/ui/resizable.tsx @@ -2,16 +2,16 @@ import * as React from "react" import { GripVerticalIcon } from "lucide-react" -import * as ResizablePrimitive from "react-resizable-panels" +import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels" import { cn } from "@/lib/utils" function ResizablePanelGroup({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - ) { - return +}: React.ComponentProps) { + return } function ResizableHandle({ withHandle, className, ...props -}: React.ComponentProps & { +}: React.ComponentProps & { withHandle?: boolean }) { return ( - div]:rotate-90", @@ -49,7 +49,7 @@ function ResizableHandle({
)} - + ) } diff --git a/hooks/use-offline-audio-player.ts b/hooks/use-offline-audio-player.ts deleted file mode 100644 index fae138d..0000000 --- a/hooks/use-offline-audio-player.ts +++ /dev/null @@ -1,281 +0,0 @@ -'use client'; - -import { useCallback } from 'react'; -import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext'; -import { useOfflineDownloads } from '@/hooks/use-offline-downloads'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; -import { Album, Song } from '@/lib/navidrome'; -import { getNavidromeAPI } from '@/lib/navidrome'; - -export interface OfflineTrack extends Track { - isOffline?: boolean; - offlineUrl?: string; -} - -export function useOfflineAudioPlayer() { - const { - playTrack, - addToQueue, - currentTrack, - ...audioPlayerProps - } = useAudioPlayer(); - - const { isSupported: isOfflineSupported, checkOfflineStatus } = useOfflineDownloads(); - const { isOnline, scrobbleOffline } = useOfflineLibrary(); - - const api = getNavidromeAPI(); - - // Convert song to track with offline awareness - const songToTrack = useCallback(async (song: Song): Promise => { - let track: OfflineTrack = { - id: song.id, - name: song.title, - url: api?.getStreamUrl(song.id) || '', - artist: song.artist, - album: song.album || '', - duration: song.duration, - coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined, - albumId: song.albumId, - artistId: song.artistId, - starred: !!song.starred - }; - - // Check if song is available offline - if (isOfflineSupported) { - const offlineStatus = await checkOfflineStatus(song.id, 'song'); - if (offlineStatus) { - track.isOffline = true; - track.offlineUrl = `offline-song-${song.id}`; - // Prefer offline cached URL to avoid re-streaming even when online - track.url = track.offlineUrl; - } - } - - return track; - }, [api, isOfflineSupported, checkOfflineStatus]); - - // Play track with offline fallback - const playTrackOffline = useCallback(async (song: Song | OfflineTrack) => { - try { - let track: OfflineTrack; - - if ('url' in song) { - // Already a track - track = song as OfflineTrack; - } else { - // Convert song to track - track = await songToTrack(song); - } - - // If offline and track has offline URL, use that - if (!isOnline && track.isOffline && track.offlineUrl) { - track.url = track.offlineUrl; - } - - playTrack(track); - - // Scrobble with offline support - scrobbleOffline(track.id); - - } catch (error) { - console.error('Failed to play track:', error); - throw error; - } - }, [songToTrack, playTrack, scrobbleOffline, isOnline]); - - // Play album with offline awareness - const playAlbumOffline = useCallback(async (album: Album, songs: Song[], startIndex: number = 0) => { - try { - if (songs.length === 0) return; - - // Convert all songs to tracks with offline awareness - const tracks = await Promise.all(songs.map(songToTrack)); - - // Filter to only available tracks (online or offline) - const availableTracks = tracks.filter((track: OfflineTrack) => { - if (isOnline) return true; // All tracks available when online - return track.isOffline; // Only offline tracks when offline - }); - - if (availableTracks.length === 0) { - throw new Error('No tracks available for playback'); - } - - // Adjust start index if needed - const safeStartIndex = Math.min(startIndex, availableTracks.length - 1); - - // Play first track - playTrack(availableTracks[safeStartIndex]); - - // Add remaining tracks to queue - const remainingTracks = [ - ...availableTracks.slice(safeStartIndex + 1), - ...availableTracks.slice(0, safeStartIndex) - ]; - - remainingTracks.forEach(track => addToQueue(track)); - - // Scrobble first track - scrobbleOffline(availableTracks[safeStartIndex].id); - - } catch (error) { - console.error('Failed to play album offline:', error); - throw error; - } - }, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]); - - // Add track to queue with offline awareness - const addToQueueOffline = useCallback(async (song: Song | OfflineTrack) => { - try { - let track: OfflineTrack; - - if ('url' in song) { - track = song as OfflineTrack; - } else { - track = await songToTrack(song); - } - - // Check if track is available - if (!isOnline && !track.isOffline) { - throw new Error('Track not available offline'); - } - - // If offline and track has offline URL, use that - if (!isOnline && track.isOffline && track.offlineUrl) { - track.url = track.offlineUrl; - } - - addToQueue(track); - - } catch (error) { - console.error('Failed to add track to queue:', error); - throw error; - } - }, [songToTrack, addToQueue, isOnline]); - - // Shuffle play with offline awareness - const shufflePlayOffline = useCallback(async (songs: Song[]) => { - try { - if (songs.length === 0) return; - - // Convert all songs to tracks - const tracks = await Promise.all(songs.map(songToTrack)); - - // Filter available tracks - const availableTracks = tracks.filter((track: OfflineTrack) => { - if (isOnline) return true; - return track.isOffline; - }); - - if (availableTracks.length === 0) { - throw new Error('No tracks available for shuffle play'); - } - - // Shuffle the available tracks - const shuffledTracks = [...availableTracks].sort(() => Math.random() - 0.5); - - // Play first track - playTrack(shuffledTracks[0]); - - // Add remaining tracks to queue - shuffledTracks.slice(1).forEach(track => addToQueue(track)); - - // Scrobble first track - scrobbleOffline(shuffledTracks[0].id); - - } catch (error) { - console.error('Failed to shuffle play offline:', error); - throw error; - } - }, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]); - - // Get availability info for a song - const getTrackAvailability = useCallback(async (song: Song): Promise<{ - isAvailable: boolean; - isOffline: boolean; - requiresConnection: boolean; - }> => { - try { - const track = await songToTrack(song); - - return { - isAvailable: isOnline || !!track.isOffline, - isOffline: !!track.isOffline, - requiresConnection: !track.isOffline - }; - } catch (error) { - console.error('Failed to check track availability:', error); - return { - isAvailable: false, - isOffline: false, - requiresConnection: true - }; - } - }, [songToTrack, isOnline]); - - // Get album availability info - const getAlbumAvailability = useCallback(async (songs: Song[]): Promise<{ - totalTracks: number; - availableTracks: number; - offlineTracks: number; - onlineOnlyTracks: number; - }> => { - try { - const tracks = await Promise.all(songs.map(songToTrack)); - - const offlineTracks = tracks.filter((t: OfflineTrack) => t.isOffline).length; - const onlineOnlyTracks = tracks.filter((t: OfflineTrack) => !t.isOffline).length; - const availableTracks = isOnline ? tracks.length : offlineTracks; - - return { - totalTracks: tracks.length, - availableTracks, - offlineTracks, - onlineOnlyTracks - }; - } catch (error) { - console.error('Failed to check album availability:', error); - return { - totalTracks: songs.length, - availableTracks: 0, - offlineTracks: 0, - onlineOnlyTracks: songs.length - }; - } - }, [songToTrack, isOnline]); - - // Enhanced track info with offline status - const getCurrentTrackInfo = useCallback(() => { - if (!currentTrack) return null; - - const offlineTrack = currentTrack as OfflineTrack; - - return { - ...currentTrack, - isAvailableOffline: offlineTrack.isOffline || false, - isPlayingOffline: !isOnline && !!offlineTrack.isOffline - }; - }, [currentTrack, isOnline]); - - return { - // Original audio player props - ...audioPlayerProps, - currentTrack, - - // Enhanced offline methods - playTrackOffline, - playAlbumOffline, - addToQueueOffline, - shufflePlayOffline, - - // Utility methods - songToTrack, - getTrackAvailability, - getAlbumAvailability, - getCurrentTrackInfo, - - // State - isOnline, - isOfflineSupported - }; -} diff --git a/hooks/use-offline-downloads.ts b/hooks/use-offline-downloads.ts deleted file mode 100644 index 644c9df..0000000 --- a/hooks/use-offline-downloads.ts +++ /dev/null @@ -1,682 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { Album, Song, getNavidromeAPI } from '@/lib/navidrome'; - -export interface DownloadProgress { - completed: number; - total: number; - failed: number; - status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error' | 'paused'; - currentSong?: string; - currentArtist?: string; - currentAlbum?: string; - error?: string; - downloadSpeed?: number; // In bytes per second - timeRemaining?: number; // In seconds - percentComplete?: number; // 0-100 -} - -export interface OfflineItem { - id: string; - type: 'album' | 'song'; - name: string; - artist: string; - downloadedAt: number; - size?: number; - bitRate?: number; - duration?: number; - format?: string; - lastPlayed?: number; -} - -export interface OfflineStats { - totalSize: number; - audioSize: number; - imageSize: number; - metaSize: number; - downloadedAlbums: number; - downloadedSongs: number; - lastDownload: number | null; - downloadErrors: number; - remainingStorage: number | null; - autoDownloadEnabled: boolean; - downloadQuality: 'original' | 'high' | 'medium' | 'low'; - downloadOnWifiOnly: boolean; - priorityContent: string[]; // IDs of albums or playlists that should always be available offline -} - -class DownloadManager { - private worker: ServiceWorker | null = null; - private messageChannel: MessageChannel | null = null; - - async initialize(): Promise { - if ('serviceWorker' in navigator) { - try { - const registration = await navigator.serviceWorker.register('/sw.js'); - console.log('Service Worker registered:', registration); - - // Wait for the service worker to be ready - const readyRegistration = await navigator.serviceWorker.ready; - this.worker = readyRegistration.active; - - return true; - } catch (error) { - console.error('Service Worker registration failed:', error); - return false; - } - } - return false; - } - - private async sendMessage(type: string, data: any): Promise { - if (!this.worker) { - throw new Error('Service Worker not available'); - } - - return new Promise((resolve, reject) => { - const channel = new MessageChannel(); - - channel.port1.onmessage = (event) => { - const { type: responseType, data: responseData } = event.data; - if (responseType.includes('ERROR')) { - reject(new Error(responseData.error)); - } else { - resolve(responseData); - } - }; - - this.worker!.postMessage({ type, data }, [channel.port2]); - }); - } - - async downloadAlbum( - album: Album, - songs: Song[], - onProgress?: (progress: DownloadProgress) => void - ): Promise { - if (!this.worker) { - throw new Error('Service Worker not available'); - } - - return new Promise((resolve, reject) => { - const channel = new MessageChannel(); - - channel.port1.onmessage = (event) => { - const { type, data } = event.data; - - switch (type) { - case 'DOWNLOAD_PROGRESS': - if (onProgress) { - onProgress(data); - } - break; - case 'DOWNLOAD_COMPLETE': - resolve(); - break; - case 'DOWNLOAD_ERROR': - reject(new Error(data.error)); - break; - } - }; - - // Add direct download URLs to songs (use 'streamUrl' field name to keep SW compatibility) - const songsWithUrls = songs.map(song => ({ - ...song, - streamUrl: this.getDownloadUrl(song.id), - offlineUrl: `offline-song-${song.id}`, - duration: song.duration, - bitRate: song.bitRate, - size: song.size - })); - - this.worker!.postMessage({ - type: 'DOWNLOAD_ALBUM', - data: { album, songs: songsWithUrls } - }, [channel.port2]); - }); - } - - async downloadSong( - song: Song, - options?: { quality?: 'original' | 'high' | 'medium' | 'low', priority?: boolean } - ): Promise { - const songWithUrl = { - ...song, - streamUrl: this.getDownloadUrl(song.id, options?.quality), - offlineUrl: `offline-song-${song.id}`, - duration: song.duration, - bitRate: song.bitRate, - size: song.size, - priority: options?.priority || false, - quality: options?.quality || 'original' - }; - - return this.sendMessage('DOWNLOAD_SONG', songWithUrl); - } - - async downloadQueue( - songs: Song[], - options?: { - quality?: 'original' | 'high' | 'medium' | 'low', - priority?: boolean, - onProgressUpdate?: (progress: DownloadProgress) => void - } - ): Promise { - if (!this.worker) { - throw new Error('Service Worker not available'); - } - - return new Promise((resolve, reject) => { - const channel = new MessageChannel(); - - channel.port1.onmessage = (event) => { - const { type, data } = event.data; - - switch (type) { - case 'DOWNLOAD_PROGRESS': - if (options?.onProgressUpdate) { - options.onProgressUpdate(data); - } - break; - case 'DOWNLOAD_COMPLETE': - resolve(); - break; - case 'DOWNLOAD_ERROR': - reject(new Error(data.error)); - break; - } - }; - - const songsWithUrls = songs.map(song => ({ - ...song, - streamUrl: this.getDownloadUrl(song.id, options?.quality), - offlineUrl: `offline-song-${song.id}`, - duration: song.duration, - bitRate: song.bitRate, - size: song.size, - priority: options?.priority || false, - quality: options?.quality || 'original' - })); - - this.worker!.postMessage({ - type: 'DOWNLOAD_QUEUE', - data: { songs: songsWithUrls } - }, [channel.port2]); - }); - } - - async pauseDownloads(): Promise { - return this.sendMessage('PAUSE_DOWNLOADS', {}); - } - - async resumeDownloads(): Promise { - return this.sendMessage('RESUME_DOWNLOADS', {}); - } - - async cancelDownloads(): Promise { - return this.sendMessage('CANCEL_DOWNLOADS', {}); - } - - async setDownloadPreferences(preferences: { - quality: 'original' | 'high' | 'medium' | 'low', - wifiOnly: boolean, - autoDownloadRecent: boolean, - autoDownloadFavorites: boolean, - maxStoragePercent: number, - priorityContent?: string[] // IDs of albums or playlists - }): Promise { - return this.sendMessage('SET_DOWNLOAD_PREFERENCES', preferences); - } - - async getDownloadPreferences(): Promise<{ - quality: 'original' | 'high' | 'medium' | 'low', - wifiOnly: boolean, - autoDownloadRecent: boolean, - autoDownloadFavorites: boolean, - maxStoragePercent: number, - priorityContent: string[] - }> { - return this.sendMessage('GET_DOWNLOAD_PREFERENCES', {}); - } - - async enableOfflineMode(settings: { - autoDownloadQueue?: boolean; - forceOffline?: boolean; - currentQueue?: Song[]; - }): Promise { - return this.sendMessage('ENABLE_OFFLINE_MODE', settings); - } - - async checkOfflineStatus(id: string, type: 'album' | 'song'): Promise { - try { - const result = await this.sendMessage('CHECK_OFFLINE_STATUS', { id, type }); - return result.isAvailable; - } catch (error) { - console.error('Failed to check offline status:', error); - return false; - } - } - - async deleteOfflineContent(id: string, type: 'album' | 'song'): Promise { - return this.sendMessage('DELETE_OFFLINE_CONTENT', { id, type }); - } - - async getOfflineStats(): Promise { - return this.sendMessage('GET_OFFLINE_STATS', {}); - } - - async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> { - return this.sendMessage('GET_OFFLINE_ITEMS', {}); - } - - private getDownloadUrl(songId: string, quality?: 'original' | 'high' | 'medium' | 'low'): string { - const api = getNavidromeAPI(); - if (!api) throw new Error('Navidrome server not configured'); - - // Use direct download to fetch original file by default - if (quality === 'original' || !quality) { - if (typeof (api as any).getDownloadUrl === 'function') { - return (api as any).getDownloadUrl(songId); - } - } - - // For other quality settings, use the stream URL with appropriate parameters - const maxBitRate = quality === 'high' ? 320 : - quality === 'medium' ? 192 : - quality === 'low' ? 128 : undefined; - - // Note: format parameter is not supported by the Navidrome API - // The server will automatically transcode based on maxBitRate - return api.getStreamUrl(songId, maxBitRate); - } - - // LocalStorage fallback for browsers without service worker support - async downloadAlbumFallback(album: Album, songs: Song[]): Promise { - const offlineData = this.getOfflineData(); - - // Store album metadata - offlineData.albums[album.id] = { - id: album.id, - name: album.name, - artist: album.artist, - downloadedAt: Date.now(), - songCount: songs.length, - songs: songs.map(song => song.id) - }; - - // Mark songs as downloaded (metadata only in localStorage fallback) - songs.forEach(song => { - offlineData.songs[song.id] = { - id: song.id, - title: song.title, - artist: song.artist, - album: song.album, - albumId: song.albumId, - downloadedAt: Date.now() - }; - }); - - this.saveOfflineData(offlineData); - } - - public getOfflineData() { - const stored = localStorage.getItem('offline-downloads'); - if (stored) { - try { - return JSON.parse(stored); - } catch (error) { - console.error('Failed to parse offline data:', error); - } - } - - return { - albums: {}, - songs: {}, - lastUpdated: Date.now() - }; - } - - public saveOfflineData(data: any) { - data.lastUpdated = Date.now(); - localStorage.setItem('offline-downloads', JSON.stringify(data)); - } - - async checkOfflineStatusFallback(id: string, type: 'album' | 'song'): Promise { - const offlineData = this.getOfflineData(); - - if (type === 'album') { - return !!offlineData.albums[id]; - } else { - return !!offlineData.songs[id]; - } - } - - async deleteOfflineContentFallback(id: string, type: 'album' | 'song'): Promise { - const offlineData = this.getOfflineData(); - - if (type === 'album') { - const album = offlineData.albums[id]; - if (album && album.songs) { - // Remove associated songs - album.songs.forEach((songId: string) => { - delete offlineData.songs[songId]; - }); - } - delete offlineData.albums[id]; - } else { - delete offlineData.songs[id]; - } - - this.saveOfflineData(offlineData); - } - - getOfflineAlbums(): OfflineItem[] { - const offlineData = this.getOfflineData(); - return Object.values(offlineData.albums).map((album: any) => ({ - id: album.id, - type: 'album' as const, - name: album.name, - artist: album.artist, - downloadedAt: album.downloadedAt - })); - } - - getOfflineSongs(): OfflineItem[] { - const offlineData = this.getOfflineData(); - return Object.values(offlineData.songs).map((song: any) => ({ - id: song.id, - type: 'song' as const, - name: song.title, - artist: song.artist, - downloadedAt: song.downloadedAt - })); - } -} - -// Create a singleton instance that will be initialized on the client side -let downloadManagerInstance: DownloadManager | null = null; - -// Only create the download manager instance on the client side -if (typeof window !== 'undefined') { - downloadManagerInstance = new DownloadManager(); -} - -// Create a safe wrapper around the download manager -const downloadManager = { - initialize: async () => { - if (!downloadManagerInstance) return false; - return downloadManagerInstance.initialize(); - }, - getOfflineStats: async () => { - if (!downloadManagerInstance) return { - totalSize: 0, - audioSize: 0, - imageSize: 0, - metaSize: 0, - downloadedAlbums: 0, - downloadedSongs: 0, - lastDownload: null, - downloadErrors: 0, - remainingStorage: null, - autoDownloadEnabled: false, - downloadQuality: 'original' as const, - downloadOnWifiOnly: true, - priorityContent: [] - }; - return downloadManagerInstance.getOfflineStats(); - }, - downloadAlbum: async (album: Album, songs: Song[], progressCallback: (progress: DownloadProgress) => void) => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.downloadAlbum(album, songs, progressCallback); - }, - downloadAlbumFallback: async (album: Album, songs: Song[]) => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.downloadAlbumFallback(album, songs); - }, - downloadSong: async (song: Song) => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.downloadSong(song); - }, - getOfflineData: () => { - if (!downloadManagerInstance) return { albums: {}, songs: {} }; - return downloadManagerInstance.getOfflineData(); - }, - saveOfflineData: (data: any) => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.saveOfflineData(data); - }, - checkOfflineStatus: async (id: string, type: 'album' | 'song') => { - if (!downloadManagerInstance) return false; - return downloadManagerInstance.checkOfflineStatus(id, type); - }, - checkOfflineStatusFallback: (id: string, type: 'album' | 'song') => { - if (!downloadManagerInstance) return false; - return downloadManagerInstance.checkOfflineStatusFallback(id, type); - }, - deleteOfflineContent: async (id: string, type: 'album' | 'song') => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.deleteOfflineContent(id, type); - }, - deleteOfflineContentFallback: async (id: string, type: 'album' | 'song') => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.deleteOfflineContentFallback(id, type); - }, - getOfflineItems: async () => { - if (!downloadManagerInstance) return { albums: [], songs: [] }; - return downloadManagerInstance.getOfflineItems(); - }, - getOfflineAlbums: () => { - if (!downloadManagerInstance) return []; - return downloadManagerInstance.getOfflineAlbums(); - }, - getOfflineSongs: () => { - if (!downloadManagerInstance) return []; - return downloadManagerInstance.getOfflineSongs(); - }, - downloadQueue: async (songs: Song[]) => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.downloadQueue(songs); - }, - enableOfflineMode: async (settings: any) => { - if (!downloadManagerInstance) return; - return downloadManagerInstance.enableOfflineMode(settings); - } -}; - -export function useOfflineDownloads() { - const [isSupported, setIsSupported] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - const [downloadProgress, setDownloadProgress] = useState({ - completed: 0, - total: 0, - failed: 0, - status: 'idle' - }); - - const [offlineStats, setOfflineStats] = useState({ - totalSize: 0, - audioSize: 0, - imageSize: 0, - metaSize: 0, - downloadedAlbums: 0, - downloadedSongs: 0, - lastDownload: null, - downloadErrors: 0, - remainingStorage: null, - autoDownloadEnabled: false, - downloadQuality: 'original', - downloadOnWifiOnly: true, - priorityContent: [] - }); - - useEffect(() => { - const initializeDownloadManager = async () => { - // Skip initialization on server-side - if (!downloadManager) { - setIsSupported(false); - setIsInitialized(true); - return; - } - - const supported = await downloadManager.initialize(); - setIsSupported(supported); - setIsInitialized(true); - - if (supported) { - // Load initial stats - try { - const stats = await downloadManager.getOfflineStats(); - setOfflineStats(stats); - } catch (error) { - console.error('Failed to load offline stats:', error); - } - } - }; - - initializeDownloadManager(); - }, []); - - const downloadAlbum = useCallback(async (album: Album, songs: Song[]) => { - try { - if (isSupported) { - await downloadManager.downloadAlbum(album, songs, setDownloadProgress); - } else { - // Fallback to localStorage metadata only - await downloadManager.downloadAlbumFallback(album, songs); - } - - // Refresh stats - if (isSupported) { - const stats = await downloadManager.getOfflineStats(); - setOfflineStats(stats); - } - } catch (error) { - console.error('Download failed:', error); - setDownloadProgress(prev => ({ ...prev, status: 'error', error: (error as Error).message })); - throw error; - } - }, [isSupported]); - - const downloadSong = useCallback(async (song: Song) => { - if (isSupported) { - await downloadManager.downloadSong(song); - } else { - // Fallback - just save metadata - const offlineData = downloadManager.getOfflineData(); - offlineData.songs[song.id] = { - id: song.id, - title: song.title, - artist: song.artist, - album: song.album, - downloadedAt: Date.now() - }; - downloadManager.saveOfflineData(offlineData); - } - }, [isSupported]); - - const checkOfflineStatus = useCallback(async (id: string, type: 'album' | 'song'): Promise => { - if (isSupported) { - return downloadManager.checkOfflineStatus(id, type); - } else { - return downloadManager.checkOfflineStatusFallback(id, type); - } - }, [isSupported]); - - const deleteOfflineContent = useCallback(async (id: string, type: 'album' | 'song') => { - if (isSupported) { - await downloadManager.deleteOfflineContent(id, type); - } else { - await downloadManager.deleteOfflineContentFallback(id, type); - } - - // Refresh stats - if (isSupported) { - const stats = await downloadManager.getOfflineStats(); - setOfflineStats(stats); - } - }, [isSupported]); - - const getOfflineItems = useCallback(async (): Promise => { - if (isSupported) { - try { - const { albums, songs } = await downloadManager.getOfflineItems(); - return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt); - } catch (e) { - console.error('Failed to get offline items from SW, falling back:', e); - } - } - const albums = downloadManager.getOfflineAlbums(); - const songs = downloadManager.getOfflineSongs(); - return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt); - }, [isSupported]); - - const clearDownloadProgress = useCallback(() => { - setDownloadProgress({ - completed: 0, - total: 0, - failed: 0, - status: 'idle' - }); - }, []); - - const downloadQueue = useCallback(async (songs: Song[]) => { - if (isSupported) { - setDownloadProgress({ completed: 0, total: songs.length, failed: 0, status: 'downloading' }); - - try { - await downloadManager.downloadQueue(songs); - // Stats will be updated via progress events - } catch (error) { - console.error('Queue download failed:', error); - setDownloadProgress(prev => ({ ...prev, status: 'error' })); - } - } else { - // Fallback: just store metadata - const offlineData = downloadManager.getOfflineData(); - songs.forEach(song => { - offlineData.songs[song.id] = { - id: song.id, - title: song.title, - artist: song.artist, - album: song.album, - albumId: song.albumId, - downloadedAt: Date.now() - }; - }); - downloadManager.saveOfflineData(offlineData); - } - }, [isSupported]); - - const enableOfflineMode = useCallback(async (settings: { - autoDownloadQueue?: boolean; - forceOffline?: boolean; - currentQueue?: Song[]; - }) => { - if (isSupported) { - try { - await downloadManager.enableOfflineMode(settings); - } catch (error) { - console.error('Failed to enable offline mode:', error); - } - } - }, [isSupported]); - - return { - isSupported, - isInitialized, - downloadProgress, - offlineStats, - downloadAlbum, - downloadSong, - downloadQueue, - enableOfflineMode, - checkOfflineStatus, - deleteOfflineContent, - getOfflineItems, - clearDownloadProgress - }; -} - -// Export the manager instance for direct use if needed -export { downloadManager }; diff --git a/hooks/use-offline-library-sync.ts b/hooks/use-offline-library-sync.ts deleted file mode 100644 index dfc2f68..0000000 --- a/hooks/use-offline-library-sync.ts +++ /dev/null @@ -1,517 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { offlineLibraryDB, LibrarySyncStats, OfflineAlbum, OfflineArtist, OfflineSong, OfflinePlaylist } from '@/lib/indexeddb'; -import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext'; -import { useToast } from '@/hooks/use-toast'; -import { getNavidromeAPI, Song } from '@/lib/navidrome'; - -export interface LibrarySyncProgress { - phase: 'idle' | 'albums' | 'artists' | 'songs' | 'playlists' | 'operations' | 'complete' | 'error'; - current: number; - total: number; - message: string; -} - -export interface LibrarySyncOptions { - includeAlbums: boolean; - includeArtists: boolean; - includeSongs: boolean; - includePlaylists: boolean; - syncStarred: boolean; - maxSongs: number; // Limit to prevent overwhelming the database -} - -const defaultSyncOptions: LibrarySyncOptions = { - includeAlbums: true, - includeArtists: true, - includeSongs: true, - includePlaylists: true, - syncStarred: true, - maxSongs: 1000 // Default limit -}; - -export function useOfflineLibrarySync() { - const [isInitialized, setIsInitialized] = useState(false); - const [isSyncing, setIsSyncing] = useState(false); - const [syncProgress, setSyncProgress] = useState({ - phase: 'idle', - current: 0, - total: 0, - message: '' - }); - const [stats, setStats] = useState({ - albums: 0, - artists: 0, - songs: 0, - playlists: 0, - lastSync: null, - pendingOperations: 0, - storageSize: 0, - syncInProgress: false - }); - const [isOnline, setIsOnline] = useState(true); - const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); - const [syncOptions, setSyncOptions] = useState(defaultSyncOptions); - - const { config, isConnected } = useNavidromeConfig(); - const api = useMemo(() => getNavidromeAPI(config), [config]); - const { toast } = useToast(); - const syncTimeoutRef = useRef(null); - - // Initialize the offline library database - useEffect(() => { - const initializeDB = async () => { - try { - const initialized = await offlineLibraryDB.initialize(); - setIsInitialized(initialized); - - if (initialized) { - await refreshStats(); - loadSyncSettings(); - } - } catch (error) { - console.error('Failed to initialize offline library:', error); - } - }; - - initializeDB(); - }, []); - - // Monitor online status - useEffect(() => { - const handleOnline = () => setIsOnline(true); - const handleOffline = () => setIsOnline(false); - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - - // Check if navigator is available (client-side only) - if (typeof navigator !== 'undefined') { - setIsOnline(navigator.onLine); - } - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; - }, []); - - // Auto-sync when coming back online - useEffect(() => { - if (isOnline && isConnected && autoSyncEnabled && !isSyncing) { - const pendingOpsSync = async () => { - try { - await syncPendingOperations(); - } catch (error) { - console.error('Auto-sync failed:', error); - } - }; - - // Delay auto-sync to avoid immediate trigger - syncTimeoutRef.current = setTimeout(pendingOpsSync, 2000); - } - - return () => { - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current); - } - }; - }, [isOnline, isConnected, autoSyncEnabled, isSyncing]); - - const loadSyncSettings = useCallback(async () => { - try { - const [autoSync, savedOptions] = await Promise.all([ - offlineLibraryDB.getMetadata('autoSyncEnabled'), - offlineLibraryDB.getMetadata('syncOptions') - ]); - - if (typeof autoSync === 'boolean') setAutoSyncEnabled(autoSync); - - if (savedOptions) { - setSyncOptions({ ...defaultSyncOptions, ...savedOptions }); - } - } catch (error) { - console.error('Failed to load sync settings:', error); - } - }, []); - - const refreshStats = useCallback(async () => { - if (!isInitialized) return; - - try { - const newStats = await offlineLibraryDB.getStats(); - setStats(newStats); - } catch (error) { - console.error('Failed to refresh stats:', error); - } - }, [isInitialized]); - - const updateSyncProgress = useCallback((phase: LibrarySyncProgress['phase'], current: number, total: number, message: string) => { - setSyncProgress({ phase, current, total, message }); - }, []); - - const syncLibraryFromServer = useCallback(async (options: Partial = {}) => { - if (!api || !isConnected || !isInitialized) { - throw new Error('Cannot sync: API not available or not connected'); - } - - if (isSyncing) { - throw new Error('Sync already in progress'); - } - - const actualOptions = { ...syncOptions, ...options }; - - try { - setIsSyncing(true); - await offlineLibraryDB.setMetadata('syncInProgress', true); - - updateSyncProgress('albums', 0, 0, 'Testing server connection...'); - - // Test connection first - const connected = await api.ping(); - if (!connected) { - throw new Error('No connection to Navidrome server'); - } - - let totalItems = 0; - let processedItems = 0; - - // Sync albums - if (actualOptions.includeAlbums) { - updateSyncProgress('albums', 0, 0, 'Fetching albums from server...'); - - const albums = await api.getAlbums('alphabeticalByName', 5000); - totalItems += albums.length; - - updateSyncProgress('albums', 0, albums.length, `Storing ${albums.length} albums...`); - - const mappedAlbums: OfflineAlbum[] = albums.map(album => ({ - ...album, - lastModified: Date.now(), - synced: true - })); - - await offlineLibraryDB.storeAlbums(mappedAlbums); - processedItems += albums.length; - - updateSyncProgress('albums', albums.length, albums.length, `Stored ${albums.length} albums`); - } - - // Sync artists - if (actualOptions.includeArtists) { - updateSyncProgress('artists', processedItems, totalItems, 'Fetching artists from server...'); - - const artists = await api.getArtists(); - totalItems += artists.length; - - updateSyncProgress('artists', 0, artists.length, `Storing ${artists.length} artists...`); - - const mappedArtists: OfflineArtist[] = artists.map(artist => ({ - ...artist, - lastModified: Date.now(), - synced: true - })); - - await offlineLibraryDB.storeArtists(mappedArtists); - processedItems += artists.length; - - updateSyncProgress('artists', artists.length, artists.length, `Stored ${artists.length} artists`); - } - - // Sync playlists - if (actualOptions.includePlaylists) { - updateSyncProgress('playlists', processedItems, totalItems, 'Fetching playlists from server...'); - - const playlists = await api.getPlaylists(); - totalItems += playlists.length; - - updateSyncProgress('playlists', 0, playlists.length, `Storing ${playlists.length} playlists...`); - - const mappedPlaylists: OfflinePlaylist[] = await Promise.all( - playlists.map(async (playlist) => { - try { - const playlistDetails = await api.getPlaylist(playlist.id); - return { - ...playlist, - songIds: (playlistDetails.songs || []).map((song: Song) => song.id), - lastModified: Date.now(), - synced: true - }; - } catch (error) { - console.warn(`Failed to get details for playlist ${playlist.id}:`, error); - return { - ...playlist, - songIds: [], - lastModified: Date.now(), - synced: true - }; - } - }) - ); - - await offlineLibraryDB.storePlaylists(mappedPlaylists); - processedItems += playlists.length; - - updateSyncProgress('playlists', playlists.length, playlists.length, `Stored ${playlists.length} playlists`); - } - - // Sync songs (limited to avoid overwhelming the database) - if (actualOptions.includeSongs) { - updateSyncProgress('songs', processedItems, totalItems, 'Fetching songs from server...'); - - const albums = await offlineLibraryDB.getAlbums(); - const albumsToSync = albums.slice(0, Math.floor(actualOptions.maxSongs / 10)); // Roughly 10 songs per album - - let songCount = 0; - updateSyncProgress('songs', 0, albumsToSync.length, `Processing songs for ${albumsToSync.length} albums...`); - - for (let i = 0; i < albumsToSync.length; i++) { - const album = albumsToSync[i]; - try { - const { songs } = await api.getAlbum(album.id); - - if (songCount + songs.length > actualOptions.maxSongs) { - const remaining = actualOptions.maxSongs - songCount; - if (remaining > 0) { - const limitedSongs = songs.slice(0, remaining); - const mappedSongs: OfflineSong[] = limitedSongs.map(song => ({ - ...song, - lastModified: Date.now(), - synced: true - })); - await offlineLibraryDB.storeSongs(mappedSongs); - songCount += limitedSongs.length; - } - break; - } - - const mappedSongs: OfflineSong[] = songs.map(song => ({ - ...song, - lastModified: Date.now(), - synced: true - })); - - await offlineLibraryDB.storeSongs(mappedSongs); - songCount += songs.length; - - updateSyncProgress('songs', i + 1, albumsToSync.length, `Processed ${i + 1}/${albumsToSync.length} albums (${songCount} songs)`); - } catch (error) { - console.warn(`Failed to sync songs for album ${album.id}:`, error); - } - } - - updateSyncProgress('songs', albumsToSync.length, albumsToSync.length, `Stored ${songCount} songs`); - } - - // Sync pending operations to server - updateSyncProgress('operations', 0, 0, 'Syncing pending operations...'); - await syncPendingOperations(); - - // Update sync timestamp - await offlineLibraryDB.setMetadata('lastSync', Date.now()); - - updateSyncProgress('complete', 100, 100, 'Library sync completed successfully'); - - toast({ - title: "Sync Complete", - description: `Successfully synced library data offline`, - }); - - } catch (error) { - console.error('Library sync failed:', error); - updateSyncProgress('error', 0, 0, `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - - toast({ - title: "Sync Failed", - description: error instanceof Error ? error.message : 'Unknown error occurred', - variant: "destructive" - }); - - throw error; - } finally { - setIsSyncing(false); - await offlineLibraryDB.setMetadata('syncInProgress', false); - await refreshStats(); - } - }, [api, isConnected, isInitialized, isSyncing, syncOptions, toast, updateSyncProgress, refreshStats]); - - const syncPendingOperations = useCallback(async () => { - if (!api || !isConnected || !isInitialized) { - return; - } - - try { - const operations = await offlineLibraryDB.getSyncOperations(); - - if (operations.length === 0) { - return; - } - - updateSyncProgress('operations', 0, operations.length, 'Syncing pending operations...'); - - for (let i = 0; i < operations.length; i++) { - const operation = operations[i]; - - try { - switch (operation.type) { - case 'star': - if (operation.entityType !== 'playlist') { - await api.star(operation.entityId, operation.entityType); - } - break; - case 'unstar': - if (operation.entityType !== 'playlist') { - await api.unstar(operation.entityId, operation.entityType); - } - break; - case 'scrobble': - await api.scrobble(operation.entityId); - break; - case 'create_playlist': - if ('name' in operation.data && typeof operation.data.name === 'string') { - await api.createPlaylist( - operation.data.name, - 'songIds' in operation.data ? operation.data.songIds : undefined - ); - } - break; - case 'update_playlist': - if ('name' in operation.data || 'comment' in operation.data || 'songIds' in operation.data) { - const d = operation.data as { name?: string; comment?: string; songIds?: string[] }; - await api.updatePlaylist(operation.entityId, d.name, d.comment, d.songIds); - } - break; - case 'delete_playlist': - await api.deletePlaylist(operation.entityId); - break; - } - - await offlineLibraryDB.removeSyncOperation(operation.id); - updateSyncProgress('operations', i + 1, operations.length, `Synced ${i + 1}/${operations.length} operations`); - - } catch (error) { - console.error(`Failed to sync operation ${operation.id}:`, error); - // Don't remove failed operations, they'll be retried later - } - } - - } catch (error) { - console.error('Failed to sync pending operations:', error); - } - }, [api, isConnected, isInitialized, updateSyncProgress]); - - const clearOfflineData = useCallback(async () => { - if (!isInitialized) return; - - try { - await offlineLibraryDB.clearAllData(); - await refreshStats(); - - toast({ - title: "Offline Data Cleared", - description: "All offline library data has been removed", - }); - } catch (error) { - console.error('Failed to clear offline data:', error); - toast({ - title: "Clear Failed", - description: "Failed to clear offline data", - variant: "destructive" - }); - } - }, [isInitialized, refreshStats, toast]); - - const updateAutoSync = useCallback(async (enabled: boolean) => { - setAutoSyncEnabled(enabled); - try { - await offlineLibraryDB.setMetadata('autoSyncEnabled', enabled); - } catch (error) { - console.error('Failed to save auto-sync setting:', error); - } - }, []); - - const updateSyncOptions = useCallback(async (newOptions: Partial) => { - const updatedOptions = { ...syncOptions, ...newOptions }; - setSyncOptions(updatedOptions); - - try { - await offlineLibraryDB.setMetadata('syncOptions', updatedOptions); - } catch (error) { - console.error('Failed to save sync options:', error); - } - }, [syncOptions]); - - // Offline-first operations - const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { - if (!isInitialized) throw new Error('Offline library not initialized'); - - try { - await offlineLibraryDB.starItem(id, type); - await refreshStats(); - - // Try to sync immediately if online - if (isOnline && isConnected && api) { - try { - await api.star(id, type); - await offlineLibraryDB.removeSyncOperation(`star-${id}`); - } catch (error) { - console.log('Failed to sync star operation immediately, will retry later:', error); - } - } - } catch (error) { - console.error('Failed to star item:', error); - throw error; - } - }, [isInitialized, refreshStats, isOnline, isConnected, api]); - - const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { - if (!isInitialized) throw new Error('Offline library not initialized'); - - try { - await offlineLibraryDB.unstarItem(id, type); - await refreshStats(); - - // Try to sync immediately if online - if (isOnline && isConnected && api) { - try { - await api.unstar(id, type); - await offlineLibraryDB.removeSyncOperation(`unstar-${id}`); - } catch (error) { - console.log('Failed to sync unstar operation immediately, will retry later:', error); - } - } - } catch (error) { - console.error('Failed to unstar item:', error); - throw error; - } - }, [isInitialized, refreshStats, isOnline, isConnected, api]); - - return { - // State - isInitialized, - isSyncing, - syncProgress, - stats, - isOnline, - autoSyncEnabled, - syncOptions, - - // Actions - syncLibraryFromServer, - syncPendingOperations, - clearOfflineData, - updateAutoSync, - updateSyncOptions, - refreshStats, - starItem, - unstarItem, - - // Data access (for offline access) - getOfflineAlbums: () => offlineLibraryDB.getAlbums(), - getOfflineArtists: () => offlineLibraryDB.getArtists(), - getOfflineSongs: (albumId?: string) => offlineLibraryDB.getSongs(albumId), - getOfflinePlaylists: () => offlineLibraryDB.getPlaylists(), - getOfflineAlbum: (id: string) => offlineLibraryDB.getAlbum(id) - }; -} diff --git a/hooks/use-offline-library.ts b/hooks/use-offline-library.ts deleted file mode 100644 index b5f6cae..0000000 --- a/hooks/use-offline-library.ts +++ /dev/null @@ -1,538 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { offlineLibraryManager, type OfflineLibraryStats, type SyncOperation } from '@/lib/offline-library'; -import { Album, Artist, Song, Playlist } from '@/lib/navidrome'; -import { useNavidrome } from '@/app/components/NavidromeContext'; - -export interface OfflineLibraryState { - isInitialized: boolean; - isOnline: boolean; - isSyncing: boolean; - lastSync: Date | null; - stats: OfflineLibraryStats; - syncProgress: { - current: number; - total: number; - stage: string; - } | null; -} - -export function useOfflineLibrary() { - // Check if we're on the client side - const isClient = typeof window !== 'undefined'; - - const [state, setState] = useState({ - isInitialized: false, - isOnline: isClient ? navigator.onLine : true, // Default to true during SSR - isSyncing: false, - lastSync: null, - stats: { - albums: 0, - artists: 0, - songs: 0, - playlists: 0, - lastSync: null, - pendingOperations: 0, - storageSize: 0 - }, - syncProgress: null - }); - - const { api } = useNavidrome(); - - // Initialize offline library - useEffect(() => { - const initializeOfflineLibrary = async () => { - try { - const initialized = await offlineLibraryManager.initialize(); - if (initialized) { - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ - ...prev, - isInitialized: true, - stats, - lastSync: stats.lastSync - })); - } - } catch (error) { - console.error('Failed to initialize offline library:', error); - } - }; - - initializeOfflineLibrary(); - }, []); - - // Listen for online/offline events - useEffect(() => { - const handleOnline = () => { - setState(prev => ({ ...prev, isOnline: true })); - // Automatically sync when back online - if (state.isInitialized && api) { - syncPendingOperations(); - } - }; - - const handleOffline = () => { - setState(prev => ({ ...prev, isOnline: false })); - }; - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; - }, [state.isInitialized, api]); - - // Full library sync from server - const syncLibraryFromServer = useCallback(async (): Promise => { - if (!api || !state.isInitialized || state.isSyncing) return; - - try { - setState(prev => ({ - ...prev, - isSyncing: true, - syncProgress: { current: 0, total: 100, stage: 'Starting sync...' } - })); - - setState(prev => ({ - ...prev, - syncProgress: { current: 20, total: 100, stage: 'Syncing albums...' } - })); - - await offlineLibraryManager.syncFromServer(api); - - setState(prev => ({ - ...prev, - syncProgress: { current: 80, total: 100, stage: 'Syncing pending operations...' } - })); - - await offlineLibraryManager.syncPendingOperations(api); - - const stats = await offlineLibraryManager.getLibraryStats(); - - setState(prev => ({ - ...prev, - isSyncing: false, - syncProgress: null, - stats, - lastSync: stats.lastSync - })); - - } catch (error) { - console.error('Library sync failed:', error); - setState(prev => ({ - ...prev, - isSyncing: false, - syncProgress: null - })); - throw error; - } - }, [api, state.isInitialized, state.isSyncing]); - - // Sync only pending operations - const syncPendingOperations = useCallback(async (): Promise => { - if (!api || !state.isInitialized) return; - - try { - await offlineLibraryManager.syncPendingOperations(api); - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - } catch (error) { - console.error('Failed to sync pending operations:', error); - } - }, [api, state.isInitialized]); - - // Data retrieval methods (offline-first) - const getAlbums = useCallback(async (starred?: boolean): Promise => { - if (!state.isInitialized) return []; - - try { - // Try offline first - const offlineAlbums = await offlineLibraryManager.getAlbums(starred); - - // If offline data exists, return it - if (offlineAlbums.length > 0) { - return offlineAlbums; - } - - // If no offline data and we're online, try server - if (state.isOnline && api) { - const serverAlbums = starred - ? await api.getAlbums('starred') - : await api.getAlbums('alphabeticalByName', 100); - - // Cache the results - await offlineLibraryManager.storeAlbums(serverAlbums); - return serverAlbums; - } - - return []; - } catch (error) { - console.error('Failed to get albums:', error); - return []; - } - }, [state.isInitialized, state.isOnline, api]); - - const getArtists = useCallback(async (starred?: boolean): Promise => { - if (!state.isInitialized) return []; - - try { - const offlineArtists = await offlineLibraryManager.getArtists(starred); - - if (offlineArtists.length > 0) { - return offlineArtists; - } - - if (state.isOnline && api) { - const serverArtists = await api.getArtists(); - await offlineLibraryManager.storeArtists(serverArtists); - return serverArtists; - } - - return []; - } catch (error) { - console.error('Failed to get artists:', error); - return []; - } - }, [state.isInitialized, state.isOnline, api]); - - const getAlbum = useCallback(async (albumId: string): Promise<{ album: Album; songs: Song[] } | null> => { - if (!state.isInitialized) return null; - - try { - // Try offline first - const offlineData = await offlineLibraryManager.getAlbum(albumId); - - if (offlineData && offlineData.songs.length > 0) { - return offlineData; - } - - // If no offline data and we're online, try server - if (state.isOnline && api) { - const serverData = await api.getAlbum(albumId); - - // Cache the results - await offlineLibraryManager.storeAlbums([serverData.album]); - await offlineLibraryManager.storeSongs(serverData.songs); - - return serverData; - } - - return offlineData; - } catch (error) { - console.error('Failed to get album:', error); - return null; - } - }, [state.isInitialized, state.isOnline, api]); - - const getPlaylists = useCallback(async (): Promise => { - if (!state.isInitialized) return []; - - try { - const offlinePlaylists = await offlineLibraryManager.getPlaylists(); - - if (offlinePlaylists.length > 0) { - return offlinePlaylists; - } - - if (state.isOnline && api) { - const serverPlaylists = await api.getPlaylists(); - await offlineLibraryManager.storePlaylists(serverPlaylists); - return serverPlaylists; - } - - return []; - } catch (error) { - console.error('Failed to get playlists:', error); - return []; - } - }, [state.isInitialized, state.isOnline, api]); - - // Search (offline-first) - const searchOffline = useCallback(async (query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> => { - if (!state.isInitialized) { - return { artists: [], albums: [], songs: [] }; - } - - try { - const offlineResults = await offlineLibraryManager.searchOffline(query); - - // If we have good offline results, return them - const totalResults = offlineResults.artists.length + offlineResults.albums.length + offlineResults.songs.length; - if (totalResults > 0) { - return offlineResults; - } - - // If no offline results and we're online, try server - if (state.isOnline && api) { - return await api.search2(query); - } - - return offlineResults; - } catch (error) { - console.error('Search failed:', error); - return { artists: [], albums: [], songs: [] }; - } - }, [state.isInitialized, state.isOnline, api]); - - // Offline favorites management - const starOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise => { - if (!state.isInitialized) return; - - try { - if (state.isOnline && api) { - // If online, try server first - await api.star(id, type); - } - - // Always update offline data - await offlineLibraryManager.starOffline(id, type); - - // Update stats - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - - } catch (error) { - console.error('Failed to star item:', error); - // If server failed but we're online, still save offline for later sync - if (state.isOnline) { - await offlineLibraryManager.starOffline(id, type); - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - } - throw error; - } - }, [state.isInitialized, state.isOnline, api]); - - const unstarOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise => { - if (!state.isInitialized) return; - - try { - if (state.isOnline && api) { - await api.unstar(id, type); - } - - await offlineLibraryManager.unstarOffline(id, type); - - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - - } catch (error) { - console.error('Failed to unstar item:', error); - if (state.isOnline) { - await offlineLibraryManager.unstarOffline(id, type); - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - } - throw error; - } - }, [state.isInitialized, state.isOnline, api]); - - // Playlist management - const createPlaylistOffline = useCallback(async (name: string, songIds?: string[]): Promise => { - if (!state.isInitialized) { - throw new Error('Offline library not initialized'); - } - - try { - if (state.isOnline && api) { - // If online, try server first - const serverPlaylist = await api.createPlaylist(name, songIds); - await offlineLibraryManager.storePlaylists([serverPlaylist]); - - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - - return serverPlaylist; - } else { - // If offline, create locally and queue for sync - const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds); - - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - - return offlinePlaylist; - } - } catch (error) { - console.error('Failed to create playlist:', error); - - // If server failed but we're online, create offline version for later sync - if (state.isOnline) { - const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds); - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - return offlinePlaylist; - } - - throw error; - } - }, [state.isInitialized, state.isOnline, api]); - - // Scrobble (offline-capable) - const scrobbleOffline = useCallback(async (songId: string): Promise => { - if (!state.isInitialized) return; - - try { - if (state.isOnline && api) { - await api.scrobble(songId); - } else { - // Queue for later sync - await offlineLibraryManager.addSyncOperation({ - type: 'scrobble', - entityType: 'song', - entityId: songId, - data: {} - }); - - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - } - } catch (error) { - console.error('Failed to scrobble:', error); - // Queue for later sync if server failed - await offlineLibraryManager.addSyncOperation({ - type: 'scrobble', - entityType: 'song', - entityId: songId, - data: {} - }); - - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats })); - } - }, [state.isInitialized, state.isOnline, api]); - - // Clear all offline data - const clearOfflineData = useCallback(async (): Promise => { - if (!state.isInitialized) return; - - try { - await offlineLibraryManager.clearAllData(); - - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ - ...prev, - stats, - lastSync: null - })); - } catch (error) { - console.error('Failed to clear offline data:', error); - throw error; - } - }, [state.isInitialized]); - - // Get songs with offline-first approach - const getSongs = useCallback(async (albumId?: string, artistId?: string): Promise => { - if (!state.isInitialized) return []; - - try { - const offlineSongs = await offlineLibraryManager.getSongs(albumId, artistId); - - if (offlineSongs.length > 0) { - return offlineSongs; - } - - if (state.isOnline && api) { - let serverSongs: Song[] = []; - - if (albumId) { - const { songs } = await api.getAlbum(albumId); - await offlineLibraryManager.storeSongs(songs); - serverSongs = songs; - } else if (artistId) { - const { albums } = await api.getArtist(artistId); - const allSongs: Song[] = []; - for (const album of albums) { - const { songs } = await api.getAlbum(album.id); - allSongs.push(...songs); - } - await offlineLibraryManager.storeSongs(allSongs); - serverSongs = allSongs; - } - - return serverSongs; - } - - return []; - } catch (error) { - console.error('Failed to get songs:', error); - return []; - } - }, [api, state.isInitialized, state.isOnline]); - - // Queue sync operation - const queueSyncOperation = useCallback(async (operation: Omit): Promise => { - if (!state.isInitialized) return; - - const fullOperation: SyncOperation = { - ...operation, - id: `${operation.type}-${operation.entityId}-${Date.now()}`, - timestamp: Date.now(), - retryCount: 0 - }; - - await offlineLibraryManager.addSyncOperation(fullOperation); - await refreshStats(); - }, [state.isInitialized]); - - // Update playlist offline - const updatePlaylistOffline = useCallback(async (id: string, name?: string, comment?: string, songIds?: string[]): Promise => { - if (!state.isInitialized) return; - - await offlineLibraryManager.updatePlaylist(id, name, comment, songIds); - await refreshStats(); - }, [state.isInitialized]); - - // Delete playlist offline - const deletePlaylistOffline = useCallback(async (id: string): Promise => { - if (!state.isInitialized) return; - - await offlineLibraryManager.deletePlaylist(id); - await refreshStats(); - }, [state.isInitialized]); - - // Refresh stats - const refreshStats = useCallback(async (): Promise => { - if (!state.isInitialized) return; - - try { - const stats = await offlineLibraryManager.getLibraryStats(); - setState(prev => ({ ...prev, stats, lastSync: stats.lastSync })); - } catch (error) { - console.error('Failed to refresh stats:', error); - } - }, [state.isInitialized]); - - return { - // State - ...state, - - // Sync methods - syncLibraryFromServer, - syncPendingOperations, - - // Data retrieval (offline-first) - getAlbums, - getArtists, - getSongs, - getAlbum, - getPlaylists, - searchOffline, - - // Offline operations - starOffline, - unstarOffline, - createPlaylistOffline, - updatePlaylistOffline, - deletePlaylistOffline, - scrobbleOffline, - queueSyncOperation, - - // Management - clearOfflineData, - refreshStats - }; -} diff --git a/hooks/use-progressive-album-loading.ts b/hooks/use-progressive-album-loading.ts index ecde93a..a29e3c3 100644 --- a/hooks/use-progressive-album-loading.ts +++ b/hooks/use-progressive-album-loading.ts @@ -3,8 +3,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Album } from '@/lib/navidrome'; import { useNavidrome } from '@/app/components/NavidromeContext'; -import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider'; -import { useOfflineLibrary } from '@/hooks/use-offline-library'; const INITIAL_BATCH_SIZE = 24; // Initial number of albums to load const BATCH_SIZE = 24; // Number of albums to load in each batch @@ -18,8 +16,6 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic const [hasMore, setHasMore] = useState(true); const [currentOffset, setCurrentOffset] = useState(0); const { api } = useNavidrome(); - const offlineApi = useOfflineNavidrome(); - const offlineLibrary = useOfflineLibrary(); const [error, setError] = useState(null); // Load initial batch @@ -36,84 +32,19 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic }; }, [sortBy]); - // We'll define the scroll listener after defining loadMoreAlbums - // Load initial batch of albums const loadInitialBatch = useCallback(async () => { - if (!api && !offlineLibrary.isInitialized) return; + if (!api) return; setIsLoading(true); setError(null); try { - let albumData: Album[] = []; - - // Try offline-first approach - if (offlineLibrary.isInitialized) { - try { - // For starred albums, use the starred parameter - if (sortBy === 'starred') { - albumData = await offlineApi.getAlbums(true); - } else { - albumData = await offlineApi.getAlbums(false); - - // Apply client-side sorting since offline API might not support all sort options - if (sortBy === 'newest') { - albumData.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); - } else if (sortBy === 'alphabeticalByArtist') { - albumData.sort((a, b) => a.artist.localeCompare(b.artist)); - } else if (sortBy === 'alphabeticalByName') { - albumData.sort((a, b) => a.name.localeCompare(b.name)); - } else if (sortBy === 'recent') { - // Sort by recently played - if we have timestamps - const recentlyPlayedMap = new Map(); - const recentlyPlayed = localStorage.getItem('recently-played-albums'); - if (recentlyPlayed) { - try { - const parsed = JSON.parse(recentlyPlayed); - Object.entries(parsed).forEach(([id, timestamp]) => { - recentlyPlayedMap.set(id, timestamp as number); - }); - albumData.sort((a, b) => { - const timestampA = recentlyPlayedMap.get(a.id) || 0; - const timestampB = recentlyPlayedMap.get(b.id) || 0; - return timestampB - timestampA; - }); - } catch (error) { - console.error('Error parsing recently played albums:', error); - } - } - } - } - - // If we got albums offline and it's non-empty, use that - if (albumData && albumData.length > 0) { - // Just take the initial batch for consistent behavior - const initialBatch = albumData.slice(0, INITIAL_BATCH_SIZE); - setAlbums(initialBatch); - setCurrentOffset(initialBatch.length); - setHasMore(albumData.length > initialBatch.length); - setIsLoading(false); - return; - } - } catch (offlineError) { - console.error('Error loading albums from offline storage:', offlineError); - // Continue to online API as fallback - } - } - - // Fall back to online API if needed - if (api) { - albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0); - setAlbums(albumData); - setCurrentOffset(albumData.length); - // Assume there are more unless we got fewer than we asked for - setHasMore(albumData.length >= INITIAL_BATCH_SIZE); - } else { - // No API available - setAlbums([]); - setHasMore(false); - } + const albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0); + setAlbums(albumData); + setCurrentOffset(albumData.length); + // Assume there are more unless we got fewer than we asked for + setHasMore(albumData.length >= INITIAL_BATCH_SIZE); } catch (err) { console.error('Failed to load initial albums batch:', err); setError(err instanceof Error ? err.message : 'Unknown error loading albums'); @@ -122,81 +53,20 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic } finally { setIsLoading(false); } - }, [api, offlineApi, offlineLibrary, sortBy]); + }, [api, sortBy]); // Load more albums when scrolling const loadMoreAlbums = useCallback(async () => { - if (isLoading || !hasMore) return; + if (isLoading || !hasMore || !api) return; setIsLoading(true); try { - let newAlbums: Album[] = []; - - // Try offline-first approach (if we already have offline data) - if (offlineLibrary.isInitialized && albums.length > 0) { - try { - // For starred albums, use the starred parameter - let allAlbums: Album[] = []; - if (sortBy === 'starred') { - allAlbums = await offlineApi.getAlbums(true); - } else { - allAlbums = await offlineApi.getAlbums(false); - - // Apply client-side sorting - if (sortBy === 'newest') { - allAlbums.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); - } else if (sortBy === 'alphabeticalByArtist') { - allAlbums.sort((a, b) => a.artist.localeCompare(b.artist)); - } else if (sortBy === 'alphabeticalByName') { - allAlbums.sort((a, b) => a.name.localeCompare(b.name)); - } else if (sortBy === 'recent') { - // Sort by recently played - if we have timestamps - const recentlyPlayedMap = new Map(); - const recentlyPlayed = localStorage.getItem('recently-played-albums'); - if (recentlyPlayed) { - try { - const parsed = JSON.parse(recentlyPlayed); - Object.entries(parsed).forEach(([id, timestamp]) => { - recentlyPlayedMap.set(id, timestamp as number); - }); - allAlbums.sort((a, b) => { - const timestampA = recentlyPlayedMap.get(a.id) || 0; - const timestampB = recentlyPlayedMap.get(b.id) || 0; - return timestampB - timestampA; - }); - } catch (error) { - console.error('Error parsing recently played albums:', error); - } - } - } - } - - // Slice the next batch from the offline data - if (allAlbums && allAlbums.length > currentOffset) { - newAlbums = allAlbums.slice(currentOffset, currentOffset + BATCH_SIZE); - setAlbums(prev => [...prev, ...newAlbums]); - setCurrentOffset(currentOffset + newAlbums.length); - setHasMore(allAlbums.length > currentOffset + newAlbums.length); - setIsLoading(false); - return; - } - } catch (offlineError) { - console.error('Error loading more albums from offline storage:', offlineError); - // Continue to online API as fallback - } - } - - // Fall back to online API - if (api) { - newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset); - setAlbums(prev => [...prev, ...newAlbums]); - setCurrentOffset(currentOffset + newAlbums.length); - // If we get fewer albums than we asked for, we've reached the end - setHasMore(newAlbums.length >= BATCH_SIZE); - } else { - setHasMore(false); - } + const newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset); + setAlbums(prev => [...prev, ...newAlbums]); + setCurrentOffset(currentOffset + newAlbums.length); + // If we get fewer albums than we asked for, we've reached the end + setHasMore(newAlbums.length >= BATCH_SIZE); } catch (err) { console.error('Failed to load more albums:', err); setError(err instanceof Error ? err.message : 'Unknown error loading more albums'); @@ -204,7 +74,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic } finally { setIsLoading(false); } - }, [api, offlineApi, offlineLibrary, albums, currentOffset, isLoading, hasMore, sortBy]); + }, [api, currentOffset, isLoading, hasMore, sortBy]); // Manual refresh (useful for pull-to-refresh functionality) const refreshAlbums = useCallback(() => { @@ -214,7 +84,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic loadInitialBatch(); }, [loadInitialBatch]); - // Setup scroll listener after function declarations + // Setup scroll listener useEffect(() => { const handleScroll = () => { // Don't trigger if already loading @@ -231,7 +101,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); - }, [isLoading, hasMore, currentOffset, loadMoreAlbums]); + }, [isLoading, hasMore, loadMoreAlbums]); return { albums,