feat: Implement offline library management with IndexedDB support

- Added `useOfflineLibrary` hook for managing offline library state and synchronization.
- Created `OfflineLibraryManager` class for handling IndexedDB operations and syncing with Navidrome API.
- Implemented methods for retrieving and storing albums, artists, songs, and playlists.
- Added support for offline favorites management (star/unstar).
- Implemented playlist creation, updating, and deletion functionalities.
- Added search functionality for offline data.
- Created a manifest file for PWA support with icons and shortcuts.
- Added service worker file for caching and offline capabilities.
This commit is contained in:
2025-08-07 22:07:53 +00:00
committed by GitHub
parent af5e24b80e
commit f6a6ee5d2e
23 changed files with 4239 additions and 229 deletions

View File

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