10 Commits

Author SHA1 Message Date
245620d4a7 docs: add CHANGELOG and commit rewriting script 2026-01-25 01:30:56 +00:00
b426cc05ff fix: docker startup issue, add GitHub release workflow and changelog config 2026-01-25 01:29:13 +00:00
699a27b0b9 Use git commit SHA for versioning, fix audio playback resume, remove all streak localStorage code 2026-01-25 01:22:54 +00:00
b5fc05382e Fix menubar, add lazy loading, improve image quality, limit search results, filter browse artists 2026-01-25 01:16:17 +00:00
43a51b165b Add pagination to library/songs and remove listening streaks
Library/Songs improvements:
- Added pagination with 50 songs per page
- Added Previous/Next navigation buttons
- Updated header to show current page range (e.g., 'Showing 1-50 of 247 songs')
- Track numbers now reflect global position across all pages
- Page resets to 1 when search/sort filters change
- Imported ChevronLeft and ChevronRight icons for navigation

Listening Streak removal:
- Removed CompactListeningStreak component from home page
- Removed ListeningStreakCard component from history page
- Removed listening streak imports from both pages
- Cleaned up empty comment sections

The songs page now handles large libraries more efficiently with pagination,
and the UI is cleaner without the listening streak cards.
2026-01-25 00:46:15 +00:00
c64e40d56b Organize documentation: move markdown files to docs/ folder
- Created docs/ directory for documentation
- Moved KEYBOARD_SHORTCUTS.md → docs/
- Moved DOCKER.md → docs/
- Moved SPOTLIGHT_SEARCH.md → docs/
- Moved themes.md → docs/
- Moved OFFLINE_DOWNLOADS.md → docs/
- Kept README.md in root for repository visibility
2026-01-25 00:39:08 +00:00
6d1e4fb063 Simplify service worker: remove offline download functionality
- Removed all audio download and caching logic
- Removed offline-song URL mapping
- Removed metadata cache (META_CACHE)
- Removed audio cache (AUDIO_CACHE)
- Removed message handlers for DOWNLOAD_ALBUM, DOWNLOAD_SONG, DOWNLOAD_QUEUE
- Removed message handlers for offline status checks and deletion
- Updated VERSION to v3 to force cache cleanup on next load
- Kept only app shell and image caching for faster loading
- Simplified to 120 lines (from 681 lines - 82% reduction)
2026-01-25 00:37:42 +00:00
eb56096992 Remove all offline download and caching functionality
- Deleted all offline-related component files:
  - EnhancedOfflineManager.tsx
  - OfflineIndicator.tsx
  - OfflineLibrarySync.tsx
  - OfflineManagement.tsx
  - OfflineNavidromeContext.tsx
  - OfflineNavidromeProvider.tsx
  - OfflineStatusIndicator.tsx
- Deleted all offline-related hooks:
  - use-offline-audio-player.ts
  - use-offline-downloads.ts
  - use-offline-library-sync.ts
  - use-offline-library.ts
- Updated components to remove offline functionality:
  - RootLayoutClient: Removed OfflineNavidromeProvider, using only NavidromeProvider
  - SongRecommendations: Removed offline data fetching logic
  - album-artwork: Removed OfflineIndicator usage
  - WhatsNewPopup: Updated changelog to reflect offline removal
- Updated pages:
  - album/[id]/page: Removed all OfflineIndicator components from album and song displays
  - page.tsx: Removed OfflineStatusIndicator and offline empty state message
  - settings/page: Removed EnhancedOfflineManager and OfflineManagement sections
- Simplified use-progressive-album-loading hook to only use online API
- Fixed resizable component imports for react-resizable-panels 4.5.1 API changes
2026-01-25 00:35:58 +00:00
df248497ae chore: Update version to 2026.01.24 and add changelog for January 2026 release 2026-01-25 00:16:58 +00:00
7b036d8b6c Update pnpm-lock.yaml to match new overrides configuration 2026-01-25 00:14:02 +00:00
48 changed files with 811 additions and 6396 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=9427a2a
NEXT_PUBLIC_COMMIT_SHA=b5fc053

33
.github/workflows/github-release.yml vendored Normal file
View 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
View 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.

View File

@@ -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

View File

@@ -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 dont have your own Navidrome server yet, you can use the public demo credentials:

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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&#39;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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)}

View File

@@ -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',

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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: [] });

View File

@@ -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
View 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"

View File

@@ -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
View 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"

View File

@@ -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 "$@"

View File

@@ -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
}
};
}

View File

@@ -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
};
}

View File

@@ -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 };

View File

@@ -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)
};
}

View File

@@ -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
};
}

View File

@@ -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,

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;
});
})
);
}
});

View File

@@ -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) });
}
}

View File

@@ -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"