13 Commits

Author SHA1 Message Date
52a00ca899 feat: Improve SortableQueueItem component with enhanced click handling and styling
All checks were successful
Lint and Build / lint-and-build (pull_request) Successful in 9m8s
2025-08-16 17:07:18 -05:00
7710bf3cc9 feat: Add keyboard shortcuts and queue management features
- Implement global keyboard shortcuts for playback controls, volume adjustments, and navigation.
- Introduce drag-and-drop functionality for queue reordering with visual feedback.
- Add context menus for tracks, albums, and artists with quick action options.
- Develop Spotlight Search feature with Last.fm integration for enhanced music discovery.
- Create GlobalSearchProvider for managing search state and keyboard shortcuts.
- Ensure accessibility and keyboard navigation support across all new features.
2025-08-12 13:09:33 +00:00
9427a2a237 feat: Add ListeningStreakCard component for tracking listening streaks
feat: Implement InfiniteScroll component for loading more items on scroll

feat: Create useListeningStreak hook to manage listening streak data and statistics

feat: Develop useProgressiveAlbumLoading hook for progressive loading of albums

feat: Implement background sync service worker for automatic data synchronization
2025-08-11 14:50:57 +00:00
1f6ebf18a3 feat: Move service worker registration to a dedicated component for improved client-side handling 2025-08-11 12:35:50 +00:00
c999c43288 feat: Refactor service worker registration and enhance offline download manager with client-side checks 2025-08-11 12:31:08 +00:00
a352021dbc feat: Enhance OfflineManagement component with improved card styling and layout 2025-08-11 05:05:00 +00:00
147602ad8c feat: Implement Auto-Tagging Settings and MusicBrainz integration
- Added AutoTaggingSettings component for configuring auto-tagging preferences.
- Integrated localStorage for saving user preferences and options.
- Developed useAutoTagging hook for fetching and applying metadata from MusicBrainz.
- Created MusicBrainz API client for searching and retrieving music metadata.
- Enhanced metadata structure with additional fields for tracks and albums.
- Implemented rate-limiting for MusicBrainz API requests.
- Added UI components for user interaction and feedback during the tagging process.
2025-08-10 15:02:49 +00:00
18f0811787 feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component 2025-08-10 02:57:55 +00:00
7a1c7e1eae feat: Update cover art retrieval to use higher resolution images and enhance download manager with new features 2025-08-10 02:06:39 +00:00
7e6a28e4f4 feat: Enhance UI with Framer Motion animations for album artwork and artist icons 2025-08-08 21:38:58 +00:00
36c1edd01e feat: Add page transition animations and notification settings for audio playback 2025-08-08 21:29:01 +00:00
3839a1be2d feat: Implement offline library synchronization with IndexedDB
- Added `useOfflineLibrarySync` hook for managing offline library sync operations.
- Created `OfflineLibrarySync` component for UI integration.
- Developed `offlineLibraryDB` for IndexedDB interactions, including storing and retrieving albums, artists, songs, and playlists.
- Implemented sync operations for starred items, playlists, and scrobbling.
- Added auto-sync functionality when coming back online.
- Included metadata management for sync settings and statistics.
- Enhanced error handling and user feedback through toasts.
2025-08-08 20:04:06 +00:00
0a0feb3748 feat: Implement offline library management with IndexedDB support
- Added `useOfflineLibrary` hook for managing offline library state and synchronization.
- Created `OfflineLibraryManager` class for handling IndexedDB operations and syncing with Navidrome API.
- Implemented methods for retrieving and storing albums, artists, songs, and playlists.
- Added support for offline favorites management (star/unstar).
- Implemented playlist creation, updating, and deletion functionalities.
- Added search functionality for offline data.
- Created a manifest file for PWA support with icons and shortcuts.
- Added service worker file for caching and offline capabilities.
2025-08-07 22:07:53 +00:00
71 changed files with 13590 additions and 700 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=0c32c05
NEXT_PUBLIC_COMMIT_SHA=9427a2a

5
.gitignore vendored
View File

@@ -70,6 +70,11 @@ next-env.d.ts
# database
still-database/
# Debug related files
scripts/sleep-debug.js
.vscode/launch.json
source-map-support/
.next/
certificates
.vercel

51
.vscode/launch.json vendored
View File

@@ -1,6 +1,22 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Merow",
"program": "${workspaceFolder}/scripts/sleep-debug.js",
"skipFiles": [
"<node_internals>/**"
],
"console": "integratedTerminal",
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"trace": true
},
{
"name": "Debug: Next.js Development",
"type": "node",
@@ -17,7 +33,34 @@
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
]
],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
},
{
"name": "Debug: Development (Verbose)",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development",
"DEBUG": "*",
"NEXT_TELEMETRY_DISABLED": "1"
},
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
},
{
"name": "Debug: Next.js Production",
@@ -32,7 +75,11 @@
"preLaunchTask": "Build: Production Build Only",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "start"],
"skipFiles": ["<node_internals>/**"]
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
}
]
}

121
KEYBOARD_SHORTCUTS.md Normal file
View File

@@ -0,0 +1,121 @@
# Keyboard Shortcuts & Queue Management Features
This document outlines the new keyboard shortcuts, queue management, and context menu features added to the music player.
## Keyboard Shortcuts
The following keyboard shortcuts work globally throughout the application:
### Playback Controls
- **Space** - Play/Pause current track
- **→ (Right Arrow)** - Skip to next track
- **← (Left Arrow)** - Skip to previous track
### Volume Controls
- **↑ (Up Arrow)** - Increase volume by 10%
- **↓ (Down Arrow)** - Decrease volume by 10%
- **M** - Toggle mute/unmute
### Navigation
- **/** - Quick search (navigates to search page and focuses input)
### Notes
- Keyboard shortcuts are disabled when typing in input fields
- When in fullscreen player mode, shortcuts are handled by the fullscreen player
- Volume changes are saved to localStorage
## Queue Management
### Drag and Drop Queue Reordering
- **Drag Handle**: Hover over queue items to reveal the grip handle (⋮⋮)
- **Reorder**: Click and drag the handle to reorder tracks in the queue
- **Visual Feedback**: Dragged items become semi-transparent during drag
- **Keyboard Support**: Use Tab to focus items, then Space + Arrow keys to reorder
### Queue Features
- Real-time visual feedback during drag operations
- Maintains playback order after reordering
- Works with both mouse and keyboard navigation
- Accessible drag and drop implementation
## Context Menus (Right-Click)
Right-click on tracks, albums, and artists to access quick actions:
### Track Context Menu
- **Play Now** - Immediately play the selected track
- **Play Next** - Add track to the beginning of the queue
- **Add to Queue** - Add track to the end of the queue
- **Add/Remove from Favorites** - Toggle favorite status
- **Go to Album** - Navigate to the track's album
- **Go to Artist** - Navigate to the track's artist
- **Track Info** - View detailed track information
- **Share** - Share the track
### Album Context Menu
- **Play Album** - Play the entire album from the beginning
- **Add Album to Queue** - Add all album tracks to queue
- **Play Album Next** - Add album tracks to beginning of queue
- **Add to Favorites** - Add album to favorites
- **Go to Artist** - Navigate to the album's artist
- **Album Info** - View detailed album information
- **Share Album** - Share the album
### Artist Context Menu
- **Play All Songs** - Play all songs by the artist
- **Add All to Queue** - Add all artist songs to queue
- **Play All Next** - Add all artist songs to beginning of queue
- **Add to Favorites** - Add artist to favorites
- **Artist Info** - View detailed artist information
- **Share Artist** - Share the artist
## Where to Find These Features
### Keyboard Shortcuts
- Available globally throughout the application
- Work in main player, fullscreen player, and all pages
- Search shortcut (/) works from any page
### Queue Management
- **Queue Page**: `/queue` - Full drag and drop interface
- **Mini Player**: Shows current track and basic controls
- **Fullscreen Player**: Queue management button available
### Context Menus
- **Search Results**: Right-click on any track, album, or artist
- **Album Pages**: Right-click on individual tracks
- **Artist Pages**: Right-click on tracks and albums
- **Queue Page**: Right-click on queued tracks
- **Library Browse**: Right-click on any item
## Technical Implementation
### Components Used
- `useKeyboardShortcuts` hook for global keyboard shortcuts
- `@dnd-kit` for drag and drop functionality
- `@radix-ui/react-context-menu` for context menus
- Custom context menu components for different content types
### Accessibility
- Full keyboard navigation support
- Screen reader compatible
- Focus management
- ARIA labels and descriptions
- High contrast support
## Tips for Users
1. **Keyboard Shortcuts**: Most shortcuts work anywhere in the app, just start typing
2. **Queue Reordering**: Hover over queue items to see the drag handle
3. **Context Menus**: Right-click almost anything to see available actions
4. **Quick Search**: Press `/` from anywhere to jump to search
5. **Volume Control**: Use arrow keys for precise volume adjustment
## Future Enhancements
Potential future additions:
- Custom keyboard shortcut configuration
- More queue management options (clear queue, save as playlist)
- Additional context menu actions (edit metadata, download)
- Gesture support for mobile devices
- Queue templates and smart playlists

168
OFFLINE_DOWNLOADS.md Normal file
View File

@@ -0,0 +1,168 @@
# Offline Downloads Feature
This document describes the offline downloads functionality implemented in the Mice Navidrome client.
## Overview
The offline downloads feature allows users to download music for offline listening using modern web technologies including Service Workers and Cache API, with localStorage fallback for browsers without Service Worker support.
## Components
### 1. Service Worker (`/public/sw.js`)
- Handles audio, image, and API caching
- Manages download operations in the background
- Provides offline audio playback capabilities
- Implements cache-first strategy for downloaded content
### 2. Download Manager Hook (`/hooks/use-offline-downloads.ts`)
- Provides React interface for download operations
- Manages download progress and status
- Handles Service Worker communication
- Provides localStorage fallback for metadata
### 3. Cache Management Component (`/app/components/CacheManagement.tsx`)
- Enhanced to show offline download statistics
- Displays download progress during operations
- Lists downloaded content with removal options
- Shows Service Worker support status
### 4. Offline Indicator Component (`/app/components/OfflineIndicator.tsx`)
- Shows download status for albums and songs
- Provides download/remove buttons
- Displays visual indicators on album artwork
## Features
### Download Capabilities
- **Album Downloads**: Download entire albums with all tracks and artwork
- **Individual Song Downloads**: Download single tracks
- **Progress Tracking**: Real-time download progress with track-by-track updates
- **Error Handling**: Graceful handling of failed downloads with retry options
### Visual Indicators
- **Album Artwork**: Small download icon in top-right corner of downloaded albums
- **Album Pages**: Download buttons and status indicators
- **Song Lists**: Individual download indicators for tracks
- **Library View**: Visual badges showing offline availability
### Offline Storage
- **Service Worker Cache**: True offline storage for audio files and images
- **localStorage Fallback**: Metadata-only storage for limited browser support
- **Progressive Enhancement**: Works in all browsers with varying capabilities
### Cache Management
- **Storage Statistics**: Shows total offline storage usage
- **Content Management**: List and remove downloaded content
- **Cache Cleanup**: Clear expired and unnecessary cache data
- **Progress Monitoring**: Real-time download progress display
## Usage
### Downloading Content
#### From Album Page
1. Navigate to any album page
2. Click the "Download" button (desktop) or small download button (mobile)
3. Monitor progress in the cache management section
4. Downloaded albums show indicators on artwork and in lists
#### From Settings Page
1. Go to Settings → Cache & Offline Downloads
2. View current download statistics
3. Monitor active downloads
4. Manage downloaded content
### Managing Downloads
#### Viewing Downloaded Content
- Settings page shows list of all downloaded albums and songs
- Album artwork displays download indicators
- Individual songs show download status
#### Removing Downloads
- Use the "X" button next to items in the cache management list
- Use "Remove Download" button on album pages
- Clear all cache to remove everything
## Technical Implementation
### Service Worker Features
- **Audio Caching**: Streams are cached using the Subsonic API
- **Image Caching**: Album artwork and avatars cached separately
- **API Caching**: Metadata cached with network-first strategy
- **Background Downloads**: Downloads continue even when page is closed
### Browser Compatibility
- **Full Support**: Modern browsers with Service Worker support
- **Limited Support**: Older browsers get metadata-only caching
- **Progressive Enhancement**: Features gracefully degrade
### Storage Strategy
- **Audio Cache**: Large files stored in Service Worker cache
- **Image Cache**: Artwork cached separately for optimization
- **Metadata Cache**: Song/album information in localStorage
- **Size Management**: Automatic cleanup of old cached content
## Configuration
### Environment Variables
The offline downloads use existing Navidrome configuration:
- `NEXT_PUBLIC_NAVIDROME_URL`
- `NEXT_PUBLIC_NAVIDROME_USERNAME`
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`
### Cache Limits
- Default audio cache: No explicit limit (browser manages)
- Image cache: Optimized sizes based on display requirements
- Metadata: Stored in localStorage with cleanup
## Development Notes
### File Structure
```
hooks/
use-offline-downloads.ts # Main download hook
app/components/
CacheManagement.tsx # Enhanced cache UI
OfflineIndicator.tsx # Download status components
album-artwork.tsx # Updated with indicators
album/[id]/
page.tsx # Enhanced with download buttons
public/
sw.js # Service Worker implementation
```
### API Integration
- Uses existing Navidrome API endpoints
- Leverages Subsonic streaming URLs
- Integrates with current authentication system
- Compatible with existing cache infrastructure
## Future Enhancements
### Planned Features
- **Playlist Downloads**: Download entire playlists
- **Smart Sync**: Automatic download of favorites
- **Storage Limits**: User-configurable storage limits
- **Download Scheduling**: Queue downloads for later
- **Offline Mode Detection**: Automatic offline behavior
### Performance Optimizations
- **Compression**: Audio compression options
- **Quality Selection**: Choose download quality
- **Selective Sync**: Download only specific tracks
- **Background Sync**: Download during idle time
## Troubleshooting
### Common Issues
- **Service Worker not registering**: Check browser console
- **Downloads failing**: Verify Navidrome server connection
- **Storage full**: Clear cache or check browser storage limits
- **Slow downloads**: Check network connection and server performance
### Debug Information
- Browser Developer Tools → Application → Service Workers
- Cache storage inspection in DevTools
- Console logs for download progress and errors
- Network tab for failed requests

135
SPOTLIGHT_SEARCH.md Normal file
View File

@@ -0,0 +1,135 @@
# Spotlight Search Feature
## Overview
The Spotlight Search feature provides a macOS Spotlight-style search interface for your music library, enhanced with Last.fm metadata for rich music information.
## Features
### 🔍 **Instant Search**
- **Global Search**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux) from anywhere in the app
- **Real-time Results**: Search as you type with 300ms debouncing
- **Multiple Types**: Search across tracks, albums, and artists simultaneously
### ⌨️ **Keyboard Navigation**
- `↑`/`↓` arrows to navigate results
- `Enter` to select and play/view
- `Tab` to show detailed information
- `Esc` to close (or close details panel)
### 🎵 **Quick Actions**
- **Play Now**: Click on any result to play immediately
- **Play Next**: Add track to the beginning of queue
- **Add to Queue**: Add track to the end of queue
- **Show Details**: Get rich information from Last.fm
### 🌍 **Last.fm Integration**
When viewing details, you'll see:
- **Artist Biography**: Rich biographical information
- **Statistics**: Play counts and listener numbers
- **Tags**: Genre and style tags
- **Similar Artists**: Discover new music based on your selections
- **Album Art**: High-quality images
## Usage
### Opening Search
- **Keyboard**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux)
- **Mouse**: Click the search button in the top menu bar (desktop)
- **Mobile**: Tap the search icon in the bottom navigation
### Search Tips
- Type partial song names, artist names, or album titles
- Results appear in real-time as you type
- Use keyboard navigation for fastest access
- Press Tab to see detailed Last.fm information
### Quick Actions
- **Tracks**: Play, Play Next, Add to Queue
- **Albums**: View album page, Add entire album to queue
- **Artists**: View artist page, Play all songs
## Last.fm Data
The search integrates with Last.fm to provide:
### Artist Information
- **Bio**: Artist biography and background
- **Stats**: Total plays and listeners globally
- **Similar**: Artists with similar style
- **Tags**: Genre classification and style tags
### Enhanced Discovery
- Click on similar artists to search for them
- Explore tags to discover new genres
- View play statistics to understand popularity
## Keyboard Shortcuts Summary
| Shortcut | Action |
|----------|--------|
| `Cmd+K` / `Ctrl+K` | Open Spotlight Search |
| `↑` / `↓` | Navigate results |
| `Enter` | Select result |
| `Tab` | Show details |
| `Esc` | Close search/details |
| `Space` | Play/Pause (when not in search) |
| `←` / `→` | Previous/Next track |
| `↑` / `↓` | Volume up/down (when not in search) |
| `M` | Toggle mute |
## Implementation Details
### Architecture
- **Global Context**: `GlobalSearchProvider` manages search state
- **Component**: `SpotlightSearch` handles UI and interactions
- **Hooks**: `useKeyboardShortcuts` for global hotkeys
- **Integration**: Uses existing Navidrome search API + Last.fm API
### Performance
- **Debounced Search**: 300ms delay prevents excessive API calls
- **Keyboard Optimized**: All interactions available via keyboard
- **Lazy Loading**: Last.fm data loaded only when details are viewed
- **Caching**: Search results cached during session
### Accessibility
- **Keyboard Navigation**: Full keyboard support
- **Screen Reader**: Proper ARIA labels and descriptions
- **Focus Management**: Automatic focus on search input
- **Visual Feedback**: Clear hover and selection states
## Future Enhancements
### Planned Features
- **Search History**: Remember recent searches
- **Smart Suggestions**: AI-powered search suggestions
- **Scoped Search**: Filter by type (tracks only, albums only, etc.)
- **Advanced Filters**: Date ranges, genres, etc.
- **Playlist Integration**: Search within specific playlists
### Last.fm Enhancements
- **Track Information**: Individual track details from Last.fm
- **Album Reviews**: User reviews and ratings
- **Concert Information**: Upcoming shows and tour dates
- **Scrobbling Integration**: Enhanced scrobbling with search data
## Troubleshooting
### Search Not Working
1. Check Navidrome connection in settings
2. Verify network connectivity
3. Try refreshing the page
### Last.fm Data Missing
1. Last.fm API may be unavailable
2. Artist/album may not exist in Last.fm database
3. Network connectivity issues
### Keyboard Shortcuts Not Working
1. Ensure you're not in an input field
2. Check if fullscreen mode is interfering
3. Try clicking outside any input fields first
The Spotlight Search feature transforms how you discover and interact with your music library, making it faster and more intuitive than ever before!

View File

@@ -13,6 +13,9 @@ 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();
@@ -26,6 +29,8 @@ 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 () => {
@@ -121,6 +126,31 @@ 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
@@ -162,6 +192,15 @@ 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 */}
@@ -173,6 +212,18 @@ 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>
@@ -196,12 +247,36 @@ export default function AlbumPage() {
<Link href={`/artist/${album.artistId}`}>
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
</Link>
{/* Controls row */}
<div className="flex items-center gap-3">
<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>
@@ -237,6 +312,12 @@ 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

@@ -1,90 +1,53 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
'use client';
import { useState, useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { AlbumArtwork } from '@/app/components/album-artwork';
import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Album } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Shuffle } from 'lucide-react';
import { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading';
import {
Shuffle,
ArrowDown,
RefreshCcw,
Loader2
} from 'lucide-react';
import Loading from '@/app/components/loading';
import { useInView } from 'react-intersection-observer';
export default function BrowsePage() {
const { artists, isLoading: contextLoading } = useNavidrome();
const { shuffleAllAlbums } = useAudioPlayer();
const [albums, setAlbums] = useState<Album[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const [isLoadingAlbums, setIsLoadingAlbums] = useState(false);
const [hasMoreAlbums, setHasMoreAlbums] = useState(true);
const albumsPerPage = 84;
const api = getNavidromeAPI();
const loadAlbums = async (page: number, append: boolean = false) => {
if (!api) {
console.error('Navidrome API not available');
return;
}
// Use our progressive loading hook
const {
albums,
isLoading,
hasMore,
loadMoreAlbums,
refreshAlbums
} = useProgressiveAlbumLoading('alphabeticalByName');
try {
setIsLoadingAlbums(true);
const offset = page * albumsPerPage;
// Use alphabeticalByName to get all albums in alphabetical order
const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset);
if (append) {
setAlbums(prev => [...prev, ...newAlbums]);
} else {
setAlbums(newAlbums);
}
// If we got fewer albums than requested, we've reached the end
setHasMoreAlbums(newAlbums.length === albumsPerPage);
} catch (error) {
console.error('Failed to load albums:', error);
} finally {
setIsLoadingAlbums(false);
}
};
// Infinite scroll with intersection observer
const { ref, inView } = useInView({
threshold: 0.1,
triggerOnce: false
});
// Load more albums when the load more sentinel comes into view
useEffect(() => {
loadAlbums(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Infinite scroll handler
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (!target || isLoadingAlbums || !hasMoreAlbums) return;
const { scrollTop, scrollHeight, clientHeight } = target;
const threshold = 200; // Load more when 200px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMore();
if (inView && hasMore && !isLoading) {
loadMoreAlbums();
}
};
}, [inView, hasMore, isLoading, loadMoreAlbums]);
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
if (scrollArea) {
scrollArea.addEventListener('scroll', handleScroll);
return () => scrollArea.removeEventListener('scroll', handleScroll);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingAlbums, hasMoreAlbums, currentPage]);
const loadMore = () => {
if (isLoadingAlbums || !hasMoreAlbums) return;
const nextPage = currentPage + 1;
setCurrentPage(nextPage);
loadAlbums(nextPage, true);
};
// Pull-to-refresh simulation
const handleRefresh = useCallback(() => {
refreshAlbums();
}, [refreshAlbums]);
if (contextLoading) {
return <Loading />;
@@ -137,6 +100,10 @@ export default function BrowsePage() {
Browse the full collection of albums ({albums.length} loaded).
</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCcw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
<Separator className="my-4" />
<div className="relative grow">
@@ -154,24 +121,47 @@ export default function BrowsePage() {
/>
))}
</div>
{hasMoreAlbums && (
<div className="flex justify-center p-4 pb-24">
<Button
onClick={loadMore}
disabled={isLoadingAlbums}
variant="outline"
{/* Load more sentinel */}
{hasMore && (
<div
ref={ref}
className="flex justify-center p-4 pb-24"
>
{isLoadingAlbums ? 'Loading...' : `Load More Albums (${albumsPerPage} more)`}
<Button
onClick={loadMoreAlbums}
disabled={isLoading}
variant="ghost"
className="flex flex-col items-center gap-2"
>
{isLoading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<ArrowDown className="h-6 w-6" />
)}
<span className="text-sm">
{isLoading ? 'Loading...' : 'Load More Albums'}
</span>
</Button>
</div>
)}
{!hasMoreAlbums && albums.length > 0 && (
{!hasMore && albums.length > 0 && (
<div className="flex justify-center p-4 pb-24">
<p className="text-sm text-muted-foreground">
All albums loaded ({albums.length} total)
</p>
</div>
)}
{albums.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center p-12">
<p className="text-lg font-medium mb-2">No albums found</p>
<p className="text-sm text-muted-foreground mb-4">
Try refreshing or check your connection
</p>
<Button onClick={handleRefresh}>Refresh</Button>
</div>
)}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>

View File

@@ -11,9 +11,27 @@ import { useToast } from '@/hooks/use-toast';
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useIsMobile } from '@/hooks/use-mobile';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useGlobalSearch } from './GlobalSearchProvider';
import { DraggableMiniPlayer } from './DraggableMiniPlayer';
export const AudioPlayer: React.FC = () => {
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
const {
currentTrack,
playPreviousTrack,
addToQueue,
playNextTrack,
clearQueue,
queue,
toggleShuffle,
shuffle,
toggleCurrentTrackStar,
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
audioEffects
} = useAudioPlayer();
const router = useRouter();
const isMobile = useIsMobile();
@@ -31,6 +49,8 @@ export const AudioPlayer: React.FC = () => {
const [volume, setVolume] = useState(1);
const [isClient, setIsClient] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
// Notifications and title management
const [lastNotifiedTrackId, setLastNotifiedTrackId] = useState<string | null>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const audioCurrent = audioRef.current;
@@ -114,6 +134,7 @@ export const AudioPlayer: React.FC = () => {
useEffect(() => {
setIsClient(true);
if (currentTrack) {
// Load saved volume
const savedVolume = localStorage.getItem('navidrome-volume');
if (savedVolume) {
@@ -126,6 +147,7 @@ export const AudioPlayer: React.FC = () => {
console.error('Failed to parse saved volume:', error);
}
}
}
// Mobile-specific audio initialization
if (isMobile) {
@@ -219,17 +241,22 @@ export const AudioPlayer: React.FC = () => {
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}, [isMobile, audioInitialized, volume]);
}, [isMobile, audioInitialized, volume, currentTrack]);
// Apply volume to audio element when volume changes
useEffect(() => {
const audioCurrent = audioRef.current;
if (audioCurrent) {
// Apply volume through audio effects chain if available
if (audioEffects) {
audioEffects.setVolume(volume);
} else {
audioCurrent.volume = volume;
}
}
// Save volume to localStorage
localStorage.setItem('navidrome-volume', volume.toString());
}, [volume]);
}, [volume, audioEffects]);
// Save position when component unmounts or track changes
useEffect(() => {
@@ -243,6 +270,7 @@ export const AudioPlayer: React.FC = () => {
useEffect(() => {
const audioCurrent = audioRef.current;
const preloadAudioCurrent = preloadAudioRef.current;
if (currentTrack && audioCurrent && audioCurrent.src !== currentTrack.url) {
// Always clear current track time when changing tracks
@@ -256,6 +284,20 @@ export const AudioPlayer: React.FC = () => {
return;
}
// If we have audio effects and ReplayGain is enabled, apply it
if (audioEffects && audioSettings.replayGainEnabled && currentTrack.replayGain) {
audioEffects.setReplayGain(currentTrack.replayGain);
}
// For gapless playback, start preloading the next track
if (audioSettings.gaplessPlayback && queue.length > 0) {
const nextTrack = queue[0];
if (preloadAudioCurrent && nextTrack) {
preloadAudioCurrent.src = nextTrack.url;
preloadAudioCurrent.load();
}
}
// Debug: Log current audio element state
console.log('🔍 Audio element state before loading:', {
src: audioCurrent.src,
@@ -368,7 +410,7 @@ export const AudioPlayer: React.FC = () => {
setIsPlaying(false);
}
}
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]);
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized, audioEffects, audioSettings.gaplessPlayback, audioSettings.replayGainEnabled, queue]);
useEffect(() => {
const audioCurrent = audioRef.current;
@@ -397,6 +439,12 @@ export const AudioPlayer: React.FC = () => {
// Notify scrobbler about track end
onTrackEnd(currentTrack, audioCurrent.currentTime, audioCurrent.duration);
// If crossfade is enabled and we have more tracks in queue
if (audioSettings.crossfadeDuration > 0 && queue.length > 0 && audioEffects) {
// Start fading out current track
audioEffects.setCrossfadeTime(audioSettings.crossfadeDuration);
}
}
playNextTrack();
};
@@ -440,7 +488,50 @@ export const AudioPlayer: React.FC = () => {
audioCurrent.removeEventListener('pause', handlePause);
}
};
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause, audioEffects, audioSettings.crossfadeDuration, queue.length]);
// Update document title and optionally show a notification when a new song starts
useEffect(() => {
if (!isClient || !currentTrack) {
if (!currentTrack) {
document.title = 'mice';
}
return;
}
// Update favicon/title like Spotify
const baseTitle = `${currentTrack.name}${currentTrack.artist} mice`;
document.title = isPlaying ? baseTitle : `(Paused) ${baseTitle}`;
// Notifications
const notifyEnabled = localStorage.getItem('playback-notifications-enabled') === 'true';
const canNotify = 'Notification' in window && Notification.permission !== 'denied';
if (notifyEnabled && canNotify && lastNotifiedTrackId !== currentTrack.id) {
try {
if (Notification.permission === 'default') {
Notification.requestPermission().then((perm) => {
if (perm === 'granted') {
new Notification('Now Playing', {
body: `${currentTrack.name}${currentTrack.artist}`,
icon: currentTrack.coverArt || '/icon-192.png',
badge: '/icon-192.png',
});
setLastNotifiedTrackId(currentTrack.id);
}
});
} else if (Notification.permission === 'granted') {
new Notification('Now Playing', {
body: `${currentTrack.name}${currentTrack.artist}`,
icon: currentTrack.coverArt || '/icon-192.png',
badge: '/icon-192.png',
});
setLastNotifiedTrackId(currentTrack.id);
}
} catch (e) {
console.warn('Notification failed:', e);
}
}
}, [currentTrack, isPlaying, isClient, lastNotifiedTrackId]);
// Media Session API integration - Enhanced for mobile
useEffect(() => {
@@ -710,6 +801,34 @@ export const AudioPlayer: React.FC = () => {
}
}
};
// Volume control functions for keyboard shortcuts
const handleVolumeUp = useCallback(() => {
setVolume(prevVolume => Math.min(1, prevVolume + 0.1));
}, []);
const handleVolumeDown = useCallback(() => {
setVolume(prevVolume => Math.max(0, prevVolume - 0.1));
}, []);
const handleToggleMute = useCallback(() => {
setVolume(prevVolume => prevVolume === 0 ? 1 : 0);
}, []);
const { openSpotlight } = useGlobalSearch();
// Set up keyboard shortcuts
useKeyboardShortcuts({
onPlayPause: togglePlayPause,
onNextTrack: playNextTrack,
onPreviousTrack: playPreviousTrack,
onVolumeUp: handleVolumeUp,
onVolumeDown: handleVolumeDown,
onToggleMute: handleToggleMute,
onSpotlightSearch: openSpotlight,
disabled: !currentTrack || isFullScreen // Disable if no track or in fullscreen (let FullScreenPlayer handle it)
});
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
@@ -819,54 +938,7 @@ export const AudioPlayer: React.FC = () => {
if (isMinimized) {
return (
<>
<div className="fixed bottom-4 left-4 z-50">
<div
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
onClick={() => setIsMinimized(false)}
>
<div className="flex items-center p-3">
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
width={40}
height={40}
className="w-10 h-10 rounded-md shrink-0"
/>
<div className="flex-1 min-w-0 mx-3">
<div className="overflow-hidden">
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
{currentTrack.name}
</p>
</div>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
{/* Heart icon for favoriting */}
<button
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors mr-2"
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
/>
</button>
<div className="flex items-center justify-center space-x-2">
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
<FaBackward className="w-3 h-3" />
</button>
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
</button>
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
<FaForward className="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>
<DraggableMiniPlayer onExpand={() => setIsMinimized(false)} />
{/* Single audio element - shared across all UI states */}
<audio

View File

@@ -1,9 +1,10 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album, Artist } from '@/lib/navidrome';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Song } from '@/lib/navidrome';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from "@/hooks/use-toast";
import { AudioEffects } from '@/lib/audio-effects';
export interface Track {
id: string;
@@ -15,8 +16,16 @@ export interface Track {
coverArt?: string;
albumId: string;
artistId: string;
autoPlay?: boolean; // Flag to control auto-play
starred?: boolean; // Flag for starred/favorited tracks
autoPlay?: boolean;
starred?: boolean;
replayGain?: number; // Added ReplayGain field
}
interface AudioSettings {
crossfadeDuration: number;
equalizer: string;
replayGainEnabled: boolean;
gaplessPlayback: boolean;
}
interface AudioPlayerContextProps {
@@ -24,12 +33,14 @@ interface AudioPlayerContextProps {
playTrack: (track: Track, autoPlay?: boolean) => void;
queue: Track[];
addToQueue: (track: Track) => void;
insertAtBeginningOfQueue: (track: Track) => void;
playNextTrack: () => void;
clearQueue: () => void;
addAlbumToQueue: (albumId: string) => Promise<void>;
playAlbum: (albumId: string) => Promise<void>;
playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>;
removeTrackFromQueue: (index: number) => void;
reorderQueue: (oldIndex: number, newIndex: number) => void;
skipToTrackInQueue: (index: number) => void;
addArtistToQueue: (artistId: string) => Promise<void>;
playPreviousTrack: () => void;
@@ -42,16 +53,37 @@ interface AudioPlayerContextProps {
clearHistory: () => void;
toggleCurrentTrackStar: () => Promise<void>;
updateTrackStarred: (trackId: string, starred: boolean) => void;
// Audio settings
audioSettings: AudioSettings;
updateAudioSettings: (settings: Partial<AudioSettings>) => void;
equalizerPreset: string;
setEqualizerPreset: (preset: string) => void;
audioEffects: AudioEffects | null;
// Playback state
isPlaying: boolean;
togglePlayPause: () => Promise<void>;
}
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
export // Default audio settings
const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
crossfadeDuration: 3,
equalizer: 'normal',
replayGainEnabled: true,
gaplessPlayback: true
};
export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [queue, setQueue] = useState<Track[]>([]);
const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [shuffle, setShuffle] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioSettings>(DEFAULT_AUDIO_SETTINGS);
const [equalizerPreset, setEqualizerPreset] = useState('normal');
const [audioEffects, setAudioEffects] = useState<AudioEffects | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { toast } = useToast();
const api = useMemo(() => {
const navidromeApi = getNavidromeAPI();
@@ -102,6 +134,73 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
}, [currentTrack]);
// Initialize audio effects when audio element is available
useEffect(() => {
const audioElement = audioRef.current;
if (audioElement && !audioEffects) {
const effects = new AudioEffects(audioElement);
setAudioEffects(effects);
// Load saved audio settings
const savedSettings = localStorage.getItem('navidrome-audio-settings');
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings);
setAudioSettings(settings);
effects.setPreset(settings.equalizer);
setEqualizerPreset(settings.equalizer);
} catch (error) {
console.error('Failed to load audio settings:', error);
}
}
return () => {
effects.disconnect();
};
}
}, [audioEffects]);
// Save all audio-related settings
const saveSettings = useCallback(() => {
try {
// Save audio settings
localStorage.setItem('navidrome-audio-settings', JSON.stringify(audioSettings));
// Save equalizer preset
localStorage.setItem('navidrome-equalizer-preset', equalizerPreset);
// Save other playback settings
const playbackSettings = {
replayGainEnabled: audioSettings.replayGainEnabled,
gaplessPlayback: audioSettings.gaplessPlayback,
crossfadeDuration: audioSettings.crossfadeDuration,
volume: audioRef.current?.volume || 1,
lastPosition: audioRef.current?.currentTime || 0
};
localStorage.setItem('navidrome-playback-settings', JSON.stringify(playbackSettings));
} catch (error) {
console.error('Failed to save settings:', error);
}
}, [audioSettings, equalizerPreset]);
// Save settings whenever they change
useEffect(() => {
saveSettings();
}, [audioSettings, equalizerPreset, saveSettings]);
// Update equalizer when preset changes
useEffect(() => {
if (audioEffects) {
audioEffects.setPreset(equalizerPreset);
}
}, [equalizerPreset, audioEffects]);
const updateAudioSettings = useCallback((settings: Partial<AudioSettings>) => {
setAudioSettings(prev => {
const newSettings = { ...prev, ...settings };
localStorage.setItem('navidrome-audio-settings', JSON.stringify(newSettings));
return newSettings;
});
}, []);
const songToTrack = useMemo(() => (song: Song): Track => {
if (!api) {
throw new Error('Navidrome API not configured');
@@ -120,7 +219,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
starred: !!song.starred,
replayGain: song.replayGain || 0 // Add ReplayGain support
};
}, [api]);
@@ -130,6 +230,40 @@ 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
@@ -159,6 +293,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
});
}, [shuffle]);
const insertAtBeginningOfQueue = useCallback((track: Track) => {
setQueue((prevQueue) => [track, ...prevQueue]);
}, []);
const clearQueue = useCallback(() => {
setQueue([]);
}, []);
@@ -167,6 +305,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index));
}, []);
const reorderQueue = useCallback((oldIndex: number, newIndex: number) => {
setQueue((prevQueue) => {
const newQueue = [...prevQueue];
const [movedItem] = newQueue.splice(oldIndex, 1);
newQueue.splice(newIndex, 0, movedItem);
return newQueue;
});
}, []);
const playNextTrack = useCallback(() => {
// Clear saved timestamp when changing tracks
localStorage.removeItem('navidrome-current-track-time');
@@ -573,15 +720,43 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
}
}, []);
// Track playback state
const [isPlaying, setIsPlaying] = useState(false);
// Shared playback control function
const togglePlayPause = useCallback(async () => {
const audioElement = audioRef.current;
if (!audioElement || !currentTrack) return;
try {
if (isPlaying) {
audioElement.pause();
setIsPlaying(false);
} else {
await audioElement.play();
setIsPlaying(true);
}
} catch (error) {
console.error('Failed to toggle playback:', error);
toast({
variant: "destructive",
title: "Playback Error",
description: "Failed to control playback. Please try again.",
});
}
}, [currentTrack, isPlaying, toast]);
const contextValue = useMemo(() => ({
currentTrack,
playTrack,
queue,
addToQueue,
insertAtBeginningOfQueue,
playNextTrack,
clearQueue,
addAlbumToQueue,
removeTrackFromQueue,
reorderQueue,
addArtistToQueue,
playPreviousTrack,
isLoading,
@@ -594,6 +769,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playArtist,
playedTracks,
clearHistory,
// Audio settings
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
audioEffects,
// Playback state
isPlaying,
togglePlayPause,
toggleCurrentTrackStar: async () => {
if (!currentTrack || !api) {
toast({
@@ -668,10 +852,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
isLoading,
playTrack,
addToQueue,
insertAtBeginningOfQueue,
playNextTrack,
clearQueue,
addAlbumToQueue,
removeTrackFromQueue,
reorderQueue,
addArtistToQueue,
playPreviousTrack,
playAlbum,
@@ -684,7 +870,14 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playedTracks,
clearHistory,
api,
toast
toast,
audioEffects,
audioSettings,
equalizerPreset,
updateAudioSettings,
setEqualizerPreset,
isPlaying,
togglePlayPause
]);
return (

View File

@@ -0,0 +1,102 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useAudioPlayer } from "./AudioPlayerContext";
import { presets } from "@/lib/audio-effects";
interface AudioSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function AudioSettingsDialog({ isOpen, onClose }: AudioSettingsDialogProps) {
const {
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
} = useAudioPlayer();
const handleCrossfadeChange = (value: number[]) => {
updateAudioSettings({ crossfadeDuration: value[0] });
};
const handleReplayGainToggle = (enabled: boolean) => {
updateAudioSettings({ replayGainEnabled: enabled });
};
const handleGaplessToggle = (enabled: boolean) => {
updateAudioSettings({ gaplessPlayback: enabled });
};
const handleEqualizerPresetChange = (preset: string) => {
setEqualizerPreset(preset);
updateAudioSettings({ equalizer: preset });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>
Configure playback settings and audio effects
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label>Crossfade Duration ({audioSettings.crossfadeDuration}s)</Label>
<Slider
value={[audioSettings.crossfadeDuration]}
onValueChange={handleCrossfadeChange}
min={0}
max={5}
step={0.5}
/>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<Label>ReplayGain</Label>
<Switch
checked={audioSettings.replayGainEnabled}
onCheckedChange={handleReplayGainToggle}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<Label>Gapless Playback</Label>
<Switch
checked={audioSettings.gaplessPlayback}
onCheckedChange={handleGaplessToggle}
/>
</div>
{/* Equalizer Presets */}
<div className="space-y-2">
<Label>Equalizer Preset</Label>
<div className="grid grid-cols-2 gap-2">
{Object.keys(presets).map((preset) => (
<Button
key={preset}
variant={preset === equalizerPreset ? "default" : "outline"}
onClick={() => handleEqualizerPresetChange(preset)}
className="w-full"
>
{presets[preset].name}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React, { useState } from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { MusicIcon, TagIcon, InfoIcon } from 'lucide-react';
import { AutoTaggingDialog } from './AutoTaggingDialog';
interface AutoTagContextMenuProps {
children: React.ReactNode;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export function AutoTagContextMenu({
children,
mode,
itemId,
itemName,
artistName
}: AutoTagContextMenuProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem
onClick={() => setIsDialogOpen(true)}
className="cursor-pointer"
>
<TagIcon className="mr-2 h-4 w-4" />
Auto-Tag {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</ContextMenuItem>
{mode === 'track' && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<InfoIcon className="mr-2 h-4 w-4" />
View Track Details
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<MusicIcon className="mr-2 h-4 w-4" />
Edit Track Metadata
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<AutoTaggingDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
mode={mode}
itemId={itemId}
itemName={itemName}
artistName={artistName}
/>
</>
);
}
export default AutoTagContextMenu;

View File

@@ -0,0 +1,319 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/hooks/use-toast";
import { useAutoTagging, EnhancedTrackMetadata, EnhancedAlbumMetadata } from "@/hooks/use-auto-tagging";
import { useIsMobile } from "@/hooks/use-mobile";
import {
MusicIcon,
AlbumIcon,
UsersIcon,
CheckCircle2Icon,
XCircleIcon,
AlertTriangleIcon,
InfoIcon
} from 'lucide-react';
import Image from 'next/image';
interface AutoTaggingDialogProps {
isOpen: boolean;
onClose: () => void;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export const AutoTaggingDialog: React.FC<AutoTaggingDialogProps> = ({
isOpen,
onClose,
mode,
itemId,
itemName,
artistName
}) => {
const isMobile = useIsMobile();
const { toast } = useToast();
const [confidenceThreshold, setConfidenceThreshold] = useState(70);
const [activeTab, setActiveTab] = useState<'tracks' | 'albums'>('tracks');
const [isApplying, setIsApplying] = useState(false);
const {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
} = useAutoTagging();
// Start auto-tagging when the dialog is opened
useEffect(() => {
if (isOpen && itemId && !isProcessing && progress === 0) {
// Wrap in try/catch to handle any errors that might occur during auto-tagging
try {
startAutoTagging(mode, itemId, confidenceThreshold);
} catch (error) {
console.error('Failed to start auto-tagging:', error);
toast({
title: "Auto-Tagging Error",
description: error instanceof Error ? error.message : "Failed to start auto-tagging",
variant: "destructive",
});
onClose();
}
}
}, [isOpen, itemId, mode, isProcessing, progress, startAutoTagging, confidenceThreshold, toast, onClose]);
// Set the active tab based on the mode
useEffect(() => {
if (mode === 'track') {
setActiveTab('tracks');
} else if (mode === 'album' || mode === 'artist') {
setActiveTab('albums');
}
}, [mode]);
const handleApplyMetadata = async () => {
try {
setIsApplying(true);
await applyEnhancedMetadata(
enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold),
enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold)
);
onClose();
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Error",
description: "Failed to apply metadata",
variant: "destructive",
});
} finally {
setIsApplying(false);
}
};
// Get match statistics
const matchedTracks = enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold).length;
const totalTracks = enhancedTracks.length;
const matchedAlbums = enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold).length;
const totalAlbums = enhancedAlbums.length;
const getStatusIcon = (status: 'pending' | 'matched' | 'failed' | 'applied', confidence: number) => {
if (status === 'pending') return <AlertTriangleIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'failed') return <XCircleIcon className="w-4 h-4 text-red-500" />;
if (status === 'matched' && confidence >= confidenceThreshold) return <CheckCircle2Icon className="w-4 h-4 text-green-500" />;
if (status === 'matched' && confidence < confidenceThreshold) return <InfoIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'applied') return <CheckCircle2Icon className="w-4 h-4 text-blue-500" />;
return null;
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 90) return 'bg-green-500';
if (confidence >= 70) return 'bg-green-400';
if (confidence >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
// Render the appropriate dialog/sheet based on mobile status
const DialogComponent = isMobile ? Sheet : Dialog;
const DialogContentComponent = isMobile ? SheetContent : DialogContent;
const DialogHeaderComponent = isMobile ? SheetHeader : DialogHeader;
const DialogTitleComponent = isMobile ? SheetTitle : DialogTitle;
const DialogDescriptionComponent = isMobile ? SheetDescription : DialogDescription;
return (
<DialogComponent open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContentComponent className={isMobile ? "p-0 pt-8" : "max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"}>
<DialogHeaderComponent className={isMobile ? "p-6 pb-2" : ""}>
<DialogTitleComponent>
Auto-Tagging {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</DialogTitleComponent>
<DialogDescriptionComponent>
{isProcessing ? (
`Analyzing ${mode === 'track' ? 'track' : mode === 'album' ? 'album' : 'artist'} "${itemName}"`
) : (
`Found metadata for ${matchedTracks} of ${totalTracks} tracks${totalAlbums > 0 ? ` and ${matchedAlbums} of ${totalAlbums} albums` : ''}`
)}
</DialogDescriptionComponent>
{/* Progress bar */}
{(isProcessing || isApplying) && (
<div className="my-4">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground mt-2">
{isProcessing ? 'Analyzing metadata...' : 'Applying metadata...'}
</p>
</div>
)}
</DialogHeaderComponent>
{/* Tabs for tracks and albums */}
{!isProcessing && !isApplying && (
<div className={`flex-1 overflow-hidden flex flex-col ${isMobile ? "px-6" : ""}`}>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'tracks' | 'albums')} className="flex-1 flex flex-col">
<div className="flex justify-between items-center mb-2">
<TabsList>
<TabsTrigger value="tracks" disabled={totalTracks === 0}>
<MusicIcon className="w-4 h-4 mr-2" /> Tracks ({matchedTracks}/{totalTracks})
</TabsTrigger>
<TabsTrigger value="albums" disabled={totalAlbums === 0}>
<AlbumIcon className="w-4 h-4 mr-2" /> Albums ({matchedAlbums}/{totalAlbums})
</TabsTrigger>
</TabsList>
{/* Confidence threshold slider */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Min. Confidence: {confidenceThreshold}%</span>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(parseInt(e.target.value))}
className="w-24"
/>
</div>
</div>
{/* Tracks tab content */}
<TabsContent value="tracks" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="rounded-md border">
<div className="bg-muted p-2 grid grid-cols-12 gap-2 text-sm font-medium">
<div className="col-span-1"></div>
<div className="col-span-4">Title</div>
<div className="col-span-3">Artist</div>
<div className="col-span-2">Album</div>
<div className="col-span-2 text-right">Confidence</div>
</div>
<div className="divide-y max-h-[50vh] overflow-auto">
{enhancedTracks.map(track => (
<div key={track.id} className="grid grid-cols-12 gap-2 p-2 items-center">
<div className="col-span-1">
{getStatusIcon(track.status, track.confidence)}
</div>
<div className="col-span-4 truncate">
{track.title}
</div>
<div className="col-span-3 truncate">
{track.artist}
</div>
<div className="col-span-2 truncate">
{track.album}
</div>
<div className="col-span-2 flex justify-end items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(track.confidence)}`}
style={{ width: `${track.confidence}%` }}
/>
</div>
<span className="text-xs">{track.confidence}%</span>
</div>
</div>
))}
</div>
</div>
</TabsContent>
{/* Albums tab content */}
<TabsContent value="albums" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[50vh] overflow-auto p-1">
{enhancedAlbums.map(album => (
<div key={album.id} className="border rounded-lg overflow-hidden">
<div className="flex">
{/* Album cover */}
<div className="relative w-24 h-24">
{album.coverArtUrl ? (
<Image
src={album.coverArtUrl}
alt={album.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<AlbumIcon className="w-8 h-8 text-muted-foreground" />
</div>
)}
{/* Status badge */}
<div className="absolute top-1 left-1">
{getStatusIcon(album.status, album.confidence)}
</div>
</div>
{/* Album info */}
<div className="flex-1 p-3">
<h4 className="font-medium text-sm truncate">{album.name}</h4>
<p className="text-xs text-muted-foreground truncate">{album.artist}</p>
<div className="mt-2 flex items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(album.confidence)}`}
style={{ width: `${album.confidence}%` }}
/>
</div>
<span className="text-xs">{album.confidence}%</span>
</div>
{album.year && (
<p className="text-xs mt-1">Year: {album.year}</p>
)}
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter className={`${isMobile ? "p-6 pt-4" : "mt-4"}`}>
<div className="w-full flex flex-col md:flex-row justify-end gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isProcessing || isApplying}
>
Cancel
</Button>
<Button
onClick={handleApplyMetadata}
disabled={
isProcessing ||
isApplying ||
(matchedTracks === 0 && matchedAlbums === 0)
}
>
Apply Metadata
</Button>
</div>
</DialogFooter>
</DialogContentComponent>
</DialogComponent>
);
};
export default AutoTaggingDialog;

View File

@@ -0,0 +1,221 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { FaTags } from 'react-icons/fa';
import { useToast } from '@/hooks/use-toast';
import { AutoTaggingDialog } from './AutoTaggingDialog';
export const AutoTaggingSettings = () => {
const { toast } = useToast();
const [isClient, setIsClient] = useState(false);
const [autoTaggingEnabled, setAutoTaggingEnabled] = useState(false);
const [autoTagDialogOpen, setAutoTagDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState({
id: '',
name: 'Library',
mode: 'artist' as 'track' | 'album' | 'artist'
});
const [autoTagOptions, setAutoTagOptions] = useState({
rateLimit: 1000, // milliseconds between requests
autoProcess: false,
preferLocalMetadata: true,
tagsToUpdate: ['title', 'artist', 'album', 'year', 'genre'],
});
useEffect(() => {
setIsClient(true);
// Load saved preferences from localStorage
const savedAutoTagging = localStorage.getItem('auto-tagging-enabled');
if (savedAutoTagging !== null) {
setAutoTaggingEnabled(savedAutoTagging === 'true');
}
// Load saved auto-tag options
const savedOptions = localStorage.getItem('auto-tagging-options');
if (savedOptions !== null) {
try {
setAutoTagOptions(JSON.parse(savedOptions));
} catch (error) {
console.error('Failed to parse stored auto-tagging options:', error);
}
}
}, []);
const handleAutoTaggingToggle = (enabled: boolean) => {
setAutoTaggingEnabled(enabled);
if (isClient) {
localStorage.setItem('auto-tagging-enabled', enabled.toString());
}
toast({
title: enabled ? 'Auto-Tagging Enabled' : 'Auto-Tagging Disabled',
description: enabled
? 'Music will be automatically tagged with metadata from MusicBrainz'
: 'Auto-tagging has been disabled',
});
};
const handleOptionsChange = (key: string, value: unknown) => {
setAutoTagOptions(prev => {
const newOptions = { ...prev, [key]: value };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const handleTagSelectionChange = (tag: string, isSelected: boolean) => {
setAutoTagOptions(prev => {
const currentTags = [...prev.tagsToUpdate];
const newTags = isSelected
? [...currentTags, tag]
: currentTags.filter(t => t !== tag);
const newOptions = { ...prev, tagsToUpdate: newTags };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const isTagSelected = (tag: string) => {
return autoTagOptions.tagsToUpdate.includes(tag);
};
return (
<>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaTags className="w-5 h-5" />
Auto-Tagging
</CardTitle>
<CardDescription>
Configure metadata auto-tagging with MusicBrainz
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Enable Auto-Tagging</p>
<p className="text-sm text-muted-foreground">
Automatically fetch and apply metadata from MusicBrainz
</p>
</div>
<Switch
checked={autoTaggingEnabled}
onCheckedChange={handleAutoTaggingToggle}
/>
</div>
{autoTaggingEnabled && (
<>
<div className="space-y-2">
<Label htmlFor="rate-limit">API Rate Limit (ms)</Label>
<Input
id="rate-limit"
type="number"
min={500}
max={5000}
step={100}
value={autoTagOptions.rateLimit}
onChange={(e) => handleOptionsChange('rateLimit', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Time between API requests in milliseconds (min: 500ms)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Auto Process Results</p>
<p className="text-sm text-muted-foreground">
Automatically apply best matches without confirmation
</p>
</div>
<Switch
checked={autoTagOptions.autoProcess}
onCheckedChange={(checked) => handleOptionsChange('autoProcess', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Prefer Local Metadata</p>
<p className="text-sm text-muted-foreground">
Keep existing metadata when confidence is low
</p>
</div>
<Switch
checked={autoTagOptions.preferLocalMetadata}
onCheckedChange={(checked) => handleOptionsChange('preferLocalMetadata', checked)}
/>
</div>
<div className="space-y-2">
<Label>Tags to Update</Label>
<div className="grid grid-cols-2 gap-2">
{['title', 'artist', 'album', 'year', 'genre', 'albumArtist', 'trackNumber', 'discNumber'].map(tag => (
<div key={tag} className="flex items-center space-x-2">
<Switch
id={`tag-${tag}`}
checked={isTagSelected(tag)}
onCheckedChange={(checked) => handleTagSelectionChange(tag, checked)}
/>
<Label htmlFor={`tag-${tag}`} className="capitalize">
{tag === 'albumArtist' ? 'Album Artist' :
tag === 'trackNumber' ? 'Track Number' :
tag === 'discNumber' ? 'Disc Number' : tag}
</Label>
</div>
))}
</div>
</div>
<div className="pt-2">
<Button onClick={() => {
// Set selected item to represent the whole library
setSelectedItem({
id: 'library',
name: 'Full Library',
mode: 'artist'
});
setAutoTagDialogOpen(true);
}} variant="outline">
<FaTags className="w-4 h-4 mr-2" />
Open Auto-Tagging Tool
</Button>
</div>
</>
)}
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>How it works:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Metadata is fetched from MusicBrainz when you play tracks</li>
<li>Tags can be applied automatically or manually reviewed</li>
<li>Right-click on tracks or albums to tag them manually</li>
<li>MusicBrainz API has rate limits, so don&apos;t set too fast</li>
</ul>
</div>
</CardContent>
</Card>
<AutoTaggingDialog
isOpen={autoTagDialogOpen}
onClose={() => setAutoTagDialogOpen(false)}
mode={selectedItem.mode}
itemId={selectedItem.id}
itemName={selectedItem.name}
/>
</>
);
};

View File

@@ -3,6 +3,8 @@
import { useRouter, usePathname } from 'next/navigation';
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { useGlobalSearch } from './GlobalSearchProvider';
interface NavItem {
href: string;
@@ -20,9 +22,15 @@ const navigationItems: NavItem[] = [
export function BottomNavigation() {
const router = useRouter();
const pathname = usePathname();
const { openSpotlight } = useGlobalSearch();
const handleNavigation = (href: string) => {
if (href === '/search') {
// Use spotlight search instead of navigating to search page
openSpotlight();
} else {
router.push(href);
}
};
const isActive = (href: string) => {
@@ -40,7 +48,7 @@ export function BottomNavigation() {
const Icon = item.icon;
return (
<button
<motion.button
key={item.href}
onClick={() => handleNavigation(item.href)}
className={cn(
@@ -50,6 +58,8 @@ export function BottomNavigation() {
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
whileTap={{ scale: 0.95 }}
whileHover={{ y: -1 }}
>
<Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} />
<span className={cn(
@@ -58,7 +68,19 @@ export function BottomNavigation() {
)}>
{item.label}
</span>
</button>
<AnimatePresence>
{isItemActive && (
<motion.div
layoutId="bottom-nav-underline"
className="h-0.5 w-6 bg-primary mt-1 rounded"
initial={{ opacity: 0, scaleX: 0.6 }}
animate={{ opacity: 1, scaleX: 1 }}
exit={{ opacity: 0, scaleX: 0.6 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</motion.button>
);
})}
</div>

View File

@@ -1,16 +1,28 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import {
Database,
Trash2,
RefreshCw,
HardDrive
HardDrive,
Download,
Wifi,
WifiOff,
X,
Music,
Globe,
Settings
} from 'lucide-react';
import { CacheManager } from '@/lib/cache';
import { useOfflineDownloads, OfflineItem } from '@/hooks/use-offline-downloads';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
export function CacheManagement() {
const [cacheStats, setCacheStats] = useState({
@@ -20,6 +32,24 @@ export function CacheManagement() {
});
const [isClearing, setIsClearing] = useState(false);
const [lastCleared, setLastCleared] = useState<string | null>(null);
const [offlineItems, setOfflineItems] = useState<OfflineItem[]>([]);
const [offlineMode, setOfflineMode] = useState(false);
const [autoDownloadQueue, setAutoDownloadQueue] = useState(false);
const [isDownloadingQueue, setIsDownloadingQueue] = useState(false);
const {
isSupported: isOfflineSupported,
isInitialized: isOfflineInitialized,
downloadProgress,
offlineStats,
downloadQueue,
enableOfflineMode,
deleteOfflineContent,
getOfflineItems,
clearDownloadProgress
} = useOfflineDownloads();
const { queue } = useAudioPlayer();
const loadCacheStats = () => {
if (typeof window === 'undefined') return;
@@ -65,15 +95,34 @@ export function CacheManagement() {
});
};
const loadOfflineItems = useCallback(async () => {
if (isOfflineInitialized) {
const items = await getOfflineItems();
setOfflineItems(items);
}
}, [isOfflineInitialized, getOfflineItems]);
useEffect(() => {
loadCacheStats();
loadOfflineItems();
// Load offline mode settings
const storedOfflineMode = localStorage.getItem('offline-mode-enabled');
const storedAutoDownload = localStorage.getItem('auto-download-queue');
if (storedOfflineMode) {
setOfflineMode(JSON.parse(storedOfflineMode));
}
if (storedAutoDownload) {
setAutoDownloadQueue(JSON.parse(storedAutoDownload));
}
// Check if there's a last cleared timestamp
const lastClearedTime = localStorage.getItem('cache-last-cleared');
if (lastClearedTime) {
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
}
}, []);
}, [loadOfflineItems]);
const handleClearCache = async () => {
setIsClearing(true);
@@ -142,19 +191,105 @@ export function CacheManagement() {
loadCacheStats();
};
const handleDeleteOfflineItem = async (item: OfflineItem) => {
try {
await deleteOfflineContent(item.id, item.type);
loadOfflineItems();
loadCacheStats();
} catch (error) {
console.error('Failed to delete offline item:', error);
}
};
// Convert Track to Song format for offline downloads
const convertTrackToSong = (track: Track) => ({
id: track.id,
parent: track.albumId || '',
isDir: false,
title: track.name,
album: track.album,
artist: track.artist,
size: 0, // Will be filled when downloaded
contentType: 'audio/mpeg',
suffix: 'mp3',
duration: track.duration,
path: '',
created: new Date().toISOString(),
albumId: track.albumId,
artistId: track.artistId,
type: 'music'
});
const handleOfflineModeToggle = async (enabled: boolean) => {
setOfflineMode(enabled);
localStorage.setItem('offline-mode-enabled', JSON.stringify(enabled));
if (enabled && isOfflineSupported) {
try {
const convertedQueue = queue.map(convertTrackToSong);
await enableOfflineMode({
forceOffline: enabled,
autoDownloadQueue,
currentQueue: convertedQueue
});
} catch (error) {
console.error('Failed to enable offline mode:', error);
}
}
};
const handleAutoDownloadToggle = async (enabled: boolean) => {
setAutoDownloadQueue(enabled);
localStorage.setItem('auto-download-queue', JSON.stringify(enabled));
if (enabled && isOfflineSupported) {
const convertedQueue = queue.map(convertTrackToSong);
await enableOfflineMode({
forceOffline: offlineMode,
autoDownloadQueue: enabled,
currentQueue: convertedQueue
});
}
};
const handleDownloadCurrentQueue = async () => {
if (!queue.length || !isOfflineSupported) return;
setIsDownloadingQueue(true);
try {
const convertedQueue = queue.map(convertTrackToSong);
await downloadQueue(convertedQueue);
loadOfflineItems();
} catch (error) {
console.error('Failed to download queue:', error);
} finally {
setIsDownloadingQueue(false);
}
};
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<Card className="break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Cache Management
Cache & Offline Downloads
</CardTitle>
<CardDescription>
Manage application cache to improve performance and free up storage
Manage application cache and offline content for better performance
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Cache Statistics */}
<CardContent className="space-y-6">
{/* Regular Cache Statistics */}
<div>
<h4 className="text-sm font-medium mb-3">Application Cache</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.total}</p>
@@ -171,7 +306,7 @@ export function CacheManagement() {
</div>
{/* Cache Actions */}
<div className="space-y-2">
<div className="space-y-2 mt-4">
<div className="flex gap-2">
<Button
onClick={handleClearCache}
@@ -209,10 +344,212 @@ export function CacheManagement() {
Refresh Stats
</Button>
</div>
</div>
<Separator />
{/* Offline Downloads Section */}
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
{isOfflineSupported ? (
<Wifi className="h-4 w-4 text-green-600" />
) : (
<WifiOff className="h-4 w-4 text-red-600" />
)}
Offline Downloads
{!isOfflineSupported && (
<span className="text-xs text-muted-foreground">(Limited Support)</span>
)}
</h4>
{isOfflineSupported && (
<div className="grid grid-cols-3 gap-4 text-center mb-4">
<div className="space-y-1">
<p className="text-lg font-bold">{offlineStats.downloadedAlbums}</p>
<p className="text-xs text-muted-foreground">Albums</p>
</div>
<div className="space-y-1">
<p className="text-lg font-bold">{offlineStats.downloadedSongs}</p>
<p className="text-xs text-muted-foreground">Songs</p>
</div>
<div className="space-y-1">
<p className="text-lg font-bold">{formatSize(offlineStats.totalSize)}</p>
<p className="text-xs text-muted-foreground">Total Size</p>
</div>
</div>
)}
{/* Offline Mode Controls */}
{isOfflineSupported && (
<div className="space-y-4 mb-4 p-3 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="offline-mode" className="text-sm font-medium">
Offline Mode
</Label>
<p className="text-xs text-muted-foreground">
Force app to use only cached content (good for slow connections)
</p>
</div>
<Switch
id="offline-mode"
checked={offlineMode}
onCheckedChange={handleOfflineModeToggle}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="auto-download" className="text-sm font-medium">
Auto-download Queue
</Label>
<p className="text-xs text-muted-foreground">
Automatically download songs when added to queue
</p>
</div>
<Switch
id="auto-download"
checked={autoDownloadQueue}
onCheckedChange={handleAutoDownloadToggle}
/>
</div>
{/* Queue Download Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Queue</span>
<span className="text-xs text-muted-foreground">
{queue.length} song{queue.length !== 1 ? 's' : ''}
</span>
</div>
{queue.length > 0 && (
<Button
onClick={handleDownloadCurrentQueue}
disabled={isDownloadingQueue || downloadProgress.status === 'downloading'}
size="sm"
className="w-full"
variant="outline"
>
{isDownloadingQueue ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Music className="h-4 w-4 mr-2" />
)}
{isDownloadingQueue ? 'Downloading...' : 'Download Current Queue'}
</Button>
)}
{queue.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Add songs to queue to enable downloading
</p>
)}
</div>
</div>
)}
{/* Download Progress */}
{downloadProgress.status !== 'idle' && (
<div className="space-y-2 mb-4 p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{downloadProgress.status === 'downloading' && 'Downloading...'}
{downloadProgress.status === 'starting' && 'Starting download...'}
{downloadProgress.status === 'complete' && 'Download complete!'}
{downloadProgress.status === 'error' && 'Download failed'}
</span>
<Button
variant="ghost"
size="sm"
onClick={clearDownloadProgress}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{downloadProgress.total > 0 && (
<div className="space-y-1">
<Progress
value={(downloadProgress.completed / downloadProgress.total) * 100}
className="h-2"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.completed} / {downloadProgress.total} songs
{downloadProgress.failed > 0 && ` (${downloadProgress.failed} failed)`}
</span>
<span>{Math.round((downloadProgress.completed / downloadProgress.total) * 100)}%</span>
</div>
</div>
)}
{downloadProgress.currentSong && (
<p className="text-xs text-muted-foreground truncate">
Current: {downloadProgress.currentSong}
</p>
)}
{downloadProgress.error && (
<p className="text-xs text-red-600">
Error: {downloadProgress.error}
</p>
)}
</div>
)}
{/* Offline Items List */}
{offlineItems.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium">Downloaded Content</Label>
<div className="max-h-40 overflow-y-auto space-y-1">
{offlineItems.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="flex items-center justify-between p-2 bg-muted rounded text-sm"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Download className="h-3 w-3 text-green-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground truncate">
{item.artist} {item.type}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteOfflineItem(item)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{offlineItems.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Download className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No offline content downloaded</p>
<p className="text-xs">Visit an album page to download content for offline listening</p>
</div>
)}
</div>
{/* Cache Info */}
<div className="text-sm text-muted-foreground space-y-1">
<p>Cache includes albums, artists, songs, and image URLs to improve loading times.</p>
{isOfflineSupported && (
<p>Offline downloads use Service Workers for true offline audio playback.</p>
)}
{!isOfflineSupported && (
<p>Limited offline support - only metadata cached without Service Worker support.</p>
)}
{lastCleared && (
<p>Last cleared: {lastCleared}</p>
)}

View File

@@ -0,0 +1,71 @@
'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

@@ -0,0 +1,260 @@
'use client';
import React from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Play,
Plus,
ListMusic,
Heart,
SkipForward,
UserIcon,
Disc3,
Star,
Share,
Info
} from 'lucide-react';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Track } from '@/app/components/AudioPlayerContext';
interface TrackContextMenuProps {
children: React.ReactNode;
track: Track;
showPlayOptions?: boolean;
showQueueOptions?: boolean;
showFavoriteOption?: boolean;
showAlbumArtistOptions?: boolean;
}
export function TrackContextMenu({
children,
track,
showPlayOptions = true,
showQueueOptions = true,
showFavoriteOption = true,
showAlbumArtistOptions = true
}: TrackContextMenuProps) {
const {
playTrack,
addToQueue,
insertAtBeginningOfQueue,
toggleCurrentTrackStar,
currentTrack,
queue
} = useAudioPlayer();
const handlePlayTrack = () => {
playTrack(track, true);
};
const handleAddToQueue = () => {
addToQueue(track);
};
const handlePlayNext = () => {
// Add track to the beginning of the queue to play next
insertAtBeginningOfQueue(track);
};
const handleToggleFavorite = () => {
if (currentTrack?.id === track.id) {
toggleCurrentTrackStar();
}
// For non-current tracks, we'd need a separate function to toggle favorites
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
{showPlayOptions && (
<>
<ContextMenuItem onClick={handlePlayTrack} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Now
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showQueueOptions && (
<>
<ContextMenuItem onClick={handlePlayNext} className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Next
</ContextMenuItem>
<ContextMenuItem onClick={handleAddToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add to Queue
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showFavoriteOption && (
<>
<ContextMenuItem onClick={handleToggleFavorite} className="cursor-pointer">
<Heart className={`mr-2 h-4 w-4 ${track.starred ? 'fill-current text-red-500' : ''}`} />
{track.starred ? 'Remove from Favorites' : 'Add to Favorites'}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showAlbumArtistOptions && (
<>
<ContextMenuItem className="cursor-pointer">
<Disc3 className="mr-2 h-4 w-4" />
Go to Album
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Track Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// Additional context menus for albums and artists
interface AlbumContextMenuProps {
children: React.ReactNode;
albumId: string;
albumName: string;
}
export function AlbumContextMenu({
children,
albumId,
albumName
}: AlbumContextMenuProps) {
const { playAlbum, addAlbumToQueue } = useAudioPlayer();
const handlePlayAlbum = () => {
playAlbum(albumId);
};
const handleAddAlbumToQueue = () => {
addAlbumToQueue(albumId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayAlbum} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Album
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddAlbumToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add Album to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Album Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Album Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Album
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
interface ArtistContextMenuProps {
children: React.ReactNode;
artistId: string;
artistName: string;
}
export function ArtistContextMenu({
children,
artistId,
artistName
}: ArtistContextMenuProps) {
const { playArtist, addArtistToQueue } = useAudioPlayer();
const handlePlayArtist = () => {
playArtist(artistId);
};
const handleAddArtistToQueue = () => {
addArtistToQueue(artistId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayArtist} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play All Songs
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddArtistToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add All to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play All Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Artist Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Artist
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,478 @@
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import { motion, PanInfo, AnimatePresence } from 'framer-motion';
import { useAudioPlayer, Track } from './AudioPlayerContext';
import { FaPlay, FaPause, FaExpand, FaForward, FaBackward, FaVolumeHigh, FaVolumeXmark } from 'react-icons/fa6';
import { Heart } from 'lucide-react';
import { constrain } from '@/lib/utils';
import { Progress } from '@/components/ui/progress';
import { extractDominantColor } from '@/lib/image-utils';
interface DraggableMiniPlayerProps {
onExpand: () => void;
}
export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpand }) => {
const {
currentTrack,
playPreviousTrack,
playNextTrack,
toggleCurrentTrackStar,
isPlaying,
togglePlayPause
} = useAudioPlayer();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dominantColor, setDominantColor] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [volume, setVolume] = useState(1);
const [clickCount, setClickCount] = useState(0);
const [clickTimer, setClickTimer] = useState<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({ x: 0, y: 0 });
// Save position to localStorage when it changes
useEffect(() => {
if (!isDragging) {
localStorage.setItem('mini-player-position', JSON.stringify(position));
}
}, [position, isDragging]);
// Extract dominant color from album art
useEffect(() => {
if (!currentTrack?.coverArt) {
setDominantColor(null);
return;
}
extractDominantColor(currentTrack.coverArt)
.then(color => setDominantColor(color))
.catch(error => {
console.error('Failed to extract color:', error);
setDominantColor(null);
});
}, [currentTrack?.coverArt]);
// Track progress from main audio player
useEffect(() => {
const updateProgress = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement && audioElement.duration) {
setProgress((audioElement.currentTime / audioElement.duration) * 100);
}
};
const updateVolume = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement) {
setVolume(audioElement.volume);
}
};
const interval = setInterval(updateProgress, 250);
updateVolume(); // Initial volume
// Set up event listener for volume changes
const audioElement = document.querySelector('audio');
if (audioElement) {
audioElement.addEventListener('volumechange', updateVolume);
}
return () => {
clearInterval(interval);
if (audioElement) {
audioElement.removeEventListener('volumechange', updateVolume);
}
};
}, [currentTrack]);
// Detect double clicks for expanding
const handleContainerClick = useCallback(() => {
setClickCount(prev => prev + 1);
if (clickTimer) {
clearTimeout(clickTimer);
}
const timer = setTimeout(() => {
// If single click, do nothing
if (clickCount === 0) {
// Nothing
}
// If double click, expand
else if (clickCount === 1) {
onExpand();
}
setClickCount(0);
}, 300);
setClickTimer(timer as unknown as NodeJS.Timeout);
}, [clickCount, clickTimer, onExpand]);
// Handle seeking in track
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percent = clickX / rect.width;
audioElement.currentTime = percent * audioElement.duration;
};
// Handle volume change
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const newVolume = parseFloat(e.target.value);
audioElement.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch (error) {
console.error('Failed to save volume:', error);
}
};
// Keyboard controls for the mini player
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle keyboard shortcuts if the mini player is focused
if (document.activeElement?.tagName === 'INPUT') return;
const step = e.shiftKey ? 100 : 10; // Larger steps with shift key
switch (e.key) {
case 'ArrowLeft':
setPosition(prev => ({
...prev,
x: constrain(
prev.x - step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowRight':
setPosition(prev => ({
...prev,
x: constrain(
prev.x + step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowUp':
setPosition(prev => ({
...prev,
y: constrain(
prev.y - step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'ArrowDown':
setPosition(prev => ({
...prev,
y: constrain(
prev.y + step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'Escape':
onExpand();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onExpand]);
// Load saved position on mount
useEffect(() => {
const savedPosition = localStorage.getItem('mini-player-position');
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition);
setPosition(pos);
} catch (error) {
console.error('Failed to parse saved mini player position:', error);
}
}
}, []);
// Ensure player stays within viewport bounds and implement edge snapping
useEffect(() => {
const constrainToViewport = () => {
if (!containerRef.current || isDragging) return;
const rect = containerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Add some padding from edges
const padding = 16;
// Calculate constrained position
let newX = constrain(
position.x,
-(viewportWidth - rect.width) / 2 + padding,
(viewportWidth - rect.width) / 2 - padding
);
let newY = constrain(
position.y,
-(viewportHeight - rect.height) / 2 + padding,
(viewportHeight - rect.height) / 2 - padding
);
// Edge snapping logic
const snapThreshold = 24; // Pixels from edge to trigger snap
const snapPositions = {
left: -(viewportWidth - rect.width) / 2 + padding,
right: (viewportWidth - rect.width) / 2 - padding,
top: -(viewportHeight - rect.height) / 2 + padding,
bottom: (viewportHeight - rect.height) / 2 - padding,
};
// Snap to left or right edge
if (Math.abs(newX - snapPositions.left) < snapThreshold) {
newX = snapPositions.left;
} else if (Math.abs(newX - snapPositions.right) < snapThreshold) {
newX = snapPositions.right;
}
// Snap to top or bottom edge
if (Math.abs(newY - snapPositions.top) < snapThreshold) {
newY = snapPositions.top;
} else if (Math.abs(newY - snapPositions.bottom) < snapThreshold) {
newY = snapPositions.bottom;
}
if (newX !== position.x || newY !== position.y) {
setPosition({ x: newX, y: newY });
}
};
constrainToViewport();
window.addEventListener('resize', constrainToViewport);
return () => window.removeEventListener('resize', constrainToViewport);
}, [position, isDragging]);
const handleDragStart = () => {
setIsDragging(true);
dragStartRef.current = position;
};
const handleDrag = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
setPosition({
x: dragStartRef.current.x + info.offset.x,
y: dragStartRef.current.y + info.offset.y
});
};
const handleDragEnd = () => {
setIsDragging(false);
};
if (!currentTrack) return null;
return (
<AnimatePresence>
<motion.div
ref={containerRef}
drag
dragMomentum={false}
dragElastic={0}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={{
x: position.x + window.innerWidth / 2,
y: position.y + window.innerHeight / 2,
scale: isDragging ? 1.02 : 1,
opacity: isDragging ? 0.8 : 1
}}
transition={{ type: 'spring', damping: 20 }}
style={{
position: 'fixed',
zIndex: 100,
transform: `translate(-50%, -50%)`
}}
className="cursor-grab active:cursor-grabbing"
onClick={handleContainerClick}
>
<div
className="backdrop-blur-sm border rounded-lg shadow-xl hover:shadow-2xl transition-shadow p-3 w-[280px]"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.15)')}`
: 'var(--background-color, rgba(0, 0, 0, 0.8))',
borderColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.3)')}`
: 'var(--border-color, rgba(255, 255, 255, 0.1))'
}}
>
{/* Progress bar at the top */}
<div className="mb-3" onClick={handleProgressClick}>
<Progress
value={progress}
className="h-1 cursor-pointer"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.2)')}`
: undefined,
'--progress-color': dominantColor || undefined
} as React.CSSProperties}
/>
</div>
<div className="flex items-center gap-3">
{/* Album Art - Animated transition */}
<AnimatePresence mode="wait">
<motion.div
key={currentTrack.id}
className="relative w-12 h-12 shrink-0"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
>
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
fill
className="rounded-md object-cover shadow-md"
sizes="48px"
priority
/>
</motion.div>
</AnimatePresence>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{currentTrack.name}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
</div>
{/* Keyboard shortcut hint */}
<div className="text-xs text-muted-foreground text-center mt-2 px-2">
Double-click to expand Arrow keys to move
</div>
{/* Controls */}
<div className="flex items-center justify-between mt-2 px-2">
<button
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
/>
</button>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
playPreviousTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaBackward className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
className="p-3 hover:bg-muted/50 rounded-full transition-colors"
>
{isPlaying ? (
<FaPause className="w-4 h-4" />
) : (
<FaPlay className="w-4 h-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
playNextTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaForward className="w-3 h-3" />
</button>
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setShowVolumeSlider(prev => !prev);
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title="Volume"
>
{volume === 0 ? (
<FaVolumeXmark className="w-4 h-4" />
) : (
<FaVolumeHigh className="w-4 h-4" />
)}
</button>
{/* Volume Slider */}
{showVolumeSlider && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg"
onClick={e => e.stopPropagation()}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="w-24 accent-foreground"
/>
</div>
)}
</div>
</div>
{/* Expand button in top-right corner */}
<button
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="absolute top-2 right-2 p-1.5 hover:bg-muted/50 rounded-full transition-colors"
title="Expand"
>
<FaExpand className="w-3 h-3" />
</button>
</div>
</motion.div>
</AnimatePresence>
);
};

View File

@@ -0,0 +1,627 @@
'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,6 +1,7 @@
'use client';
"use client";
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
@@ -8,6 +9,9 @@ import { Progress } from '@/components/ui/progress';
import { lrcLibClient } from '@/lib/lrclib';
import Link from 'next/link';
import { useIsMobile } from '@/hooks/use-mobile';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useGlobalSearch } from './GlobalSearchProvider';
import { AudioSettingsDialog } from './AudioSettingsDialog';
import {
FaPlay,
FaPause,
@@ -19,7 +23,8 @@ import {
FaRepeat,
FaXmark,
FaQuoteLeft,
FaListUl
FaListUl,
FaSliders
} from "react-icons/fa6";
import { Heart } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -45,8 +50,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
shuffle,
toggleShuffle,
toggleCurrentTrackStar,
queue
queue,
audioSettings,
updateAudioSettings
} = useAudioPlayer();
const [showAudioSettings, setShowAudioSettings] = useState(false);
const isMobile = useIsMobile();
const router = useRouter();
@@ -63,6 +71,22 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const [activeTab, setActiveTab] = useState<MobileTab>('player');
const lyricsRef = useRef<HTMLDivElement>(null);
// Initialize volume from saved preference when fullscreen opens
useEffect(() => {
if (!isOpen) return;
try {
const savedVolume = localStorage.getItem('navidrome-volume');
if (savedVolume !== null) {
const vol = parseFloat(savedVolume);
if (!isNaN(vol) && vol >= 0 && vol <= 1) {
setVolume(vol);
const mainAudio = document.querySelector('audio') as HTMLAudioElement | null;
if (mainAudio) mainAudio.volume = vol;
}
}
} catch {}
}, [isOpen]);
// Debug logging for component changes
useEffect(() => {
console.log('🔍 FullScreenPlayer state changed:', {
@@ -127,7 +151,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const containerHeight = scrollContainer.clientHeight;
const elementTop = currentLyricElement.offsetTop;
const elementHeight = currentLyricElement.offsetHeight;
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2);
// Position the active lyric higher on the screen (~25% from top)
const focusFraction = 0.25; // 0.5 would be center
const targetScrollTop = elementTop - (containerHeight * focusFraction) + (elementHeight / 2);
scrollContainer.scrollTo({
top: Math.max(0, targetScrollTop),
@@ -378,6 +404,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
mainAudio.currentTime = newTime;
setCurrentTime(newTime);
try {
localStorage.setItem('navidrome-current-track-time', newTime.toString());
} catch {}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -387,14 +416,68 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const newVolume = parseInt(e.target.value) / 100;
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
// Volume control functions for keyboard shortcuts
const handleVolumeUp = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
const newVolume = Math.min(1, mainAudio.volume + 0.1);
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
const handleVolumeDown = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
const newVolume = Math.max(0, mainAudio.volume - 0.1);
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
const handleToggleMute = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
const newVolume = mainAudio.volume === 0 ? 1 : 0;
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
const { openSpotlight } = useGlobalSearch();
// Set up keyboard shortcuts for fullscreen player
useKeyboardShortcuts({
onPlayPause: togglePlayPause,
onNextTrack: playNextTrack,
onPreviousTrack: playPreviousTrack,
onVolumeUp: handleVolumeUp,
onVolumeDown: handleVolumeDown,
onToggleMute: handleToggleMute,
onSpotlightSearch: openSpotlight,
disabled: !isOpen || !currentTrack // Only active when fullscreen is open
});
const handleLyricClick = (time: number) => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
mainAudio.currentTime = time;
setCurrentTime(time);
try {
localStorage.setItem('navidrome-current-track-time', time.toString());
} catch {}
// Update progress bar as well
if (duration > 0) {
@@ -410,15 +493,24 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!isOpen || !currentTrack) return null;
if (!currentTrack) return null;
return (
<div className="fixed inset-0 z-[70] bg-black overflow-hidden">
<>
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-[70] bg-black overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
{/* Enhanced Blurred background image */}
{currentTrack.coverArt && (
<div className="absolute inset-0 w-full h-full">
<motion.div className="absolute inset-0 w-full h-full" initial={{ scale: 1.02 }} animate={{ scale: 1.08 }} transition={{ duration: 10, ease: 'linear' }}>
{/* Main background */}
<div
<motion.div
className="absolute inset-0 w-full h-full"
style={{
backgroundImage: `url(${currentTrack.coverArt})`,
@@ -428,9 +520,12 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
filter: 'blur(20px) brightness(0.3)',
transform: 'scale(1.1)',
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
{/* Top gradient blur for mobile */}
<div
<motion.div
className="absolute top-0 left-0 right-0 h-32"
style={{
background: `linear-gradient(to bottom,
@@ -439,9 +534,12 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
transparent 100%)`,
backdropFilter: 'blur(10px)',
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25 }}
/>
{/* Bottom gradient blur for mobile */}
<div
<motion.div
className="absolute bottom-0 left-0 right-0 h-32"
style={{
background: `linear-gradient(to top,
@@ -450,31 +548,34 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
transparent 100%)`,
backdropFilter: 'blur(10px)',
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25 }}
/>
</div>
</motion.div>
)}
{/* Overlay for better contrast */}
<div className="absolute inset-0 bg-black/30" />
<motion.div className="absolute inset-0 bg-black/30" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />
<div className="relative h-full w-full flex flex-col">
<motion.div className="relative h-full w-full flex flex-col" initial={{ y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 10, opacity: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }}>
{/* Mobile Close Handle */}
{isMobile && (
<div className="flex justify-center py-4 px-4">
<motion.div className="flex justify-center py-4 px-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }}>
<div
onClick={onClose}
className="cursor-pointer px-8 py-3 -mx-8 -my-3"
style={{ touchAction: 'manipulation' }}
>
<div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" />
</div>
<motion.div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" initial={{ scaleX: 0.9 }} animate={{ scaleX: 1 }} transition={{ duration: 0.3 }} />
</div>
</motion.div>
)}
{/* Desktop Header */}
{!isMobile && (
<div className="absolute top-0 right-0 z-10 p-4 lg:p-6">
<motion.div className="absolute top-0 right-0 z-10 p-4 lg:p-6" initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2 }}>
<div className="flex items-center gap-2">
{onOpenQueue && (
<button
@@ -493,7 +594,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<FaXmark className="w-5 h-5" />
</button>
</div>
</div>
</motion.div>
)}
{/* Main Content */}
@@ -502,10 +603,20 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
/* Mobile Tab Content */
<div className="h-full flex flex-col">
<div className="flex-1 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{activeTab === 'player' && (
<div className="h-full flex flex-col justify-center items-center px-8 py-4">
{/* Mobile Album Art */}
<div className="relative mb-6 shrink-0">
<motion.div key="tab-player" className="h-full flex flex-col justify-center items-center px-8 py-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}>
{/* Mobile Album Art (crossfade on track change) */}
<div className="relative mb-6 shrink-0 flex items-center justify-center" style={{ minHeight: 208 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentTrack.id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02, position: 'absolute' as const }}
transition={{ duration: 0.25 }}
className="flex items-center justify-center"
>
<Image
src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album}
@@ -516,6 +627,8 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
}`}
priority
/>
</motion.div>
</AnimatePresence>
</div>
{/* Track Info - Left Aligned and Heart on Same Line */}
@@ -621,24 +734,27 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
/>
</div>
)}
</div>
</motion.div>
)}
{activeTab === 'lyrics' && lyrics.length > 0 && (
<div className="h-full flex flex-col px-4">
<motion.div key="tab-lyrics" className="h-full flex flex-col px-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}>
<div
className="flex-1 overflow-y-auto"
ref={lyricsRef}
>
<div className="space-y-3 py-4">
<div className="space-y-4 py-10">
{lyrics.map((line, index) => (
<div
<motion.div
key={index}
data-lyric-index={index}
onClick={() => handleLyricClick(line.time)}
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground px-2 ${
initial={false}
animate={index === currentLyricIndex ? { scale: 1.06, opacity: 1 } : index < currentLyricIndex ? { scale: 0.985, opacity: 0.75 } : { scale: 0.98, opacity: 0.6 }}
transition={{ duration: 0.2 }}
className={`text-2xl sm:text-3xl leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground px-2 ${
index === currentLyricIndex
? 'text-foreground font-bold text-xl'
? 'text-foreground font-extrabold leading-tight text-5xl sm:text-6xl'
: index < currentLyricIndex
? 'text-foreground/60'
: 'text-foreground/40'
@@ -647,21 +763,25 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
wordWrap: 'break-word',
overflowWrap: 'break-word',
hyphens: 'auto',
paddingBottom: '4px'
paddingBottom: '4px',
// Subtle glow to make the current line feel elevated
textShadow: index === currentLyricIndex
? '0 4px 16px rgba(0,0,0,0.7), 0 0 24px rgba(255,255,255,0.16)'
: undefined
}}
title={`Click to jump to ${formatTime(line.time)}`}
>
{line.text || '♪'}
</div>
</motion.div>
))}
<div style={{ height: '200px' }} />
</div>
<div style={{ height: '260px' }} />
</div>
</div>
</motion.div>
)}
{activeTab === 'queue' && (
<div className="h-full flex flex-col px-4">
<motion.div key="tab-queue" className="h-full flex flex-col px-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}>
<ScrollArea className="flex-1">
<div className="space-y-2 py-4">
{queue.map((track, index) => (
@@ -690,11 +810,10 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
))}
</div>
</ScrollArea>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Mobile Tab Bar */}
<div className="flex-shrink-0 pb-safe">
<div className="flex justify-around py-4 mb-2">
<button
@@ -733,8 +852,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<div className="h-full flex flex-row gap-8 p-6 overflow-hidden">
{/* Left Side - Album Art and Controls */}
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
{/* Album Art */}
<div className="relative mb-6 shrink-0">
{/* Album Art (crossfade on track change) */}
<div className="relative mb-6 shrink-0 w-80 h-80">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentTrack.id}
className="absolute inset-0"
initial={{ opacity: 0, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.25 }}
>
<Image
src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album}
@@ -743,6 +871,8 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
className="w-80 h-80 rounded-lg shadow-2xl object-cover"
priority
/>
</motion.div>
</AnimatePresence>
</div>
{/* Track Info */}
@@ -838,6 +968,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</button>
)}
<button
onClick={() => setShowAudioSettings(true)}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
title="Audio Settings"
>
<FaSliders className="w-5 h-5" />
</button>
{showVolumeSlider && (
<div
className="w-24"
@@ -857,19 +995,28 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Right Side - Lyrics (Desktop Only) */}
<AnimatePresence initial={false}>
{showLyrics && lyrics.length > 0 && (
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
<motion.div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}
initial={{ x: 30, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 30, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="h-full flex flex-col">
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-3 pl-4 pr-4 py-4">
<div className="space-y-3 pl-4 pr-4 py-8">
{lyrics.map((line, index) => (
<div
<motion.div
key={index}
data-lyric-index={index}
onClick={() => handleLyricClick(line.time)}
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
initial={false}
animate={index === currentLyricIndex ? { scale: 1.04, opacity: 1 } : index < currentLyricIndex ? { scale: 0.985, opacity: 0.75 } : { scale: 0.98, opacity: 0.5 }}
transition={{ duration: 0.2 }}
className={`text-base leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground ${
index === currentLyricIndex
? 'text-foreground font-bold text-2xl'
? 'text-foreground font-extrabold leading-tight text-5xl'
: index < currentLyricIndex
? 'text-foreground/60'
: 'text-foreground/40'
@@ -879,23 +1026,35 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
overflowWrap: 'break-word',
hyphens: 'auto',
paddingBottom: '4px',
paddingLeft: '8px'
paddingLeft: '8px',
// Subtle glow to make the current line feel elevated
textShadow: index === currentLyricIndex
? '0 6px 18px rgba(0,0,0,0.7), 0 0 28px rgba(255,255,255,0.18)'
: undefined
}}
title={`Click to jump to ${formatTime(line.time)}`}
>
{line.text || '♪'}
</div>
</motion.div>
))}
<div style={{ height: '200px' }} />
<div style={{ height: '240px' }} />
</div>
</ScrollArea>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</div>
</div>
</div>
</AnimatePresence>
<AudioSettingsDialog
isOpen={showAudioSettings}
onClose={() => setShowAudioSettings(false)}
/>
</>
);
};

View File

@@ -0,0 +1,46 @@
'use client';
import React, { createContext, useContext, useState, useCallback } from 'react';
import { SpotlightSearch } from './SpotlightSearch';
interface GlobalSearchContextProps {
isSpotlightOpen: boolean;
openSpotlight: () => void;
closeSpotlight: () => void;
}
const GlobalSearchContext = createContext<GlobalSearchContextProps | undefined>(undefined);
export function GlobalSearchProvider({ children }: { children: React.ReactNode }) {
const [isSpotlightOpen, setIsSpotlightOpen] = useState(false);
const openSpotlight = useCallback(() => {
setIsSpotlightOpen(true);
}, []);
const closeSpotlight = useCallback(() => {
setIsSpotlightOpen(false);
}, []);
return (
<GlobalSearchContext.Provider value={{
isSpotlightOpen,
openSpotlight,
closeSpotlight
}}>
{children}
<SpotlightSearch
isOpen={isSpotlightOpen}
onClose={closeSpotlight}
/>
</GlobalSearchContext.Provider>
);
}
export function useGlobalSearch() {
const context = useContext(GlobalSearchContext);
if (!context) {
throw new Error('useGlobalSearch must be used within a GlobalSearchProvider');
}
return context;
}

View File

@@ -0,0 +1,153 @@
'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

@@ -0,0 +1,226 @@
'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

View File

@@ -0,0 +1,395 @@
'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

@@ -0,0 +1,367 @@
'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

@@ -0,0 +1,281 @@
'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

@@ -0,0 +1,65 @@
'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

@@ -0,0 +1,23 @@
"use client";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
export default function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeInOut" }}
className="contents"
>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -11,7 +11,8 @@ function PathnameTracker() {
const searchParams = useSearchParams()
useEffect(() => {
if (posthogClient) {
// Only track if PostHog client is available and properly initialized
if (posthogClient && typeof posthogClient.capture === 'function') {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
@@ -31,20 +32,35 @@ function SuspendedPostHogPageView() {
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
// Only initialize PostHog if we have a valid key
if (posthogKey && posthogKey.trim() !== '') {
posthog.init(posthogKey, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
})
});
} else {
console.log('PostHog not initialized - NEXT_PUBLIC_POSTHOG_KEY not provided');
}
}, [])
// Only provide PostHog context if we have a key
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (posthogKey && posthogKey.trim() !== '') {
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
)
);
}
// Return children without PostHog context if no key is provided
return <>{children}</>;
}

View File

@@ -1,8 +1,8 @@
"use client";
import React from "react";
import React, { useEffect } from "react";
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider";
import { PostHogProvider } from "../components/PostHogProvider";
@@ -13,9 +13,29 @@ import ThemeColorHandler from "./ThemeColorHandler";
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen";
import Image from "next/image";
import PageTransition from "./PageTransition";
import { GlobalSearchProvider } from "./GlobalSearchProvider";
// ServiceWorkerRegistration component to handle registration
function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered successfully:', registration);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
}, []);
return null;
}
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
const { error } = useNavidrome();
// For now, since we're switching to offline-first, we'll handle errors differently
// The offline provider will handle connectivity issues automatically
const [isClient, setIsClient] = React.useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash
@@ -58,10 +78,9 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// Show start screen ONLY if:
// 1. First-time user (no onboarding completed), OR
// 2. User has completed onboarding BUT there's an error AND no config exists
const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig);
// Show start screen ONLY if first-time user (no onboarding completed)
// In offline-first mode, we don't need to check for errors since the app works offline
const shouldShowStartScreen = !hasCompletedOnboarding;
if (shouldShowStartScreen) {
return (
@@ -86,17 +105,20 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<ThemeProvider>
<DynamicViewportTheme />
<ThemeColorHandler />
<ServiceWorkerRegistration />
<NavidromeConfigProvider>
<NavidromeProvider>
<OfflineNavidromeProvider>
<NavidromeErrorBoundary>
<AudioPlayerProvider>
<GlobalSearchProvider>
<Ihateserverside>
{children}
<PageTransition>{children}</PageTransition>
</Ihateserverside>
<WhatsNewPopup />
</GlobalSearchProvider>
</AudioPlayerProvider>
</NavidromeErrorBoundary>
</NavidromeProvider>
</OfflineNavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
</PostHogProvider>

View File

@@ -1,8 +1,8 @@
'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
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 { api, isConnected } = useNavidrome();
const offline = useOfflineNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
@@ -42,21 +42,18 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
useEffect(() => {
const loadRecommendations = async () => {
if (!api || !isConnected) return;
setLoading(true);
try {
// Get random albums for both mobile album view and desktop song extraction
const randomAlbums = await api.getAlbums('random', 10);
const api = getNavidromeAPI();
const isOnline = !offline.isOfflineMode && !!api;
if (isOnline && api) {
// Online: use server-side recommendations
const randomAlbums = await api.getAlbums('random', 10);
if (isMobile) {
// For mobile: show 6 random albums
setRecommendedAlbums(randomAlbums.slice(0, 6));
} else {
// For desktop: extract songs from albums (original behavior)
const allSongs: Song[] = [];
// Get songs from first few albums
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
try {
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
@@ -65,43 +62,64 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
console.error('Failed to get album songs:', error);
}
}
// Shuffle and limit to 6 songs
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states for songs
const states: Record<string, boolean> = {};
recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred;
});
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);
setRecommendedAlbums([]);
setRecommendedSongs([]);
} finally {
setLoading(false);
}
};
loadRecommendations();
}, [api, isConnected, isMobile]);
}, [offline, isMobile]);
const handlePlaySong = async (song: Song) => {
if (!api) return;
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;
const track = {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt,
starred: !!song.starred
};
await playTrack(track, true);
@@ -111,23 +129,29 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
};
const handlePlayAlbum = async (album: Album) => {
if (!api) return;
try {
// Get album songs and play the first one
const albumSongs = await api.getAlbumSongs(album.id);
const api = getNavidromeAPI();
let albumSongs: Song[] = [];
if (api) {
albumSongs = await api.getAlbumSongs(album.id);
} else {
albumSongs = await offline.getSongs(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 track = {
id: albumSongs[0].id,
name: albumSongs[0].title,
url: api.getStreamUrl(albumSongs[0].id),
artist: albumSongs[0].artist || 'Unknown Artist',
artistId: albumSongs[0].artistId || '',
album: albumSongs[0].album || 'Unknown Album',
albumId: albumSongs[0].albumId || '',
duration: albumSongs[0].duration || 0,
coverArt: albumSongs[0].coverArt ? api.getCoverArtUrl(albumSongs[0].coverArt, 64) : undefined,
starred: !!albumSongs[0].starred
id: first.id,
name: first.title,
url,
artist: first.artist || 'Unknown Artist',
artistId: first.artistId || '',
album: first.album || 'Unknown Album',
albumId: first.albumId || '',
duration: first.duration || 0,
coverArt,
starred: !!first.starred
};
await playTrack(track, true);
}
@@ -222,9 +246,9 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
className="group cursor-pointer block"
>
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && api ? (
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 300)}
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
alt={album.name}
width={600}
height={600}
@@ -281,10 +305,10 @@ 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 && api ? (
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<>
<Image
src={api.getCoverArtUrl(song.coverArt, 48)}
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
alt={song.title}
fill
className="object-cover"

View File

@@ -0,0 +1,653 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
Search,
X,
Music,
Disc,
User,
Clock,
Heart,
Play,
Plus,
ExternalLink,
Info,
Star,
TrendingUp,
Users,
Calendar,
Globe
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { lastFmAPI } from '@/lib/lastfm-api';
import { Song, Album, Artist, getNavidromeAPI } from '@/lib/navidrome';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import Image from 'next/image';
interface SpotlightSearchProps {
isOpen: boolean;
onClose: () => void;
}
interface LastFmTrackInfo {
name: string;
artist: {
name: string;
url: string;
};
album?: {
title: string;
image?: string;
};
wiki?: {
summary: string;
content: string;
};
duration?: string;
playcount?: string;
listeners?: string;
tags?: Array<{
name: string;
url: string;
}>;
}
interface LastFmTag {
name: string;
url: string;
}
interface LastFmArtist {
name: string;
url: string;
image?: Array<{ '#text': string; size: string }>;
}
interface LastFmBio {
summary: string;
content: string;
}
interface LastFmStats {
listeners: string;
playcount: string;
}
interface LastFmArtistInfo {
name: string;
bio?: LastFmBio;
stats?: LastFmStats;
tags?: {
tag: LastFmTag[];
};
similar?: {
artist: LastFmArtist[];
};
image?: Array<{ '#text': string; size: string }>;
}
interface SearchResult {
type: 'track' | 'album' | 'artist';
id: string;
title: string;
subtitle: string;
image?: string;
data: Song | Album | Artist;
lastFmData?: LastFmArtistInfo;
}
export function SpotlightSearch({ isOpen, onClose }: SpotlightSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [selectedResult, setSelectedResult] = useState<SearchResult | null>(null);
const [lastFmDetails, setLastFmDetails] = useState<LastFmArtistInfo | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const api = getNavidromeAPI();
const { search2 } = useNavidrome();
const { playTrack, addToQueue, insertAtBeginningOfQueue } = useAudioPlayer();
// Convert Song to Track with proper URL generation
const songToTrack = useCallback((song: Song) => {
if (!api) {
throw new Error('Navidrome API not configured');
}
return {
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, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred,
replayGain: song.replayGain || 0
};
}, [api]);
// Focus input when opened
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Close on escape
useKeyboardShortcuts({
disabled: !isOpen
});
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (results[selectedIndex]) {
handleResultSelect(results[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
if (showDetails) {
setShowDetails(false);
setSelectedResult(null);
} else {
onClose();
}
break;
case 'Tab':
e.preventDefault();
if (results[selectedIndex]) {
handleShowDetails(results[selectedIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex, showDetails, onClose]);
// Search function with debouncing
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
const searchResults = await search2(searchQuery);
const formattedResults: SearchResult[] = [];
// Add tracks
searchResults.songs?.forEach(song => {
formattedResults.push({
type: 'track',
id: song.id,
title: song.title,
subtitle: `${song.artist}${song.album}`,
image: song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 256) : undefined,
data: song
});
});
// Add albums
searchResults.albums?.forEach(album => {
formattedResults.push({
type: 'album',
id: album.id,
title: album.name,
subtitle: `${album.artist}${album.songCount} tracks`,
image: album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 256) : undefined,
data: album
});
});
// Add artists
searchResults.artists?.forEach(artist => {
formattedResults.push({
type: 'artist',
id: artist.id,
title: artist.name,
subtitle: `${artist.albumCount} albums`,
image: artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 256) : undefined,
data: artist
});
});
setResults(formattedResults);
setSelectedIndex(0);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, [search2]);
// Debounced search
useEffect(() => {
const timeoutId = setTimeout(() => {
performSearch(query);
}, 300);
return () => clearTimeout(timeoutId);
}, [query, performSearch]);
const handleResultSelect = (result: SearchResult) => {
switch (result.type) {
case 'track':
const songData = result.data as Song;
const track = songToTrack(songData);
playTrack(track, true);
onClose();
break;
case 'album':
router.push(`/album/${result.id}`);
onClose();
break;
case 'artist':
router.push(`/artist/${result.id}`);
onClose();
break;
}
};
const handleShowDetails = async (result: SearchResult) => {
setSelectedResult(result);
setShowDetails(true);
setLastFmDetails(null);
// Fetch Last.fm data
try {
let lastFmData = null;
if (result.type === 'artist') {
const artistData = result.data as Artist;
lastFmData = await lastFmAPI.getArtistInfo(artistData.name);
} else if (result.type === 'album') {
// For albums, get artist info as Last.fm album info is limited
const albumData = result.data as Album;
lastFmData = await lastFmAPI.getArtistInfo(albumData.artist);
} else if (result.type === 'track') {
// For tracks, get artist info
const songData = result.data as Song;
lastFmData = await lastFmAPI.getArtistInfo(songData.artist);
}
setLastFmDetails(lastFmData);
} catch (error) {
console.error('Failed to fetch Last.fm data:', error);
}
};
const handlePlayNext = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
insertAtBeginningOfQueue(track);
}
};
const handleAddToQueue = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
addToQueue(track);
}
};
const getResultIcon = (type: string) => {
switch (type) {
case 'track': return <Music className="w-4 h-4" />;
case 'album': return <Disc className="w-4 h-4" />;
case 'artist': return <User className="w-4 h-4" />;
default: return <Search className="w-4 h-4" />;
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div className="flex items-start justify-center pt-[10vh] px-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ type: "spring", duration: 0.4 }}
className="w-full max-w-2xl bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search Input */}
<div className="flex items-center px-4 py-3 border-b border-border">
<Search className="w-5 h-5 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tracks, albums, artists..."
className="border-0 focus-visible:ring-0 text-lg bg-transparent"
/>
{query && (
<Button
variant="ghost"
size="sm"
onClick={() => setQuery('')}
className="p-1 h-auto"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{/* Results */}
<div className="max-h-96 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
) : results.length > 0 ? (
<ScrollArea className="max-h-96" ref={resultsRef}>
<div className="py-2">
{results.map((result, index) => (
<div
key={`${result.type}-${result.id}`}
className={`flex items-center px-4 py-3 cursor-pointer transition-colors ${
index === selectedIndex ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => handleResultSelect(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{result.image ? (
<Image
src={result.image}
alt={result.title}
width={40}
height={40}
className="w-10 h-10 rounded object-cover"
/>
) : (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
{getResultIcon(result.type)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
<div className="text-sm text-muted-foreground truncate">
{result.subtitle}
</div>
</div>
<Badge variant="secondary" className="capitalize">
{result.type}
</Badge>
</div>
{/* Quick Actions */}
<div className="flex items-center space-x-1 ml-3">
{result.type === 'track' && (
<>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePlayNext(result);
}}
className="h-8 w-8 p-0"
title="Play Next"
>
<Play className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddToQueue(result);
}}
className="h-8 w-8 p-0"
title="Add to Queue"
>
<Plus className="w-3 h-3" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleShowDetails(result);
}}
className="h-8 w-8 p-0"
title="Show Details (Tab)"
>
<Info className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
) : query.trim() ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Search className="w-8 h-8 mb-2" />
<p>No results found for &ldquo;{query}&rdquo;</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Music className="w-8 h-8 mb-2" />
<p>Start typing to search your music library</p>
<div className="text-xs mt-2 space-y-1">
<p> Use to navigate Enter to select</p>
<p> Tab for details Esc to close</p>
</div>
</div>
)}
</div>
</motion.div>
</div>
{/* Details Panel */}
<AnimatePresence>
{showDetails && selectedResult && (
<motion.div
initial={{ opacity: 0, x: 400 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 400 }}
transition={{ type: "spring", duration: 0.4 }}
className="fixed right-4 top-[10vh] bottom-4 w-80 bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold">Details</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Basic Info */}
<div className="space-y-3">
{selectedResult.image && (
<Image
src={selectedResult.image}
alt={selectedResult.title}
width={200}
height={200}
className="w-full aspect-square rounded object-cover"
/>
)}
<div>
<h4 className="font-semibold text-lg">{selectedResult.title}</h4>
<p className="text-muted-foreground">{selectedResult.subtitle}</p>
<Badge variant="secondary" className="mt-1 capitalize">
{selectedResult.type}
</Badge>
</div>
</div>
{/* Last.fm Data */}
{lastFmDetails && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center space-x-2">
<ExternalLink className="w-4 h-4" />
<span className="font-medium">Last.fm Info</span>
</div>
{/* Stats */}
{lastFmDetails.stats && (
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<Users className="w-3 h-3" />
<span className="text-xs font-medium">Listeners</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.listeners).toLocaleString()}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs font-medium">Plays</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.playcount).toLocaleString()}
</div>
</div>
</div>
)}
{/* Bio */}
{lastFmDetails.bio?.summary && (
<div>
<h5 className="font-medium mb-2">Biography</h5>
<p className="text-sm text-muted-foreground leading-relaxed">
{lastFmDetails.bio.summary.replace(/<[^>]*>/g, '').split('\n')[0]}
</p>
</div>
)}
{/* Tags */}
{lastFmDetails.tags?.tag && (
<div>
<h5 className="font-medium mb-2">Tags</h5>
<div className="flex flex-wrap gap-1">
{lastFmDetails.tags.tag.slice(0, 6).map((tag: LastFmTag, index: number) => (
<Badge key={index} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
</div>
)}
{/* Similar Artists */}
{lastFmDetails.similar?.artist && (
<div>
<h5 className="font-medium mb-2">Similar Artists</h5>
<div className="space-y-2">
{lastFmDetails.similar.artist.slice(0, 4).map((artist: LastFmArtist, index: number) => (
<div key={index} className="flex items-center space-x-2">
<div className="w-8 h-8 bg-muted rounded flex items-center justify-center">
<User className="w-3 h-3" />
</div>
<span className="text-sm">{artist.name}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
{/* Actions */}
<Separator />
<div className="space-y-2">
<Button
onClick={() => handleResultSelect(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
{selectedResult.type === 'track' ? 'Play Track' :
selectedResult.type === 'album' ? 'View Album' : 'View Artist'}
</Button>
{selectedResult.type === 'track' && (
<>
<Button
variant="outline"
onClick={() => handlePlayNext(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
Play Next
</Button>
<Button
variant="outline"
onClick={() => handleAddToQueue(selectedResult)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add to Queue
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -94,8 +94,8 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
<div className="w-4 h-4 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-2 h-2 text-primary" />
</div>
)}
</Button>
@@ -106,8 +106,8 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
width={32}
height={32}
className="rounded-full"
/>
) : (
@@ -207,3 +207,4 @@ export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
);
}
}

View File

@@ -3,6 +3,7 @@
import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { cn } from "@/lib/utils"
import {
@@ -17,6 +18,7 @@ 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";
@@ -24,10 +26,14 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon";
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
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 React.HTMLAttributes<HTMLDivElement> {
interface AlbumArtworkProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onDragEnter' | 'onDragLeave' | 'onDrop'
> {
album: Album
aspectRatio?: "portrait" | "square"
width?: number
@@ -43,6 +49,7 @@ export function AlbumArtwork({
...props
}: AlbumArtworkProps) {
const { api, isConnected } = useNavidrome();
const offline = useOfflineNavidrome();
const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome();
@@ -69,6 +76,17 @@ export function AlbumArtwork({
router.push(`/album/${album.id}`);
};
const handlePrefetch = () => {
try {
// Next.js App Router will prefetch on hover when using Link with prefetch
// but we also call router.prefetch to ensure programmatic prefetch when present.
const r = router as unknown as { prefetch?: (href: string) => Promise<void> | void };
if (r && typeof r.prefetch === 'function') {
r.prefetch(`/album/${album.id}`);
}
} catch {}
};
const handleAddToQueue = () => {
addAlbumToQueue(album.id);
};
@@ -124,11 +142,18 @@ export function AlbumArtwork({
return (
<div className={cn("space-y-3", className)} {...props}>
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<ContextMenu>
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<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 ? (
{album.coverArt && api && !offline.isOfflineMode ? (
<Image
src={coverArtUrl}
alt={album.name}
@@ -148,9 +173,21 @@ 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">{album.name}</h3>
<h3 className="font-semibold truncate">
<Link href={`/album/${album.id}`} prefetch>{album.name}</Link>
</h3>
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
<p className="text-xs text-muted-foreground mt-1">
{album.songCount} songs {Math.floor(album.duration / 60)} min
@@ -213,6 +250,7 @@ export function AlbumArtwork({
<ContextMenuItem>Share</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation';
import { cn } from "@/lib/utils"
import { motion } from 'framer-motion'
import {
ContextMenu,
ContextMenuContent,
@@ -88,6 +89,13 @@ export function ArtistIcon({
<div className={cn("space-y-3", className)} {...props}>
<ContextMenu>
<ContextMenuTrigger>
<motion.div
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<Card key={artist.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div
className="aspect-square relative group"
@@ -118,6 +126,7 @@ export function ArtistIcon({
</p>
</CardContent>
</Card>
</motion.div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={handleStar}>

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation';
import Image from "next/image";
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile";
import { useGlobalSearch } from "./GlobalSearchProvider";
import {
Menubar,
MenubarCheckboxItem,
@@ -75,6 +76,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
const [isClient, setIsClient] = useState(false);
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
const isMobile = useIsMobile();
const { openSpotlight } = useGlobalSearch();
// Navigation items for mobile menu
const navigationItems = [
@@ -333,9 +335,19 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</Menubar>
)}
{/* User Profile - Desktop only */}
{/* User Profile and Search - Desktop only */}
{!isMobile && (
<div className="ml-auto">
<div className="ml-auto flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={openSpotlight}
className="flex items-center space-x-2"
title="Search (/ or ⌘K)"
>
<Search className="w-4 h-4" />
<span className="hidden lg:inline">Search</span>
</Button>
<UserProfile variant="desktop" />
</div>
)}

View File

@@ -10,6 +10,7 @@ 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,
@@ -78,6 +79,10 @@ 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

@@ -26,12 +26,6 @@ export const metadata = {
'max-snippet': -1,
},
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
@@ -57,6 +51,13 @@ export const metadata = {
},
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
@@ -76,6 +77,7 @@ export default function Layout({ children }: LayoutProps) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="manifest" href="/manifest.json" />
<script
dangerouslySetInnerHTML={{
__html: `

View File

@@ -114,7 +114,7 @@ export default function SongsPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -135,7 +135,7 @@ export default function SongsPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred

View File

@@ -1,134 +0,0 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Mice',
short_name: 'Mice',
description: 'a very awesome navidrome client',
start_url: '/',
categories: ["music", "entertainment"],
display_override: ['window-controls-overlay'],
display: 'standalone',
background_color: '#0f0f0f',
theme_color: '#0f0f0f',
icons: [
{
src: '/favicon.ico',
type: 'image/x-icon',
sizes: '48x48'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '192x192'
},
{
src: '/icon-512.png',
type: 'image/png',
sizes: '512x512'
},
{
src: '/icon-192-maskable.png',
type: 'image/png',
sizes: '192x192',
purpose: 'maskable'
},
{
src: './icon-512-maskable.png',
type: 'image/png',
sizes: '512x512',
purpose: 'maskable'
},
// Apple Touch Icons for iOS
{
src: '/apple-touch-icon.png',
type: 'image/png',
sizes: '180x180',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '152x152',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '120x120',
purpose: 'any'
}
],
screenshots: [
{
src: '/home-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Home Preview',
form_factor: 'wide'
},
{
src: '/browse-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Browse Preview',
form_factor: 'wide'
},
{
src: '/album-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Album Preview',
form_factor: 'wide'
},
{
src: '/fullscreen-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Fullscreen Preview',
form_factor: 'wide'
}
],
shortcuts: [
{
name: 'Resume Song',
short_name: 'Resume',
description: 'Resume the last played song',
url: '/?action=resume',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Play Recent Albums',
short_name: 'Recent',
description: 'Play from recently added albums',
url: '/?action=recent',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Shuffle Favorites',
short_name: 'Shuffle',
description: 'Shuffle songs from favorite artists',
url: '/?action=shuffle-favorites',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
]
}
}

View File

@@ -4,9 +4,9 @@ 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 { useNavidrome } from './components/NavidromeContext';
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
import { useEffect, useState, Suspense } from 'react';
import { Album } from '@/lib/navidrome';
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidromeConfig } from './components/NavidromeConfigContext';
import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext';
@@ -14,52 +14,76 @@ 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() {
const { albums, isLoading, api, isConnected } = useNavidrome();
// Offline-first provider (falls back to offline data when not connected)
const offline = useOfflineNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams();
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [albumsLoading, setAlbumsLoading] = useState(true);
const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
// Load albums (offline-first)
useEffect(() => {
if (albums.length > 0) {
// Split albums into recent and newest for display
const recent = albums.slice(0, Math.ceil(albums.length / 2));
const newest = albums.slice(Math.ceil(albums.length / 2));
let mounted = true;
const load = async () => {
setAlbumsLoading(true);
try {
const list = await offline.getAlbums(false);
if (!mounted) return;
setAllAlbums(list || []);
// Split albums into two sections
const recent = list.slice(0, Math.ceil(list.length / 2));
const newest = list.slice(Math.ceil(list.length / 2));
setRecentAlbums(recent);
setNewestAlbums(newest);
} catch (e) {
console.error('Failed to load albums (offline-first):', e);
if (mounted) {
setAllAlbums([]);
setRecentAlbums([]);
setNewestAlbums([]);
}
}, [albums]);
useEffect(() => {
const loadFavoriteAlbums = async () => {
if (!api || !isConnected) return;
setFavoritesLoading(true);
try {
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage
setFavoriteAlbums(starredAlbums);
} catch (error) {
console.error('Failed to load favorite albums:', error);
} finally {
setFavoritesLoading(false);
if (mounted) setAlbumsLoading(false);
}
};
load();
return () => { mounted = false; };
}, [offline]);
useEffect(() => {
let mounted = true;
const loadFavoriteAlbums = async () => {
setFavoritesLoading(true);
try {
const starred = await offline.getAlbums(true);
if (mounted) setFavoriteAlbums((starred || []).slice(0, 20));
} catch (error) {
console.error('Failed to load favorite albums (offline-first):', error);
if (mounted) setFavoriteAlbums([]);
} finally {
if (mounted) setFavoritesLoading(false);
}
};
loadFavoriteAlbums();
}, [api, isConnected]);
return () => { mounted = false; };
}, [offline]);
// Handle PWA shortcuts
useEffect(() => {
const action = searchParams.get('action');
if (!action || shortcutProcessed || !api || !isConnected) return;
if (!action || shortcutProcessed) return;
const handleShortcuts = async () => {
try {
@@ -93,12 +117,13 @@ function MusicPageContent() {
// Add remaining albums to queue
for (let i = 1; i < shuffledAlbums.length; i++) {
try {
const albumSongs = await api.getAlbumSongs(shuffledAlbums[i].id);
albumSongs.forEach(song => {
const songs = await offline.getSongs(shuffledAlbums[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
@@ -109,7 +134,7 @@ function MusicPageContent() {
});
});
} catch (error) {
console.error('Failed to load album tracks:', error);
console.error('Failed to load album tracks (offline-first):', error);
}
}
}
@@ -131,12 +156,13 @@ function MusicPageContent() {
// Add remaining albums to queue
for (let i = 1; i < shuffledFavorites.length; i++) {
try {
const albumSongs = await api.getAlbumSongs(shuffledFavorites[i].id);
albumSongs.forEach(song => {
const songs = await offline.getSongs(shuffledFavorites[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
@@ -147,7 +173,7 @@ function MusicPageContent() {
});
});
} catch (error) {
console.error('Failed to load album tracks:', error);
console.error('Failed to load album tracks (offline-first):', error);
}
}
}
@@ -162,7 +188,7 @@ function MusicPageContent() {
// Delay to ensure data is loaded
const timeout = setTimeout(handleShortcuts, 1000);
return () => clearTimeout(timeout);
}, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]);
// Try to get user name from navidrome context, fallback to 'user'
let userName = '';
@@ -175,11 +201,30 @@ 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">
<TabsContent value="music" className="border-none p-0 outline-hidden">
@@ -197,7 +242,7 @@ function MusicPageContent() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">
@@ -284,7 +329,7 @@ function MusicPageContent() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">

View File

@@ -57,7 +57,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred

View File

@@ -3,14 +3,161 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react';
import { Play, X, Disc, Trash2, SkipForward, GripVertical } from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import {
CSS,
} from '@dnd-kit/utilities';
interface SortableQueueItemProps {
track: Track;
index: number;
onPlay: () => void;
onRemove: () => void;
formatDuration: (seconds: number) => string;
}
function SortableQueueItem({ track, index, onPlay, onRemove, formatDuration }: SortableQueueItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${track.id}-${index}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 transition-colors ${
isDragging ? 'bg-accent' : ''
}`}
>
{/* Drag Handle */}
<div
className="mr-3 opacity-60 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing p-1 -m-1 hover:bg-accent rounded"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
{/* Clickable content area for play */}
<div
className="flex items-center flex-1 cursor-pointer"
onClick={onPlay}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.duration)}
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
);
}
const QueuePage: React.FC = () => {
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue, reorderQueue } = useAudioPlayer();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px of movement before starting drag
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = queue.findIndex((track, index) => `${track.id}-${index}` === active.id);
const newIndex = queue.findIndex((track, index) => `${track.id}-${index}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderQueue(oldIndex, newIndex);
}
}
};
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
@@ -107,67 +254,29 @@ const QueuePage: React.FC = () => {
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.map((track, index) => `${track.id}-${index}`)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{queue.map((track, index) => (
<div
<SortableQueueItem
key={`${track.id}-${index}`}
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => skipToTrackInQueue(index)}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
track={track}
index={index}
onPlay={() => skipToTrackInQueue(index)}
onRemove={() => removeTrackFromQueue(index)}
formatDuration={formatDuration}
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeTrackFromQueue(index);
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</ScrollArea>
</div>

View File

@@ -11,6 +11,7 @@ import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { TrackContextMenu, AlbumContextMenu, ArtistContextMenu } from '@/app/components/ContextMenus';
import { Search, Play, Plus } from 'lucide-react';
export default function SearchPage() {
@@ -51,6 +52,31 @@ export default function SearchPage() {
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
// Focus search input when component mounts (for keyboard shortcut navigation)
useEffect(() => {
const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, []);
const createTrackFromSong = (song: Song) => {
if (!api) return null;
return {
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, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
};
};
const handlePlaySong = (song: Song) => {
if (!api) {
console.error('Navidrome API not available');
@@ -136,25 +162,29 @@ export default function SearchPage() {
)}
{/* Artists */}
{/* {searchResults.artists.length > 0 && (
{searchResults.artists.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4">Artists</h2>
<ScrollArea className="w-full">
<div className="flex space-x-4 pb-4">
{searchResults.artists.map((artist) => (
<ArtistIcon
<ArtistContextMenu
key={artist.id}
artistId={artist.id}
artistName={artist.name}
>
<ArtistIcon
artist={artist}
className="shrink-0 overflow-hidden"
size={190}
/>
</ArtistContextMenu>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)} */}
{/* broken for now */}
)}
{/* Albums */}
{searchResults.albums.length > 0 && (
@@ -163,14 +193,19 @@ export default function SearchPage() {
<ScrollArea className="w-full">
<div className="flex space-x-4 pb-4">
{searchResults.albums.map((album) => (
<AlbumArtwork
<AlbumContextMenu
key={album.id}
albumId={album.id}
albumName={album.name}
>
<AlbumArtwork
album={album}
className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
</AlbumContextMenu>
))}
</div>
<ScrollBar orientation="horizontal" />
@@ -183,8 +218,13 @@ export default function SearchPage() {
<div>
<h2 className="text-2xl font-bold mb-4">Songs</h2>
<div className="space-y-2">
{searchResults.songs.slice(0, 10).map((song, index) => (
<div key={song.id} className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors">
{searchResults.songs.slice(0, 10).map((song, index) => {
const track = createTrackFromSong(song);
if (!track) return null;
return (
<TrackContextMenu key={song.id} track={track}>
<div className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors cursor-pointer">
<div className="w-8 text-center text-sm text-muted-foreground">
<span className="group-hover:hidden">{index + 1}</span>
<Button
@@ -198,8 +238,9 @@ export default function SearchPage() {
</div>
{/* Song Cover */}
<div className="shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'}
<div className="shrink-0">
<Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}
@@ -230,7 +271,9 @@ export default function SearchPage() {
</Button>
</div>
</div>
))}
</TrackContextMenu>
);
})}
{searchResults.songs.length > 10 && (
<div className="text-center pt-4">

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useTheme } from '@/app/components/ThemeProvider';
@@ -14,8 +15,11 @@ import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-sh
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement';
import { CacheManagement } from '@/app/components/CacheManagement';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
import { Settings, ExternalLink } from 'lucide-react';
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';
import { Switch } from '@/components/ui/switch';
const SettingsPage = () => {
const { theme, setTheme, mode, setMode } = useTheme();
@@ -23,6 +27,7 @@ const SettingsPage = () => {
const { toast } = useToast();
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
const { shortcutType, updateShortcutType } = useSidebarShortcuts();
const audioPlayer = useAudioPlayer();
const [formData, setFormData] = useState({
serverUrl: '',
@@ -58,6 +63,7 @@ const SettingsPage = () => {
// Sidebar settings
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [notifyNowPlaying, setNotifyNowPlaying] = useState(false);
// Initialize client-side state after hydration
useEffect(() => {
@@ -93,6 +99,12 @@ const SettingsPage = () => {
setSidebarVisible(true); // Default to visible
}
// Notifications preference
const savedNotify = localStorage.getItem('playback-notifications-enabled');
if (savedNotify !== null) {
setNotifyNowPlaying(savedNotify === 'true');
}
// Load Last.fm credentials
const storedCredentials = localStorage.getItem('lastfm-credentials');
if (storedCredentials) {
@@ -263,6 +275,43 @@ const SettingsPage = () => {
}
};
const handleNotifyToggle = async (enabled: boolean) => {
setNotifyNowPlaying(enabled);
if (isClient) {
localStorage.setItem('playback-notifications-enabled', enabled.toString());
}
if (enabled && typeof window !== 'undefined' && 'Notification' in window) {
try {
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
} catch {}
}
toast({
title: enabled ? 'Notifications Enabled' : 'Notifications Disabled',
description: enabled ? 'You will be notified when a new song starts.' : 'Now playing notifications are off.',
});
};
const handleTestNotification = () => {
if (typeof window === 'undefined') return;
if (!('Notification' in window)) {
toast({ title: 'Not supported', description: 'Browser does not support notifications.', variant: 'destructive' });
return;
}
if (Notification.permission === 'denied') {
toast({ title: 'Permission denied', description: 'Enable notifications in your browser settings.', variant: 'destructive' });
return;
}
const title = 'mice Test Notification';
const body = 'This is how a now playing notification will look.';
try {
new Notification(title, { body, icon: '/icon-192.png', badge: '/icon-192.png' });
} catch {
toast({ title: 'Test Notification', description: body });
}
};
const handleLastFmAuth = () => {
if (!lastFmCredentials.apiKey) {
toast({
@@ -469,6 +518,29 @@ const SettingsPage = () => {
</Card>
)}
{/* Notifications */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Notifications
</CardTitle>
<CardDescription>Control now playing notifications</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Now playing notifications</p>
<p className="text-sm text-muted-foreground">Show a notification when a new song starts</p>
</div>
<Switch checked={notifyNowPlaying} onCheckedChange={handleNotifyToggle} />
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleTestNotification}>Test notification</Button>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -712,6 +784,16 @@ const SettingsPage = () => {
<CacheManagement />
</div>
{/* Offline Library Management */}
<div className="break-inside-avoid mb-6">
<EnhancedOfflineManager />
</div>
{/* Auto-Tagging Settings */}
<div className="break-inside-avoid mb-6">
<AutoTaggingSettings />
</div>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Appearance</CardTitle>
@@ -761,6 +843,87 @@ const SettingsPage = () => {
</Card>
{/* Theme Preview */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaCog className="w-5 h-5" />
Audio Settings
</CardTitle>
<CardDescription>
Configure playback and audio effects
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label htmlFor="crossfade-duration">Crossfade Duration</Label>
<Select
value={String(audioPlayer.audioSettings.crossfadeDuration)}
onValueChange={(value) => audioPlayer.updateAudioSettings({ crossfadeDuration: Number(value) })}
>
<SelectTrigger id="crossfade-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Off</SelectItem>
<SelectItem value="2">2 seconds</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="4">4 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
</SelectContent>
</Select>
</div>
{/* Equalizer Preset */}
<div className="space-y-2">
<Label htmlFor="equalizer-preset">Equalizer Preset</Label>
<Select
value={audioPlayer.equalizerPreset}
onValueChange={audioPlayer.setEqualizerPreset}
>
<SelectTrigger id="equalizer-preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bassBoost">Bass Boost</SelectItem>
<SelectItem value="trebleBoost">Treble Boost</SelectItem>
<SelectItem value="vocalBoost">Vocal Boost</SelectItem>
</SelectContent>
</Select>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<div>
<Label>ReplayGain</Label>
<p className="text-sm text-muted-foreground">Normalize volume across tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.replayGainEnabled}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ replayGainEnabled: checked })}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<div>
<Label>Gapless Playback</Label>
<p className="text-sm text-muted-foreground">Seamless transitions between tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.gaplessPlayback}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ gaplessPlayback: checked })}
/>
</div> <div className="text-sm text-muted-foreground space-y-2">
<p><strong>Crossfade:</strong> Smooth fade between tracks (2-5 seconds)</p>
<p><strong>Equalizer:</strong> Preset frequency adjustments for different music styles</p>
<p><strong>ReplayGain:</strong> Consistent volume across all tracks in your library</p>
<p><strong>Gapless:</strong> Perfect for live albums and continuous DJ mixes</p>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Preview</CardTitle>

View File

@@ -0,0 +1,56 @@
'use client';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface InfiniteScrollProps {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
loadingText?: string;
endMessage?: string;
className?: string;
}
export function InfiniteScroll({
onLoadMore,
hasMore,
isLoading,
loadingText = 'Loading more items...',
endMessage = 'No more items to load',
className
}: InfiniteScrollProps) {
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '100px 0px',
});
useEffect(() => {
if (inView && hasMore && !isLoading) {
onLoadMore();
}
}, [inView, hasMore, isLoading, onLoadMore]);
return (
<div
ref={ref}
className={cn(
'py-4 flex flex-col items-center justify-center w-full',
className
)}
>
{isLoading && (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm text-muted-foreground">{loadingText}</p>
</div>
)}
{!hasMore && !isLoading && (
<p className="text-sm text-muted-foreground">{endMessage}</p>
)}
</div>
);
}

603
hooks/use-auto-tagging.ts Normal file
View File

@@ -0,0 +1,603 @@
import { useState, useCallback } from 'react';
import MusicBrainzClient, {
MusicBrainzRelease,
MusicBrainzReleaseDetails,
MusicBrainzRecording,
MusicBrainzRecordingDetails
} from '@/lib/musicbrainz-api';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast';
import { Album, Song, Artist } from '@/lib/navidrome';
// Define interfaces for the enhanced metadata
// Define interfaces for the enhanced metadata
export interface EnhancedTrackMetadata {
id: string; // Navidrome track ID
title: string; // Track title
artist: string; // Artist name
album: string; // Album name
mbTrackId?: string; // MusicBrainz recording ID
mbReleaseId?: string; // MusicBrainz release ID
mbArtistId?: string; // MusicBrainz artist ID
year?: string; // Release year
genres?: string[]; // Genres
tags?: string[]; // Tags
trackNumber?: number; // Track number
discNumber?: number; // Disc number
duration?: number; // Duration in seconds
artistCountry?: string; // Artist country
artistType?: string; // Artist type (group, person, etc.)
releaseType?: string; // Release type (album, EP, single, etc.)
status: 'pending' | 'matched' | 'failed' | 'applied'; // Status of the track metadata
confidence: number; // Match confidence (0-100)
}
export interface EnhancedAlbumMetadata {
id: string; // Navidrome album ID
name: string; // Album name
artist: string; // Album artist name
mbReleaseId?: string; // MusicBrainz release ID
mbArtistId?: string; // MusicBrainz artist ID
year?: string; // Release year
genres?: string[]; // Genres
tags?: string[]; // Tags
country?: string; // Release country
releaseType?: string; // Release type (album, EP, single, etc.)
barcode?: string; // Barcode
label?: string; // Record label
status: 'pending' | 'matched' | 'failed' | 'applied'; // Status
confidence: number; // Match confidence (0-100)
tracks: EnhancedTrackMetadata[]; // Tracks in the album
coverArtUrl?: string; // Cover art URL from MusicBrainz
}
// Type for the Auto-Tagging operation mode
export type AutoTaggingMode = 'track' | 'album' | 'artist';
export function useAutoTagging() {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [enhancedTracks, setEnhancedTracks] = useState<EnhancedTrackMetadata[]>([]);
const [enhancedAlbums, setEnhancedAlbums] = useState<EnhancedAlbumMetadata[]>([]);
const { toast } = useToast();
const api = getNavidromeAPI();
/**
* Find enhanced metadata for a single track from MusicBrainz
*/
const enhanceTrack = useCallback(async (track: Song): Promise<EnhancedTrackMetadata> => {
try {
// Start with basic metadata
const enhancedTrack: EnhancedTrackMetadata = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'pending',
confidence: 0
};
// Try to find the track in MusicBrainz
const recording = await MusicBrainzClient.findBestMatchingRecording(
track.title,
track.artist,
track.duration * 1000 // Convert to milliseconds
);
if (!recording) {
enhancedTrack.status = 'failed';
return enhancedTrack;
}
// Get detailed recording information
const recordingDetails = await MusicBrainzClient.getRecording(recording.id);
if (!recordingDetails) {
enhancedTrack.status = 'failed';
return enhancedTrack;
}
// Calculate match confidence
const titleSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(recording.title)
);
const artistSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.artist),
MusicBrainzClient.normalizeString(recording['artist-credit'][0]?.artist.name || '')
);
// Calculate confidence score (0-100)
enhancedTrack.confidence = Math.round((titleSimilarity * 0.6 + artistSimilarity * 0.4) * 100);
// Update track with MusicBrainz metadata
enhancedTrack.mbTrackId = recording.id;
enhancedTrack.mbArtistId = recording['artist-credit'][0]?.artist.id;
// Extract additional metadata from recordingDetails
if (recordingDetails.releases && recordingDetails.releases.length > 0) {
enhancedTrack.mbReleaseId = recordingDetails.releases[0].id;
}
if (recordingDetails['first-release-date']) {
enhancedTrack.year = recordingDetails['first-release-date'].split('-')[0];
}
if (recordingDetails.genres) {
enhancedTrack.genres = recordingDetails.genres.map(genre => genre.name);
}
if (recordingDetails.tags) {
enhancedTrack.tags = recordingDetails.tags.map(tag => tag.name);
}
enhancedTrack.status = 'matched';
return enhancedTrack;
} catch (error) {
console.error('Failed to enhance track:', error);
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'failed',
confidence: 0
};
}
}, []);
/**
* Find enhanced metadata for an album and its tracks from MusicBrainz
*/
const enhanceAlbum = useCallback(async (album: Album, tracks: Song[]): Promise<EnhancedAlbumMetadata> => {
try {
// Start with basic metadata
const enhancedAlbum: EnhancedAlbumMetadata = {
id: album.id,
name: album.name,
artist: album.artist,
status: 'pending',
confidence: 0,
tracks: []
};
// Try to find the album in MusicBrainz
const release = await MusicBrainzClient.findBestMatchingRelease(
album.name,
album.artist,
tracks.length
);
if (!release) {
enhancedAlbum.status = 'failed';
return enhancedAlbum;
}
// Get detailed release information
const releaseDetails = await MusicBrainzClient.getRelease(release.id);
if (!releaseDetails) {
enhancedAlbum.status = 'failed';
return enhancedAlbum;
}
// Calculate match confidence
const albumSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(album.name),
MusicBrainzClient.normalizeString(release.title)
);
const artistSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(album.artist),
MusicBrainzClient.normalizeString(release['artist-credit'][0]?.artist.name || '')
);
// Calculate confidence score (0-100)
enhancedAlbum.confidence = Math.round((albumSimilarity * 0.6 + artistSimilarity * 0.4) * 100);
// Update album with MusicBrainz metadata
enhancedAlbum.mbReleaseId = release.id;
enhancedAlbum.mbArtistId = release['artist-credit'][0]?.artist.id;
if (release.date) {
enhancedAlbum.year = release.date.split('-')[0];
}
if (release.country) {
enhancedAlbum.country = release.country;
}
// We need to access release-group via a type assertion since it's not defined in MusicBrainzRelease interface
// But it exists in the MusicBrainzReleaseDetails which we're working with
const releaseWithGroup = release as unknown as { 'release-group'?: { id: string; 'primary-type'?: string } };
if (releaseWithGroup['release-group'] && releaseWithGroup['release-group']['primary-type']) {
enhancedAlbum.releaseType = releaseWithGroup['release-group']['primary-type'];
}
if (releaseDetails.barcode) {
enhancedAlbum.barcode = releaseDetails.barcode;
}
// Get cover art URL
if (releaseDetails['cover-art-archive'] && releaseDetails['cover-art-archive'].front) {
enhancedAlbum.coverArtUrl = MusicBrainzClient.getCoverArtUrl(release.id);
}
// Match tracks with MusicBrainz tracks
const enhancedTracks: EnhancedTrackMetadata[] = [];
// First, organize MB tracks by disc and track number
// Define a type for the MusicBrainz track
interface MusicBrainzTrack {
position: number;
number: string;
title: string;
length?: number;
recording: {
id: string;
title: string;
length?: number;
};
}
const mbTracks: Record<number, Record<number, MusicBrainzTrack>> = {};
if (releaseDetails.media) {
for (const medium of releaseDetails.media) {
const discNumber = medium.position;
mbTracks[discNumber] = {};
for (const track of medium.tracks) {
mbTracks[discNumber][track.position] = track;
}
}
}
// Try to match each track
for (const track of tracks) {
// Basic track info
const enhancedTrack: EnhancedTrackMetadata = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'pending',
confidence: 0
};
// Try to find the track by position if available
if (track.discNumber && track.track && mbTracks[track.discNumber] && mbTracks[track.discNumber][track.track]) {
const mbTrack = mbTracks[track.discNumber][track.track];
enhancedTrack.mbTrackId = mbTrack.recording.id;
enhancedTrack.mbReleaseId = release.id;
enhancedTrack.trackNumber = track.track;
enhancedTrack.discNumber = track.discNumber;
// Calculate title similarity
const titleSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(mbTrack.title)
);
enhancedTrack.confidence = Math.round(titleSimilarity * 100);
enhancedTrack.status = 'matched';
}
// If we can't match by position, try to match by title
else {
// Find in any medium and any position
let bestMatch: MusicBrainzTrack | null = null;
let bestSimilarity = 0;
for (const discNumber of Object.keys(mbTracks)) {
for (const trackNumber of Object.keys(mbTracks[Number(discNumber)])) {
const mbTrack = mbTracks[Number(discNumber)][Number(trackNumber)];
const similarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(mbTrack.title)
);
if (similarity > bestSimilarity && similarity > 0.6) { // 60% similarity threshold
bestMatch = mbTrack;
bestSimilarity = similarity;
}
}
}
if (bestMatch) {
enhancedTrack.mbTrackId = bestMatch.recording.id;
enhancedTrack.mbReleaseId = release.id;
enhancedTrack.confidence = Math.round(bestSimilarity * 100);
enhancedTrack.status = 'matched';
} else {
enhancedTrack.status = 'failed';
}
}
enhancedTracks.push(enhancedTrack);
}
// Update album with tracks
enhancedAlbum.tracks = enhancedTracks;
enhancedAlbum.status = 'matched';
return enhancedAlbum;
} catch (error) {
console.error('Failed to enhance album:', error);
return {
id: album.id,
name: album.name,
artist: album.artist,
status: 'failed',
confidence: 0,
tracks: []
};
}
}, []);
/**
* Start the auto-tagging process for a track, album, or artist
*/
const startAutoTagging = useCallback(async (
mode: AutoTaggingMode,
itemId: string,
confidenceThreshold: number = 70
) => {
if (!api) {
toast({
title: "Error",
description: "Navidrome API is not configured",
variant: "destructive",
});
return;
}
setIsProcessing(true);
setProgress(0);
setEnhancedTracks([]);
setEnhancedAlbums([]);
try {
// Process different modes
if (mode === 'track') {
// In the absence of a direct method to get a song by ID,
// we'll find it by searching for it in its album
const searchResults = await api.search(itemId, 0, 0, 10);
const track = searchResults.songs.find(song => song.id === itemId);
if (!track) {
throw new Error('Track not found');
}
setProgress(10);
// Enhance track metadata
const enhancedTrack = await enhanceTrack(track);
setEnhancedTracks([enhancedTrack]);
setProgress(100);
toast({
title: "Track Analysis Complete",
description: enhancedTrack.status === 'matched'
? `Found metadata for "${track.title}" with ${enhancedTrack.confidence}% confidence`
: `Couldn't find metadata for "${track.title}"`,
});
}
else if (mode === 'album') {
// Get album and its tracks from Navidrome
const { album, songs } = await api.getAlbum(itemId);
if (!album) {
throw new Error('Album not found');
}
setProgress(10);
// Enhance album metadata
const enhancedAlbum = await enhanceAlbum(album, songs);
setEnhancedAlbums([enhancedAlbum]);
setProgress(100);
toast({
title: "Album Analysis Complete",
description: enhancedAlbum.status === 'matched'
? `Found metadata for "${album.name}" with ${enhancedAlbum.confidence}% confidence`
: `Couldn't find metadata for "${album.name}"`,
});
}
else if (mode === 'artist') {
// Get artist and their albums from Navidrome
try {
const { artist, albums } = await api.getArtist(itemId);
if (!artist) {
throw new Error('Artist not found');
}
setProgress(5);
const enhancedAlbumsData: EnhancedAlbumMetadata[] = [];
let processedAlbums = 0;
// Process each album
for (const album of albums) {
try {
const { songs } = await api.getAlbum(album.id);
const enhancedAlbum = await enhanceAlbum(album, songs);
enhancedAlbumsData.push(enhancedAlbum);
} catch (albumError) {
console.error('Error processing album:', albumError);
// Continue with the next album
}
processedAlbums++;
setProgress(5 + Math.round((processedAlbums / albums.length) * 95));
}
setEnhancedAlbums(enhancedAlbumsData);
setProgress(100);
const matchedAlbums = enhancedAlbumsData.filter(album =>
album.status === 'matched' && album.confidence >= confidenceThreshold
).length;
toast({
title: "Artist Analysis Complete",
description: `Found metadata for ${matchedAlbums} of ${albums.length} albums by "${artist.name}"`,
});
} catch (artistError) {
console.error('Error fetching artist:', artistError);
toast({
title: "Artist Not Found",
description: "Could not find the artist in your library",
variant: "destructive",
});
setProgress(100);
}
}
} catch (error) {
console.error('Auto-tagging error:', error);
toast({
title: "Auto-Tagging Failed",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
}, [api, enhanceTrack, enhanceAlbum, toast]);
/**
* Apply enhanced metadata to tracks in Navidrome
*/
const applyEnhancedMetadata = useCallback(async (
tracks: EnhancedTrackMetadata[],
albums?: EnhancedAlbumMetadata[]
) => {
if (!api) {
toast({
title: "Error",
description: "Navidrome API is not configured",
variant: "destructive",
});
return;
}
setIsProcessing(true);
setProgress(0);
try {
let processedItems = 0;
const totalItems = tracks.length + (albums?.length || 0);
// Apply album metadata first
if (albums && albums.length > 0) {
for (const album of albums) {
if (album.status === 'matched') {
// To be implemented: Update album metadata via Navidrome API
// This requires a custom Navidrome endpoint or plugin
console.log('Would update album:', album);
}
processedItems++;
setProgress(Math.round((processedItems / totalItems) * 100));
}
}
// Apply track metadata
for (const track of tracks) {
if (track.status === 'matched') {
// To be implemented: Update track metadata via Navidrome API
// This requires a custom Navidrome endpoint or plugin
console.log('Would update track:', track);
// Alternatively, suggest implementing this feature using a separate
// script that interacts with music files directly
}
processedItems++;
setProgress(Math.round((processedItems / totalItems) * 100));
}
toast({
title: "Metadata Applied",
description: `Updated metadata for ${tracks.filter(t => t.status === 'matched').length} tracks`,
});
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Metadata Update Failed",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
}, [api, toast]);
return {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
};
}
/**
* Calculate similarity between two strings (0-1)
* Uses Levenshtein distance
*/
function calculateStringSimilarity(str1: string, str2: string): number {
// If either string is empty, return 0
if (!str1.length || !str2.length) {
return 0;
}
// If strings are identical, return 1
if (str1 === str2) {
return 1;
}
// Calculate Levenshtein distance
const distance = levenshteinDistance(str1, str2);
// Calculate similarity score
const maxLength = Math.max(str1.length, str2.length);
const similarity = 1 - distance / maxLength;
return similarity;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = [];
// Initialize matrix with row and column indices
for (let i = 0; i <= str1.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str2.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= str1.length; i++) {
for (let j = 1; j <= str2.length; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + cost // Substitution
);
}
}
return matrix[str1.length][str2.length];
}

View File

@@ -60,7 +60,7 @@ export function useFavoriteAlbums() {
id: album.id,
name: album.name,
artist: album.artist,
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 64) : undefined
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 300) : undefined
};
addFavoriteAlbum(favoriteAlbum);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
interface KeyboardShortcutsOptions {
onPlayPause?: () => void;
onNextTrack?: () => void;
onPreviousTrack?: () => void;
onVolumeUp?: () => void;
onVolumeDown?: () => void;
onToggleMute?: () => void;
onSpotlightSearch?: () => void;
disabled?: boolean;
}
export function useKeyboardShortcuts({
onPlayPause,
onNextTrack,
onPreviousTrack,
onVolumeUp,
onVolumeDown,
onToggleMute,
onSpotlightSearch,
disabled = false
}: KeyboardShortcutsOptions = {}) {
const router = useRouter();
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Don't trigger shortcuts if user is typing in an input field
const target = event.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.closest('[data-cmdk-input]'); // Command palette input
if (disabled || isInputField) return;
// Prevent default behavior for our shortcuts
const preventDefault = () => {
event.preventDefault();
event.stopPropagation();
};
switch (event.key) {
case ' ': // Space - Play/Pause
if (onPlayPause) {
preventDefault();
onPlayPause();
}
break;
case 'ArrowRight': // Right Arrow - Next Track
if (onNextTrack) {
preventDefault();
onNextTrack();
}
break;
case 'ArrowLeft': // Left Arrow - Previous Track
if (onPreviousTrack) {
preventDefault();
onPreviousTrack();
}
break;
case 'ArrowUp': // Up Arrow - Volume Up
if (onVolumeUp) {
preventDefault();
onVolumeUp();
}
break;
case 'ArrowDown': // Down Arrow - Volume Down
if (onVolumeDown) {
preventDefault();
onVolumeDown();
}
break;
case 'm': // M - Toggle Mute
case 'M':
if (onToggleMute) {
preventDefault();
onToggleMute();
}
break;
case 'k': // Cmd+K or Ctrl+K - Spotlight Search
case 'K':
if ((event.metaKey || event.ctrlKey) && onSpotlightSearch) {
preventDefault();
onSpotlightSearch();
}
break;
default:
break;
}
}, [
disabled,
onPlayPause,
onNextTrack,
onPreviousTrack,
onVolumeUp,
onVolumeDown,
onToggleMute,
onSpotlightSearch,
router
]);
useEffect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return {
// Return any utility functions if needed
isShortcutActive: !disabled
};
}

View File

@@ -0,0 +1,287 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Track } from '@/app/components/AudioPlayerContext';
// Interface for a single day's listening data
export interface DayStreakData {
date: string; // ISO string of the date
tracks: number; // Number of tracks played that day
uniqueArtists: Set<string>; // Unique artists listened to
uniqueAlbums: Set<string>; // Unique albums listened to
totalListeningTime: number; // Total seconds listened
}
// Interface for streak statistics
export interface StreakStats {
currentStreak: number; // Current consecutive days streak
longestStreak: number; // Longest streak ever achieved
totalDaysListened: number; // Total days with listening activity
lastListenedDate: string | null; // Last date with listening activity
}
const STREAK_THRESHOLD_TRACKS = 3; // Minimum tracks to count as an active day
const STREAK_THRESHOLD_TIME = 5 * 60; // 5 minutes minimum listening time
export function useListeningStreak() {
const [streakData, setStreakData] = useState<Map<string, DayStreakData>>(new Map());
const [stats, setStats] = useState<StreakStats>({
currentStreak: 0,
longestStreak: 0,
totalDaysListened: 0,
lastListenedDate: null,
});
const { playedTracks, currentTrack } = useAudioPlayer();
// Initialize streak data from localStorage
useEffect(() => {
// Check if we're in the browser environment
if (typeof window === 'undefined') return;
try {
const savedStreakData = localStorage.getItem('navidrome-streak-data');
const savedStats = localStorage.getItem('navidrome-streak-stats');
if (savedStreakData) {
// Convert the plain object back to a Map
const parsedData = JSON.parse(savedStreakData);
const dataMap = new Map<string, DayStreakData>();
// Reconstruct the Map and Sets
Object.entries(parsedData).forEach(([key, value]: [string, any]) => {
dataMap.set(key, {
...value,
uniqueArtists: new Set(value.uniqueArtists),
uniqueAlbums: new Set(value.uniqueAlbums)
});
});
setStreakData(dataMap);
}
if (savedStats) {
setStats(JSON.parse(savedStats));
}
// Check if we need to update the streak based on the current date
updateStreakStatus();
} catch (error) {
console.error('Failed to load streak data:', error);
}
}, []);
// Save streak data to localStorage whenever it changes
useEffect(() => {
if (typeof window === 'undefined' || streakData.size === 0) return;
try {
// Convert Map to a plain object for serialization
const dataObject: Record<string, any> = {};
streakData.forEach((value, key) => {
dataObject[key] = {
...value,
uniqueArtists: Array.from(value.uniqueArtists),
uniqueAlbums: Array.from(value.uniqueAlbums)
};
});
localStorage.setItem('navidrome-streak-data', JSON.stringify(dataObject));
localStorage.setItem('navidrome-streak-stats', JSON.stringify(stats));
} catch (error) {
console.error('Failed to save streak data:', error);
}
}, [streakData, stats]);
// Process playedTracks to update the streak
useEffect(() => {
if (playedTracks.length === 0) return;
// Get today's date in YYYY-MM-DD format
const today = new Date().toISOString().split('T')[0];
// Update streak data for today
setStreakData(prev => {
const updated = new Map(prev);
const todayData = updated.get(today) || {
date: today,
tracks: 0,
uniqueArtists: new Set<string>(),
uniqueAlbums: new Set<string>(),
totalListeningTime: 0
};
// Update today's data based on played tracks
// For simplicity, we'll assume one track added = one complete listen
const lastTrack = playedTracks[playedTracks.length - 1];
todayData.tracks += 1;
todayData.uniqueArtists.add(lastTrack.artistId);
todayData.uniqueAlbums.add(lastTrack.albumId);
todayData.totalListeningTime += lastTrack.duration;
updated.set(today, todayData);
return updated;
});
// Update streak statistics
updateStreakStatus();
}, [playedTracks.length]);
// Function to update streak status based on current data
const updateStreakStatus = useCallback(() => {
if (streakData.size === 0) return;
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
// Sort dates in descending order (newest first)
const dates = Array.from(streakData.keys()).sort((a, b) =>
new Date(b).getTime() - new Date(a).getTime()
);
// Check which days count as active based on threshold
const activeDays = dates.filter(date => {
const dayData = streakData.get(date);
if (!dayData) return false;
return dayData.tracks >= STREAK_THRESHOLD_TRACKS ||
dayData.totalListeningTime >= STREAK_THRESHOLD_TIME;
});
// Calculate current streak
let currentStreak = 0;
let checkDate = new Date(today);
// Keep checking consecutive days backward until streak breaks
while (true) {
const dateString = checkDate.toISOString().split('T')[0];
if (activeDays.includes(dateString)) {
currentStreak++;
checkDate.setDate(checkDate.getDate() - 1); // Go back one day
} else {
break; // Streak broken
}
}
// Get total active days
const totalDaysListened = activeDays.length;
// Get longest streak (requires analyzing all streaks)
let longestStreak = currentStreak;
let tempStreak = 0;
// Sort dates in ascending order for streak calculation
const ascDates = [...activeDays].sort();
for (let i = 0; i < ascDates.length; i++) {
const currentDate = new Date(ascDates[i]);
if (i > 0) {
const prevDate = new Date(ascDates[i-1]);
prevDate.setDate(prevDate.getDate() + 1);
// If dates are consecutive
if (currentDate.getTime() === prevDate.getTime()) {
tempStreak++;
} else {
// Streak broken
tempStreak = 1;
}
} else {
tempStreak = 1; // First active day
}
longestStreak = Math.max(longestStreak, tempStreak);
}
// Get last listened date
const lastListenedDate = activeDays.length > 0 ? activeDays[0] : null;
// Update stats
setStats({
currentStreak,
longestStreak,
totalDaysListened,
lastListenedDate
});
}, [streakData]);
// Check if user has listened today
const hasListenedToday = useCallback(() => {
const today = new Date().toISOString().split('T')[0];
const todayData = streakData.get(today);
return todayData && (
todayData.tracks >= STREAK_THRESHOLD_TRACKS ||
todayData.totalListeningTime >= STREAK_THRESHOLD_TIME
);
}, [streakData]);
// Get streak emoji representation
const getStreakEmoji = useCallback(() => {
if (stats.currentStreak <= 0) return '';
if (stats.currentStreak >= 30) return '🔥🔥🔥'; // 30+ days
if (stats.currentStreak >= 14) return '🔥🔥'; // 14+ days
if (stats.currentStreak >= 7) return '🔥'; // 7+ days
if (stats.currentStreak >= 3) return '✨'; // 3+ days
return '📅'; // 1-2 days
}, [stats.currentStreak]);
// Get today's listening summary
const getTodaySummary = useCallback(() => {
const today = new Date().toISOString().split('T')[0];
const todayData = streakData.get(today);
if (!todayData) {
return {
tracks: 0,
artists: 0,
albums: 0,
time: '0m'
};
}
// Format time nicely
const minutes = Math.floor(todayData.totalListeningTime / 60);
const timeDisplay = minutes === 1 ? '1m' : `${minutes}m`;
return {
tracks: todayData.tracks,
artists: todayData.uniqueArtists.size,
albums: todayData.uniqueAlbums.size,
time: timeDisplay
};
}, [streakData]);
// Reset streak data (for testing)
const resetStreakData = useCallback(() => {
setStreakData(new Map());
setStats({
currentStreak: 0,
longestStreak: 0,
totalDaysListened: 0,
lastListenedDate: null,
});
localStorage.removeItem('navidrome-streak-data');
localStorage.removeItem('navidrome-streak-stats');
}, []);
return {
stats,
hasListenedToday,
getStreakEmoji,
getTodaySummary,
resetStreakData,
streakThresholds: {
tracks: STREAK_THRESHOLD_TRACKS,
time: STREAK_THRESHOLD_TIME
}
};
}

View File

@@ -0,0 +1,281 @@
'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

@@ -0,0 +1,682 @@
'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

@@ -0,0 +1,517 @@
'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

@@ -0,0 +1,538 @@
'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

@@ -0,0 +1,245 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
const INITIAL_BATCH_SIZE = 24; // Initial number of albums to load
const BATCH_SIZE = 24; // Number of albums to load in each batch
const SCROLL_THRESHOLD = 200; // Pixels from bottom before loading more
export type AlbumSortOption = 'alphabeticalByName' | 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByArtist' | 'starred' | 'highest';
export function useProgressiveAlbumLoading(sortBy: AlbumSortOption = 'alphabeticalByName') {
const [albums, setAlbums] = useState<Album[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [currentOffset, setCurrentOffset] = useState(0);
const { api } = useNavidrome();
const offlineApi = useOfflineNavidrome();
const offlineLibrary = useOfflineLibrary();
const [error, setError] = useState<string | null>(null);
// Load initial batch
useEffect(() => {
loadInitialBatch();
}, [sortBy]);
// Cleanup when sort changes
useEffect(() => {
return () => {
setAlbums([]);
setCurrentOffset(0);
setHasMore(true);
};
}, [sortBy]);
// We'll define the scroll listener after defining loadMoreAlbums
// Load initial batch of albums
const loadInitialBatch = useCallback(async () => {
if (!api && !offlineLibrary.isInitialized) return;
setIsLoading(true);
setError(null);
try {
let albumData: Album[] = [];
// Try offline-first approach
if (offlineLibrary.isInitialized) {
try {
// For starred albums, use the starred parameter
if (sortBy === 'starred') {
albumData = await offlineApi.getAlbums(true);
} else {
albumData = await offlineApi.getAlbums(false);
// Apply client-side sorting since offline API might not support all sort options
if (sortBy === 'newest') {
albumData.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
} else if (sortBy === 'alphabeticalByArtist') {
albumData.sort((a, b) => a.artist.localeCompare(b.artist));
} else if (sortBy === 'alphabeticalByName') {
albumData.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === 'recent') {
// Sort by recently played - if we have timestamps
const recentlyPlayedMap = new Map<string, number>();
const recentlyPlayed = localStorage.getItem('recently-played-albums');
if (recentlyPlayed) {
try {
const parsed = JSON.parse(recentlyPlayed);
Object.entries(parsed).forEach(([id, timestamp]) => {
recentlyPlayedMap.set(id, timestamp as number);
});
albumData.sort((a, b) => {
const timestampA = recentlyPlayedMap.get(a.id) || 0;
const timestampB = recentlyPlayedMap.get(b.id) || 0;
return timestampB - timestampA;
});
} catch (error) {
console.error('Error parsing recently played albums:', error);
}
}
}
}
// If we got albums offline and it's non-empty, use that
if (albumData && albumData.length > 0) {
// Just take the initial batch for consistent behavior
const initialBatch = albumData.slice(0, INITIAL_BATCH_SIZE);
setAlbums(initialBatch);
setCurrentOffset(initialBatch.length);
setHasMore(albumData.length > initialBatch.length);
setIsLoading(false);
return;
}
} catch (offlineError) {
console.error('Error loading albums from offline storage:', offlineError);
// Continue to online API as fallback
}
}
// Fall back to online API if needed
if (api) {
albumData = await api.getAlbums(sortBy, INITIAL_BATCH_SIZE, 0);
setAlbums(albumData);
setCurrentOffset(albumData.length);
// Assume there are more unless we got fewer than we asked for
setHasMore(albumData.length >= INITIAL_BATCH_SIZE);
} else {
// No API available
setAlbums([]);
setHasMore(false);
}
} catch (err) {
console.error('Failed to load initial albums batch:', err);
setError(err instanceof Error ? err.message : 'Unknown error loading albums');
setAlbums([]);
setHasMore(false);
} finally {
setIsLoading(false);
}
}, [api, offlineApi, offlineLibrary, sortBy]);
// Load more albums when scrolling
const loadMoreAlbums = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
let newAlbums: Album[] = [];
// Try offline-first approach (if we already have offline data)
if (offlineLibrary.isInitialized && albums.length > 0) {
try {
// For starred albums, use the starred parameter
let allAlbums: Album[] = [];
if (sortBy === 'starred') {
allAlbums = await offlineApi.getAlbums(true);
} else {
allAlbums = await offlineApi.getAlbums(false);
// Apply client-side sorting
if (sortBy === 'newest') {
allAlbums.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
} else if (sortBy === 'alphabeticalByArtist') {
allAlbums.sort((a, b) => a.artist.localeCompare(b.artist));
} else if (sortBy === 'alphabeticalByName') {
allAlbums.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === 'recent') {
// Sort by recently played - if we have timestamps
const recentlyPlayedMap = new Map<string, number>();
const recentlyPlayed = localStorage.getItem('recently-played-albums');
if (recentlyPlayed) {
try {
const parsed = JSON.parse(recentlyPlayed);
Object.entries(parsed).forEach(([id, timestamp]) => {
recentlyPlayedMap.set(id, timestamp as number);
});
allAlbums.sort((a, b) => {
const timestampA = recentlyPlayedMap.get(a.id) || 0;
const timestampB = recentlyPlayedMap.get(b.id) || 0;
return timestampB - timestampA;
});
} catch (error) {
console.error('Error parsing recently played albums:', error);
}
}
}
}
// Slice the next batch from the offline data
if (allAlbums && allAlbums.length > currentOffset) {
newAlbums = allAlbums.slice(currentOffset, currentOffset + BATCH_SIZE);
setAlbums(prev => [...prev, ...newAlbums]);
setCurrentOffset(currentOffset + newAlbums.length);
setHasMore(allAlbums.length > currentOffset + newAlbums.length);
setIsLoading(false);
return;
}
} catch (offlineError) {
console.error('Error loading more albums from offline storage:', offlineError);
// Continue to online API as fallback
}
}
// Fall back to online API
if (api) {
newAlbums = await api.getAlbums(sortBy, BATCH_SIZE, currentOffset);
setAlbums(prev => [...prev, ...newAlbums]);
setCurrentOffset(currentOffset + newAlbums.length);
// If we get fewer albums than we asked for, we've reached the end
setHasMore(newAlbums.length >= BATCH_SIZE);
} else {
setHasMore(false);
}
} catch (err) {
console.error('Failed to load more albums:', err);
setError(err instanceof Error ? err.message : 'Unknown error loading more albums');
setHasMore(false);
} finally {
setIsLoading(false);
}
}, [api, offlineApi, offlineLibrary, albums, currentOffset, isLoading, hasMore, sortBy]);
// Manual refresh (useful for pull-to-refresh functionality)
const refreshAlbums = useCallback(() => {
setAlbums([]);
setCurrentOffset(0);
setHasMore(true);
loadInitialBatch();
}, [loadInitialBatch]);
// Setup scroll listener after function declarations
useEffect(() => {
const handleScroll = () => {
// Don't trigger if already loading
if (isLoading || !hasMore) return;
// Check if we're near the bottom
const scrollHeight = document.documentElement.scrollHeight;
const currentScroll = window.innerHeight + document.documentElement.scrollTop;
if (scrollHeight - currentScroll <= SCROLL_THRESHOLD) {
loadMoreAlbums();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [isLoading, hasMore, currentOffset, loadMoreAlbums]);
return {
albums,
isLoading,
hasMore,
loadMoreAlbums,
refreshAlbums,
error,
resetAndLoad: refreshAlbums // Alias for consistency
};
}

169
lib/audio-effects.ts Normal file
View File

@@ -0,0 +1,169 @@
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
export interface AudioEffectPreset {
name: string;
gains: number[]; // Gains for different frequency bands
frequencies: number[]; // Center frequencies for each band
}
export const presets: { [key: string]: AudioEffectPreset } = {
normal: {
name: "Normal",
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
bassBoost: {
name: "Bass Boost",
gains: [7, 5, 3, 2, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
trebleBoost: {
name: "Treble Boost",
gains: [0, 0, 0, 0, 0, 0, 2, 3, 5, 7],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
vocalBoost: {
name: "Vocal Boost",
gains: [0, 0, 0, 2, 4, 4, 2, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
}
};
export class AudioEffects {
private context: AudioContext;
private source: MediaElementAudioSourceNode | null = null;
private destination: AudioDestinationNode;
private filters: BiquadFilterNode[] = [];
private gainNode: GainNode;
private crossfadeGainNode: GainNode;
private analyser: AnalyserNode;
private replayGainNode: GainNode;
private currentPreset: string = 'normal';
constructor(audioElement: HTMLAudioElement) {
// Properly type the AudioContext initialization
this.context = new (window.AudioContext || window.webkitAudioContext || AudioContext)();
this.destination = this.context.destination;
this.gainNode = this.context.createGain();
this.crossfadeGainNode = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.replayGainNode = this.context.createGain();
// Initialize ReplayGain node
this.replayGainNode.gain.value = 1.0;
// Create the audio processing chain
this.setupAudioChain(audioElement);
// Initialize EQ filters
this.setupEqualizer();
}
private setupAudioChain(audioElement: HTMLAudioElement) {
// Disconnect any existing source
if (this.source) {
this.source.disconnect();
}
// Create new source from audio element
this.source = this.context.createMediaElementSource(audioElement);
// Connect the audio processing chain
this.source
.connect(this.replayGainNode)
.connect(this.gainNode)
.connect(this.crossfadeGainNode);
// Connect filters in series
let lastNode: AudioNode = this.crossfadeGainNode;
this.filters.forEach(filter => {
lastNode.connect(filter);
lastNode = filter;
});
// Connect to analyser and destination
lastNode.connect(this.analyser);
this.analyser.connect(this.destination);
}
private setupEqualizer() {
// Create 10-band EQ
presets.normal.frequencies.forEach((freq, index) => {
const filter = this.context.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 1.0;
filter.gain.value = 0;
this.filters.push(filter);
});
}
public setPreset(presetName: string) {
if (presets[presetName]) {
this.currentPreset = presetName;
presets[presetName].gains.forEach((gain, index) => {
if (this.filters[index]) {
this.filters[index].gain.setValueAtTime(gain, this.context.currentTime);
}
});
}
}
public getCurrentPreset(): string {
return this.currentPreset;
}
public setVolume(volume: number) {
if (this.gainNode) {
this.gainNode.gain.setValueAtTime(volume, this.context.currentTime);
}
}
public setCrossfadeTime(seconds: number) {
if (this.crossfadeGainNode) {
const now = this.context.currentTime;
this.crossfadeGainNode.gain.setValueAtTime(1, now);
this.crossfadeGainNode.gain.linearRampToValueAtTime(0, now + seconds);
}
}
public startCrossfade() {
if (this.crossfadeGainNode) {
this.crossfadeGainNode.gain.value = 1;
}
}
public setReplayGain(gain: number) {
if (this.replayGainNode) {
// Clamp gain between -12dB and +12dB for safety
const clampedGain = Math.max(-12, Math.min(12, gain));
const gainValue = Math.pow(10, clampedGain / 20); // Convert dB to linear gain
this.replayGainNode.gain.setValueAtTime(gainValue, this.context.currentTime);
}
}
public getAnalyserNode(): AnalyserNode {
return this.analyser;
}
public async resume() {
if (this.context.state === 'suspended') {
await this.context.resume();
}
}
public disconnect() {
if (this.source) {
this.source.disconnect();
}
this.filters.forEach(filter => filter.disconnect());
this.gainNode.disconnect();
this.crossfadeGainNode.disconnect();
this.analyser.disconnect();
this.replayGainNode.disconnect();
}
}

View File

@@ -14,7 +14,7 @@ export function getGravatarUrl(
): string {
// Normalize email: trim whitespace and convert to lowercase
const normalizedEmail = email.trim().toLowerCase();
// i love md5 hash (no i dont)
// Generate MD5 hash of the email
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex');

View File

@@ -123,3 +123,85 @@ export function useOptimalImageSize(
const divisions = [60, 120, 240, 400, 600, 1200];
return divisions.find(size => size >= optimalSize) || 1200;
}
/**
* Extract dominant color from an image
* @param imageUrl - URL of the image to analyze
* @returns Promise that resolves to CSS color string (rgb format)
*/
export async function extractDominantColor(imageUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve('rgb(25, 25, 25)'); // Fallback dark color
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Simple dominant color extraction
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let r = 0, g = 0, b = 0;
// Sample points across the image (for performance, not using all pixels)
const sampleSize = Math.max(1, Math.floor(data.length / 4000));
let sampleCount = 0;
for (let i = 0; i < data.length; i += 4 * sampleSize) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
sampleCount++;
}
r = Math.floor(r / sampleCount);
g = Math.floor(g / sampleCount);
b = Math.floor(b / sampleCount);
// Adjust brightness to ensure readability
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
// For very light colors, darken them
if (brightness > 200) {
const darkFactor = 0.7;
r = Math.floor(r * darkFactor);
g = Math.floor(g * darkFactor);
b = Math.floor(b * darkFactor);
}
// For very dark colors, lighten them slightly
if (brightness < 50) {
const lightFactor = 1.3;
r = Math.min(255, Math.floor(r * lightFactor));
g = Math.min(255, Math.floor(g * lightFactor));
b = Math.min(255, Math.floor(b * lightFactor));
}
resolve(`rgb(${r}, ${g}, ${b})`);
} catch (error) {
console.error('Error extracting color:', error);
resolve('rgb(25, 25, 25)'); // Fallback dark color
}
};
img.onerror = () => {
resolve('rgb(25, 25, 25)'); // Fallback dark color
};
img.src = imageUrl;
} catch (error) {
console.error('Error loading image for color extraction:', error);
resolve('rgb(25, 25, 25)'); // Fallback dark color
}
});
}

655
lib/indexeddb.ts Normal file
View File

@@ -0,0 +1,655 @@
'use client';
export interface LibraryItem {
id: string;
lastModified: number;
synced: boolean;
}
export interface OfflineAlbum extends LibraryItem {
name: string;
artist: string;
artistId: string;
coverArt?: string;
songCount: number;
duration: number;
playCount?: number;
created: string;
starred?: string;
year?: number;
genre?: string;
}
export interface OfflineArtist extends LibraryItem {
name: string;
albumCount: number;
starred?: string;
coverArt?: string;
}
export interface OfflineSong extends LibraryItem {
parent: string;
isDir: boolean;
title: string;
album: string;
artist: string;
track?: number;
year?: number;
genre?: string;
coverArt?: string;
size: number;
contentType: string;
suffix: string;
duration: number;
bitRate?: number;
path: string;
playCount?: number;
discNumber?: number;
created: string;
albumId: string;
artistId: string;
type: string;
starred?: string;
}
export interface OfflinePlaylist extends LibraryItem {
name: string;
comment?: string;
owner: string;
public: boolean;
songCount: number;
duration: number;
created: string;
changed: string;
coverArt?: string;
songIds: string[];
}
export interface SyncMetadata<T = unknown> {
key: string;
value: T;
lastUpdated: number;
}
// Shape for queued operations' data payloads
export type SyncOperationData =
| { star: true } // star
| { star: false } // unstar
| { name: string; songIds?: string[] } // create_playlist
| { name?: string; comment?: string; songIds?: string[] } // update_playlist
| Record<string, never>; // delete_playlist, scrobble, or empty
export interface SyncOperation {
id: string;
type: 'star' | 'unstar' | 'create_playlist' | 'update_playlist' | 'delete_playlist' | 'scrobble';
entityType: 'song' | 'album' | 'artist' | 'playlist';
entityId: string;
data: SyncOperationData;
timestamp: number;
retryCount: number;
}
export interface LibrarySyncStats {
albums: number;
artists: number;
songs: number;
playlists: number;
lastSync: Date | null;
pendingOperations: number;
storageSize: number;
syncInProgress: boolean;
}
class OfflineLibraryDB {
private dbName = 'stillnavidrome-offline';
private dbVersion = 2;
private db: IDBDatabase | null = null;
private isInitialized = false;
async initialize(): Promise<boolean> {
if (this.isInitialized && this.db) {
return true;
}
if (!('indexedDB' in window)) {
console.warn('IndexedDB not supported');
return false;
}
try {
this.db = await this.openDatabase();
this.isInitialized = true;
return true;
} catch (error) {
console.error('Failed to initialize offline library:', error);
return false;
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Albums store
if (!db.objectStoreNames.contains('albums')) {
const albumsStore = db.createObjectStore('albums', { keyPath: 'id' });
albumsStore.createIndex('artist', 'artist', { unique: false });
albumsStore.createIndex('artistId', 'artistId', { unique: false });
albumsStore.createIndex('starred', 'starred', { unique: false });
albumsStore.createIndex('synced', 'synced', { unique: false });
albumsStore.createIndex('lastModified', 'lastModified', { unique: false });
}
// Artists store
if (!db.objectStoreNames.contains('artists')) {
const artistsStore = db.createObjectStore('artists', { keyPath: 'id' });
artistsStore.createIndex('name', 'name', { unique: false });
artistsStore.createIndex('starred', 'starred', { unique: false });
artistsStore.createIndex('synced', 'synced', { unique: false });
artistsStore.createIndex('lastModified', 'lastModified', { unique: false });
}
// Songs store
if (!db.objectStoreNames.contains('songs')) {
const songsStore = db.createObjectStore('songs', { keyPath: 'id' });
songsStore.createIndex('albumId', 'albumId', { unique: false });
songsStore.createIndex('artistId', 'artistId', { unique: false });
songsStore.createIndex('starred', 'starred', { unique: false });
songsStore.createIndex('synced', 'synced', { unique: false });
songsStore.createIndex('lastModified', 'lastModified', { unique: false });
songsStore.createIndex('title', 'title', { unique: false });
}
// Playlists store
if (!db.objectStoreNames.contains('playlists')) {
const playlistsStore = db.createObjectStore('playlists', { keyPath: 'id' });
playlistsStore.createIndex('name', 'name', { unique: false });
playlistsStore.createIndex('owner', 'owner', { unique: false });
playlistsStore.createIndex('synced', 'synced', { unique: false });
playlistsStore.createIndex('lastModified', 'lastModified', { unique: false });
}
// Sync operations queue
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id' });
syncStore.createIndex('timestamp', 'timestamp', { unique: false });
syncStore.createIndex('type', 'type', { unique: false });
syncStore.createIndex('entityType', 'entityType', { unique: false });
}
// Metadata store for sync info and settings
if (!db.objectStoreNames.contains('metadata')) {
const metadataStore = db.createObjectStore('metadata', { keyPath: 'key' });
}
};
});
}
// Metadata operations
async setMetadata<T>(key: string, value: T): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['metadata'], 'readwrite');
const store = transaction.objectStore('metadata');
const metadata: SyncMetadata<T> = {
key,
value,
lastUpdated: Date.now()
};
return new Promise((resolve, reject) => {
const request = store.put(metadata);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getMetadata<T = unknown>(key: string): Promise<T | null> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['metadata'], 'readonly');
const store = transaction.objectStore('metadata');
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
const result = request.result as SyncMetadata<T> | undefined;
resolve(result ? (result.value as T) : null);
};
request.onerror = () => reject(request.error);
});
}
// Album operations
async storeAlbums(albums: OfflineAlbum[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums'], 'readwrite');
const store = transaction.objectStore('albums');
return new Promise((resolve, reject) => {
let completed = 0;
const total = albums.length;
if (total === 0) {
resolve();
return;
}
albums.forEach(album => {
const albumWithMeta = {
...album,
lastModified: Date.now(),
synced: true
};
const request = store.put(albumWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getAlbums(starred?: boolean): Promise<OfflineAlbum[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
return new Promise((resolve, reject) => {
const request = starred
? store.index('starred').getAll(IDBKeyRange.only('starred'))
: store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getAlbum(id: string): Promise<OfflineAlbum | null> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
// Artist operations
async storeArtists(artists: OfflineArtist[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['artists'], 'readwrite');
const store = transaction.objectStore('artists');
return new Promise((resolve, reject) => {
let completed = 0;
const total = artists.length;
if (total === 0) {
resolve();
return;
}
artists.forEach(artist => {
const artistWithMeta = {
...artist,
lastModified: Date.now(),
synced: true
};
const request = store.put(artistWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getArtists(starred?: boolean): Promise<OfflineArtist[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['artists'], 'readonly');
const store = transaction.objectStore('artists');
return new Promise((resolve, reject) => {
const request = starred
? store.index('starred').getAll(IDBKeyRange.only('starred'))
: store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Song operations
async storeSongs(songs: OfflineSong[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['songs'], 'readwrite');
const store = transaction.objectStore('songs');
return new Promise((resolve, reject) => {
let completed = 0;
const total = songs.length;
if (total === 0) {
resolve();
return;
}
songs.forEach(song => {
const songWithMeta = {
...song,
lastModified: Date.now(),
synced: true
};
const request = store.put(songWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getSongs(albumId?: string, starred?: boolean): Promise<OfflineSong[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['songs'], 'readonly');
const store = transaction.objectStore('songs');
return new Promise((resolve, reject) => {
let request: IDBRequest<OfflineSong[]>;
if (albumId) {
request = store.index('albumId').getAll(IDBKeyRange.only(albumId));
} else if (starred) {
request = store.index('starred').getAll(IDBKeyRange.only('starred'));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result as OfflineSong[]);
request.onerror = () => reject(request.error);
});
}
// Playlist operations
async storePlaylists(playlists: OfflinePlaylist[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
return new Promise((resolve, reject) => {
let completed = 0;
const total = playlists.length;
if (total === 0) {
resolve();
return;
}
playlists.forEach(playlist => {
const playlistWithMeta = {
...playlist,
lastModified: Date.now(),
synced: true
};
const request = store.put(playlistWithMeta);
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
async getPlaylists(): Promise<OfflinePlaylist[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['playlists'], 'readonly');
const store = transaction.objectStore('playlists');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Sync operations
async addSyncOperation(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
const syncOp: SyncOperation = {
...operation,
id: crypto.randomUUID(),
timestamp: Date.now(),
retryCount: 0
};
return new Promise((resolve, reject) => {
const request = store.add(syncOp);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getSyncOperations(): Promise<SyncOperation[]> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['syncQueue'], 'readonly');
const store = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async removeSyncOperation(id: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Statistics and management
async getStats(): Promise<LibrarySyncStats> {
if (!this.db) throw new Error('Database not initialized');
const [albums, artists, songs, playlists, syncOps, lastSyncNum] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists(),
this.getSyncOperations(),
this.getMetadata<number>('lastSync')
]);
// Estimate storage size
const storageSize = await this.estimateStorageSize();
return {
albums: albums.length,
artists: artists.length,
songs: songs.length,
playlists: playlists.length,
lastSync: typeof lastSyncNum === 'number' ? new Date(lastSyncNum) : null,
pendingOperations: syncOps.length,
storageSize,
syncInProgress: (await this.getMetadata<boolean>('syncInProgress')) ?? false
};
}
async estimateStorageSize(): Promise<number> {
if (!this.db) return 0;
try {
const estimate = await navigator.storage.estimate();
return estimate.usage || 0;
} catch {
// Fallback estimation if storage API not available
const [albums, artists, songs, playlists] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists()
]);
// Rough estimation: average 2KB per item
return (albums.length + artists.length + songs.length + playlists.length) * 2048;
}
}
async clearAllData(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['albums', 'artists', 'songs', 'playlists', 'syncQueue', 'metadata'], 'readwrite');
const stores = [
transaction.objectStore('albums'),
transaction.objectStore('artists'),
transaction.objectStore('songs'),
transaction.objectStore('playlists'),
transaction.objectStore('syncQueue'),
transaction.objectStore('metadata')
];
return new Promise((resolve, reject) => {
let completed = 0;
const total = stores.length;
stores.forEach(store => {
const request = store.clear();
request.onsuccess = () => {
completed++;
if (completed === total) resolve();
};
request.onerror = () => reject(request.error);
});
});
}
// Star/unstar operations (offline-first)
async starItem(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const storeName = `${type}s`;
const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite');
const store = transaction.objectStore(storeName);
const syncStore = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
// Update the item locally first
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
item.starred = 'starred';
item.lastModified = Date.now();
item.synced = false;
const putRequest = store.put(item);
putRequest.onsuccess = () => {
// Add to sync queue
const syncOp: SyncOperation = {
id: crypto.randomUUID(),
type: 'star',
entityType: type,
entityId: id,
data: { star: true },
timestamp: Date.now(),
retryCount: 0
};
const syncRequest = syncStore.add(syncOp);
syncRequest.onsuccess = () => resolve();
syncRequest.onerror = () => reject(syncRequest.error);
};
putRequest.onerror = () => reject(putRequest.error);
} else {
reject(new Error(`${type} not found`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
async unstarItem(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const storeName = `${type}s`;
const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite');
const store = transaction.objectStore(storeName);
const syncStore = transaction.objectStore('syncQueue');
return new Promise((resolve, reject) => {
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
delete item.starred;
item.lastModified = Date.now();
item.synced = false;
const putRequest = store.put(item);
putRequest.onsuccess = () => {
const syncOp: SyncOperation = {
id: crypto.randomUUID(),
type: 'unstar',
entityType: type,
entityId: id,
data: { star: false },
timestamp: Date.now(),
retryCount: 0
};
const syncRequest = syncStore.add(syncOp);
syncRequest.onsuccess = () => resolve();
syncRequest.onerror = () => reject(syncRequest.error);
};
putRequest.onerror = () => reject(putRequest.error);
} else {
reject(new Error(`${type} not found`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
}
// Singleton instance
export const offlineLibraryDB = new OfflineLibraryDB();

347
lib/musicbrainz-api.ts Normal file
View File

@@ -0,0 +1,347 @@
/**
* MusicBrainz API client for the auto-tagging feature
*
* This module provides functions to search and fetch metadata from MusicBrainz,
* which is an open music encyclopedia that collects music metadata.
*/
// Define the User-Agent string as per MusicBrainz API guidelines
// https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#User-Agent
const USER_AGENT = 'mice/1.0.0 (https://github.com/sillyangel/mice)';
// Base URL for MusicBrainz API
const API_BASE_URL = 'https://musicbrainz.org/ws/2';
// Add a delay between requests to comply with MusicBrainz rate limiting
const RATE_LIMIT_DELAY = 1100; // Slightly more than 1 second to be safe
// Queue for API requests to ensure proper rate limiting
const requestQueue: (() => Promise<unknown>)[] = [];
let isProcessingQueue = false;
/**
* Process the request queue with proper rate limiting
*/
async function processQueue() {
if (isProcessingQueue || requestQueue.length === 0) return;
isProcessingQueue = true;
while (requestQueue.length > 0) {
const request = requestQueue.shift();
if (request) {
try {
await request();
} catch (error) {
console.error('MusicBrainz API request failed:', error);
}
// Wait before processing the next request
await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_DELAY));
}
}
isProcessingQueue = false;
}
/**
* Make a rate-limited request to the MusicBrainz API
*/
async function makeRequest<T>(endpoint: string, params: Record<string, string> = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
const requestFn = async () => {
try {
const url = new URL(`${API_BASE_URL}${endpoint}`);
// Add format parameter
url.searchParams.append('fmt', 'json');
// Add other parameters
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url.toString(), {
headers: {
'User-Agent': USER_AGENT
}
});
if (!response.ok) {
throw new Error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
resolve(data as T);
} catch (error) {
reject(error);
}
};
// Add request to queue
requestQueue.push(requestFn);
processQueue();
});
}
/**
* Search for releases (albums) in MusicBrainz
*/
export async function searchReleases(query: string, limit: number = 10): Promise<MusicBrainzRelease[]> {
try {
interface ReleaseSearchResult {
releases: MusicBrainzRelease[];
}
const data = await makeRequest<ReleaseSearchResult>('/release', {
query,
limit: limit.toString()
});
return data.releases || [];
} catch (error) {
console.error('Failed to search releases:', error);
return [];
}
}
/**
* Search for recordings (tracks) in MusicBrainz
*/
export async function searchRecordings(query: string, limit: number = 10): Promise<MusicBrainzRecording[]> {
try {
interface RecordingSearchResult {
recordings: MusicBrainzRecording[];
}
const data = await makeRequest<RecordingSearchResult>('/recording', {
query,
limit: limit.toString()
});
return data.recordings || [];
} catch (error) {
console.error('Failed to search recordings:', error);
return [];
}
}
/**
* Get detailed information about a release by its MBID
*/
export async function getRelease(mbid: string): Promise<MusicBrainzReleaseDetails | null> {
try {
// Request with recording-level relationships to get track-level data
const data = await makeRequest<MusicBrainzReleaseDetails>(`/release/${mbid}`, {
inc: 'recordings+artists+labels+artist-credits'
});
return data;
} catch (error) {
console.error(`Failed to get release ${mbid}:`, error);
return null;
}
}
/**
* Get detailed information about a recording by its MBID
*/
export async function getRecording(mbid: string): Promise<MusicBrainzRecordingDetails | null> {
try {
const data = await makeRequest<MusicBrainzRecordingDetails>(`/recording/${mbid}`, {
inc: 'artists+releases+artist-credits'
});
return data;
} catch (error) {
console.error(`Failed to get recording ${mbid}:`, error);
return null;
}
}
/**
* Find the best matching release for the given album information
* This uses fuzzy matching to find the most likely match
*/
export async function findBestMatchingRelease(
albumName: string,
artistName: string,
trackCount?: number
): Promise<MusicBrainzRelease | null> {
try {
// Build a search query with both album and artist
const query = `release:"${albumName}" AND artist:"${artistName}"`;
const releases = await searchReleases(query, 5);
if (!releases || releases.length === 0) {
return null;
}
// If track count is provided, prioritize releases with the same track count
if (trackCount !== undefined) {
const exactTrackCountMatch = releases.find(release =>
release['track-count'] === trackCount
);
if (exactTrackCountMatch) {
return exactTrackCountMatch;
}
}
// Just return the first result as it's likely the best match
return releases[0];
} catch (error) {
console.error('Failed to find matching release:', error);
return null;
}
}
/**
* Find the best matching recording for the given track information
*/
export async function findBestMatchingRecording(
trackName: string,
artistName: string,
duration?: number // in milliseconds
): Promise<MusicBrainzRecording | null> {
try {
// Build a search query with both track and artist
const query = `recording:"${trackName}" AND artist:"${artistName}"`;
const recordings = await searchRecordings(query, 5);
if (!recordings || recordings.length === 0) {
return null;
}
// If duration is provided, try to find a close match
if (duration !== undefined) {
// Convert to milliseconds if not already (MusicBrainz uses milliseconds)
const durationMs = duration < 1000 ? duration * 1000 : duration;
// Find recording with the closest duration (within 5 seconds)
const durationMatches = recordings.filter(recording => {
if (!recording.length) return false;
return Math.abs(recording.length - durationMs) < 5000; // 5 second tolerance
});
if (durationMatches.length > 0) {
return durationMatches[0];
}
}
// Just return the first result as it's likely the best match
return recordings[0];
} catch (error) {
console.error('Failed to find matching recording:', error);
return null;
}
}
// Type definitions for MusicBrainz API responses
export interface MusicBrainzRelease {
id: string; // MBID
title: string;
'artist-credit': Array<{
artist: {
id: string;
name: string;
};
name: string;
}>;
date?: string;
country?: string;
'track-count': number;
status?: string;
disambiguation?: string;
}
export interface MusicBrainzReleaseDetails extends MusicBrainzRelease {
media: Array<{
position: number;
format?: string;
tracks: Array<{
position: number;
number: string;
title: string;
length?: number;
recording: {
id: string;
title: string;
length?: number;
};
}>;
}>;
'cover-art-archive'?: {
artwork: boolean;
count: number;
front: boolean;
back: boolean;
};
barcode?: string;
'release-group'?: {
id: string;
'primary-type'?: string;
};
}
export interface MusicBrainzRecording {
id: string; // MBID
title: string;
length?: number; // in milliseconds
'artist-credit': Array<{
artist: {
id: string;
name: string;
};
name: string;
}>;
releases?: Array<{
id: string;
title: string;
}>;
isrcs?: string[];
}
export interface MusicBrainzRecordingDetails extends MusicBrainzRecording {
disambiguation?: string;
'first-release-date'?: string;
genres?: Array<{
id: string;
name: string;
}>;
tags?: Array<{
count: number;
name: string;
}>;
}
// Cover art functions
// MusicBrainz has a separate API for cover art: Cover Art Archive
export function getCoverArtUrl(releaseId: string, size: 'small' | 'large' | '500' | 'full' = 'large'): string {
return `https://coverartarchive.org/release/${releaseId}/front-${size}`;
}
// Utility function to normalize strings for comparison
export function normalizeString(input: string): string {
return input
.toLowerCase()
.replace(/[^\w\s]/g, '') // Remove special characters
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
.trim();
}
// Export the MusicBrainz client as a singleton
const MusicBrainzClient = {
searchReleases,
searchRecordings,
getRelease,
getRecording,
findBestMatchingRelease,
findBestMatchingRecording,
getCoverArtUrl,
normalizeString
};
export default MusicBrainzClient;

View File

@@ -68,6 +68,7 @@ export interface Song {
artistId: string;
type: string;
starred?: string;
replayGain?: number;
}
export interface Playlist {
@@ -214,12 +215,21 @@ class NavidromeAPI {
}
async getArtist(artistId: string): Promise<{ artist: Artist; albums: Album[] }> {
try {
const response = await this.makeRequest('getArtist', { id: artistId });
// Check if artist data exists
if (!response.artist) {
throw new Error('Artist not found in response');
}
const artistData = response.artist as Artist & { album?: Album[] };
return {
artist: artistData,
albums: artistData.album || []
};
} catch (error) {
console.error('Navidrome API request failed:', error);
throw new Error('Artist not found');
}
}
async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise<Album[]> {
@@ -330,6 +340,23 @@ class NavidromeAPI {
return `${this.config.serverUrl}/rest/stream?${params.toString()}`;
}
// Direct download URL (original file). Useful for offline caching where the browser can handle transcoding.
getDownloadUrl(songId: string): string {
const salt = this.generateSalt();
const token = this.generateToken(this.config.password, salt);
const params = new URLSearchParams({
u: this.config.username,
t: token,
s: salt,
v: this.version,
c: this.clientName,
id: songId
});
return `${this.config.serverUrl}/rest/download?${params.toString()}`;
}
getCoverArtUrl(coverArtId: string, size?: number): string {
const salt = this.generateSalt();
const token = this.generateToken(this.config.password, salt);

782
lib/offline-library.ts Normal file
View File

@@ -0,0 +1,782 @@
'use client';
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
export interface NavidromeAPIInterface {
ping(): Promise<boolean>;
getAlbums(type?: string, size?: number): Promise<Album[]>;
getArtists(): Promise<Artist[]>;
getPlaylists(): Promise<Playlist[]>;
getAlbum(id: string): Promise<{ album: Album; songs: Song[] }>;
star(id: string, type: string): Promise<void>;
unstar(id: string, type: string): Promise<void>;
createPlaylist(name: string, songIds?: string[]): Promise<Playlist>;
updatePlaylist(id: string, name?: string, comment?: string, songIds?: string[]): Promise<void>;
deletePlaylist(id: string): Promise<void>;
scrobble(songId: string): Promise<void>;
}
export interface OfflineDatabase {
albums: Album[];
artists: Artist[];
songs: Song[];
playlists: Playlist[];
favorites: {
albums: string[];
artists: string[];
songs: string[];
};
syncQueue: SyncOperation[];
lastSync: number;
}
export interface SyncOperationData {
// For star/unstar operations
star?: boolean;
// For playlist operations
name?: string;
comment?: string;
songIds?: string[];
// For scrobble operations
timestamp?: number;
}
export interface SyncOperation {
id: string;
type: 'star' | 'unstar' | 'create_playlist' | 'update_playlist' | 'delete_playlist' | 'scrobble';
data: SyncOperationData;
timestamp: number;
retryCount: number;
entityType: 'song' | 'album' | 'artist' | 'playlist';
entityId: string;
}
export interface OfflineLibraryStats {
albums: number;
artists: number;
songs: number;
playlists: number;
lastSync: Date | null;
pendingOperations: number;
storageSize: number;
}
class OfflineLibraryManager {
private dbName = 'mice-offline-library';
private dbVersion = 1;
private db: IDBDatabase | null = null;
async initialize(): Promise<boolean> {
if (!('indexedDB' in window)) {
console.warn('IndexedDB not supported');
return false;
}
try {
this.db = await this.openDatabase();
return true;
} catch (error) {
console.error('Failed to initialize offline library:', error);
return false;
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores
if (!db.objectStoreNames.contains('albums')) {
const albumsStore = db.createObjectStore('albums', { keyPath: 'id' });
albumsStore.createIndex('artist', 'artist', { unique: false });
albumsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('artists')) {
const artistsStore = db.createObjectStore('artists', { keyPath: 'id' });
artistsStore.createIndex('name', 'name', { unique: false });
artistsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('songs')) {
const songsStore = db.createObjectStore('songs', { keyPath: 'id' });
songsStore.createIndex('albumId', 'albumId', { unique: false });
songsStore.createIndex('artistId', 'artistId', { unique: false });
songsStore.createIndex('starred', 'starred', { unique: false });
}
if (!db.objectStoreNames.contains('playlists')) {
const playlistsStore = db.createObjectStore('playlists', { keyPath: 'id' });
playlistsStore.createIndex('name', 'name', { unique: false });
}
if (!db.objectStoreNames.contains('playlistSongs')) {
const playlistSongsStore = db.createObjectStore('playlistSongs', { keyPath: ['playlistId', 'songId'] });
playlistSongsStore.createIndex('playlistId', 'playlistId', { unique: false });
}
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id' });
syncStore.createIndex('timestamp', 'timestamp', { unique: false });
syncStore.createIndex('type', 'type', { unique: false });
}
if (!db.objectStoreNames.contains('metadata')) {
const metadataStore = db.createObjectStore('metadata', { keyPath: 'key' });
}
};
});
}
// Library sync methods
async syncFromServer(navidromeAPI: NavidromeAPIInterface): Promise<void> {
if (!this.db || !navidromeAPI) return;
try {
console.log('Starting full library sync...');
// Test connection
const isConnected = await navidromeAPI.ping();
if (!isConnected) {
throw new Error('No connection to Navidrome server');
}
// Sync albums
const albums = await navidromeAPI.getAlbums('alphabeticalByName', 5000);
await this.storeAlbums(albums);
// Sync artists
const artists = await navidromeAPI.getArtists();
await this.storeArtists(artists);
// Sync playlists
const playlists = await navidromeAPI.getPlaylists();
await this.storePlaylists(playlists);
// Sync songs for recently added albums (to avoid overwhelming the db)
const recentAlbums = albums.slice(0, 100);
for (const album of recentAlbums) {
try {
const { songs } = await navidromeAPI.getAlbum(album.id);
await this.storeSongs(songs);
} catch (error) {
console.warn(`Failed to sync songs for album ${album.id}:`, error);
}
}
// Update last sync timestamp
await this.setMetadata('lastSync', Date.now());
console.log('Library sync completed successfully');
} catch (error) {
console.error('Failed to sync library:', error);
throw error;
}
}
async syncPendingOperations(navidromeAPI: NavidromeAPIInterface): Promise<void> {
if (!this.db || !navidromeAPI) return;
const operations = await this.getAllSyncOperations();
for (const operation of operations) {
try {
await this.executeOperation(operation, navidromeAPI);
await this.removeSyncOperation(operation.id);
} catch (error) {
console.error(`Failed to sync operation ${operation.id}:`, error);
// Increment retry count
operation.retryCount++;
if (operation.retryCount < 3) {
await this.updateSyncOperation(operation);
} else {
// Remove after 3 failed attempts
await this.removeSyncOperation(operation.id);
}
}
}
}
private async executeOperation(operation: SyncOperation, api: NavidromeAPIInterface): Promise<void> {
switch (operation.type) {
case 'star':
await api.star(operation.entityId, operation.entityType);
break;
case 'unstar':
await api.unstar(operation.entityId, operation.entityType);
break;
case 'create_playlist':
if (operation.data.name) {
await api.createPlaylist(operation.data.name, operation.data.songIds);
}
break;
case 'update_playlist':
await api.updatePlaylist(operation.entityId, operation.data.name, operation.data.comment, operation.data.songIds);
break;
case 'delete_playlist':
await api.deletePlaylist(operation.entityId);
break;
case 'scrobble':
await api.scrobble(operation.entityId);
break;
}
}
// Data storage methods
async storeAlbums(albums: Album[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['albums'], 'readwrite');
const store = transaction.objectStore('albums');
for (const album of albums) {
store.put(album);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storeArtists(artists: Artist[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['artists'], 'readwrite');
const store = transaction.objectStore('artists');
for (const artist of artists) {
store.put(artist);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storeSongs(songs: Song[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['songs'], 'readwrite');
const store = transaction.objectStore('songs');
for (const song of songs) {
store.put(song);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async storePlaylists(playlists: Playlist[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
for (const playlist of playlists) {
store.put(playlist);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Data retrieval methods
async getAlbums(starred?: boolean): Promise<Album[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
let request: IDBRequest;
if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getArtists(starred?: boolean): Promise<Artist[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['artists'], 'readonly');
const store = transaction.objectStore('artists');
let request: IDBRequest;
if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getSongs(albumId?: string, artistId?: string, starred?: boolean): Promise<Song[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['songs'], 'readonly');
const store = transaction.objectStore('songs');
let request: IDBRequest;
if (albumId) {
const index = store.index('albumId');
request = index.getAll(albumId);
} else if (artistId) {
const index = store.index('artistId');
request = index.getAll(artistId);
} else if (starred !== undefined) {
const index = store.index('starred');
request = index.getAll(starred ? IDBKeyRange.only('starred') : IDBKeyRange.only(undefined));
} else {
request = store.getAll();
}
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getPlaylists(): Promise<Playlist[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['playlists'], 'readonly');
const store = transaction.objectStore('playlists');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getAlbum(id: string): Promise<{ album: Album; songs: Song[] } | null> {
if (!this.db) return null;
try {
const [album, songs] = await Promise.all([
this.getAlbumById(id),
this.getSongs(id)
]);
if (!album) return null;
return { album, songs };
} catch (error) {
console.error('Failed to get album:', error);
return null;
}
}
private async getAlbumById(id: string): Promise<Album | null> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['albums'], 'readonly');
const store = transaction.objectStore('albums');
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
// Sync queue methods
async addSyncOperation(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
if (!this.db) return;
const fullOperation: SyncOperation = {
...operation,
id: `${operation.type}_${operation.entityId}_${Date.now()}`,
timestamp: Date.now(),
retryCount: 0
};
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.put(fullOperation);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private async getAllSyncOperations(): Promise<SyncOperation[]> {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['syncQueue'], 'readonly');
const store = transaction.objectStore('syncQueue');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
private async updateSyncOperation(operation: SyncOperation): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.put(operation);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private async removeSyncOperation(id: string): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
store.delete(id);
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Metadata methods
async setMetadata(key: string, value: unknown): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['metadata'], 'readwrite');
const store = transaction.objectStore('metadata');
store.put({ key, value });
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getMetadata(key: string): Promise<unknown> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['metadata'], 'readonly');
const store = transaction.objectStore('metadata');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(request.error);
});
}
// Search methods
async searchOffline(query: string): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }> {
if (!this.db) return { artists: [], albums: [], songs: [] };
try {
const [allAlbums, allArtists, allSongs] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs()
]);
const searchLower = query.toLowerCase();
const albums = allAlbums.filter(album =>
album.name.toLowerCase().includes(searchLower) ||
album.artist.toLowerCase().includes(searchLower)
);
const artists = allArtists.filter(artist =>
artist.name.toLowerCase().includes(searchLower)
);
const songs = allSongs.filter(song =>
song.title.toLowerCase().includes(searchLower) ||
(song.artist && song.artist.toLowerCase().includes(searchLower)) ||
(song.album && song.album.toLowerCase().includes(searchLower))
);
return { artists, albums, songs };
} catch (error) {
console.error('Offline search failed:', error);
return { artists: [], albums: [], songs: [] };
}
}
// Offline favorites management
async starOffline(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) return;
// Add to sync queue
await this.addSyncOperation({
type: 'star',
entityType: type,
entityId: id,
data: {}
});
// Update local data
const storeName = type === 'song' ? 'songs' : type === 'album' ? 'albums' : 'artists';
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
item.starred = new Date().toISOString();
store.put(item);
}
};
}
async unstarOffline(id: string, type: 'song' | 'album' | 'artist'): Promise<void> {
if (!this.db) return;
// Add to sync queue
await this.addSyncOperation({
type: 'unstar',
entityType: type,
entityId: id,
data: {}
});
// Update local data
const storeName = type === 'song' ? 'songs' : type === 'album' ? 'albums' : 'artists';
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const item = getRequest.result;
if (item) {
delete item.starred;
store.put(item);
}
};
}
// Playlist management
async createPlaylistOffline(name: string, songIds?: string[]): Promise<Playlist> {
if (!this.db) throw new Error('Database not initialized');
const playlistId = `offline_${Date.now()}`;
const playlist: Playlist = {
id: playlistId,
name,
comment: '',
owner: 'offline',
public: false,
songCount: songIds?.length || 0,
duration: 0,
created: new Date().toISOString(),
changed: new Date().toISOString()
};
// Store playlist
const transaction = this.db.transaction(['playlists'], 'readwrite');
const store = transaction.objectStore('playlists');
store.put(playlist);
// Add to sync queue
await this.addSyncOperation({
type: 'create_playlist',
entityType: 'playlist',
entityId: playlistId,
data: { name, songIds }
});
return playlist;
}
// Update playlist
async updatePlaylist(id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists', 'playlistSongs'], 'readwrite');
const playlistStore = transaction.objectStore('playlists');
const playlistSongsStore = transaction.objectStore('playlistSongs');
// Get existing playlist
const playlist = await new Promise<Playlist>((resolve, reject) => {
const request = playlistStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!playlist) {
throw new Error('Playlist not found');
}
// Update playlist metadata
const updatedPlaylist = {
...playlist,
...(name && { name }),
...(comment && { comment }),
updatedAt: new Date().toISOString()
};
playlistStore.put(updatedPlaylist);
// Update song associations if provided
if (songIds) {
// Remove existing songs
const existingSongs = await new Promise<{ playlistId: string; songId: string }[]>((resolve, reject) => {
const index = playlistSongsStore.index('playlistId');
const request = index.getAll(id);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
for (const song of existingSongs) {
playlistSongsStore.delete([song.playlistId, song.songId]);
}
// Add new songs
for (const songId of songIds) {
playlistSongsStore.put({
playlistId: id,
songId: songId
});
}
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Delete playlist
async deletePlaylist(id: string): Promise<void> {
if (!this.db) return;
const transaction = this.db.transaction(['playlists', 'playlistSongs'], 'readwrite');
const playlistStore = transaction.objectStore('playlists');
const playlistSongsStore = transaction.objectStore('playlistSongs');
// Delete playlist
playlistStore.delete(id);
// Delete associated songs
const index = playlistSongsStore.index('playlistId');
const songs = await new Promise<{ playlistId: string; songId: string }[]>((resolve, reject) => {
const request = index.getAll(id);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
for (const song of songs) {
playlistSongsStore.delete([song.playlistId, song.songId]);
}
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Statistics
async getLibraryStats(): Promise<OfflineLibraryStats> {
if (!this.db) {
return {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
};
}
try {
const [albums, artists, songs, playlists, syncOps, lastSync] = await Promise.all([
this.getAlbums(),
this.getArtists(),
this.getSongs(),
this.getPlaylists(),
this.getAllSyncOperations(),
this.getMetadata('lastSync')
]);
// Estimate storage size (rough calculation)
const storageSize = this.estimateStorageSize(albums, artists, songs, playlists);
return {
albums: albums.length,
artists: artists.length,
songs: songs.length,
playlists: playlists.length,
lastSync: lastSync && typeof lastSync === 'number' ? new Date(lastSync) : null,
pendingOperations: syncOps.length,
storageSize
};
} catch (error) {
console.error('Failed to get library stats:', error);
return {
albums: 0,
artists: 0,
songs: 0,
playlists: 0,
lastSync: null,
pendingOperations: 0,
storageSize: 0
};
}
}
private estimateStorageSize(albums: Album[], artists: Artist[], songs: Song[], playlists: Playlist[]): number {
// Rough estimation: each item is approximately 1KB in JSON
return (albums.length + artists.length + songs.length + playlists.length) * 1024;
}
// Clear all data
async clearAllData(): Promise<void> {
if (!this.db) return;
const storeNames = ['albums', 'artists', 'songs', 'playlists', 'playlistSongs', 'syncQueue', 'metadata'];
for (const storeName of storeNames) {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
await new Promise<void>((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
}
export const offlineLibraryManager = new OfflineLibraryManager();

View File

@@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function constrain(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function formatBytes(bytes: number, decimals: number = 2): string {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

View File

@@ -12,6 +12,8 @@ const nextConfig = {
hostname: "**",
}
],
minimumCacheTTL: 60,
// unoptimized: true,
},
async headers() {
return [
@@ -45,7 +47,7 @@ const nextConfig = {
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
value: "default-src 'self' *; connect-src 'self' *; script-src 'self'",
},
],
},
@@ -69,6 +71,7 @@ const nextConfig = {
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;

View File

@@ -50,6 +50,7 @@
"colorthief": "^2.6.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"next": "15.4.4",
@@ -61,6 +62,7 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.60.0",
"react-icons": "^5.3.0",
"react-intersection-observer": "^9.16.0",
"react-resizable-panels": "^3.0.3",
"recharts": "^3.0.2",
"sonner": "^2.0.5",
@@ -75,22 +77,24 @@
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"chalk": "^5.3.0",
"eslint": "^9.31",
"eslint": "^9.32",
"eslint-config-next": "15.4.5",
"postcss": "^8",
"source-map-support": "^0.5.21",
"tailwindcss": "^4.1.11",
"typescript": "^5"
},
"packageManager": "pnpm@10.13.1",
"overrides": {
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6"
"@types/react-dom": "19.1.6",
"typescript": "5.9.2"
},
"pnpm": {
"overrides": {
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6"
"@types/react-dom": "19.1.6",
"typescript": "5.9.2"
},
"onlyBuiltDependencies": [
"sharp",

99
pnpm-lock.yaml generated
View File

@@ -7,6 +7,7 @@ settings:
overrides:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6
typescript: 5.9.2
importers:
@@ -132,6 +133,9 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.1.0)
framer-motion:
specifier: ^11.18.2
version: 11.18.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -165,6 +169,9 @@ importers:
react-icons:
specifier: ^5.3.0
version: 5.4.0(react@19.1.0)
react-intersection-observer:
specifier: ^9.16.0
version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-resizable-panels:
specifier: ^3.0.3
version: 3.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -211,11 +218,14 @@ importers:
postcss:
specifier: ^8
version: 8.5.6
source-map-support:
specifier: ^0.5.21
version: 0.5.21
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
typescript:
specifier: ^5
specifier: 5.9.2
version: 5.9.2
packages:
@@ -1619,20 +1629,20 @@ packages:
peerDependencies:
'@typescript-eslint/parser': ^8.38.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/parser@8.38.0':
resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/project-service@8.38.0':
resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/scope-manager@8.38.0':
resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==}
@@ -1642,14 +1652,14 @@ packages:
resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/type-utils@8.38.0':
resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/types@8.38.0':
resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==}
@@ -1659,14 +1669,14 @@ packages:
resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/utils@8.38.0':
resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
typescript: 5.9.2
'@typescript-eslint/visitor-keys@8.38.0':
resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==}
@@ -1865,6 +1875,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -2127,7 +2140,7 @@ packages:
resolution: {integrity: sha512-IMijiXaZ43qFB+Gcpnb374ipTKD8JIyVNR+6VsifFQ/LHyx+A9wgcgSIhCX5PYSjwOoSYD5LtNHKlM5uc23eww==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
typescript: 5.9.2
peerDependenciesMeta:
typescript:
optional: true
@@ -2313,6 +2326,20 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
framer-motion@11.18.2:
resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -2724,6 +2751,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
motion-dom@11.18.1:
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
motion-utils@11.18.1:
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2923,6 +2956,15 @@ packages:
peerDependencies:
react: '*'
react-intersection-observer@9.16.0:
resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react-dom:
optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -3123,6 +3165,13 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -3227,7 +3276,7 @@ packages:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
typescript: 5.9.2
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -4936,6 +4985,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
buffer-from@1.1.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -5526,6 +5577,15 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
framer-motion@11.18.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
motion-dom: 11.18.1
motion-utils: 11.18.1
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -5908,6 +5968,12 @@ snapshots:
mkdirp@3.0.1: {}
motion-dom@11.18.1:
dependencies:
motion-utils: 11.18.1
motion-utils@11.18.1: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -6102,6 +6168,12 @@ snapshots:
dependencies:
react: 19.1.0
react-intersection-observer@9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -6379,6 +6451,13 @@ snapshots:
source-map-js@1.2.1: {}
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
stable-hash@0.0.5: {}
stop-iteration-iterator@1.1.0:

538
public/background-sync.js Normal file
View File

@@ -0,0 +1,538 @@
// 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;
});
})
);
}
});

129
public/manifest.json Normal file
View File

@@ -0,0 +1,129 @@
{
"name": "Mice",
"short_name": "Mice",
"description": "a very awesome navidrome client",
"start_url": "/",
"categories": ["music", "entertainment"],
"display_override": ["window-controls-overlay"],
"display": "standalone",
"background_color": "#0f0f0f",
"theme_color": "#0f0f0f",
"icons": [
{
"src": "/favicon.ico",
"type": "image/x-icon",
"sizes": "48x48"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon-192-maskable.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
{
"src": "/icon-512-maskable.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180",
"purpose": "any"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "152x152",
"purpose": "any"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "120x120",
"purpose": "any"
}
],
"screenshots": [
{
"src": "/home-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Home Preview",
"form_factor": "wide"
},
{
"src": "/browse-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Browse Preview",
"form_factor": "wide"
},
{
"src": "/album-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Album Preview",
"form_factor": "wide"
},
{
"src": "/fullscreen-preview.png",
"sizes": "1920x1020",
"type": "image/png",
"label": "Fullscreen Preview",
"form_factor": "wide"
}
],
"shortcuts": [
{
"name": "Resume Song",
"short_name": "Resume",
"description": "Resume the last played song",
"url": "/?action=resume",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Play Recent Albums",
"short_name": "Recent",
"description": "Play from recently added albums",
"url": "/?action=recent",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Shuffle Favorites",
"short_name": "Shuffle",
"description": "Shuffle songs from favorite artists",
"url": "/?action=shuffle-favorites",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
]
}

680
public/sw.js Normal file
View File

@@ -0,0 +1,680 @@
/*
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
*/
/* global self, caches, clients */
const VERSION = 'v2';
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 = [
'/',
'/favicon.ico',
'/manifest.json',
'/icon-192.png',
'/icon-192-maskable.png',
'/icon-512.png',
'/icon-512-maskable.png',
'/apple-touch-icon.png',
'/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(
(async () => {
const cache = await caches.open(APP_SHELL_CACHE);
await cache.addAll(APP_SHELL.map((u) => new Request(u, { cache: 'reload' })));
// Force activate new SW immediately
await self.skipWaiting();
})()
);
});
// Activate: clean old caches and claim clients
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => ![APP_SHELL_CACHE, AUDIO_CACHE, IMAGE_CACHE, META_CACHE].includes(k))
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
// 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') {
event.respondWith(
(async () => {
try {
const fresh = await fetch(req);
const cache = await caches.open(APP_SHELL_CACHE);
cache.put(req, fresh.clone()).catch(() => {});
return fresh;
} catch {
const cache = await caches.open(APP_SHELL_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
// final fallback to index
return (await cache.match('/')) || Response.error();
}
})()
);
return;
}
// Images: cache-first
if (req.destination === 'image') {
event.respondWith(
(async () => {
const cache = await caches.open(IMAGE_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
cache.put(req, res.clone()).catch(() => {});
return res;
} catch {
// fall back
return cached || Response.error();
}
})()
);
return;
}
// Scripts, styles, fonts, and Next.js assets: cache-first for offline boot
if (
req.destination === 'script' ||
req.destination === 'style' ||
req.destination === 'font' ||
req.url.includes('/_next/')
) {
event.respondWith(
(async () => {
const cache = await caches.open(APP_SHELL_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
cache.put(req, res.clone()).catch(() => {});
return res;
} catch {
return cached || Response.error();
}
})()
);
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();
}
})()
);
});
// 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) });
}
}