refactor: remove all offline download and caching functionality

This commit is contained in:
2026-01-25 00:35:58 +00:00
committed by GitHub
parent 6b0e7f73d4
commit 1d013bb9f6
20 changed files with 91 additions and 4331 deletions

View File

@@ -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<OfflineTrack> => {
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
};
}

View File

@@ -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<boolean> {
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<any> {
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<void> {
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<void> {
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<void> {
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<void> {
return this.sendMessage('PAUSE_DOWNLOADS', {});
}
async resumeDownloads(): Promise<void> {
return this.sendMessage('RESUME_DOWNLOADS', {});
}
async cancelDownloads(): Promise<void> {
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<void> {
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<void> {
return this.sendMessage('ENABLE_OFFLINE_MODE', settings);
}
async checkOfflineStatus(id: string, type: 'album' | 'song'): Promise<boolean> {
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<void> {
return this.sendMessage('DELETE_OFFLINE_CONTENT', { id, type });
}
async getOfflineStats(): Promise<OfflineStats> {
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<void> {
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<boolean> {
const offlineData = this.getOfflineData();
if (type === 'album') {
return !!offlineData.albums[id];
} else {
return !!offlineData.songs[id];
}
}
async deleteOfflineContentFallback(id: string, type: 'album' | 'song'): Promise<void> {
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<DownloadProgress>({
completed: 0,
total: 0,
failed: 0,
status: 'idle'
});
const [offlineStats, setOfflineStats] = useState<OfflineStats>({
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<boolean> => {
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<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({
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 };

View File

@@ -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<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);
// 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<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)
};
}

View File

@@ -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<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
};
}

View File

@@ -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<string | null>(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<string, number>();
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<string, number>();
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,