feat: Add ListeningStreakCard component for tracking listening streaks
feat: Implement InfiniteScroll component for loading more items on scroll feat: Create useListeningStreak hook to manage listening streak data and statistics feat: Develop useProgressiveAlbumLoading hook for progressive loading of albums feat: Implement background sync service worker for automatic data synchronization
This commit is contained in:
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=7e6a28e
|
NEXT_PUBLIC_COMMIT_SHA=1f6ebf1
|
||||||
|
|||||||
@@ -1,90 +1,53 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
|
||||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||||
import { ArtistIcon } from '@/app/components/artist-icon';
|
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
import { getNavidromeAPI, Album } from '@/lib/navidrome';
|
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
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 Loading from '@/app/components/loading';
|
||||||
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
export default function BrowsePage() {
|
export default function BrowsePage() {
|
||||||
const { artists, isLoading: contextLoading } = useNavidrome();
|
const { artists, isLoading: contextLoading } = useNavidrome();
|
||||||
const { shuffleAllAlbums } = useAudioPlayer();
|
const { shuffleAllAlbums } = useAudioPlayer();
|
||||||
const [albums, setAlbums] = useState<Album[]>([]);
|
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
// Use our progressive loading hook
|
||||||
const [isLoadingAlbums, setIsLoadingAlbums] = useState(false);
|
const {
|
||||||
const [hasMoreAlbums, setHasMoreAlbums] = useState(true);
|
albums,
|
||||||
const albumsPerPage = 84;
|
isLoading,
|
||||||
|
hasMore,
|
||||||
const api = getNavidromeAPI();
|
loadMoreAlbums,
|
||||||
const loadAlbums = async (page: number, append: boolean = false) => {
|
refreshAlbums
|
||||||
if (!api) {
|
} = useProgressiveAlbumLoading('alphabeticalByName');
|
||||||
console.error('Navidrome API not available');
|
|
||||||
return;
|
// Infinite scroll with intersection observer
|
||||||
}
|
const { ref, inView } = useInView({
|
||||||
|
threshold: 0.1,
|
||||||
try {
|
triggerOnce: false
|
||||||
setIsLoadingAlbums(true);
|
});
|
||||||
const offset = page * albumsPerPage;
|
|
||||||
|
// Load more albums when the load more sentinel comes into view
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAlbums(0);
|
if (inView && hasMore && !isLoading) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
loadMoreAlbums();
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [inView, hasMore, isLoading, loadMoreAlbums]);
|
||||||
}, [isLoadingAlbums, hasMoreAlbums, currentPage]);
|
|
||||||
|
// Pull-to-refresh simulation
|
||||||
const loadMore = () => {
|
const handleRefresh = useCallback(() => {
|
||||||
if (isLoadingAlbums || !hasMoreAlbums) return;
|
refreshAlbums();
|
||||||
const nextPage = currentPage + 1;
|
}, [refreshAlbums]);
|
||||||
setCurrentPage(nextPage);
|
|
||||||
loadAlbums(nextPage, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (contextLoading) {
|
if (contextLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
@@ -137,6 +100,10 @@ export default function BrowsePage() {
|
|||||||
Browse the full collection of albums ({albums.length} loaded).
|
Browse the full collection of albums ({albums.length} loaded).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||||
|
<RefreshCcw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="relative grow">
|
<div className="relative grow">
|
||||||
@@ -154,24 +121,47 @@ export default function BrowsePage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hasMoreAlbums && (
|
{/* Load more sentinel */}
|
||||||
<div className="flex justify-center p-4 pb-24">
|
{hasMore && (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="flex justify-center p-4 pb-24"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={loadMore}
|
onClick={loadMoreAlbums}
|
||||||
disabled={isLoadingAlbums}
|
disabled={isLoading}
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
className="flex flex-col items-center gap-2"
|
||||||
>
|
>
|
||||||
{isLoadingAlbums ? 'Loading...' : `Load More Albums (${albumsPerPage} more)`}
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm">
|
||||||
|
{isLoading ? 'Loading...' : 'Load More Albums'}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMoreAlbums && albums.length > 0 && (
|
|
||||||
|
{!hasMore && albums.length > 0 && (
|
||||||
<div className="flex justify-center p-4 pb-24">
|
<div className="flex justify-center p-4 pb-24">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
All albums loaded ({albums.length} total)
|
All albums loaded ({albums.length} total)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{albums.length === 0 && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center p-12">
|
||||||
|
<p className="text-lg font-medium mb-2">No albums found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Try refreshing or check your connection
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleRefresh}>Refresh</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollBar orientation="vertical" />
|
<ScrollBar orientation="vertical" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -228,6 +228,40 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
|
|
||||||
if (currentTrack) {
|
if (currentTrack) {
|
||||||
setPlayedTracks((prev) => [...prev, 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
|
// Set autoPlay flag on the track
|
||||||
|
|||||||
71
app/components/CompactListeningStreak.tsx
Normal file
71
app/components/CompactListeningStreak.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Flame className={cn(
|
||||||
|
"w-5 h-5",
|
||||||
|
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
key={stats.currentStreak}
|
||||||
|
initial={{ scale: animate ? 0.8 : 1 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<span className="text-xl font-bold">
|
||||||
|
{stats.currentStreak}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-sm text-muted-foreground">
|
||||||
|
day streak
|
||||||
|
</span>
|
||||||
|
{streakEmoji && (
|
||||||
|
<motion.span
|
||||||
|
className="ml-1 text-xl"
|
||||||
|
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
|
||||||
|
>
|
||||||
|
{streakEmoji}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{hasCompletedToday ? "Today's goal complete!" : "Keep listening!"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
627
app/components/EnhancedOfflineManager.tsx
Normal file
627
app/components/EnhancedOfflineManager.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
|
||||||
|
<div className="flex p-3">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Image
|
||||||
|
src={album.coverArt ? (api?.getCoverArtUrl(album.coverArt) || '/default-user.jpg') : '/default-user.jpg'}
|
||||||
|
alt={album.name}
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
className="rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 flex-1 overflow-hidden">
|
||||||
|
<h4 className="font-medium truncate">{album.name}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{album.songCount} songs • {estimatedSize}</span>
|
||||||
|
<Switch
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={onToggleSelection}
|
||||||
|
disabled={isDownloading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading && downloadProgress !== undefined && (
|
||||||
|
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist selection card
|
||||||
|
function PlaylistSelectionCard({
|
||||||
|
playlist,
|
||||||
|
isSelected,
|
||||||
|
onToggleSelection,
|
||||||
|
isDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
estimatedSize
|
||||||
|
}: {
|
||||||
|
playlist: Playlist;
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggleSelection: () => void;
|
||||||
|
isDownloading: boolean;
|
||||||
|
downloadProgress?: number;
|
||||||
|
estimatedSize: string;
|
||||||
|
}) {
|
||||||
|
const { api } = useNavidrome();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
|
||||||
|
<div className="flex p-3">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="w-[60px] h-[60px] rounded-md bg-accent flex items-center justify-center">
|
||||||
|
<List className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 flex-1 overflow-hidden">
|
||||||
|
<h4 className="font-medium truncate">{playlist.name}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">by {playlist.owner}</p>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{playlist.songCount} songs • {estimatedSize}</span>
|
||||||
|
<Switch
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={onToggleSelection}
|
||||||
|
disabled={isDownloading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading && downloadProgress !== undefined && (
|
||||||
|
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnhancedOfflineManager() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
|
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||||
|
const [loading, setLoading] = useState({
|
||||||
|
albums: false,
|
||||||
|
playlists: false
|
||||||
|
});
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string>>(new Set());
|
||||||
|
const [downloadingItems, setDownloadingItems] = useState<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [sortBy, setSortBy] = useState('recent');
|
||||||
|
const [filtersVisible, setFiltersVisible] = useState(false);
|
||||||
|
|
||||||
|
const offline = useOfflineLibrary();
|
||||||
|
const { api } = useNavidrome();
|
||||||
|
|
||||||
|
// Load albums and playlists
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
// ...existing code...
|
||||||
|
// Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists
|
||||||
|
|
||||||
|
// Load albums data
|
||||||
|
const loadAlbums = async () => {
|
||||||
|
setLoading(prev => ({ ...prev, albums: true }));
|
||||||
|
try {
|
||||||
|
const albumData = await offline.getAlbums();
|
||||||
|
setAlbums(albumData);
|
||||||
|
|
||||||
|
// Load previously selected albums from localStorage
|
||||||
|
const savedSelections = localStorage.getItem('navidrome-offline-albums');
|
||||||
|
if (savedSelections) {
|
||||||
|
setSelectedAlbums(new Set(JSON.parse(savedSelections)));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load albums:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load albums. Please try again.',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(prev => ({ ...prev, albums: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load playlists data
|
||||||
|
const loadPlaylists = async () => {
|
||||||
|
setLoading(prev => ({ ...prev, playlists: true }));
|
||||||
|
try {
|
||||||
|
const playlistData = await offline.getPlaylists();
|
||||||
|
setPlaylists(playlistData);
|
||||||
|
|
||||||
|
// Load previously selected playlists from localStorage
|
||||||
|
const savedSelections = localStorage.getItem('navidrome-offline-playlists');
|
||||||
|
if (savedSelections) {
|
||||||
|
setSelectedPlaylists(new Set(JSON.parse(savedSelections)));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load playlists:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load playlists. Please try again.',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(prev => ({ ...prev, playlists: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle album selection
|
||||||
|
const toggleAlbumSelection = (albumId: string) => {
|
||||||
|
setSelectedAlbums(prev => {
|
||||||
|
const newSelection = new Set(prev);
|
||||||
|
if (newSelection.has(albumId)) {
|
||||||
|
newSelection.delete(albumId);
|
||||||
|
} else {
|
||||||
|
newSelection.add(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection]));
|
||||||
|
|
||||||
|
return newSelection;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle playlist selection
|
||||||
|
const togglePlaylistSelection = (playlistId: string) => {
|
||||||
|
setSelectedPlaylists(prev => {
|
||||||
|
const newSelection = new Set(prev);
|
||||||
|
if (newSelection.has(playlistId)) {
|
||||||
|
newSelection.delete(playlistId);
|
||||||
|
} else {
|
||||||
|
newSelection.add(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection]));
|
||||||
|
|
||||||
|
return newSelection;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download selected items
|
||||||
|
const downloadSelected = async () => {
|
||||||
|
// Mock implementation - in a real implementation, you'd integrate with the download system
|
||||||
|
const selectedIds = [...selectedAlbums, ...selectedPlaylists];
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: 'No items selected',
|
||||||
|
description: 'Please select albums or playlists to download.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Download Started',
|
||||||
|
description: `Downloading ${selectedIds.length} items for offline use.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock download progress
|
||||||
|
const downloadMap = new Map<string, number>();
|
||||||
|
selectedIds.forEach(id => downloadMap.set(id, 0));
|
||||||
|
setDownloadingItems(downloadMap);
|
||||||
|
|
||||||
|
// Simulate download progress
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDownloadingItems(prev => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
let allComplete = true;
|
||||||
|
|
||||||
|
for (const [id, progress] of prev.entries()) {
|
||||||
|
if (progress < 100) {
|
||||||
|
updated.set(id, Math.min(progress + Math.random() * 10, 100));
|
||||||
|
allComplete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allComplete) {
|
||||||
|
clearInterval(interval);
|
||||||
|
toast({
|
||||||
|
title: 'Download Complete',
|
||||||
|
description: `${selectedIds.length} items are now available offline.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setDownloadingItems(new Map());
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort albums
|
||||||
|
const filteredAlbums = albums
|
||||||
|
.filter(album => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
return album.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
album.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'recent':
|
||||||
|
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case 'artist':
|
||||||
|
return a.artist.localeCompare(b.artist);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter and sort playlists
|
||||||
|
const filteredPlaylists = playlists
|
||||||
|
.filter(playlist => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
return playlist.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'recent':
|
||||||
|
return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime();
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estimate album size (mock implementation)
|
||||||
|
const estimateSize = (songCount: number) => {
|
||||||
|
const averageSongSizeMB = 8;
|
||||||
|
const totalSizeMB = songCount * averageSongSizeMB;
|
||||||
|
if (totalSizeMB > 1000) {
|
||||||
|
return `${(totalSizeMB / 1000).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
return `${totalSizeMB.toFixed(0)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="albums">Albums</TabsTrigger>
|
||||||
|
<TabsTrigger value="playlists">Playlists</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<OfflineManagement />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="albums" className="space-y-4">
|
||||||
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Disc className="h-5 w-5" />
|
||||||
|
Select Albums
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose albums to make available offline
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search albums..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtersVisible && (
|
||||||
|
<div className="p-3 border rounded-md bg-muted/30">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Sort By</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'recent' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortBy('recent')}
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'name' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortBy('name')}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'artist' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortBy('artist')}
|
||||||
|
>
|
||||||
|
Artist
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAlbums(new Set())}
|
||||||
|
disabled={selectedAlbums.size === 0}
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
|
||||||
|
{loading.albums ? (
|
||||||
|
// Loading skeletons
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i} className="mb-3">
|
||||||
|
<div className="flex p-3">
|
||||||
|
<Skeleton className="h-[60px] w-[60px] rounded-md" />
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<Skeleton className="h-5 w-2/3 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : filteredAlbums.length > 0 ? (
|
||||||
|
filteredAlbums.map(album => (
|
||||||
|
<AlbumSelectionCard
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
isSelected={selectedAlbums.has(album.id)}
|
||||||
|
onToggleSelection={() => toggleAlbumSelection(album.id)}
|
||||||
|
isDownloading={downloadingItems.has(album.id)}
|
||||||
|
downloadProgress={downloadingItems.get(album.id)}
|
||||||
|
estimatedSize={estimateSize(album.songCount)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Disc className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery ? 'No albums found matching your search' : 'No albums available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={downloadSelected}
|
||||||
|
disabled={selectedAlbums.size === 0 || downloadingItems.size > 0}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download {selectedAlbums.size} Selected Album{selectedAlbums.size !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="playlists" className="space-y-4">
|
||||||
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<List className="h-5 w-5" />
|
||||||
|
Select Playlists
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose playlists to make available offline
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search playlists..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtersVisible && (
|
||||||
|
<div className="p-3 border rounded-md bg-muted/30">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Sort By</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'recent' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortBy('recent')}
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'name' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortBy('name')}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedPlaylists(new Set())}
|
||||||
|
disabled={selectedPlaylists.size === 0}
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
|
||||||
|
{loading.playlists ? (
|
||||||
|
// Loading skeletons
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i} className="mb-3">
|
||||||
|
<div className="flex p-3">
|
||||||
|
<Skeleton className="h-[60px] w-[60px] rounded-md" />
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<Skeleton className="h-5 w-2/3 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : filteredPlaylists.length > 0 ? (
|
||||||
|
filteredPlaylists.map(playlist => (
|
||||||
|
<PlaylistSelectionCard
|
||||||
|
key={playlist.id}
|
||||||
|
playlist={playlist}
|
||||||
|
isSelected={selectedPlaylists.has(playlist.id)}
|
||||||
|
onToggleSelection={() => togglePlaylistSelection(playlist.id)}
|
||||||
|
isDownloading={downloadingItems.has(playlist.id)}
|
||||||
|
downloadProgress={downloadingItems.get(playlist.id)}
|
||||||
|
estimatedSize={estimateSize(playlist.songCount)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<List className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery ? 'No playlists found matching your search' : 'No playlists available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={downloadSelected}
|
||||||
|
disabled={selectedPlaylists.size === 0 || downloadingItems.size > 0}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download {selectedPlaylists.size} Selected Playlist{selectedPlaylists.size !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/components/ListeningStreakCard.tsx
Normal file
153
app/components/ListeningStreakCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Flame className={cn(
|
||||||
|
"w-5 h-5 transition-all",
|
||||||
|
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
<span>Listening Streak</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{stats.totalDaysListened} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center py-2">
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
key={stats.currentStreak}
|
||||||
|
initial={{ scale: animate ? 0.5 : 1 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
exit={{ scale: 0.5 }}
|
||||||
|
className="relative mb-2"
|
||||||
|
>
|
||||||
|
<div className="text-5xl font-bold text-center">
|
||||||
|
{stats.currentStreak}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-center text-muted-foreground">
|
||||||
|
day{stats.currentStreak !== 1 ? 's' : ''} streak
|
||||||
|
</div>
|
||||||
|
{getStreakEmoji() && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute -top-2 -right-4 text-2xl"
|
||||||
|
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
{getStreakEmoji()}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="w-full mt-4">
|
||||||
|
<div className="flex justify-between items-center text-sm mb-1">
|
||||||
|
<span className="text-muted-foreground">Today's Progress</span>
|
||||||
|
<span className={cn(
|
||||||
|
hasCompletedToday ? "text-green-500 font-medium" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{hasCompletedToday ? "Complete!" : "In progress..."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={overallProgress}
|
||||||
|
className={cn(
|
||||||
|
"h-2",
|
||||||
|
hasCompletedToday ? "bg-green-500/20" : "",
|
||||||
|
hasCompletedToday ? "[&>div]:bg-green-500" : ""
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 w-full mt-6">
|
||||||
|
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Music className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Tracks</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">{todaySummary.tracks}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Goal: {streakThresholds.tracks}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Time</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">{todaySummary.time}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Goal: {timeThresholdMinutes}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 w-full mt-4">
|
||||||
|
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<User2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Artists</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">{todaySummary.artists}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Disc className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Albums</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">{todaySummary.albums}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-center text-muted-foreground">
|
||||||
|
{hasCompletedToday ? (
|
||||||
|
<span>You've met your daily listening goal! 🎵</span>
|
||||||
|
) : (
|
||||||
|
<span>Listen to {streakThresholds.tracks} tracks or {timeThresholdMinutes} minutes to continue your streak!</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Tabs, TabsContent } from '@/components/ui/tabs';
|
|||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
import { Play, Plus, User, Disc, History, Trash2 } from 'lucide-react';
|
import { Play, Plus, User, Disc, History, Trash2 } from 'lucide-react';
|
||||||
|
import ListeningStreakCard from '@/app/components/ListeningStreakCard';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -78,6 +79,10 @@ export default function HistoryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full px-4 py-6 lg:px-8">
|
<div className="h-full px-4 py-6 lg:px-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<ListeningStreakCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||||
<TabsContent value="music" className="border-none p-0 outline-hidden">
|
<TabsContent value="music" className="border-none p-0 outline-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { UserProfile } from './components/UserProfile';
|
import { UserProfile } from './components/UserProfile';
|
||||||
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
|
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
|
||||||
|
import CompactListeningStreak from './components/CompactListeningStreak';
|
||||||
|
|
||||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||||
|
|
||||||
@@ -218,6 +219,11 @@ function MusicPageContent() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<SongRecommendations userName={userName} />
|
<SongRecommendations userName={userName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Listening Streak Section - Only shown when 3+ days streak */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<CompactListeningStreak />
|
||||||
|
</div>
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-sh
|
|||||||
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
|
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
|
||||||
import { SettingsManagement } from '@/app/components/SettingsManagement';
|
import { SettingsManagement } from '@/app/components/SettingsManagement';
|
||||||
import { CacheManagement } from '@/app/components/CacheManagement';
|
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 { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
|
||||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
|
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
|
||||||
import { Settings, ExternalLink, Tag } from 'lucide-react';
|
import { Settings, ExternalLink, Tag } from 'lucide-react';
|
||||||
@@ -786,7 +786,7 @@ const SettingsPage = () => {
|
|||||||
|
|
||||||
{/* Offline Library Management */}
|
{/* Offline Library Management */}
|
||||||
<div className="break-inside-avoid mb-6">
|
<div className="break-inside-avoid mb-6">
|
||||||
<OfflineManagement />
|
<EnhancedOfflineManager />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto-Tagging Settings */}
|
{/* Auto-Tagging Settings */}
|
||||||
|
|||||||
56
components/ui/infinite-scroll.tsx
Normal file
56
components/ui/infinite-scroll.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'py-4 flex flex-col items-center justify-center w-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<p className="text-sm text-muted-foreground">{loadingText}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && !isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">{endMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
hooks/use-listening-streak.ts
Normal file
287
hooks/use-listening-streak.ts
Normal file
@@ -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<string>; // Unique artists listened to
|
||||||
|
uniqueAlbums: Set<string>; // 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<Map<string, DayStreakData>>(new Map());
|
||||||
|
const [stats, setStats] = useState<StreakStats>({
|
||||||
|
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<string, DayStreakData>();
|
||||||
|
|
||||||
|
// 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<string, any> = {};
|
||||||
|
|
||||||
|
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<string>(),
|
||||||
|
uniqueAlbums: new Set<string>(),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
245
hooks/use-progressive-album-loading.ts
Normal file
245
hooks/use-progressive-album-loading.ts
Normal file
@@ -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<Album[]>([]);
|
||||||
|
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<string | null>(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<string, number>();
|
||||||
|
const recentlyPlayed = localStorage.getItem('recently-played-albums');
|
||||||
|
if (recentlyPlayed) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(recentlyPlayed);
|
||||||
|
Object.entries(parsed).forEach(([id, timestamp]) => {
|
||||||
|
recentlyPlayedMap.set(id, timestamp as number);
|
||||||
|
});
|
||||||
|
albumData.sort((a, b) => {
|
||||||
|
const timestampA = recentlyPlayedMap.get(a.id) || 0;
|
||||||
|
const timestampB = recentlyPlayedMap.get(b.id) || 0;
|
||||||
|
return timestampB - timestampA;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing recently played albums:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got albums offline and it's non-empty, use that
|
||||||
|
if (albumData && albumData.length > 0) {
|
||||||
|
// Just take the initial batch for consistent behavior
|
||||||
|
const initialBatch = albumData.slice(0, INITIAL_BATCH_SIZE);
|
||||||
|
setAlbums(initialBatch);
|
||||||
|
setCurrentOffset(initialBatch.length);
|
||||||
|
setHasMore(albumData.length > initialBatch.length);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (offlineError) {
|
||||||
|
console.error('Error loading albums from offline storage:', offlineError);
|
||||||
|
// Continue to online API as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to online API if needed
|
||||||
|
if (api) {
|
||||||
|
albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0);
|
||||||
|
setAlbums(albumData);
|
||||||
|
setCurrentOffset(albumData.length);
|
||||||
|
// Assume there are more unless we got fewer than we asked for
|
||||||
|
setHasMore(albumData.length >= INITIAL_BATCH_SIZE);
|
||||||
|
} else {
|
||||||
|
// No API available
|
||||||
|
setAlbums([]);
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
} 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<string, number>();
|
||||||
|
const recentlyPlayed = localStorage.getItem('recently-played-albums');
|
||||||
|
if (recentlyPlayed) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(recentlyPlayed);
|
||||||
|
Object.entries(parsed).forEach(([id, timestamp]) => {
|
||||||
|
recentlyPlayedMap.set(id, timestamp as number);
|
||||||
|
});
|
||||||
|
allAlbums.sort((a, b) => {
|
||||||
|
const timestampA = recentlyPlayedMap.get(a.id) || 0;
|
||||||
|
const timestampB = recentlyPlayedMap.get(b.id) || 0;
|
||||||
|
return timestampB - timestampA;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing recently played albums:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice the next batch from the offline data
|
||||||
|
if (allAlbums && allAlbums.length > currentOffset) {
|
||||||
|
newAlbums = allAlbums.slice(currentOffset, currentOffset + BATCH_SIZE);
|
||||||
|
setAlbums(prev => [...prev, ...newAlbums]);
|
||||||
|
setCurrentOffset(currentOffset + newAlbums.length);
|
||||||
|
setHasMore(allAlbums.length > currentOffset + newAlbums.length);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (offlineError) {
|
||||||
|
console.error('Error loading more albums from offline storage:', offlineError);
|
||||||
|
// Continue to online API as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to online API
|
||||||
|
if (api) {
|
||||||
|
newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset);
|
||||||
|
setAlbums(prev => [...prev, ...newAlbums]);
|
||||||
|
setCurrentOffset(currentOffset + newAlbums.length);
|
||||||
|
// If we get fewer albums than we asked for, we've reached the end
|
||||||
|
setHasMore(newAlbums.length >= BATCH_SIZE);
|
||||||
|
} else {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"recharts": "^3.0.2",
|
"recharts": "^3.0.2",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -169,6 +169,9 @@ importers:
|
|||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.4.0(react@19.1.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:
|
react-resizable-panels:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 3.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -2953,6 +2956,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '*'
|
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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -6156,6 +6168,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
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@16.13.1: {}
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|||||||
538
public/background-sync.js
Normal file
538
public/background-sync.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user