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:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user