Files
mice/hooks/use-offline-library.ts

539 lines
16 KiB
TypeScript

'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<OfflineLibraryState>({
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<void> => {
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<void> => {
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<Album[]> => {
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<Artist[]> => {
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<Playlist[]> => {
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<void> => {
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<void> => {
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<Playlist> => {
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<void> => {
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<void> => {
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<Song[]> => {
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<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> => {
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<void> => {
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<void> => {
if (!state.isInitialized) return;
await offlineLibraryManager.deletePlaylist(id);
await refreshStats();
}, [state.isInitialized]);
// Refresh stats
const refreshStats = useCallback(async (): Promise<void> => {
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
};
}