diff --git a/.env.local b/.env.local index b060a3b..43b94c4 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=7e6a28e +NEXT_PUBLIC_COMMIT_SHA=1f6ebf1 diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 940ac72..fcc7da4 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -1,90 +1,53 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ 'use client'; -import { useState, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent } from '@/components/ui/tabs'; import { AlbumArtwork } from '@/app/components/album-artwork'; import { ArtistIcon } from '@/app/components/artist-icon'; import { useNavidrome } from '@/app/components/NavidromeContext'; -import { getNavidromeAPI, Album } from '@/lib/navidrome'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; -import { Shuffle } from 'lucide-react'; +import { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading'; +import { + Shuffle, + ArrowDown, + RefreshCcw, + Loader2 +} from 'lucide-react'; import Loading from '@/app/components/loading'; +import { useInView } from 'react-intersection-observer'; export default function BrowsePage() { const { artists, isLoading: contextLoading } = useNavidrome(); const { shuffleAllAlbums } = useAudioPlayer(); - const [albums, setAlbums] = useState([]); - const [currentPage, setCurrentPage] = useState(0); - const [isLoadingAlbums, setIsLoadingAlbums] = useState(false); - const [hasMoreAlbums, setHasMoreAlbums] = useState(true); - const albumsPerPage = 84; - - const api = getNavidromeAPI(); - const loadAlbums = async (page: number, append: boolean = false) => { - if (!api) { - console.error('Navidrome API not available'); - return; - } - - try { - setIsLoadingAlbums(true); - const offset = page * albumsPerPage; - - // Use alphabeticalByName to get all albums in alphabetical order - const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset); - - if (append) { - setAlbums(prev => [...prev, ...newAlbums]); - } else { - setAlbums(newAlbums); - } - - // If we got fewer albums than requested, we've reached the end - setHasMoreAlbums(newAlbums.length === albumsPerPage); - } catch (error) { - console.error('Failed to load albums:', error); - } finally { - setIsLoadingAlbums(false); - } - }; - + + // Use our progressive loading hook + const { + albums, + isLoading, + hasMore, + loadMoreAlbums, + refreshAlbums + } = useProgressiveAlbumLoading('alphabeticalByName'); + + // Infinite scroll with intersection observer + const { ref, inView } = useInView({ + threshold: 0.1, + triggerOnce: false + }); + + // Load more albums when the load more sentinel comes into view useEffect(() => { - loadAlbums(0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Infinite scroll handler - useEffect(() => { - const handleScroll = (e: Event) => { - const target = e.target as HTMLElement; - if (!target || isLoadingAlbums || !hasMoreAlbums) return; - - const { scrollTop, scrollHeight, clientHeight } = target; - const threshold = 200; // Load more when 200px from bottom - - if (scrollHeight - scrollTop - clientHeight < threshold) { - loadMore(); - } - }; - - const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]'); - if (scrollArea) { - scrollArea.addEventListener('scroll', handleScroll); - return () => scrollArea.removeEventListener('scroll', handleScroll); + if (inView && hasMore && !isLoading) { + loadMoreAlbums(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoadingAlbums, hasMoreAlbums, currentPage]); - - const loadMore = () => { - if (isLoadingAlbums || !hasMoreAlbums) return; - const nextPage = currentPage + 1; - setCurrentPage(nextPage); - loadAlbums(nextPage, true); - }; + }, [inView, hasMore, isLoading, loadMoreAlbums]); + + // Pull-to-refresh simulation + const handleRefresh = useCallback(() => { + refreshAlbums(); + }, [refreshAlbums]); if (contextLoading) { return ; @@ -137,6 +100,10 @@ export default function BrowsePage() { Browse the full collection of albums ({albums.length} loaded).

+
@@ -154,24 +121,47 @@ export default function BrowsePage() { /> ))}
- {hasMoreAlbums && ( -
+ {/* Load more sentinel */} + {hasMore && ( +
)} - {!hasMoreAlbums && albums.length > 0 && ( + + {!hasMore && albums.length > 0 && (

All albums loaded ({albums.length} total)

)} + + {albums.length === 0 && !isLoading && ( +
+

No albums found

+

+ Try refreshing or check your connection +

+ +
+ )}
diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index 9df8385..87bd456 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -228,6 +228,40 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c if (currentTrack) { setPlayedTracks((prev) => [...prev, currentTrack]); + + // Record the play for listening streak + // This will store timestamp with the track play + try { + const today = new Date().toISOString().split('T')[0]; + const streakData = localStorage.getItem('navidrome-streak-data'); + + if (streakData) { + const parsedData = JSON.parse(streakData); + const todayData = parsedData[today] || { + date: today, + tracks: 0, + uniqueArtists: [], + uniqueAlbums: [], + totalListeningTime: 0 + }; + + // Update today's listening data + todayData.tracks += 1; + if (!todayData.uniqueArtists.includes(currentTrack.artistId)) { + todayData.uniqueArtists.push(currentTrack.artistId); + } + if (!todayData.uniqueAlbums.includes(currentTrack.albumId)) { + todayData.uniqueAlbums.push(currentTrack.albumId); + } + todayData.totalListeningTime += currentTrack.duration; + + // Save updated data + parsedData[today] = todayData; + localStorage.setItem('navidrome-streak-data', JSON.stringify(parsedData)); + } + } catch (error) { + console.error('Failed to update listening streak data:', error); + } } // Set autoPlay flag on the track diff --git a/app/components/CompactListeningStreak.tsx b/app/components/CompactListeningStreak.tsx new file mode 100644 index 0000000..633b76c --- /dev/null +++ b/app/components/CompactListeningStreak.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useListeningStreak } from '@/hooks/use-listening-streak'; +import { Card, CardContent } from '@/components/ui/card'; +import { Flame } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { AnimatePresence, motion } from 'framer-motion'; + +export default function CompactListeningStreak() { + const { stats, hasListenedToday, getStreakEmoji } = useListeningStreak(); + const [animate, setAnimate] = useState(false); + + // Trigger animation when streak increases + useEffect(() => { + if (stats.currentStreak > 0) { + setAnimate(true); + const timer = setTimeout(() => setAnimate(false), 1000); + return () => clearTimeout(timer); + } + }, [stats.currentStreak]); + + const hasCompletedToday = hasListenedToday(); + const streakEmoji = getStreakEmoji(); + + // Only show if the streak is 3 days or more + if (stats.currentStreak < 3) { + return null; + } + + return ( + + +
+
+ + + + + {stats.currentStreak} + + + day streak + + {streakEmoji && ( + + {streakEmoji} + + )} + + +
+
+ {hasCompletedToday ? "Today's goal complete!" : "Keep listening!"} +
+
+
+
+ ); +} diff --git a/app/components/EnhancedOfflineManager.tsx b/app/components/EnhancedOfflineManager.tsx new file mode 100644 index 0000000..0c930b7 --- /dev/null +++ b/app/components/EnhancedOfflineManager.tsx @@ -0,0 +1,627 @@ +'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 ( + +
+
+ {album.name} +
+
+

{album.name}

+

{album.artist}

+
+ {album.songCount} songs • {estimatedSize} + +
+
+
+ + {isDownloading && downloadProgress !== undefined && ( + + )} +
+ ); +} + +// 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 ( + +
+
+
+ +
+
+
+

{playlist.name}

+

by {playlist.owner}

+
+ {playlist.songCount} songs • {estimatedSize} + +
+
+
+ + {isDownloading && downloadProgress !== undefined && ( + + )} +
+ ); +} + +export default function EnhancedOfflineManager() { + const { toast } = useToast(); + const [activeTab, setActiveTab] = useState('overview'); + const [albums, setAlbums] = useState([]); + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState({ + albums: false, + playlists: false + }); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedAlbums, setSelectedAlbums] = useState>(new Set()); + const [selectedPlaylists, setSelectedPlaylists] = useState>(new Set()); + const [downloadingItems, setDownloadingItems] = useState>(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(); + 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 ( + + + Overview + Albums + Playlists + + + + + + + + + + + + Select Albums + + + Choose albums to make available offline + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ +
+ + {filtersVisible && ( +
+
+
Sort By
+
+ + + +
+
+
+ )} + +
+
+ {selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected +
+ +
+ + + {loading.albums ? ( + // Loading skeletons + Array.from({ length: 5 }).map((_, i) => ( + +
+ +
+ + + +
+
+
+ )) + ) : filteredAlbums.length > 0 ? ( + filteredAlbums.map(album => ( + toggleAlbumSelection(album.id)} + isDownloading={downloadingItems.has(album.id)} + downloadProgress={downloadingItems.get(album.id)} + estimatedSize={estimateSize(album.songCount)} + /> + )) + ) : ( +
+ +

+ {searchQuery ? 'No albums found matching your search' : 'No albums available'} +

+
+ )} +
+
+ + + +
+
+ + + + + + + Select Playlists + + + Choose playlists to make available offline + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ +
+ + {filtersVisible && ( +
+
+
Sort By
+
+ + +
+
+
+ )} + +
+
+ {selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected +
+ +
+ + + {loading.playlists ? ( + // Loading skeletons + Array.from({ length: 5 }).map((_, i) => ( + +
+ +
+ + + +
+
+
+ )) + ) : filteredPlaylists.length > 0 ? ( + filteredPlaylists.map(playlist => ( + togglePlaylistSelection(playlist.id)} + isDownloading={downloadingItems.has(playlist.id)} + downloadProgress={downloadingItems.get(playlist.id)} + estimatedSize={estimateSize(playlist.songCount)} + /> + )) + ) : ( +
+ +

+ {searchQuery ? 'No playlists found matching your search' : 'No playlists available'} +

+
+ )} +
+
+ + + +
+
+
+ ); +} diff --git a/app/components/ListeningStreakCard.tsx b/app/components/ListeningStreakCard.tsx new file mode 100644 index 0000000..de658a0 --- /dev/null +++ b/app/components/ListeningStreakCard.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useListeningStreak } from '@/hooks/use-listening-streak'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Flame, Calendar, Clock, Music, Disc, User2 } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +export default function ListeningStreakCard() { + const { stats, hasListenedToday, getStreakEmoji, getTodaySummary, streakThresholds } = useListeningStreak(); + const [animate, setAnimate] = useState(false); + + // Trigger animation when streak increases + useEffect(() => { + if (stats.currentStreak > 0) { + setAnimate(true); + const timer = setTimeout(() => setAnimate(false), 1000); + return () => clearTimeout(timer); + } + }, [stats.currentStreak]); + + const todaySummary = getTodaySummary(); + const hasCompletedToday = hasListenedToday(); + + // Calculate progress towards today's goal + const trackProgress = Math.min(100, (todaySummary.tracks / streakThresholds.tracks) * 100); + const timeInMinutes = parseInt(todaySummary.time.replace('m', ''), 10) || 0; + const timeThresholdMinutes = Math.floor(streakThresholds.time / 60); + const timeProgress = Math.min(100, (timeInMinutes / timeThresholdMinutes) * 100); + + // Overall progress (highest of the two metrics) + const overallProgress = Math.max(trackProgress, timeProgress); + + return ( + + + +
+ + Listening Streak +
+
+ + + {stats.totalDaysListened} days + +
+
+
+ +
+ + +
+ {stats.currentStreak} +
+
+ day{stats.currentStreak !== 1 ? 's' : ''} streak +
+ {getStreakEmoji() && ( + + {getStreakEmoji()} + + )} +
+
+ +
+
+ Today's Progress + + {hasCompletedToday ? "Complete!" : "In progress..."} + +
+ div]:bg-green-500" : "" + )} + /> +
+ +
+
+
+ + Tracks +
+ {todaySummary.tracks} + + Goal: {streakThresholds.tracks} + +
+
+
+ + Time +
+ {todaySummary.time} + + Goal: {timeThresholdMinutes}m + +
+
+ +
+
+
+ + Artists +
+ {todaySummary.artists} +
+
+
+ + Albums +
+ {todaySummary.albums} +
+
+ +
+ {hasCompletedToday ? ( + You've met your daily listening goal! 🎵 + ) : ( + Listen to {streakThresholds.tracks} tracks or {timeThresholdMinutes} minutes to continue your streak! + )} +
+
+
+
+ ); +} diff --git a/app/history/page.tsx b/app/history/page.tsx index 71ec7c7..14f2976 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -10,6 +10,7 @@ import { Tabs, TabsContent } from '@/components/ui/tabs'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { getNavidromeAPI } from '@/lib/navidrome'; import { Play, Plus, User, Disc, History, Trash2 } from 'lucide-react'; +import ListeningStreakCard from '@/app/components/ListeningStreakCard'; import { AlertDialog, AlertDialogAction, @@ -78,6 +79,10 @@ export default function HistoryPage() { return (
+
+ +
+
diff --git a/app/page.tsx b/app/page.tsx index 1d11e3e..68935eb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -15,6 +15,7 @@ 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'; @@ -218,6 +219,11 @@ function MusicPageContent() {
+ + {/* Listening Streak Section - Only shown when 3+ days streak */} +
+ +
<> diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 05b8908..f485ed4 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -15,7 +15,7 @@ import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-sh import { SidebarCustomization } from '@/app/components/SidebarCustomization'; import { SettingsManagement } from '@/app/components/SettingsManagement'; import { CacheManagement } from '@/app/components/CacheManagement'; -import { OfflineManagement } from '@/app/components/OfflineManagement'; +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'; @@ -786,7 +786,7 @@ const SettingsPage = () => { {/* Offline Library Management */}
- +
{/* Auto-Tagging Settings */} diff --git a/components/ui/infinite-scroll.tsx b/components/ui/infinite-scroll.tsx new file mode 100644 index 0000000..780dd32 --- /dev/null +++ b/components/ui/infinite-scroll.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface InfiniteScrollProps { + onLoadMore: () => void; + hasMore: boolean; + isLoading: boolean; + loadingText?: string; + endMessage?: string; + className?: string; +} + +export function InfiniteScroll({ + onLoadMore, + hasMore, + isLoading, + loadingText = 'Loading more items...', + endMessage = 'No more items to load', + className +}: InfiniteScrollProps) { + const { ref, inView } = useInView({ + threshold: 0, + rootMargin: '100px 0px', + }); + + useEffect(() => { + if (inView && hasMore && !isLoading) { + onLoadMore(); + } + }, [inView, hasMore, isLoading, onLoadMore]); + + return ( +
+ {isLoading && ( +
+ +

{loadingText}

+
+ )} + + {!hasMore && !isLoading && ( +

{endMessage}

+ )} +
+ ); +} diff --git a/hooks/use-listening-streak.ts b/hooks/use-listening-streak.ts new file mode 100644 index 0000000..f0c00b6 --- /dev/null +++ b/hooks/use-listening-streak.ts @@ -0,0 +1,287 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; +import { Track } from '@/app/components/AudioPlayerContext'; + +// Interface for a single day's listening data +export interface DayStreakData { + date: string; // ISO string of the date + tracks: number; // Number of tracks played that day + uniqueArtists: Set; // Unique artists listened to + uniqueAlbums: Set; // Unique albums listened to + totalListeningTime: number; // Total seconds listened +} + +// Interface for streak statistics +export interface StreakStats { + currentStreak: number; // Current consecutive days streak + longestStreak: number; // Longest streak ever achieved + totalDaysListened: number; // Total days with listening activity + lastListenedDate: string | null; // Last date with listening activity +} + +const STREAK_THRESHOLD_TRACKS = 3; // Minimum tracks to count as an active day +const STREAK_THRESHOLD_TIME = 5 * 60; // 5 minutes minimum listening time + +export function useListeningStreak() { + const [streakData, setStreakData] = useState>(new Map()); + const [stats, setStats] = useState({ + currentStreak: 0, + longestStreak: 0, + totalDaysListened: 0, + lastListenedDate: null, + }); + const { playedTracks, currentTrack } = useAudioPlayer(); + + // Initialize streak data from localStorage + useEffect(() => { + // Check if we're in the browser environment + if (typeof window === 'undefined') return; + + try { + const savedStreakData = localStorage.getItem('navidrome-streak-data'); + const savedStats = localStorage.getItem('navidrome-streak-stats'); + + if (savedStreakData) { + // Convert the plain object back to a Map + const parsedData = JSON.parse(savedStreakData); + const dataMap = new Map(); + + // Reconstruct the Map and Sets + Object.entries(parsedData).forEach(([key, value]: [string, any]) => { + dataMap.set(key, { + ...value, + uniqueArtists: new Set(value.uniqueArtists), + uniqueAlbums: new Set(value.uniqueAlbums) + }); + }); + + setStreakData(dataMap); + } + + if (savedStats) { + setStats(JSON.parse(savedStats)); + } + + // Check if we need to update the streak based on the current date + updateStreakStatus(); + + } catch (error) { + console.error('Failed to load streak data:', error); + } + }, []); + + // Save streak data to localStorage whenever it changes + useEffect(() => { + if (typeof window === 'undefined' || streakData.size === 0) return; + + try { + // Convert Map to a plain object for serialization + const dataObject: Record = {}; + + streakData.forEach((value, key) => { + dataObject[key] = { + ...value, + uniqueArtists: Array.from(value.uniqueArtists), + uniqueAlbums: Array.from(value.uniqueAlbums) + }; + }); + + localStorage.setItem('navidrome-streak-data', JSON.stringify(dataObject)); + localStorage.setItem('navidrome-streak-stats', JSON.stringify(stats)); + + } catch (error) { + console.error('Failed to save streak data:', error); + } + }, [streakData, stats]); + + // Process playedTracks to update the streak + useEffect(() => { + if (playedTracks.length === 0) return; + + // Get today's date in YYYY-MM-DD format + const today = new Date().toISOString().split('T')[0]; + + // Update streak data for today + setStreakData(prev => { + const updated = new Map(prev); + + const todayData = updated.get(today) || { + date: today, + tracks: 0, + uniqueArtists: new Set(), + uniqueAlbums: new Set(), + totalListeningTime: 0 + }; + + // Update today's data based on played tracks + // For simplicity, we'll assume one track added = one complete listen + const lastTrack = playedTracks[playedTracks.length - 1]; + + todayData.tracks += 1; + todayData.uniqueArtists.add(lastTrack.artistId); + todayData.uniqueAlbums.add(lastTrack.albumId); + todayData.totalListeningTime += lastTrack.duration; + + updated.set(today, todayData); + return updated; + }); + + // Update streak statistics + updateStreakStatus(); + }, [playedTracks.length]); + + // Function to update streak status based on current data + const updateStreakStatus = useCallback(() => { + if (streakData.size === 0) return; + + const today = new Date().toISOString().split('T')[0]; + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + + // Sort dates in descending order (newest first) + const dates = Array.from(streakData.keys()).sort((a, b) => + new Date(b).getTime() - new Date(a).getTime() + ); + + // Check which days count as active based on threshold + const activeDays = dates.filter(date => { + const dayData = streakData.get(date); + if (!dayData) return false; + + return dayData.tracks >= STREAK_THRESHOLD_TRACKS || + dayData.totalListeningTime >= STREAK_THRESHOLD_TIME; + }); + + // Calculate current streak + let currentStreak = 0; + let checkDate = new Date(today); + + // Keep checking consecutive days backward until streak breaks + while (true) { + const dateString = checkDate.toISOString().split('T')[0]; + if (activeDays.includes(dateString)) { + currentStreak++; + checkDate.setDate(checkDate.getDate() - 1); // Go back one day + } else { + break; // Streak broken + } + } + + // Get total active days + const totalDaysListened = activeDays.length; + + // Get longest streak (requires analyzing all streaks) + let longestStreak = currentStreak; + let tempStreak = 0; + + // Sort dates in ascending order for streak calculation + const ascDates = [...activeDays].sort(); + + for (let i = 0; i < ascDates.length; i++) { + const currentDate = new Date(ascDates[i]); + + if (i > 0) { + const prevDate = new Date(ascDates[i-1]); + prevDate.setDate(prevDate.getDate() + 1); + + // If dates are consecutive + if (currentDate.getTime() === prevDate.getTime()) { + tempStreak++; + } else { + // Streak broken + tempStreak = 1; + } + } else { + tempStreak = 1; // First active day + } + + longestStreak = Math.max(longestStreak, tempStreak); + } + + // Get last listened date + const lastListenedDate = activeDays.length > 0 ? activeDays[0] : null; + + // Update stats + setStats({ + currentStreak, + longestStreak, + totalDaysListened, + lastListenedDate + }); + }, [streakData]); + + // Check if user has listened today + const hasListenedToday = useCallback(() => { + const today = new Date().toISOString().split('T')[0]; + const todayData = streakData.get(today); + + return todayData && ( + todayData.tracks >= STREAK_THRESHOLD_TRACKS || + todayData.totalListeningTime >= STREAK_THRESHOLD_TIME + ); + }, [streakData]); + + // Get streak emoji representation + const getStreakEmoji = useCallback(() => { + if (stats.currentStreak <= 0) return ''; + + if (stats.currentStreak >= 30) return '🔥🔥🔥'; // 30+ days + if (stats.currentStreak >= 14) return '🔥🔥'; // 14+ days + if (stats.currentStreak >= 7) return '🔥'; // 7+ days + if (stats.currentStreak >= 3) return '✨'; // 3+ days + return '📅'; // 1-2 days + }, [stats.currentStreak]); + + // Get today's listening summary + const getTodaySummary = useCallback(() => { + const today = new Date().toISOString().split('T')[0]; + const todayData = streakData.get(today); + + if (!todayData) { + return { + tracks: 0, + artists: 0, + albums: 0, + time: '0m' + }; + } + + // Format time nicely + const minutes = Math.floor(todayData.totalListeningTime / 60); + const timeDisplay = minutes === 1 ? '1m' : `${minutes}m`; + + return { + tracks: todayData.tracks, + artists: todayData.uniqueArtists.size, + albums: todayData.uniqueAlbums.size, + time: timeDisplay + }; + }, [streakData]); + + // Reset streak data (for testing) + const resetStreakData = useCallback(() => { + setStreakData(new Map()); + setStats({ + currentStreak: 0, + longestStreak: 0, + totalDaysListened: 0, + lastListenedDate: null, + }); + + localStorage.removeItem('navidrome-streak-data'); + localStorage.removeItem('navidrome-streak-stats'); + }, []); + + return { + stats, + hasListenedToday, + getStreakEmoji, + getTodaySummary, + resetStreakData, + streakThresholds: { + tracks: STREAK_THRESHOLD_TRACKS, + time: STREAK_THRESHOLD_TIME + } + }; +} diff --git a/hooks/use-progressive-album-loading.ts b/hooks/use-progressive-album-loading.ts new file mode 100644 index 0000000..ecde93a --- /dev/null +++ b/hooks/use-progressive-album-loading.ts @@ -0,0 +1,245 @@ +'use client'; + +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 +const SCROLL_THRESHOLD = 200; // Pixels from bottom before loading more + +export type AlbumSortOption = 'alphabeticalByName' | 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByArtist' | 'starred' | 'highest'; + +export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabeticalByName') { + const [albums, setAlbums] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [currentOffset, setCurrentOffset] = useState(0); + const { api } = useNavidrome(); + const offlineApi = useOfflineNavidrome(); + const offlineLibrary = useOfflineLibrary(); + const [error, setError] = useState(null); + + // Load initial batch + useEffect(() => { + loadInitialBatch(); + }, [sortBy]); + + // Cleanup when sort changes + useEffect(() => { + return () => { + setAlbums([]); + setCurrentOffset(0); + setHasMore(true); + }; + }, [sortBy]); + + // We'll define the scroll listener after defining loadMoreAlbums + + // Load initial batch of albums + const loadInitialBatch = useCallback(async () => { + if (!api && !offlineLibrary.isInitialized) 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(); + 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); + } + } catch (err) { + console.error('Failed to load initial albums batch:', err); + setError(err instanceof Error ? err.message : 'Unknown error loading albums'); + setAlbums([]); + setHasMore(false); + } finally { + setIsLoading(false); + } + }, [api, offlineApi, offlineLibrary, sortBy]); + + // Load more albums when scrolling + const loadMoreAlbums = useCallback(async () => { + if (isLoading || !hasMore) 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(); + 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); + } + } catch (err) { + console.error('Failed to load more albums:', err); + setError(err instanceof Error ? err.message : 'Unknown error loading more albums'); + setHasMore(false); + } finally { + setIsLoading(false); + } + }, [api, offlineApi, offlineLibrary, albums, currentOffset, isLoading, hasMore, sortBy]); + + // Manual refresh (useful for pull-to-refresh functionality) + const refreshAlbums = useCallback(() => { + setAlbums([]); + setCurrentOffset(0); + setHasMore(true); + loadInitialBatch(); + }, [loadInitialBatch]); + + // Setup scroll listener after function declarations + useEffect(() => { + const handleScroll = () => { + // Don't trigger if already loading + if (isLoading || !hasMore) return; + + // Check if we're near the bottom + const scrollHeight = document.documentElement.scrollHeight; + const currentScroll = window.innerHeight + document.documentElement.scrollTop; + + if (scrollHeight - currentScroll <= SCROLL_THRESHOLD) { + loadMoreAlbums(); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [isLoading, hasMore, currentOffset, loadMoreAlbums]); + + return { + albums, + isLoading, + hasMore, + loadMoreAlbums, + refreshAlbums, + error, + resetAndLoad: refreshAlbums // Alias for consistency + }; +} diff --git a/package.json b/package.json index 0e1c579..355391c 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-dom": "19.1.0", "react-hook-form": "^7.60.0", "react-icons": "^5.3.0", + "react-intersection-observer": "^9.16.0", "react-resizable-panels": "^3.0.3", "recharts": "^3.0.2", "sonner": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1d0c5..67cde30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: react-icons: specifier: ^5.3.0 version: 5.4.0(react@19.1.0) + react-intersection-observer: + specifier: ^9.16.0 + version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-resizable-panels: specifier: ^3.0.3 version: 3.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2953,6 +2956,15 @@ packages: peerDependencies: react: '*' + react-intersection-observer@9.16.0: + resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6156,6 +6168,12 @@ snapshots: dependencies: react: 19.1.0 + react-intersection-observer@9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/public/background-sync.js b/public/background-sync.js new file mode 100644 index 0000000..8531d4a --- /dev/null +++ b/public/background-sync.js @@ -0,0 +1,538 @@ +// Background sync service worker for StillNavidrome +// This enhances the main service worker to support automatic background sync + +// Cache version identifier - update when cache structure changes +const BACKGROUND_SYNC_CACHE = 'stillnavidrome-background-sync-v1'; + +// Interval for background sync (in minutes) +const SYNC_INTERVAL_MINUTES = 60; + +// List of APIs to keep fresh in cache +const BACKGROUND_SYNC_APIS = [ + '/api/getAlbums', + '/api/getArtists', + '/api/getPlaylists', + '/rest/getStarred', + '/rest/getPlayQueue', + '/rest/getRecentlyPlayed' +]; + +// Listen for the install event +self.addEventListener('install', (event) => { + console.log('[Background Sync] Service worker installing...'); + event.waitUntil( + // Create cache for background sync + caches.open(BACKGROUND_SYNC_CACHE).then(cache => { + console.log('[Background Sync] Cache opened'); + // Initial cache population would happen in the activate event + // to avoid any conflicts with the main service worker + }) + ); +}); + +// Listen for the activate event +self.addEventListener('activate', (event) => { + console.log('[Background Sync] Service worker activating...'); + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + // Delete any old caches that don't match our current version + if (cacheName.startsWith('stillnavidrome-background-sync-') && cacheName !== BACKGROUND_SYNC_CACHE) { + console.log('[Background Sync] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + ); + + // Start background sync scheduler + initBackgroundSync(); +}); + +// Initialize background sync scheduler +function initBackgroundSync() { + console.log('[Background Sync] Initializing background sync scheduler'); + + // Set up periodic sync if available (modern browsers) + if ('periodicSync' in self.registration) { + self.registration.periodicSync.register('background-library-sync', { + minInterval: SYNC_INTERVAL_MINUTES * 60 * 1000 // Convert to milliseconds + }).then(() => { + console.log('[Background Sync] Registered periodic sync'); + }).catch(error => { + console.error('[Background Sync] Failed to register periodic sync:', error); + // Fall back to manual interval as backup + setupManualSyncInterval(); + }); + } else { + // Fall back to manual interval for browsers without periodicSync + console.log('[Background Sync] PeriodicSync not available, using manual interval'); + setupManualSyncInterval(); + } +} + +// Set up manual sync interval as fallback +function setupManualSyncInterval() { + // Use service worker's setInterval (be careful with this in production) + setInterval(() => { + if (navigator.onLine) { + console.log('[Background Sync] Running manual background sync'); + performBackgroundSync(); + } + }, SYNC_INTERVAL_MINUTES * 60 * 1000); // Convert to milliseconds +} + +// Listen for periodic sync events +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'background-library-sync') { + console.log('[Background Sync] Periodic sync event triggered'); + event.waitUntil(performBackgroundSync()); + } +}); + +// Listen for message events (for manual sync triggers) +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'TRIGGER_BACKGROUND_SYNC') { + console.log('[Background Sync] Manual sync triggered from client'); + event.waitUntil(performBackgroundSync().then(() => { + // Notify the client that sync is complete + if (event.ports && event.ports[0]) { + event.ports[0].postMessage({ type: 'BACKGROUND_SYNC_COMPLETE' }); + } + })); + } +}); + +// Perform the actual background sync +async function performBackgroundSync() { + console.log('[Background Sync] Starting background sync'); + + // Check if we're online before attempting sync + if (!navigator.onLine) { + console.log('[Background Sync] Device is offline, skipping sync'); + return; + } + + try { + // Get server config from IndexedDB + const config = await getNavidromeConfig(); + + if (!config || !config.serverUrl) { + console.log('[Background Sync] No server configuration found, skipping sync'); + return; + } + + // Get authentication token + const authToken = await getAuthToken(config); + + if (!authToken) { + console.log('[Background Sync] Failed to get auth token, skipping sync'); + return; + } + + // Perform API requests to refresh cache + const apiResponses = await Promise.all(BACKGROUND_SYNC_APIS.map(apiPath => { + return refreshApiCache(config.serverUrl, apiPath, authToken); + })); + + // Process recently played data to update listening streak + await updateListeningStreakData(apiResponses); + + // Update last sync timestamp + await updateLastSyncTimestamp(); + + // Notify clients about successful sync + const clients = await self.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ + type: 'BACKGROUND_SYNC_COMPLETE', + timestamp: Date.now() + }); + }); + + console.log('[Background Sync] Background sync completed successfully'); + } catch (error) { + console.error('[Background Sync] Error during background sync:', error); + } +} + +// Get Navidrome config from IndexedDB +async function getNavidromeConfig() { + return new Promise((resolve) => { + // Try to get from localStorage first (simplest approach) + if (typeof self.localStorage !== 'undefined') { + try { + const configJson = self.localStorage.getItem('navidrome-config'); + if (configJson) { + resolve(JSON.parse(configJson)); + return; + } + } catch (e) { + console.error('[Background Sync] Error reading from localStorage:', e); + } + } + + // Fallback to IndexedDB + const request = indexedDB.open('stillnavidrome-offline', 1); + + request.onerror = () => { + console.error('[Background Sync] Failed to open IndexedDB'); + resolve(null); + }; + + request.onsuccess = (event) => { + const db = event.target.result; + const transaction = db.transaction(['metadata'], 'readonly'); + const store = transaction.objectStore('metadata'); + const getRequest = store.get('navidrome-config'); + + getRequest.onsuccess = () => { + resolve(getRequest.result ? getRequest.result.value : null); + }; + + getRequest.onerror = () => { + console.error('[Background Sync] Error getting config from IndexedDB'); + resolve(null); + }; + }; + + request.onupgradeneeded = () => { + // This shouldn't happen here - the DB should already be set up + console.error('[Background Sync] IndexedDB needs upgrade, skipping config retrieval'); + resolve(null); + }; + }); +} + +// Get authentication token for API requests +async function getAuthToken(config) { + try { + const response = await fetch(`${config.serverUrl}/rest/ping`, { + method: 'GET', + headers: { + 'Authorization': 'Basic ' + btoa(`${config.username}:${config.password}`) + } + }); + + if (!response.ok) { + throw new Error(`Auth failed with status: ${response.status}`); + } + + // Extract token from response + const data = await response.json(); + return data.token || null; + } catch (error) { + console.error('[Background Sync] Authentication error:', error); + return null; + } +} + +// Refresh specific API cache +async function refreshApiCache(serverUrl, apiPath, authToken) { + try { + // Construct API URL + const apiUrl = `${serverUrl}${apiPath}`; + + // Make the request with authentication + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + + if (!response.ok) { + throw new Error(`API request failed with status: ${response.status}`); + } + + // Clone the response to store in cache + const responseToCache = response.clone(); + + // Open the cache and store the response + const cache = await caches.open(BACKGROUND_SYNC_CACHE); + await cache.put(apiUrl, responseToCache); + + console.log(`[Background Sync] Successfully updated cache for: ${apiPath}`); + return response.json(); // Return parsed data for potential use + } catch (error) { + console.error(`[Background Sync] Failed to refresh cache for ${apiPath}:`, error); + throw error; + } +} + +// Process recently played data to update listening streak +async function updateListeningStreakData(apiResponses) { + try { + // Find the recently played response + const recentlyPlayedResponse = apiResponses.find(response => + response && response.data && Array.isArray(response.data.song) + ); + + if (!recentlyPlayedResponse) { + console.log('[Background Sync] No recently played data found'); + return; + } + + const recentlyPlayed = recentlyPlayedResponse.data.song; + if (!recentlyPlayed || recentlyPlayed.length === 0) { + return; + } + + // Get existing streak data + let streakData; + try { + const streakDataRaw = localStorage.getItem('navidrome-streak-data'); + const streakStats = localStorage.getItem('navidrome-streak-stats'); + + if (streakDataRaw && streakStats) { + const dataMap = new Map(); + const parsedData = JSON.parse(streakDataRaw); + + // Reconstruct the streak data + Object.entries(parsedData).forEach(([key, value]) => { + dataMap.set(key, { + ...value, + uniqueArtists: new Set(value.uniqueArtists), + uniqueAlbums: new Set(value.uniqueAlbums) + }); + }); + + streakData = { + data: dataMap, + stats: JSON.parse(streakStats) + }; + } + } catch (e) { + console.error('[Background Sync] Failed to parse existing streak data:', e); + return; + } + + if (!streakData) { + console.log('[Background Sync] No existing streak data found'); + return; + } + + // Process recently played tracks + let updated = false; + recentlyPlayed.forEach(track => { + if (!track.played) return; + + // Parse play date (format: 2023-10-15T14:32:45Z) + const playDate = new Date(track.played); + const dateKey = playDate.toISOString().split('T')[0]; // YYYY-MM-DD + + // If we already have data for this date, update it + let dayData = streakData.data.get(dateKey); + if (!dayData) { + // Create new day data + dayData = { + date: dateKey, + tracks: 0, + uniqueArtists: new Set(), + uniqueAlbums: new Set(), + totalListeningTime: 0 + }; + } + + // Update day data with this track + dayData.tracks += 1; + dayData.uniqueArtists.add(track.artistId); + dayData.uniqueAlbums.add(track.albumId); + dayData.totalListeningTime += track.duration || 0; + + // Update the map + streakData.data.set(dateKey, dayData); + updated = true; + }); + + // If we updated streak data, save it back + if (updated) { + // Convert Map to a plain object for serialization + const dataObject = {}; + + streakData.data.forEach((value, key) => { + dataObject[key] = { + ...value, + uniqueArtists: Array.from(value.uniqueArtists), + uniqueAlbums: Array.from(value.uniqueAlbums) + }; + }); + + // Update stats based on new data + const updatedStats = calculateStreakStats(streakData.data); + + // Save back to localStorage + localStorage.setItem('navidrome-streak-data', JSON.stringify(dataObject)); + localStorage.setItem('navidrome-streak-stats', JSON.stringify(updatedStats)); + + console.log('[Background Sync] Updated listening streak data'); + } + } catch (error) { + console.error('[Background Sync] Failed to update listening streak data:', error); + } +} + +// Calculate streak statistics based on data +function calculateStreakStats(streakData) { + const STREAK_THRESHOLD_TRACKS = 3; + const STREAK_THRESHOLD_TIME = 5 * 60; // 5 minutes + + // Get active days (that meet threshold) + const activeDays = []; + streakData.forEach((dayData, dateKey) => { + if (dayData.tracks >= STREAK_THRESHOLD_TRACKS || + dayData.totalListeningTime >= STREAK_THRESHOLD_TIME) { + activeDays.push(dateKey); + } + }); + + // Sort dates newest first + activeDays.sort((a, b) => new Date(b).getTime() - new Date(a).getTime()); + + // Calculate current streak + let currentStreak = 0; + let checkDate = new Date(); + + // Keep checking consecutive days backward until streak breaks + while (true) { + const dateString = checkDate.toISOString().split('T')[0]; + if (activeDays.includes(dateString)) { + currentStreak++; + checkDate.setDate(checkDate.getDate() - 1); // Go back one day + } else { + break; // Streak broken + } + } + + // Get total active days + const totalDaysListened = activeDays.length; + + // Get longest streak (requires analyzing all streaks) + let longestStreak = currentStreak; + let tempStreak = 0; + + // Sort dates in ascending order for streak calculation + const ascDates = [...activeDays].sort(); + + for (let i = 0; i < ascDates.length; i++) { + const currentDate = new Date(ascDates[i]); + + if (i > 0) { + const prevDate = new Date(ascDates[i-1]); + prevDate.setDate(prevDate.getDate() + 1); + + // If dates are consecutive + if (currentDate.getTime() === prevDate.getTime()) { + tempStreak++; + } else { + // Streak broken + tempStreak = 1; + } + } else { + tempStreak = 1; // First active day + } + + longestStreak = Math.max(longestStreak, tempStreak); + } + + // Get last listened date + const lastListenedDate = activeDays.length > 0 ? activeDays[0] : null; + + return { + currentStreak, + longestStreak, + totalDaysListened, + lastListenedDate + }; +} + +// Update the last sync timestamp in IndexedDB +async function updateLastSyncTimestamp() { + return new Promise((resolve, reject) => { + const timestamp = Date.now(); + + const request = indexedDB.open('stillnavidrome-offline', 1); + + request.onerror = () => { + console.error('[Background Sync] Failed to open IndexedDB for timestamp update'); + reject(new Error('Failed to open IndexedDB')); + }; + + request.onsuccess = (event) => { + const db = event.target.result; + const transaction = db.transaction(['metadata'], 'readwrite'); + const store = transaction.objectStore('metadata'); + + const lastSyncData = { + key: 'background-sync-last-timestamp', + value: timestamp, + lastUpdated: timestamp + }; + + const putRequest = store.put(lastSyncData); + + putRequest.onsuccess = () => { + console.log('[Background Sync] Updated last sync timestamp:', new Date(timestamp).toISOString()); + resolve(timestamp); + }; + + putRequest.onerror = () => { + console.error('[Background Sync] Failed to update last sync timestamp'); + reject(new Error('Failed to update timestamp')); + }; + }; + }); +} + +// Listen for fetch events to serve from cache when offline +self.addEventListener('fetch', (event) => { + // Only handle API requests that we're syncing in the background + const url = new URL(event.request.url); + const isBackgroundSyncApi = BACKGROUND_SYNC_APIS.some(api => url.pathname.includes(api)); + + if (isBackgroundSyncApi) { + event.respondWith( + caches.match(event.request).then(cachedResponse => { + // Return cached response if available + if (cachedResponse) { + // Always try to refresh cache in the background if online + if (navigator.onLine) { + event.waitUntil( + fetch(event.request).then(response => { + return caches.open(BACKGROUND_SYNC_CACHE).then(cache => { + cache.put(event.request, response.clone()); + return response; + }); + }).catch(error => { + console.log('[Background Sync] Background refresh failed, using cache:', error); + }) + ); + } + return cachedResponse; + } + + // If no cache, try network and cache the result + return fetch(event.request).then(response => { + if (!response || response.status !== 200) { + return response; + } + + // Clone the response to store in cache + const responseToCache = response.clone(); + + caches.open(BACKGROUND_SYNC_CACHE).then(cache => { + cache.put(event.request, responseToCache); + }); + + return response; + }).catch(error => { + console.error('[Background Sync] Fetch failed and no cache available:', error); + // Could return a custom offline response here + throw error; + }); + }) + ); + } +});