feat: Implement offline library synchronization with IndexedDB

- Added `useOfflineLibrarySync` hook for managing offline library sync operations.
- Created `OfflineLibrarySync` component for UI integration.
- Developed `offlineLibraryDB` for IndexedDB interactions, including storing and retrieving albums, artists, songs, and playlists.
- Implemented sync operations for starred items, playlists, and scrobbling.
- Added auto-sync functionality when coming back online.
- Included metadata management for sync settings and statistics.
- Enhanced error handling and user feedback through toasts.
This commit is contained in:
2025-08-08 20:04:06 +00:00
committed by GitHub
parent 0a0feb3748
commit 3839a1be2d
13 changed files with 2102 additions and 113 deletions

View File

@@ -95,16 +95,16 @@ export function CacheManagement() {
});
};
const loadOfflineItems = useCallback(() => {
const loadOfflineItems = useCallback(async () => {
if (isOfflineInitialized) {
const items = getOfflineItems();
const items = await getOfflineItems();
setOfflineItems(items);
}
}, [isOfflineInitialized, getOfflineItems]);
useEffect(() => {
loadCacheStats();
loadOfflineItems();
loadCacheStats();
loadOfflineItems();
// Load offline mode settings
const storedOfflineMode = localStorage.getItem('offline-mode-enabled');

View File

View File

@@ -1,8 +1,8 @@
'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
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 { api, isConnected } = useNavidrome();
const offline = useOfflineNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
@@ -42,66 +42,84 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
useEffect(() => {
const loadRecommendations = async () => {
if (!api || !isConnected) return;
setLoading(true);
try {
// Get random albums for both mobile album view and desktop song extraction
const randomAlbums = await api.getAlbums('random', 10);
const api = getNavidromeAPI();
const isOnline = !offline.isOfflineMode && !!api;
if (isMobile) {
// For mobile: show 6 random albums
setRecommendedAlbums(randomAlbums.slice(0, 6));
} else {
// For desktop: extract songs from albums (original behavior)
const allSongs: Song[] = [];
// Get songs from first few albums
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
try {
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
allSongs.push(...albumSongs);
} catch (error) {
console.error('Failed to get album songs:', error);
if (isOnline && api) {
// Online: use server-side recommendations
const randomAlbums = await api.getAlbums('random', 10);
if (isMobile) {
setRecommendedAlbums(randomAlbums.slice(0, 6));
} else {
const allSongs: Song[] = [];
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
try {
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
allSongs.push(...albumSongs);
} catch (error) {
console.error('Failed to get album songs:', error);
}
}
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
const states: Record<string, boolean> = {};
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);
}
// Shuffle and limit to 6 songs
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states for songs
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);
setRecommendedAlbums([]);
setRecommendedSongs([]);
} finally {
setLoading(false);
}
};
loadRecommendations();
}, [api, isConnected, isMobile]);
}, [offline, isMobile]);
const handlePlaySong = async (song: Song) => {
if (!api) return;
try {
const api = getNavidromeAPI();
const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`;
const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : undefined;
const track = {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt,
starred: !!song.starred
};
await playTrack(track, true);
@@ -111,23 +129,29 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
};
const handlePlayAlbum = async (album: Album) => {
if (!api) return;
try {
// Get album songs and play the first one
const albumSongs = await api.getAlbumSongs(album.id);
const api = getNavidromeAPI();
let albumSongs: Song[] = [];
if (api) {
albumSongs = await api.getAlbumSongs(album.id);
} else {
albumSongs = await offline.getSongs(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, 64) : undefined;
const track = {
id: albumSongs[0].id,
name: albumSongs[0].title,
url: api.getStreamUrl(albumSongs[0].id),
artist: albumSongs[0].artist || 'Unknown Artist',
artistId: albumSongs[0].artistId || '',
album: albumSongs[0].album || 'Unknown Album',
albumId: albumSongs[0].albumId || '',
duration: albumSongs[0].duration || 0,
coverArt: albumSongs[0].coverArt ? api.getCoverArtUrl(albumSongs[0].coverArt, 64) : undefined,
starred: !!albumSongs[0].starred
id: first.id,
name: first.title,
url,
artist: first.artist || 'Unknown Artist',
artistId: first.artistId || '',
album: first.album || 'Unknown Album',
albumId: first.albumId || '',
duration: first.duration || 0,
coverArt,
starred: !!first.starred
};
await playTrack(track, true);
}
@@ -222,9 +246,9 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
className="group cursor-pointer block"
>
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && api ? (
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 300)}
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
alt={album.name}
width={600}
height={600}
@@ -281,10 +305,10 @@ 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 && api ? (
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<>
<Image
src={api.getCoverArtUrl(song.coverArt, 48)}
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
alt={song.title}
fill
className="object-cover"

View File

@@ -17,6 +17,7 @@ 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";
@@ -44,6 +45,7 @@ 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();
@@ -129,7 +131,7 @@ export function AlbumArtwork({
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div className="aspect-square relative group">
{album.coverArt && api ? (
{album.coverArt && api && !offline.isOfflineMode ? (
<Image
src={coverArtUrl}
alt={album.name}

View File

@@ -4,9 +4,9 @@ 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 { useNavidrome } from './components/NavidromeContext';
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
import { useEffect, useState, Suspense } from 'react';
import { Album } from '@/lib/navidrome';
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidromeConfig } from './components/NavidromeConfigContext';
import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext';
@@ -14,52 +14,75 @@ 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';
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
function MusicPageContent() {
const { albums, isLoading, api, isConnected } = useNavidrome();
// Offline-first provider (falls back to offline data when not connected)
const offline = useOfflineNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams();
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [albumsLoading, setAlbumsLoading] = useState(true);
const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
// Load albums (offline-first)
useEffect(() => {
if (albums.length > 0) {
// Split albums into recent and newest for display
const recent = albums.slice(0, Math.ceil(albums.length / 2));
const newest = albums.slice(Math.ceil(albums.length / 2));
setRecentAlbums(recent);
setNewestAlbums(newest);
}
}, [albums]);
useEffect(() => {
const loadFavoriteAlbums = async () => {
if (!api || !isConnected) return;
setFavoritesLoading(true);
let mounted = true;
const load = async () => {
setAlbumsLoading(true);
try {
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage
setFavoriteAlbums(starredAlbums);
} catch (error) {
console.error('Failed to load favorite albums:', error);
const list = await offline.getAlbums(false);
if (!mounted) return;
setAllAlbums(list || []);
// Split albums into two sections
const recent = list.slice(0, Math.ceil(list.length / 2));
const newest = list.slice(Math.ceil(list.length / 2));
setRecentAlbums(recent);
setNewestAlbums(newest);
} catch (e) {
console.error('Failed to load albums (offline-first):', e);
if (mounted) {
setAllAlbums([]);
setRecentAlbums([]);
setNewestAlbums([]);
}
} finally {
setFavoritesLoading(false);
if (mounted) setAlbumsLoading(false);
}
};
load();
return () => { mounted = false; };
}, [offline]);
useEffect(() => {
let mounted = true;
const loadFavoriteAlbums = async () => {
setFavoritesLoading(true);
try {
const starred = await offline.getAlbums(true);
if (mounted) setFavoriteAlbums((starred || []).slice(0, 20));
} catch (error) {
console.error('Failed to load favorite albums (offline-first):', error);
if (mounted) setFavoriteAlbums([]);
} finally {
if (mounted) setFavoritesLoading(false);
}
};
loadFavoriteAlbums();
}, [api, isConnected]);
return () => { mounted = false; };
}, [offline]);
// Handle PWA shortcuts
useEffect(() => {
const action = searchParams.get('action');
if (!action || shortcutProcessed || !api || !isConnected) return;
if (!action || shortcutProcessed) return;
const handleShortcuts = async () => {
try {
@@ -93,12 +116,13 @@ function MusicPageContent() {
// Add remaining albums to queue
for (let i = 1; i < shuffledAlbums.length; i++) {
try {
const albumSongs = await api.getAlbumSongs(shuffledAlbums[i].id);
albumSongs.forEach(song => {
const songs = await offline.getSongs(shuffledAlbums[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
@@ -109,7 +133,7 @@ function MusicPageContent() {
});
});
} catch (error) {
console.error('Failed to load album tracks:', error);
console.error('Failed to load album tracks (offline-first):', error);
}
}
}
@@ -131,12 +155,13 @@ function MusicPageContent() {
// Add remaining albums to queue
for (let i = 1; i < shuffledFavorites.length; i++) {
try {
const albumSongs = await api.getAlbumSongs(shuffledFavorites[i].id);
albumSongs.forEach(song => {
const songs = await offline.getSongs(shuffledFavorites[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
@@ -147,7 +172,7 @@ function MusicPageContent() {
});
});
} catch (error) {
console.error('Failed to load album tracks:', error);
console.error('Failed to load album tracks (offline-first):', error);
}
}
}
@@ -162,7 +187,7 @@ function MusicPageContent() {
// Delay to ensure data is loaded
const timeout = setTimeout(handleShortcuts, 1000);
return () => clearTimeout(timeout);
}, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]);
// Try to get user name from navidrome context, fallback to 'user'
let userName = '';
@@ -175,6 +200,20 @@ 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} />
@@ -197,7 +236,7 @@ function MusicPageContent() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">
@@ -284,7 +323,7 @@ function MusicPageContent() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">