fix: use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code
This commit is contained in:
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=eb56096
|
NEXT_PUBLIC_COMMIT_SHA=b5fc053
|
||||||
|
|||||||
@@ -115,8 +115,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
if (savedCurrentTrack) {
|
if (savedCurrentTrack) {
|
||||||
try {
|
try {
|
||||||
const track = JSON.parse(savedCurrentTrack);
|
const track = JSON.parse(savedCurrentTrack);
|
||||||
// Clear autoPlay flag when loading from localStorage to prevent auto-play on refresh
|
// Check if there's a saved playback position - if so, user was likely playing
|
||||||
track.autoPlay = false;
|
const savedTime = localStorage.getItem('navidrome-current-track-time');
|
||||||
|
track.autoPlay = savedTime !== null && parseFloat(savedTime) > 0;
|
||||||
setCurrentTrack(track);
|
setCurrentTrack(track);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse saved current track:', 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) {
|
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
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local",
|
"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",
|
"dev": "next dev --turbopack -p 40625",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 40625",
|
"start": "next start -p 40625",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user