Remove all offline download and caching functionality

- Deleted all offline-related component files:
  - EnhancedOfflineManager.tsx
  - OfflineIndicator.tsx
  - OfflineLibrarySync.tsx
  - OfflineManagement.tsx
  - OfflineNavidromeContext.tsx
  - OfflineNavidromeProvider.tsx
  - OfflineStatusIndicator.tsx
- Deleted all offline-related hooks:
  - use-offline-audio-player.ts
  - use-offline-downloads.ts
  - use-offline-library-sync.ts
  - use-offline-library.ts
- Updated components to remove offline functionality:
  - RootLayoutClient: Removed OfflineNavidromeProvider, using only NavidromeProvider
  - SongRecommendations: Removed offline data fetching logic
  - album-artwork: Removed OfflineIndicator usage
  - WhatsNewPopup: Updated changelog to reflect offline removal
- Updated pages:
  - album/[id]/page: Removed all OfflineIndicator components from album and song displays
  - page.tsx: Removed OfflineStatusIndicator and offline empty state message
  - settings/page: Removed EnhancedOfflineManager and OfflineManagement sections
- Simplified use-progressive-album-loading hook to only use online API
- Fixed resizable component imports for react-resizable-panels 4.5.1 API changes
This commit is contained in:
2026-01-25 00:35:58 +00:00
committed by GitHub
parent df248497ae
commit eb56096992
20 changed files with 91 additions and 4331 deletions

View File

@@ -1,627 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/hooks/use-toast';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import { useNavidrome } from '@/app/components/NavidromeContext';
import {
Download,
Trash2,
RefreshCw,
Wifi,
WifiOff,
Database,
Clock,
AlertCircle,
CheckCircle,
Music,
User,
List,
HardDrive,
Disc,
Search,
Filter,
SlidersHorizontal
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import Image from 'next/image';
import { Album, Playlist } from '@/lib/navidrome';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { OfflineManagement } from './OfflineManagement';
import { Skeleton } from '@/components/ui/skeleton';
// Helper functions
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();
}
// Album card for selection
function AlbumSelectionCard({
album,
isSelected,
onToggleSelection,
isDownloading,
downloadProgress,
estimatedSize
}: {
album: Album;
isSelected: boolean;
onToggleSelection: () => void;
isDownloading: boolean;
downloadProgress?: number;
estimatedSize: string;
}) {
const { api } = useNavidrome();
return (
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
<div className="flex p-3">
<div className="shrink-0">
<Image
src={album.coverArt ? (api?.getCoverArtUrl(album.coverArt) || '/default-user.jpg') : '/default-user.jpg'}
alt={album.name}
width={60}
height={60}
className="rounded-md object-cover"
/>
</div>
<div className="ml-3 flex-1 overflow-hidden">
<h4 className="font-medium truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">{album.songCount} songs {estimatedSize}</span>
<Switch
checked={isSelected}
onCheckedChange={onToggleSelection}
disabled={isDownloading}
/>
</div>
</div>
</div>
{isDownloading && downloadProgress !== undefined && (
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
)}
</Card>
);
}
// Playlist selection card
function PlaylistSelectionCard({
playlist,
isSelected,
onToggleSelection,
isDownloading,
downloadProgress,
estimatedSize
}: {
playlist: Playlist;
isSelected: boolean;
onToggleSelection: () => void;
isDownloading: boolean;
downloadProgress?: number;
estimatedSize: string;
}) {
const { api } = useNavidrome();
return (
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
<div className="flex p-3">
<div className="shrink-0">
<div className="w-[60px] h-[60px] rounded-md bg-accent flex items-center justify-center">
<List className="h-6 w-6 text-primary" />
</div>
</div>
<div className="ml-3 flex-1 overflow-hidden">
<h4 className="font-medium truncate">{playlist.name}</h4>
<p className="text-sm text-muted-foreground truncate">by {playlist.owner}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">{playlist.songCount} songs {estimatedSize}</span>
<Switch
checked={isSelected}
onCheckedChange={onToggleSelection}
disabled={isDownloading}
/>
</div>
</div>
</div>
{isDownloading && downloadProgress !== undefined && (
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
)}
</Card>
);
}
export default function EnhancedOfflineManager() {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState('overview');
const [albums, setAlbums] = useState<Album[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState({
albums: false,
playlists: false
});
const [searchQuery, setSearchQuery] = useState('');
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(new Set());
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string>>(new Set());
const [downloadingItems, setDownloadingItems] = useState<Map<string, number>>(new Map());
// Filter state
const [sortBy, setSortBy] = useState('recent');
const [filtersVisible, setFiltersVisible] = useState(false);
const offline = useOfflineLibrary();
const { api } = useNavidrome();
// Load albums and playlists
// ...existing code...
// ...existing code...
// Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists
// Load albums data
const loadAlbums = async () => {
setLoading(prev => ({ ...prev, albums: true }));
try {
const albumData = await offline.getAlbums();
setAlbums(albumData);
// Load previously selected albums from localStorage
const savedSelections = localStorage.getItem('navidrome-offline-albums');
if (savedSelections) {
setSelectedAlbums(new Set(JSON.parse(savedSelections)));
}
} catch (error) {
console.error('Failed to load albums:', error);
toast({
title: 'Error',
description: 'Failed to load albums. Please try again.',
variant: 'destructive'
});
} finally {
setLoading(prev => ({ ...prev, albums: false }));
}
};
// Load playlists data
const loadPlaylists = async () => {
setLoading(prev => ({ ...prev, playlists: true }));
try {
const playlistData = await offline.getPlaylists();
setPlaylists(playlistData);
// Load previously selected playlists from localStorage
const savedSelections = localStorage.getItem('navidrome-offline-playlists');
if (savedSelections) {
setSelectedPlaylists(new Set(JSON.parse(savedSelections)));
}
} catch (error) {
console.error('Failed to load playlists:', error);
toast({
title: 'Error',
description: 'Failed to load playlists. Please try again.',
variant: 'destructive'
});
} finally {
setLoading(prev => ({ ...prev, playlists: false }));
}
};
// Toggle album selection
const toggleAlbumSelection = (albumId: string) => {
setSelectedAlbums(prev => {
const newSelection = new Set(prev);
if (newSelection.has(albumId)) {
newSelection.delete(albumId);
} else {
newSelection.add(albumId);
}
// Save to localStorage
localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection]));
return newSelection;
});
};
// Toggle playlist selection
const togglePlaylistSelection = (playlistId: string) => {
setSelectedPlaylists(prev => {
const newSelection = new Set(prev);
if (newSelection.has(playlistId)) {
newSelection.delete(playlistId);
} else {
newSelection.add(playlistId);
}
// Save to localStorage
localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection]));
return newSelection;
});
};
// Download selected items
const downloadSelected = async () => {
// Mock implementation - in a real implementation, you'd integrate with the download system
const selectedIds = [...selectedAlbums, ...selectedPlaylists];
if (selectedIds.length === 0) {
toast({
title: 'No items selected',
description: 'Please select albums or playlists to download.',
});
return;
}
toast({
title: 'Download Started',
description: `Downloading ${selectedIds.length} items for offline use.`,
});
// Mock download progress
const downloadMap = new Map<string, number>();
selectedIds.forEach(id => downloadMap.set(id, 0));
setDownloadingItems(downloadMap);
// Simulate download progress
const interval = setInterval(() => {
setDownloadingItems(prev => {
const updated = new Map(prev);
let allComplete = true;
for (const [id, progress] of prev.entries()) {
if (progress < 100) {
updated.set(id, Math.min(progress + Math.random() * 10, 100));
allComplete = false;
}
}
if (allComplete) {
clearInterval(interval);
toast({
title: 'Download Complete',
description: `${selectedIds.length} items are now available offline.`,
});
setTimeout(() => {
setDownloadingItems(new Map());
}, 1000);
}
return updated;
});
}, 500);
};
// Filter and sort albums
const filteredAlbums = albums
.filter(album => {
if (!searchQuery) return true;
return album.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
album.artist.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
switch (sortBy) {
case 'recent':
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
case 'name':
return a.name.localeCompare(b.name);
case 'artist':
return a.artist.localeCompare(b.artist);
default:
return 0;
}
});
// Filter and sort playlists
const filteredPlaylists = playlists
.filter(playlist => {
if (!searchQuery) return true;
return playlist.name.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
switch (sortBy) {
case 'recent':
return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime();
case 'name':
return a.name.localeCompare(b.name);
default:
return 0;
}
});
// Estimate album size (mock implementation)
const estimateSize = (songCount: number) => {
const averageSongSizeMB = 8;
const totalSizeMB = songCount * averageSongSizeMB;
if (totalSizeMB > 1000) {
return `${(totalSizeMB / 1000).toFixed(1)} GB`;
}
return `${totalSizeMB.toFixed(0)} MB`;
};
return (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="space-y-4"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="albums">Albums</TabsTrigger>
<TabsTrigger value="playlists">Playlists</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OfflineManagement />
</TabsContent>
<TabsContent value="albums" className="space-y-4">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Disc className="h-5 w-5" />
Select Albums
</CardTitle>
<CardDescription>
Choose albums to make available offline
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search albums..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiltersVisible(!filtersVisible)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
{filtersVisible && (
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div className="text-sm font-medium">Sort By</div>
<div className="flex flex-wrap gap-2">
<Button
variant={sortBy === 'recent' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('recent')}
>
Recent
</Button>
<Button
variant={sortBy === 'name' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('name')}
>
Name
</Button>
<Button
variant={sortBy === 'artist' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('artist')}
>
Artist
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedAlbums(new Set())}
disabled={selectedAlbums.size === 0}
>
Clear Selection
</Button>
</div>
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
{loading.albums ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<div className="flex p-3">
<Skeleton className="h-[60px] w-[60px] rounded-md" />
<div className="ml-3 flex-1">
<Skeleton className="h-5 w-2/3 mb-1" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
))
) : filteredAlbums.length > 0 ? (
filteredAlbums.map(album => (
<AlbumSelectionCard
key={album.id}
album={album}
isSelected={selectedAlbums.has(album.id)}
onToggleSelection={() => toggleAlbumSelection(album.id)}
isDownloading={downloadingItems.has(album.id)}
downloadProgress={downloadingItems.get(album.id)}
estimatedSize={estimateSize(album.songCount)}
/>
))
) : (
<div className="text-center py-8">
<Disc className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? 'No albums found matching your search' : 'No albums available'}
</p>
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={downloadSelected}
disabled={selectedAlbums.size === 0 || downloadingItems.size > 0}
>
<Download className="h-4 w-4 mr-2" />
Download {selectedAlbums.size} Selected Album{selectedAlbums.size !== 1 ? 's' : ''}
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="playlists" className="space-y-4">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<List className="h-5 w-5" />
Select Playlists
</CardTitle>
<CardDescription>
Choose playlists to make available offline
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search playlists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiltersVisible(!filtersVisible)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
{filtersVisible && (
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div className="text-sm font-medium">Sort By</div>
<div className="flex flex-wrap gap-2">
<Button
variant={sortBy === 'recent' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('recent')}
>
Recent
</Button>
<Button
variant={sortBy === 'name' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('name')}
>
Name
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedPlaylists(new Set())}
disabled={selectedPlaylists.size === 0}
>
Clear Selection
</Button>
</div>
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
{loading.playlists ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<div className="flex p-3">
<Skeleton className="h-[60px] w-[60px] rounded-md" />
<div className="ml-3 flex-1">
<Skeleton className="h-5 w-2/3 mb-1" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
))
) : filteredPlaylists.length > 0 ? (
filteredPlaylists.map(playlist => (
<PlaylistSelectionCard
key={playlist.id}
playlist={playlist}
isSelected={selectedPlaylists.has(playlist.id)}
onToggleSelection={() => togglePlaylistSelection(playlist.id)}
isDownloading={downloadingItems.has(playlist.id)}
downloadProgress={downloadingItems.get(playlist.id)}
estimatedSize={estimateSize(playlist.songCount)}
/>
))
) : (
<div className="text-center py-8">
<List className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? 'No playlists found matching your search' : 'No playlists available'}
</p>
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={downloadSelected}
disabled={selectedPlaylists.size === 0 || downloadingItems.size > 0}
>
<Download className="h-4 w-4 mr-2" />
Download {selectedPlaylists.size} Selected Playlist{selectedPlaylists.size !== 1 ? 's' : ''}
</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
);
}

View File

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

View File

@@ -1,395 +0,0 @@
'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 className="mb-6 break-inside-avoid py-5">
<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 className="mb-6 break-inside-avoid py-5">
<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 className="mb-6 break-inside-avoid py-5">
<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 className="mb-6 break-inside-avoid py-5">
<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 className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className='flex items-center gap-2'>Offline Features</CardTitle>
<CardDescription>
What works when you&apos;re offline
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Browse & Search</p>
<p className="text-sm text-muted-foreground">
Browse your synced albums, artists, and search offline
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Favorites & Playlists</p>
<p className="text-sm text-muted-foreground">
Star songs/albums and create playlists (syncs when online)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Play Downloaded Music</p>
<p className="text-sm text-muted-foreground">
Play songs you&apos;ve downloaded for offline listening
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Auto-Sync</p>
<p className="text-sm text-muted-foreground">
Changes sync automatically when you reconnect
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="mb-6 break-inside-avoid py-5 border-red-200">
<CardHeader>
<CardTitle className="text-red-600 flex items-center gap-2">Danger Zone</CardTitle>
<CardDescription>
Permanently delete all offline data
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Clear All Offline Data</p>
<p className="text-sm text-muted-foreground">
This will remove all synced library data and downloaded audio
</p>
</div>
<Button
variant="destructive"
onClick={handleClearData}
disabled={isClearing}
>
<Trash2 className="h-4 w-4 mr-1" />
{isClearing ? 'Clearing...' : 'Clear Data'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import React, { useEffect } from "react";
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider";
import { WhatsNewPopup } from "../components/WhatsNewPopup";
@@ -105,7 +105,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<ThemeColorHandler />
<ServiceWorkerRegistration />
<NavidromeConfigProvider>
<OfflineNavidromeProvider>
<NavidromeProvider>
<NavidromeErrorBoundary>
<AudioPlayerProvider>
<GlobalSearchProvider>
@@ -116,7 +116,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
</GlobalSearchProvider>
</AudioPlayerProvider>
</NavidromeErrorBoundary>
</OfflineNavidromeProvider>
</NavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
);

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button';
@@ -17,7 +17,7 @@ interface SongRecommendationsProps {
}
export function SongRecommendations({ userName }: SongRecommendationsProps) {
const offline = useOfflineNavidrome();
const { api } = useNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
@@ -45,10 +45,9 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
setLoading(true);
try {
const api = getNavidromeAPI();
const isOnline = !offline.isOfflineMode && !!api;
if (isOnline && api) {
// Online: use server-side recommendations
if (api) {
// Use server-side recommendations
const randomAlbums = await api.getAlbums('random', 10);
if (isMobile) {
setRecommendedAlbums(randomAlbums.slice(0, 6));
@@ -69,29 +68,6 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
setSongStates(states);
}
} else {
// Offline: use cached library
const albums = await offline.getAlbums(false);
const shuffledAlbums = [...(albums || [])].sort(() => Math.random() - 0.5);
if (isMobile) {
setRecommendedAlbums(shuffledAlbums.slice(0, 6));
} else {
const pick = shuffledAlbums.slice(0, 3);
const allSongs: Song[] = [];
for (const a of pick) {
try {
const songs = await offline.getSongs(a.id);
allSongs.push(...songs);
} catch (e) {
// ignore per-album errors
}
}
const recommendations = allSongs.sort(() => Math.random() - 0.5).slice(0, 6);
setRecommendedSongs(recommendations);
const states: Record<string, boolean> = {};
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
setSongStates(states);
}
}
} catch (error) {
console.error('Failed to load recommendations:', error);
@@ -103,13 +79,15 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
};
loadRecommendations();
}, [offline, isMobile]);
}, [isMobile]);
const handlePlaySong = async (song: Song) => {
try {
const api = getNavidromeAPI();
const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`;
const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
if (!api) return;
const url = api.getStreamUrl(song.id);
const coverArt = song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
const track = {
id: song.id,
name: song.title,
@@ -131,16 +109,13 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const handlePlayAlbum = async (album: Album) => {
try {
const api = getNavidromeAPI();
let albumSongs: Song[] = [];
if (api) {
albumSongs = await api.getAlbumSongs(album.id);
} else {
albumSongs = await offline.getSongs(album.id);
}
if (!api) return;
const albumSongs = await api.getAlbumSongs(album.id);
if (albumSongs.length > 0) {
const first = albumSongs[0];
const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`;
const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
const url = api.getStreamUrl(first.id);
const coverArt = first.coverArt ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
const track = {
id: first.id,
name: first.title,
@@ -246,7 +221,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
className="group cursor-pointer block"
>
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
{album.coverArt && getNavidromeAPI() ? (
<Image
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
alt={album.name}
@@ -305,7 +280,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<CardContent className="px-2">
<div className="flex items-center gap-3">
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
{song.coverArt && getNavidromeAPI() ? (
<>
<Image
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}

View File

@@ -17,16 +17,12 @@ const CHANGELOG = [
'Added keyboard shortcuts and queue management features',
'Added ListeningStreakCard component for tracking listening streaks',
'Moved service worker registration to dedicated component for improved client-side handling',
'Enhanced offline download manager with client-side checks',
'Enhanced OfflineManagement component with improved card styling and layout',
'Implemented Auto-Tagging Settings and MusicBrainz integration',
'Enhanced audio settings with ReplayGain, crossfade, and equalizer presets',
'Added AudioSettingsDialog component',
'Updated cover art retrieval to use higher resolution images',
'Enhanced UI with Framer Motion animations for album artwork and artist icons',
'Added page transition animations and notification settings for audio playback',
'Implemented offline library synchronization with IndexedDB',
'Implemented offline library management with IndexedDB support',
'Updated all npm subdependencies to latest minor versions',
],
fixes: [
@@ -34,7 +30,7 @@ const CHANGELOG = [
],
breaking: [
'Removed PostHog analytics tracking',
'Removed caching system (replaced with offline library management)',
'Removed all offline download and caching functionality',
]
},
{

View File

@@ -18,7 +18,6 @@ import {
} from "../../components/ui/context-menu"
import { useNavidrome } from "./NavidromeContext"
import { useOfflineNavidrome } from "./OfflineNavidromeProvider"
import Link from "next/link";
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
import { getNavidromeAPI } from "@/lib/navidrome";
@@ -28,7 +27,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { ArtistIcon } from "@/app/components/artist-icon";
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 Omit<
React.HTMLAttributes<HTMLDivElement>,
@@ -49,7 +47,6 @@ export function AlbumArtwork({
...props
}: AlbumArtworkProps) {
const { api, isConnected } = useNavidrome();
const offline = useOfflineNavidrome();
const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome();
@@ -153,7 +150,7 @@ export function AlbumArtwork({
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
<div className="aspect-square relative group">
{album.coverArt && api && !offline.isOfflineMode ? (
{album.coverArt && api ? (
<Image
src={coverArtUrl}
alt={album.name}
@@ -173,16 +170,6 @@ 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">