Compare commits
10 Commits
a0051576c6
...
245620d4a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 245620d4a7 | |||
| b426cc05ff | |||
| 699a27b0b9 | |||
| b5fc05382e | |||
| 43a51b165b | |||
| c64e40d56b | |||
| 6d1e4fb063 | |||
| eb56096992 | |||
| df248497ae | |||
| 7b036d8b6c |
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=9427a2a
|
||||
NEXT_PUBLIC_COMMIT_SHA=b5fc053
|
||||
|
||||
33
.github/workflows/github-release.yml
vendored
Normal file
33
.github/workflows/github-release.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip header
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: ${{ steps.changelog.outputs.content }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Features
|
||||
- Fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists
|
||||
- Add pagination to library/songs and remove listening streaks
|
||||
- Improve SortableQueueItem component with enhanced click handling and styling
|
||||
- Add keyboard shortcuts and queue management features
|
||||
- Add ListeningStreakCard component for tracking listening streaks
|
||||
- Enhance OfflineManagement component with improved card styling and layout
|
||||
- Implement Auto-Tagging Settings and MusicBrainz integration
|
||||
- Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component
|
||||
- Update cover art retrieval to use higher resolution images and enhance download manager with new features
|
||||
- Enhance UI with Framer Motion animations for album artwork and artist icons
|
||||
- Add page transition animations and notification settings for audio playback
|
||||
- Implement offline library synchronization with IndexedDB
|
||||
- Implement offline library management with IndexedDB support
|
||||
|
||||
### Bug Fixes
|
||||
- Use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code
|
||||
- Docker startup issue, add GitHub release workflow and changelog config
|
||||
|
||||
### Refactoring
|
||||
- Simplify service worker by removing offline download functionality
|
||||
- Remove all offline download and caching functionality
|
||||
- Move service worker registration to a dedicated component for improved client-side handling
|
||||
- Refactor service worker registration and enhance offline download manager with client-side checks
|
||||
|
||||
### Miscellaneous
|
||||
- Organize documentation: move markdown files to docs/ folder
|
||||
- Update version to 2026.01.24 and add changelog for January 2026 release
|
||||
- Update pnpm-lock.yaml to match new overrides configuration
|
||||
- Remove PostHog analytics and update dependencies to latest minor versions
|
||||
- Bump the dev group across 1 directory with 2 updates
|
||||
- Merge pull request #39 from sillyangel/dependabot/npm_and_yarn/dev-99ea30e4b7
|
||||
|
||||
### Styling
|
||||
- Update README formatting and improve content clarity
|
||||
|
||||
## [2026.01.24] - 2026-01-24
|
||||
|
||||
Previous release before changelog tracking.
|
||||
@@ -24,11 +24,9 @@ COPY README.md /app/
|
||||
ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL
|
||||
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME
|
||||
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD
|
||||
ENV NEXT_PUBLIC_COMMIT_SHA=docker-build
|
||||
ENV PORT=3000
|
||||
|
||||
# Generate git commit hash for build info (fallback if not available)
|
||||
RUN echo "NEXT_PUBLIC_COMMIT_SHA=docker-build" > .env.local
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@ Next, open the new `.env` file and update it with your Navidrome server credenti
|
||||
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
||||
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
|
||||
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
|
||||
NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXXXXXXXXXXXXXXX
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
> **Tip:** If you don’t have your own Navidrome server yet, you can use the public demo credentials:
|
||||
|
||||
@@ -13,9 +13,6 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { OfflineIndicator, DownloadButton } from '@/app/components/OfflineIndicator';
|
||||
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export default function AlbumPage() {
|
||||
const { id } = useParams();
|
||||
@@ -29,8 +26,6 @@ export default function AlbumPage() {
|
||||
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
|
||||
const isMobile = useIsMobile();
|
||||
const api = getNavidromeAPI();
|
||||
const { downloadAlbum, isSupported: isOfflineSupported } = useOfflineDownloads();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbum = async () => {
|
||||
@@ -126,41 +121,16 @@ export default function AlbumPage() {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = async () => {
|
||||
if (!album || !tracklist.length) return;
|
||||
|
||||
try {
|
||||
toast({
|
||||
title: "Download Started",
|
||||
description: `Starting download of "${album.name}" by ${album.artist}`,
|
||||
});
|
||||
|
||||
await downloadAlbum(album, tracklist);
|
||||
|
||||
toast({
|
||||
title: "Download Complete",
|
||||
description: `"${album.name}" has been downloaded for offline listening`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to download album:', error);
|
||||
toast({
|
||||
title: "Download Failed",
|
||||
description: `Failed to download "${album.name}". Please try again.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic cover art URLs based on image size
|
||||
const getMobileCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 280)
|
||||
? api.getCoverArtUrl(album.coverArt, 600)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
const getDesktopCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
? api.getCoverArtUrl(album.coverArt, 600)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
@@ -176,8 +146,8 @@ export default function AlbumPage() {
|
||||
<Image
|
||||
src={getMobileCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={280}
|
||||
height={280}
|
||||
width={600}
|
||||
height={600}
|
||||
className="rounded-md shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -192,15 +162,6 @@ export default function AlbumPage() {
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground text-left">{album.genre} • {album.year}</p>
|
||||
<p className="text-sm text-muted-foreground text-left">{album.songCount} songs, {formatDuration(album.duration)}</p>
|
||||
|
||||
{/* Offline indicator for mobile */}
|
||||
<OfflineIndicator
|
||||
id={album.id}
|
||||
type="album"
|
||||
showLabel
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side - Controls */}
|
||||
@@ -212,18 +173,6 @@ export default function AlbumPage() {
|
||||
>
|
||||
<Play className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
{/* Download button for mobile */}
|
||||
{isOfflineSupported && (
|
||||
<DownloadButton
|
||||
id={album.id}
|
||||
type="album"
|
||||
onDownload={handleDownloadAlbum}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs px-2 py-1 h-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,8 +182,8 @@ export default function AlbumPage() {
|
||||
<Image
|
||||
src={getDesktopCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={300}
|
||||
height={300}
|
||||
width={600}
|
||||
height={600}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
@@ -253,30 +202,12 @@ export default function AlbumPage() {
|
||||
<Button className="px-5" onClick={() => playAlbum(album.id)}>
|
||||
Play
|
||||
</Button>
|
||||
|
||||
{/* Download button for desktop */}
|
||||
{isOfflineSupported && (
|
||||
<DownloadButton
|
||||
id={album.id}
|
||||
type="album"
|
||||
onDownload={handleDownloadAlbum}
|
||||
variant="outline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Album info */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{album.genre} • {album.year}</p>
|
||||
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
|
||||
|
||||
{/* Offline indicator for desktop */}
|
||||
<OfflineIndicator
|
||||
id={album.id}
|
||||
type="album"
|
||||
showLabel
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,12 +243,6 @@ export default function AlbumPage() {
|
||||
}`}>
|
||||
{song.title}
|
||||
</p>
|
||||
{/* Song offline indicator */}
|
||||
<OfflineIndicator
|
||||
id={song.id}
|
||||
type="song"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -19,7 +19,9 @@ import Loading from '@/app/components/loading';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
export default function BrowsePage() {
|
||||
const { artists, isLoading: contextLoading } = useNavidrome();
|
||||
const { artists: allArtists, isLoading: contextLoading } = useNavidrome();
|
||||
// Filter to only show album artists (artists with at least one album)
|
||||
const artists = allArtists.filter(artist => artist.albumCount && artist.albumCount > 0);
|
||||
const { shuffleAllAlbums } = useAudioPlayer();
|
||||
|
||||
// Use our progressive loading hook
|
||||
@@ -78,12 +80,13 @@ export default function BrowsePage() {
|
||||
<div className="relative">
|
||||
<ScrollArea>
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{artists.map((artist) => (
|
||||
{artists.map((artist, index) => (
|
||||
<ArtistIcon
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
className="shrink-0 overflow-hidden"
|
||||
size={190}
|
||||
loading={index < 10 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -110,7 +113,7 @@ export default function BrowsePage() {
|
||||
<ScrollArea className="h-full">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 p-4 pb-8">
|
||||
{albums.map((album) => (
|
||||
{albums.map((album, index) => (
|
||||
<AlbumArtwork
|
||||
key={album.id}
|
||||
album={album}
|
||||
@@ -118,6 +121,7 @@ export default function BrowsePage() {
|
||||
aspectRatio="square"
|
||||
width={200}
|
||||
height={200}
|
||||
loading={index < 20 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,627 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Database,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Music,
|
||||
User,
|
||||
List,
|
||||
HardDrive,
|
||||
Disc,
|
||||
Search,
|
||||
Filter,
|
||||
SlidersHorizontal
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import Image from 'next/image';
|
||||
import { Album, Playlist } from '@/lib/navidrome';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { OfflineManagement } from './OfflineManagement';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
// Helper functions
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return 'Never';
|
||||
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Album card for selection
|
||||
function AlbumSelectionCard({
|
||||
album,
|
||||
isSelected,
|
||||
onToggleSelection,
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
estimatedSize
|
||||
}: {
|
||||
album: Album;
|
||||
isSelected: boolean;
|
||||
onToggleSelection: () => void;
|
||||
isDownloading: boolean;
|
||||
downloadProgress?: number;
|
||||
estimatedSize: string;
|
||||
}) {
|
||||
const { api } = useNavidrome();
|
||||
|
||||
return (
|
||||
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
|
||||
<div className="flex p-3">
|
||||
<div className="shrink-0">
|
||||
<Image
|
||||
src={album.coverArt ? (api?.getCoverArtUrl(album.coverArt) || '/default-user.jpg') : '/default-user.jpg'}
|
||||
alt={album.name}
|
||||
width={60}
|
||||
height={60}
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<h4 className="font-medium truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-muted-foreground">{album.songCount} songs • {estimatedSize}</span>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && downloadProgress !== undefined && (
|
||||
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Playlist selection card
|
||||
function PlaylistSelectionCard({
|
||||
playlist,
|
||||
isSelected,
|
||||
onToggleSelection,
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
estimatedSize
|
||||
}: {
|
||||
playlist: Playlist;
|
||||
isSelected: boolean;
|
||||
onToggleSelection: () => void;
|
||||
isDownloading: boolean;
|
||||
downloadProgress?: number;
|
||||
estimatedSize: string;
|
||||
}) {
|
||||
const { api } = useNavidrome();
|
||||
|
||||
return (
|
||||
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
|
||||
<div className="flex p-3">
|
||||
<div className="shrink-0">
|
||||
<div className="w-[60px] h-[60px] rounded-md bg-accent flex items-center justify-center">
|
||||
<List className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<h4 className="font-medium truncate">{playlist.name}</h4>
|
||||
<p className="text-sm text-muted-foreground truncate">by {playlist.owner}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-muted-foreground">{playlist.songCount} songs • {estimatedSize}</span>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && downloadProgress !== undefined && (
|
||||
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EnhancedOfflineManager() {
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState({
|
||||
albums: false,
|
||||
playlists: false
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(new Set());
|
||||
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string>>(new Set());
|
||||
const [downloadingItems, setDownloadingItems] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Filter state
|
||||
const [sortBy, setSortBy] = useState('recent');
|
||||
const [filtersVisible, setFiltersVisible] = useState(false);
|
||||
|
||||
const offline = useOfflineLibrary();
|
||||
const { api } = useNavidrome();
|
||||
|
||||
// Load albums and playlists
|
||||
// ...existing code...
|
||||
|
||||
// ...existing code...
|
||||
// Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists
|
||||
|
||||
// Load albums data
|
||||
const loadAlbums = async () => {
|
||||
setLoading(prev => ({ ...prev, albums: true }));
|
||||
try {
|
||||
const albumData = await offline.getAlbums();
|
||||
setAlbums(albumData);
|
||||
|
||||
// Load previously selected albums from localStorage
|
||||
const savedSelections = localStorage.getItem('navidrome-offline-albums');
|
||||
if (savedSelections) {
|
||||
setSelectedAlbums(new Set(JSON.parse(savedSelections)));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load albums:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load albums. Please try again.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, albums: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Load playlists data
|
||||
const loadPlaylists = async () => {
|
||||
setLoading(prev => ({ ...prev, playlists: true }));
|
||||
try {
|
||||
const playlistData = await offline.getPlaylists();
|
||||
setPlaylists(playlistData);
|
||||
|
||||
// Load previously selected playlists from localStorage
|
||||
const savedSelections = localStorage.getItem('navidrome-offline-playlists');
|
||||
if (savedSelections) {
|
||||
setSelectedPlaylists(new Set(JSON.parse(savedSelections)));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlists:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load playlists. Please try again.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, playlists: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle album selection
|
||||
const toggleAlbumSelection = (albumId: string) => {
|
||||
setSelectedAlbums(prev => {
|
||||
const newSelection = new Set(prev);
|
||||
if (newSelection.has(albumId)) {
|
||||
newSelection.delete(albumId);
|
||||
} else {
|
||||
newSelection.add(albumId);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection]));
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle playlist selection
|
||||
const togglePlaylistSelection = (playlistId: string) => {
|
||||
setSelectedPlaylists(prev => {
|
||||
const newSelection = new Set(prev);
|
||||
if (newSelection.has(playlistId)) {
|
||||
newSelection.delete(playlistId);
|
||||
} else {
|
||||
newSelection.add(playlistId);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection]));
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
// Download selected items
|
||||
const downloadSelected = async () => {
|
||||
// Mock implementation - in a real implementation, you'd integrate with the download system
|
||||
const selectedIds = [...selectedAlbums, ...selectedPlaylists];
|
||||
if (selectedIds.length === 0) {
|
||||
toast({
|
||||
title: 'No items selected',
|
||||
description: 'Please select albums or playlists to download.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Download Started',
|
||||
description: `Downloading ${selectedIds.length} items for offline use.`,
|
||||
});
|
||||
|
||||
// Mock download progress
|
||||
const downloadMap = new Map<string, number>();
|
||||
selectedIds.forEach(id => downloadMap.set(id, 0));
|
||||
setDownloadingItems(downloadMap);
|
||||
|
||||
// Simulate download progress
|
||||
const interval = setInterval(() => {
|
||||
setDownloadingItems(prev => {
|
||||
const updated = new Map(prev);
|
||||
let allComplete = true;
|
||||
|
||||
for (const [id, progress] of prev.entries()) {
|
||||
if (progress < 100) {
|
||||
updated.set(id, Math.min(progress + Math.random() * 10, 100));
|
||||
allComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allComplete) {
|
||||
clearInterval(interval);
|
||||
toast({
|
||||
title: 'Download Complete',
|
||||
description: `${selectedIds.length} items are now available offline.`,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setDownloadingItems(new Map());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Filter and sort albums
|
||||
const filteredAlbums = albums
|
||||
.filter(album => {
|
||||
if (!searchQuery) return true;
|
||||
return album.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
album.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'artist':
|
||||
return a.artist.localeCompare(b.artist);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort playlists
|
||||
const filteredPlaylists = playlists
|
||||
.filter(playlist => {
|
||||
if (!searchQuery) return true;
|
||||
return playlist.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime();
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate album size (mock implementation)
|
||||
const estimateSize = (songCount: number) => {
|
||||
const averageSongSizeMB = 8;
|
||||
const totalSizeMB = songCount * averageSongSizeMB;
|
||||
if (totalSizeMB > 1000) {
|
||||
return `${(totalSizeMB / 1000).toFixed(1)} GB`;
|
||||
}
|
||||
return `${totalSizeMB.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="space-y-4"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="albums">Albums</TabsTrigger>
|
||||
<TabsTrigger value="playlists">Playlists</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OfflineManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="albums" className="space-y-4">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Disc className="h-5 w-5" />
|
||||
Select Albums
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose albums to make available offline
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search albums..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtersVisible && (
|
||||
<div className="p-3 border rounded-md bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Sort By</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={sortBy === 'recent' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('recent')}
|
||||
>
|
||||
Recent
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === 'name' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('name')}
|
||||
>
|
||||
Name
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === 'artist' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('artist')}
|
||||
>
|
||||
Artist
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedAlbums(new Set())}
|
||||
disabled={selectedAlbums.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
|
||||
{loading.albums ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<div className="flex p-3">
|
||||
<Skeleton className="h-[60px] w-[60px] rounded-md" />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton className="h-5 w-2/3 mb-1" />
|
||||
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : filteredAlbums.length > 0 ? (
|
||||
filteredAlbums.map(album => (
|
||||
<AlbumSelectionCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
isSelected={selectedAlbums.has(album.id)}
|
||||
onToggleSelection={() => toggleAlbumSelection(album.id)}
|
||||
isDownloading={downloadingItems.has(album.id)}
|
||||
downloadProgress={downloadingItems.get(album.id)}
|
||||
estimatedSize={estimateSize(album.songCount)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Disc className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'No albums found matching your search' : 'No albums available'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={downloadSelected}
|
||||
disabled={selectedAlbums.size === 0 || downloadingItems.size > 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {selectedAlbums.size} Selected Album{selectedAlbums.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="playlists" className="space-y-4">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<List className="h-5 w-5" />
|
||||
Select Playlists
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose playlists to make available offline
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search playlists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtersVisible && (
|
||||
<div className="p-3 border rounded-md bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Sort By</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={sortBy === 'recent' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('recent')}
|
||||
>
|
||||
Recent
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === 'name' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSortBy('name')}
|
||||
>
|
||||
Name
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedPlaylists(new Set())}
|
||||
disabled={selectedPlaylists.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
|
||||
{loading.playlists ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<div className="flex p-3">
|
||||
<Skeleton className="h-[60px] w-[60px] rounded-md" />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton className="h-5 w-2/3 mb-1" />
|
||||
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : filteredPlaylists.length > 0 ? (
|
||||
filteredPlaylists.map(playlist => (
|
||||
<PlaylistSelectionCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
isSelected={selectedPlaylists.has(playlist.id)}
|
||||
onToggleSelection={() => togglePlaylistSelection(playlist.id)}
|
||||
isDownloading={downloadingItems.has(playlist.id)}
|
||||
downloadProgress={downloadingItems.get(playlist.id)}
|
||||
estimatedSize={estimateSize(playlist.songCount)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<List className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'No playlists found matching your search' : 'No playlists available'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={downloadSelected}
|
||||
disabled={selectedPlaylists.size === 0 || downloadingItems.size > 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download {selectedPlaylists.size} Selected Playlist{selectedPlaylists.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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,226 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Check, X, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function OfflineIndicator({
|
||||
id,
|
||||
type,
|
||||
className,
|
||||
showLabel = false,
|
||||
size = 'md'
|
||||
}: OfflineIndicatorProps) {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const { checkOfflineStatus, isInitialized } = useOfflineDownloads();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await checkOfflineStatus(id, type);
|
||||
if (mounted) {
|
||||
setIsOffline(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
if (mounted) {
|
||||
setIsOffline(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [id, type, isInitialized, checkOfflineStatus]);
|
||||
|
||||
const iconSize = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5'
|
||||
}[size];
|
||||
|
||||
const textSize = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
}[size];
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1 text-muted-foreground', className)}>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin')} />
|
||||
{showLabel && <span className={textSize}>Checking...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOffline) {
|
||||
return null; // Don't show anything if not downloaded
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1 text-green-600', className)}>
|
||||
<Download className={iconSize} />
|
||||
{showLabel && (
|
||||
<span className={textSize}>
|
||||
{type === 'album' ? 'Album Downloaded' : 'Downloaded'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DownloadButtonProps {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
onDownload?: () => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DownloadButton({
|
||||
id,
|
||||
type,
|
||||
onDownload,
|
||||
className,
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
children
|
||||
}: DownloadButtonProps) {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const {
|
||||
checkOfflineStatus,
|
||||
deleteOfflineContent,
|
||||
isInitialized,
|
||||
downloadProgress
|
||||
} = useOfflineDownloads();
|
||||
|
||||
const isDownloading = downloadProgress.status === 'downloading' || downloadProgress.status === 'starting';
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await checkOfflineStatus(id, type);
|
||||
if (mounted) {
|
||||
setIsOffline(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
if (mounted) {
|
||||
setIsOffline(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [id, type, isInitialized, checkOfflineStatus]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isOffline) {
|
||||
// Remove from offline storage
|
||||
try {
|
||||
await deleteOfflineContent(id, type);
|
||||
setIsOffline(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete offline content:', error);
|
||||
}
|
||||
} else {
|
||||
// Start download
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSize = {
|
||||
sm: 'sm',
|
||||
md: 'default',
|
||||
lg: 'lg'
|
||||
}[size] as 'sm' | 'default' | 'lg';
|
||||
|
||||
const iconSize = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5'
|
||||
}[size];
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={buttonSize}
|
||||
disabled
|
||||
className={className}
|
||||
>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
|
||||
{children || 'Checking...'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={buttonSize}
|
||||
onClick={handleClick}
|
||||
disabled={isDownloading}
|
||||
className={className}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
|
||||
{children || 'Downloading...'}
|
||||
</>
|
||||
) : isOffline ? (
|
||||
<>
|
||||
<X className={cn(iconSize, 'mr-2')} />
|
||||
{children || 'Remove Download'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className={cn(iconSize, 'mr-2')} />
|
||||
{children || 'Download'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Database,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Music,
|
||||
User,
|
||||
List,
|
||||
HardDrive
|
||||
} from 'lucide-react';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) return 'Never';
|
||||
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
export function OfflineManagement() {
|
||||
const { toast } = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
||||
const {
|
||||
isInitialized,
|
||||
isOnline,
|
||||
isSyncing,
|
||||
lastSync,
|
||||
stats,
|
||||
syncProgress,
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
clearOfflineData,
|
||||
refreshStats
|
||||
} = useOfflineLibrary();
|
||||
|
||||
// Refresh stats periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isInitialized && !isSyncing) {
|
||||
refreshStats();
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isInitialized, isSyncing, refreshStats]);
|
||||
|
||||
const handleFullSync = async () => {
|
||||
try {
|
||||
await syncLibraryFromServer();
|
||||
toast({
|
||||
title: "Sync Complete",
|
||||
description: "Your music library has been synced for offline use.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Full sync failed:', error);
|
||||
toast({
|
||||
title: "Sync Failed",
|
||||
description: "Failed to sync library. Check your connection and try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePendingSync = async () => {
|
||||
try {
|
||||
await syncPendingOperations();
|
||||
toast({
|
||||
title: "Pending Operations Synced",
|
||||
description: "All pending changes have been synced to the server.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Pending sync failed:', error);
|
||||
toast({
|
||||
title: "Sync Failed",
|
||||
description: "Failed to sync pending operations. Will retry automatically when online.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearData = async () => {
|
||||
if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearOfflineData();
|
||||
toast({
|
||||
title: "Offline Data Cleared",
|
||||
description: "All offline music data has been removed.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Clear data failed:', error);
|
||||
toast({
|
||||
title: "Clear Failed",
|
||||
description: "Failed to clear offline data. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Offline Library
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Setting up offline library...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
|
||||
<p className="text-muted-foreground">Initializing offline storage...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connection Status */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isOnline ? (
|
||||
<Wifi className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
Connection Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isOnline ? "default" : "destructive"}>
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isOnline ? "Connected to Navidrome server" : "Working offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats.pendingOperations > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-600">
|
||||
{stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sync Status */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Library Sync
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Keep your offline library up to date
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isSyncing && syncProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{syncProgress.stage}</span>
|
||||
<span>{syncProgress.current}%</span>
|
||||
</div>
|
||||
<Progress value={syncProgress.current} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Last Sync</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(lastSync)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{stats.pendingOperations > 0 && isOnline && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePendingSync}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Sync Pending ({stats.pendingOperations})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleFullSync}
|
||||
disabled={!isOnline || isSyncing}
|
||||
size="sm"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
{isSyncing ? 'Syncing...' : 'Full Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Library Statistics */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Offline Library Stats
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your offline music collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Music className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.albums.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.artists.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Artists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Music className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.songs.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Songs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<List className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.playlists.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">Playlists</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Storage Used</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBytes(stats.storageSize)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Offline Features */}
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>Offline Features</CardTitle>
|
||||
<CardDescription>
|
||||
What works when you're offline
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Browse & Search</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse your synced albums, artists, and search offline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Favorites & Playlists</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Star songs/albums and create playlists (syncs when online)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Play Downloaded Music</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play songs you've downloaded for offline listening
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium">Auto-Sync</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changes sync automatically when you reconnect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="mb-6 break-inside-avoid py-5 border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600 flex items-center gap-2">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete all offline data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Clear All Offline Data</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will remove all synced library data and downloaded audio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearData}
|
||||
disabled={isClearing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{isClearing ? 'Clearing...' : 'Clear Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
|
||||
interface OfflineNavidromeContextType {
|
||||
// Data (offline-first)
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
playlists: Playlist[];
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
albumsLoading: boolean;
|
||||
artistsLoading: boolean;
|
||||
playlistsLoading: boolean;
|
||||
|
||||
// Connection state
|
||||
isOnline: boolean;
|
||||
isOfflineReady: boolean;
|
||||
|
||||
// Error states
|
||||
error: string | null;
|
||||
|
||||
// Offline sync status
|
||||
isSyncing: boolean;
|
||||
lastSync: Date | null;
|
||||
pendingOperations: number;
|
||||
|
||||
// Methods (offline-aware)
|
||||
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
||||
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>;
|
||||
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>;
|
||||
getPlaylists: () => Promise<Playlist[]>;
|
||||
refreshData: () => Promise<void>;
|
||||
|
||||
// Offline-capable operations
|
||||
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
createPlaylist: (name: string, songIds?: string[]) => Promise<Playlist>;
|
||||
scrobble: (songId: string) => Promise<void>;
|
||||
|
||||
// Sync management
|
||||
syncLibrary: () => Promise<void>;
|
||||
syncPendingOperations: () => Promise<void>;
|
||||
clearOfflineData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
|
||||
|
||||
interface OfflineNavidromeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const OfflineNavidromeProvider: React.FC<OfflineNavidromeProviderProps> = ({ children }) => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false);
|
||||
const [artistsLoading, setArtistsLoading] = useState(false);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use the original Navidrome context for online operations
|
||||
const originalNavidrome = useNavidrome();
|
||||
|
||||
// Use offline library for offline operations
|
||||
const {
|
||||
isInitialized: isOfflineReady,
|
||||
isOnline,
|
||||
isSyncing,
|
||||
lastSync,
|
||||
stats,
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations: syncPendingOps,
|
||||
getAlbums: getAlbumsOffline,
|
||||
getArtists: getArtistsOffline,
|
||||
getAlbum: getAlbumOffline,
|
||||
getPlaylists: getPlaylistsOffline,
|
||||
searchOffline,
|
||||
starOffline,
|
||||
unstarOffline,
|
||||
createPlaylistOffline,
|
||||
scrobbleOffline,
|
||||
clearOfflineData: clearOfflineDataInternal,
|
||||
refreshStats
|
||||
} = useOfflineLibrary();
|
||||
|
||||
const isLoading = albumsLoading || artistsLoading || playlistsLoading;
|
||||
const pendingOperations = stats.pendingOperations;
|
||||
|
||||
// Load initial data (offline-first approach)
|
||||
const loadAlbums = useCallback(async () => {
|
||||
setAlbumsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const albumData = await getAlbumsOffline();
|
||||
setAlbums(albumData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load albums:', err);
|
||||
setError('Failed to load albums');
|
||||
} finally {
|
||||
setAlbumsLoading(false);
|
||||
}
|
||||
}, [getAlbumsOffline]);
|
||||
|
||||
const loadArtists = useCallback(async () => {
|
||||
setArtistsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const artistData = await getArtistsOffline();
|
||||
setArtists(artistData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load artists:', err);
|
||||
setError('Failed to load artists');
|
||||
} finally {
|
||||
setArtistsLoading(false);
|
||||
}
|
||||
}, [getArtistsOffline]);
|
||||
|
||||
const loadPlaylists = useCallback(async () => {
|
||||
setPlaylistsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const playlistData = await getPlaylistsOffline();
|
||||
setPlaylists(playlistData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlists:', err);
|
||||
setError('Failed to load playlists');
|
||||
} finally {
|
||||
setPlaylistsLoading(false);
|
||||
}
|
||||
}, [getPlaylistsOffline]);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]);
|
||||
await refreshStats();
|
||||
}, [loadAlbums, loadArtists, loadPlaylists, refreshStats]);
|
||||
|
||||
// Initialize data when offline library is ready
|
||||
useEffect(() => {
|
||||
if (isOfflineReady) {
|
||||
refreshData();
|
||||
}
|
||||
}, [isOfflineReady, refreshData]);
|
||||
|
||||
// Auto-sync when coming back online
|
||||
useEffect(() => {
|
||||
if (isOnline && isOfflineReady && pendingOperations > 0) {
|
||||
console.log('Back online with pending operations, starting sync...');
|
||||
syncPendingOps();
|
||||
}
|
||||
}, [isOnline, isOfflineReady, pendingOperations, syncPendingOps]);
|
||||
|
||||
// Offline-first methods
|
||||
const searchMusic = useCallback(async (query: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await searchOffline(query);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
setError('Search failed');
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
}, [searchOffline]);
|
||||
|
||||
const getAlbum = useCallback(async (albumId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await getAlbumOffline(albumId);
|
||||
} catch (err) {
|
||||
console.error('Failed to get album:', err);
|
||||
setError('Failed to get album');
|
||||
return null;
|
||||
}
|
||||
}, [getAlbumOffline]);
|
||||
|
||||
const getArtist = useCallback(async (artistId: string): Promise<{ artist: Artist; albums: Album[] } | null> => {
|
||||
setError(null);
|
||||
try {
|
||||
// For now, use the original implementation if online, or search offline
|
||||
if (isOnline && originalNavidrome.api) {
|
||||
return await originalNavidrome.getArtist(artistId);
|
||||
} else {
|
||||
// Try to find artist in offline data
|
||||
const allArtists = await getArtistsOffline();
|
||||
const artist = allArtists.find(a => a.id === artistId);
|
||||
if (!artist) return null;
|
||||
|
||||
const allAlbums = await getAlbumsOffline();
|
||||
const artistAlbums = allAlbums.filter(a => a.artistId === artistId);
|
||||
|
||||
return { artist, albums: artistAlbums };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get artist:', err);
|
||||
setError('Failed to get artist');
|
||||
return null;
|
||||
}
|
||||
}, [isOnline, originalNavidrome, getArtistsOffline, getAlbumsOffline]);
|
||||
|
||||
const getPlaylistsWrapper = useCallback(async (): Promise<Playlist[]> => {
|
||||
try {
|
||||
return await getPlaylistsOffline();
|
||||
} catch (err) {
|
||||
console.error('Failed to get playlists:', err);
|
||||
return [];
|
||||
}
|
||||
}, [getPlaylistsOffline]);
|
||||
|
||||
// Offline-capable operations
|
||||
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
setError(null);
|
||||
try {
|
||||
await starOffline(id, type);
|
||||
// Refresh relevant data
|
||||
if (type === 'album') {
|
||||
await loadAlbums();
|
||||
} else if (type === 'artist') {
|
||||
await loadArtists();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to star item:', err);
|
||||
setError('Failed to star item');
|
||||
throw err;
|
||||
}
|
||||
}, [starOffline, loadAlbums, loadArtists]);
|
||||
|
||||
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
setError(null);
|
||||
try {
|
||||
await unstarOffline(id, type);
|
||||
// Refresh relevant data
|
||||
if (type === 'album') {
|
||||
await loadAlbums();
|
||||
} else if (type === 'artist') {
|
||||
await loadArtists();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to unstar item:', err);
|
||||
setError('Failed to unstar item');
|
||||
throw err;
|
||||
}
|
||||
}, [unstarOffline, loadAlbums, loadArtists]);
|
||||
|
||||
const createPlaylist = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
|
||||
setError(null);
|
||||
try {
|
||||
const playlist = await createPlaylistOffline(name, songIds);
|
||||
await loadPlaylists(); // Refresh playlists
|
||||
return playlist;
|
||||
} catch (err) {
|
||||
console.error('Failed to create playlist:', err);
|
||||
setError('Failed to create playlist');
|
||||
throw err;
|
||||
}
|
||||
}, [createPlaylistOffline, loadPlaylists]);
|
||||
|
||||
const scrobble = useCallback(async (songId: string) => {
|
||||
try {
|
||||
await scrobbleOffline(songId);
|
||||
} catch (err) {
|
||||
console.error('Failed to scrobble:', err);
|
||||
// Don't set error state for scrobbling failures as they're not critical
|
||||
}
|
||||
}, [scrobbleOffline]);
|
||||
|
||||
// Sync management
|
||||
const syncLibrary = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await syncLibraryFromServer();
|
||||
await refreshData(); // Refresh local state after sync
|
||||
} catch (err) {
|
||||
console.error('Library sync failed:', err);
|
||||
setError('Library sync failed');
|
||||
throw err;
|
||||
}
|
||||
}, [syncLibraryFromServer, refreshData]);
|
||||
|
||||
const syncPendingOperations = useCallback(async () => {
|
||||
try {
|
||||
await syncPendingOps();
|
||||
await refreshStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to sync pending operations:', err);
|
||||
// Don't throw or set error for pending operations sync
|
||||
}
|
||||
}, [syncPendingOps, refreshStats]);
|
||||
|
||||
const clearOfflineData = useCallback(async () => {
|
||||
try {
|
||||
await clearOfflineDataInternal();
|
||||
setAlbums([]);
|
||||
setArtists([]);
|
||||
setPlaylists([]);
|
||||
} catch (err) {
|
||||
console.error('Failed to clear offline data:', err);
|
||||
setError('Failed to clear offline data');
|
||||
throw err;
|
||||
}
|
||||
}, [clearOfflineDataInternal]);
|
||||
|
||||
const value: OfflineNavidromeContextType = {
|
||||
// Data
|
||||
albums,
|
||||
artists,
|
||||
playlists,
|
||||
|
||||
// Loading states
|
||||
isLoading,
|
||||
albumsLoading,
|
||||
artistsLoading,
|
||||
playlistsLoading,
|
||||
|
||||
// Connection state
|
||||
isOnline,
|
||||
isOfflineReady,
|
||||
|
||||
// Error state
|
||||
error,
|
||||
|
||||
// Offline sync status
|
||||
isSyncing,
|
||||
lastSync,
|
||||
pendingOperations,
|
||||
|
||||
// Methods
|
||||
searchMusic,
|
||||
getAlbum,
|
||||
getArtist,
|
||||
getPlaylists: getPlaylistsWrapper,
|
||||
refreshData,
|
||||
|
||||
// Offline-capable operations
|
||||
starItem,
|
||||
unstarItem,
|
||||
createPlaylist,
|
||||
scrobble,
|
||||
|
||||
// Sync management
|
||||
syncLibrary,
|
||||
syncPendingOperations,
|
||||
clearOfflineData
|
||||
};
|
||||
|
||||
return (
|
||||
<OfflineNavidromeContext.Provider value={value}>
|
||||
{children}
|
||||
</OfflineNavidromeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
|
||||
const context = useContext(OfflineNavidromeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,281 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
||||
import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
|
||||
interface OfflineNavidromeContextType {
|
||||
// All the original NavidromeContext methods but with offline-first behavior
|
||||
getAlbums: (starred?: boolean) => Promise<Album[]>;
|
||||
getArtists: (starred?: boolean) => Promise<Artist[]>;
|
||||
getSongs: (albumId?: string, artistId?: string) => Promise<Song[]>;
|
||||
getPlaylists: () => Promise<Playlist[]>;
|
||||
|
||||
// Offline-aware operations
|
||||
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
|
||||
createPlaylist: (name: string, songIds?: string[]) => Promise<void>;
|
||||
updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise<void>;
|
||||
deletePlaylist: (id: string) => Promise<void>;
|
||||
scrobble: (songId: string) => Promise<void>;
|
||||
|
||||
// Offline state
|
||||
isOfflineMode: boolean;
|
||||
hasPendingOperations: boolean;
|
||||
lastSync: Date | null;
|
||||
}
|
||||
|
||||
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
|
||||
|
||||
interface OfflineNavidromeProviderInnerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Inner component that has access to both contexts
|
||||
const OfflineNavidromeProviderInner: React.FC<OfflineNavidromeProviderInnerProps> = ({ children }) => {
|
||||
const navidromeContext = useNavidrome();
|
||||
const offlineLibrary = useOfflineLibrary();
|
||||
|
||||
// Offline-first data retrieval methods
|
||||
const getAlbums = async (starred?: boolean): Promise<Album[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
// Offline mode - get from IndexedDB
|
||||
return await offlineLibrary.getAlbums(starred);
|
||||
}
|
||||
|
||||
try {
|
||||
// Online mode - try server first, fallback to offline
|
||||
const albums = starred
|
||||
? await navidromeContext.api.getAlbums('starred', 1000)
|
||||
: await navidromeContext.api.getAlbums('alphabeticalByName', 1000);
|
||||
return albums;
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getAlbums(starred);
|
||||
}
|
||||
};
|
||||
|
||||
const getArtists = async (starred?: boolean): Promise<Artist[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
return await offlineLibrary.getArtists(starred);
|
||||
}
|
||||
|
||||
try {
|
||||
const artists = await navidromeContext.api.getArtists();
|
||||
if (starred) {
|
||||
// Filter starred artists from the full list
|
||||
const starredData = await navidromeContext.api.getStarred2();
|
||||
const starredArtistIds = new Set(starredData.starred2.artist?.map(a => a.id) || []);
|
||||
return artists.filter(artist => starredArtistIds.has(artist.id));
|
||||
}
|
||||
return artists;
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getArtists(starred);
|
||||
}
|
||||
};
|
||||
|
||||
const getSongs = async (albumId?: string, artistId?: string): Promise<Song[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
return await offlineLibrary.getSongs(albumId, artistId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (albumId) {
|
||||
const { songs } = await navidromeContext.api.getAlbum(albumId);
|
||||
return songs;
|
||||
} else if (artistId) {
|
||||
const { albums } = await navidromeContext.api.getArtist(artistId);
|
||||
const allSongs: Song[] = [];
|
||||
for (const album of albums) {
|
||||
const { songs } = await navidromeContext.api.getAlbum(album.id);
|
||||
allSongs.push(...songs);
|
||||
}
|
||||
return allSongs;
|
||||
} else {
|
||||
return await navidromeContext.getAllSongs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getSongs(albumId, artistId);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaylists = async (): Promise<Playlist[]> => {
|
||||
if (!offlineLibrary.isOnline || !navidromeContext.api) {
|
||||
return await offlineLibrary.getPlaylists();
|
||||
}
|
||||
|
||||
try {
|
||||
return await navidromeContext.api.getPlaylists();
|
||||
} catch (error) {
|
||||
console.warn('Server request failed, falling back to offline data:', error);
|
||||
return await offlineLibrary.getPlaylists();
|
||||
}
|
||||
};
|
||||
|
||||
// Offline-aware operations (queue for sync when offline)
|
||||
const starItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.starItem(id, type);
|
||||
// Update offline data immediately
|
||||
await offlineLibrary.starOffline(id, type);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server star failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for sync when back online
|
||||
await offlineLibrary.starOffline(id, type);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'star',
|
||||
entityType: type,
|
||||
entityId: id,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
const unstarItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.unstarItem(id, type);
|
||||
await offlineLibrary.unstarOffline(id, type);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server unstar failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.unstarOffline(id, type);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'unstar',
|
||||
entityType: type,
|
||||
entityId: id,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
const createPlaylist = async (name: string, songIds?: string[]): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
const playlist = await navidromeContext.createPlaylist(name, songIds);
|
||||
await offlineLibrary.createPlaylistOffline(name, songIds || []);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server playlist creation failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create offline
|
||||
await offlineLibrary.createPlaylistOffline(name, songIds || []);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'create_playlist',
|
||||
entityType: 'playlist',
|
||||
entityId: 'temp-' + Date.now(),
|
||||
data: { name, songIds: songIds || [] }
|
||||
});
|
||||
};
|
||||
|
||||
const updatePlaylist = async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.updatePlaylist(id, name, comment, songIds);
|
||||
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server playlist update failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'update_playlist',
|
||||
entityType: 'playlist',
|
||||
entityId: id,
|
||||
data: { name, comment, songIds }
|
||||
});
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id: string): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.deletePlaylist(id);
|
||||
await offlineLibrary.deletePlaylistOffline(id);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server playlist deletion failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.deletePlaylistOffline(id);
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'delete_playlist',
|
||||
entityType: 'playlist',
|
||||
entityId: id,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
const scrobble = async (songId: string): Promise<void> => {
|
||||
if (offlineLibrary.isOnline && navidromeContext.api) {
|
||||
try {
|
||||
await navidromeContext.scrobble(songId);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Server scrobble failed, queuing for sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await offlineLibrary.queueSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: { timestamp: Date.now() }
|
||||
});
|
||||
};
|
||||
|
||||
const contextValue: OfflineNavidromeContextType = {
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getSongs,
|
||||
getPlaylists,
|
||||
starItem,
|
||||
unstarItem,
|
||||
createPlaylist,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
scrobble,
|
||||
isOfflineMode: !offlineLibrary.isOnline,
|
||||
hasPendingOperations: offlineLibrary.stats.pendingOperations > 0,
|
||||
lastSync: offlineLibrary.lastSync
|
||||
};
|
||||
|
||||
return (
|
||||
<OfflineNavidromeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</OfflineNavidromeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Main provider component
|
||||
export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<NavidromeProvider>
|
||||
<OfflineNavidromeProviderInner>
|
||||
{children}
|
||||
</OfflineNavidromeProviderInner>
|
||||
</NavidromeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use the offline-aware Navidrome context
|
||||
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
|
||||
const context = useContext(OfflineNavidromeContext);
|
||||
if (!context) {
|
||||
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { Wifi, WifiOff, Download, Clock } from 'lucide-react';
|
||||
|
||||
export function OfflineStatusIndicator() {
|
||||
const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary();
|
||||
|
||||
if (!isOnline) {
|
||||
return (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<WifiOff size={12} />
|
||||
Offline Mode
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSyncing) {
|
||||
return (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Download size={12} className="animate-bounce" />
|
||||
Syncing...
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.pendingOperations > 0) {
|
||||
return (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{stats.pendingOperations} pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Wifi size={12} />
|
||||
Online
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function OfflineLibraryStats() {
|
||||
const { stats, lastSync } = useOfflineLibrary();
|
||||
|
||||
if (!stats.albums && !stats.songs && !stats.artists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>
|
||||
📀 {stats.albums} albums • 🎵 {stats.songs} songs • 👤 {stats.artists} artists
|
||||
</div>
|
||||
{lastSync && (
|
||||
<div>
|
||||
Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
|
||||
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
|
||||
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
|
||||
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
|
||||
import { ThemeProvider } from "../components/ThemeProvider";
|
||||
import { WhatsNewPopup } from "../components/WhatsNewPopup";
|
||||
@@ -105,7 +105,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
||||
<ThemeColorHandler />
|
||||
<ServiceWorkerRegistration />
|
||||
<NavidromeConfigProvider>
|
||||
<OfflineNavidromeProvider>
|
||||
<NavidromeProvider>
|
||||
<NavidromeErrorBoundary>
|
||||
<AudioPlayerProvider>
|
||||
<GlobalSearchProvider>
|
||||
@@ -116,7 +116,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
||||
</GlobalSearchProvider>
|
||||
</AudioPlayerProvider>
|
||||
</NavidromeErrorBoundary>
|
||||
</OfflineNavidromeProvider>
|
||||
</NavidromeProvider>
|
||||
</NavidromeConfigProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,7 +17,7 @@ interface SongRecommendationsProps {
|
||||
}
|
||||
|
||||
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
const offline = useOfflineNavidrome();
|
||||
const { api } = useNavidrome();
|
||||
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||
const isMobile = useIsMobile();
|
||||
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
||||
@@ -45,10 +45,9 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = getNavidromeAPI();
|
||||
const isOnline = !offline.isOfflineMode && !!api;
|
||||
|
||||
if (isOnline && api) {
|
||||
// Online: use server-side recommendations
|
||||
if (api) {
|
||||
// Use server-side recommendations
|
||||
const randomAlbums = await api.getAlbums('random', 10);
|
||||
if (isMobile) {
|
||||
setRecommendedAlbums(randomAlbums.slice(0, 6));
|
||||
@@ -69,29 +68,6 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
|
||||
setSongStates(states);
|
||||
}
|
||||
} else {
|
||||
// Offline: use cached library
|
||||
const albums = await offline.getAlbums(false);
|
||||
const shuffledAlbums = [...(albums || [])].sort(() => Math.random() - 0.5);
|
||||
if (isMobile) {
|
||||
setRecommendedAlbums(shuffledAlbums.slice(0, 6));
|
||||
} else {
|
||||
const pick = shuffledAlbums.slice(0, 3);
|
||||
const allSongs: Song[] = [];
|
||||
for (const a of pick) {
|
||||
try {
|
||||
const songs = await offline.getSongs(a.id);
|
||||
allSongs.push(...songs);
|
||||
} catch (e) {
|
||||
// ignore per-album errors
|
||||
}
|
||||
}
|
||||
const recommendations = allSongs.sort(() => Math.random() - 0.5).slice(0, 6);
|
||||
setRecommendedSongs(recommendations);
|
||||
const states: Record<string, boolean> = {};
|
||||
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
|
||||
setSongStates(states);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load recommendations:', error);
|
||||
@@ -103,13 +79,15 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
};
|
||||
|
||||
loadRecommendations();
|
||||
}, [offline, isMobile]);
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePlaySong = async (song: Song) => {
|
||||
try {
|
||||
const api = getNavidromeAPI();
|
||||
const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`;
|
||||
const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
|
||||
if (!api) return;
|
||||
|
||||
const url = api.getStreamUrl(song.id);
|
||||
const coverArt = song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
|
||||
const track = {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
@@ -131,16 +109,13 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
try {
|
||||
const api = getNavidromeAPI();
|
||||
let albumSongs: Song[] = [];
|
||||
if (api) {
|
||||
albumSongs = await api.getAlbumSongs(album.id);
|
||||
} else {
|
||||
albumSongs = await offline.getSongs(album.id);
|
||||
}
|
||||
if (!api) return;
|
||||
|
||||
const albumSongs = await api.getAlbumSongs(album.id);
|
||||
if (albumSongs.length > 0) {
|
||||
const first = albumSongs[0];
|
||||
const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`;
|
||||
const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
|
||||
const url = api.getStreamUrl(first.id);
|
||||
const coverArt = first.coverArt ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
|
||||
const track = {
|
||||
id: first.id,
|
||||
name: first.title,
|
||||
@@ -246,7 +221,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
className="group cursor-pointer block"
|
||||
>
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
||||
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
|
||||
{album.coverArt && getNavidromeAPI() ? (
|
||||
<Image
|
||||
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
|
||||
alt={album.name}
|
||||
@@ -305,7 +280,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
<CardContent className="px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
|
||||
{song.coverArt && getNavidromeAPI() ? (
|
||||
<>
|
||||
<Image
|
||||
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
|
||||
|
||||
@@ -5,10 +5,34 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// Current app version from package.json
|
||||
const APP_VERSION = '2025.07.31';
|
||||
const APP_VERSION = '2026.01.24';
|
||||
|
||||
// Changelog data - add new versions at the top
|
||||
const CHANGELOG = [
|
||||
{
|
||||
version: '2026.01.24',
|
||||
title: 'January 2026 Update',
|
||||
changes: [
|
||||
'Improved SortableQueueItem component with enhanced click handling and styling',
|
||||
'Added keyboard shortcuts and queue management features',
|
||||
'Added ListeningStreakCard component for tracking listening streaks',
|
||||
'Moved service worker registration to dedicated component for improved client-side handling',
|
||||
'Implemented Auto-Tagging Settings and MusicBrainz integration',
|
||||
'Enhanced audio settings with ReplayGain, crossfade, and equalizer presets',
|
||||
'Added AudioSettingsDialog component',
|
||||
'Updated cover art retrieval to use higher resolution images',
|
||||
'Enhanced UI with Framer Motion animations for album artwork and artist icons',
|
||||
'Added page transition animations and notification settings for audio playback',
|
||||
'Updated all npm subdependencies to latest minor versions',
|
||||
],
|
||||
fixes: [
|
||||
'Updated README formatting and improved content clarity',
|
||||
],
|
||||
breaking: [
|
||||
'Removed PostHog analytics tracking',
|
||||
'Removed all offline download and caching functionality',
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '2025.07.31',
|
||||
title: 'July End of Month Update',
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "../../components/ui/context-menu"
|
||||
|
||||
import { useNavidrome } from "./NavidromeContext"
|
||||
import { useOfflineNavidrome } from "./OfflineNavidromeProvider"
|
||||
import Link from "next/link";
|
||||
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
||||
@@ -28,7 +27,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { ArtistIcon } from "@/app/components/artist-icon";
|
||||
import { Heart, Music, Disc, Mic, Play, Download } from "lucide-react";
|
||||
import { Album, Artist, Song } from "@/lib/navidrome";
|
||||
import { OfflineIndicator } from "@/app/components/OfflineIndicator";
|
||||
|
||||
interface AlbumArtworkProps extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -38,6 +36,7 @@ interface AlbumArtworkProps extends Omit<
|
||||
aspectRatio?: "portrait" | "square"
|
||||
width?: number
|
||||
height?: number
|
||||
loading?: 'eager' | 'lazy'
|
||||
}
|
||||
|
||||
export function AlbumArtwork({
|
||||
@@ -45,11 +44,11 @@ export function AlbumArtwork({
|
||||
aspectRatio = "portrait",
|
||||
width,
|
||||
height,
|
||||
loading = 'lazy',
|
||||
className,
|
||||
...props
|
||||
}: AlbumArtworkProps) {
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const offline = useOfflineNavidrome();
|
||||
const router = useRouter();
|
||||
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
|
||||
const { playlists, starItem, unstarItem } = useNavidrome();
|
||||
@@ -153,7 +152,7 @@ export function AlbumArtwork({
|
||||
<ContextMenuTrigger>
|
||||
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api && !offline.isOfflineMode ? (
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
alt={album.name}
|
||||
@@ -163,7 +162,7 @@ export function AlbumArtwork({
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
@@ -173,16 +172,6 @@ export function AlbumArtwork({
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
|
||||
</div>
|
||||
|
||||
{/* Offline indicator in top-right corner */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<OfflineIndicator
|
||||
id={album.id}
|
||||
type="album"
|
||||
size="sm"
|
||||
className="bg-black/60 text-white rounded-full p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold truncate">
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ArtistIconProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: number
|
||||
imageOnly?: boolean
|
||||
responsive?: boolean
|
||||
loading?: 'eager' | 'lazy'
|
||||
}
|
||||
|
||||
export function ArtistIcon({
|
||||
@@ -34,6 +35,7 @@ export function ArtistIcon({
|
||||
size = 150,
|
||||
imageOnly = false,
|
||||
responsive = false,
|
||||
loading = 'lazy',
|
||||
className,
|
||||
...props
|
||||
}: ArtistIconProps) {
|
||||
@@ -77,6 +79,7 @@ export function ArtistIcon({
|
||||
width={size}
|
||||
height={size}
|
||||
className="w-full h-full object-cover transition-all hover:scale-105"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -116,6 +119,7 @@ export function ArtistIcon({
|
||||
}
|
||||
)}
|
||||
className={isResponsive ? "object-cover" : "object-cover w-full h-full"}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,11 +191,8 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="relative">File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>New</MenubarSubTrigger>
|
||||
<MenubarSubContent className="w-[230px]">
|
||||
<MenubarItem>
|
||||
Playlist <MenubarShortcut>⌘N</MenubarShortcut>
|
||||
<MenubarItem onClick={() => router.push('/library/playlists')}>
|
||||
View Playlists
|
||||
</MenubarItem>
|
||||
<MenubarItem disabled>
|
||||
Playlist from Selection <MenubarShortcut>⇧⌘N</MenubarShortcut>
|
||||
@@ -205,8 +202,6 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
</MenubarItem>
|
||||
<MenubarItem>Playlist Folder</MenubarItem>
|
||||
<MenubarItem disabled>Genius Playlist</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
<MenubarItem>
|
||||
Open Stream URL <MenubarShortcut>⌘U</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
@@ -386,7 +381,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
) : navidromeUrl ? (
|
||||
navidromeUrl
|
||||
) : (
|
||||
<span className="italic text-gray-400">Not set</span>
|
||||
<span className="italic text-gray-400">Auto-configured</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { Play, Plus, User, Disc, History, Trash2 } from 'lucide-react';
|
||||
import ListeningStreakCard from '@/app/components/ListeningStreakCard';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -79,10 +78,6 @@ export default function HistoryPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="mb-6">
|
||||
<ListeningStreakCard />
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||
<TabsContent value="music" className="border-none p-0 outline-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -10,13 +10,15 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, Play, Plus, User, Disc } from 'lucide-react';
|
||||
import { Search, Play, Plus, User, Disc, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import Loading from '@/app/components/loading';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
type SortOption = 'title' | 'artist' | 'album' | 'year' | 'duration' | 'track';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
export default function SongsPage() {
|
||||
const { getAllSongs } = useNavidrome();
|
||||
const { playTrack, addToQueue, currentTrack } = useAudioPlayer();
|
||||
@@ -26,6 +28,7 @@ export default function SongsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('title');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,6 +103,7 @@ export default function SongsPage() {
|
||||
});
|
||||
|
||||
setFilteredSongs(filtered);
|
||||
setCurrentPage(1); // Reset to first page when filters change
|
||||
}, [songs, searchQuery, sortBy, sortDirection]);
|
||||
const handlePlayClick = (song: Song) => {
|
||||
if (!api) {
|
||||
@@ -154,6 +158,24 @@ export default function SongsPage() {
|
||||
return currentTrack?.id === song.id;
|
||||
};
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(filteredSongs.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const paginatedSongs = filteredSongs.slice(startIndex, endIndex);
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -165,7 +187,8 @@ export default function SongsPage() {
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Songs</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredSongs.length} of {songs.length} songs
|
||||
Showing {startIndex + 1}-{Math.min(endIndex, filteredSongs.length)} of {filteredSongs.length} songs
|
||||
{searchQuery && ` (filtered from ${songs.length} total)`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +239,7 @@ export default function SongsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredSongs.map((song, index) => (
|
||||
{paginatedSongs.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
|
||||
@@ -232,7 +255,7 @@ export default function SongsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="group-hover:hidden">{index + 1}</span>
|
||||
<span className="group-hover:hidden">{startIndex + index + 1}</span>
|
||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||
</>
|
||||
)}
|
||||
@@ -298,6 +321,35 @@ export default function SongsPage() {
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredSongs.length > ITEMS_PER_PAGE && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
106
app/page.tsx
106
app/page.tsx
@@ -4,7 +4,7 @@ import { ScrollArea, ScrollBar } from '../components/ui/scroll-area';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import { Tabs, TabsContent } from '../components/ui/tabs';
|
||||
import { AlbumArtwork } from './components/album-artwork';
|
||||
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
|
||||
import { useNavidrome } from './components/NavidromeContext';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useNavidromeConfig } from './components/NavidromeConfigContext';
|
||||
@@ -14,14 +14,11 @@ import { SongRecommendations } from './components/SongRecommendations';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
|
||||
import CompactListeningStreak from './components/CompactListeningStreak';
|
||||
|
||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||
|
||||
function MusicPageContent() {
|
||||
// Offline-first provider (falls back to offline data when not connected)
|
||||
const offline = useOfflineNavidrome();
|
||||
const { api } = useNavidrome();
|
||||
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
|
||||
const searchParams = useSearchParams();
|
||||
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
|
||||
@@ -33,13 +30,14 @@ function MusicPageContent() {
|
||||
const [shortcutProcessed, setShortcutProcessed] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Load albums (offline-first)
|
||||
// Load albums
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
if (!api) return;
|
||||
setAlbumsLoading(true);
|
||||
try {
|
||||
const list = await offline.getAlbums(false);
|
||||
const list = await api.getAlbums('newest', 500);
|
||||
if (!mounted) return;
|
||||
setAllAlbums(list || []);
|
||||
// Split albums into two sections
|
||||
@@ -48,7 +46,7 @@ function MusicPageContent() {
|
||||
setRecentAlbums(recent);
|
||||
setNewestAlbums(newest);
|
||||
} catch (e) {
|
||||
console.error('Failed to load albums (offline-first):', e);
|
||||
console.error('Failed to load albums:', e);
|
||||
if (mounted) {
|
||||
setAllAlbums([]);
|
||||
setRecentAlbums([]);
|
||||
@@ -60,17 +58,18 @@ function MusicPageContent() {
|
||||
};
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, [offline]);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const loadFavoriteAlbums = async () => {
|
||||
if (!api) return;
|
||||
setFavoritesLoading(true);
|
||||
try {
|
||||
const starred = await offline.getAlbums(true);
|
||||
if (mounted) setFavoriteAlbums((starred || []).slice(0, 20));
|
||||
const starred = await api.getAlbums('starred', 20);
|
||||
if (mounted) setFavoriteAlbums(starred || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load favorite albums (offline-first):', error);
|
||||
console.error('Failed to load favorite albums:', error);
|
||||
if (mounted) setFavoriteAlbums([]);
|
||||
} finally {
|
||||
if (mounted) setFavoritesLoading(false);
|
||||
@@ -78,7 +77,7 @@ function MusicPageContent() {
|
||||
};
|
||||
loadFavoriteAlbums();
|
||||
return () => { mounted = false; };
|
||||
}, [offline]);
|
||||
}, [api]);
|
||||
|
||||
// Handle PWA shortcuts
|
||||
useEffect(() => {
|
||||
@@ -115,29 +114,31 @@ function MusicPageContent() {
|
||||
await playAlbum(shuffledAlbums[0].id);
|
||||
|
||||
// Add remaining albums to queue
|
||||
for (let i = 1; i < shuffledAlbums.length; i++) {
|
||||
try {
|
||||
const songs = await offline.getSongs(shuffledAlbums[i].id);
|
||||
const api = getNavidromeAPI();
|
||||
songs.forEach((song: Song) => {
|
||||
addToQueue({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
albumId: song.parent,
|
||||
const navidromeApi = getNavidromeAPI();
|
||||
if (navidromeApi) {
|
||||
for (let i = 1; i < shuffledAlbums.length; i++) {
|
||||
try {
|
||||
const songs = await navidromeApi.getAlbumSongs(shuffledAlbums[i].id);
|
||||
songs.forEach((song: Song) => {
|
||||
addToQueue({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: navidromeApi.getStreamUrl(song.id),
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
albumId: song.parent,
|
||||
duration: song.duration || 0,
|
||||
coverArt: song.coverArt,
|
||||
starred: !!song.starred
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load album tracks (offline-first):', error);
|
||||
console.error('Failed to load album tracks:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'shuffle-favorites':
|
||||
@@ -154,29 +155,31 @@ function MusicPageContent() {
|
||||
await playAlbum(shuffledFavorites[0].id);
|
||||
|
||||
// Add remaining albums to queue
|
||||
for (let i = 1; i < shuffledFavorites.length; i++) {
|
||||
try {
|
||||
const songs = await offline.getSongs(shuffledFavorites[i].id);
|
||||
const api = getNavidromeAPI();
|
||||
songs.forEach((song: Song) => {
|
||||
addToQueue({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
albumId: song.parent,
|
||||
const navidromeApiFav = getNavidromeAPI();
|
||||
if (navidromeApiFav) {
|
||||
for (let i = 1; i < shuffledFavorites.length; i++) {
|
||||
try {
|
||||
const songs = await navidromeApiFav.getAlbumSongs(shuffledFavorites[i].id);
|
||||
songs.forEach((song: Song) => {
|
||||
addToQueue({
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: navidromeApiFav.getStreamUrl(song.id),
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
artistId: song.artistId || '',
|
||||
album: song.album || 'Unknown Album',
|
||||
albumId: song.parent,
|
||||
duration: song.duration || 0,
|
||||
coverArt: song.coverArt,
|
||||
starred: !!song.starred
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load album tracks (offline-first):', error);
|
||||
console.error('Failed to load album tracks:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
setShortcutProcessed(true);
|
||||
@@ -188,7 +191,7 @@ function MusicPageContent() {
|
||||
// Delay to ensure data is loaded
|
||||
const timeout = setTimeout(handleShortcuts, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]);
|
||||
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
|
||||
|
||||
// Try to get user name from navidrome context, fallback to 'user'
|
||||
let userName = '';
|
||||
@@ -201,29 +204,10 @@ function MusicPageContent() {
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 w-full">
|
||||
{/* Connection status (offline indicator) */}
|
||||
{!offline.isOfflineMode ? null : (
|
||||
<div className="mb-4">
|
||||
<OfflineStatusIndicator />
|
||||
</div>
|
||||
)}
|
||||
{/* Offline empty state when nothing is cached */}
|
||||
{offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && (
|
||||
<div className="mb-6 p-4 border rounded-lg bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You are offline and no albums are cached yet. Download albums for offline use from an album page, or open Settings → Offline Library to sync your library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Song Recommendations Section */}
|
||||
<div className="mb-8">
|
||||
<SongRecommendations userName={userName} />
|
||||
</div>
|
||||
|
||||
{/* Listening Streak Section - Only shown when 3+ days streak */}
|
||||
<div className="mb-6">
|
||||
<CompactListeningStreak />
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||
|
||||
@@ -35,7 +35,12 @@ export default function SearchPage() {
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const results = await search2(query);
|
||||
setSearchResults(results);
|
||||
// Limit results to 5 of each type
|
||||
setSearchResults({
|
||||
artists: results.artists.slice(0, 5),
|
||||
albums: results.albums.slice(0, 5),
|
||||
songs: results.songs.slice(0, 5)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults({ artists: [], albums: [], songs: [] });
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
|
||||
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
|
||||
import { SettingsManagement } from '@/app/components/SettingsManagement';
|
||||
import EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager';
|
||||
import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
|
||||
import { Settings, ExternalLink, Tag } from 'lucide-react';
|
||||
@@ -778,11 +777,6 @@ const SettingsPage = () => {
|
||||
<SettingsManagement />
|
||||
</div>
|
||||
|
||||
{/* Offline Library Management */}
|
||||
<div className="break-inside-avoid mb-6">
|
||||
<EnhancedOfflineManager />
|
||||
</div>
|
||||
|
||||
{/* Auto-Tagging Settings */}
|
||||
<div className="break-inside-avoid mb-6">
|
||||
<AutoTaggingSettings />
|
||||
|
||||
46
cliff.toml
Normal file
46
cliff.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
# git-cliff configuration for changelog generation
|
||||
# https://git-cliff.org
|
||||
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
"""
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}{{ commit.scope }}: {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
footer = ""
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
split_commits = false
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactoring" },
|
||||
{ message = "^style", group = "Styling" },
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^chore\\(release\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "Miscellaneous" },
|
||||
]
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
tag_pattern = "v[0-9].*"
|
||||
topo_order = false
|
||||
sort_commits = "oldest"
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
}: React.ComponentProps<typeof PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
<PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
@@ -24,19 +24,19 @@ function ResizablePanelGroup({
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
}: React.ComponentProps<typeof PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
<PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
@@ -49,7 +49,7 @@ function ResizableHandle({
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
</PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
63
docs/rewrite-commits.sh
Executable file
63
docs/rewrite-commits.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# Script to rewrite commits to conventional commit format
|
||||
# WARNING: This rewrites git history. Only use on branches that haven't been shared or after coordinating with team.
|
||||
|
||||
# This script uses git filter-branch to rewrite commit messages
|
||||
# Run from repository root: bash docs/rewrite-commits.sh
|
||||
|
||||
echo "⚠️ WARNING: This will rewrite git history!"
|
||||
echo "This should only be done on the offline-support branch before merging to main."
|
||||
echo ""
|
||||
read -p "Are you sure you want to continue? (yes/no): " confirm
|
||||
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup current branch
|
||||
git branch backup-$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# Set up commit message mapping
|
||||
export FILTER_BRANCH_SQUELCH_WARNING=1
|
||||
|
||||
git filter-branch -f --msg-filter '
|
||||
msg=$(cat)
|
||||
|
||||
# Skip if already has conventional commit prefix
|
||||
if echo "$msg" | grep -qE "^(feat|fix|chore|docs|style|refactor|perf|test|build|ci):"; then
|
||||
echo "$msg"
|
||||
# Convert specific commit messages
|
||||
elif echo "$msg" | grep -q "Use git commit SHA for versioning"; then
|
||||
echo "fix: use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code"
|
||||
elif echo "$msg" | grep -q "Fix menubar, add lazy loading"; then
|
||||
echo "feat: fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists"
|
||||
elif echo "$msg" | grep -q "Add pagination to library/songs"; then
|
||||
echo "feat: add pagination to library/songs and remove listening streaks"
|
||||
elif echo "$msg" | grep -q "Organize documentation"; then
|
||||
echo "chore: organize documentation - move markdown files to docs/ folder"
|
||||
elif echo "$msg" | grep -q "Simplify service worker"; then
|
||||
echo "refactor: simplify service worker by removing offline download functionality"
|
||||
elif echo "$msg" | grep -q "Remove all offline download"; then
|
||||
echo "refactor: remove all offline download and caching functionality"
|
||||
elif echo "$msg" | grep -q "Update pnpm-lock.yaml"; then
|
||||
echo "chore: update pnpm-lock.yaml to match new overrides configuration"
|
||||
elif echo "$msg" | grep -q "Remove PostHog analytics"; then
|
||||
echo "chore: remove PostHog analytics and update dependencies to latest minor versions"
|
||||
elif echo "$msg" | grep -q "Merge pull request"; then
|
||||
echo "chore: $(echo "$msg" | sed "s/^Merge/merge/")"
|
||||
# Default: add chore: prefix to any other commit
|
||||
else
|
||||
first_char=$(echo "$msg" | cut -c1 | tr "[:upper:]" "[:lower:]")
|
||||
rest=$(echo "$msg" | cut -c2-)
|
||||
echo "chore: ${first_char}${rest}"
|
||||
fi
|
||||
' 2025.07.31..HEAD
|
||||
|
||||
echo ""
|
||||
echo "✅ Commits have been rewritten!"
|
||||
echo "⚠️ To update the remote branch, you'll need to force push:"
|
||||
echo " git push origin offline-support --force-with-lease"
|
||||
echo ""
|
||||
echo "If something went wrong, restore from backup:"
|
||||
echo " git reset --hard backup-TIMESTAMP"
|
||||
@@ -15,3 +15,7 @@ printenv | grep NEXT_PUBLIC_ | while read -r line ; do
|
||||
done
|
||||
|
||||
echo "✅ Environment variable replacement complete"
|
||||
echo "🚀 Starting Next.js application..."
|
||||
|
||||
# Execute the command passed as arguments
|
||||
exec "$@"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
|
||||
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
|
||||
import { useOfflineLibrary } from '@/hooks/use-offline-library';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
export interface OfflineTrack extends Track {
|
||||
isOffline?: boolean;
|
||||
offlineUrl?: string;
|
||||
}
|
||||
|
||||
export function useOfflineAudioPlayer() {
|
||||
const {
|
||||
playTrack,
|
||||
addToQueue,
|
||||
currentTrack,
|
||||
...audioPlayerProps
|
||||
} = useAudioPlayer();
|
||||
|
||||
const { isSupported: isOfflineSupported, checkOfflineStatus } = useOfflineDownloads();
|
||||
const { isOnline, scrobbleOffline } = useOfflineLibrary();
|
||||
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
// Convert song to track with offline awareness
|
||||
const songToTrack = useCallback(async (song: Song): Promise<OfflineTrack> => {
|
||||
let track: OfflineTrack = {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
artist: song.artist,
|
||||
album: song.album || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
};
|
||||
|
||||
// Check if song is available offline
|
||||
if (isOfflineSupported) {
|
||||
const offlineStatus = await checkOfflineStatus(song.id, 'song');
|
||||
if (offlineStatus) {
|
||||
track.isOffline = true;
|
||||
track.offlineUrl = `offline-song-${song.id}`;
|
||||
// Prefer offline cached URL to avoid re-streaming even when online
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return track;
|
||||
}, [api, isOfflineSupported, checkOfflineStatus]);
|
||||
|
||||
// Play track with offline fallback
|
||||
const playTrackOffline = useCallback(async (song: Song | OfflineTrack) => {
|
||||
try {
|
||||
let track: OfflineTrack;
|
||||
|
||||
if ('url' in song) {
|
||||
// Already a track
|
||||
track = song as OfflineTrack;
|
||||
} else {
|
||||
// Convert song to track
|
||||
track = await songToTrack(song);
|
||||
}
|
||||
|
||||
// If offline and track has offline URL, use that
|
||||
if (!isOnline && track.isOffline && track.offlineUrl) {
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
|
||||
playTrack(track);
|
||||
|
||||
// Scrobble with offline support
|
||||
scrobbleOffline(track.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to play track:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, scrobbleOffline, isOnline]);
|
||||
|
||||
// Play album with offline awareness
|
||||
const playAlbumOffline = useCallback(async (album: Album, songs: Song[], startIndex: number = 0) => {
|
||||
try {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
// Convert all songs to tracks with offline awareness
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
// Filter to only available tracks (online or offline)
|
||||
const availableTracks = tracks.filter((track: OfflineTrack) => {
|
||||
if (isOnline) return true; // All tracks available when online
|
||||
return track.isOffline; // Only offline tracks when offline
|
||||
});
|
||||
|
||||
if (availableTracks.length === 0) {
|
||||
throw new Error('No tracks available for playback');
|
||||
}
|
||||
|
||||
// Adjust start index if needed
|
||||
const safeStartIndex = Math.min(startIndex, availableTracks.length - 1);
|
||||
|
||||
// Play first track
|
||||
playTrack(availableTracks[safeStartIndex]);
|
||||
|
||||
// Add remaining tracks to queue
|
||||
const remainingTracks = [
|
||||
...availableTracks.slice(safeStartIndex + 1),
|
||||
...availableTracks.slice(0, safeStartIndex)
|
||||
];
|
||||
|
||||
remainingTracks.forEach(track => addToQueue(track));
|
||||
|
||||
// Scrobble first track
|
||||
scrobbleOffline(availableTracks[safeStartIndex].id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to play album offline:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
|
||||
|
||||
// Add track to queue with offline awareness
|
||||
const addToQueueOffline = useCallback(async (song: Song | OfflineTrack) => {
|
||||
try {
|
||||
let track: OfflineTrack;
|
||||
|
||||
if ('url' in song) {
|
||||
track = song as OfflineTrack;
|
||||
} else {
|
||||
track = await songToTrack(song);
|
||||
}
|
||||
|
||||
// Check if track is available
|
||||
if (!isOnline && !track.isOffline) {
|
||||
throw new Error('Track not available offline');
|
||||
}
|
||||
|
||||
// If offline and track has offline URL, use that
|
||||
if (!isOnline && track.isOffline && track.offlineUrl) {
|
||||
track.url = track.offlineUrl;
|
||||
}
|
||||
|
||||
addToQueue(track);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to add track to queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, addToQueue, isOnline]);
|
||||
|
||||
// Shuffle play with offline awareness
|
||||
const shufflePlayOffline = useCallback(async (songs: Song[]) => {
|
||||
try {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
// Convert all songs to tracks
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
// Filter available tracks
|
||||
const availableTracks = tracks.filter((track: OfflineTrack) => {
|
||||
if (isOnline) return true;
|
||||
return track.isOffline;
|
||||
});
|
||||
|
||||
if (availableTracks.length === 0) {
|
||||
throw new Error('No tracks available for shuffle play');
|
||||
}
|
||||
|
||||
// Shuffle the available tracks
|
||||
const shuffledTracks = [...availableTracks].sort(() => Math.random() - 0.5);
|
||||
|
||||
// Play first track
|
||||
playTrack(shuffledTracks[0]);
|
||||
|
||||
// Add remaining tracks to queue
|
||||
shuffledTracks.slice(1).forEach(track => addToQueue(track));
|
||||
|
||||
// Scrobble first track
|
||||
scrobbleOffline(shuffledTracks[0].id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle play offline:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [songToTrack, playTrack, addToQueue, scrobbleOffline, isOnline]);
|
||||
|
||||
// Get availability info for a song
|
||||
const getTrackAvailability = useCallback(async (song: Song): Promise<{
|
||||
isAvailable: boolean;
|
||||
isOffline: boolean;
|
||||
requiresConnection: boolean;
|
||||
}> => {
|
||||
try {
|
||||
const track = await songToTrack(song);
|
||||
|
||||
return {
|
||||
isAvailable: isOnline || !!track.isOffline,
|
||||
isOffline: !!track.isOffline,
|
||||
requiresConnection: !track.isOffline
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check track availability:', error);
|
||||
return {
|
||||
isAvailable: false,
|
||||
isOffline: false,
|
||||
requiresConnection: true
|
||||
};
|
||||
}
|
||||
}, [songToTrack, isOnline]);
|
||||
|
||||
// Get album availability info
|
||||
const getAlbumAvailability = useCallback(async (songs: Song[]): Promise<{
|
||||
totalTracks: number;
|
||||
availableTracks: number;
|
||||
offlineTracks: number;
|
||||
onlineOnlyTracks: number;
|
||||
}> => {
|
||||
try {
|
||||
const tracks = await Promise.all(songs.map(songToTrack));
|
||||
|
||||
const offlineTracks = tracks.filter((t: OfflineTrack) => t.isOffline).length;
|
||||
const onlineOnlyTracks = tracks.filter((t: OfflineTrack) => !t.isOffline).length;
|
||||
const availableTracks = isOnline ? tracks.length : offlineTracks;
|
||||
|
||||
return {
|
||||
totalTracks: tracks.length,
|
||||
availableTracks,
|
||||
offlineTracks,
|
||||
onlineOnlyTracks
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check album availability:', error);
|
||||
return {
|
||||
totalTracks: songs.length,
|
||||
availableTracks: 0,
|
||||
offlineTracks: 0,
|
||||
onlineOnlyTracks: songs.length
|
||||
};
|
||||
}
|
||||
}, [songToTrack, isOnline]);
|
||||
|
||||
// Enhanced track info with offline status
|
||||
const getCurrentTrackInfo = useCallback(() => {
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const offlineTrack = currentTrack as OfflineTrack;
|
||||
|
||||
return {
|
||||
...currentTrack,
|
||||
isAvailableOffline: offlineTrack.isOffline || false,
|
||||
isPlayingOffline: !isOnline && !!offlineTrack.isOffline
|
||||
};
|
||||
}, [currentTrack, isOnline]);
|
||||
|
||||
return {
|
||||
// Original audio player props
|
||||
...audioPlayerProps,
|
||||
currentTrack,
|
||||
|
||||
// Enhanced offline methods
|
||||
playTrackOffline,
|
||||
playAlbumOffline,
|
||||
addToQueueOffline,
|
||||
shufflePlayOffline,
|
||||
|
||||
// Utility methods
|
||||
songToTrack,
|
||||
getTrackAvailability,
|
||||
getAlbumAvailability,
|
||||
getCurrentTrackInfo,
|
||||
|
||||
// State
|
||||
isOnline,
|
||||
isOfflineSupported
|
||||
};
|
||||
}
|
||||
@@ -1,682 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
export interface DownloadProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error' | 'paused';
|
||||
currentSong?: string;
|
||||
currentArtist?: string;
|
||||
currentAlbum?: string;
|
||||
error?: string;
|
||||
downloadSpeed?: number; // In bytes per second
|
||||
timeRemaining?: number; // In seconds
|
||||
percentComplete?: number; // 0-100
|
||||
}
|
||||
|
||||
export interface OfflineItem {
|
||||
id: string;
|
||||
type: 'album' | 'song';
|
||||
name: string;
|
||||
artist: string;
|
||||
downloadedAt: number;
|
||||
size?: number;
|
||||
bitRate?: number;
|
||||
duration?: number;
|
||||
format?: string;
|
||||
lastPlayed?: number;
|
||||
}
|
||||
|
||||
export interface OfflineStats {
|
||||
totalSize: number;
|
||||
audioSize: number;
|
||||
imageSize: number;
|
||||
metaSize: number;
|
||||
downloadedAlbums: number;
|
||||
downloadedSongs: number;
|
||||
lastDownload: number | null;
|
||||
downloadErrors: number;
|
||||
remainingStorage: number | null;
|
||||
autoDownloadEnabled: boolean;
|
||||
downloadQuality: 'original' | 'high' | 'medium' | 'low';
|
||||
downloadOnWifiOnly: boolean;
|
||||
priorityContent: string[]; // IDs of albums or playlists that should always be available offline
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
private worker: ServiceWorker | null = null;
|
||||
private messageChannel: MessageChannel | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
console.log('Service Worker registered:', registration);
|
||||
|
||||
// Wait for the service worker to be ready
|
||||
const readyRegistration = await navigator.serviceWorker.ready;
|
||||
this.worker = readyRegistration.active;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async sendMessage(type: string, data: any): Promise<any> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type: responseType, data: responseData } = event.data;
|
||||
if (responseType.includes('ERROR')) {
|
||||
reject(new Error(responseData.error));
|
||||
} else {
|
||||
resolve(responseData);
|
||||
}
|
||||
};
|
||||
|
||||
this.worker!.postMessage({ type, data }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadAlbum(
|
||||
album: Album,
|
||||
songs: Song[],
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<void> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'DOWNLOAD_PROGRESS':
|
||||
if (onProgress) {
|
||||
onProgress(data);
|
||||
}
|
||||
break;
|
||||
case 'DOWNLOAD_COMPLETE':
|
||||
resolve();
|
||||
break;
|
||||
case 'DOWNLOAD_ERROR':
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Add direct download URLs to songs (use 'streamUrl' field name to keep SW compatibility)
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getDownloadUrl(song.id),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'DOWNLOAD_ALBUM',
|
||||
data: { album, songs: songsWithUrls }
|
||||
}, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadSong(
|
||||
song: Song,
|
||||
options?: { quality?: 'original' | 'high' | 'medium' | 'low', priority?: boolean }
|
||||
): Promise<void> {
|
||||
const songWithUrl = {
|
||||
...song,
|
||||
streamUrl: this.getDownloadUrl(song.id, options?.quality),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
priority: options?.priority || false,
|
||||
quality: options?.quality || 'original'
|
||||
};
|
||||
|
||||
return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
|
||||
}
|
||||
|
||||
async downloadQueue(
|
||||
songs: Song[],
|
||||
options?: {
|
||||
quality?: 'original' | 'high' | 'medium' | 'low',
|
||||
priority?: boolean,
|
||||
onProgressUpdate?: (progress: DownloadProgress) => void
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!this.worker) {
|
||||
throw new Error('Service Worker not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'DOWNLOAD_PROGRESS':
|
||||
if (options?.onProgressUpdate) {
|
||||
options.onProgressUpdate(data);
|
||||
}
|
||||
break;
|
||||
case 'DOWNLOAD_COMPLETE':
|
||||
resolve();
|
||||
break;
|
||||
case 'DOWNLOAD_ERROR':
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const songsWithUrls = songs.map(song => ({
|
||||
...song,
|
||||
streamUrl: this.getDownloadUrl(song.id, options?.quality),
|
||||
offlineUrl: `offline-song-${song.id}`,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
priority: options?.priority || false,
|
||||
quality: options?.quality || 'original'
|
||||
}));
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'DOWNLOAD_QUEUE',
|
||||
data: { songs: songsWithUrls }
|
||||
}, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async pauseDownloads(): Promise<void> {
|
||||
return this.sendMessage('PAUSE_DOWNLOADS', {});
|
||||
}
|
||||
|
||||
async resumeDownloads(): Promise<void> {
|
||||
return this.sendMessage('RESUME_DOWNLOADS', {});
|
||||
}
|
||||
|
||||
async cancelDownloads(): Promise<void> {
|
||||
return this.sendMessage('CANCEL_DOWNLOADS', {});
|
||||
}
|
||||
|
||||
async setDownloadPreferences(preferences: {
|
||||
quality: 'original' | 'high' | 'medium' | 'low',
|
||||
wifiOnly: boolean,
|
||||
autoDownloadRecent: boolean,
|
||||
autoDownloadFavorites: boolean,
|
||||
maxStoragePercent: number,
|
||||
priorityContent?: string[] // IDs of albums or playlists
|
||||
}): Promise<void> {
|
||||
return this.sendMessage('SET_DOWNLOAD_PREFERENCES', preferences);
|
||||
}
|
||||
|
||||
async getDownloadPreferences(): Promise<{
|
||||
quality: 'original' | 'high' | 'medium' | 'low',
|
||||
wifiOnly: boolean,
|
||||
autoDownloadRecent: boolean,
|
||||
autoDownloadFavorites: boolean,
|
||||
maxStoragePercent: number,
|
||||
priorityContent: string[]
|
||||
}> {
|
||||
return this.sendMessage('GET_DOWNLOAD_PREFERENCES', {});
|
||||
}
|
||||
|
||||
async enableOfflineMode(settings: {
|
||||
autoDownloadQueue?: boolean;
|
||||
forceOffline?: boolean;
|
||||
currentQueue?: Song[];
|
||||
}): Promise<void> {
|
||||
return this.sendMessage('ENABLE_OFFLINE_MODE', settings);
|
||||
}
|
||||
|
||||
async checkOfflineStatus(id: string, type: 'album' | 'song'): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.sendMessage('CHECK_OFFLINE_STATUS', { id, type });
|
||||
return result.isAvailable;
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOfflineContent(id: string, type: 'album' | 'song'): Promise<void> {
|
||||
return this.sendMessage('DELETE_OFFLINE_CONTENT', { id, type });
|
||||
}
|
||||
|
||||
async getOfflineStats(): Promise<OfflineStats> {
|
||||
return this.sendMessage('GET_OFFLINE_STATS', {});
|
||||
}
|
||||
|
||||
async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> {
|
||||
return this.sendMessage('GET_OFFLINE_ITEMS', {});
|
||||
}
|
||||
|
||||
private getDownloadUrl(songId: string, quality?: 'original' | 'high' | 'medium' | 'low'): string {
|
||||
const api = getNavidromeAPI();
|
||||
if (!api) throw new Error('Navidrome server not configured');
|
||||
|
||||
// Use direct download to fetch original file by default
|
||||
if (quality === 'original' || !quality) {
|
||||
if (typeof (api as any).getDownloadUrl === 'function') {
|
||||
return (api as any).getDownloadUrl(songId);
|
||||
}
|
||||
}
|
||||
|
||||
// For other quality settings, use the stream URL with appropriate parameters
|
||||
const maxBitRate = quality === 'high' ? 320 :
|
||||
quality === 'medium' ? 192 :
|
||||
quality === 'low' ? 128 : undefined;
|
||||
|
||||
// Note: format parameter is not supported by the Navidrome API
|
||||
// The server will automatically transcode based on maxBitRate
|
||||
return api.getStreamUrl(songId, maxBitRate);
|
||||
}
|
||||
|
||||
// LocalStorage fallback for browsers without service worker support
|
||||
async downloadAlbumFallback(album: Album, songs: Song[]): Promise<void> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
// Store album metadata
|
||||
offlineData.albums[album.id] = {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
downloadedAt: Date.now(),
|
||||
songCount: songs.length,
|
||||
songs: songs.map(song => song.id)
|
||||
};
|
||||
|
||||
// Mark songs as downloaded (metadata only in localStorage fallback)
|
||||
songs.forEach(song => {
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
this.saveOfflineData(offlineData);
|
||||
}
|
||||
|
||||
public getOfflineData() {
|
||||
const stored = localStorage.getItem('offline-downloads');
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse offline data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
albums: {},
|
||||
songs: {},
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
public saveOfflineData(data: any) {
|
||||
data.lastUpdated = Date.now();
|
||||
localStorage.setItem('offline-downloads', JSON.stringify(data));
|
||||
}
|
||||
|
||||
async checkOfflineStatusFallback(id: string, type: 'album' | 'song'): Promise<boolean> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
if (type === 'album') {
|
||||
return !!offlineData.albums[id];
|
||||
} else {
|
||||
return !!offlineData.songs[id];
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOfflineContentFallback(id: string, type: 'album' | 'song'): Promise<void> {
|
||||
const offlineData = this.getOfflineData();
|
||||
|
||||
if (type === 'album') {
|
||||
const album = offlineData.albums[id];
|
||||
if (album && album.songs) {
|
||||
// Remove associated songs
|
||||
album.songs.forEach((songId: string) => {
|
||||
delete offlineData.songs[songId];
|
||||
});
|
||||
}
|
||||
delete offlineData.albums[id];
|
||||
} else {
|
||||
delete offlineData.songs[id];
|
||||
}
|
||||
|
||||
this.saveOfflineData(offlineData);
|
||||
}
|
||||
|
||||
getOfflineAlbums(): OfflineItem[] {
|
||||
const offlineData = this.getOfflineData();
|
||||
return Object.values(offlineData.albums).map((album: any) => ({
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
downloadedAt: album.downloadedAt
|
||||
}));
|
||||
}
|
||||
|
||||
getOfflineSongs(): OfflineItem[] {
|
||||
const offlineData = this.getOfflineData();
|
||||
return Object.values(offlineData.songs).map((song: any) => ({
|
||||
id: song.id,
|
||||
type: 'song' as const,
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
downloadedAt: song.downloadedAt
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance that will be initialized on the client side
|
||||
let downloadManagerInstance: DownloadManager | null = null;
|
||||
|
||||
// Only create the download manager instance on the client side
|
||||
if (typeof window !== 'undefined') {
|
||||
downloadManagerInstance = new DownloadManager();
|
||||
}
|
||||
|
||||
// Create a safe wrapper around the download manager
|
||||
const downloadManager = {
|
||||
initialize: async () => {
|
||||
if (!downloadManagerInstance) return false;
|
||||
return downloadManagerInstance.initialize();
|
||||
},
|
||||
getOfflineStats: async () => {
|
||||
if (!downloadManagerInstance) return {
|
||||
totalSize: 0,
|
||||
audioSize: 0,
|
||||
imageSize: 0,
|
||||
metaSize: 0,
|
||||
downloadedAlbums: 0,
|
||||
downloadedSongs: 0,
|
||||
lastDownload: null,
|
||||
downloadErrors: 0,
|
||||
remainingStorage: null,
|
||||
autoDownloadEnabled: false,
|
||||
downloadQuality: 'original' as const,
|
||||
downloadOnWifiOnly: true,
|
||||
priorityContent: []
|
||||
};
|
||||
return downloadManagerInstance.getOfflineStats();
|
||||
},
|
||||
downloadAlbum: async (album: Album, songs: Song[], progressCallback: (progress: DownloadProgress) => void) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadAlbum(album, songs, progressCallback);
|
||||
},
|
||||
downloadAlbumFallback: async (album: Album, songs: Song[]) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadAlbumFallback(album, songs);
|
||||
},
|
||||
downloadSong: async (song: Song) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadSong(song);
|
||||
},
|
||||
getOfflineData: () => {
|
||||
if (!downloadManagerInstance) return { albums: {}, songs: {} };
|
||||
return downloadManagerInstance.getOfflineData();
|
||||
},
|
||||
saveOfflineData: (data: any) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.saveOfflineData(data);
|
||||
},
|
||||
checkOfflineStatus: async (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return false;
|
||||
return downloadManagerInstance.checkOfflineStatus(id, type);
|
||||
},
|
||||
checkOfflineStatusFallback: (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return false;
|
||||
return downloadManagerInstance.checkOfflineStatusFallback(id, type);
|
||||
},
|
||||
deleteOfflineContent: async (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.deleteOfflineContent(id, type);
|
||||
},
|
||||
deleteOfflineContentFallback: async (id: string, type: 'album' | 'song') => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.deleteOfflineContentFallback(id, type);
|
||||
},
|
||||
getOfflineItems: async () => {
|
||||
if (!downloadManagerInstance) return { albums: [], songs: [] };
|
||||
return downloadManagerInstance.getOfflineItems();
|
||||
},
|
||||
getOfflineAlbums: () => {
|
||||
if (!downloadManagerInstance) return [];
|
||||
return downloadManagerInstance.getOfflineAlbums();
|
||||
},
|
||||
getOfflineSongs: () => {
|
||||
if (!downloadManagerInstance) return [];
|
||||
return downloadManagerInstance.getOfflineSongs();
|
||||
},
|
||||
downloadQueue: async (songs: Song[]) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.downloadQueue(songs);
|
||||
},
|
||||
enableOfflineMode: async (settings: any) => {
|
||||
if (!downloadManagerInstance) return;
|
||||
return downloadManagerInstance.enableOfflineMode(settings);
|
||||
}
|
||||
};
|
||||
|
||||
export function useOfflineDownloads() {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
|
||||
const [offlineStats, setOfflineStats] = useState<OfflineStats>({
|
||||
totalSize: 0,
|
||||
audioSize: 0,
|
||||
imageSize: 0,
|
||||
metaSize: 0,
|
||||
downloadedAlbums: 0,
|
||||
downloadedSongs: 0,
|
||||
lastDownload: null,
|
||||
downloadErrors: 0,
|
||||
remainingStorage: null,
|
||||
autoDownloadEnabled: false,
|
||||
downloadQuality: 'original',
|
||||
downloadOnWifiOnly: true,
|
||||
priorityContent: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDownloadManager = async () => {
|
||||
// Skip initialization on server-side
|
||||
if (!downloadManager) {
|
||||
setIsSupported(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const supported = await downloadManager.initialize();
|
||||
setIsSupported(supported);
|
||||
setIsInitialized(true);
|
||||
|
||||
if (supported) {
|
||||
// Load initial stats
|
||||
try {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
} catch (error) {
|
||||
console.error('Failed to load offline stats:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeDownloadManager();
|
||||
}, []);
|
||||
|
||||
const downloadAlbum = useCallback(async (album: Album, songs: Song[]) => {
|
||||
try {
|
||||
if (isSupported) {
|
||||
await downloadManager.downloadAlbum(album, songs, setDownloadProgress);
|
||||
} else {
|
||||
// Fallback to localStorage metadata only
|
||||
await downloadManager.downloadAlbumFallback(album, songs);
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
if (isSupported) {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
setDownloadProgress(prev => ({ ...prev, status: 'error', error: (error as Error).message }));
|
||||
throw error;
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const downloadSong = useCallback(async (song: Song) => {
|
||||
if (isSupported) {
|
||||
await downloadManager.downloadSong(song);
|
||||
} else {
|
||||
// Fallback - just save metadata
|
||||
const offlineData = downloadManager.getOfflineData();
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
downloadManager.saveOfflineData(offlineData);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const checkOfflineStatus = useCallback(async (id: string, type: 'album' | 'song'): Promise<boolean> => {
|
||||
if (isSupported) {
|
||||
return downloadManager.checkOfflineStatus(id, type);
|
||||
} else {
|
||||
return downloadManager.checkOfflineStatusFallback(id, type);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const deleteOfflineContent = useCallback(async (id: string, type: 'album' | 'song') => {
|
||||
if (isSupported) {
|
||||
await downloadManager.deleteOfflineContent(id, type);
|
||||
} else {
|
||||
await downloadManager.deleteOfflineContentFallback(id, type);
|
||||
}
|
||||
|
||||
// Refresh stats
|
||||
if (isSupported) {
|
||||
const stats = await downloadManager.getOfflineStats();
|
||||
setOfflineStats(stats);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const getOfflineItems = useCallback(async (): Promise<OfflineItem[]> => {
|
||||
if (isSupported) {
|
||||
try {
|
||||
const { albums, songs } = await downloadManager.getOfflineItems();
|
||||
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
|
||||
} catch (e) {
|
||||
console.error('Failed to get offline items from SW, falling back:', e);
|
||||
}
|
||||
}
|
||||
const albums = downloadManager.getOfflineAlbums();
|
||||
const songs = downloadManager.getOfflineSongs();
|
||||
return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt);
|
||||
}, [isSupported]);
|
||||
|
||||
const clearDownloadProgress = useCallback(() => {
|
||||
setDownloadProgress({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
status: 'idle'
|
||||
});
|
||||
}, []);
|
||||
|
||||
const downloadQueue = useCallback(async (songs: Song[]) => {
|
||||
if (isSupported) {
|
||||
setDownloadProgress({ completed: 0, total: songs.length, failed: 0, status: 'downloading' });
|
||||
|
||||
try {
|
||||
await downloadManager.downloadQueue(songs);
|
||||
// Stats will be updated via progress events
|
||||
} catch (error) {
|
||||
console.error('Queue download failed:', error);
|
||||
setDownloadProgress(prev => ({ ...prev, status: 'error' }));
|
||||
}
|
||||
} else {
|
||||
// Fallback: just store metadata
|
||||
const offlineData = downloadManager.getOfflineData();
|
||||
songs.forEach(song => {
|
||||
offlineData.songs[song.id] = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
downloadedAt: Date.now()
|
||||
};
|
||||
});
|
||||
downloadManager.saveOfflineData(offlineData);
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
const enableOfflineMode = useCallback(async (settings: {
|
||||
autoDownloadQueue?: boolean;
|
||||
forceOffline?: boolean;
|
||||
currentQueue?: Song[];
|
||||
}) => {
|
||||
if (isSupported) {
|
||||
try {
|
||||
await downloadManager.enableOfflineMode(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to enable offline mode:', error);
|
||||
}
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isInitialized,
|
||||
downloadProgress,
|
||||
offlineStats,
|
||||
downloadAlbum,
|
||||
downloadSong,
|
||||
downloadQueue,
|
||||
enableOfflineMode,
|
||||
checkOfflineStatus,
|
||||
deleteOfflineContent,
|
||||
getOfflineItems,
|
||||
clearDownloadProgress
|
||||
};
|
||||
}
|
||||
|
||||
// Export the manager instance for direct use if needed
|
||||
export { downloadManager };
|
||||
@@ -1,517 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { offlineLibraryDB, LibrarySyncStats, OfflineAlbum, OfflineArtist, OfflineSong, OfflinePlaylist } from '@/lib/indexeddb';
|
||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getNavidromeAPI, Song } from '@/lib/navidrome';
|
||||
|
||||
export interface LibrarySyncProgress {
|
||||
phase: 'idle' | 'albums' | 'artists' | 'songs' | 'playlists' | 'operations' | 'complete' | 'error';
|
||||
current: number;
|
||||
total: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LibrarySyncOptions {
|
||||
includeAlbums: boolean;
|
||||
includeArtists: boolean;
|
||||
includeSongs: boolean;
|
||||
includePlaylists: boolean;
|
||||
syncStarred: boolean;
|
||||
maxSongs: number; // Limit to prevent overwhelming the database
|
||||
}
|
||||
|
||||
const defaultSyncOptions: LibrarySyncOptions = {
|
||||
includeAlbums: true,
|
||||
includeArtists: true,
|
||||
includeSongs: true,
|
||||
includePlaylists: true,
|
||||
syncStarred: true,
|
||||
maxSongs: 1000 // Default limit
|
||||
};
|
||||
|
||||
export function useOfflineLibrarySync() {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncProgress, setSyncProgress] = useState<LibrarySyncProgress>({
|
||||
phase: 'idle',
|
||||
current: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
});
|
||||
const [stats, setStats] = useState<LibrarySyncStats>({
|
||||
albums: 0,
|
||||
artists: 0,
|
||||
songs: 0,
|
||||
playlists: 0,
|
||||
lastSync: null,
|
||||
pendingOperations: 0,
|
||||
storageSize: 0,
|
||||
syncInProgress: false
|
||||
});
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
|
||||
const [syncOptions, setSyncOptions] = useState<LibrarySyncOptions>(defaultSyncOptions);
|
||||
|
||||
const { config, isConnected } = useNavidromeConfig();
|
||||
const api = useMemo(() => getNavidromeAPI(config), [config]);
|
||||
const { toast } = useToast();
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Initialize the offline library database
|
||||
useEffect(() => {
|
||||
const initializeDB = async () => {
|
||||
try {
|
||||
const initialized = await offlineLibraryDB.initialize();
|
||||
setIsInitialized(initialized);
|
||||
|
||||
if (initialized) {
|
||||
await refreshStats();
|
||||
loadSyncSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize offline library:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeDB();
|
||||
}, []);
|
||||
|
||||
// Monitor online status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// Check if navigator is available (client-side only)
|
||||
if (typeof navigator !== 'undefined') {
|
||||
setIsOnline(navigator.onLine);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-sync when coming back online
|
||||
useEffect(() => {
|
||||
if (isOnline && isConnected && autoSyncEnabled && !isSyncing) {
|
||||
const pendingOpsSync = async () => {
|
||||
try {
|
||||
await syncPendingOperations();
|
||||
} catch (error) {
|
||||
console.error('Auto-sync failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delay auto-sync to avoid immediate trigger
|
||||
syncTimeoutRef.current = setTimeout(pendingOpsSync, 2000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isOnline, isConnected, autoSyncEnabled, isSyncing]);
|
||||
|
||||
const loadSyncSettings = useCallback(async () => {
|
||||
try {
|
||||
const [autoSync, savedOptions] = await Promise.all([
|
||||
offlineLibraryDB.getMetadata<boolean>('autoSyncEnabled'),
|
||||
offlineLibraryDB.getMetadata<LibrarySyncOptions>('syncOptions')
|
||||
]);
|
||||
|
||||
if (typeof autoSync === 'boolean') setAutoSyncEnabled(autoSync);
|
||||
|
||||
if (savedOptions) {
|
||||
setSyncOptions({ ...defaultSyncOptions, ...savedOptions });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync settings:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshStats = useCallback(async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
try {
|
||||
const newStats = await offlineLibraryDB.getStats();
|
||||
setStats(newStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh stats:', error);
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
const updateSyncProgress = useCallback((phase: LibrarySyncProgress['phase'], current: number, total: number, message: string) => {
|
||||
setSyncProgress({ phase, current, total, message });
|
||||
}, []);
|
||||
|
||||
const syncLibraryFromServer = useCallback(async (options: Partial<LibrarySyncOptions> = {}) => {
|
||||
if (!api || !isConnected || !isInitialized) {
|
||||
throw new Error('Cannot sync: API not available or not connected');
|
||||
}
|
||||
|
||||
if (isSyncing) {
|
||||
throw new Error('Sync already in progress');
|
||||
}
|
||||
|
||||
const actualOptions = { ...syncOptions, ...options };
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
await offlineLibraryDB.setMetadata('syncInProgress', true);
|
||||
|
||||
updateSyncProgress('albums', 0, 0, 'Testing server connection...');
|
||||
|
||||
// Test connection first
|
||||
const connected = await api.ping();
|
||||
if (!connected) {
|
||||
throw new Error('No connection to Navidrome server');
|
||||
}
|
||||
|
||||
let totalItems = 0;
|
||||
let processedItems = 0;
|
||||
|
||||
// Sync albums
|
||||
if (actualOptions.includeAlbums) {
|
||||
updateSyncProgress('albums', 0, 0, 'Fetching albums from server...');
|
||||
|
||||
const albums = await api.getAlbums('alphabeticalByName', 5000);
|
||||
totalItems += albums.length;
|
||||
|
||||
updateSyncProgress('albums', 0, albums.length, `Storing ${albums.length} albums...`);
|
||||
|
||||
const mappedAlbums: OfflineAlbum[] = albums.map(album => ({
|
||||
...album,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
|
||||
await offlineLibraryDB.storeAlbums(mappedAlbums);
|
||||
processedItems += albums.length;
|
||||
|
||||
updateSyncProgress('albums', albums.length, albums.length, `Stored ${albums.length} albums`);
|
||||
}
|
||||
|
||||
// Sync artists
|
||||
if (actualOptions.includeArtists) {
|
||||
updateSyncProgress('artists', processedItems, totalItems, 'Fetching artists from server...');
|
||||
|
||||
const artists = await api.getArtists();
|
||||
totalItems += artists.length;
|
||||
|
||||
updateSyncProgress('artists', 0, artists.length, `Storing ${artists.length} artists...`);
|
||||
|
||||
const mappedArtists: OfflineArtist[] = artists.map(artist => ({
|
||||
...artist,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
|
||||
await offlineLibraryDB.storeArtists(mappedArtists);
|
||||
processedItems += artists.length;
|
||||
|
||||
updateSyncProgress('artists', artists.length, artists.length, `Stored ${artists.length} artists`);
|
||||
}
|
||||
|
||||
// Sync playlists
|
||||
if (actualOptions.includePlaylists) {
|
||||
updateSyncProgress('playlists', processedItems, totalItems, 'Fetching playlists from server...');
|
||||
|
||||
const playlists = await api.getPlaylists();
|
||||
totalItems += playlists.length;
|
||||
|
||||
updateSyncProgress('playlists', 0, playlists.length, `Storing ${playlists.length} playlists...`);
|
||||
|
||||
const mappedPlaylists: OfflinePlaylist[] = await Promise.all(
|
||||
playlists.map(async (playlist) => {
|
||||
try {
|
||||
const playlistDetails = await api.getPlaylist(playlist.id);
|
||||
return {
|
||||
...playlist,
|
||||
songIds: (playlistDetails.songs || []).map((song: Song) => song.id),
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get details for playlist ${playlist.id}:`, error);
|
||||
return {
|
||||
...playlist,
|
||||
songIds: [],
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await offlineLibraryDB.storePlaylists(mappedPlaylists);
|
||||
processedItems += playlists.length;
|
||||
|
||||
updateSyncProgress('playlists', playlists.length, playlists.length, `Stored ${playlists.length} playlists`);
|
||||
}
|
||||
|
||||
// Sync songs (limited to avoid overwhelming the database)
|
||||
if (actualOptions.includeSongs) {
|
||||
updateSyncProgress('songs', processedItems, totalItems, 'Fetching songs from server...');
|
||||
|
||||
const albums = await offlineLibraryDB.getAlbums();
|
||||
const albumsToSync = albums.slice(0, Math.floor(actualOptions.maxSongs / 10)); // Roughly 10 songs per album
|
||||
|
||||
let songCount = 0;
|
||||
updateSyncProgress('songs', 0, albumsToSync.length, `Processing songs for ${albumsToSync.length} albums...`);
|
||||
|
||||
for (let i = 0; i < albumsToSync.length; i++) {
|
||||
const album = albumsToSync[i];
|
||||
try {
|
||||
const { songs } = await api.getAlbum(album.id);
|
||||
|
||||
if (songCount + songs.length > actualOptions.maxSongs) {
|
||||
const remaining = actualOptions.maxSongs - songCount;
|
||||
if (remaining > 0) {
|
||||
const limitedSongs = songs.slice(0, remaining);
|
||||
const mappedSongs: OfflineSong[] = limitedSongs.map(song => ({
|
||||
...song,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
await offlineLibraryDB.storeSongs(mappedSongs);
|
||||
songCount += limitedSongs.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const mappedSongs: OfflineSong[] = songs.map(song => ({
|
||||
...song,
|
||||
lastModified: Date.now(),
|
||||
synced: true
|
||||
}));
|
||||
|
||||
await offlineLibraryDB.storeSongs(mappedSongs);
|
||||
songCount += songs.length;
|
||||
|
||||
updateSyncProgress('songs', i + 1, albumsToSync.length, `Processed ${i + 1}/${albumsToSync.length} albums (${songCount} songs)`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to sync songs for album ${album.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
updateSyncProgress('songs', albumsToSync.length, albumsToSync.length, `Stored ${songCount} songs`);
|
||||
}
|
||||
|
||||
// Sync pending operations to server
|
||||
updateSyncProgress('operations', 0, 0, 'Syncing pending operations...');
|
||||
await syncPendingOperations();
|
||||
|
||||
// Update sync timestamp
|
||||
await offlineLibraryDB.setMetadata('lastSync', Date.now());
|
||||
|
||||
updateSyncProgress('complete', 100, 100, 'Library sync completed successfully');
|
||||
|
||||
toast({
|
||||
title: "Sync Complete",
|
||||
description: `Successfully synced library data offline`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Library sync failed:', error);
|
||||
updateSyncProgress('error', 0, 0, `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
toast({
|
||||
title: "Sync Failed",
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
await offlineLibraryDB.setMetadata('syncInProgress', false);
|
||||
await refreshStats();
|
||||
}
|
||||
}, [api, isConnected, isInitialized, isSyncing, syncOptions, toast, updateSyncProgress, refreshStats]);
|
||||
|
||||
const syncPendingOperations = useCallback(async () => {
|
||||
if (!api || !isConnected || !isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const operations = await offlineLibraryDB.getSyncOperations();
|
||||
|
||||
if (operations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSyncProgress('operations', 0, operations.length, 'Syncing pending operations...');
|
||||
|
||||
for (let i = 0; i < operations.length; i++) {
|
||||
const operation = operations[i];
|
||||
|
||||
try {
|
||||
switch (operation.type) {
|
||||
case 'star':
|
||||
if (operation.entityType !== 'playlist') {
|
||||
await api.star(operation.entityId, operation.entityType);
|
||||
}
|
||||
break;
|
||||
case 'unstar':
|
||||
if (operation.entityType !== 'playlist') {
|
||||
await api.unstar(operation.entityId, operation.entityType);
|
||||
}
|
||||
break;
|
||||
case 'scrobble':
|
||||
await api.scrobble(operation.entityId);
|
||||
break;
|
||||
case 'create_playlist':
|
||||
if ('name' in operation.data && typeof operation.data.name === 'string') {
|
||||
await api.createPlaylist(
|
||||
operation.data.name,
|
||||
'songIds' in operation.data ? operation.data.songIds : undefined
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'update_playlist':
|
||||
if ('name' in operation.data || 'comment' in operation.data || 'songIds' in operation.data) {
|
||||
const d = operation.data as { name?: string; comment?: string; songIds?: string[] };
|
||||
await api.updatePlaylist(operation.entityId, d.name, d.comment, d.songIds);
|
||||
}
|
||||
break;
|
||||
case 'delete_playlist':
|
||||
await api.deletePlaylist(operation.entityId);
|
||||
break;
|
||||
}
|
||||
|
||||
await offlineLibraryDB.removeSyncOperation(operation.id);
|
||||
updateSyncProgress('operations', i + 1, operations.length, `Synced ${i + 1}/${operations.length} operations`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync operation ${operation.id}:`, error);
|
||||
// Don't remove failed operations, they'll be retried later
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to sync pending operations:', error);
|
||||
}
|
||||
}, [api, isConnected, isInitialized, updateSyncProgress]);
|
||||
|
||||
const clearOfflineData = useCallback(async () => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.clearAllData();
|
||||
await refreshStats();
|
||||
|
||||
toast({
|
||||
title: "Offline Data Cleared",
|
||||
description: "All offline library data has been removed",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
toast({
|
||||
title: "Clear Failed",
|
||||
description: "Failed to clear offline data",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}, [isInitialized, refreshStats, toast]);
|
||||
|
||||
const updateAutoSync = useCallback(async (enabled: boolean) => {
|
||||
setAutoSyncEnabled(enabled);
|
||||
try {
|
||||
await offlineLibraryDB.setMetadata('autoSyncEnabled', enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to save auto-sync setting:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSyncOptions = useCallback(async (newOptions: Partial<LibrarySyncOptions>) => {
|
||||
const updatedOptions = { ...syncOptions, ...newOptions };
|
||||
setSyncOptions(updatedOptions);
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.setMetadata('syncOptions', updatedOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to save sync options:', error);
|
||||
}
|
||||
}, [syncOptions]);
|
||||
|
||||
// Offline-first operations
|
||||
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
if (!isInitialized) throw new Error('Offline library not initialized');
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.starItem(id, type);
|
||||
await refreshStats();
|
||||
|
||||
// Try to sync immediately if online
|
||||
if (isOnline && isConnected && api) {
|
||||
try {
|
||||
await api.star(id, type);
|
||||
await offlineLibraryDB.removeSyncOperation(`star-${id}`);
|
||||
} catch (error) {
|
||||
console.log('Failed to sync star operation immediately, will retry later:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to star item:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [isInitialized, refreshStats, isOnline, isConnected, api]);
|
||||
|
||||
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
|
||||
if (!isInitialized) throw new Error('Offline library not initialized');
|
||||
|
||||
try {
|
||||
await offlineLibraryDB.unstarItem(id, type);
|
||||
await refreshStats();
|
||||
|
||||
// Try to sync immediately if online
|
||||
if (isOnline && isConnected && api) {
|
||||
try {
|
||||
await api.unstar(id, type);
|
||||
await offlineLibraryDB.removeSyncOperation(`unstar-${id}`);
|
||||
} catch (error) {
|
||||
console.log('Failed to sync unstar operation immediately, will retry later:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unstar item:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [isInitialized, refreshStats, isOnline, isConnected, api]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
isSyncing,
|
||||
syncProgress,
|
||||
stats,
|
||||
isOnline,
|
||||
autoSyncEnabled,
|
||||
syncOptions,
|
||||
|
||||
// Actions
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
clearOfflineData,
|
||||
updateAutoSync,
|
||||
updateSyncOptions,
|
||||
refreshStats,
|
||||
starItem,
|
||||
unstarItem,
|
||||
|
||||
// Data access (for offline access)
|
||||
getOfflineAlbums: () => offlineLibraryDB.getAlbums(),
|
||||
getOfflineArtists: () => offlineLibraryDB.getArtists(),
|
||||
getOfflineSongs: (albumId?: string) => offlineLibraryDB.getSongs(albumId),
|
||||
getOfflinePlaylists: () => offlineLibraryDB.getPlaylists(),
|
||||
getOfflineAlbum: (id: string) => offlineLibraryDB.getAlbum(id)
|
||||
};
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { offlineLibraryManager, type OfflineLibraryStats, type SyncOperation } from '@/lib/offline-library';
|
||||
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
|
||||
export interface OfflineLibraryState {
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
lastSync: Date | null;
|
||||
stats: OfflineLibraryStats;
|
||||
syncProgress: {
|
||||
current: number;
|
||||
total: number;
|
||||
stage: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useOfflineLibrary() {
|
||||
// Check if we're on the client side
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
const [state, setState] = useState<OfflineLibraryState>({
|
||||
isInitialized: false,
|
||||
isOnline: isClient ? navigator.onLine : true, // Default to true during SSR
|
||||
isSyncing: false,
|
||||
lastSync: null,
|
||||
stats: {
|
||||
albums: 0,
|
||||
artists: 0,
|
||||
songs: 0,
|
||||
playlists: 0,
|
||||
lastSync: null,
|
||||
pendingOperations: 0,
|
||||
storageSize: 0
|
||||
},
|
||||
syncProgress: null
|
||||
});
|
||||
|
||||
const { api } = useNavidrome();
|
||||
|
||||
// Initialize offline library
|
||||
useEffect(() => {
|
||||
const initializeOfflineLibrary = async () => {
|
||||
try {
|
||||
const initialized = await offlineLibraryManager.initialize();
|
||||
if (initialized) {
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
stats,
|
||||
lastSync: stats.lastSync
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize offline library:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeOfflineLibrary();
|
||||
}, []);
|
||||
|
||||
// Listen for online/offline events
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setState(prev => ({ ...prev, isOnline: true }));
|
||||
// Automatically sync when back online
|
||||
if (state.isInitialized && api) {
|
||||
syncPendingOperations();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
setState(prev => ({ ...prev, isOnline: false }));
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [state.isInitialized, api]);
|
||||
|
||||
// Full library sync from server
|
||||
const syncLibraryFromServer = useCallback(async (): Promise<void> => {
|
||||
if (!api || !state.isInitialized || state.isSyncing) return;
|
||||
|
||||
try {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: true,
|
||||
syncProgress: { current: 0, total: 100, stage: 'Starting sync...' }
|
||||
}));
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncProgress: { current: 20, total: 100, stage: 'Syncing albums...' }
|
||||
}));
|
||||
|
||||
await offlineLibraryManager.syncFromServer(api);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncProgress: { current: 80, total: 100, stage: 'Syncing pending operations...' }
|
||||
}));
|
||||
|
||||
await offlineLibraryManager.syncPendingOperations(api);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
syncProgress: null,
|
||||
stats,
|
||||
lastSync: stats.lastSync
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Library sync failed:', error);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSyncing: false,
|
||||
syncProgress: null
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}, [api, state.isInitialized, state.isSyncing]);
|
||||
|
||||
// Sync only pending operations
|
||||
const syncPendingOperations = useCallback(async (): Promise<void> => {
|
||||
if (!api || !state.isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryManager.syncPendingOperations(api);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
} catch (error) {
|
||||
console.error('Failed to sync pending operations:', error);
|
||||
}
|
||||
}, [api, state.isInitialized]);
|
||||
|
||||
// Data retrieval methods (offline-first)
|
||||
const getAlbums = useCallback(async (starred?: boolean): Promise<Album[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
// Try offline first
|
||||
const offlineAlbums = await offlineLibraryManager.getAlbums(starred);
|
||||
|
||||
// If offline data exists, return it
|
||||
if (offlineAlbums.length > 0) {
|
||||
return offlineAlbums;
|
||||
}
|
||||
|
||||
// If no offline data and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
const serverAlbums = starred
|
||||
? await api.getAlbums('starred')
|
||||
: await api.getAlbums('alphabeticalByName', 100);
|
||||
|
||||
// Cache the results
|
||||
await offlineLibraryManager.storeAlbums(serverAlbums);
|
||||
return serverAlbums;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get albums:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getArtists = useCallback(async (starred?: boolean): Promise<Artist[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlineArtists = await offlineLibraryManager.getArtists(starred);
|
||||
|
||||
if (offlineArtists.length > 0) {
|
||||
return offlineArtists;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
const serverArtists = await api.getArtists();
|
||||
await offlineLibraryManager.storeArtists(serverArtists);
|
||||
return serverArtists;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get artists:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getAlbum = useCallback(async (albumId: string): Promise<{ album: Album; songs: Song[] } | null> => {
|
||||
if (!state.isInitialized) return null;
|
||||
|
||||
try {
|
||||
// Try offline first
|
||||
const offlineData = await offlineLibraryManager.getAlbum(albumId);
|
||||
|
||||
if (offlineData && offlineData.songs.length > 0) {
|
||||
return offlineData;
|
||||
}
|
||||
|
||||
// If no offline data and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
const serverData = await api.getAlbum(albumId);
|
||||
|
||||
// Cache the results
|
||||
await offlineLibraryManager.storeAlbums([serverData.album]);
|
||||
await offlineLibraryManager.storeSongs(serverData.songs);
|
||||
|
||||
return serverData;
|
||||
}
|
||||
|
||||
return offlineData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get album:', error);
|
||||
return null;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const getPlaylists = useCallback(async (): Promise<Playlist[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlinePlaylists = await offlineLibraryManager.getPlaylists();
|
||||
|
||||
if (offlinePlaylists.length > 0) {
|
||||
return offlinePlaylists;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
const serverPlaylists = await api.getPlaylists();
|
||||
await offlineLibraryManager.storePlaylists(serverPlaylists);
|
||||
return serverPlaylists;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get playlists:', error);
|
||||
return [];
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Search (offline-first)
|
||||
const searchOffline = useCallback(async (query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> => {
|
||||
if (!state.isInitialized) {
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const offlineResults = await offlineLibraryManager.searchOffline(query);
|
||||
|
||||
// If we have good offline results, return them
|
||||
const totalResults = offlineResults.artists.length + offlineResults.albums.length + offlineResults.songs.length;
|
||||
if (totalResults > 0) {
|
||||
return offlineResults;
|
||||
}
|
||||
|
||||
// If no offline results and we're online, try server
|
||||
if (state.isOnline && api) {
|
||||
return await api.search2(query);
|
||||
}
|
||||
|
||||
return offlineResults;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Offline favorites management
|
||||
const starOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
// If online, try server first
|
||||
await api.star(id, type);
|
||||
}
|
||||
|
||||
// Always update offline data
|
||||
await offlineLibraryManager.starOffline(id, type);
|
||||
|
||||
// Update stats
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to star item:', error);
|
||||
// If server failed but we're online, still save offline for later sync
|
||||
if (state.isOnline) {
|
||||
await offlineLibraryManager.starOffline(id, type);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
const unstarOffline = useCallback(async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
await api.unstar(id, type);
|
||||
}
|
||||
|
||||
await offlineLibraryManager.unstarOffline(id, type);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to unstar item:', error);
|
||||
if (state.isOnline) {
|
||||
await offlineLibraryManager.unstarOffline(id, type);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Playlist management
|
||||
const createPlaylistOffline = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
|
||||
if (!state.isInitialized) {
|
||||
throw new Error('Offline library not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
// If online, try server first
|
||||
const serverPlaylist = await api.createPlaylist(name, songIds);
|
||||
await offlineLibraryManager.storePlaylists([serverPlaylist]);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
return serverPlaylist;
|
||||
} else {
|
||||
// If offline, create locally and queue for sync
|
||||
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
|
||||
return offlinePlaylist;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create playlist:', error);
|
||||
|
||||
// If server failed but we're online, create offline version for later sync
|
||||
if (state.isOnline) {
|
||||
const offlinePlaylist = await offlineLibraryManager.createPlaylistOffline(name, songIds);
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
return offlinePlaylist;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Scrobble (offline-capable)
|
||||
const scrobbleOffline = useCallback(async (songId: string): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
if (state.isOnline && api) {
|
||||
await api.scrobble(songId);
|
||||
} else {
|
||||
// Queue for later sync
|
||||
await offlineLibraryManager.addSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to scrobble:', error);
|
||||
// Queue for later sync if server failed
|
||||
await offlineLibraryManager.addSyncOperation({
|
||||
type: 'scrobble',
|
||||
entityType: 'song',
|
||||
entityId: songId,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats }));
|
||||
}
|
||||
}, [state.isInitialized, state.isOnline, api]);
|
||||
|
||||
// Clear all offline data
|
||||
const clearOfflineData = useCallback(async (): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
await offlineLibraryManager.clearAllData();
|
||||
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stats,
|
||||
lastSync: null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Get songs with offline-first approach
|
||||
const getSongs = useCallback(async (albumId?: string, artistId?: string): Promise<Song[]> => {
|
||||
if (!state.isInitialized) return [];
|
||||
|
||||
try {
|
||||
const offlineSongs = await offlineLibraryManager.getSongs(albumId, artistId);
|
||||
|
||||
if (offlineSongs.length > 0) {
|
||||
return offlineSongs;
|
||||
}
|
||||
|
||||
if (state.isOnline && api) {
|
||||
let serverSongs: Song[] = [];
|
||||
|
||||
if (albumId) {
|
||||
const { songs } = await api.getAlbum(albumId);
|
||||
await offlineLibraryManager.storeSongs(songs);
|
||||
serverSongs = songs;
|
||||
} else if (artistId) {
|
||||
const { albums } = await api.getArtist(artistId);
|
||||
const allSongs: Song[] = [];
|
||||
for (const album of albums) {
|
||||
const { songs } = await api.getAlbum(album.id);
|
||||
allSongs.push(...songs);
|
||||
}
|
||||
await offlineLibraryManager.storeSongs(allSongs);
|
||||
serverSongs = allSongs;
|
||||
}
|
||||
|
||||
return serverSongs;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get songs:', error);
|
||||
return [];
|
||||
}
|
||||
}, [api, state.isInitialized, state.isOnline]);
|
||||
|
||||
// Queue sync operation
|
||||
const queueSyncOperation = useCallback(async (operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
const fullOperation: SyncOperation = {
|
||||
...operation,
|
||||
id: `${operation.type}-${operation.entityId}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
await offlineLibraryManager.addSyncOperation(fullOperation);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Update playlist offline
|
||||
const updatePlaylistOffline = useCallback(async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
await offlineLibraryManager.updatePlaylist(id, name, comment, songIds);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Delete playlist offline
|
||||
const deletePlaylistOffline = useCallback(async (id: string): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
await offlineLibraryManager.deletePlaylist(id);
|
||||
await refreshStats();
|
||||
}, [state.isInitialized]);
|
||||
|
||||
// Refresh stats
|
||||
const refreshStats = useCallback(async (): Promise<void> => {
|
||||
if (!state.isInitialized) return;
|
||||
|
||||
try {
|
||||
const stats = await offlineLibraryManager.getLibraryStats();
|
||||
setState(prev => ({ ...prev, stats, lastSync: stats.lastSync }));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh stats:', error);
|
||||
}
|
||||
}, [state.isInitialized]);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Sync methods
|
||||
syncLibraryFromServer,
|
||||
syncPendingOperations,
|
||||
|
||||
// Data retrieval (offline-first)
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getSongs,
|
||||
getAlbum,
|
||||
getPlaylists,
|
||||
searchOffline,
|
||||
|
||||
// Offline operations
|
||||
starOffline,
|
||||
unstarOffline,
|
||||
createPlaylistOffline,
|
||||
updatePlaylistOffline,
|
||||
deletePlaylistOffline,
|
||||
scrobbleOffline,
|
||||
queueSyncOperation,
|
||||
|
||||
// Management
|
||||
clearOfflineData,
|
||||
refreshStats
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
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
|
||||
@@ -18,8 +16,6 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
|
||||
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
|
||||
@@ -36,84 +32,19 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
|
||||
};
|
||||
}, [sortBy]);
|
||||
|
||||
// We'll define the scroll listener after defining loadMoreAlbums
|
||||
|
||||
// Load initial batch of albums
|
||||
const loadInitialBatch = useCallback(async () => {
|
||||
if (!api && !offlineLibrary.isInitialized) return;
|
||||
if (!api) 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);
|
||||
}
|
||||
const 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);
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial albums batch:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error loading albums');
|
||||
@@ -122,81 +53,20 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [api, offlineApi, offlineLibrary, sortBy]);
|
||||
}, [api, sortBy]);
|
||||
|
||||
// Load more albums when scrolling
|
||||
const loadMoreAlbums = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
if (isLoading || !hasMore || !api) 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);
|
||||
}
|
||||
const 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);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more albums:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error loading more albums');
|
||||
@@ -204,7 +74,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [api, offlineApi, offlineLibrary, albums, currentOffset, isLoading, hasMore, sortBy]);
|
||||
}, [api, currentOffset, isLoading, hasMore, sortBy]);
|
||||
|
||||
// Manual refresh (useful for pull-to-refresh functionality)
|
||||
const refreshAlbums = useCallback(() => {
|
||||
@@ -214,7 +84,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
|
||||
loadInitialBatch();
|
||||
}, [loadInitialBatch]);
|
||||
|
||||
// Setup scroll listener after function declarations
|
||||
// Setup scroll listener
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Don't trigger if already loading
|
||||
@@ -231,7 +101,7 @@ export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabetic
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isLoading, hasMore, currentOffset, loadMoreAlbums]);
|
||||
}, [isLoading, hasMore, loadMoreAlbums]);
|
||||
|
||||
return {
|
||||
albums,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "mice-reworked",
|
||||
"version": "2025.07.10",
|
||||
"version": "2026.01.24",
|
||||
"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",
|
||||
|
||||
776
pnpm-lock.yaml
generated
776
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
575
public/sw.js
575
public/sw.js
@@ -1,17 +1,13 @@
|
||||
/*
|
||||
Service Worker for Mice (Navidrome client)
|
||||
- App shell caching for offline load
|
||||
- Audio download/cache for offline playback
|
||||
- Image/runtime caching
|
||||
- Message-based controls used by use-offline-downloads hook
|
||||
- App shell caching for faster loading
|
||||
- Static asset caching
|
||||
*/
|
||||
|
||||
/* global self, caches, clients */
|
||||
const VERSION = 'v2';
|
||||
/* global self, caches */
|
||||
const VERSION = 'v3';
|
||||
const APP_SHELL_CACHE = `mice-app-shell-${VERSION}`;
|
||||
const AUDIO_CACHE = `mice-audio-${VERSION}`;
|
||||
const IMAGE_CACHE = `mice-images-${VERSION}`;
|
||||
const META_CACHE = `mice-meta-${VERSION}`; // stores small JSON manifests and indices
|
||||
|
||||
// Core assets to precache (safe, static public files)
|
||||
const APP_SHELL = [
|
||||
@@ -26,91 +22,6 @@ const APP_SHELL = [
|
||||
'/apple-touch-icon-precomposed.png',
|
||||
];
|
||||
|
||||
// Utility: post message back to a MessageChannel port safely
|
||||
function replyPort(event, type, data) {
|
||||
try {
|
||||
if (event && event.ports && event.ports[0]) {
|
||||
event.ports[0].postMessage({ type, data });
|
||||
} else if (self.clients && event.source && event.source.postMessage) {
|
||||
// Fallback to client postMessage (won't carry response to specific channel)
|
||||
event.source.postMessage({ type, data });
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('SW reply failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility: fetch and put into a cache with basic error handling
|
||||
async function fetchAndCache(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const req = typeof request === 'string' ? new Request(request) : request;
|
||||
// Try normal fetch first to preserve CORS and headers; fall back to no-cors if it fails
|
||||
let res = await fetch(req).catch(() => null);
|
||||
if (!res) {
|
||||
const reqNoCors = new Request(req, { mode: 'no-cors' });
|
||||
res = await fetch(reqNoCors).catch(() => null);
|
||||
if (!res) throw new Error('Network failed');
|
||||
await cache.put(reqNoCors, res.clone());
|
||||
return res;
|
||||
}
|
||||
await cache.put(req, res.clone());
|
||||
return res;
|
||||
}
|
||||
|
||||
// Utility: put small JSON under META_CACHE at a logical URL key
|
||||
async function putJSONMeta(keyUrl, obj) {
|
||||
const cache = await caches.open(META_CACHE);
|
||||
const res = new Response(JSON.stringify(obj), {
|
||||
headers: { 'content-type': 'application/json', 'x-sw-meta': '1' },
|
||||
});
|
||||
await cache.put(new Request(keyUrl), res);
|
||||
}
|
||||
|
||||
async function getJSONMeta(keyUrl) {
|
||||
const cache = await caches.open(META_CACHE);
|
||||
const res = await cache.match(new Request(keyUrl));
|
||||
if (!res) return null;
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMeta(keyUrl) {
|
||||
const cache = await caches.open(META_CACHE);
|
||||
await cache.delete(new Request(keyUrl));
|
||||
}
|
||||
|
||||
// Manifest helpers
|
||||
function albumManifestKey(albumId) {
|
||||
return `/offline/albums/${encodeURIComponent(albumId)}`;
|
||||
}
|
||||
function songManifestKey(songId) {
|
||||
return `/offline/songs/${encodeURIComponent(songId)}`;
|
||||
}
|
||||
|
||||
// Build cover art URL using the same auth tokens from media URL (stream or download)
|
||||
function buildCoverArtUrlFromStream(streamUrl, coverArtId) {
|
||||
try {
|
||||
const u = new URL(streamUrl);
|
||||
// copy params needed
|
||||
const searchParams = new URLSearchParams(u.search);
|
||||
const needed = new URLSearchParams({
|
||||
u: searchParams.get('u') || '',
|
||||
t: searchParams.get('t') || '',
|
||||
s: searchParams.get('s') || '',
|
||||
v: searchParams.get('v') || '',
|
||||
c: searchParams.get('c') || 'miceclient',
|
||||
id: coverArtId || '',
|
||||
});
|
||||
return `${u.origin}/rest/getCoverArt?${needed.toString()}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Install: pre-cache app shell
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
@@ -130,7 +41,7 @@ self.addEventListener('activate', (event) => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => ![APP_SHELL_CACHE, AUDIO_CACHE, IMAGE_CACHE, META_CACHE].includes(k))
|
||||
.filter((k) => ![APP_SHELL_CACHE, IMAGE_CACHE].includes(k))
|
||||
.map((k) => caches.delete(k))
|
||||
);
|
||||
await self.clients.claim();
|
||||
@@ -141,59 +52,6 @@ self.addEventListener('activate', (event) => {
|
||||
// Fetch strategy
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Custom offline song mapping: /offline-song-<songId>
|
||||
// Handle this EARLY, including Range requests, by mapping to the cached streamUrl
|
||||
const offlineSongMatch = url.pathname.match(/^\/offline-song-([\w-]+)/);
|
||||
if (offlineSongMatch) {
|
||||
const songId = offlineSongMatch[1];
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const meta = await getJSONMeta(songManifestKey(songId));
|
||||
if (meta && meta.streamUrl) {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
const match = await cache.match(new Request(meta.streamUrl));
|
||||
if (match) return match;
|
||||
// Not cached yet: try to fetch now and cache, then return
|
||||
try {
|
||||
const res = await fetchAndCache(meta.streamUrl, AUDIO_CACHE);
|
||||
return res;
|
||||
} catch (e) {
|
||||
return new Response('Offline song not available', { status: 404 });
|
||||
}
|
||||
}
|
||||
return new Response('Offline song not available', { status: 404 });
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle HTTP Range requests for audio cached blobs (map offline-song to cached stream)
|
||||
if (req.headers.get('range')) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
// Try direct match first
|
||||
let cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
// If this is an offline-song path, map to the original streamUrl
|
||||
const offMatch = url.pathname.match(/^\/offline-song-([\w-]+)/);
|
||||
if (offMatch) {
|
||||
const meta = await getJSONMeta(songManifestKey(offMatch[1]));
|
||||
if (meta && meta.streamUrl) {
|
||||
cached = await cache.match(new Request(meta.streamUrl));
|
||||
if (cached) return cached;
|
||||
}
|
||||
}
|
||||
// If not cached yet, fetch and cache normally; range will likely be handled by server
|
||||
const res = await fetch(req);
|
||||
cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation requests: network-first, fallback to cache
|
||||
if (req.mode === 'navigate') {
|
||||
@@ -216,7 +74,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Images: cache-first
|
||||
// Images: cache-first for better performance
|
||||
if (req.destination === 'image') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
@@ -228,7 +86,6 @@ self.addEventListener('fetch', (event) => {
|
||||
cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
} catch {
|
||||
// fall back
|
||||
return cached || Response.error();
|
||||
}
|
||||
})()
|
||||
@@ -236,7 +93,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scripts, styles, fonts, and Next.js assets: cache-first for offline boot
|
||||
// Scripts, styles, fonts, and Next.js assets: cache-first for faster loading
|
||||
if (
|
||||
req.destination === 'script' ||
|
||||
req.destination === 'style' ||
|
||||
@@ -260,421 +117,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Audio and media: cache-first (to support offline playback)
|
||||
if (req.destination === 'audio' || /\/rest\/(stream|download)/.test(req.url)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
// Try normal fetch; if CORS blocks, fall back to no-cors and still cache opaque
|
||||
let res = await fetch(req);
|
||||
if (!res || !res.ok) {
|
||||
res = await fetch(new Request(req, { mode: 'no-cors' }));
|
||||
}
|
||||
cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
} catch {
|
||||
// Fallback: if this is /rest/stream with an id, try to serve cached by stored meta
|
||||
try {
|
||||
const u = new URL(req.url);
|
||||
if (/\/rest\/(stream|download)/.test(u.pathname)) {
|
||||
const id = u.searchParams.get('id');
|
||||
if (id) {
|
||||
const meta = await getJSONMeta(songManifestKey(id));
|
||||
if (meta && meta.streamUrl) {
|
||||
const alt = await cache.match(new Request(meta.streamUrl));
|
||||
if (alt) return alt;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return cached || Response.error();
|
||||
}
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: try network, fallback to cache
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
return await fetch(req);
|
||||
} catch {
|
||||
const cache = await caches.open(APP_SHELL_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
return Response.error();
|
||||
}
|
||||
})()
|
||||
);
|
||||
// Default: network-only (no caching for API calls, audio streams, etc.)
|
||||
event.respondWith(fetch(req));
|
||||
});
|
||||
|
||||
// Message handlers for offline downloads and controls
|
||||
self.addEventListener('message', (event) => {
|
||||
const { type, data } = event.data || {};
|
||||
switch (type) {
|
||||
case 'DOWNLOAD_ALBUM':
|
||||
handleDownloadAlbum(event, data);
|
||||
break;
|
||||
case 'DOWNLOAD_SONG':
|
||||
handleDownloadSong(event, data);
|
||||
break;
|
||||
case 'DOWNLOAD_QUEUE':
|
||||
handleDownloadQueue(event, data);
|
||||
break;
|
||||
case 'ENABLE_OFFLINE_MODE':
|
||||
// Store a simple flag in META_CACHE
|
||||
(async () => {
|
||||
await putJSONMeta('/offline/settings', { ...data, updatedAt: Date.now() });
|
||||
replyPort(event, 'ENABLE_OFFLINE_MODE_OK', { ok: true });
|
||||
})();
|
||||
break;
|
||||
case 'CHECK_OFFLINE_STATUS':
|
||||
(async () => {
|
||||
const { id, type: entityType } = data || {};
|
||||
let isAvailable = false;
|
||||
if (entityType === 'album') {
|
||||
const manifest = await getJSONMeta(albumManifestKey(id));
|
||||
isAvailable = !!manifest && Array.isArray(manifest.songIds) && manifest.songIds.length > 0;
|
||||
} else if (entityType === 'song') {
|
||||
const songMeta = await getJSONMeta(songManifestKey(id));
|
||||
if (songMeta && songMeta.streamUrl) {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
const match = await cache.match(new Request(songMeta.streamUrl));
|
||||
isAvailable = !!match;
|
||||
}
|
||||
}
|
||||
replyPort(event, 'CHECK_OFFLINE_STATUS_OK', { isAvailable });
|
||||
})();
|
||||
break;
|
||||
case 'DELETE_OFFLINE_CONTENT':
|
||||
(async () => {
|
||||
try {
|
||||
const { id, type: entityType } = data || {};
|
||||
if (entityType === 'album') {
|
||||
const manifest = await getJSONMeta(albumManifestKey(id));
|
||||
if (manifest && Array.isArray(manifest.songIds)) {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
for (const s of manifest.songIds) {
|
||||
const songMeta = await getJSONMeta(songManifestKey(s));
|
||||
if (songMeta && songMeta.streamUrl) {
|
||||
await cache.delete(new Request(songMeta.streamUrl));
|
||||
await deleteMeta(songManifestKey(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteMeta(albumManifestKey(id));
|
||||
} else if (entityType === 'song') {
|
||||
const songMeta = await getJSONMeta(songManifestKey(id));
|
||||
if (songMeta && songMeta.streamUrl) {
|
||||
const cache = await caches.open(AUDIO_CACHE);
|
||||
await cache.delete(new Request(songMeta.streamUrl));
|
||||
}
|
||||
await deleteMeta(songManifestKey(id));
|
||||
}
|
||||
replyPort(event, 'DELETE_OFFLINE_CONTENT_OK', { ok: true });
|
||||
} catch (e) {
|
||||
replyPort(event, 'DELETE_OFFLINE_CONTENT_ERROR', { error: String(e) });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
case 'GET_OFFLINE_STATS':
|
||||
(async () => {
|
||||
try {
|
||||
const audioCache = await caches.open(AUDIO_CACHE);
|
||||
const imageCache = await caches.open(IMAGE_CACHE);
|
||||
const audioReqs = await audioCache.keys();
|
||||
const imageReqs = await imageCache.keys();
|
||||
const totalItems = audioReqs.length + imageReqs.length;
|
||||
// Size estimation is limited (opaque responses). We'll count items and attempt content-length.
|
||||
let totalSize = 0;
|
||||
let audioSize = 0;
|
||||
let imageSize = 0;
|
||||
async function sumCache(cache, reqs) {
|
||||
let sum = 0;
|
||||
for (const r of reqs) {
|
||||
const res = await cache.match(r);
|
||||
if (!res) continue;
|
||||
const lenHeader = res.headers.get('content-length');
|
||||
const len = Number(lenHeader || '0');
|
||||
if (!isNaN(len) && len > 0) {
|
||||
sum += len;
|
||||
} else {
|
||||
// Try estimate using song manifest bitrate and duration if available
|
||||
try {
|
||||
const u = new URL(r.url);
|
||||
if (/\/rest\/stream/.test(u.pathname)) {
|
||||
const id = u.searchParams.get('id');
|
||||
if (id) {
|
||||
const meta = await getJSONMeta(songManifestKey(id));
|
||||
if (meta) {
|
||||
if (meta.size && Number.isFinite(meta.size)) {
|
||||
sum += Number(meta.size);
|
||||
} else if (meta.duration) {
|
||||
// If bitrate known, use it, else assume 192 kbps
|
||||
const kbps = meta.bitRate || 192;
|
||||
const bytes = Math.floor((kbps * 1000 / 8) * meta.duration);
|
||||
sum += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
audioSize = await sumCache(audioCache, audioReqs);
|
||||
imageSize = await sumCache(imageCache, imageReqs);
|
||||
totalSize = audioSize + imageSize;
|
||||
// Derive counts of albums/songs from manifests
|
||||
const metaCache = await caches.open(META_CACHE);
|
||||
const metaKeys = await metaCache.keys();
|
||||
const downloadedAlbums = metaKeys.filter((k) => /\/offline\/albums\//.test(k.url)).length;
|
||||
const downloadedSongs = metaKeys.filter((k) => /\/offline\/songs\//.test(k.url)).length;
|
||||
replyPort(event, 'GET_OFFLINE_STATS_OK', {
|
||||
totalSize,
|
||||
audioSize,
|
||||
imageSize,
|
||||
metaSize: 0,
|
||||
downloadedAlbums,
|
||||
downloadedSongs,
|
||||
totalItems,
|
||||
});
|
||||
} catch (e) {
|
||||
replyPort(event, 'GET_OFFLINE_STATS_ERROR', { error: String(e) });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
case 'GET_OFFLINE_ITEMS':
|
||||
(async () => {
|
||||
try {
|
||||
const metaCache = await caches.open(META_CACHE);
|
||||
const keys = await metaCache.keys();
|
||||
const albums = [];
|
||||
const songs = [];
|
||||
for (const req of keys) {
|
||||
if (/\/offline\/albums\//.test(req.url)) {
|
||||
const res = await metaCache.match(req);
|
||||
if (res) {
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json) {
|
||||
albums.push({
|
||||
id: json.id,
|
||||
type: 'album',
|
||||
name: json.name,
|
||||
artist: json.artist,
|
||||
downloadedAt: json.downloadedAt || Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (/\/offline\/songs\//.test(req.url)) {
|
||||
const res = await metaCache.match(req);
|
||||
if (res) {
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json) {
|
||||
songs.push({
|
||||
id: json.id,
|
||||
type: 'song',
|
||||
name: json.title,
|
||||
artist: json.artist,
|
||||
downloadedAt: json.downloadedAt || Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
replyPort(event, 'GET_OFFLINE_ITEMS_OK', { albums, songs });
|
||||
} catch (e) {
|
||||
replyPort(event, 'GET_OFFLINE_ITEMS_ERROR', { error: String(e) });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
default:
|
||||
// no-op
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleDownloadAlbum(event, payload) {
|
||||
try {
|
||||
const { album, songs } = payload || {};
|
||||
if (!album || !Array.isArray(songs)) throw new Error('Invalid album payload');
|
||||
|
||||
const songIds = [];
|
||||
let completed = 0;
|
||||
const total = songs.length;
|
||||
|
||||
for (const song of songs) {
|
||||
songIds.push(song.id);
|
||||
try {
|
||||
if (!song.streamUrl) throw new Error('Missing streamUrl');
|
||||
try {
|
||||
await fetchAndCache(song.streamUrl, AUDIO_CACHE);
|
||||
} catch (err) {
|
||||
try {
|
||||
const u = new URL(song.streamUrl);
|
||||
if (/\/rest\/download/.test(u.pathname)) {
|
||||
u.pathname = u.pathname.replace('/rest/download', '/rest/stream');
|
||||
await fetchAndCache(u.toString(), AUDIO_CACHE);
|
||||
song.streamUrl = u.toString();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (e2) {
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
// Save per-song meta for quick lookup
|
||||
await putJSONMeta(songManifestKey(song.id), {
|
||||
id: song.id,
|
||||
streamUrl: song.streamUrl,
|
||||
albumId: song.albumId,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
downloadedAt: Date.now(),
|
||||
});
|
||||
completed += 1;
|
||||
replyPort(event, 'DOWNLOAD_PROGRESS', {
|
||||
completed,
|
||||
total,
|
||||
failed: 0,
|
||||
status: 'downloading',
|
||||
currentSong: song.title,
|
||||
});
|
||||
} catch (e) {
|
||||
replyPort(event, 'DOWNLOAD_PROGRESS', {
|
||||
completed,
|
||||
total,
|
||||
failed: 1,
|
||||
status: 'downloading',
|
||||
currentSong: song.title,
|
||||
error: String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save album manifest
|
||||
await putJSONMeta(albumManifestKey(album.id), {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
songIds,
|
||||
downloadedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Optionally cache cover art
|
||||
try {
|
||||
if (songs[0] && songs[0].streamUrl && (album.coverArt || songs[0].coverArt)) {
|
||||
const coverArtUrl = buildCoverArtUrlFromStream(songs[0].streamUrl, album.coverArt || songs[0].coverArt);
|
||||
if (coverArtUrl) await fetchAndCache(coverArtUrl, IMAGE_CACHE);
|
||||
}
|
||||
} catch {
|
||||
// ignore cover art failures
|
||||
}
|
||||
|
||||
replyPort(event, 'DOWNLOAD_COMPLETE', { ok: true });
|
||||
} catch (e) {
|
||||
replyPort(event, 'DOWNLOAD_ERROR', { error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadSong(event, song) {
|
||||
try {
|
||||
if (!song || !song.id || !song.streamUrl) throw new Error('Invalid song payload');
|
||||
try {
|
||||
await fetchAndCache(song.streamUrl, AUDIO_CACHE);
|
||||
} catch (err) {
|
||||
try {
|
||||
const u = new URL(song.streamUrl);
|
||||
if (/\/rest\/download/.test(u.pathname)) {
|
||||
u.pathname = u.pathname.replace('/rest/download', '/rest/stream');
|
||||
await fetchAndCache(u.toString(), AUDIO_CACHE);
|
||||
song.streamUrl = u.toString();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (e2) {
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
await putJSONMeta(songManifestKey(song.id), {
|
||||
id: song.id,
|
||||
streamUrl: song.streamUrl,
|
||||
albumId: song.albumId,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
downloadedAt: Date.now(),
|
||||
});
|
||||
replyPort(event, 'DOWNLOAD_COMPLETE', { ok: true });
|
||||
} catch (e) {
|
||||
replyPort(event, 'DOWNLOAD_ERROR', { error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadQueue(event, payload) {
|
||||
try {
|
||||
const { songs } = payload || {};
|
||||
if (!Array.isArray(songs)) throw new Error('Invalid queue payload');
|
||||
let completed = 0;
|
||||
const total = songs.length;
|
||||
for (const song of songs) {
|
||||
try {
|
||||
if (!song.streamUrl) throw new Error('Missing streamUrl');
|
||||
try {
|
||||
await fetchAndCache(song.streamUrl, AUDIO_CACHE);
|
||||
} catch (err) {
|
||||
const u = new URL(song.streamUrl);
|
||||
if (/\/rest\/download/.test(u.pathname)) {
|
||||
u.pathname = u.pathname.replace('/rest/download', '/rest/stream');
|
||||
await fetchAndCache(u.toString(), AUDIO_CACHE);
|
||||
song.streamUrl = u.toString();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await putJSONMeta(songManifestKey(song.id), {
|
||||
id: song.id,
|
||||
streamUrl: song.streamUrl,
|
||||
albumId: song.albumId,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
size: song.size,
|
||||
downloadedAt: Date.now(),
|
||||
});
|
||||
completed += 1;
|
||||
replyPort(event, 'DOWNLOAD_PROGRESS', {
|
||||
completed,
|
||||
total,
|
||||
failed: 0,
|
||||
status: 'downloading',
|
||||
currentSong: song.title,
|
||||
});
|
||||
} catch (e) {
|
||||
replyPort(event, 'DOWNLOAD_PROGRESS', {
|
||||
completed,
|
||||
total,
|
||||
failed: 1,
|
||||
status: 'downloading',
|
||||
currentSong: song?.title,
|
||||
error: String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
replyPort(event, 'DOWNLOAD_COMPLETE', { ok: true });
|
||||
} catch (e) {
|
||||
replyPort(event, 'DOWNLOAD_ERROR', { error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -32,7 +32,8 @@
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user