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:
2025-08-07 22:07:53 +00:00
committed by GitHub
parent 7ac5eb89ce
commit 0a0feb3748
23 changed files with 4239 additions and 229 deletions

168
OFFLINE_DOWNLOADS.md Normal file
View File

@@ -0,0 +1,168 @@
# Offline Downloads Feature
This document describes the offline downloads functionality implemented in the Mice Navidrome client.
## Overview
The offline downloads feature allows users to download music for offline listening using modern web technologies including Service Workers and Cache API, with localStorage fallback for browsers without Service Worker support.
## Components
### 1. Service Worker (`/public/sw.js`)
- Handles audio, image, and API caching
- Manages download operations in the background
- Provides offline audio playback capabilities
- Implements cache-first strategy for downloaded content
### 2. Download Manager Hook (`/hooks/use-offline-downloads.ts`)
- Provides React interface for download operations
- Manages download progress and status
- Handles Service Worker communication
- Provides localStorage fallback for metadata
### 3. Cache Management Component (`/app/components/CacheManagement.tsx`)
- Enhanced to show offline download statistics
- Displays download progress during operations
- Lists downloaded content with removal options
- Shows Service Worker support status
### 4. Offline Indicator Component (`/app/components/OfflineIndicator.tsx`)
- Shows download status for albums and songs
- Provides download/remove buttons
- Displays visual indicators on album artwork
## Features
### Download Capabilities
- **Album Downloads**: Download entire albums with all tracks and artwork
- **Individual Song Downloads**: Download single tracks
- **Progress Tracking**: Real-time download progress with track-by-track updates
- **Error Handling**: Graceful handling of failed downloads with retry options
### Visual Indicators
- **Album Artwork**: Small download icon in top-right corner of downloaded albums
- **Album Pages**: Download buttons and status indicators
- **Song Lists**: Individual download indicators for tracks
- **Library View**: Visual badges showing offline availability
### Offline Storage
- **Service Worker Cache**: True offline storage for audio files and images
- **localStorage Fallback**: Metadata-only storage for limited browser support
- **Progressive Enhancement**: Works in all browsers with varying capabilities
### Cache Management
- **Storage Statistics**: Shows total offline storage usage
- **Content Management**: List and remove downloaded content
- **Cache Cleanup**: Clear expired and unnecessary cache data
- **Progress Monitoring**: Real-time download progress display
## Usage
### Downloading Content
#### From Album Page
1. Navigate to any album page
2. Click the "Download" button (desktop) or small download button (mobile)
3. Monitor progress in the cache management section
4. Downloaded albums show indicators on artwork and in lists
#### From Settings Page
1. Go to Settings → Cache & Offline Downloads
2. View current download statistics
3. Monitor active downloads
4. Manage downloaded content
### Managing Downloads
#### Viewing Downloaded Content
- Settings page shows list of all downloaded albums and songs
- Album artwork displays download indicators
- Individual songs show download status
#### Removing Downloads
- Use the "X" button next to items in the cache management list
- Use "Remove Download" button on album pages
- Clear all cache to remove everything
## Technical Implementation
### Service Worker Features
- **Audio Caching**: Streams are cached using the Subsonic API
- **Image Caching**: Album artwork and avatars cached separately
- **API Caching**: Metadata cached with network-first strategy
- **Background Downloads**: Downloads continue even when page is closed
### Browser Compatibility
- **Full Support**: Modern browsers with Service Worker support
- **Limited Support**: Older browsers get metadata-only caching
- **Progressive Enhancement**: Features gracefully degrade
### Storage Strategy
- **Audio Cache**: Large files stored in Service Worker cache
- **Image Cache**: Artwork cached separately for optimization
- **Metadata Cache**: Song/album information in localStorage
- **Size Management**: Automatic cleanup of old cached content
## Configuration
### Environment Variables
The offline downloads use existing Navidrome configuration:
- `NEXT_PUBLIC_NAVIDROME_URL`
- `NEXT_PUBLIC_NAVIDROME_USERNAME`
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`
### Cache Limits
- Default audio cache: No explicit limit (browser manages)
- Image cache: Optimized sizes based on display requirements
- Metadata: Stored in localStorage with cleanup
## Development Notes
### File Structure
```
hooks/
use-offline-downloads.ts # Main download hook
app/components/
CacheManagement.tsx # Enhanced cache UI
OfflineIndicator.tsx # Download status components
album-artwork.tsx # Updated with indicators
album/[id]/
page.tsx # Enhanced with download buttons
public/
sw.js # Service Worker implementation
```
### API Integration
- Uses existing Navidrome API endpoints
- Leverages Subsonic streaming URLs
- Integrates with current authentication system
- Compatible with existing cache infrastructure
## Future Enhancements
### Planned Features
- **Playlist Downloads**: Download entire playlists
- **Smart Sync**: Automatic download of favorites
- **Storage Limits**: User-configurable storage limits
- **Download Scheduling**: Queue downloads for later
- **Offline Mode Detection**: Automatic offline behavior
### Performance Optimizations
- **Compression**: Audio compression options
- **Quality Selection**: Choose download quality
- **Selective Sync**: Download only specific tracks
- **Background Sync**: Download during idle time
## Troubleshooting
### Common Issues
- **Service Worker not registering**: Check browser console
- **Downloads failing**: Verify Navidrome server connection
- **Storage full**: Clear cache or check browser storage limits
- **Slow downloads**: Check network connection and server performance
### Debug Information
- Browser Developer Tools → Application → Service Workers
- Cache storage inspection in DevTools
- Console logs for download progress and errors
- Network tab for failed requests

View File

@@ -13,6 +13,9 @@ import { Separator } from '@/components/ui/separator';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
import { useIsMobile } from '@/hooks/use-mobile';
import { OfflineIndicator, DownloadButton } from '@/app/components/OfflineIndicator';
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
import { useToast } from '@/hooks/use-toast';
export default function AlbumPage() {
const { id } = useParams();
@@ -26,6 +29,8 @@ export default function AlbumPage() {
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
const isMobile = useIsMobile();
const api = getNavidromeAPI();
const { downloadAlbum, isSupported: isOfflineSupported } = useOfflineDownloads();
const { toast } = useToast();
useEffect(() => {
const fetchAlbum = async () => {
@@ -121,6 +126,31 @@ export default function AlbumPage() {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const handleDownloadAlbum = async () => {
if (!album || !tracklist.length) return;
try {
toast({
title: "Download Started",
description: `Starting download of "${album.name}" by ${album.artist}`,
});
await downloadAlbum(album, tracklist);
toast({
title: "Download Complete",
description: `"${album.name}" has been downloaded for offline listening`,
});
} catch (error) {
console.error('Failed to download album:', error);
toast({
title: "Download Failed",
description: `Failed to download "${album.name}". Please try again.`,
variant: "destructive"
});
}
};
// Dynamic cover art URLs based on image size
const getMobileCoverArtUrl = () => {
return album.coverArt && api
@@ -162,6 +192,15 @@ export default function AlbumPage() {
</Link>
<p className="text-sm text-muted-foreground text-left">{album.genre} {album.year}</p>
<p className="text-sm text-muted-foreground text-left">{album.songCount} songs, {formatDuration(album.duration)}</p>
{/* Offline indicator for mobile */}
<OfflineIndicator
id={album.id}
type="album"
showLabel
size="sm"
className="mt-2"
/>
</div>
{/* Right side - Controls */}
@@ -173,6 +212,18 @@ export default function AlbumPage() {
>
<Play className="w-6 h-6" />
</Button>
{/* Download button for mobile */}
{isOfflineSupported && (
<DownloadButton
id={album.id}
type="album"
onDownload={handleDownloadAlbum}
size="sm"
variant="outline"
className="text-xs px-2 py-1 h-8"
/>
)}
</div>
</div>
</div>
@@ -196,12 +247,36 @@ export default function AlbumPage() {
<Link href={`/artist/${album.artistId}`}>
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
</Link>
<Button className="px-5" onClick={() => playAlbum(album.id)}>
Play
</Button>
{/* Controls row */}
<div className="flex items-center gap-3">
<Button className="px-5" onClick={() => playAlbum(album.id)}>
Play
</Button>
{/* Download button for desktop */}
{isOfflineSupported && (
<DownloadButton
id={album.id}
type="album"
onDownload={handleDownloadAlbum}
variant="outline"
/>
)}
</div>
{/* Album info */}
<div className="text-sm text-muted-foreground">
<p>{album.genre} {album.year}</p>
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
{/* Offline indicator for desktop */}
<OfflineIndicator
id={album.id}
type="album"
showLabel
className="mt-2"
/>
</div>
</div>
</div>
@@ -237,6 +312,12 @@ export default function AlbumPage() {
}`}>
{song.title}
</p>
{/* Song offline indicator */}
<OfflineIndicator
id={song.id}
type="song"
size="sm"
/>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="flex items-center gap-1">

View File

@@ -1,16 +1,28 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import {
Database,
Trash2,
RefreshCw,
HardDrive
HardDrive,
Download,
Wifi,
WifiOff,
X,
Music,
Globe,
Settings
} from 'lucide-react';
import { CacheManager } from '@/lib/cache';
import { useOfflineDownloads, OfflineItem } from '@/hooks/use-offline-downloads';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
export function CacheManagement() {
const [cacheStats, setCacheStats] = useState({
@@ -20,6 +32,24 @@ export function CacheManagement() {
});
const [isClearing, setIsClearing] = useState(false);
const [lastCleared, setLastCleared] = useState<string | null>(null);
const [offlineItems, setOfflineItems] = useState<OfflineItem[]>([]);
const [offlineMode, setOfflineMode] = useState(false);
const [autoDownloadQueue, setAutoDownloadQueue] = useState(false);
const [isDownloadingQueue, setIsDownloadingQueue] = useState(false);
const {
isSupported: isOfflineSupported,
isInitialized: isOfflineInitialized,
downloadProgress,
offlineStats,
downloadQueue,
enableOfflineMode,
deleteOfflineContent,
getOfflineItems,
clearDownloadProgress
} = useOfflineDownloads();
const { queue } = useAudioPlayer();
const loadCacheStats = () => {
if (typeof window === 'undefined') return;
@@ -65,15 +95,34 @@ export function CacheManagement() {
});
};
const loadOfflineItems = useCallback(() => {
if (isOfflineInitialized) {
const items = getOfflineItems();
setOfflineItems(items);
}
}, [isOfflineInitialized, getOfflineItems]);
useEffect(() => {
loadCacheStats();
loadOfflineItems();
// Load offline mode settings
const storedOfflineMode = localStorage.getItem('offline-mode-enabled');
const storedAutoDownload = localStorage.getItem('auto-download-queue');
if (storedOfflineMode) {
setOfflineMode(JSON.parse(storedOfflineMode));
}
if (storedAutoDownload) {
setAutoDownloadQueue(JSON.parse(storedAutoDownload));
}
// Check if there's a last cleared timestamp
const lastClearedTime = localStorage.getItem('cache-last-cleared');
if (lastClearedTime) {
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
}
}, []);
}, [loadOfflineItems]);
const handleClearCache = async () => {
setIsClearing(true);
@@ -142,77 +191,365 @@ export function CacheManagement() {
loadCacheStats();
};
const handleDeleteOfflineItem = async (item: OfflineItem) => {
try {
await deleteOfflineContent(item.id, item.type);
loadOfflineItems();
loadCacheStats();
} catch (error) {
console.error('Failed to delete offline item:', error);
}
};
// Convert Track to Song format for offline downloads
const convertTrackToSong = (track: Track) => ({
id: track.id,
parent: track.albumId || '',
isDir: false,
title: track.name,
album: track.album,
artist: track.artist,
size: 0, // Will be filled when downloaded
contentType: 'audio/mpeg',
suffix: 'mp3',
duration: track.duration,
path: '',
created: new Date().toISOString(),
albumId: track.albumId,
artistId: track.artistId,
type: 'music'
});
const handleOfflineModeToggle = async (enabled: boolean) => {
setOfflineMode(enabled);
localStorage.setItem('offline-mode-enabled', JSON.stringify(enabled));
if (enabled && isOfflineSupported) {
try {
const convertedQueue = queue.map(convertTrackToSong);
await enableOfflineMode({
forceOffline: enabled,
autoDownloadQueue,
currentQueue: convertedQueue
});
} catch (error) {
console.error('Failed to enable offline mode:', error);
}
}
};
const handleAutoDownloadToggle = async (enabled: boolean) => {
setAutoDownloadQueue(enabled);
localStorage.setItem('auto-download-queue', JSON.stringify(enabled));
if (enabled && isOfflineSupported) {
const convertedQueue = queue.map(convertTrackToSong);
await enableOfflineMode({
forceOffline: offlineMode,
autoDownloadQueue: enabled,
currentQueue: convertedQueue
});
}
};
const handleDownloadCurrentQueue = async () => {
if (!queue.length || !isOfflineSupported) return;
setIsDownloadingQueue(true);
try {
const convertedQueue = queue.map(convertTrackToSong);
await downloadQueue(convertedQueue);
loadOfflineItems();
} catch (error) {
console.error('Failed to download queue:', error);
} finally {
setIsDownloadingQueue(false);
}
};
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<Card className="break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Cache Management
Cache & Offline Downloads
</CardTitle>
<CardDescription>
Manage application cache to improve performance and free up storage
Manage application cache and offline content for better performance
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Cache Statistics */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.total}</p>
<p className="text-xs text-muted-foreground">Total Items</p>
<CardContent className="space-y-6">
{/* Regular Cache Statistics */}
<div>
<h4 className="text-sm font-medium mb-3">Application Cache</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.total}</p>
<p className="text-xs text-muted-foreground">Total Items</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.expired}</p>
<p className="text-xs text-muted-foreground">Expired</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.size}</p>
<p className="text-xs text-muted-foreground">Storage Used</p>
</div>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.expired}</p>
<p className="text-xs text-muted-foreground">Expired</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.size}</p>
<p className="text-xs text-muted-foreground">Storage Used</p>
{/* Cache Actions */}
<div className="space-y-2 mt-4">
<div className="flex gap-2">
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="destructive"
size="sm"
className="flex-1"
>
{isClearing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isClearing ? 'Clearing...' : 'Clear All Cache'}
</Button>
<Button
onClick={handleCleanExpired}
variant="outline"
size="sm"
className="flex-1"
>
<HardDrive className="h-4 w-4 mr-2" />
Clean Expired
</Button>
</div>
<Button
onClick={loadCacheStats}
variant="ghost"
size="sm"
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Stats
</Button>
</div>
</div>
{/* Cache Actions */}
<div className="space-y-2">
<div className="flex gap-2">
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="destructive"
size="sm"
className="flex-1"
>
{isClearing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
<Separator />
{/* Offline Downloads Section */}
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
{isOfflineSupported ? (
<Wifi className="h-4 w-4 text-green-600" />
) : (
<WifiOff className="h-4 w-4 text-red-600" />
)}
Offline Downloads
{!isOfflineSupported && (
<span className="text-xs text-muted-foreground">(Limited Support)</span>
)}
</h4>
{isOfflineSupported && (
<div className="grid grid-cols-3 gap-4 text-center mb-4">
<div className="space-y-1">
<p className="text-lg font-bold">{offlineStats.downloadedAlbums}</p>
<p className="text-xs text-muted-foreground">Albums</p>
</div>
<div className="space-y-1">
<p className="text-lg font-bold">{offlineStats.downloadedSongs}</p>
<p className="text-xs text-muted-foreground">Songs</p>
</div>
<div className="space-y-1">
<p className="text-lg font-bold">{formatSize(offlineStats.totalSize)}</p>
<p className="text-xs text-muted-foreground">Total Size</p>
</div>
</div>
)}
{/* Offline Mode Controls */}
{isOfflineSupported && (
<div className="space-y-4 mb-4 p-3 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="offline-mode" className="text-sm font-medium">
Offline Mode
</Label>
<p className="text-xs text-muted-foreground">
Force app to use only cached content (good for slow connections)
</p>
</div>
<Switch
id="offline-mode"
checked={offlineMode}
onCheckedChange={handleOfflineModeToggle}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="auto-download" className="text-sm font-medium">
Auto-download Queue
</Label>
<p className="text-xs text-muted-foreground">
Automatically download songs when added to queue
</p>
</div>
<Switch
id="auto-download"
checked={autoDownloadQueue}
onCheckedChange={handleAutoDownloadToggle}
/>
</div>
{/* Queue Download Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Queue</span>
<span className="text-xs text-muted-foreground">
{queue.length} song{queue.length !== 1 ? 's' : ''}
</span>
</div>
{queue.length > 0 && (
<Button
onClick={handleDownloadCurrentQueue}
disabled={isDownloadingQueue || downloadProgress.status === 'downloading'}
size="sm"
className="w-full"
variant="outline"
>
{isDownloadingQueue ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Music className="h-4 w-4 mr-2" />
)}
{isDownloadingQueue ? 'Downloading...' : 'Download Current Queue'}
</Button>
)}
{queue.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Add songs to queue to enable downloading
</p>
)}
</div>
</div>
)}
{/* Download Progress */}
{downloadProgress.status !== 'idle' && (
<div className="space-y-2 mb-4 p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{downloadProgress.status === 'downloading' && 'Downloading...'}
{downloadProgress.status === 'starting' && 'Starting download...'}
{downloadProgress.status === 'complete' && 'Download complete!'}
{downloadProgress.status === 'error' && 'Download failed'}
</span>
<Button
variant="ghost"
size="sm"
onClick={clearDownloadProgress}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{downloadProgress.total > 0 && (
<div className="space-y-1">
<Progress
value={(downloadProgress.completed / downloadProgress.total) * 100}
className="h-2"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.completed} / {downloadProgress.total} songs
{downloadProgress.failed > 0 && ` (${downloadProgress.failed} failed)`}
</span>
<span>{Math.round((downloadProgress.completed / downloadProgress.total) * 100)}%</span>
</div>
</div>
)}
{isClearing ? 'Clearing...' : 'Clear All Cache'}
</Button>
<Button
onClick={handleCleanExpired}
variant="outline"
size="sm"
className="flex-1"
>
<HardDrive className="h-4 w-4 mr-2" />
Clean Expired
</Button>
</div>
<Button
onClick={loadCacheStats}
variant="ghost"
size="sm"
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Stats
</Button>
{downloadProgress.currentSong && (
<p className="text-xs text-muted-foreground truncate">
Current: {downloadProgress.currentSong}
</p>
)}
{downloadProgress.error && (
<p className="text-xs text-red-600">
Error: {downloadProgress.error}
</p>
)}
</div>
)}
{/* Offline Items List */}
{offlineItems.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium">Downloaded Content</Label>
<div className="max-h-40 overflow-y-auto space-y-1">
{offlineItems.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="flex items-center justify-between p-2 bg-muted rounded text-sm"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Download className="h-3 w-3 text-green-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground truncate">
{item.artist} {item.type}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteOfflineItem(item)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{offlineItems.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Download className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No offline content downloaded</p>
<p className="text-xs">Visit an album page to download content for offline listening</p>
</div>
)}
</div>
{/* Cache Info */}
<div className="text-sm text-muted-foreground space-y-1">
<p>Cache includes albums, artists, songs, and image URLs to improve loading times.</p>
{isOfflineSupported && (
<p>Offline downloads use Service Workers for true offline audio playback.</p>
)}
{!isOfflineSupported && (
<p>Limited offline support - only metadata cached without Service Worker support.</p>
)}
{lastCleared && (
<p>Last cleared: {lastCleared}</p>
)}

View File

@@ -0,0 +1,226 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Download, Check, X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
interface OfflineIndicatorProps {
id: string;
type: 'album' | 'song';
className?: string;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
}
export function OfflineIndicator({
id,
type,
className,
showLabel = false,
size = 'md'
}: OfflineIndicatorProps) {
const [isOffline, setIsOffline] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const { checkOfflineStatus, isInitialized } = useOfflineDownloads();
useEffect(() => {
let mounted = true;
const checkStatus = async () => {
if (!isInitialized) return;
setIsChecking(true);
try {
const status = await checkOfflineStatus(id, type);
if (mounted) {
setIsOffline(status);
}
} catch (error) {
console.error('Failed to check offline status:', error);
if (mounted) {
setIsOffline(false);
}
} finally {
if (mounted) {
setIsChecking(false);
}
}
};
checkStatus();
return () => {
mounted = false;
};
}, [id, type, isInitialized, checkOfflineStatus]);
const iconSize = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
}[size];
const textSize = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}[size];
if (isChecking) {
return (
<div className={cn('flex items-center gap-1 text-muted-foreground', className)}>
<Loader2 className={cn(iconSize, 'animate-spin')} />
{showLabel && <span className={textSize}>Checking...</span>}
</div>
);
}
if (!isOffline) {
return null; // Don't show anything if not downloaded
}
return (
<div className={cn('flex items-center gap-1 text-green-600', className)}>
<Download className={iconSize} />
{showLabel && (
<span className={textSize}>
{type === 'album' ? 'Album Downloaded' : 'Downloaded'}
</span>
)}
</div>
);
}
interface DownloadButtonProps {
id: string;
type: 'album' | 'song';
onDownload?: () => void;
className?: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
children?: React.ReactNode;
}
export function DownloadButton({
id,
type,
onDownload,
className,
size = 'md',
variant = 'outline',
children
}: DownloadButtonProps) {
const [isOffline, setIsOffline] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const {
checkOfflineStatus,
deleteOfflineContent,
isInitialized,
downloadProgress
} = useOfflineDownloads();
const isDownloading = downloadProgress.status === 'downloading' || downloadProgress.status === 'starting';
useEffect(() => {
let mounted = true;
const checkStatus = async () => {
if (!isInitialized) return;
setIsChecking(true);
try {
const status = await checkOfflineStatus(id, type);
if (mounted) {
setIsOffline(status);
}
} catch (error) {
console.error('Failed to check offline status:', error);
if (mounted) {
setIsOffline(false);
}
} finally {
if (mounted) {
setIsChecking(false);
}
}
};
checkStatus();
return () => {
mounted = false;
};
}, [id, type, isInitialized, checkOfflineStatus]);
const handleClick = async () => {
if (isOffline) {
// Remove from offline storage
try {
await deleteOfflineContent(id, type);
setIsOffline(false);
} catch (error) {
console.error('Failed to delete offline content:', error);
}
} else {
// Start download
if (onDownload) {
onDownload();
}
}
};
const buttonSize = {
sm: 'sm',
md: 'default',
lg: 'lg'
}[size] as 'sm' | 'default' | 'lg';
const iconSize = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
}[size];
if (isChecking) {
return (
<Button
variant={variant}
size={buttonSize}
disabled
className={className}
>
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
{children || 'Checking...'}
</Button>
);
}
return (
<Button
variant={variant}
size={buttonSize}
onClick={handleClick}
disabled={isDownloading}
className={className}
>
{isDownloading ? (
<>
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
{children || 'Downloading...'}
</>
) : isOffline ? (
<>
<X className={cn(iconSize, 'mr-2')} />
{children || 'Remove Download'}
</>
) : (
<>
<Download className={cn(iconSize, 'mr-2')} />
{children || 'Download'}
</>
)}
</Button>
);
}

View File

@@ -0,0 +1,395 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/use-toast';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import {
Download,
Trash2,
RefreshCw,
Wifi,
WifiOff,
Database,
Clock,
AlertCircle,
CheckCircle,
Music,
User,
List,
HardDrive
} from 'lucide-react';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(date: Date | null): string {
if (!date) return 'Never';
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
}
export function OfflineManagement() {
const { toast } = useToast();
const [isClearing, setIsClearing] = useState(false);
const {
isInitialized,
isOnline,
isSyncing,
lastSync,
stats,
syncProgress,
syncLibraryFromServer,
syncPendingOperations,
clearOfflineData,
refreshStats
} = useOfflineLibrary();
// Refresh stats periodically
useEffect(() => {
const interval = setInterval(() => {
if (isInitialized && !isSyncing) {
refreshStats();
}
}, 10000); // Every 10 seconds
return () => clearInterval(interval);
}, [isInitialized, isSyncing, refreshStats]);
const handleFullSync = async () => {
try {
await syncLibraryFromServer();
toast({
title: "Sync Complete",
description: "Your music library has been synced for offline use.",
});
} catch (error) {
console.error('Full sync failed:', error);
toast({
title: "Sync Failed",
description: "Failed to sync library. Check your connection and try again.",
variant: "destructive"
});
}
};
const handlePendingSync = async () => {
try {
await syncPendingOperations();
toast({
title: "Pending Operations Synced",
description: "All pending changes have been synced to the server.",
});
} catch (error) {
console.error('Pending sync failed:', error);
toast({
title: "Sync Failed",
description: "Failed to sync pending operations. Will retry automatically when online.",
variant: "destructive"
});
}
};
const handleClearData = async () => {
if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) {
return;
}
setIsClearing(true);
try {
await clearOfflineData();
toast({
title: "Offline Data Cleared",
description: "All offline music data has been removed.",
});
} catch (error) {
console.error('Clear data failed:', error);
toast({
title: "Clear Failed",
description: "Failed to clear offline data. Please try again.",
variant: "destructive"
});
} finally {
setIsClearing(false);
}
};
if (!isInitialized) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Offline Library
</CardTitle>
<CardDescription>
Setting up offline library...
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Initializing offline storage...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Connection Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isOnline ? (
<Wifi className="h-5 w-5 text-green-500" />
) : (
<WifiOff className="h-5 w-5 text-red-500" />
)}
Connection Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={isOnline ? "default" : "destructive"}>
{isOnline ? "Online" : "Offline"}
</Badge>
<span className="text-sm text-muted-foreground">
{isOnline ? "Connected to Navidrome server" : "Working offline"}
</span>
</div>
{stats.pendingOperations > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-600">
{stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Sync Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
Library Sync
</CardTitle>
<CardDescription>
Keep your offline library up to date
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isSyncing && syncProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{syncProgress.stage}</span>
<span>{syncProgress.current}%</span>
</div>
<Progress value={syncProgress.current} className="w-full" />
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Last Sync</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(lastSync)}
</p>
</div>
<div className="flex gap-2">
{stats.pendingOperations > 0 && isOnline && (
<Button
variant="outline"
size="sm"
onClick={handlePendingSync}
disabled={isSyncing}
>
<RefreshCw className="h-4 w-4 mr-1" />
Sync Pending ({stats.pendingOperations})
</Button>
)}
<Button
onClick={handleFullSync}
disabled={!isOnline || isSyncing}
size="sm"
>
<Download className="h-4 w-4 mr-1" />
{isSyncing ? 'Syncing...' : 'Full Sync'}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Library Statistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Offline Library Stats
</CardTitle>
<CardDescription>
Your offline music collection
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<Music className="h-8 w-8 text-blue-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.albums.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Albums</p>
</div>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<User className="h-8 w-8 text-green-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.artists.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Artists</p>
</div>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<Music className="h-8 w-8 text-purple-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.songs.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Songs</p>
</div>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<List className="h-8 w-8 text-orange-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.playlists.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Playlists</p>
</div>
</div>
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
<span className="text-sm font-medium">Storage Used</span>
</div>
<span className="text-sm text-muted-foreground">
{formatBytes(stats.storageSize)}
</span>
</div>
</CardContent>
</Card>
{/* Offline Features */}
<Card>
<CardHeader>
<CardTitle>Offline Features</CardTitle>
<CardDescription>
What works when you&apos;re offline
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Browse & Search</p>
<p className="text-sm text-muted-foreground">
Browse your synced albums, artists, and search offline
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Favorites & Playlists</p>
<p className="text-sm text-muted-foreground">
Star songs/albums and create playlists (syncs when online)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Play Downloaded Music</p>
<p className="text-sm text-muted-foreground">
Play songs you&apos;ve downloaded for offline listening
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Auto-Sync</p>
<p className="text-sm text-muted-foreground">
Changes sync automatically when you reconnect
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-red-200">
<CardHeader>
<CardTitle className="text-red-600">Danger Zone</CardTitle>
<CardDescription>
Permanently delete all offline data
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Clear All Offline Data</p>
<p className="text-sm text-muted-foreground">
This will remove all synced library data and downloaded audio
</p>
</div>
<Button
variant="destructive"
onClick={handleClearData}
disabled={isClearing}
>
<Trash2 className="h-4 w-4 mr-1" />
{isClearing ? 'Clearing...' : 'Clear Data'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,367 @@
'use client';
import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
interface OfflineNavidromeContextType {
// Data (offline-first)
albums: Album[];
artists: Artist[];
playlists: Playlist[];
// Loading states
isLoading: boolean;
albumsLoading: boolean;
artistsLoading: boolean;
playlistsLoading: boolean;
// Connection state
isOnline: boolean;
isOfflineReady: boolean;
// Error states
error: string | null;
// Offline sync status
isSyncing: boolean;
lastSync: Date | null;
pendingOperations: number;
// Methods (offline-aware)
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>;
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>;
getPlaylists: () => Promise<Playlist[]>;
refreshData: () => Promise<void>;
// Offline-capable operations
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<Playlist>;
scrobble: (songId: string) => Promise<void>;
// Sync management
syncLibrary: () => Promise<void>;
syncPendingOperations: () => Promise<void>;
clearOfflineData: () => Promise<void>;
}
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
interface OfflineNavidromeProviderProps {
children: ReactNode;
}
export const OfflineNavidromeProvider: React.FC<OfflineNavidromeProviderProps> = ({ children }) => {
const [albums, setAlbums] = useState<Album[]>([]);
const [artists, setArtists] = useState<Artist[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [albumsLoading, setAlbumsLoading] = useState(false);
const [artistsLoading, setArtistsLoading] = useState(false);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use the original Navidrome context for online operations
const originalNavidrome = useNavidrome();
// Use offline library for offline operations
const {
isInitialized: isOfflineReady,
isOnline,
isSyncing,
lastSync,
stats,
syncLibraryFromServer,
syncPendingOperations: syncPendingOps,
getAlbums: getAlbumsOffline,
getArtists: getArtistsOffline,
getAlbum: getAlbumOffline,
getPlaylists: getPlaylistsOffline,
searchOffline,
starOffline,
unstarOffline,
createPlaylistOffline,
scrobbleOffline,
clearOfflineData: clearOfflineDataInternal,
refreshStats
} = useOfflineLibrary();
const isLoading = albumsLoading || artistsLoading || playlistsLoading;
const pendingOperations = stats.pendingOperations;
// Load initial data (offline-first approach)
const loadAlbums = useCallback(async () => {
setAlbumsLoading(true);
setError(null);
try {
const albumData = await getAlbumsOffline();
setAlbums(albumData);
} catch (err) {
console.error('Failed to load albums:', err);
setError('Failed to load albums');
} finally {
setAlbumsLoading(false);
}
}, [getAlbumsOffline]);
const loadArtists = useCallback(async () => {
setArtistsLoading(true);
setError(null);
try {
const artistData = await getArtistsOffline();
setArtists(artistData);
} catch (err) {
console.error('Failed to load artists:', err);
setError('Failed to load artists');
} finally {
setArtistsLoading(false);
}
}, [getArtistsOffline]);
const loadPlaylists = useCallback(async () => {
setPlaylistsLoading(true);
setError(null);
try {
const playlistData = await getPlaylistsOffline();
setPlaylists(playlistData);
} catch (err) {
console.error('Failed to load playlists:', err);
setError('Failed to load playlists');
} finally {
setPlaylistsLoading(false);
}
}, [getPlaylistsOffline]);
const refreshData = useCallback(async () => {
await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]);
await refreshStats();
}, [loadAlbums, loadArtists, loadPlaylists, refreshStats]);
// Initialize data when offline library is ready
useEffect(() => {
if (isOfflineReady) {
refreshData();
}
}, [isOfflineReady, refreshData]);
// Auto-sync when coming back online
useEffect(() => {
if (isOnline && isOfflineReady && pendingOperations > 0) {
console.log('Back online with pending operations, starting sync...');
syncPendingOps();
}
}, [isOnline, isOfflineReady, pendingOperations, syncPendingOps]);
// Offline-first methods
const searchMusic = useCallback(async (query: string) => {
setError(null);
try {
return await searchOffline(query);
} catch (err) {
console.error('Search failed:', err);
setError('Search failed');
return { artists: [], albums: [], songs: [] };
}
}, [searchOffline]);
const getAlbum = useCallback(async (albumId: string) => {
setError(null);
try {
return await getAlbumOffline(albumId);
} catch (err) {
console.error('Failed to get album:', err);
setError('Failed to get album');
return null;
}
}, [getAlbumOffline]);
const getArtist = useCallback(async (artistId: string): Promise<{ artist: Artist; albums: Album[] } | null> => {
setError(null);
try {
// For now, use the original implementation if online, or search offline
if (isOnline && originalNavidrome.api) {
return await originalNavidrome.getArtist(artistId);
} else {
// Try to find artist in offline data
const allArtists = await getArtistsOffline();
const artist = allArtists.find(a => a.id === artistId);
if (!artist) return null;
const allAlbums = await getAlbumsOffline();
const artistAlbums = allAlbums.filter(a => a.artistId === artistId);
return { artist, albums: artistAlbums };
}
} catch (err) {
console.error('Failed to get artist:', err);
setError('Failed to get artist');
return null;
}
}, [isOnline, originalNavidrome, getArtistsOffline, getAlbumsOffline]);
const getPlaylistsWrapper = useCallback(async (): Promise<Playlist[]> => {
try {
return await getPlaylistsOffline();
} catch (err) {
console.error('Failed to get playlists:', err);
return [];
}
}, [getPlaylistsOffline]);
// Offline-capable operations
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
setError(null);
try {
await starOffline(id, type);
// Refresh relevant data
if (type === 'album') {
await loadAlbums();
} else if (type === 'artist') {
await loadArtists();
}
} catch (err) {
console.error('Failed to star item:', err);
setError('Failed to star item');
throw err;
}
}, [starOffline, loadAlbums, loadArtists]);
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
setError(null);
try {
await unstarOffline(id, type);
// Refresh relevant data
if (type === 'album') {
await loadAlbums();
} else if (type === 'artist') {
await loadArtists();
}
} catch (err) {
console.error('Failed to unstar item:', err);
setError('Failed to unstar item');
throw err;
}
}, [unstarOffline, loadAlbums, loadArtists]);
const createPlaylist = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
setError(null);
try {
const playlist = await createPlaylistOffline(name, songIds);
await loadPlaylists(); // Refresh playlists
return playlist;
} catch (err) {
console.error('Failed to create playlist:', err);
setError('Failed to create playlist');
throw err;
}
}, [createPlaylistOffline, loadPlaylists]);
const scrobble = useCallback(async (songId: string) => {
try {
await scrobbleOffline(songId);
} catch (err) {
console.error('Failed to scrobble:', err);
// Don't set error state for scrobbling failures as they're not critical
}
}, [scrobbleOffline]);
// Sync management
const syncLibrary = useCallback(async () => {
setError(null);
try {
await syncLibraryFromServer();
await refreshData(); // Refresh local state after sync
} catch (err) {
console.error('Library sync failed:', err);
setError('Library sync failed');
throw err;
}
}, [syncLibraryFromServer, refreshData]);
const syncPendingOperations = useCallback(async () => {
try {
await syncPendingOps();
await refreshStats();
} catch (err) {
console.error('Failed to sync pending operations:', err);
// Don't throw or set error for pending operations sync
}
}, [syncPendingOps, refreshStats]);
const clearOfflineData = useCallback(async () => {
try {
await clearOfflineDataInternal();
setAlbums([]);
setArtists([]);
setPlaylists([]);
} catch (err) {
console.error('Failed to clear offline data:', err);
setError('Failed to clear offline data');
throw err;
}
}, [clearOfflineDataInternal]);
const value: OfflineNavidromeContextType = {
// Data
albums,
artists,
playlists,
// Loading states
isLoading,
albumsLoading,
artistsLoading,
playlistsLoading,
// Connection state
isOnline,
isOfflineReady,
// Error state
error,
// Offline sync status
isSyncing,
lastSync,
pendingOperations,
// Methods
searchMusic,
getAlbum,
getArtist,
getPlaylists: getPlaylistsWrapper,
refreshData,
// Offline-capable operations
starItem,
unstarItem,
createPlaylist,
scrobble,
// Sync management
syncLibrary,
syncPendingOperations,
clearOfflineData
};
return (
<OfflineNavidromeContext.Provider value={value}>
{children}
</OfflineNavidromeContext.Provider>
);
};
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
const context = useContext(OfflineNavidromeContext);
if (context === undefined) {
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
}
return context;
};

View File

@@ -0,0 +1,281 @@
'use client';
import React, { createContext, useContext, ReactNode } from 'react';
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
interface OfflineNavidromeContextType {
// All the original NavidromeContext methods but with offline-first behavior
getAlbums: (starred?: boolean) => Promise<Album[]>;
getArtists: (starred?: boolean) => Promise<Artist[]>;
getSongs: (albumId?: string, artistId?: string) => Promise<Song[]>;
getPlaylists: () => Promise<Playlist[]>;
// Offline-aware operations
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<void>;
updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise<void>;
deletePlaylist: (id: string) => Promise<void>;
scrobble: (songId: string) => Promise<void>;
// Offline state
isOfflineMode: boolean;
hasPendingOperations: boolean;
lastSync: Date | null;
}
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
interface OfflineNavidromeProviderInnerProps {
children: ReactNode;
}
// Inner component that has access to both contexts
const OfflineNavidromeProviderInner: React.FC<OfflineNavidromeProviderInnerProps> = ({ children }) => {
const navidromeContext = useNavidrome();
const offlineLibrary = useOfflineLibrary();
// Offline-first data retrieval methods
const getAlbums = async (starred?: boolean): Promise<Album[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
// Offline mode - get from IndexedDB
return await offlineLibrary.getAlbums(starred);
}
try {
// Online mode - try server first, fallback to offline
const albums = starred
? await navidromeContext.api.getAlbums('starred', 1000)
: await navidromeContext.api.getAlbums('alphabeticalByName', 1000);
return albums;
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getAlbums(starred);
}
};
const getArtists = async (starred?: boolean): Promise<Artist[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
return await offlineLibrary.getArtists(starred);
}
try {
const artists = await navidromeContext.api.getArtists();
if (starred) {
// Filter starred artists from the full list
const starredData = await navidromeContext.api.getStarred2();
const starredArtistIds = new Set(starredData.starred2.artist?.map(a => a.id) || []);
return artists.filter(artist => starredArtistIds.has(artist.id));
}
return artists;
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getArtists(starred);
}
};
const getSongs = async (albumId?: string, artistId?: string): Promise<Song[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
return await offlineLibrary.getSongs(albumId, artistId);
}
try {
if (albumId) {
const { songs } = await navidromeContext.api.getAlbum(albumId);
return songs;
} else if (artistId) {
const { albums } = await navidromeContext.api.getArtist(artistId);
const allSongs: Song[] = [];
for (const album of albums) {
const { songs } = await navidromeContext.api.getAlbum(album.id);
allSongs.push(...songs);
}
return allSongs;
} else {
return await navidromeContext.getAllSongs();
}
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getSongs(albumId, artistId);
}
};
const getPlaylists = async (): Promise<Playlist[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
return await offlineLibrary.getPlaylists();
}
try {
return await navidromeContext.api.getPlaylists();
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getPlaylists();
}
};
// Offline-aware operations (queue for sync when offline)
const starItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.starItem(id, type);
// Update offline data immediately
await offlineLibrary.starOffline(id, type);
return;
} catch (error) {
console.warn('Server star failed, queuing for sync:', error);
}
}
// Queue for sync when back online
await offlineLibrary.starOffline(id, type);
await offlineLibrary.queueSyncOperation({
type: 'star',
entityType: type,
entityId: id,
data: {}
});
};
const unstarItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.unstarItem(id, type);
await offlineLibrary.unstarOffline(id, type);
return;
} catch (error) {
console.warn('Server unstar failed, queuing for sync:', error);
}
}
await offlineLibrary.unstarOffline(id, type);
await offlineLibrary.queueSyncOperation({
type: 'unstar',
entityType: type,
entityId: id,
data: {}
});
};
const createPlaylist = async (name: string, songIds?: string[]): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
const playlist = await navidromeContext.createPlaylist(name, songIds);
await offlineLibrary.createPlaylistOffline(name, songIds || []);
return;
} catch (error) {
console.warn('Server playlist creation failed, queuing for sync:', error);
}
}
// Create offline
await offlineLibrary.createPlaylistOffline(name, songIds || []);
await offlineLibrary.queueSyncOperation({
type: 'create_playlist',
entityType: 'playlist',
entityId: 'temp-' + Date.now(),
data: { name, songIds: songIds || [] }
});
};
const updatePlaylist = async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.updatePlaylist(id, name, comment, songIds);
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
return;
} catch (error) {
console.warn('Server playlist update failed, queuing for sync:', error);
}
}
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
await offlineLibrary.queueSyncOperation({
type: 'update_playlist',
entityType: 'playlist',
entityId: id,
data: { name, comment, songIds }
});
};
const deletePlaylist = async (id: string): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.deletePlaylist(id);
await offlineLibrary.deletePlaylistOffline(id);
return;
} catch (error) {
console.warn('Server playlist deletion failed, queuing for sync:', error);
}
}
await offlineLibrary.deletePlaylistOffline(id);
await offlineLibrary.queueSyncOperation({
type: 'delete_playlist',
entityType: 'playlist',
entityId: id,
data: {}
});
};
const scrobble = async (songId: string): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.scrobble(songId);
return;
} catch (error) {
console.warn('Server scrobble failed, queuing for sync:', error);
}
}
await offlineLibrary.queueSyncOperation({
type: 'scrobble',
entityType: 'song',
entityId: songId,
data: { timestamp: Date.now() }
});
};
const contextValue: OfflineNavidromeContextType = {
getAlbums,
getArtists,
getSongs,
getPlaylists,
starItem,
unstarItem,
createPlaylist,
updatePlaylist,
deletePlaylist,
scrobble,
isOfflineMode: !offlineLibrary.isOnline,
hasPendingOperations: offlineLibrary.stats.pendingOperations > 0,
lastSync: offlineLibrary.lastSync
};
return (
<OfflineNavidromeContext.Provider value={contextValue}>
{children}
</OfflineNavidromeContext.Provider>
);
};
// Main provider component
export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<NavidromeProvider>
<OfflineNavidromeProviderInner>
{children}
</OfflineNavidromeProviderInner>
</NavidromeProvider>
);
};
// Hook to use the offline-aware Navidrome context
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
const context = useContext(OfflineNavidromeContext);
if (!context) {
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
}
return context;
};

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import { Wifi, WifiOff, Download, Clock } from 'lucide-react';
export function OfflineStatusIndicator() {
const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary();
if (!isOnline) {
return (
<Badge variant="secondary" className="flex items-center gap-1">
<WifiOff size={12} />
Offline Mode
</Badge>
);
}
if (isSyncing) {
return (
<Badge variant="default" className="flex items-center gap-1">
<Download size={12} className="animate-bounce" />
Syncing...
</Badge>
);
}
if (stats.pendingOperations > 0) {
return (
<Badge variant="outline" className="flex items-center gap-1">
<Clock size={12} />
{stats.pendingOperations} pending
</Badge>
);
}
return (
<Badge variant="default" className="flex items-center gap-1">
<Wifi size={12} />
Online
</Badge>
);
}
export function OfflineLibraryStats() {
const { stats, lastSync } = useOfflineLibrary();
if (!stats.albums && !stats.songs && !stats.artists) {
return null;
}
return (
<div className="text-xs text-muted-foreground space-y-1">
<div>
📀 {stats.albums} albums 🎵 {stats.songs} songs 👤 {stats.artists} artists
</div>
{lastSync && (
<div>
Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()}
</div>
)}
</div>
);
}

View File

@@ -11,7 +11,8 @@ function PathnameTracker() {
const searchParams = useSearchParams()
useEffect(() => {
if (posthogClient) {
// Only track if PostHog client is available and properly initialized
if (posthogClient && typeof posthogClient.capture === 'function') {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
@@ -31,20 +32,35 @@ function SuspendedPostHogPageView() {
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
})
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
// Only initialize PostHog if we have a valid key
if (posthogKey && posthogKey.trim() !== '') {
posthog.init(posthogKey, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});
} else {
console.log('PostHog not initialized - NEXT_PUBLIC_POSTHOG_KEY not provided');
}
}, [])
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
)
// Only provide PostHog context if we have a key
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (posthogKey && posthogKey.trim() !== '') {
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
);
}
// Return children without PostHog context if no key is provided
return <>{children}</>;
}

View File

@@ -2,7 +2,7 @@
import React from "react";
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider";
import { PostHogProvider } from "../components/PostHogProvider";
@@ -14,8 +14,20 @@ import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen";
import Image from "next/image";
// Service Worker registration
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered successfully:', registration);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
const { error } = useNavidrome();
// For now, since we're switching to offline-first, we'll handle errors differently
// The offline provider will handle connectivity issues automatically
const [isClient, setIsClient] = React.useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash
@@ -58,10 +70,9 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// Show start screen ONLY if:
// 1. First-time user (no onboarding completed), OR
// 2. User has completed onboarding BUT there's an error AND no config exists
const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig);
// Show start screen ONLY if first-time user (no onboarding completed)
// In offline-first mode, we don't need to check for errors since the app works offline
const shouldShowStartScreen = !hasCompletedOnboarding;
if (shouldShowStartScreen) {
return (
@@ -87,7 +98,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<DynamicViewportTheme />
<ThemeColorHandler />
<NavidromeConfigProvider>
<NavidromeProvider>
<OfflineNavidromeProvider>
<NavidromeErrorBoundary>
<AudioPlayerProvider>
<Ihateserverside>
@@ -96,7 +107,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<WhatsNewPopup />
</AudioPlayerProvider>
</NavidromeErrorBoundary>
</NavidromeProvider>
</OfflineNavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
</PostHogProvider>

View File

@@ -94,9 +94,9 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div className="w-4 h-4 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-2 h-2 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
@@ -106,8 +106,8 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
width={32}
height={32}
className="rounded-full"
/>
) : (
@@ -207,3 +207,4 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
);
}
}

View File

@@ -24,8 +24,9 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon";
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
import { Heart, Music, Disc, Mic, Play, Download } from "lucide-react";
import { Album, Artist, Song } from "@/lib/navidrome";
import { OfflineIndicator } from "@/app/components/OfflineIndicator";
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> {
album: Album
@@ -148,6 +149,16 @@ export function AlbumArtwork({
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
</div>
{/* Offline indicator in top-right corner */}
<div className="absolute top-2 right-2">
<OfflineIndicator
id={album.id}
type="album"
size="sm"
className="bg-black/60 text-white rounded-full p-1"
/>
</div>
</div>
<CardContent className="p-4">
<h3 className="font-semibold truncate">{album.name}</h3>

View File

@@ -26,12 +26,6 @@ export const metadata = {
'max-snippet': -1,
},
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
@@ -57,6 +51,13 @@ export const metadata = {
},
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
@@ -76,6 +77,7 @@ export default function Layout({ children }: LayoutProps) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="manifest" href="/manifest.json" />
<script
dangerouslySetInnerHTML={{
__html: `

View File

@@ -1,134 +0,0 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Mice',
short_name: 'Mice',
description: 'a very awesome navidrome client',
start_url: '/',
categories: ["music", "entertainment"],
display_override: ['window-controls-overlay'],
display: 'standalone',
background_color: '#0f0f0f',
theme_color: '#0f0f0f',
icons: [
{
src: '/favicon.ico',
type: 'image/x-icon',
sizes: '48x48'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '192x192'
},
{
src: '/icon-512.png',
type: 'image/png',
sizes: '512x512'
},
{
src: '/icon-192-maskable.png',
type: 'image/png',
sizes: '192x192',
purpose: 'maskable'
},
{
src: './icon-512-maskable.png',
type: 'image/png',
sizes: '512x512',
purpose: 'maskable'
},
// Apple Touch Icons for iOS
{
src: '/apple-touch-icon.png',
type: 'image/png',
sizes: '180x180',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '152x152',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '120x120',
purpose: 'any'
}
],
screenshots: [
{
src: '/home-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Home Preview',
form_factor: 'wide'
},
{
src: '/browse-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Browse Preview',
form_factor: 'wide'
},
{
src: '/album-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Album Preview',
form_factor: 'wide'
},
{
src: '/fullscreen-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Fullscreen Preview',
form_factor: 'wide'
}
],
shortcuts: [
{
name: 'Resume Song',
short_name: 'Resume',
description: 'Resume the last played song',
url: '/?action=resume',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Play Recent Albums',
short_name: 'Recent',
description: 'Play from recently added albums',
url: '/?action=recent',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Shuffle Favorites',
short_name: 'Shuffle',
description: 'Shuffle songs from favorite artists',
url: '/?action=shuffle-favorites',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
]
}
}

View File

@@ -14,6 +14,7 @@ import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-sh
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement';
import { CacheManagement } from '@/app/components/CacheManagement';
import { OfflineManagement } from '@/app/components/OfflineManagement';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
import { Settings, ExternalLink } from 'lucide-react';
@@ -712,6 +713,11 @@ const SettingsPage = () => {
<CacheManagement />
</div>
{/* Offline Library Management */}
<div className="break-inside-avoid mb-6">
<OfflineManagement />
</div>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Appearance</CardTitle>

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

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

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

View File

@@ -14,7 +14,7 @@ export function getGravatarUrl(
): string {
// Normalize email: trim whitespace and convert to lowercase
const normalizedEmail = email.trim().toLowerCase();
// i love md5 hash (no i dont)
// Generate MD5 hash of the email
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex');

782
lib/offline-library.ts Normal file
View File

@@ -0,0 +1,782 @@
'use client';
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
export interface NavidromeAPIInterface {
ping(): Promise<boolean>;
getAlbums(type?: string, size?: number): Promise<Album[]>;
getArtists(): Promise<Artist[]>;
getPlaylists(): Promise<Playlist[]>;
getAlbum(id: string): Promise<{ album: Album; songs: Song[] }>;
star(id: string, type: string): Promise<void>;
unstar(id: string, type: string): Promise<void>;
createPlaylist(name: string, songIds?: string[]): Promise<Playlist>;
updatePlaylist(id: string, name?: string, comment?: string, songIds?: string[]): Promise<void>;
deletePlaylist(id: string): Promise<void>;
scrobble(songId: string): Promise<void>;
}
export interface OfflineDatabase {
albums: Album[];
artists: Artist[];
songs: Song[];
playlists: Playlist[];
favorites: {
albums: string[];
artists: string[];
songs: string[];
};
syncQueue: SyncOperation[];
lastSync: number;
}
export interface SyncOperationData {
// For star/unstar operations
star?: boolean;
// For playlist operations
name?: string;
comment?: string;
songIds?: string[];
// For scrobble operations
timestamp?: number;
}
export interface SyncOperation {
id: string;
type: 'star' | 'unstar' | 'create_playlist' | 'update_playlist' | 'delete_playlist' | 'scrobble';
data: SyncOperationData;
timestamp: number;
retryCount: number;
entityType: 'song' | 'album' | 'artist' | 'playlist';
entityId: string;
}
export interface OfflineLibraryStats {
albums: number;
artists: number;
songs: number;
playlists: number;
lastSync: Date | null;
pendingOperations: number;
storageSize: number;
}
class OfflineLibraryManager {
private dbName = 'mice-offline-library';
private dbVersion = 1;
private db: IDBDatabase | null = null;
async initialize(): Promise<boolean> {
if (!('indexedDB' in window)) {
console.warn('IndexedDB not supported');
return false;
}
try {
this.db = await this.openDatabase();
return true;
} catch (error) {
console.error('Failed to initialize offline library:', error);
return false;
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores
if (!db.objectStoreNames.contains('albums')) {
const albumsStore = db.createObjectStore('albums', { keyPath: 'id' });
albumsStore.createIndex('artist', 'artist', { unique: false });
albumsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('artists')) {
const artistsStore = db.createObjectStore('artists', { keyPath: 'id' });
artistsStore.createIndex('name', 'name', { unique: false });
artistsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('songs')) {
const songsStore = db.createObjectStore('songs', { keyPath: 'id' });
songsStore.createIndex('albumId', 'albumId', { unique: false });
songsStore.createIndex('artistId', 'artistId', { unique: false });
songsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('playlists')) {
const playlistsStore = db.createObjectStore('playlists', { keyPath: 'id' });
playlistsStore.createIndex('name', 'name', { unique: false });
}
if (!db.objectStoreNames.contains('playlistSongs')) {
const playlistSongsStore = db.createObjectStore('playlistSongs', { keyPath: ['playlistId', 'songId'] });
playlistSongsStore.createIndex('playlistId', 'playlistId', { unique: false });
}
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id' });
syncStore.createIndex('timestamp', 'timestamp', { unique: false });
syncStore.createIndex('type', 'type', { unique: false });
}
if (!db.objectStoreNames.contains('metadata')) {
const metadataStore = db.createObjectStore('metadata', { keyPath: 'key' });
}
};
});
}
// Library sync methods
async syncFromServer(navidromeAPI: NavidromeAPIInterface): Promise<void> {
if (!this.db || !navidromeAPI) return;
try {
console.log('Starting full library sync...');
// Test connection
const isConnected = await navidromeAPI.ping();
if (!isConnected) {
throw new Error('No connection to Navidrome server');
}
// Sync albums
const albums = await navidromeAPI.getAlbums('alphabeticalByName', 5000);
await this.storeAlbums(albums);
// Sync artists
const artists = await navidromeAPI.getArtists();
await this.storeArtists(artists);
// Sync playlists
const playlists = await navidromeAPI.getPlaylists();
await this.storePlaylists(playlists);
// Sync songs for recently added albums (to avoid overwhelming the db)
const recentAlbums = albums.slice(0, 100);
for (const album of recentAlbums) {
try {
const { songs } = await navidromeAPI.getAlbum(album.id);
await this.storeSongs(songs);
} catch (error) {
console.warn(`Failed to sync songs for album ${album.id}:`, error);
}
}
// Update last sync timestamp
await this.setMetadata('lastSync', Date.now());
console.log('Library sync completed successfully');
} catch (error) {
console.error('Failed to sync library:', error);
throw error;
}
}
async syncPendingOperations(navidromeAPI: NavidromeAPIInterface): Promise<void> {
if (!this.db || !navidromeAPI) return;
const operations = await this.getAllSyncOperations();
for (const operation of operations) {
try {
await this.executeOperation(operation, navidromeAPI);
await this.removeSyncOperation(operation.id);
} catch (error) {
console.error(`Failed to sync operation ${operation.id}:`, error);
// Increment retry count
operation.retryCount++;
if (operation.retryCount < 3) {
await this.updateSyncOperation(operation);
} else {
// Remove after 3 failed attempts
await this.removeSyncOperation(operation.id);
}
}
}
}
private async executeOperation(operation: SyncOperation, api: NavidromeAPIInterface): Promise<void> {
switch (operation.type) {
case 'star':
await api.star(operation.entityId, operation.entityType);
break;
case 'unstar':
await api.unstar(operation.entityId, operation.entityType);
break;
case 'create_playlist':
if (operation.data.name) {
await api.createPlaylist(operation.data.name, operation.data.songIds);
}
break;
case 'update_playlist':
await api.updatePlaylist(operation.entityId, operation.data.name, operation.data.comment, operation.data.songIds);
break;
case 'delete_playlist':
await api.deletePlaylist(operation.entityId);
break;
case 'scrobble':
await api.scrobble(operation.entityId);
break;
}
}
// Data storage methods
async storeAlbums(albums: Album[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['albums'], 'readwrite');
const store = transaction.objectStore('albums');
for (const album of albums) {
store.put(album);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storeArtists(artists: Artist[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['artists'], 'readwrite');
const store = transaction.objectStore('artists');
for (const artist of artists) {
store.put(artist);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storeSongs(songs: Song[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['songs'], 'readwrite');
const store = transaction.objectStore('songs');
for (const song of songs) {
store.put(song);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storePlaylists(playlists: Playlist[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
for (const playlist of playlists) {
store.put(playlist);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Data retrieval methods
async getAlbums(starred?: boolean): Promise<Album[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
let request: IDBRequest;
if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getArtists(starred?: boolean): Promise<Artist[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['artists'], 'readonly');
const store = transaction.objectStore('artists');
let request: IDBRequest;
if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getSongs(albumId?: string, artistId?: string, starred?: boolean): Promise<Song[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['songs'], 'readonly');
const store = transaction.objectStore('songs');
let request: IDBRequest;
if (albumId) {
const index = store.index('albumId');
request = index.getAll(albumId);
} else if (artistId) {
const index = store.index('artistId');
request = index.getAll(artistId);
} else if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getPlaylists(): Promise<Playlist[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['playlists'], 'readonly');
const store = transaction.objectStore('playlists');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getAlbum(id: string): Promise<{ album: Album; songs: Song[] } | null> {
if (!this.db) return null;
try {
const [album, songs] = await Promise.all([
this.getAlbumById(id),
this.getSongs(id)
]);
if (!album) return null;
return { album, songs };
} catch (error) {
console.error('Failed to get album:', error);
return null;
}
}
private async getAlbumById(id: string): Promise<Album | null> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
// Sync queue methods
async addSyncOperation(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
if (!this.db) return;
const fullOperation: SyncOperation = {
...operation,
id: `${operation.type}_${operation.entityId}_${Date.now()}`,
timestamp: Date.now(),
retryCount: 0
};
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.put(fullOperation);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private async getAllSyncOperations(): Promise<SyncOperation[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['syncQueue'], 'readonly');
const store = transaction.objectStore('syncQueue');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
private async updateSyncOperation(operation: SyncOperation): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.put(operation);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private async removeSyncOperation(id: string): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.delete(id);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Metadata methods
async setMetadata(key: string, value: unknown): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['metadata'], 'readwrite');
const store = transaction.objectStore('metadata');
store.put({ key, value });
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getMetadata(key: string): Promise<unknown> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['metadata'], 'readonly');
const store = transaction.objectStore('metadata');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(request.error);
});
}
// Search methods
async searchOffline(query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> {
if (!this.db) return { artists: [], albums: [], songs: [] };
try {
const [allAlbums, allArtists, allSongs] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs()
]);
const searchLower = query.toLowerCase();
const albums = allAlbums.filter(album =>
album.name.toLowerCase().includes(searchLower) ||
album.artist.toLowerCase().includes(searchLower)
);
const artists = allArtists.filter(artist =>
artist.name.toLowerCase().includes(searchLower)
);
const songs = allSongs.filter(song =>
song.title.toLowerCase().includes(searchLower) ||
(song.artist && song.artist.toLowerCase().includes(searchLower)) ||
(song.album && song.album.toLowerCase().includes(searchLower))
);
return { artists, albums, songs };
} catch (error) {
console.error('Offline search failed:', error);
return { artists: [], albums: [], songs: [] };
}
}
// Offline favorites management
async starOffline(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) return;
// Add to sync queue
await this.addSyncOperation({
type: 'star',
entityType: type,
entityId: id,
data: {}
});
// Update local data
const storeName = type === 'song' ? 'songs' : type === 'album' ? 'albums' : 'artists';
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
item.starred = new Date().toISOString();
store.put(item);
}
};
}
async unstarOffline(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) return;
// Add to sync queue
await this.addSyncOperation({
type: 'unstar',
entityType: type,
entityId: id,
data: {}
});
// Update local data
const storeName = type === 'song' ? 'songs' : type === 'album' ? 'albums' : 'artists';
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
delete item.starred;
store.put(item);
}
};
}
// Playlist management
async createPlaylistOffline(name: string, songIds?: string[]): Promise<Playlist> {
if (!this.db) throw new Error('Database not initialized');
const playlistId = `offline_${Date.now()}`;
const playlist: Playlist = {
id: playlistId,
name,
comment: '',
owner: 'offline',
public: false,
songCount: songIds?.length || 0,
duration: 0,
created: new Date().toISOString(),
changed: new Date().toISOString()
};
// Store playlist
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
store.put(playlist);
// Add to sync queue
await this.addSyncOperation({
type: 'create_playlist',
entityType: 'playlist',
entityId: playlistId,
data: { name, songIds }
});
return playlist;
}
// Update playlist
async updatePlaylist(id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists', 'playlistSongs'], 'readwrite');
const playlistStore = transaction.objectStore('playlists');
const playlistSongsStore = transaction.objectStore('playlistSongs');
// Get existing playlist
const playlist = await new Promise<Playlist>((resolve, reject) => {
const request = playlistStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!playlist) {
throw new Error('Playlist not found');
}
// Update playlist metadata
const updatedPlaylist = {
...playlist,
...(name && { name }),
...(comment && { comment }),
updatedAt: new Date().toISOString()
};
playlistStore.put(updatedPlaylist);
// Update song associations if provided
if (songIds) {
// Remove existing songs
const existingSongs = await new Promise<{ playlistId: string; songId: string }[]>((resolve, reject) => {
const index = playlistSongsStore.index('playlistId');
const request = index.getAll(id);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
for (const song of existingSongs) {
playlistSongsStore.delete([song.playlistId, song.songId]);
}
// Add new songs
for (const songId of songIds) {
playlistSongsStore.put({
playlistId: id,
songId: songId
});
}
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Delete playlist
async deletePlaylist(id: string): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists', 'playlistSongs'], 'readwrite');
const playlistStore = transaction.objectStore('playlists');
const playlistSongsStore = transaction.objectStore('playlistSongs');
// Delete playlist
playlistStore.delete(id);
// Delete associated songs
const index = playlistSongsStore.index('playlistId');
const songs = await new Promise<{ playlistId: string; songId: string }[]>((resolve, reject) => {
const request = index.getAll(id);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
for (const song of songs) {
playlistSongsStore.delete([song.playlistId, song.songId]);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Statistics
async getLibraryStats(): Promise<OfflineLibraryStats> {
if (!this.db) {
return {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
};
}
try {
const [albums, artists, songs, playlists, syncOps, lastSync] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists(),
this.getAllSyncOperations(),
this.getMetadata('lastSync')
]);
// Estimate storage size (rough calculation)
const storageSize = this.estimateStorageSize(albums, artists, songs, playlists);
return {
albums: albums.length,
artists: artists.length,
songs: songs.length,
playlists: playlists.length,
lastSync: lastSync && typeof lastSync === 'number' ? new Date(lastSync) : null,
pendingOperations: syncOps.length,
storageSize
};
} catch (error) {
console.error('Failed to get library stats:', error);
return {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
};
}
}
private estimateStorageSize(albums: Album[], artists: Artist[], songs: Song[], playlists: Playlist[]): number {
// Rough estimation: each item is approximately 1KB in JSON
return (albums.length + artists.length + songs.length + playlists.length) * 1024;
}
// Clear all data
async clearAllData(): Promise<void> {
if (!this.db) return;
const storeNames = ['albums', 'artists', 'songs', 'playlists', 'playlistSongs', 'syncQueue', 'metadata'];
for (const storeName of storeNames) {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
await new Promise<void>((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
}
export const offlineLibraryManager = new OfflineLibraryManager();

View File

@@ -45,7 +45,7 @@ const nextConfig = {
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
value: "default-src 'self' *; connect-src 'self' *; script-src 'self'",
},
],
},

129
public/manifest.json Normal file
View File

@@ -0,0 +1,129 @@
{
"name": "Mice",
"short_name": "Mice",
"description": "a very awesome navidrome client",
"start_url": "/",
"categories": ["music", "entertainment"],
"display_override": ["window-controls-overlay"],
"display": "standalone",
"background_color": "#0f0f0f",
"theme_color": "#0f0f0f",
"icons": [
{
"src": "/favicon.ico",
"type": "image/x-icon",
"sizes": "48x48"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon-192-maskable.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
{
"src": "/icon-512-maskable.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180",
"purpose": "any"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "152x152",
"purpose": "any"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "120x120",
"purpose": "any"
}
],
"screenshots": [
{
"src": "/home-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Home Preview",
"form_factor": "wide"
},
{
"src": "/browse-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Browse Preview",
"form_factor": "wide"
},
{
"src": "/album-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Album Preview",
"form_factor": "wide"
},
{
"src": "/fullscreen-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Fullscreen Preview",
"form_factor": "wide"
}
],
"shortcuts": [
{
"name": "Resume Song",
"short_name": "Resume",
"description": "Resume the last played song",
"url": "/?action=resume",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Play Recent Albums",
"short_name": "Recent",
"description": "Play from recently added albums",
"url": "/?action=recent",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Shuffle Favorites",
"short_name": "Shuffle",
"description": "Shuffle songs from favorite artists",
"url": "/?action=shuffle-favorites",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
]
}

0
public/sw.js Normal file
View File