588 lines
17 KiB
TypeScript
588 lines
17 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
|
|
}));
|
|
}
|
|
}
|
|
|
|
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,
|
|
lastDownload: null,
|
|
downloadErrors: 0,
|
|
remainingStorage: null,
|
|
autoDownloadEnabled: false,
|
|
downloadQuality: 'original',
|
|
downloadOnWifiOnly: true,
|
|
priorityContent: []
|
|
});
|
|
|
|
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(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 };
|