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

683 lines
20 KiB
TypeScript

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