feat: Implement offline library management with IndexedDB support
- Added `useOfflineLibrary` hook for managing offline library state and synchronization. - Created `OfflineLibraryManager` class for handling IndexedDB operations and syncing with Navidrome API. - Implemented methods for retrieving and storing albums, artists, songs, and playlists. - Added support for offline favorites management (star/unstar). - Implemented playlist creation, updating, and deletion functionalities. - Added search functionality for offline data. - Created a manifest file for PWA support with icons and shortcuts. - Added service worker file for caching and offline capabilities.
This commit is contained in:
367
app/components/OfflineNavidromeContext.tsx
Normal file
367
app/components/OfflineNavidromeContext.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'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<Playlist[]>;
|
||||
refreshData: () => Promise<void>;
|
||||
|
||||
// Offline-capable operations
|
||||
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
createPlaylist: (name: string, songIds?: string[]) => Promise<Playlist>;
|
||||
scrobble: (songId: string) => Promise<void>;
|
||||
|
||||
// Sync management
|
||||
syncLibrary: () => Promise<void>;
|
||||
syncPendingOperations: () => Promise<void>;
|
||||
clearOfflineData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
|
||||
|
||||
interface OfflineNavidromeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const OfflineNavidromeProvider: React.FC<OfflineNavidromeProviderProps> = ({ children }) => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false);
|
||||
const [artistsLoading, setArtistsLoading] = useState(false);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(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<Playlist[]> => {
|
||||
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<Playlist> => {
|
||||
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 (
|
||||
<OfflineNavidromeContext.Provider value={value}>
|
||||
{children}
|
||||
</OfflineNavidromeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
|
||||
const context = useContext(OfflineNavidromeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user