feat: Implement offline library synchronization with IndexedDB
- Added `useOfflineLibrarySync` hook for managing offline library sync operations. - Created `OfflineLibrarySync` component for UI integration. - Developed `offlineLibraryDB` for IndexedDB interactions, including storing and retrieving albums, artists, songs, and playlists. - Implemented sync operations for starred items, playlists, and scrobbling. - Added auto-sync functionality when coming back online. - Included metadata management for sync settings and statistics. - Enhanced error handling and user feedback through toasts.
This commit is contained in:
@@ -46,6 +46,8 @@ export function useOfflineAudioPlayer() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
export interface DownloadProgress {
|
||||
completed: number;
|
||||
@@ -104,10 +104,14 @@ class DownloadManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Add stream URLs to songs
|
||||
// Add direct download URLs to songs (use 'streamUrl' field name to keep SW compatibility)
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
streamUrl: this.getDownloadUrl(song.id),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
@@ -120,7 +124,11 @@ class DownloadManager {
|
||||
async downloadSong(song: Song): Promise<void> {
|
||||
const songWithUrl = {
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
streamUrl: this.getDownloadUrl(song.id),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size
|
||||
};
|
||||
|
||||
return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
|
||||
@@ -129,7 +137,11 @@ class DownloadManager {
|
||||
async downloadQueue(songs: Song[]): Promise<void> {
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
streamUrl: this.getDownloadUrl(song.id),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size
|
||||
}));
|
||||
|
||||
return this.sendMessage('DOWNLOAD_QUEUE', { songs: songsWithUrls });
|
||||
@@ -160,15 +172,20 @@ class DownloadManager {
|
||||
async getOfflineStats(): Promise<OfflineStats> {
|
||||
return this.sendMessage('GET_OFFLINE_STATS', {});
|
||||
}
|
||||
|
||||
async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> {
|
||||
return this.sendMessage('GET_OFFLINE_ITEMS', {});
|
||||
}
|
||||
|
||||
private getStreamUrl(songId: string): string {
|
||||
// This should match your actual Navidrome stream URL format
|
||||
const config = JSON.parse(localStorage.getItem('navidrome-config') || '{}');
|
||||
if (!config.serverUrl) {
|
||||
throw new Error('Navidrome server not configured');
|
||||
private getDownloadUrl(songId: string): string {
|
||||
const api = getNavidromeAPI();
|
||||
if (!api) throw new Error('Navidrome server not configured');
|
||||
// Use direct download to fetch original file; browser handles transcoding/decoding.
|
||||
// Fall back to stream URL if the server does not allow downloads.
|
||||
if (typeof (api as any).getDownloadUrl === 'function') {
|
||||
return (api as any).getDownloadUrl(songId);
|
||||
}
|
||||
|
||||
return `${config.serverUrl}/rest/stream?id=${songId}&u=${config.username}&p=${config.password}&c=mice&f=json`;
|
||||
return api.getStreamUrl(songId);
|
||||
}
|
||||
|
||||
// LocalStorage fallback for browsers without service worker support
|
||||
@@ -375,11 +392,19 @@ export function useOfflineDownloads() {
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const getOfflineItems = useCallback((): OfflineItem[] => {
|
||||
const getOfflineItems = useCallback(async (): Promise<OfflineItem[]> => {
|
||||
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({
|
||||
|
||||
514
hooks/use-offline-library-sync.ts
Normal file
514
hooks/use-offline-library-sync.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
'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<LibrarySyncProgress>({
|
||||
phase: 'idle',
|
||||
current: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
});
|
||||
const [stats, setStats] = useState<LibrarySyncStats>({
|
||||
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<LibrarySyncOptions>(defaultSyncOptions);
|
||||
|
||||
const { config, isConnected } = useNavidromeConfig();
|
||||
const api = useMemo(() => getNavidromeAPI(config), [config]);
|
||||
const { toast } = useToast();
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(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);
|
||||
|
||||
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<boolean>('autoSyncEnabled'),
|
||||
offlineLibraryDB.getMetadata<LibrarySyncOptions>('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<LibrarySyncOptions> = {}) => {
|
||||
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<LibrarySyncOptions>) => {
|
||||
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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user