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:
168
OFFLINE_DOWNLOADS.md
Normal file
168
OFFLINE_DOWNLOADS.md
Normal 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
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
226
app/components/OfflineIndicator.tsx
Normal file
226
app/components/OfflineIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
395
app/components/OfflineManagement.tsx
Normal file
395
app/components/OfflineManagement.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
367
app/components/OfflineNavidromeContext.tsx
Normal file
367
app/components/OfflineNavidromeContext.tsx
Normal 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;
|
||||
};
|
||||
281
app/components/OfflineNavidromeProvider.tsx
Normal file
281
app/components/OfflineNavidromeProvider.tsx
Normal 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;
|
||||
};
|
||||
65
app/components/OfflineStatusIndicator.tsx
Normal file
65
app/components/OfflineStatusIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: `
|
||||
|
||||
134
app/manifest.ts
134
app/manifest.ts
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
279
hooks/use-offline-audio-player.ts
Normal file
279
hooks/use-offline-audio-player.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
|
||||
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
export interface OfflineTrack extends Track {
|
||||
isOffline?: boolean;
|
||||
offlineUrl?: string;
|
||||
}
|
||||
|
||||
export function useOfflineAudioPlayer() {
|
||||
const {
|
||||
playTrack,
|
||||
addToQueue,
|
||||
currentTrack,
|
||||
...audioPlayerProps
|
||||
} = useAudioPlayer();
|
||||
|
||||
const { isSupported: isOfflineSupported, checkOfflineStatus } = useOfflineDownloads();
|
||||
const { isOnline, scrobbleOffline } = useOfflineLibrary();
|
||||
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
// Convert song to track with offline awareness
|
||||
const songToTrack = useCallback(async (song: Song): Promise<OfflineTrack> => {
|
||||
let track: OfflineTrack = {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
artist: song.artist,
|
||||
album: song.album || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
// Check if song is available offline
|
||||
if (isOfflineSupported) {
|
||||
const offlineStatus = await checkOfflineStatus(song.id, 'song');
|
||||
if (offlineStatus) {
|
||||
track.isOffline = true;
|
||||
track.offlineUrl = `offline-song-${song.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
return track;
|
||||
}, [api, isOfflineSupported, checkOfflineStatus]);
|
||||
|
||||
// Play track with offline fallback
|
||||
const playTrackOffline = useCallback(async (song: Song | OfflineTrack) => {
|
||||
try {
|
||||
let track: OfflineTrack;
|
||||
|
||||
if ('url' in song) {
|
||||
// Already a track
|
||||
track = song as OfflineTrack;
|
||||
} else {
|
||||
// Convert song to track
|
||||
track = await songToTrack(song);
|
||||
}
|
||||
|
||||
// If offline and track has offline URL, use that
|
||||
if (!isOnline && track.isOffline && track.offlineUrl) {
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
|
||||
playTrack(track);
|
||||
|
||||
// Scrobble with offline support
|
||||
scrobbleOffline(track.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to play track:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, scrobbleOffline, isOnline]);
|
||||
|
||||
// Play album with offline awareness
|
||||
const playAlbumOffline = useCallback(async (album: Album, songs: Song[], startIndex: number = 0) => {
|
||||
try {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
// Convert all songs to tracks with offline awareness
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
// Filter to only available tracks (online or offline)
|
||||
const availableTracks = tracks.filter((track: OfflineTrack) => {
|
||||
if (isOnline) return true; // All tracks available when online
|
||||
return track.isOffline; // Only offline tracks when offline
|
||||
});
|
||||
|
||||
if (availableTracks.length === 0) {
|
||||
throw new Error('No tracks available for playback');
|
||||
}
|
||||
|
||||
// Adjust start index if needed
|
||||
const safeStartIndex = Math.min(startIndex, availableTracks.length - 1);
|
||||
|
||||
// Play first track
|
||||
playTrack(availableTracks[safeStartIndex]);
|
||||
|
||||
// Add remaining tracks to queue
|
||||
const remainingTracks = [
|
||||
...availableTracks.slice(safeStartIndex + 1),
|
||||
...availableTracks.slice(0, safeStartIndex)
|
||||
];
|
||||
|
||||
remainingTracks.forEach(track => addToQueue(track));
|
||||
|
||||
// Scrobble first track
|
||||
scrobbleOffline(availableTracks[safeStartIndex].id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to play album offline:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
|
||||
|
||||
// Add track to queue with offline awareness
|
||||
const addToQueueOffline = useCallback(async (song: Song | OfflineTrack) => {
|
||||
try {
|
||||
let track: OfflineTrack;
|
||||
|
||||
if ('url' in song) {
|
||||
track = song as OfflineTrack;
|
||||
} else {
|
||||
track = await songToTrack(song);
|
||||
}
|
||||
|
||||
// Check if track is available
|
||||
if (!isOnline && !track.isOffline) {
|
||||
throw new Error('Track not available offline');
|
||||
}
|
||||
|
||||
// If offline and track has offline URL, use that
|
||||
if (!isOnline && track.isOffline && track.offlineUrl) {
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
|
||||
addToQueue(track);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to add track to queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, addToQueue, isOnline]);
|
||||
|
||||
// Shuffle play with offline awareness
|
||||
const shufflePlayOffline = useCallback(async (songs: Song[]) => {
|
||||
try {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
// Convert all songs to tracks
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
// Filter available tracks
|
||||
const availableTracks = tracks.filter((track: OfflineTrack) => {
|
||||
if (isOnline) return true;
|
||||
return track.isOffline;
|
||||
});
|
||||
|
||||
if (availableTracks.length === 0) {
|
||||
throw new Error('No tracks available for shuffle play');
|
||||
}
|
||||
|
||||
// Shuffle the available tracks
|
||||
const shuffledTracks = [...availableTracks].sort(() => Math.random() - 0.5);
|
||||
|
||||
// Play first track
|
||||
playTrack(shuffledTracks[0]);
|
||||
|
||||
// Add remaining tracks to queue
|
||||
shuffledTracks.slice(1).forEach(track => addToQueue(track));
|
||||
|
||||
// Scrobble first track
|
||||
scrobbleOffline(shuffledTracks[0].id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle play offline:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
|
||||
|
||||
// Get availability info for a song
|
||||
const getTrackAvailability = useCallback(async (song: Song): Promise<{
|
||||
isAvailable: boolean;
|
||||
isOffline: boolean;
|
||||
requiresConnection: boolean;
|
||||
}> => {
|
||||
try {
|
||||
const track = await songToTrack(song);
|
||||
|
||||
return {
|
||||
isAvailable: isOnline || !!track.isOffline,
|
||||
isOffline: !!track.isOffline,
|
||||
requiresConnection: !track.isOffline
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check track availability:', error);
|
||||
return {
|
||||
isAvailable: false,
|
||||
isOffline: false,
|
||||
requiresConnection: true
|
||||
};
|
||||
}
|
||||
}, [songToTrack, isOnline]);
|
||||
|
||||
// Get album availability info
|
||||
const getAlbumAvailability = useCallback(async (songs: Song[]): Promise<{
|
||||
totalTracks: number;
|
||||
availableTracks: number;
|
||||
offlineTracks: number;
|
||||
onlineOnlyTracks: number;
|
||||
}> => {
|
||||
try {
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
const offlineTracks = tracks.filter((t: OfflineTrack) => t.isOffline).length;
|
||||
const onlineOnlyTracks = tracks.filter((t: OfflineTrack) => !t.isOffline).length;
|
||||
const availableTracks = isOnline ? tracks.length : offlineTracks;
|
||||
|
||||
return {
|
||||
totalTracks: tracks.length,
|
||||
availableTracks,
|
||||
offlineTracks,
|
||||
onlineOnlyTracks
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check album availability:', error);
|
||||
return {
|
||||
totalTracks: songs.length,
|
||||
availableTracks: 0,
|
||||
offlineTracks: 0,
|
||||
onlineOnlyTracks: songs.length
|
||||
};
|
||||
}
|
||||
}, [songToTrack, isOnline]);
|
||||
|
||||
// Enhanced track info with offline status
|
||||
const getCurrentTrackInfo = useCallback(() => {
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const offlineTrack = currentTrack as OfflineTrack;
|
||||
|
||||
return {
|
||||
...currentTrack,
|
||||
isAvailableOffline: offlineTrack.isOffline || false,
|
||||
isPlayingOffline: !isOnline && !!offlineTrack.isOffline
|
||||
};
|
||||
}, [currentTrack, isOnline]);
|
||||
|
||||
return {
|
||||
// Original audio player props
|
||||
...audioPlayerProps,
|
||||
currentTrack,
|
||||
|
||||
// Enhanced offline methods
|
||||
playTrackOffline,
|
||||
playAlbumOffline,
|
||||
addToQueueOffline,
|
||||
shufflePlayOffline,
|
||||
|
||||
// Utility methods
|
||||
songToTrack,
|
||||
getTrackAvailability,
|
||||
getAlbumAvailability,
|
||||
getCurrentTrackInfo,
|
||||
|
||||
// State
|
||||
isOnline,
|
||||
isOfflineSupported
|
||||
};
|
||||
}
|
||||
452
hooks/use-offline-downloads.ts
Normal file
452
hooks/use-offline-downloads.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
|
||||
export interface DownloadProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error';
|
||||
currentSong?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OfflineItem {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
name: string;
|
||||
artist: string;
|
||||
downloadedAt: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface OfflineStats {
|
||||
totalSize: number;
|
||||
audioSize: number;
|
||||
imageSize: number;
|
||||
metaSize: number;
|
||||
downloadedAlbums: number;
|
||||
downloadedSongs: number;
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
private worker: ServiceWorker | null = null;
|
||||
private messageChannel: MessageChannel | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
console.log('Service Worker registered:', registration);
|
||||
|
||||
// Wait for the service worker to be ready
|
||||
const readyRegistration = await navigator.serviceWorker.ready;
|
||||
this.worker = readyRegistration.active;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async sendMessage(type: string, data: any): Promise<any> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type: responseType, data: responseData } = event.data;
|
||||
if (responseType.includes('ERROR')) {
|
||||
reject(new Error(responseData.error));
|
||||
} else {
|
||||
resolve(responseData);
|
||||
}
|
||||
};
|
||||
|
||||
this.worker!.postMessage({ type, data }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadAlbum(
|
||||
album: Album,
|
||||
songs: Song[],
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<void> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'DOWNLOAD_PROGRESS':
|
||||
if (onProgress) {
|
||||
onProgress(data);
|
||||
}
|
||||
break;
|
||||
case 'DOWNLOAD_COMPLETE':
|
||||
resolve();
|
||||
break;
|
||||
case 'DOWNLOAD_ERROR':
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Add stream URLs to songs
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'DOWNLOAD_ALBUM',
|
||||
data: { album, songs: songsWithUrls }
|
||||
}, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadSong(song: Song): Promise<void> {
|
||||
const songWithUrl = {
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
};
|
||||
|
||||
return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
|
||||
}
|
||||
|
||||
async downloadQueue(songs: Song[]): Promise<void> {
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getStreamUrl(song.id)
|
||||
}));
|
||||
|
||||
return this.sendMessage('DOWNLOAD_QUEUE', { songs: songsWithUrls });
|
||||
}
|
||||
|
||||
async enableOfflineMode(settings: {
|
||||
autoDownloadQueue?: boolean;
|
||||
forceOffline?: boolean;
|
||||
currentQueue?: Song[];
|
||||
}): Promise<void> {
|
||||
return this.sendMessage('ENABLE_OFFLINE_MODE', settings);
|
||||
}
|
||||
|
||||
async checkOfflineStatus(id: string, type: 'album' | 'song'): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.sendMessage('CHECK_OFFLINE_STATUS', { id, type });
|
||||
return result.isAvailable;
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOfflineContent(id: string, type: 'album' | 'song'): Promise<void> {
|
||||
return this.sendMessage('DELETE_OFFLINE_CONTENT', { id, type });
|
||||
}
|
||||
|
||||
async getOfflineStats(): Promise<OfflineStats> {
|
||||
return this.sendMessage('GET_OFFLINE_STATS', {});
|
||||
}
|
||||
|
||||
private getStreamUrl(songId: string): string {
|
||||
// This should match your actual Navidrome stream URL format
|
||||
const config = JSON.parse(localStorage.getItem('navidrome-config') || '{}');
|
||||
if (!config.serverUrl) {
|
||||
throw new Error('Navidrome server not configured');
|
||||
}
|
||||
|
||||
return `${config.serverUrl}/rest/stream?id=${songId}&u=${config.username}&p=${config.password}&c=mice&f=json`;
|
||||
}
|
||||
|
||||
// LocalStorage fallback for browsers without service worker support
|
||||
async downloadAlbumFallback(album: Album, songs: Song[]): Promise<void> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
// Store album metadata
|
||||
offlineData.albums[album.id] = {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
downloadedAt: Date.now(),
|
||||
songCount: songs.length,
|
||||
songs: songs.map(song => song.id)
|
||||
};
|
||||
|
||||
// Mark songs as downloaded (metadata only in localStorage fallback)
|
||||
songs.forEach(song => {
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
this.saveOfflineData(offlineData);
|
||||
}
|
||||
|
||||
public getOfflineData() {
|
||||
const stored = localStorage.getItem('offline-downloads');
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse offline data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
albums: {},
|
||||
songs: {},
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
public saveOfflineData(data: any) {
|
||||
data.lastUpdated = Date.now();
|
||||
localStorage.setItem('offline-downloads', JSON.stringify(data));
|
||||
}
|
||||
|
||||
async checkOfflineStatusFallback(id: string, type: 'album' | 'song'): Promise<boolean> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
if (type === 'album') {
|
||||
return !!offlineData.albums[id];
|
||||
} else {
|
||||
return !!offlineData.songs[id];
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOfflineContentFallback(id: string, type: 'album' | 'song'): Promise<void> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
if (type === 'album') {
|
||||
const album = offlineData.albums[id];
|
||||
if (album && album.songs) {
|
||||
// Remove associated songs
|
||||
album.songs.forEach((songId: string) => {
|
||||
delete offlineData.songs[songId];
|
||||
});
|
||||
}
|
||||
delete offlineData.albums[id];
|
||||
} else {
|
||||
delete offlineData.songs[id];
|
||||
}
|
||||
|
||||
this.saveOfflineData(offlineData);
|
||||
}
|
||||
|
||||
getOfflineAlbums(): OfflineItem[] {
|
||||
const offlineData = this.getOfflineData();
|
||||
return Object.values(offlineData.albums).map((album: any) => ({
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
downloadedAt: album.downloadedAt
|
||||
}));
|
||||
}
|
||||
|
||||
getOfflineSongs(): OfflineItem[] {
|
||||
const offlineData = this.getOfflineData();
|
||||
return Object.values(offlineData.songs).map((song: any) => ({
|
||||
id: song.id,
|
||||
type: 'song' as const,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
downloadedAt: song.downloadedAt
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const downloadManager = new DownloadManager();
|
||||
|
||||
export function useOfflineDownloads() {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
|
||||
const [offlineStats, setOfflineStats] = useState<OfflineStats>({
|
||||
totalSize: 0,
|
||||
audioSize: 0,
|
||||
imageSize: 0,
|
||||
metaSize: 0,
|
||||
downloadedAlbums: 0,
|
||||
downloadedSongs: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDownloadManager = async () => {
|
||||
const supported = await downloadManager.initialize();
|
||||
setIsSupported(supported);
|
||||
setIsInitialized(true);
|
||||
|
||||
if (supported) {
|
||||
// Load initial stats
|
||||
try {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
} catch (error) {
|
||||
console.error('Failed to load offline stats:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeDownloadManager();
|
||||
}, []);
|
||||
|
||||
const downloadAlbum = useCallback(async (album: Album, songs: Song[]) => {
|
||||
try {
|
||||
if (isSupported) {
|
||||
await downloadManager.downloadAlbum(album, songs, setDownloadProgress);
|
||||
} else {
|
||||
// Fallback to localStorage metadata only
|
||||
await downloadManager.downloadAlbumFallback(album, songs);
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
if (isSupported) {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
setDownloadProgress(prev => ({ ...prev, status: 'error', error: (error as Error).message }));
|
||||
throw error;
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const downloadSong = useCallback(async (song: Song) => {
|
||||
if (isSupported) {
|
||||
await downloadManager.downloadSong(song);
|
||||
} else {
|
||||
// Fallback - just save metadata
|
||||
const offlineData = downloadManager.getOfflineData();
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
downloadManager.saveOfflineData(offlineData);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const checkOfflineStatus = useCallback(async (id: string, type: 'album' | 'song'): Promise<boolean> => {
|
||||
if (isSupported) {
|
||||
return downloadManager.checkOfflineStatus(id, type);
|
||||
} else {
|
||||
return downloadManager.checkOfflineStatusFallback(id, type);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const deleteOfflineContent = useCallback(async (id: string, type: 'album' | 'song') => {
|
||||
if (isSupported) {
|
||||
await downloadManager.deleteOfflineContent(id, type);
|
||||
} else {
|
||||
await downloadManager.deleteOfflineContentFallback(id, type);
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
if (isSupported) {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const getOfflineItems = useCallback((): OfflineItem[] => {
|
||||
const albums = downloadManager.getOfflineAlbums();
|
||||
const songs = downloadManager.getOfflineSongs();
|
||||
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
|
||||
}, []);
|
||||
|
||||
const clearDownloadProgress = useCallback(() => {
|
||||
setDownloadProgress({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
}, []);
|
||||
|
||||
const downloadQueue = useCallback(async (songs: Song[]) => {
|
||||
if (isSupported) {
|
||||
setDownloadProgress({ completed: 0, total: songs.length, failed: 0, status: 'downloading' });
|
||||
|
||||
try {
|
||||
await downloadManager.downloadQueue(songs);
|
||||
// Stats will be updated via progress events
|
||||
} catch (error) {
|
||||
console.error('Queue download failed:', error);
|
||||
setDownloadProgress(prev => ({ ...prev, status: 'error' }));
|
||||
}
|
||||
} else {
|
||||
// Fallback: just store metadata
|
||||
const offlineData = downloadManager.getOfflineData();
|
||||
songs.forEach(song => {
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
});
|
||||
downloadManager.saveOfflineData(offlineData);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const enableOfflineMode = useCallback(async (settings: {
|
||||
autoDownloadQueue?: boolean;
|
||||
forceOffline?: boolean;
|
||||
currentQueue?: Song[];
|
||||
}) => {
|
||||
if (isSupported) {
|
||||
try {
|
||||
await downloadManager.enableOfflineMode(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to enable offline mode:', error);
|
||||
}
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isInitialized,
|
||||
downloadProgress,
|
||||
offlineStats,
|
||||
downloadAlbum,
|
||||
downloadSong,
|
||||
downloadQueue,
|
||||
enableOfflineMode,
|
||||
checkOfflineStatus,
|
||||
deleteOfflineContent,
|
||||
getOfflineItems,
|
||||
clearDownloadProgress
|
||||
};
|
||||
}
|
||||
|
||||
// Export the manager instance for direct use if needed
|
||||
export { downloadManager };
|
||||
535
hooks/use-offline-library.ts
Normal file
535
hooks/use-offline-library.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { offlineLibraryManager, type OfflineLibraryStats, type SyncOperation } from '@/lib/offline-library';
|
||||
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
|
||||
export interface OfflineLibraryState {
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
lastSync: Date | null;
|
||||
stats: OfflineLibraryStats;
|
||||
syncProgress: {
|
||||
current: number;
|
||||
total: number;
|
||||
stage: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useOfflineLibrary() {
|
||||
const [state, setState] = useState<OfflineLibraryState>({
|
||||
isInitialized: false,
|
||||
isOnline: navigator.onLine,
|
||||
isSyncing: false,
|
||||
lastSync: null,
|
||||
stats: {
|
||||
albums: 0,
|
||||
artists: 0,
|
||||
songs: 0,
|
||||
playlists: 0,
|
||||
lastSync: null,
|
||||
pendingOperations: 0,
|
||||
storageSize: 0
|
||||
},
|
||||
syncProgress: null
|
||||
});
|
||||
|
||||
const { api } = useNavidrome();
|
||||
|
||||
// Initialize offline library
|
||||
useEffect(() => {
|
||||
const initializeOfflineLibrary = async () => {
|
||||
try {
|
||||
const initialized = await offlineLibraryManager.initialize();
|
||||
if (initialized) {
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
stats,
|
||||
lastSync: stats.lastSync
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize offline library:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeOfflineLibrary();
|
||||
}, []);
|
||||
|
||||
// Listen for online/offline events
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setState(prev => ({ ...prev, isOnline: true }));
|
||||
// Automatically sync when back online
|
||||
if (state.isInitialized && api) {
|
||||
syncPendingOperations();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
setState(prev => ({ ...prev, isOnline: false }));
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [state.isInitialized, api]);
|
||||
|
||||
// Full library sync from server
|
||||
const syncLibraryFromServer = useCallback(async (): Promise<void> => {
|
||||
if (!api || !state.isInitialized || state.isSyncing) return;
|
||||
|
||||
try {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: true,
|
||||
syncProgress: { current: 0, total: 100, stage: 'Starting sync...' }
|
||||
}));
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncProgress: { current: 20, total: 100, stage: 'Syncing albums...' }
|
||||
}));
|
||||
|
||||
await offlineLibraryManager.syncFromServer(api);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncProgress: { current: 80, total: 100, stage: 'Syncing pending operations...' }
|
||||
}));
|
||||
|
||||
await offlineLibraryManager.syncPendingOperations(api);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
syncProgress: null,
|
||||
stats,
|
||||
lastSync: stats.lastSync
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Library sync failed:', error);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
syncProgress: null
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}, [api, state.isInitialized, state.isSyncing]);
|
||||
|
||||
// Sync only pending operations
|
||||
const syncPendingOperations = useCallback(async (): Promise<void> => {
|
||||
if (!api || !state.isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryManager.syncPendingOperations(api);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
} catch (error) {
|
||||
console.error('Failed to sync pending operations:', error);
|
||||
}
|
||||
}, [api, state.isInitialized]);
|
||||
|
||||
// Data retrieval methods (offline-first)
|
||||
const getAlbums = useCallback(async (starred?: boolean): Promise<Album[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
// Try offline first
|
||||
const offlineAlbums = await offlineLibraryManager.getAlbums(starred);
|
||||
|
||||
// If offline data exists, return it
|
||||
if (offlineAlbums.length > 0) {
|
||||
return offlineAlbums;
|
||||
}
|
||||
|
||||
// If no offline data and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
const serverAlbums = starred
|
||||
? await api.getAlbums('starred')
|
||||
: await api.getAlbums('alphabeticalByName', 100);
|
||||
|
||||
// Cache the results
|
||||
await offlineLibraryManager.storeAlbums(serverAlbums);
|
||||
return serverAlbums;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get albums:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getArtists = useCallback(async (starred?: boolean): Promise<Artist[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlineArtists = await offlineLibraryManager.getArtists(starred);
|
||||
|
||||
if (offlineArtists.length > 0) {
|
||||
return offlineArtists;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
const serverArtists = await api.getArtists();
|
||||
await offlineLibraryManager.storeArtists(serverArtists);
|
||||
return serverArtists;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get artists:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getAlbum = useCallback(async (albumId: string): Promise<{ album: Album; songs: Song[] } | null> => {
|
||||
if (!state.isInitialized) return null;
|
||||
|
||||
try {
|
||||
// Try offline first
|
||||
const offlineData = await offlineLibraryManager.getAlbum(albumId);
|
||||
|
||||
if (offlineData && offlineData.songs.length > 0) {
|
||||
return offlineData;
|
||||
}
|
||||
|
||||
// If no offline data and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
const serverData = await api.getAlbum(albumId);
|
||||
|
||||
// Cache the results
|
||||
await offlineLibraryManager.storeAlbums([serverData.album]);
|
||||
await offlineLibraryManager.storeSongs(serverData.songs);
|
||||
|
||||
return serverData;
|
||||
}
|
||||
|
||||
return offlineData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get album:', error);
|
||||
return null;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getPlaylists = useCallback(async (): Promise<Playlist[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlinePlaylists = await offlineLibraryManager.getPlaylists();
|
||||
|
||||
if (offlinePlaylists.length > 0) {
|
||||
return offlinePlaylists;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
const serverPlaylists = await api.getPlaylists();
|
||||
await offlineLibraryManager.storePlaylists(serverPlaylists);
|
||||
return serverPlaylists;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get playlists:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Search (offline-first)
|
||||
const searchOffline = useCallback(async (query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> => {
|
||||
if (!state.isInitialized) {
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const offlineResults = await offlineLibraryManager.searchOffline(query);
|
||||
|
||||
// If we have good offline results, return them
|
||||
const totalResults = offlineResults.artists.length + offlineResults.albums.length + offlineResults.songs.length;
|
||||
if (totalResults > 0) {
|
||||
return offlineResults;
|
||||
}
|
||||
|
||||
// If no offline results and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
return await api.search2(query);
|
||||
}
|
||||
|
||||
return offlineResults;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Offline favorites management
|
||||
const starOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
// If online, try server first
|
||||
await api.star(id, type);
|
||||
}
|
||||
|
||||
// Always update offline data
|
||||
await offlineLibraryManager.starOffline(id, type);
|
||||
|
||||
// Update stats
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to star item:', error);
|
||||
// If server failed but we're online, still save offline for later sync
|
||||
if (state.isOnline) {
|
||||
await offlineLibraryManager.starOffline(id, type);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const unstarOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
await api.unstar(id, type);
|
||||
}
|
||||
|
||||
await offlineLibraryManager.unstarOffline(id, type);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to unstar item:', error);
|
||||
if (state.isOnline) {
|
||||
await offlineLibraryManager.unstarOffline(id, type);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Playlist management
|
||||
const createPlaylistOffline = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
|
||||
if (!state.isInitialized) {
|
||||
throw new Error('Offline library not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
// If online, try server first
|
||||
const serverPlaylist = await api.createPlaylist(name, songIds);
|
||||
await offlineLibraryManager.storePlaylists([serverPlaylist]);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
return serverPlaylist;
|
||||
} else {
|
||||
// If offline, create locally and queue for sync
|
||||
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
return offlinePlaylist;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create playlist:', error);
|
||||
|
||||
// If server failed but we're online, create offline version for later sync
|
||||
if (state.isOnline) {
|
||||
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
return offlinePlaylist;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Scrobble (offline-capable)
|
||||
const scrobbleOffline = useCallback(async (songId: string): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
await api.scrobble(songId);
|
||||
} else {
|
||||
// Queue for later sync
|
||||
await offlineLibraryManager.addSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble:', error);
|
||||
// Queue for later sync if server failed
|
||||
await offlineLibraryManager.addSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Clear all offline data
|
||||
const clearOfflineData = useCallback(async (): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryManager.clearAllData();
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stats,
|
||||
lastSync: null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Get songs with offline-first approach
|
||||
const getSongs = useCallback(async (albumId?: string, artistId?: string): Promise<Song[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlineSongs = await offlineLibraryManager.getSongs(albumId, artistId);
|
||||
|
||||
if (offlineSongs.length > 0) {
|
||||
return offlineSongs;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
let serverSongs: Song[] = [];
|
||||
|
||||
if (albumId) {
|
||||
const { songs } = await api.getAlbum(albumId);
|
||||
await offlineLibraryManager.storeSongs(songs);
|
||||
serverSongs = songs;
|
||||
} else if (artistId) {
|
||||
const { albums } = await api.getArtist(artistId);
|
||||
const allSongs: Song[] = [];
|
||||
for (const album of albums) {
|
||||
const { songs } = await api.getAlbum(album.id);
|
||||
allSongs.push(...songs);
|
||||
}
|
||||
await offlineLibraryManager.storeSongs(allSongs);
|
||||
serverSongs = allSongs;
|
||||
}
|
||||
|
||||
return serverSongs;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get songs:', error);
|
||||
return [];
|
||||
}
|
||||
}, [api, state.isInitialized, state.isOnline]);
|
||||
|
||||
// Queue sync operation
|
||||
const queueSyncOperation = useCallback(async (operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
const fullOperation: SyncOperation = {
|
||||
...operation,
|
||||
id: `${operation.type}-${operation.entityId}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
await offlineLibraryManager.addSyncOperation(fullOperation);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Update playlist offline
|
||||
const updatePlaylistOffline = useCallback(async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
await offlineLibraryManager.updatePlaylist(id, name, comment, songIds);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Delete playlist offline
|
||||
const deletePlaylistOffline = useCallback(async (id: string): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
await offlineLibraryManager.deletePlaylist(id);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Refresh stats
|
||||
const refreshStats = useCallback(async (): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats, lastSync: stats.lastSync }));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh stats:', error);
|
||||
}
|
||||
}, [state.isInitialized]);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Sync methods
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
|
||||
// Data retrieval (offline-first)
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getSongs,
|
||||
getAlbum,
|
||||
getPlaylists,
|
||||
searchOffline,
|
||||
|
||||
// Offline operations
|
||||
starOffline,
|
||||
unstarOffline,
|
||||
createPlaylistOffline,
|
||||
updatePlaylistOffline,
|
||||
deletePlaylistOffline,
|
||||
scrobbleOffline,
|
||||
queueSyncOperation,
|
||||
|
||||
// Management
|
||||
clearOfflineData,
|
||||
refreshStats
|
||||
};
|
||||
}
|
||||
@@ -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
782
lib/offline-library.ts
Normal 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();
|
||||
@@ -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
129
public/manifest.json
Normal 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
0
public/sw.js
Normal file
Reference in New Issue
Block a user