refactor: remove all offline download and caching functionality

This commit is contained in:
2026-01-25 00:35:58 +00:00
committed by GitHub
parent 6b0e7f73d4
commit 1d013bb9f6
20 changed files with 91 additions and 4331 deletions

View File

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

View File

@@ -1,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">

View File

@@ -4,7 +4,7 @@ import { ScrollArea, ScrollBar } from '../components/ui/scroll-area';
import { Separator } from '../components/ui/separator';
import { Tabs, TabsContent } from '../components/ui/tabs';
import { AlbumArtwork } from './components/album-artwork';
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
import { useNavidrome } from './components/NavidromeContext';
import { useEffect, useState, Suspense } from 'react';
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidromeConfig } from './components/NavidromeConfigContext';
@@ -14,14 +14,12 @@ import { SongRecommendations } from './components/SongRecommendations';
import { Skeleton } from '@/components/ui/skeleton';
import { useIsMobile } from '@/hooks/use-mobile';
import { UserProfile } from './components/UserProfile';
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
import CompactListeningStreak from './components/CompactListeningStreak';
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
function MusicPageContent() {
// Offline-first provider (falls back to offline data when not connected)
const offline = useOfflineNavidrome();
const { api } = useNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams();
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
@@ -33,13 +31,14 @@ function MusicPageContent() {
const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
// Load albums (offline-first)
// Load albums
useEffect(() => {
let mounted = true;
const load = async () => {
if (!api) return;
setAlbumsLoading(true);
try {
const list = await offline.getAlbums(false);
const list = await api.getAlbums('newest', 500);
if (!mounted) return;
setAllAlbums(list || []);
// Split albums into two sections
@@ -48,7 +47,7 @@ function MusicPageContent() {
setRecentAlbums(recent);
setNewestAlbums(newest);
} catch (e) {
console.error('Failed to load albums (offline-first):', e);
console.error('Failed to load albums:', e);
if (mounted) {
setAllAlbums([]);
setRecentAlbums([]);
@@ -60,17 +59,18 @@ function MusicPageContent() {
};
load();
return () => { mounted = false; };
}, [offline]);
}, [api]);
useEffect(() => {
let mounted = true;
const loadFavoriteAlbums = async () => {
if (!api) return;
setFavoritesLoading(true);
try {
const starred = await offline.getAlbums(true);
if (mounted) setFavoriteAlbums((starred || []).slice(0, 20));
const starred = await api.getAlbums('starred', 20);
if (mounted) setFavoriteAlbums(starred || []);
} catch (error) {
console.error('Failed to load favorite albums (offline-first):', error);
console.error('Failed to load favorite albums:', error);
if (mounted) setFavoriteAlbums([]);
} finally {
if (mounted) setFavoritesLoading(false);
@@ -78,7 +78,7 @@ function MusicPageContent() {
};
loadFavoriteAlbums();
return () => { mounted = false; };
}, [offline]);
}, [api]);
// Handle PWA shortcuts
useEffect(() => {
@@ -115,29 +115,31 @@ function MusicPageContent() {
await playAlbum(shuffledAlbums[0].id);
// Add remaining albums to queue
for (let i = 1; i < shuffledAlbums.length; i++) {
try {
const songs = await offline.getSongs(shuffledAlbums[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
const navidromeApi = getNavidromeAPI();
if (navidromeApi) {
for (let i = 1; i < shuffledAlbums.length; i++) {
try {
const songs = await navidromeApi.getAlbumSongs(shuffledAlbums[i].id);
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: navidromeApi.getStreamUrl(song.id),
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
duration: song.duration || 0,
coverArt: song.coverArt,
starred: !!song.starred
});
});
} catch (error) {
console.error('Failed to load album tracks (offline-first):', error);
console.error('Failed to load album tracks:', error);
}
}
}
}
break;
case 'shuffle-favorites':
@@ -154,29 +156,31 @@ function MusicPageContent() {
await playAlbum(shuffledFavorites[0].id);
// Add remaining albums to queue
for (let i = 1; i < shuffledFavorites.length; i++) {
try {
const songs = await offline.getSongs(shuffledFavorites[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
const navidromeApiFav = getNavidromeAPI();
if (navidromeApiFav) {
for (let i = 1; i < shuffledFavorites.length; i++) {
try {
const songs = await navidromeApiFav.getAlbumSongs(shuffledFavorites[i].id);
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: navidromeApiFav.getStreamUrl(song.id),
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
duration: song.duration || 0,
coverArt: song.coverArt,
starred: !!song.starred
});
});
} catch (error) {
console.error('Failed to load album tracks (offline-first):', error);
console.error('Failed to load album tracks:', error);
}
}
}
}
break;
}
setShortcutProcessed(true);
@@ -188,7 +192,7 @@ function MusicPageContent() {
// Delay to ensure data is loaded
const timeout = setTimeout(handleShortcuts, 1000);
return () => clearTimeout(timeout);
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]);
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
// Try to get user name from navidrome context, fallback to 'user'
let userName = '';
@@ -202,19 +206,7 @@ function MusicPageContent() {
return (
<div className="p-6 pb-24 w-full">
{/* Connection status (offline indicator) */}
{!offline.isOfflineMode ? null : (
<div className="mb-4">
<OfflineStatusIndicator />
</div>
)}
{/* Offline empty state when nothing is cached */}
{offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && (
<div className="mb-6 p-4 border rounded-lg bg-muted/30">
<p className="text-sm text-muted-foreground">
You are offline and no albums are cached yet. Download albums for offline use from an album page, or open Settings Offline Library to sync your library.
</p>
</div>
)}
{/* Song Recommendations Section */}
<div className="mb-8">
<SongRecommendations userName={userName} />

View File

@@ -14,7 +14,6 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement';
import EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager';
import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
import { Settings, ExternalLink, Tag } from 'lucide-react';
@@ -778,11 +777,6 @@ const SettingsPage = () => {
<SettingsManagement />
</div>
{/* Offline Library Management */}
<div className="break-inside-avoid mb-6">
<EnhancedOfflineManager />
</div>
{/* Auto-Tagging Settings */}
<div className="break-inside-avoid mb-6">
<AutoTaggingSettings />

View File

@@ -2,16 +2,16 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
}: React.ComponentProps<typeof PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
<PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
@@ -24,19 +24,19 @@ function ResizablePanelGroup({
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}: React.ComponentProps<typeof Panel>) {
return <Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
<PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
@@ -49,7 +49,7 @@ function ResizableHandle({
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</PanelResizeHandle>
)
}

View File

@@ -1,281 +0,0 @@
'use client';
import { useCallback } from 'react';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import { Album, Song } from '@/lib/navidrome';
import { getNavidromeAPI } from '@/lib/navidrome';
export interface OfflineTrack extends Track {
isOffline?: boolean;
offlineUrl?: string;
}
export function useOfflineAudioPlayer() {
const {
playTrack,
addToQueue,
currentTrack,
...audioPlayerProps
} = useAudioPlayer();
const { isSupported: isOfflineSupported, checkOfflineStatus } = useOfflineDownloads();
const { isOnline, scrobbleOffline } = useOfflineLibrary();
const api = getNavidromeAPI();
// Convert song to track with offline awareness
const songToTrack = useCallback(async (song: Song): Promise<OfflineTrack> => {
let track: OfflineTrack = {
id: song.id,
name: song.title,
url: api?.getStreamUrl(song.id) || '',
artist: song.artist,
album: song.album || '',
duration: song.duration,
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
};
// Check if song is available offline
if (isOfflineSupported) {
const offlineStatus = await checkOfflineStatus(song.id, 'song');
if (offlineStatus) {
track.isOffline = true;
track.offlineUrl = `offline-song-${song.id}`;
// Prefer offline cached URL to avoid re-streaming even when online
track.url = track.offlineUrl;
}
}
return track;
}, [api, isOfflineSupported, checkOfflineStatus]);
// Play track with offline fallback
const playTrackOffline = useCallback(async (song: Song | OfflineTrack) => {
try {
let track: OfflineTrack;
if ('url' in song) {
// Already a track
track = song as OfflineTrack;
} else {
// Convert song to track
track = await songToTrack(song);
}
// If offline and track has offline URL, use that
if (!isOnline && track.isOffline && track.offlineUrl) {
track.url = track.offlineUrl;
}
playTrack(track);
// Scrobble with offline support
scrobbleOffline(track.id);
} catch (error) {
console.error('Failed to play track:', error);
throw error;
}
}, [songToTrack, playTrack, scrobbleOffline, isOnline]);
// Play album with offline awareness
const playAlbumOffline = useCallback(async (album: Album, songs: Song[], startIndex: number = 0) => {
try {
if (songs.length === 0) return;
// Convert all songs to tracks with offline awareness
const tracks = await Promise.all(songs.map(songToTrack));
// Filter to only available tracks (online or offline)
const availableTracks = tracks.filter((track: OfflineTrack) => {
if (isOnline) return true; // All tracks available when online
return track.isOffline; // Only offline tracks when offline
});
if (availableTracks.length === 0) {
throw new Error('No tracks available for playback');
}
// Adjust start index if needed
const safeStartIndex = Math.min(startIndex, availableTracks.length - 1);
// Play first track
playTrack(availableTracks[safeStartIndex]);
// Add remaining tracks to queue
const remainingTracks = [
...availableTracks.slice(safeStartIndex + 1),
...availableTracks.slice(0, safeStartIndex)
];
remainingTracks.forEach(track => addToQueue(track));
// Scrobble first track
scrobbleOffline(availableTracks[safeStartIndex].id);
} catch (error) {
console.error('Failed to play album offline:', error);
throw error;
}
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
// Add track to queue with offline awareness
const addToQueueOffline = useCallback(async (song: Song | OfflineTrack) => {
try {
let track: OfflineTrack;
if ('url' in song) {
track = song as OfflineTrack;
} else {
track = await songToTrack(song);
}
// Check if track is available
if (!isOnline && !track.isOffline) {
throw new Error('Track not available offline');
}
// If offline and track has offline URL, use that
if (!isOnline && track.isOffline && track.offlineUrl) {
track.url = track.offlineUrl;
}
addToQueue(track);
} catch (error) {
console.error('Failed to add track to queue:', error);
throw error;
}
}, [songToTrack, addToQueue, isOnline]);
// Shuffle play with offline awareness
const shufflePlayOffline = useCallback(async (songs: Song[]) => {
try {
if (songs.length === 0) return;
// Convert all songs to tracks
const tracks = await Promise.all(songs.map(songToTrack));
// Filter available tracks
const availableTracks = tracks.filter((track: OfflineTrack) => {
if (isOnline) return true;
return track.isOffline;
});
if (availableTracks.length === 0) {
throw new Error('No tracks available for shuffle play');
}
// Shuffle the available tracks
const shuffledTracks = [...availableTracks].sort(() => Math.random() - 0.5);
// Play first track
playTrack(shuffledTracks[0]);
// Add remaining tracks to queue
shuffledTracks.slice(1).forEach(track => addToQueue(track));
// Scrobble first track
scrobbleOffline(shuffledTracks[0].id);
} catch (error) {
console.error('Failed to shuffle play offline:', error);
throw error;
}
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
// Get availability info for a song
const getTrackAvailability = useCallback(async (song: Song): Promise<{
isAvailable: boolean;
isOffline: boolean;
requiresConnection: boolean;
}> => {
try {
const track = await songToTrack(song);
return {
isAvailable: isOnline || !!track.isOffline,
isOffline: !!track.isOffline,
requiresConnection: !track.isOffline
};
} catch (error) {
console.error('Failed to check track availability:', error);
return {
isAvailable: false,
isOffline: false,
requiresConnection: true
};
}
}, [songToTrack, isOnline]);
// Get album availability info
const getAlbumAvailability = useCallback(async (songs: Song[]): Promise<{
totalTracks: number;
availableTracks: number;
offlineTracks: number;
onlineOnlyTracks: number;
}> => {
try {
const tracks = await Promise.all(songs.map(songToTrack));
const offlineTracks = tracks.filter((t: OfflineTrack) => t.isOffline).length;
const onlineOnlyTracks = tracks.filter((t: OfflineTrack) => !t.isOffline).length;
const availableTracks = isOnline ? tracks.length : offlineTracks;
return {
totalTracks: tracks.length,
availableTracks,
offlineTracks,
onlineOnlyTracks
};
} catch (error) {
console.error('Failed to check album availability:', error);
return {
totalTracks: songs.length,
availableTracks: 0,
offlineTracks: 0,
onlineOnlyTracks: songs.length
};
}
}, [songToTrack, isOnline]);
// Enhanced track info with offline status
const getCurrentTrackInfo = useCallback(() => {
if (!currentTrack) return null;
const offlineTrack = currentTrack as OfflineTrack;
return {
...currentTrack,
isAvailableOffline: offlineTrack.isOffline || false,
isPlayingOffline: !isOnline && !!offlineTrack.isOffline
};
}, [currentTrack, isOnline]);
return {
// Original audio player props
...audioPlayerProps,
currentTrack,
// Enhanced offline methods
playTrackOffline,
playAlbumOffline,
addToQueueOffline,
shufflePlayOffline,
// Utility methods
songToTrack,
getTrackAvailability,
getAlbumAvailability,
getCurrentTrackInfo,
// State
isOnline,
isOfflineSupported
};
}

View File

@@ -1,682 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
export interface DownloadProgress {
completed: number;
total: number;
failed: number;
status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error' | 'paused';
currentSong?: string;
currentArtist?: string;
currentAlbum?: string;
error?: string;
downloadSpeed?: number; // In bytes per second
timeRemaining?: number; // In seconds
percentComplete?: number; // 0-100
}
export interface OfflineItem {
id: string;
type: 'album' | 'song';
name: string;
artist: string;
downloadedAt: number;
size?: number;
bitRate?: number;
duration?: number;
format?: string;
lastPlayed?: number;
}
export interface OfflineStats {
totalSize: number;
audioSize: number;
imageSize: number;
metaSize: number;
downloadedAlbums: number;
downloadedSongs: number;
lastDownload: number | null;
downloadErrors: number;
remainingStorage: number | null;
autoDownloadEnabled: boolean;
downloadQuality: 'original' | 'high' | 'medium' | 'low';
downloadOnWifiOnly: boolean;
priorityContent: string[]; // IDs of albums or playlists that should always be available offline
}
class DownloadManager {
private worker: ServiceWorker | null = null;
private messageChannel: MessageChannel | null = null;
async initialize(): Promise<boolean> {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered:', registration);
// Wait for the service worker to be ready
const readyRegistration = await navigator.serviceWorker.ready;
this.worker = readyRegistration.active;
return true;
} catch (error) {
console.error('Service Worker registration failed:', error);
return false;
}
}
return false;
}
private async sendMessage(type: string, data: any): Promise<any> {
if (!this.worker) {
throw new Error('Service Worker not available');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
const { type: responseType, data: responseData } = event.data;
if (responseType.includes('ERROR')) {
reject(new Error(responseData.error));
} else {
resolve(responseData);
}
};
this.worker!.postMessage({ type, data }, [channel.port2]);
});
}
async downloadAlbum(
album: Album,
songs: Song[],
onProgress?: (progress: DownloadProgress) => void
): Promise<void> {
if (!this.worker) {
throw new Error('Service Worker not available');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'DOWNLOAD_PROGRESS':
if (onProgress) {
onProgress(data);
}
break;
case 'DOWNLOAD_COMPLETE':
resolve();
break;
case 'DOWNLOAD_ERROR':
reject(new Error(data.error));
break;
}
};
// Add direct download URLs to songs (use 'streamUrl' field name to keep SW compatibility)
const songsWithUrls = songs.map(song => ({
...song,
streamUrl: this.getDownloadUrl(song.id),
offlineUrl: `offline-song-${song.id}`,
duration: song.duration,
bitRate: song.bitRate,
size: song.size
}));
this.worker!.postMessage({
type: 'DOWNLOAD_ALBUM',
data: { album, songs: songsWithUrls }
}, [channel.port2]);
});
}
async downloadSong(
song: Song,
options?: { quality?: 'original' | 'high' | 'medium' | 'low', priority?: boolean }
): Promise<void> {
const songWithUrl = {
...song,
streamUrl: this.getDownloadUrl(song.id, options?.quality),
offlineUrl: `offline-song-${song.id}`,
duration: song.duration,
bitRate: song.bitRate,
size: song.size,
priority: options?.priority || false,
quality: options?.quality || 'original'
};
return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
}
async downloadQueue(
songs: Song[],
options?: {
quality?: 'original' | 'high' | 'medium' | 'low',
priority?: boolean,
onProgressUpdate?: (progress: DownloadProgress) => void
}
): Promise<void> {
if (!this.worker) {
throw new Error('Service Worker not available');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'DOWNLOAD_PROGRESS':
if (options?.onProgressUpdate) {
options.onProgressUpdate(data);
}
break;
case 'DOWNLOAD_COMPLETE':
resolve();
break;
case 'DOWNLOAD_ERROR':
reject(new Error(data.error));
break;
}
};
const songsWithUrls = songs.map(song => ({
...song,
streamUrl: this.getDownloadUrl(song.id, options?.quality),
offlineUrl: `offline-song-${song.id}`,
duration: song.duration,
bitRate: song.bitRate,
size: song.size,
priority: options?.priority || false,
quality: options?.quality || 'original'
}));
this.worker!.postMessage({
type: 'DOWNLOAD_QUEUE',
data: { songs: songsWithUrls }
}, [channel.port2]);
});
}
async pauseDownloads(): Promise<void> {
return this.sendMessage('PAUSE_DOWNLOADS', {});
}
async resumeDownloads(): Promise<void> {
return this.sendMessage('RESUME_DOWNLOADS', {});
}
async cancelDownloads(): Promise<void> {
return this.sendMessage('CANCEL_DOWNLOADS', {});
}
async setDownloadPreferences(preferences: {
quality: 'original' | 'high' | 'medium' | 'low',
wifiOnly: boolean,
autoDownloadRecent: boolean,
autoDownloadFavorites: boolean,
maxStoragePercent: number,
priorityContent?: string[] // IDs of albums or playlists
}): Promise<void> {
return this.sendMessage('SET_DOWNLOAD_PREFERENCES', preferences);
}
async getDownloadPreferences(): Promise<{
quality: 'original' | 'high' | 'medium' | 'low',
wifiOnly: boolean,
autoDownloadRecent: boolean,
autoDownloadFavorites: boolean,
maxStoragePercent: number,
priorityContent: string[]
}> {
return this.sendMessage('GET_DOWNLOAD_PREFERENCES', {});
}
async enableOfflineMode(settings: {
autoDownloadQueue?: boolean;
forceOffline?: boolean;
currentQueue?: Song[];
}): Promise<void> {
return this.sendMessage('ENABLE_OFFLINE_MODE', settings);
}
async checkOfflineStatus(id: string, type: 'album' | 'song'): Promise<boolean> {
try {
const result = await this.sendMessage('CHECK_OFFLINE_STATUS', { id, type });
return result.isAvailable;
} catch (error) {
console.error('Failed to check offline status:', error);
return false;
}
}
async deleteOfflineContent(id: string, type: 'album' | 'song'): Promise<void> {
return this.sendMessage('DELETE_OFFLINE_CONTENT', { id, type });
}
async getOfflineStats(): Promise<OfflineStats> {
return this.sendMessage('GET_OFFLINE_STATS', {});
}
async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> {
return this.sendMessage('GET_OFFLINE_ITEMS', {});
}
private getDownloadUrl(songId: string, quality?: 'original' | 'high' | 'medium' | 'low'): string {
const api = getNavidromeAPI();
if (!api) throw new Error('Navidrome server not configured');
// Use direct download to fetch original file by default
if (quality === 'original' || !quality) {
if (typeof (api as any).getDownloadUrl === 'function') {
return (api as any).getDownloadUrl(songId);
}
}
// For other quality settings, use the stream URL with appropriate parameters
const maxBitRate = quality === 'high' ? 320 :
quality === 'medium' ? 192 :
quality === 'low' ? 128 : undefined;
// Note: format parameter is not supported by the Navidrome API
// The server will automatically transcode based on maxBitRate
return api.getStreamUrl(songId, maxBitRate);
}
// LocalStorage fallback for browsers without service worker support
async downloadAlbumFallback(album: Album, songs: Song[]): Promise<void> {
const offlineData = this.getOfflineData();
// Store album metadata
offlineData.albums[album.id] = {
id: album.id,
name: album.name,
artist: album.artist,
downloadedAt: Date.now(),
songCount: songs.length,
songs: songs.map(song => song.id)
};
// Mark songs as downloaded (metadata only in localStorage fallback)
songs.forEach(song => {
offlineData.songs[song.id] = {
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
downloadedAt: Date.now()
};
});
this.saveOfflineData(offlineData);
}
public getOfflineData() {
const stored = localStorage.getItem('offline-downloads');
if (stored) {
try {
return JSON.parse(stored);
} catch (error) {
console.error('Failed to parse offline data:', error);
}
}
return {
albums: {},
songs: {},
lastUpdated: Date.now()
};
}
public saveOfflineData(data: any) {
data.lastUpdated = Date.now();
localStorage.setItem('offline-downloads', JSON.stringify(data));
}
async checkOfflineStatusFallback(id: string, type: 'album' | 'song'): Promise<boolean> {
const offlineData = this.getOfflineData();
if (type === 'album') {
return !!offlineData.albums[id];
} else {
return !!offlineData.songs[id];
}
}
async deleteOfflineContentFallback(id: string, type: 'album' | 'song'): Promise<void> {
const offlineData = this.getOfflineData();
if (type === 'album') {
const album = offlineData.albums[id];
if (album && album.songs) {
// Remove associated songs
album.songs.forEach((songId: string) => {
delete offlineData.songs[songId];
});
}
delete offlineData.albums[id];
} else {
delete offlineData.songs[id];
}
this.saveOfflineData(offlineData);
}
getOfflineAlbums(): OfflineItem[] {
const offlineData = this.getOfflineData();
return Object.values(offlineData.albums).map((album: any) => ({
id: album.id,
type: 'album' as const,
name: album.name,
artist: album.artist,
downloadedAt: album.downloadedAt
}));
}
getOfflineSongs(): OfflineItem[] {
const offlineData = this.getOfflineData();
return Object.values(offlineData.songs).map((song: any) => ({
id: song.id,
type: 'song' as const,
name: song.title,
artist: song.artist,
downloadedAt: song.downloadedAt
}));
}
}
// Create a singleton instance that will be initialized on the client side
let downloadManagerInstance: DownloadManager | null = null;
// Only create the download manager instance on the client side
if (typeof window !== 'undefined') {
downloadManagerInstance = new DownloadManager();
}
// Create a safe wrapper around the download manager
const downloadManager = {
initialize: async () => {
if (!downloadManagerInstance) return false;
return downloadManagerInstance.initialize();
},
getOfflineStats: async () => {
if (!downloadManagerInstance) return {
totalSize: 0,
audioSize: 0,
imageSize: 0,
metaSize: 0,
downloadedAlbums: 0,
downloadedSongs: 0,
lastDownload: null,
downloadErrors: 0,
remainingStorage: null,
autoDownloadEnabled: false,
downloadQuality: 'original' as const,
downloadOnWifiOnly: true,
priorityContent: []
};
return downloadManagerInstance.getOfflineStats();
},
downloadAlbum: async (album: Album, songs: Song[], progressCallback: (progress: DownloadProgress) => void) => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.downloadAlbum(album, songs, progressCallback);
},
downloadAlbumFallback: async (album: Album, songs: Song[]) => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.downloadAlbumFallback(album, songs);
},
downloadSong: async (song: Song) => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.downloadSong(song);
},
getOfflineData: () => {
if (!downloadManagerInstance) return { albums: {}, songs: {} };
return downloadManagerInstance.getOfflineData();
},
saveOfflineData: (data: any) => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.saveOfflineData(data);
},
checkOfflineStatus: async (id: string, type: 'album' | 'song') => {
if (!downloadManagerInstance) return false;
return downloadManagerInstance.checkOfflineStatus(id, type);
},
checkOfflineStatusFallback: (id: string, type: 'album' | 'song') => {
if (!downloadManagerInstance) return false;
return downloadManagerInstance.checkOfflineStatusFallback(id, type);
},
deleteOfflineContent: async (id: string, type: 'album' | 'song') => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.deleteOfflineContent(id, type);
},
deleteOfflineContentFallback: async (id: string, type: 'album' | 'song') => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.deleteOfflineContentFallback(id, type);
},
getOfflineItems: async () => {
if (!downloadManagerInstance) return { albums: [], songs: [] };
return downloadManagerInstance.getOfflineItems();
},
getOfflineAlbums: () => {
if (!downloadManagerInstance) return [];
return downloadManagerInstance.getOfflineAlbums();
},
getOfflineSongs: () => {
if (!downloadManagerInstance) return [];
return downloadManagerInstance.getOfflineSongs();
},
downloadQueue: async (songs: Song[]) => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.downloadQueue(songs);
},
enableOfflineMode: async (settings: any) => {
if (!downloadManagerInstance) return;
return downloadManagerInstance.enableOfflineMode(settings);
}
};
export function useOfflineDownloads() {
const [isSupported, setIsSupported] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
completed: 0,
total: 0,
failed: 0,
status: 'idle'
});
const [offlineStats, setOfflineStats] = useState<OfflineStats>({
totalSize: 0,
audioSize: 0,
imageSize: 0,
metaSize: 0,
downloadedAlbums: 0,
downloadedSongs: 0,
lastDownload: null,
downloadErrors: 0,
remainingStorage: null,
autoDownloadEnabled: false,
downloadQuality: 'original',
downloadOnWifiOnly: true,
priorityContent: []
});
useEffect(() => {
const initializeDownloadManager = async () => {
// Skip initialization on server-side
if (!downloadManager) {
setIsSupported(false);
setIsInitialized(true);
return;
}
const supported = await downloadManager.initialize();
setIsSupported(supported);
setIsInitialized(true);
if (supported) {
// Load initial stats
try {
const stats = await downloadManager.getOfflineStats();
setOfflineStats(stats);
} catch (error) {
console.error('Failed to load offline stats:', error);
}
}
};
initializeDownloadManager();
}, []);
const downloadAlbum = useCallback(async (album: Album, songs: Song[]) => {
try {
if (isSupported) {
await downloadManager.downloadAlbum(album, songs, setDownloadProgress);
} else {
// Fallback to localStorage metadata only
await downloadManager.downloadAlbumFallback(album, songs);
}
// Refresh stats
if (isSupported) {
const stats = await downloadManager.getOfflineStats();
setOfflineStats(stats);
}
} catch (error) {
console.error('Download failed:', error);
setDownloadProgress(prev => ({ ...prev, status: 'error', error: (error as Error).message }));
throw error;
}
}, [isSupported]);
const downloadSong = useCallback(async (song: Song) => {
if (isSupported) {
await downloadManager.downloadSong(song);
} else {
// Fallback - just save metadata
const offlineData = downloadManager.getOfflineData();
offlineData.songs[song.id] = {
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
downloadedAt: Date.now()
};
downloadManager.saveOfflineData(offlineData);
}
}, [isSupported]);
const checkOfflineStatus = useCallback(async (id: string, type: 'album' | 'song'): Promise<boolean> => {
if (isSupported) {
return downloadManager.checkOfflineStatus(id, type);
} else {
return downloadManager.checkOfflineStatusFallback(id, type);
}
}, [isSupported]);
const deleteOfflineContent = useCallback(async (id: string, type: 'album' | 'song') => {
if (isSupported) {
await downloadManager.deleteOfflineContent(id, type);
} else {
await downloadManager.deleteOfflineContentFallback(id, type);
}
// Refresh stats
if (isSupported) {
const stats = await downloadManager.getOfflineStats();
setOfflineStats(stats);
}
}, [isSupported]);
const getOfflineItems = useCallback(async (): Promise<OfflineItem[]> => {
if (isSupported) {
try {
const { albums, songs } = await downloadManager.getOfflineItems();
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
} catch (e) {
console.error('Failed to get offline items from SW, falling back:', e);
}
}
const albums = downloadManager.getOfflineAlbums();
const songs = downloadManager.getOfflineSongs();
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
}, [isSupported]);
const clearDownloadProgress = useCallback(() => {
setDownloadProgress({
completed: 0,
total: 0,
failed: 0,
status: 'idle'
});
}, []);
const downloadQueue = useCallback(async (songs: Song[]) => {
if (isSupported) {
setDownloadProgress({ completed: 0, total: songs.length, failed: 0, status: 'downloading' });
try {
await downloadManager.downloadQueue(songs);
// Stats will be updated via progress events
} catch (error) {
console.error('Queue download failed:', error);
setDownloadProgress(prev => ({ ...prev, status: 'error' }));
}
} else {
// Fallback: just store metadata
const offlineData = downloadManager.getOfflineData();
songs.forEach(song => {
offlineData.songs[song.id] = {
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
downloadedAt: Date.now()
};
});
downloadManager.saveOfflineData(offlineData);
}
}, [isSupported]);
const enableOfflineMode = useCallback(async (settings: {
autoDownloadQueue?: boolean;
forceOffline?: boolean;
currentQueue?: Song[];
}) => {
if (isSupported) {
try {
await downloadManager.enableOfflineMode(settings);
} catch (error) {
console.error('Failed to enable offline mode:', error);
}
}
}, [isSupported]);
return {
isSupported,
isInitialized,
downloadProgress,
offlineStats,
downloadAlbum,
downloadSong,
downloadQueue,
enableOfflineMode,
checkOfflineStatus,
deleteOfflineContent,
getOfflineItems,
clearDownloadProgress
};
}
// Export the manager instance for direct use if needed
export { downloadManager };

View File

@@ -1,517 +0,0 @@
'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { offlineLibraryDB, LibrarySyncStats, OfflineAlbum, OfflineArtist, OfflineSong, OfflinePlaylist } from '@/lib/indexeddb';
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
import { useToast } from '@/hooks/use-toast';
import { getNavidromeAPI, Song } from '@/lib/navidrome';
export interface LibrarySyncProgress {
phase: 'idle' | 'albums' | 'artists' | 'songs' | 'playlists' | 'operations' | 'complete' | 'error';
current: number;
total: number;
message: string;
}
export interface LibrarySyncOptions {
includeAlbums: boolean;
includeArtists: boolean;
includeSongs: boolean;
includePlaylists: boolean;
syncStarred: boolean;
maxSongs: number; // Limit to prevent overwhelming the database
}
const defaultSyncOptions: LibrarySyncOptions = {
includeAlbums: true,
includeArtists: true,
includeSongs: true,
includePlaylists: true,
syncStarred: true,
maxSongs: 1000 // Default limit
};
export function useOfflineLibrarySync() {
const [isInitialized, setIsInitialized] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [syncProgress, setSyncProgress] = useState<LibrarySyncProgress>({
phase: 'idle',
current: 0,
total: 0,
message: ''
});
const [stats, setStats] = useState<LibrarySyncStats>({
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0,
syncInProgress: false
});
const [isOnline, setIsOnline] = useState(true);
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
const [syncOptions, setSyncOptions] = useState<LibrarySyncOptions>(defaultSyncOptions);
const { config, isConnected } = useNavidromeConfig();
const api = useMemo(() => getNavidromeAPI(config), [config]);
const { toast } = useToast();
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Initialize the offline library database
useEffect(() => {
const initializeDB = async () => {
try {
const initialized = await offlineLibraryDB.initialize();
setIsInitialized(initialized);
if (initialized) {
await refreshStats();
loadSyncSettings();
}
} catch (error) {
console.error('Failed to initialize offline library:', error);
}
};
initializeDB();
}, []);
// Monitor online status
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Check if navigator is available (client-side only)
if (typeof navigator !== 'undefined') {
setIsOnline(navigator.onLine);
}
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Auto-sync when coming back online
useEffect(() => {
if (isOnline && isConnected && autoSyncEnabled && !isSyncing) {
const pendingOpsSync = async () => {
try {
await syncPendingOperations();
} catch (error) {
console.error('Auto-sync failed:', error);
}
};
// Delay auto-sync to avoid immediate trigger
syncTimeoutRef.current = setTimeout(pendingOpsSync, 2000);
}
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [isOnline, isConnected, autoSyncEnabled, isSyncing]);
const loadSyncSettings = useCallback(async () => {
try {
const [autoSync, savedOptions] = await Promise.all([
offlineLibraryDB.getMetadata<boolean>('autoSyncEnabled'),
offlineLibraryDB.getMetadata<LibrarySyncOptions>('syncOptions')
]);
if (typeof autoSync === 'boolean') setAutoSyncEnabled(autoSync);
if (savedOptions) {
setSyncOptions({ ...defaultSyncOptions, ...savedOptions });
}
} catch (error) {
console.error('Failed to load sync settings:', error);
}
}, []);
const refreshStats = useCallback(async () => {
if (!isInitialized) return;
try {
const newStats = await offlineLibraryDB.getStats();
setStats(newStats);
} catch (error) {
console.error('Failed to refresh stats:', error);
}
}, [isInitialized]);
const updateSyncProgress = useCallback((phase: LibrarySyncProgress['phase'], current: number, total: number, message: string) => {
setSyncProgress({ phase, current, total, message });
}, []);
const syncLibraryFromServer = useCallback(async (options: Partial<LibrarySyncOptions> = {}) => {
if (!api || !isConnected || !isInitialized) {
throw new Error('Cannot sync: API not available or not connected');
}
if (isSyncing) {
throw new Error('Sync already in progress');
}
const actualOptions = { ...syncOptions, ...options };
try {
setIsSyncing(true);
await offlineLibraryDB.setMetadata('syncInProgress', true);
updateSyncProgress('albums', 0, 0, 'Testing server connection...');
// Test connection first
const connected = await api.ping();
if (!connected) {
throw new Error('No connection to Navidrome server');
}
let totalItems = 0;
let processedItems = 0;
// Sync albums
if (actualOptions.includeAlbums) {
updateSyncProgress('albums', 0, 0, 'Fetching albums from server...');
const albums = await api.getAlbums('alphabeticalByName', 5000);
totalItems += albums.length;
updateSyncProgress('albums', 0, albums.length, `Storing ${albums.length} albums...`);
const mappedAlbums: OfflineAlbum[] = albums.map(album => ({
...album,
lastModified: Date.now(),
synced: true
}));
await offlineLibraryDB.storeAlbums(mappedAlbums);
processedItems += albums.length;
updateSyncProgress('albums', albums.length, albums.length, `Stored ${albums.length} albums`);
}
// Sync artists
if (actualOptions.includeArtists) {
updateSyncProgress('artists', processedItems, totalItems, 'Fetching artists from server...');
const artists = await api.getArtists();
totalItems += artists.length;
updateSyncProgress('artists', 0, artists.length, `Storing ${artists.length} artists...`);
const mappedArtists: OfflineArtist[] = artists.map(artist => ({
...artist,
lastModified: Date.now(),
synced: true
}));
await offlineLibraryDB.storeArtists(mappedArtists);
processedItems += artists.length;
updateSyncProgress('artists', artists.length, artists.length, `Stored ${artists.length} artists`);
}
// Sync playlists
if (actualOptions.includePlaylists) {
updateSyncProgress('playlists', processedItems, totalItems, 'Fetching playlists from server...');
const playlists = await api.getPlaylists();
totalItems += playlists.length;
updateSyncProgress('playlists', 0, playlists.length, `Storing ${playlists.length} playlists...`);
const mappedPlaylists: OfflinePlaylist[] = await Promise.all(
playlists.map(async (playlist) => {
try {
const playlistDetails = await api.getPlaylist(playlist.id);
return {
...playlist,
songIds: (playlistDetails.songs || []).map((song: Song) => song.id),
lastModified: Date.now(),
synced: true
};
} catch (error) {
console.warn(`Failed to get details for playlist ${playlist.id}:`, error);
return {
...playlist,
songIds: [],
lastModified: Date.now(),
synced: true
};
}
})
);
await offlineLibraryDB.storePlaylists(mappedPlaylists);
processedItems += playlists.length;
updateSyncProgress('playlists', playlists.length, playlists.length, `Stored ${playlists.length} playlists`);
}
// Sync songs (limited to avoid overwhelming the database)
if (actualOptions.includeSongs) {
updateSyncProgress('songs', processedItems, totalItems, 'Fetching songs from server...');
const albums = await offlineLibraryDB.getAlbums();
const albumsToSync = albums.slice(0, Math.floor(actualOptions.maxSongs / 10)); // Roughly 10 songs per album
let songCount = 0;
updateSyncProgress('songs', 0, albumsToSync.length, `Processing songs for ${albumsToSync.length} albums...`);
for (let i = 0; i < albumsToSync.length; i++) {
const album = albumsToSync[i];
try {
const { songs } = await api.getAlbum(album.id);
if (songCount + songs.length > actualOptions.maxSongs) {
const remaining = actualOptions.maxSongs - songCount;
if (remaining > 0) {
const limitedSongs = songs.slice(0, remaining);
const mappedSongs: OfflineSong[] = limitedSongs.map(song => ({
...song,
lastModified: Date.now(),
synced: true
}));
await offlineLibraryDB.storeSongs(mappedSongs);
songCount += limitedSongs.length;
}
break;
}
const mappedSongs: OfflineSong[] = songs.map(song => ({
...song,
lastModified: Date.now(),
synced: true
}));
await offlineLibraryDB.storeSongs(mappedSongs);
songCount += songs.length;
updateSyncProgress('songs', i + 1, albumsToSync.length, `Processed ${i + 1}/${albumsToSync.length} albums (${songCount} songs)`);
} catch (error) {
console.warn(`Failed to sync songs for album ${album.id}:`, error);
}
}
updateSyncProgress('songs', albumsToSync.length, albumsToSync.length, `Stored ${songCount} songs`);
}
// Sync pending operations to server
updateSyncProgress('operations', 0, 0, 'Syncing pending operations...');
await syncPendingOperations();
// Update sync timestamp
await offlineLibraryDB.setMetadata('lastSync', Date.now());
updateSyncProgress('complete', 100, 100, 'Library sync completed successfully');
toast({
title: "Sync Complete",
description: `Successfully synced library data offline`,
});
} catch (error) {
console.error('Library sync failed:', error);
updateSyncProgress('error', 0, 0, `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
toast({
title: "Sync Failed",
description: error instanceof Error ? error.message : 'Unknown error occurred',
variant: "destructive"
});
throw error;
} finally {
setIsSyncing(false);
await offlineLibraryDB.setMetadata('syncInProgress', false);
await refreshStats();
}
}, [api, isConnected, isInitialized, isSyncing, syncOptions, toast, updateSyncProgress, refreshStats]);
const syncPendingOperations = useCallback(async () => {
if (!api || !isConnected || !isInitialized) {
return;
}
try {
const operations = await offlineLibraryDB.getSyncOperations();
if (operations.length === 0) {
return;
}
updateSyncProgress('operations', 0, operations.length, 'Syncing pending operations...');
for (let i = 0; i < operations.length; i++) {
const operation = operations[i];
try {
switch (operation.type) {
case 'star':
if (operation.entityType !== 'playlist') {
await api.star(operation.entityId, operation.entityType);
}
break;
case 'unstar':
if (operation.entityType !== 'playlist') {
await api.unstar(operation.entityId, operation.entityType);
}
break;
case 'scrobble':
await api.scrobble(operation.entityId);
break;
case 'create_playlist':
if ('name' in operation.data && typeof operation.data.name === 'string') {
await api.createPlaylist(
operation.data.name,
'songIds' in operation.data ? operation.data.songIds : undefined
);
}
break;
case 'update_playlist':
if ('name' in operation.data || 'comment' in operation.data || 'songIds' in operation.data) {
const d = operation.data as { name?: string; comment?: string; songIds?: string[] };
await api.updatePlaylist(operation.entityId, d.name, d.comment, d.songIds);
}
break;
case 'delete_playlist':
await api.deletePlaylist(operation.entityId);
break;
}
await offlineLibraryDB.removeSyncOperation(operation.id);
updateSyncProgress('operations', i + 1, operations.length, `Synced ${i + 1}/${operations.length} operations`);
} catch (error) {
console.error(`Failed to sync operation ${operation.id}:`, error);
// Don't remove failed operations, they'll be retried later
}
}
} catch (error) {
console.error('Failed to sync pending operations:', error);
}
}, [api, isConnected, isInitialized, updateSyncProgress]);
const clearOfflineData = useCallback(async () => {
if (!isInitialized) return;
try {
await offlineLibraryDB.clearAllData();
await refreshStats();
toast({
title: "Offline Data Cleared",
description: "All offline library data has been removed",
});
} catch (error) {
console.error('Failed to clear offline data:', error);
toast({
title: "Clear Failed",
description: "Failed to clear offline data",
variant: "destructive"
});
}
}, [isInitialized, refreshStats, toast]);
const updateAutoSync = useCallback(async (enabled: boolean) => {
setAutoSyncEnabled(enabled);
try {
await offlineLibraryDB.setMetadata('autoSyncEnabled', enabled);
} catch (error) {
console.error('Failed to save auto-sync setting:', error);
}
}, []);
const updateSyncOptions = useCallback(async (newOptions: Partial<LibrarySyncOptions>) => {
const updatedOptions = { ...syncOptions, ...newOptions };
setSyncOptions(updatedOptions);
try {
await offlineLibraryDB.setMetadata('syncOptions', updatedOptions);
} catch (error) {
console.error('Failed to save sync options:', error);
}
}, [syncOptions]);
// Offline-first operations
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
if (!isInitialized) throw new Error('Offline library not initialized');
try {
await offlineLibraryDB.starItem(id, type);
await refreshStats();
// Try to sync immediately if online
if (isOnline && isConnected && api) {
try {
await api.star(id, type);
await offlineLibraryDB.removeSyncOperation(`star-${id}`);
} catch (error) {
console.log('Failed to sync star operation immediately, will retry later:', error);
}
}
} catch (error) {
console.error('Failed to star item:', error);
throw error;
}
}, [isInitialized, refreshStats, isOnline, isConnected, api]);
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
if (!isInitialized) throw new Error('Offline library not initialized');
try {
await offlineLibraryDB.unstarItem(id, type);
await refreshStats();
// Try to sync immediately if online
if (isOnline && isConnected && api) {
try {
await api.unstar(id, type);
await offlineLibraryDB.removeSyncOperation(`unstar-${id}`);
} catch (error) {
console.log('Failed to sync unstar operation immediately, will retry later:', error);
}
}
} catch (error) {
console.error('Failed to unstar item:', error);
throw error;
}
}, [isInitialized, refreshStats, isOnline, isConnected, api]);
return {
// State
isInitialized,
isSyncing,
syncProgress,
stats,
isOnline,
autoSyncEnabled,
syncOptions,
// Actions
syncLibraryFromServer,
syncPendingOperations,
clearOfflineData,
updateAutoSync,
updateSyncOptions,
refreshStats,
starItem,
unstarItem,
// Data access (for offline access)
getOfflineAlbums: () => offlineLibraryDB.getAlbums(),
getOfflineArtists: () => offlineLibraryDB.getArtists(),
getOfflineSongs: (albumId?: string) => offlineLibraryDB.getSongs(albumId),
getOfflinePlaylists: () => offlineLibraryDB.getPlaylists(),
getOfflineAlbum: (id: string) => offlineLibraryDB.getAlbum(id)
};
}

View File

@@ -1,538 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { offlineLibraryManager, type OfflineLibraryStats, type SyncOperation } from '@/lib/offline-library';
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
export interface OfflineLibraryState {
isInitialized: boolean;
isOnline: boolean;
isSyncing: boolean;
lastSync: Date | null;
stats: OfflineLibraryStats;
syncProgress: {
current: number;
total: number;
stage: string;
} | null;
}
export function useOfflineLibrary() {
// Check if we're on the client side
const isClient = typeof window !== 'undefined';
const [state, setState] = useState<OfflineLibraryState>({
isInitialized: false,
isOnline: isClient ? navigator.onLine : true, // Default to true during SSR
isSyncing: false,
lastSync: null,
stats: {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
},
syncProgress: null
});
const { api } = useNavidrome();
// Initialize offline library
useEffect(() => {
const initializeOfflineLibrary = async () => {
try {
const initialized = await offlineLibraryManager.initialize();
if (initialized) {
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({
...prev,
isInitialized: true,
stats,
lastSync: stats.lastSync
}));
}
} catch (error) {
console.error('Failed to initialize offline library:', error);
}
};
initializeOfflineLibrary();
}, []);
// Listen for online/offline events
useEffect(() => {
const handleOnline = () => {
setState(prev => ({ ...prev, isOnline: true }));
// Automatically sync when back online
if (state.isInitialized && api) {
syncPendingOperations();
}
};
const handleOffline = () => {
setState(prev => ({ ...prev, isOnline: false }));
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [state.isInitialized, api]);
// Full library sync from server
const syncLibraryFromServer = useCallback(async (): Promise<void> => {
if (!api || !state.isInitialized || state.isSyncing) return;
try {
setState(prev => ({
...prev,
isSyncing: true,
syncProgress: { current: 0, total: 100, stage: 'Starting sync...' }
}));
setState(prev => ({
...prev,
syncProgress: { current: 20, total: 100, stage: 'Syncing albums...' }
}));
await offlineLibraryManager.syncFromServer(api);
setState(prev => ({
...prev,
syncProgress: { current: 80, total: 100, stage: 'Syncing pending operations...' }
}));
await offlineLibraryManager.syncPendingOperations(api);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({
...prev,
isSyncing: false,
syncProgress: null,
stats,
lastSync: stats.lastSync
}));
} catch (error) {
console.error('Library sync failed:', error);
setState(prev => ({
...prev,
isSyncing: false,
syncProgress: null
}));
throw error;
}
}, [api, state.isInitialized, state.isSyncing]);
// Sync only pending operations
const syncPendingOperations = useCallback(async (): Promise<void> => {
if (!api || !state.isInitialized) return;
try {
await offlineLibraryManager.syncPendingOperations(api);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
} catch (error) {
console.error('Failed to sync pending operations:', error);
}
}, [api, state.isInitialized]);
// Data retrieval methods (offline-first)
const getAlbums = useCallback(async (starred?: boolean): Promise<Album[]> => {
if (!state.isInitialized) return [];
try {
// Try offline first
const offlineAlbums = await offlineLibraryManager.getAlbums(starred);
// If offline data exists, return it
if (offlineAlbums.length > 0) {
return offlineAlbums;
}
// If no offline data and we're online, try server
if (state.isOnline && api) {
const serverAlbums = starred
? await api.getAlbums('starred')
: await api.getAlbums('alphabeticalByName', 100);
// Cache the results
await offlineLibraryManager.storeAlbums(serverAlbums);
return serverAlbums;
}
return [];
} catch (error) {
console.error('Failed to get albums:', error);
return [];
}
}, [state.isInitialized, state.isOnline, api]);
const getArtists = useCallback(async (starred?: boolean): Promise<Artist[]> => {
if (!state.isInitialized) return [];
try {
const offlineArtists = await offlineLibraryManager.getArtists(starred);
if (offlineArtists.length > 0) {
return offlineArtists;
}
if (state.isOnline && api) {
const serverArtists = await api.getArtists();
await offlineLibraryManager.storeArtists(serverArtists);
return serverArtists;
}
return [];
} catch (error) {
console.error('Failed to get artists:', error);
return [];
}
}, [state.isInitialized, state.isOnline, api]);
const getAlbum = useCallback(async (albumId: string): Promise<{ album: Album; songs: Song[] } | null> => {
if (!state.isInitialized) return null;
try {
// Try offline first
const offlineData = await offlineLibraryManager.getAlbum(albumId);
if (offlineData && offlineData.songs.length > 0) {
return offlineData;
}
// If no offline data and we're online, try server
if (state.isOnline && api) {
const serverData = await api.getAlbum(albumId);
// Cache the results
await offlineLibraryManager.storeAlbums([serverData.album]);
await offlineLibraryManager.storeSongs(serverData.songs);
return serverData;
}
return offlineData;
} catch (error) {
console.error('Failed to get album:', error);
return null;
}
}, [state.isInitialized, state.isOnline, api]);
const getPlaylists = useCallback(async (): Promise<Playlist[]> => {
if (!state.isInitialized) return [];
try {
const offlinePlaylists = await offlineLibraryManager.getPlaylists();
if (offlinePlaylists.length > 0) {
return offlinePlaylists;
}
if (state.isOnline && api) {
const serverPlaylists = await api.getPlaylists();
await offlineLibraryManager.storePlaylists(serverPlaylists);
return serverPlaylists;
}
return [];
} catch (error) {
console.error('Failed to get playlists:', error);
return [];
}
}, [state.isInitialized, state.isOnline, api]);
// Search (offline-first)
const searchOffline = useCallback(async (query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> => {
if (!state.isInitialized) {
return { artists: [], albums: [], songs: [] };
}
try {
const offlineResults = await offlineLibraryManager.searchOffline(query);
// If we have good offline results, return them
const totalResults = offlineResults.artists.length + offlineResults.albums.length + offlineResults.songs.length;
if (totalResults > 0) {
return offlineResults;
}
// If no offline results and we're online, try server
if (state.isOnline && api) {
return await api.search2(query);
}
return offlineResults;
} catch (error) {
console.error('Search failed:', error);
return { artists: [], albums: [], songs: [] };
}
}, [state.isInitialized, state.isOnline, api]);
// Offline favorites management
const starOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
if (!state.isInitialized) return;
try {
if (state.isOnline && api) {
// If online, try server first
await api.star(id, type);
}
// Always update offline data
await offlineLibraryManager.starOffline(id, type);
// Update stats
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
} catch (error) {
console.error('Failed to star item:', error);
// If server failed but we're online, still save offline for later sync
if (state.isOnline) {
await offlineLibraryManager.starOffline(id, type);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
}
throw error;
}
}, [state.isInitialized, state.isOnline, api]);
const unstarOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
if (!state.isInitialized) return;
try {
if (state.isOnline && api) {
await api.unstar(id, type);
}
await offlineLibraryManager.unstarOffline(id, type);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
} catch (error) {
console.error('Failed to unstar item:', error);
if (state.isOnline) {
await offlineLibraryManager.unstarOffline(id, type);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
}
throw error;
}
}, [state.isInitialized, state.isOnline, api]);
// Playlist management
const createPlaylistOffline = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
if (!state.isInitialized) {
throw new Error('Offline library not initialized');
}
try {
if (state.isOnline && api) {
// If online, try server first
const serverPlaylist = await api.createPlaylist(name, songIds);
await offlineLibraryManager.storePlaylists([serverPlaylist]);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
return serverPlaylist;
} else {
// If offline, create locally and queue for sync
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
return offlinePlaylist;
}
} catch (error) {
console.error('Failed to create playlist:', error);
// If server failed but we're online, create offline version for later sync
if (state.isOnline) {
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
return offlinePlaylist;
}
throw error;
}
}, [state.isInitialized, state.isOnline, api]);
// Scrobble (offline-capable)
const scrobbleOffline = useCallback(async (songId: string): Promise<void> => {
if (!state.isInitialized) return;
try {
if (state.isOnline && api) {
await api.scrobble(songId);
} else {
// Queue for later sync
await offlineLibraryManager.addSyncOperation({
type: 'scrobble',
entityType: 'song',
entityId: songId,
data: {}
});
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
}
} catch (error) {
console.error('Failed to scrobble:', error);
// Queue for later sync if server failed
await offlineLibraryManager.addSyncOperation({
type: 'scrobble',
entityType: 'song',
entityId: songId,
data: {}
});
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats }));
}
}, [state.isInitialized, state.isOnline, api]);
// Clear all offline data
const clearOfflineData = useCallback(async (): Promise<void> => {
if (!state.isInitialized) return;
try {
await offlineLibraryManager.clearAllData();
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({
...prev,
stats,
lastSync: null
}));
} catch (error) {
console.error('Failed to clear offline data:', error);
throw error;
}
}, [state.isInitialized]);
// Get songs with offline-first approach
const getSongs = useCallback(async (albumId?: string, artistId?: string): Promise<Song[]> => {
if (!state.isInitialized) return [];
try {
const offlineSongs = await offlineLibraryManager.getSongs(albumId, artistId);
if (offlineSongs.length > 0) {
return offlineSongs;
}
if (state.isOnline && api) {
let serverSongs: Song[] = [];
if (albumId) {
const { songs } = await api.getAlbum(albumId);
await offlineLibraryManager.storeSongs(songs);
serverSongs = songs;
} else if (artistId) {
const { albums } = await api.getArtist(artistId);
const allSongs: Song[] = [];
for (const album of albums) {
const { songs } = await api.getAlbum(album.id);
allSongs.push(...songs);
}
await offlineLibraryManager.storeSongs(allSongs);
serverSongs = allSongs;
}
return serverSongs;
}
return [];
} catch (error) {
console.error('Failed to get songs:', error);
return [];
}
}, [api, state.isInitialized, state.isOnline]);
// Queue sync operation
const queueSyncOperation = useCallback(async (operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> => {
if (!state.isInitialized) return;
const fullOperation: SyncOperation = {
...operation,
id: `${operation.type}-${operation.entityId}-${Date.now()}`,
timestamp: Date.now(),
retryCount: 0
};
await offlineLibraryManager.addSyncOperation(fullOperation);
await refreshStats();
}, [state.isInitialized]);
// Update playlist offline
const updatePlaylistOffline = useCallback(async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
if (!state.isInitialized) return;
await offlineLibraryManager.updatePlaylist(id, name, comment, songIds);
await refreshStats();
}, [state.isInitialized]);
// Delete playlist offline
const deletePlaylistOffline = useCallback(async (id: string): Promise<void> => {
if (!state.isInitialized) return;
await offlineLibraryManager.deletePlaylist(id);
await refreshStats();
}, [state.isInitialized]);
// Refresh stats
const refreshStats = useCallback(async (): Promise<void> => {
if (!state.isInitialized) return;
try {
const stats = await offlineLibraryManager.getLibraryStats();
setState(prev => ({ ...prev, stats, lastSync: stats.lastSync }));
} catch (error) {
console.error('Failed to refresh stats:', error);
}
}, [state.isInitialized]);
return {
// State
...state,
// Sync methods
syncLibraryFromServer,
syncPendingOperations,
// Data retrieval (offline-first)
getAlbums,
getArtists,
getSongs,
getAlbum,
getPlaylists,
searchOffline,
// Offline operations
starOffline,
unstarOffline,
createPlaylistOffline,
updatePlaylistOffline,
deletePlaylistOffline,
scrobbleOffline,
queueSyncOperation,
// Management
clearOfflineData,
refreshStats
};
}

View File

@@ -3,8 +3,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
const INITIAL_BATCH_SIZE = 24; // Initial number of albums to load
const BATCH_SIZE = 24; // Number of albums to load in each batch
@@ -18,8 +16,6 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
const [hasMore, setHasMore] = useState(true);
const [currentOffset, setCurrentOffset] = useState(0);
const { api } = useNavidrome();
const offlineApi = useOfflineNavidrome();
const offlineLibrary = useOfflineLibrary();
const [error, setError] = useState<string | null>(null);
// Load initial batch
@@ -36,84 +32,19 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
};
}, [sortBy]);
// We'll define the scroll listener after defining loadMoreAlbums
// Load initial batch of albums
const loadInitialBatch = useCallback(async () => {
if (!api && !offlineLibrary.isInitialized) return;
if (!api) return;
setIsLoading(true);
setError(null);
try {
let albumData: Album[] = [];
// Try offline-first approach
if (offlineLibrary.isInitialized) {
try {
// For starred albums, use the starred parameter
if (sortBy === 'starred') {
albumData = await offlineApi.getAlbums(true);
} else {
albumData = await offlineApi.getAlbums(false);
// Apply client-side sorting since offline API might not support all sort options
if (sortBy === 'newest') {
albumData.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
} else if (sortBy === 'alphabeticalByArtist') {
albumData.sort((a, b) => a.artist.localeCompare(b.artist));
} else if (sortBy === 'alphabeticalByName') {
albumData.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === 'recent') {
// Sort by recently played - if we have timestamps
const recentlyPlayedMap = new Map<string, number>();
const recentlyPlayed = localStorage.getItem('recently-played-albums');
if (recentlyPlayed) {
try {
const parsed = JSON.parse(recentlyPlayed);
Object.entries(parsed).forEach(([id, timestamp]) => {
recentlyPlayedMap.set(id, timestamp as number);
});
albumData.sort((a, b) => {
const timestampA = recentlyPlayedMap.get(a.id) || 0;
const timestampB = recentlyPlayedMap.get(b.id) || 0;
return timestampB - timestampA;
});
} catch (error) {
console.error('Error parsing recently played albums:', error);
}
}
}
}
// If we got albums offline and it's non-empty, use that
if (albumData && albumData.length > 0) {
// Just take the initial batch for consistent behavior
const initialBatch = albumData.slice(0, INITIAL_BATCH_SIZE);
setAlbums(initialBatch);
setCurrentOffset(initialBatch.length);
setHasMore(albumData.length > initialBatch.length);
setIsLoading(false);
return;
}
} catch (offlineError) {
console.error('Error loading albums from offline storage:', offlineError);
// Continue to online API as fallback
}
}
// Fall back to online API if needed
if (api) {
albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0);
setAlbums(albumData);
setCurrentOffset(albumData.length);
// Assume there are more unless we got fewer than we asked for
setHasMore(albumData.length >= INITIAL_BATCH_SIZE);
} else {
// No API available
setAlbums([]);
setHasMore(false);
}
const albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0);
setAlbums(albumData);
setCurrentOffset(albumData.length);
// Assume there are more unless we got fewer than we asked for
setHasMore(albumData.length >= INITIAL_BATCH_SIZE);
} catch (err) {
console.error('Failed to load initial albums batch:', err);
setError(err instanceof Error ? err.message : 'Unknown error loading albums');
@@ -122,81 +53,20 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
} finally {
setIsLoading(false);
}
}, [api, offlineApi, offlineLibrary, sortBy]);
}, [api, sortBy]);
// Load more albums when scrolling
const loadMoreAlbums = useCallback(async () => {
if (isLoading || !hasMore) return;
if (isLoading || !hasMore || !api) return;
setIsLoading(true);
try {
let newAlbums: Album[] = [];
// Try offline-first approach (if we already have offline data)
if (offlineLibrary.isInitialized && albums.length > 0) {
try {
// For starred albums, use the starred parameter
let allAlbums: Album[] = [];
if (sortBy === 'starred') {
allAlbums = await offlineApi.getAlbums(true);
} else {
allAlbums = await offlineApi.getAlbums(false);
// Apply client-side sorting
if (sortBy === 'newest') {
allAlbums.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
} else if (sortBy === 'alphabeticalByArtist') {
allAlbums.sort((a, b) => a.artist.localeCompare(b.artist));
} else if (sortBy === 'alphabeticalByName') {
allAlbums.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === 'recent') {
// Sort by recently played - if we have timestamps
const recentlyPlayedMap = new Map<string, number>();
const recentlyPlayed = localStorage.getItem('recently-played-albums');
if (recentlyPlayed) {
try {
const parsed = JSON.parse(recentlyPlayed);
Object.entries(parsed).forEach(([id, timestamp]) => {
recentlyPlayedMap.set(id, timestamp as number);
});
allAlbums.sort((a, b) => {
const timestampA = recentlyPlayedMap.get(a.id) || 0;
const timestampB = recentlyPlayedMap.get(b.id) || 0;
return timestampB - timestampA;
});
} catch (error) {
console.error('Error parsing recently played albums:', error);
}
}
}
}
// Slice the next batch from the offline data
if (allAlbums && allAlbums.length > currentOffset) {
newAlbums = allAlbums.slice(currentOffset, currentOffset + BATCH_SIZE);
setAlbums(prev => [...prev, ...newAlbums]);
setCurrentOffset(currentOffset + newAlbums.length);
setHasMore(allAlbums.length > currentOffset + newAlbums.length);
setIsLoading(false);
return;
}
} catch (offlineError) {
console.error('Error loading more albums from offline storage:', offlineError);
// Continue to online API as fallback
}
}
// Fall back to online API
if (api) {
newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset);
setAlbums(prev => [...prev, ...newAlbums]);
setCurrentOffset(currentOffset + newAlbums.length);
// If we get fewer albums than we asked for, we've reached the end
setHasMore(newAlbums.length >= BATCH_SIZE);
} else {
setHasMore(false);
}
const newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset);
setAlbums(prev => [...prev, ...newAlbums]);
setCurrentOffset(currentOffset + newAlbums.length);
// If we get fewer albums than we asked for, we've reached the end
setHasMore(newAlbums.length >= BATCH_SIZE);
} catch (err) {
console.error('Failed to load more albums:', err);
setError(err instanceof Error ? err.message : 'Unknown error loading more albums');
@@ -204,7 +74,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
} finally {
setIsLoading(false);
}
}, [api, offlineApi, offlineLibrary, albums, currentOffset, isLoading, hasMore, sortBy]);
}, [api, currentOffset, isLoading, hasMore, sortBy]);
// Manual refresh (useful for pull-to-refresh functionality)
const refreshAlbums = useCallback(() => {
@@ -214,7 +84,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
loadInitialBatch();
}, [loadInitialBatch]);
// Setup scroll listener after function declarations
// Setup scroll listener
useEffect(() => {
const handleScroll = () => {
// Don't trigger if already loading
@@ -231,7 +101,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [isLoading, hasMore, currentOffset, loadMoreAlbums]);
}, [isLoading, hasMore, loadMoreAlbums]);
return {
albums,