From 699a27b0b94cf12b4a988dd181c86c5c2dd014ec Mon Sep 17 00:00:00 2001 From: angel Date: Sun, 25 Jan 2026 01:22:54 +0000 Subject: [PATCH] Use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code --- .env.local | 2 +- app/components/AudioPlayerContext.tsx | 39 +- app/components/CompactListeningStreak.tsx | 71 --- app/components/ListeningStreakCard.tsx | 153 ------ hooks/use-listening-streak.ts | 287 ------------ package.json | 1 + public/background-sync.js | 538 ---------------------- 7 files changed, 5 insertions(+), 1086 deletions(-) delete mode 100644 app/components/CompactListeningStreak.tsx delete mode 100644 app/components/ListeningStreakCard.tsx delete mode 100644 hooks/use-listening-streak.ts delete mode 100644 public/background-sync.js diff --git a/.env.local b/.env.local index 6b62389..ae4cef1 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=eb56096 +NEXT_PUBLIC_COMMIT_SHA=b5fc053 diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index 32aa4ca..ca8bafe 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -115,8 +115,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c if (savedCurrentTrack) { try { const track = JSON.parse(savedCurrentTrack); - // Clear autoPlay flag when loading from localStorage to prevent auto-play on refresh - track.autoPlay = false; + // Check if there's a saved playback position - if so, user was likely playing + const savedTime = localStorage.getItem('navidrome-current-track-time'); + track.autoPlay = savedTime !== null && parseFloat(savedTime) > 0; setCurrentTrack(track); } catch (error) { console.error('Failed to parse saved current track:', error); @@ -230,40 +231,6 @@ 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 deleted file mode 100644 index 633b76c..0000000 --- a/app/components/CompactListeningStreak.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'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/ListeningStreakCard.tsx b/app/components/ListeningStreakCard.tsx deleted file mode 100644 index de658a0..0000000 --- a/app/components/ListeningStreakCard.tsx +++ /dev/null @@ -1,153 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useListeningStreak } from '@/hooks/use-listening-streak'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; -import { Flame, Calendar, Clock, Music, Disc, User2 } from 'lucide-react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { cn } from '@/lib/utils'; - -export default function ListeningStreakCard() { - const { stats, hasListenedToday, getStreakEmoji, getTodaySummary, streakThresholds } = useListeningStreak(); - const [animate, setAnimate] = useState(false); - - // Trigger animation when streak increases - useEffect(() => { - if (stats.currentStreak > 0) { - setAnimate(true); - const timer = setTimeout(() => setAnimate(false), 1000); - return () => clearTimeout(timer); - } - }, [stats.currentStreak]); - - const todaySummary = getTodaySummary(); - const hasCompletedToday = hasListenedToday(); - - // Calculate progress towards today's goal - const trackProgress = Math.min(100, (todaySummary.tracks / streakThresholds.tracks) * 100); - const timeInMinutes = parseInt(todaySummary.time.replace('m', ''), 10) || 0; - const timeThresholdMinutes = Math.floor(streakThresholds.time / 60); - const timeProgress = Math.min(100, (timeInMinutes / timeThresholdMinutes) * 100); - - // Overall progress (highest of the two metrics) - const overallProgress = Math.max(trackProgress, timeProgress); - - return ( - - - -
- - Listening Streak -
-
- - - {stats.totalDaysListened} days - -
-
-
- -
- - -
- {stats.currentStreak} -
-
- day{stats.currentStreak !== 1 ? 's' : ''} streak -
- {getStreakEmoji() && ( - - {getStreakEmoji()} - - )} -
-
- -
-
- Today's Progress - - {hasCompletedToday ? "Complete!" : "In progress..."} - -
- div]:bg-green-500" : "" - )} - /> -
- -
-
-
- - Tracks -
- {todaySummary.tracks} - - Goal: {streakThresholds.tracks} - -
-
-
- - Time -
- {todaySummary.time} - - Goal: {timeThresholdMinutes}m - -
-
- -
-
-
- - Artists -
- {todaySummary.artists} -
-
-
- - Albums -
- {todaySummary.albums} -
-
- -
- {hasCompletedToday ? ( - You've met your daily listening goal! 🎵 - ) : ( - Listen to {streakThresholds.tracks} tracks or {timeThresholdMinutes} minutes to continue your streak! - )} -
-
-
-
- ); -} diff --git a/hooks/use-listening-streak.ts b/hooks/use-listening-streak.ts deleted file mode 100644 index f0c00b6..0000000 --- a/hooks/use-listening-streak.ts +++ /dev/null @@ -1,287 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; -import { Track } from '@/app/components/AudioPlayerContext'; - -// Interface for a single day's listening data -export interface DayStreakData { - date: string; // ISO string of the date - tracks: number; // Number of tracks played that day - uniqueArtists: Set; // Unique artists listened to - uniqueAlbums: Set; // Unique albums listened to - totalListeningTime: number; // Total seconds listened -} - -// Interface for streak statistics -export interface StreakStats { - currentStreak: number; // Current consecutive days streak - longestStreak: number; // Longest streak ever achieved - totalDaysListened: number; // Total days with listening activity - lastListenedDate: string | null; // Last date with listening activity -} - -const STREAK_THRESHOLD_TRACKS = 3; // Minimum tracks to count as an active day -const STREAK_THRESHOLD_TIME = 5 * 60; // 5 minutes minimum listening time - -export function useListeningStreak() { - const [streakData, setStreakData] = useState>(new Map()); - const [stats, setStats] = useState({ - currentStreak: 0, - longestStreak: 0, - totalDaysListened: 0, - lastListenedDate: null, - }); - const { playedTracks, currentTrack } = useAudioPlayer(); - - // Initialize streak data from localStorage - useEffect(() => { - // Check if we're in the browser environment - if (typeof window === 'undefined') return; - - try { - const savedStreakData = localStorage.getItem('navidrome-streak-data'); - const savedStats = localStorage.getItem('navidrome-streak-stats'); - - if (savedStreakData) { - // Convert the plain object back to a Map - const parsedData = JSON.parse(savedStreakData); - const dataMap = new Map(); - - // Reconstruct the Map and Sets - Object.entries(parsedData).forEach(([key, value]: [string, any]) => { - dataMap.set(key, { - ...value, - uniqueArtists: new Set(value.uniqueArtists), - uniqueAlbums: new Set(value.uniqueAlbums) - }); - }); - - setStreakData(dataMap); - } - - if (savedStats) { - setStats(JSON.parse(savedStats)); - } - - // Check if we need to update the streak based on the current date - updateStreakStatus(); - - } catch (error) { - console.error('Failed to load streak data:', error); - } - }, []); - - // Save streak data to localStorage whenever it changes - useEffect(() => { - if (typeof window === 'undefined' || streakData.size === 0) return; - - try { - // Convert Map to a plain object for serialization - const dataObject: Record = {}; - - streakData.forEach((value, key) => { - dataObject[key] = { - ...value, - uniqueArtists: Array.from(value.uniqueArtists), - uniqueAlbums: Array.from(value.uniqueAlbums) - }; - }); - - localStorage.setItem('navidrome-streak-data', JSON.stringify(dataObject)); - localStorage.setItem('navidrome-streak-stats', JSON.stringify(stats)); - - } catch (error) { - console.error('Failed to save streak data:', error); - } - }, [streakData, stats]); - - // Process playedTracks to update the streak - useEffect(() => { - if (playedTracks.length === 0) return; - - // Get today's date in YYYY-MM-DD format - const today = new Date().toISOString().split('T')[0]; - - // Update streak data for today - setStreakData(prev => { - const updated = new Map(prev); - - const todayData = updated.get(today) || { - date: today, - tracks: 0, - uniqueArtists: new Set(), - uniqueAlbums: new Set(), - totalListeningTime: 0 - }; - - // Update today's data based on played tracks - // For simplicity, we'll assume one track added = one complete listen - const lastTrack = playedTracks[playedTracks.length - 1]; - - todayData.tracks += 1; - todayData.uniqueArtists.add(lastTrack.artistId); - todayData.uniqueAlbums.add(lastTrack.albumId); - todayData.totalListeningTime += lastTrack.duration; - - updated.set(today, todayData); - return updated; - }); - - // Update streak statistics - updateStreakStatus(); - }, [playedTracks.length]); - - // Function to update streak status based on current data - const updateStreakStatus = useCallback(() => { - if (streakData.size === 0) return; - - const today = new Date().toISOString().split('T')[0]; - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; - - // Sort dates in descending order (newest first) - const dates = Array.from(streakData.keys()).sort((a, b) => - new Date(b).getTime() - new Date(a).getTime() - ); - - // Check which days count as active based on threshold - const activeDays = dates.filter(date => { - const dayData = streakData.get(date); - if (!dayData) return false; - - return dayData.tracks >= STREAK_THRESHOLD_TRACKS || - dayData.totalListeningTime >= STREAK_THRESHOLD_TIME; - }); - - // Calculate current streak - let currentStreak = 0; - let checkDate = new Date(today); - - // Keep checking consecutive days backward until streak breaks - while (true) { - const dateString = checkDate.toISOString().split('T')[0]; - if (activeDays.includes(dateString)) { - currentStreak++; - checkDate.setDate(checkDate.getDate() - 1); // Go back one day - } else { - break; // Streak broken - } - } - - // Get total active days - const totalDaysListened = activeDays.length; - - // Get longest streak (requires analyzing all streaks) - let longestStreak = currentStreak; - let tempStreak = 0; - - // Sort dates in ascending order for streak calculation - const ascDates = [...activeDays].sort(); - - for (let i = 0; i < ascDates.length; i++) { - const currentDate = new Date(ascDates[i]); - - if (i > 0) { - const prevDate = new Date(ascDates[i-1]); - prevDate.setDate(prevDate.getDate() + 1); - - // If dates are consecutive - if (currentDate.getTime() === prevDate.getTime()) { - tempStreak++; - } else { - // Streak broken - tempStreak = 1; - } - } else { - tempStreak = 1; // First active day - } - - longestStreak = Math.max(longestStreak, tempStreak); - } - - // Get last listened date - const lastListenedDate = activeDays.length > 0 ? activeDays[0] : null; - - // Update stats - setStats({ - currentStreak, - longestStreak, - totalDaysListened, - lastListenedDate - }); - }, [streakData]); - - // Check if user has listened today - const hasListenedToday = useCallback(() => { - const today = new Date().toISOString().split('T')[0]; - const todayData = streakData.get(today); - - return todayData && ( - todayData.tracks >= STREAK_THRESHOLD_TRACKS || - todayData.totalListeningTime >= STREAK_THRESHOLD_TIME - ); - }, [streakData]); - - // Get streak emoji representation - const getStreakEmoji = useCallback(() => { - if (stats.currentStreak <= 0) return ''; - - if (stats.currentStreak >= 30) return '🔥🔥🔥'; // 30+ days - if (stats.currentStreak >= 14) return '🔥🔥'; // 14+ days - if (stats.currentStreak >= 7) return '🔥'; // 7+ days - if (stats.currentStreak >= 3) return '✨'; // 3+ days - return '📅'; // 1-2 days - }, [stats.currentStreak]); - - // Get today's listening summary - const getTodaySummary = useCallback(() => { - const today = new Date().toISOString().split('T')[0]; - const todayData = streakData.get(today); - - if (!todayData) { - return { - tracks: 0, - artists: 0, - albums: 0, - time: '0m' - }; - } - - // Format time nicely - const minutes = Math.floor(todayData.totalListeningTime / 60); - const timeDisplay = minutes === 1 ? '1m' : `${minutes}m`; - - return { - tracks: todayData.tracks, - artists: todayData.uniqueArtists.size, - albums: todayData.uniqueAlbums.size, - time: timeDisplay - }; - }, [streakData]); - - // Reset streak data (for testing) - const resetStreakData = useCallback(() => { - setStreakData(new Map()); - setStats({ - currentStreak: 0, - longestStreak: 0, - totalDaysListened: 0, - lastListenedDate: null, - }); - - localStorage.removeItem('navidrome-streak-data'); - localStorage.removeItem('navidrome-streak-stats'); - }, []); - - return { - stats, - hasListenedToday, - getStreakEmoji, - getTodaySummary, - resetStreakData, - streakThresholds: { - tracks: STREAK_THRESHOLD_TRACKS, - time: STREAK_THRESHOLD_TIME - } - }; -} diff --git a/package.json b/package.json index fecd464..cf7fccb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local", + "prebuild": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local", "dev": "next dev --turbopack -p 40625", "build": "next build", "start": "next start -p 40625", diff --git a/public/background-sync.js b/public/background-sync.js deleted file mode 100644 index 8531d4a..0000000 --- a/public/background-sync.js +++ /dev/null @@ -1,538 +0,0 @@ -// 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; - }); - }) - ); - } -});