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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user