feat: Implement offline library management with IndexedDB support
- Added `useOfflineLibrary` hook for managing offline library state and synchronization. - Created `OfflineLibraryManager` class for handling IndexedDB operations and syncing with Navidrome API. - Implemented methods for retrieving and storing albums, artists, songs, and playlists. - Added support for offline favorites management (star/unstar). - Implemented playlist creation, updating, and deletion functionalities. - Added search functionality for offline data. - Created a manifest file for PWA support with icons and shortcuts. - Added service worker file for caching and offline capabilities.
This commit is contained in:
279
hooks/use-offline-audio-player.ts
Normal file
279
hooks/use-offline-audio-player.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
'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}`;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
452
hooks/use-offline-downloads.ts
Normal file
452
hooks/use-offline-downloads.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
|
||||
export interface DownloadProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error';
|
||||
currentSong?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OfflineItem {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
name: string;
|
||||
artist: string;
|
||||
downloadedAt: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface OfflineStats {
|
||||
totalSize: number;
|
||||
audioSize: number;
|
||||
imageSize: number;
|
||||
metaSize: number;
|
||||
downloadedAlbums: number;
|
||||
downloadedSongs: number;
|
||||
}
|
||||
|
||||
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 stream URLs to songs
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'DOWNLOAD_ALBUM',
|
||||
data: { album, songs: songsWithUrls }
|
||||
}, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadSong(song: Song): Promise<void> {
|
||||
const songWithUrl = {
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
};
|
||||
|
||||
return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
|
||||
}
|
||||
|
||||
async downloadQueue(songs: Song[]): Promise<void> {
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
}));
|
||||
|
||||
return this.sendMessage('DOWNLOAD_QUEUE', { songs: songsWithUrls });
|
||||
}
|
||||
|
||||
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', {});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return `${config.serverUrl}/rest/stream?id=${songId}&u=${config.username}&p=${config.password}&c=mice&f=json`;
|
||||
}
|
||||
|
||||
// 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const downloadManager = new DownloadManager();
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDownloadManager = async () => {
|
||||
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((): OfflineItem[] => {
|
||||
const albums = downloadManager.getOfflineAlbums();
|
||||
const songs = downloadManager.getOfflineSongs();
|
||||
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
|
||||
}, []);
|
||||
|
||||
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 };
|
||||
535
hooks/use-offline-library.ts
Normal file
535
hooks/use-offline-library.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
'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() {
|
||||
const [state, setState] = useState<OfflineLibraryState>({
|
||||
isInitialized: false,
|
||||
isOnline: navigator.onLine,
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user