From 9427a2a237100fa400aedaf2008cfd0cabb826d0 Mon Sep 17 00:00:00 2001
From: angel
Date: Mon, 11 Aug 2025 14:50:57 +0000
Subject: [PATCH] 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
---
.env.local | 2 +-
app/browse/page.tsx | 146 +++--
app/components/AudioPlayerContext.tsx | 34 ++
app/components/CompactListeningStreak.tsx | 71 +++
app/components/EnhancedOfflineManager.tsx | 627 ++++++++++++++++++++++
app/components/ListeningStreakCard.tsx | 153 ++++++
app/history/page.tsx | 5 +
app/page.tsx | 6 +
app/settings/page.tsx | 4 +-
components/ui/infinite-scroll.tsx | 56 ++
hooks/use-listening-streak.ts | 287 ++++++++++
hooks/use-progressive-album-loading.ts | 245 +++++++++
package.json | 1 +
pnpm-lock.yaml | 18 +
public/background-sync.js | 538 +++++++++++++++++++
15 files changed, 2112 insertions(+), 81 deletions(-)
create mode 100644 app/components/CompactListeningStreak.tsx
create mode 100644 app/components/EnhancedOfflineManager.tsx
create mode 100644 app/components/ListeningStreakCard.tsx
create mode 100644 components/ui/infinite-scroll.tsx
create mode 100644 hooks/use-listening-streak.ts
create mode 100644 hooks/use-progressive-album-loading.ts
create mode 100644 public/background-sync.js
diff --git a/.env.local b/.env.local
index b060a3b..43b94c4 100644
--- a/.env.local
+++ b/.env.local
@@ -1 +1 @@
-NEXT_PUBLIC_COMMIT_SHA=7e6a28e
+NEXT_PUBLIC_COMMIT_SHA=1f6ebf1
diff --git a/app/browse/page.tsx b/app/browse/page.tsx
index 940ac72..fcc7da4 100644
--- a/app/browse/page.tsx
+++ b/app/browse/page.tsx
@@ -1,90 +1,53 @@
-/* eslint-disable @typescript-eslint/no-unused-vars */
'use client';
-import { useState, useEffect } from 'react';
+import { useCallback, useEffect } from 'react';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
-import { Tabs, TabsContent } from '@/components/ui/tabs';
import { AlbumArtwork } from '@/app/components/album-artwork';
import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext';
-import { getNavidromeAPI, Album } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
-import { Shuffle } from 'lucide-react';
+import { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading';
+import {
+ Shuffle,
+ ArrowDown,
+ RefreshCcw,
+ Loader2
+} from 'lucide-react';
import Loading from '@/app/components/loading';
+import { useInView } from 'react-intersection-observer';
export default function BrowsePage() {
const { artists, isLoading: contextLoading } = useNavidrome();
const { shuffleAllAlbums } = useAudioPlayer();
- const [albums, setAlbums] = useState([]);
- const [currentPage, setCurrentPage] = useState(0);
- const [isLoadingAlbums, setIsLoadingAlbums] = useState(false);
- const [hasMoreAlbums, setHasMoreAlbums] = useState(true);
- const albumsPerPage = 84;
-
- const api = getNavidromeAPI();
- const loadAlbums = async (page: number, append: boolean = false) => {
- if (!api) {
- console.error('Navidrome API not available');
- return;
- }
-
- try {
- setIsLoadingAlbums(true);
- const offset = page * albumsPerPage;
-
- // Use alphabeticalByName to get all albums in alphabetical order
- const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset);
-
- if (append) {
- setAlbums(prev => [...prev, ...newAlbums]);
- } else {
- setAlbums(newAlbums);
- }
-
- // If we got fewer albums than requested, we've reached the end
- setHasMoreAlbums(newAlbums.length === albumsPerPage);
- } catch (error) {
- console.error('Failed to load albums:', error);
- } finally {
- setIsLoadingAlbums(false);
- }
- };
-
+
+ // Use our progressive loading hook
+ const {
+ albums,
+ isLoading,
+ hasMore,
+ loadMoreAlbums,
+ refreshAlbums
+ } = useProgressiveAlbumLoading('alphabeticalByName');
+
+ // Infinite scroll with intersection observer
+ const { ref, inView } = useInView({
+ threshold: 0.1,
+ triggerOnce: false
+ });
+
+ // Load more albums when the load more sentinel comes into view
useEffect(() => {
- loadAlbums(0);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Infinite scroll handler
- useEffect(() => {
- const handleScroll = (e: Event) => {
- const target = e.target as HTMLElement;
- if (!target || isLoadingAlbums || !hasMoreAlbums) return;
-
- const { scrollTop, scrollHeight, clientHeight } = target;
- const threshold = 200; // Load more when 200px from bottom
-
- if (scrollHeight - scrollTop - clientHeight < threshold) {
- loadMore();
- }
- };
-
- const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
- if (scrollArea) {
- scrollArea.addEventListener('scroll', handleScroll);
- return () => scrollArea.removeEventListener('scroll', handleScroll);
+ if (inView && hasMore && !isLoading) {
+ loadMoreAlbums();
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isLoadingAlbums, hasMoreAlbums, currentPage]);
-
- const loadMore = () => {
- if (isLoadingAlbums || !hasMoreAlbums) return;
- const nextPage = currentPage + 1;
- setCurrentPage(nextPage);
- loadAlbums(nextPage, true);
- };
+ }, [inView, hasMore, isLoading, loadMoreAlbums]);
+
+ // Pull-to-refresh simulation
+ const handleRefresh = useCallback(() => {
+ refreshAlbums();
+ }, [refreshAlbums]);
if (contextLoading) {
return ;
@@ -137,6 +100,10 @@ export default function BrowsePage() {
Browse the full collection of albums ({albums.length} loaded).
+
@@ -154,24 +121,47 @@ export default function BrowsePage() {
/>
))}
- {hasMoreAlbums && (
-
+ {/* Load more sentinel */}
+ {hasMore && (
+
)}
- {!hasMoreAlbums && albums.length > 0 && (
+
+ {!hasMore && albums.length > 0 && (
All albums loaded ({albums.length} total)
)}
+
+ {albums.length === 0 && !isLoading && (
+
+
No albums found
+
+ Try refreshing or check your connection
+
+
+
+ )}
diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx
index 9df8385..87bd456 100644
--- a/app/components/AudioPlayerContext.tsx
+++ b/app/components/AudioPlayerContext.tsx
@@ -228,6 +228,40 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
if (currentTrack) {
setPlayedTracks((prev) => [...prev, currentTrack]);
+
+ // Record the play for listening streak
+ // This will store timestamp with the track play
+ try {
+ const today = new Date().toISOString().split('T')[0];
+ const streakData = localStorage.getItem('navidrome-streak-data');
+
+ if (streakData) {
+ const parsedData = JSON.parse(streakData);
+ const todayData = parsedData[today] || {
+ date: today,
+ tracks: 0,
+ uniqueArtists: [],
+ uniqueAlbums: [],
+ totalListeningTime: 0
+ };
+
+ // Update today's listening data
+ todayData.tracks += 1;
+ if (!todayData.uniqueArtists.includes(currentTrack.artistId)) {
+ todayData.uniqueArtists.push(currentTrack.artistId);
+ }
+ if (!todayData.uniqueAlbums.includes(currentTrack.albumId)) {
+ todayData.uniqueAlbums.push(currentTrack.albumId);
+ }
+ todayData.totalListeningTime += currentTrack.duration;
+
+ // Save updated data
+ parsedData[today] = todayData;
+ localStorage.setItem('navidrome-streak-data', JSON.stringify(parsedData));
+ }
+ } catch (error) {
+ console.error('Failed to update listening streak data:', error);
+ }
}
// Set autoPlay flag on the track
diff --git a/app/components/CompactListeningStreak.tsx b/app/components/CompactListeningStreak.tsx
new file mode 100644
index 0000000..633b76c
--- /dev/null
+++ b/app/components/CompactListeningStreak.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useListeningStreak } from '@/hooks/use-listening-streak';
+import { Card, CardContent } from '@/components/ui/card';
+import { Flame } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { AnimatePresence, motion } from 'framer-motion';
+
+export default function CompactListeningStreak() {
+ const { stats, hasListenedToday, getStreakEmoji } = useListeningStreak();
+ const [animate, setAnimate] = useState(false);
+
+ // Trigger animation when streak increases
+ useEffect(() => {
+ if (stats.currentStreak > 0) {
+ setAnimate(true);
+ const timer = setTimeout(() => setAnimate(false), 1000);
+ return () => clearTimeout(timer);
+ }
+ }, [stats.currentStreak]);
+
+ const hasCompletedToday = hasListenedToday();
+ const streakEmoji = getStreakEmoji();
+
+ // Only show if the streak is 3 days or more
+ if (stats.currentStreak < 3) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {stats.currentStreak}
+
+
+ day streak
+
+ {streakEmoji && (
+
+ {streakEmoji}
+
+ )}
+
+
+
+
+ {hasCompletedToday ? "Today's goal complete!" : "Keep listening!"}
+
+
+
+
+ );
+}
diff --git a/app/components/EnhancedOfflineManager.tsx b/app/components/EnhancedOfflineManager.tsx
new file mode 100644
index 0000000..0c930b7
--- /dev/null
+++ b/app/components/EnhancedOfflineManager.tsx
@@ -0,0 +1,627 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useToast } from '@/hooks/use-toast';
+import { useOfflineLibrary } from '@/hooks/use-offline-library';
+import { useNavidrome } from '@/app/components/NavidromeContext';
+import {
+ Download,
+ Trash2,
+ RefreshCw,
+ Wifi,
+ WifiOff,
+ Database,
+ Clock,
+ AlertCircle,
+ CheckCircle,
+ Music,
+ User,
+ List,
+ HardDrive,
+ Disc,
+ Search,
+ Filter,
+ SlidersHorizontal
+} from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import Image from 'next/image';
+import { Album, Playlist } from '@/lib/navidrome';
+import { Switch } from '@/components/ui/switch';
+import { Label } from '@/components/ui/label';
+import { OfflineManagement } from './OfflineManagement';
+import { Skeleton } from '@/components/ui/skeleton';
+
+// Helper functions
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
+
+function formatDate(date: Date | null): string {
+ if (!date) return 'Never';
+ return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
+}
+
+// Album card for selection
+function AlbumSelectionCard({
+ album,
+ isSelected,
+ onToggleSelection,
+ isDownloading,
+ downloadProgress,
+ estimatedSize
+}: {
+ album: Album;
+ isSelected: boolean;
+ onToggleSelection: () => void;
+ isDownloading: boolean;
+ downloadProgress?: number;
+ estimatedSize: string;
+}) {
+ const { api } = useNavidrome();
+
+ return (
+
+
+
+
+
+
+
{album.name}
+
{album.artist}
+
+ {album.songCount} songs • {estimatedSize}
+
+
+
+
+
+ {isDownloading && downloadProgress !== undefined && (
+
+ )}
+
+ );
+}
+
+// Playlist selection card
+function PlaylistSelectionCard({
+ playlist,
+ isSelected,
+ onToggleSelection,
+ isDownloading,
+ downloadProgress,
+ estimatedSize
+}: {
+ playlist: Playlist;
+ isSelected: boolean;
+ onToggleSelection: () => void;
+ isDownloading: boolean;
+ downloadProgress?: number;
+ estimatedSize: string;
+}) {
+ const { api } = useNavidrome();
+
+ return (
+
+
+
+
+
{playlist.name}
+
by {playlist.owner}
+
+ {playlist.songCount} songs • {estimatedSize}
+
+
+
+
+
+ {isDownloading && downloadProgress !== undefined && (
+
+ )}
+
+ );
+}
+
+export default function EnhancedOfflineManager() {
+ const { toast } = useToast();
+ const [activeTab, setActiveTab] = useState('overview');
+ const [albums, setAlbums] = useState([]);
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState({
+ albums: false,
+ playlists: false
+ });
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedAlbums, setSelectedAlbums] = useState>(new Set());
+ const [selectedPlaylists, setSelectedPlaylists] = useState>(new Set());
+ const [downloadingItems, setDownloadingItems] = useState