Cache includes albums, artists, songs, and image URLs to improve loading times.
+ {isOfflineSupported && (
+
Offline downloads use Service Workers for true offline audio playback.
+ )}
+ {!isOfflineSupported && (
+
Limited offline support - only metadata cached without Service Worker support.
+ )}
{lastCleared && (
Last cleared: {lastCleared}
)}
diff --git a/app/components/OfflineIndicator.tsx b/app/components/OfflineIndicator.tsx
new file mode 100644
index 0000000..977f3a1
--- /dev/null
+++ b/app/components/OfflineIndicator.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Download, Check, X, Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
+
+interface OfflineIndicatorProps {
+ id: string;
+ type: 'album' | 'song';
+ className?: string;
+ showLabel?: boolean;
+ size?: 'sm' | 'md' | 'lg';
+}
+
+export function OfflineIndicator({
+ id,
+ type,
+ className,
+ showLabel = false,
+ size = 'md'
+}: OfflineIndicatorProps) {
+ const [isOffline, setIsOffline] = useState(false);
+ const [isChecking, setIsChecking] = useState(true);
+ const { checkOfflineStatus, isInitialized } = useOfflineDownloads();
+
+ useEffect(() => {
+ let mounted = true;
+
+ const checkStatus = async () => {
+ if (!isInitialized) return;
+
+ setIsChecking(true);
+ try {
+ const status = await checkOfflineStatus(id, type);
+ if (mounted) {
+ setIsOffline(status);
+ }
+ } catch (error) {
+ console.error('Failed to check offline status:', error);
+ if (mounted) {
+ setIsOffline(false);
+ }
+ } finally {
+ if (mounted) {
+ setIsChecking(false);
+ }
+ }
+ };
+
+ checkStatus();
+
+ return () => {
+ mounted = false;
+ };
+ }, [id, type, isInitialized, checkOfflineStatus]);
+
+ const iconSize = {
+ sm: 'h-3 w-3',
+ md: 'h-4 w-4',
+ lg: 'h-5 w-5'
+ }[size];
+
+ const textSize = {
+ sm: 'text-xs',
+ md: 'text-sm',
+ lg: 'text-base'
+ }[size];
+
+ if (isChecking) {
+ return (
+
+
+ {showLabel && Checking...}
+
+ );
+ }
+
+ if (!isOffline) {
+ return null; // Don't show anything if not downloaded
+ }
+
+ return (
+
+
+ {showLabel && (
+
+ {type === 'album' ? 'Album Downloaded' : 'Downloaded'}
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/app/components/OfflineManagement.tsx b/app/components/OfflineManagement.tsx
new file mode 100644
index 0000000..6105213
--- /dev/null
+++ b/app/components/OfflineManagement.tsx
@@ -0,0 +1,395 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { useToast } from '@/hooks/use-toast';
+import { useOfflineLibrary } from '@/hooks/use-offline-library';
+import {
+ Download,
+ Trash2,
+ RefreshCw,
+ Wifi,
+ WifiOff,
+ Database,
+ Clock,
+ AlertCircle,
+ CheckCircle,
+ Music,
+ User,
+ List,
+ HardDrive
+} from 'lucide-react';
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
+
+function formatDate(date: Date | null): string {
+ if (!date) return 'Never';
+ return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
+}
+
+export function OfflineManagement() {
+ const { toast } = useToast();
+ const [isClearing, setIsClearing] = useState(false);
+
+ const {
+ isInitialized,
+ isOnline,
+ isSyncing,
+ lastSync,
+ stats,
+ syncProgress,
+ syncLibraryFromServer,
+ syncPendingOperations,
+ clearOfflineData,
+ refreshStats
+ } = useOfflineLibrary();
+
+ // Refresh stats periodically
+ useEffect(() => {
+ const interval = setInterval(() => {
+ if (isInitialized && !isSyncing) {
+ refreshStats();
+ }
+ }, 10000); // Every 10 seconds
+
+ return () => clearInterval(interval);
+ }, [isInitialized, isSyncing, refreshStats]);
+
+ const handleFullSync = async () => {
+ try {
+ await syncLibraryFromServer();
+ toast({
+ title: "Sync Complete",
+ description: "Your music library has been synced for offline use.",
+ });
+ } catch (error) {
+ console.error('Full sync failed:', error);
+ toast({
+ title: "Sync Failed",
+ description: "Failed to sync library. Check your connection and try again.",
+ variant: "destructive"
+ });
+ }
+ };
+
+ const handlePendingSync = async () => {
+ try {
+ await syncPendingOperations();
+ toast({
+ title: "Pending Operations Synced",
+ description: "All pending changes have been synced to the server.",
+ });
+ } catch (error) {
+ console.error('Pending sync failed:', error);
+ toast({
+ title: "Sync Failed",
+ description: "Failed to sync pending operations. Will retry automatically when online.",
+ variant: "destructive"
+ });
+ }
+ };
+
+ const handleClearData = async () => {
+ if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) {
+ return;
+ }
+
+ setIsClearing(true);
+ try {
+ await clearOfflineData();
+ toast({
+ title: "Offline Data Cleared",
+ description: "All offline music data has been removed.",
+ });
+ } catch (error) {
+ console.error('Clear data failed:', error);
+ toast({
+ title: "Clear Failed",
+ description: "Failed to clear offline data. Please try again.",
+ variant: "destructive"
+ });
+ } finally {
+ setIsClearing(false);
+ }
+ };
+
+ if (!isInitialized) {
+ return (
+
+
+
+
+ Offline Library
+
+
+ Setting up offline library...
+
+
+
+
+
+
+
Initializing offline storage...
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Connection Status */}
+
+
+
+ {isOnline ? (
+
+ ) : (
+
+ )}
+ Connection Status
+
+
+
+
+
+
+ {isOnline ? "Online" : "Offline"}
+
+
+ {isOnline ? "Connected to Navidrome server" : "Working offline"}
+
+
+
+ {stats.pendingOperations > 0 && (
+
+
+
+ {stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''}
+
+
+ )}
+
+
+
+
+ {/* Sync Status */}
+
+
+
+
+ Library Sync
+
+
+ Keep your offline library up to date
+
+
+
+ {isSyncing && syncProgress && (
+
+
+ {syncProgress.stage}
+ {syncProgress.current}%
+
+
+
+ )}
+
+
+
+
Last Sync
+
+
+ {formatDate(lastSync)}
+
+
+
+
+ {stats.pendingOperations > 0 && isOnline && (
+
+ )}
+
+
+
+
+
+
+
+ {/* Library Statistics */}
+
+
+
+
+ Offline Library Stats
+
+
+ Your offline music collection
+
+
+
+
+
+
+
+
+
+
{stats.albums.toLocaleString()}
+
Albums
+
+
+
+
+
+
+
+
+
{stats.artists.toLocaleString()}
+
Artists
+
+
+
+
+
+
+
+
+
{stats.songs.toLocaleString()}
+
Songs
+
+
+
+
+
+
+
+
+
{stats.playlists.toLocaleString()}
+
Playlists
+
+
+
+
+
+
+
+
+
+ Storage Used
+
+
+ {formatBytes(stats.storageSize)}
+
+
+
+
+
+ {/* Offline Features */}
+
+
+ Offline Features
+
+ What works when you're offline
+
+
+
+
+
+
+
+
Browse & Search
+
+ Browse your synced albums, artists, and search offline
+
+
+
+
+
+
+
+
Favorites & Playlists
+
+ Star songs/albums and create playlists (syncs when online)
+
+
+
+
+
+
+
+
Play Downloaded Music
+
+ Play songs you've downloaded for offline listening
+
+
+
+
+
+
+
+
Auto-Sync
+
+ Changes sync automatically when you reconnect
+
+
+
+
+
+
+
+ {/* Danger Zone */}
+
+
+ Danger Zone
+
+ Permanently delete all offline data
+
+
+
+
+
+
Clear All Offline Data
+
+ This will remove all synced library data and downloaded audio
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/OfflineNavidromeContext.tsx b/app/components/OfflineNavidromeContext.tsx
new file mode 100644
index 0000000..5fc9d54
--- /dev/null
+++ b/app/components/OfflineNavidromeContext.tsx
@@ -0,0 +1,367 @@
+'use client';
+
+import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
+import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
+import { useNavidrome } from '@/app/components/NavidromeContext';
+import { useOfflineLibrary } from '@/hooks/use-offline-library';
+
+interface OfflineNavidromeContextType {
+ // Data (offline-first)
+ albums: Album[];
+ artists: Artist[];
+ playlists: Playlist[];
+
+ // Loading states
+ isLoading: boolean;
+ albumsLoading: boolean;
+ artistsLoading: boolean;
+ playlistsLoading: boolean;
+
+ // Connection state
+ isOnline: boolean;
+ isOfflineReady: boolean;
+
+ // Error states
+ error: string | null;
+
+ // Offline sync status
+ isSyncing: boolean;
+ lastSync: Date | null;
+ pendingOperations: number;
+
+ // Methods (offline-aware)
+ searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
+ getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>;
+ getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>;
+ getPlaylists: () => Promise
;
+ refreshData: () => Promise;
+
+ // Offline-capable operations
+ starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise;
+ unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise;
+ createPlaylist: (name: string, songIds?: string[]) => Promise;
+ scrobble: (songId: string) => Promise;
+
+ // Sync management
+ syncLibrary: () => Promise;
+ syncPendingOperations: () => Promise;
+ clearOfflineData: () => Promise;
+}
+
+const OfflineNavidromeContext = createContext(undefined);
+
+interface OfflineNavidromeProviderProps {
+ children: ReactNode;
+}
+
+export const OfflineNavidromeProvider: React.FC = ({ children }) => {
+ const [albums, setAlbums] = useState([]);
+ const [artists, setArtists] = useState([]);
+ const [playlists, setPlaylists] = useState([]);
+
+ const [albumsLoading, setAlbumsLoading] = useState(false);
+ const [artistsLoading, setArtistsLoading] = useState(false);
+ const [playlistsLoading, setPlaylistsLoading] = useState(false);
+
+ const [error, setError] = useState(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 => {
+ 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 => {
+ 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 (
+
+ {children}
+
+ );
+};
+
+export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
+ const context = useContext(OfflineNavidromeContext);
+ if (context === undefined) {
+ throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
+ }
+ return context;
+};
diff --git a/app/components/OfflineNavidromeProvider.tsx b/app/components/OfflineNavidromeProvider.tsx
new file mode 100644
index 0000000..b1abd49
--- /dev/null
+++ b/app/components/OfflineNavidromeProvider.tsx
@@ -0,0 +1,281 @@
+'use client';
+
+import React, { createContext, useContext, ReactNode } from 'react';
+import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
+import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext';
+import { useOfflineLibrary } from '@/hooks/use-offline-library';
+
+interface OfflineNavidromeContextType {
+ // All the original NavidromeContext methods but with offline-first behavior
+ getAlbums: (starred?: boolean) => Promise;
+ getArtists: (starred?: boolean) => Promise;
+ getSongs: (albumId?: string, artistId?: string) => Promise;
+ getPlaylists: () => Promise;
+
+ // Offline-aware operations
+ starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise;
+ unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise;
+ createPlaylist: (name: string, songIds?: string[]) => Promise;
+ updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise;
+ deletePlaylist: (id: string) => Promise;
+ scrobble: (songId: string) => Promise;
+
+ // Offline state
+ isOfflineMode: boolean;
+ hasPendingOperations: boolean;
+ lastSync: Date | null;
+}
+
+const OfflineNavidromeContext = createContext(undefined);
+
+interface OfflineNavidromeProviderInnerProps {
+ children: ReactNode;
+}
+
+// Inner component that has access to both contexts
+const OfflineNavidromeProviderInner: React.FC = ({ children }) => {
+ const navidromeContext = useNavidrome();
+ const offlineLibrary = useOfflineLibrary();
+
+ // Offline-first data retrieval methods
+ const getAlbums = async (starred?: boolean): Promise => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 (
+
+ {children}
+
+ );
+};
+
+// Main provider component
+export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+// 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;
+};
diff --git a/app/components/OfflineStatusIndicator.tsx b/app/components/OfflineStatusIndicator.tsx
new file mode 100644
index 0000000..f739db8
--- /dev/null
+++ b/app/components/OfflineStatusIndicator.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import React from 'react';
+import { Badge } from '@/components/ui/badge';
+import { useOfflineLibrary } from '@/hooks/use-offline-library';
+import { Wifi, WifiOff, Download, Clock } from 'lucide-react';
+
+export function OfflineStatusIndicator() {
+ const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary();
+
+ if (!isOnline) {
+ return (
+
+
+ Offline Mode
+
+ );
+ }
+
+ if (isSyncing) {
+ return (
+
+
+ Syncing...
+
+ );
+ }
+
+ if (stats.pendingOperations > 0) {
+ return (
+
+
+ {stats.pendingOperations} pending
+
+ );
+ }
+
+ return (
+
+
+ Online
+
+ );
+}
+
+export function OfflineLibraryStats() {
+ const { stats, lastSync } = useOfflineLibrary();
+
+ if (!stats.albums && !stats.songs && !stats.artists) {
+ return null;
+ }
+
+ return (
+
+
+ ๐ {stats.albums} albums โข ๐ต {stats.songs} songs โข ๐ค {stats.artists} artists
+
+ {lastSync && (
+
+ Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()}
+
+ )}
+
+ );
+}
diff --git a/app/components/PostHogProvider.tsx b/app/components/PostHogProvider.tsx
index 2afcd45..4d6705c 100644
--- a/app/components/PostHogProvider.tsx
+++ b/app/components/PostHogProvider.tsx
@@ -11,7 +11,8 @@ function PathnameTracker() {
const searchParams = useSearchParams()
useEffect(() => {
- if (posthogClient) {
+ // Only track if PostHog client is available and properly initialized
+ if (posthogClient && typeof posthogClient.capture === 'function') {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
@@ -31,20 +32,35 @@ function SuspendedPostHogPageView() {
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
- posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
- api_host: "/ingest",
- ui_host: "https://us.posthog.com",
- capture_pageview: 'history_change',
- capture_pageleave: true,
- capture_exceptions: true,
- debug: process.env.NODE_ENV === "development",
- })
+ const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+
+ // Only initialize PostHog if we have a valid key
+ if (posthogKey && posthogKey.trim() !== '') {
+ posthog.init(posthogKey, {
+ api_host: "/ingest",
+ ui_host: "https://us.posthog.com",
+ capture_pageview: 'history_change',
+ capture_pageleave: true,
+ capture_exceptions: true,
+ debug: process.env.NODE_ENV === "development",
+ });
+ } else {
+ console.log('PostHog not initialized - NEXT_PUBLIC_POSTHOG_KEY not provided');
+ }
}, [])
- return (
-
-
- {children}
-
- )
+ // Only provide PostHog context if we have a key
+ const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+
+ if (posthogKey && posthogKey.trim() !== '') {
+ return (
+
+
+ {children}
+
+ );
+ }
+
+ // Return children without PostHog context if no key is provided
+ return <>{children}>;
}
\ No newline at end of file
diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx
index c332370..4ace375 100644
--- a/app/components/RootLayoutClient.tsx
+++ b/app/components/RootLayoutClient.tsx
@@ -2,7 +2,7 @@
import React from "react";
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
-import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
+import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider";
import { PostHogProvider } from "../components/PostHogProvider";
@@ -14,8 +14,20 @@ import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen";
import Image from "next/image";
+// Service Worker registration
+if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js')
+ .then((registration) => {
+ console.log('Service Worker registered successfully:', registration);
+ })
+ .catch((error) => {
+ console.error('Service Worker registration failed:', error);
+ });
+}
+
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
- const { error } = useNavidrome();
+ // For now, since we're switching to offline-first, we'll handle errors differently
+ // The offline provider will handle connectivity issues automatically
const [isClient, setIsClient] = React.useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash
@@ -58,10 +70,9 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
- // Show start screen ONLY if:
- // 1. First-time user (no onboarding completed), OR
- // 2. User has completed onboarding BUT there's an error AND no config exists
- const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig);
+ // Show start screen ONLY if first-time user (no onboarding completed)
+ // In offline-first mode, we don't need to check for errors since the app works offline
+ const shouldShowStartScreen = !hasCompletedOnboarding;
if (shouldShowStartScreen) {
return (
@@ -87,7 +98,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
-
+
@@ -96,7 +107,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
-
+
diff --git a/app/components/UserProfile.tsx b/app/components/UserProfile.tsx
index 8152e5a..31d59c5 100644
--- a/app/components/UserProfile.tsx
+++ b/app/components/UserProfile.tsx
@@ -94,9 +94,9 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
}}
/>
) : (
-
-
-
+
+
+
)}
@@ -106,8 +106,8 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
) : (
@@ -207,3 +207,4 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
);
}
}
+
diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx
index 7673ce2..c03336e 100644
--- a/app/components/album-artwork.tsx
+++ b/app/components/album-artwork.tsx
@@ -24,8 +24,9 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon";
-import { Heart, Music, Disc, Mic, Play } from "lucide-react";
+import { Heart, Music, Disc, Mic, Play, Download } from "lucide-react";
import { Album, Artist, Song } from "@/lib/navidrome";
+import { OfflineIndicator } from "@/app/components/OfflineIndicator";
interface AlbumArtworkProps extends React.HTMLAttributes {
album: Album
@@ -148,6 +149,16 @@ export function AlbumArtwork({
handlePlayAlbum(album)}/>
+
+ {/* Offline indicator in top-right corner */}
+
+
+